fix: fit the reply editor to Copilot suggestions (#15428)
# Pull Request Template ## Description This PR fixes the reply editor staying at its default height when Copilot generates a suggestion, causing longer suggestions to be clipped after a line or two. The editor now automatically grows to fit the suggestion, up to a maximum of 350px, and returns to the height it had before the suggestion when it is accepted or discarded. Manual resizing continues to work as expected and always takes priority. ### How to reproduce 1. Open a conversation and generate a Copilot suggestion (✨ → Summarize the conversation). 2. The reply editor stays at its default height and the suggestion gets clipped after a line or two. 3. Resize the reply editor, generate another suggestion, and discard it. The editor no longer returns to the height you set. ### What changed * `CopilotEditor` measures the rendered suggestion and requests enough space to display it, capped at 350px. The requested height is released when the suggestion is gone. * `ResizableEditorWrapper` handles this requested height separately from the manually dragged height, so the suggestion can grow the editor without overriding the user's preferred height. * The loading and suggestion states now cross-fade in place while the editor resizes, instead of briefly switching through an empty card. ### Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screencast https://github.com/user-attachments/assets/d9a5dab9-6206-4333-92d1-d3f710f9022e ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
watch,
|
||||
inject,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
useTemplateRef,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
buildMessageSchema,
|
||||
@@ -39,6 +48,12 @@ const emit = defineEmits([
|
||||
'send',
|
||||
]);
|
||||
|
||||
const SUGGESTION_GAP = 8; // gap-2
|
||||
const MAX_HEIGHT = 350;
|
||||
|
||||
// Not provided in the compose modal, which sizes itself
|
||||
const requestEditorHeight = inject('requestEditorHeight', () => {});
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
// Minimal schema with no marks or nodes for copilot input
|
||||
@@ -74,6 +89,19 @@ const isTextSelected = ref(false); // Tracks text selection and prevents unneces
|
||||
|
||||
// element refs
|
||||
const editor = useTemplateRef('editor');
|
||||
const suggestion = useTemplateRef('suggestion');
|
||||
const followUp = useTemplateRef('followUp');
|
||||
|
||||
// The wait matters: the markdown lands in the pane through a directive, so
|
||||
// measuring any earlier reads an empty pane and the suggestion stays clipped
|
||||
async function requestRoomForSuggestion() {
|
||||
await nextTick();
|
||||
const height =
|
||||
suggestion.value.scrollHeight +
|
||||
SUGGESTION_GAP +
|
||||
followUp.value.offsetHeight;
|
||||
requestEditorHeight(Math.min(height, MAX_HEIGHT));
|
||||
}
|
||||
|
||||
function contentFromEditor() {
|
||||
if (editorView) {
|
||||
@@ -199,18 +227,27 @@ onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
focusEditorInputField();
|
||||
}
|
||||
|
||||
requestRoomForSuggestion();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestEditorHeight(0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="resizable-editor-body flex flex-col gap-2 mb-3">
|
||||
<div class="copilot-suggestion flex-1 min-h-0 overflow-y-auto">
|
||||
<div
|
||||
ref="suggestion"
|
||||
class="copilot-suggestion flex-1 min-h-0 overflow-y-auto"
|
||||
>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(generatedContent, false)"
|
||||
class="text-n-iris-12 text-sm prose-sm font-normal"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor-root relative editor--copilot shrink-0">
|
||||
<div ref="followUp" class="editor-root relative editor--copilot shrink-0">
|
||||
<div ref="editor" />
|
||||
<div class="flex items-center justify-end absolute end-2 bottom-2">
|
||||
<NextButton
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, inject } from 'vue';
|
||||
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
|
||||
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
showCopilotEditor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -16,6 +16,10 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -26,8 +30,15 @@ const emit = defineEmits([
|
||||
'send',
|
||||
]);
|
||||
|
||||
const requestEditorHeight = inject('requestEditorHeight', () => {});
|
||||
|
||||
const copilotEditorContent = ref('');
|
||||
|
||||
// The loader needs no room of its own, the suggestion asks for its own
|
||||
const onStateEnter = () => {
|
||||
if (props.isGeneratingContent) requestEditorHeight(0);
|
||||
};
|
||||
|
||||
const onFocus = () => {
|
||||
emit('focus');
|
||||
};
|
||||
@@ -47,46 +58,49 @@ const onSend = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
@after-enter="emit('contentReady')"
|
||||
>
|
||||
<CopilotEditor
|
||||
v-if="showCopilotEditor && !isGeneratingContent"
|
||||
key="copilot-editor"
|
||||
v-model="copilotEditorContent"
|
||||
class="copilot-editor"
|
||||
:generated-content="generatedContent"
|
||||
:min-height="4"
|
||||
:enabled-menu-options="[]"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@send="onSend"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="resizable-editor-body flex flex-col justify-end mb-3"
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="absolute inset-x-0 top-0 pointer-events-none transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
@enter="onStateEnter"
|
||||
@after-enter="emit('contentReady')"
|
||||
>
|
||||
<CopilotEditor
|
||||
v-if="showCopilotEditor && !isGeneratingContent"
|
||||
key="copilot-editor"
|
||||
v-model="copilotEditorContent"
|
||||
class="copilot-editor"
|
||||
:generated-content="generatedContent"
|
||||
:placeholder="placeholder"
|
||||
:min-height="4"
|
||||
:enabled-menu-options="[]"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@send="onSend"
|
||||
/>
|
||||
<div
|
||||
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full p-4 flex items-start"
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="resizable-editor-body flex flex-col justify-end mb-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
<span class="text-sm text-n-iris-10">
|
||||
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
|
||||
</span>
|
||||
<div
|
||||
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full p-4 flex items-start"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
<span class="text-sm text-n-iris-10">
|
||||
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
provide,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
useTemplateRef,
|
||||
} from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -20,20 +27,11 @@ const editorHeight = ref(DEFAULT_HEIGHT);
|
||||
const isResizing = ref(false);
|
||||
const startY = ref(0);
|
||||
const startHeight = ref(0);
|
||||
const requestedHeight = ref(0);
|
||||
let resetTimeoutId = null;
|
||||
|
||||
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
|
||||
|
||||
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
|
||||
const measureSurroundingHeight = () => {
|
||||
if (wrapperRef.value) {
|
||||
surroundingHeight.value = Math.max(
|
||||
0,
|
||||
wrapperRef.value.offsetHeight - editorHeight.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isContainerReady = computed(() => props.containerHeight > 0);
|
||||
|
||||
const sizeBounds = computed(() => {
|
||||
@@ -52,6 +50,31 @@ const sizeBounds = computed(() => {
|
||||
const clampToBounds = val =>
|
||||
clamp(val, sizeBounds.value.min, sizeBounds.value.max);
|
||||
|
||||
// The dragged height always comes back, content can only ask the editor to grow
|
||||
const appliedHeight = computed(() => {
|
||||
const requested = requestedHeight.value
|
||||
? Math.max(requestedHeight.value, sizeBounds.value.default)
|
||||
: 0;
|
||||
return clampToBounds(Math.max(requested, editorHeight.value));
|
||||
});
|
||||
|
||||
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
|
||||
const measureSurroundingHeight = () => {
|
||||
if (wrapperRef.value) {
|
||||
surroundingHeight.value = Math.max(
|
||||
0,
|
||||
wrapperRef.value.offsetHeight - appliedHeight.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
provide('requestEditorHeight', height => {
|
||||
// The bounds subtract the panels around the editor, so measure them before
|
||||
// the request is clamped, the drag and the toggle may not have run yet
|
||||
measureSurroundingHeight();
|
||||
requestedHeight.value = height;
|
||||
});
|
||||
|
||||
const clearDragStyles = () => {
|
||||
Object.assign(document.body.style, { cursor: '', userSelect: '' });
|
||||
};
|
||||
@@ -59,12 +82,12 @@ const clearDragStyles = () => {
|
||||
const getClientY = e => (e.touches ? e.touches[0].clientY : e.clientY);
|
||||
|
||||
const onResizeStart = event => {
|
||||
editorHeight.value = clampToBounds(editorHeight.value);
|
||||
measureSurroundingHeight();
|
||||
isResizing.value = true;
|
||||
startY.value = getClientY(event);
|
||||
startHeight.value = clampToBounds(editorHeight.value);
|
||||
startHeight.value = appliedHeight.value;
|
||||
editorHeight.value = startHeight.value;
|
||||
requestedHeight.value = 0;
|
||||
Object.assign(document.body.style, {
|
||||
cursor: 'row-resize',
|
||||
userSelect: 'none',
|
||||
@@ -86,14 +109,15 @@ const onResizeEnd = () => {
|
||||
};
|
||||
|
||||
const resetEditorHeight = () => {
|
||||
requestedHeight.value = 0;
|
||||
editorHeight.value = sizeBounds.value.default;
|
||||
};
|
||||
|
||||
const toggleEditorExpand = () => {
|
||||
editorHeight.value = clampToBounds(editorHeight.value);
|
||||
measureSurroundingHeight();
|
||||
const { expanded, max, default: defaultHeight } = sizeBounds.value;
|
||||
const isExpanded = editorHeight.value > defaultHeight;
|
||||
const isExpanded = appliedHeight.value > defaultHeight;
|
||||
requestedHeight.value = 0;
|
||||
// If expanded is too close to default, use max so the toggle is always noticeable
|
||||
const target = expanded - defaultHeight < 100 ? max : expanded;
|
||||
editorHeight.value = isExpanded ? defaultHeight : target;
|
||||
@@ -132,10 +156,12 @@ defineExpose({ toggleEditorExpand, resetEditorHeight });
|
||||
ref="wrapperRef"
|
||||
class="relative resizable-editor-wrapper"
|
||||
:style="{
|
||||
'--editor-height': editorHeight + 'px',
|
||||
'--editor-height': appliedHeight + 'px',
|
||||
'--editor-min-allowed': sizeBounds.min + 'px',
|
||||
'--editor-max-allowed': sizeBounds.max + 'px',
|
||||
'--editor-height-transition': isResizing ? 'none' : '180ms ease',
|
||||
'--editor-height-transition': isResizing
|
||||
? '0s'
|
||||
: '200ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
@@ -158,19 +184,14 @@ defineExpose({ toggleEditorExpand, resetEditorHeight });
|
||||
.resizable-editor-body {
|
||||
@apply overflow-auto;
|
||||
|
||||
min-height: clamp(
|
||||
var(--editor-min-allowed, 5rem),
|
||||
var(--editor-height, 5rem),
|
||||
var(--editor-max-allowed, 7.5rem)
|
||||
);
|
||||
max-height: clamp(
|
||||
height: clamp(
|
||||
var(--editor-min-allowed, 5rem),
|
||||
var(--editor-height, 5rem),
|
||||
var(--editor-max-allowed, 7.5rem)
|
||||
);
|
||||
transition:
|
||||
min-height var(--editor-height-transition, 180ms ease),
|
||||
max-height var(--editor-height-transition, 180ms ease);
|
||||
height var(--editor-height-transition),
|
||||
opacity var(--editor-height-transition);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user