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:
Sivin Varghese
2026-08-13 12:39:56 +05:30
committed by GitHub
parent 13dfe1a6c6
commit 416ac8660f
3 changed files with 137 additions and 65 deletions

View File

@@ -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

View File

@@ -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">

View File

@@ -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>