-
Notifications
You must be signed in to change notification settings - Fork 909
feat(web): auto-grow composer and add expandable editing mode #1121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8a14cb0
36d67ce
3b60aaa
6e051dd
a5c39c1
39eccb0
21eec04
9843c4c
826bf3e
dfa4310
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| Make the web chat input grow with its content and add an expandable editor for longer messages. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| <!-- apps/kimi-web/src/components/chat/Composer.vue --> | ||
| <script setup lang="ts"> | ||
| import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'; | ||
| import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; | ||
| import { useI18n } from 'vue-i18n'; | ||
| import SlashMenu from './SlashMenu.vue'; | ||
| import MentionMenu from './MentionMenu.vue'; | ||
|
|
@@ -87,6 +87,74 @@ const { text, textareaRef, autosize, loadForEdit } = useComposerDraft({ | |
| sessionId: () => props.sessionId, | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Expanded editor — a taller, multi-line composing mode. While expanded, Enter | ||
| // inserts a newline instead of sending (send via the button or Cmd/Ctrl+Enter); | ||
| // it auto-collapses after a successful send. See handleKeydown / handleSubmit. | ||
| // --------------------------------------------------------------------------- | ||
| const expanded = ref(false); | ||
| function toggleExpand(): void { | ||
| expanded.value = !expanded.value; | ||
| // Re-fit the textarea after the min/max-height swap between modes, then | ||
| // recompute growth against the *post-toggle* resting height. Without this, | ||
| // collapsing would keep the isGrown measured against the expanded 70vh | ||
| // min-height, hiding the toggle even though the collapsed draft is still | ||
| // multi-line. (This does not affect the expanded state itself — once | ||
| // expanded, it stays at 70vh until toggled back or sent.) | ||
| void nextTick(() => { | ||
| autosize(); | ||
| recomputeGrown(); | ||
| // Return focus to the textarea so the user can keep typing right away; | ||
| // otherwise focus stays on the toggle button and the next Enter would | ||
| // activate it again instead of inserting a newline. | ||
| textareaRef.value?.focus(); | ||
| }); | ||
|
Comment on lines
+104
to
+111
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the user activates the new expand button while composing, browser focus remains on that button because this callback only resizes/recomputes the textarea. In that state the next keystrokes do not enter the expanded editor, and pressing Enter can activate the button again instead of inserting a newline, so the user must click back into the textarea before continuing the long message. Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| // Collapse the expanded editor after a successful send/steer and re-fit the | ||
| // textarea once the 70vh min-height is gone. On image-only sends the text is | ||
| // already empty, so the draft watcher never re-runs autosize — without this, | ||
| // the textarea keeps the inline height measured at 70vh and the collapsed cap | ||
| // (1/4 viewport) leaves an oversized empty box until the next keystroke. | ||
| function collapseAndRefit(): void { | ||
| if (!expanded.value) return; | ||
| expanded.value = false; | ||
| void nextTick(autosize); | ||
| } | ||
|
|
||
| // The expand toggle is hidden at the resting height and only appears once the | ||
| // box has grown past it (multi-line content) — keeps the empty composer | ||
| // uncluttered. While expanded it always shows so the user can collapse back. | ||
| // | ||
| // The resting height equals the textarea's computed `min-height`, which varies | ||
| // by theme (the modern/kimi global override in style.css sets 40px; the scoped | ||
| // default is 56px). We read it from the element instead of hard-coding so the | ||
| // threshold matches whatever theme is active. | ||
| const RESTING_HEIGHT_FALLBACK_PX = 56; | ||
| function restingHeightPx(el: HTMLTextAreaElement): number { | ||
| if (typeof getComputedStyle === 'undefined') return RESTING_HEIGHT_FALLBACK_PX; | ||
| const min = Number.parseFloat(getComputedStyle(el).minHeight); | ||
| return Number.isFinite(min) && min > 0 ? min : RESTING_HEIGHT_FALLBACK_PX; | ||
| } | ||
| const isGrown = ref(false); | ||
| function recomputeGrown(): void { | ||
| const el = textareaRef.value; | ||
| isGrown.value = !!el && el.scrollHeight > restingHeightPx(el); | ||
| } | ||
| watch(text, () => { | ||
| // Registered after useComposerDraft's autosize watcher, so the inline height | ||
| // already reflects the latest content when this reads scrollHeight. | ||
| void nextTick(recomputeGrown); | ||
| }); | ||
|
|
||
| // The component instance is reused across session switches (it is not keyed by | ||
| // session), so reset the per-session expanded preference when the active | ||
| // session changes. Without this, expanding in one chat would leave the next | ||
| // session's draft stuck in the tall editor with Enter inserting newlines. | ||
| watch(() => props.sessionId, () => { | ||
| expanded.value = false; | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Sent-message history recall (shell-style ↑/↓). See useInputHistory for the | ||
| // implementation; the composer keeps the keydown orchestration (which also | ||
|
|
@@ -169,8 +237,14 @@ const { | |
| void fileInputRef; | ||
|
|
||
| onMounted(() => { | ||
| // Fit the box to a restored draft on first render. | ||
| if (text.value) void nextTick(autosize); | ||
| // Fit the box to a restored draft on first render, and reflect its grown | ||
| // state so the expand toggle shows for an already-long draft. | ||
| if (text.value) { | ||
| void nextTick(() => { | ||
| autosize(); | ||
| recomputeGrown(); | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| onUnmounted(() => { | ||
|
|
@@ -215,6 +289,7 @@ function handleSubmit(): void { | |
| if (parsed && known) { | ||
| text.value = ''; | ||
| slashOpen.value = false; | ||
| collapseAndRefit(); | ||
| emit('command', parsed.arg ? `${parsed.cmd} ${parsed.arg}` : parsed.cmd); | ||
| return; | ||
| } | ||
|
|
@@ -232,6 +307,7 @@ function handleSubmit(): void { | |
| text.value = ''; | ||
| slashOpen.value = false; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the editor is expanded, the user deletes all text and sends an attachment-only message, Useful? React with 👍 / 👎. |
||
| mentionOpen.value = false; | ||
| collapseAndRefit(); | ||
| emit('submit', payload); | ||
| } | ||
|
|
||
|
|
@@ -257,6 +333,7 @@ function handleSteer(): void { | |
| text.value = ''; | ||
| slashOpen.value = false; | ||
| mentionOpen.value = false; | ||
| collapseAndRefit(); | ||
| emit('steer', payload); | ||
| } | ||
|
|
||
|
|
@@ -389,6 +466,12 @@ function handleKeydown(e: KeyboardEvent): void { | |
|
|
||
| // Normal Enter / Shift+Enter | ||
| if (e.key === 'Enter' && !e.shiftKey) { | ||
| // Expanded editor: Enter inserts a newline; Cmd/Ctrl+Enter sends. | ||
| // (Clicking the send button always sends.) Shift+Enter already falls | ||
| // through to the default newline above, so behavior matches either way. | ||
| if (expanded.value && !(e.metaKey || e.ctrlKey)) { | ||
| return; | ||
| } | ||
| e.preventDefault(); | ||
| handleSubmit(); | ||
| } | ||
|
|
@@ -581,7 +664,7 @@ function selectModel(modelId: string): void { | |
| <template> | ||
| <div | ||
| class="composer" | ||
| :class="{ 'drag-over': isDragOver }" | ||
| :class="{ 'drag-over': isDragOver, expanded }" | ||
| @dragover="handleDragOver" | ||
| @dragleave="handleDragLeave" | ||
| @drop="handleDrop" | ||
|
|
@@ -670,40 +753,63 @@ function selectModel(modelId: string): void { | |
| @input="handleInput" | ||
| /> | ||
|
|
||
| <button | ||
| class="send" | ||
| :class="{ aborting: running }" | ||
| :aria-label="sendLabel" | ||
| :title="running ? t('composer.interruptTitle') : sendLabel" | ||
| @click="running ? emit('interrupt') : handleSubmit()" | ||
| > | ||
| <svg | ||
| class="send-icon" | ||
| :class="{ hidden: running }" | ||
| viewBox="0 0 16 16" | ||
| width="14" | ||
| height="14" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| stroke-width="2" | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| aria-hidden="true" | ||
| <div class="send-col"> | ||
| <button | ||
| v-if="expanded || isGrown" | ||
| class="expand-btn" | ||
| type="button" | ||
| :aria-label="expanded ? t('composer.collapseTitle') : t('composer.expandTitle')" | ||
| :title="expanded ? t('composer.collapseTitle') : t('composer.expandTitle')" | ||
| @click="toggleExpand" | ||
| > | ||
| <path d="M8 3l6 5.5M8 3L2 8.5M8 3v10" /> | ||
| </svg> | ||
| <svg | ||
| class="send-icon" | ||
| :class="{ hidden: !running }" | ||
| viewBox="0 0 16 16" | ||
| width="14" | ||
| height="14" | ||
| fill="currentColor" | ||
| aria-hidden="true" | ||
| <svg v-if="expanded" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | ||
| <path d="M4 14h6v6" /> | ||
| <path d="M20 10h-6V4" /> | ||
| <path d="M14 10l7-7" /> | ||
| <path d="M3 21l7-7" /> | ||
| </svg> | ||
| <svg v-else viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> | ||
| <path d="M15 3h6v6" /> | ||
| <path d="M9 21H3v-6" /> | ||
| <path d="M21 3l-7 7" /> | ||
| <path d="M3 21l7-7" /> | ||
| </svg> | ||
| </button> | ||
| <button | ||
| class="send" | ||
| :class="{ aborting: running }" | ||
| :aria-label="sendLabel" | ||
| :title="running ? t('composer.interruptTitle') : sendLabel" | ||
| @click="running ? emit('interrupt') : handleSubmit()" | ||
| > | ||
| <rect x="3" y="3" width="10" height="10" rx="1.5" /> | ||
| </svg> | ||
| </button> | ||
| <svg | ||
| class="send-icon" | ||
| :class="{ hidden: running }" | ||
| viewBox="0 0 16 16" | ||
| width="14" | ||
| height="14" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| stroke-width="2" | ||
| stroke-linecap="round" | ||
| stroke-linejoin="round" | ||
| aria-hidden="true" | ||
| > | ||
| <path d="M8 3l6 5.5M8 3L2 8.5M8 3v10" /> | ||
| </svg> | ||
| <svg | ||
| class="send-icon" | ||
| :class="{ hidden: !running }" | ||
| viewBox="0 0 16 16" | ||
| width="14" | ||
| height="14" | ||
| fill="currentColor" | ||
| aria-hidden="true" | ||
| > | ||
| <rect x="3" y="3" width="10" height="10" rx="1.5" /> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
|
|
@@ -1128,6 +1234,40 @@ function selectModel(modelId: string): void { | |
| gap: 8px; | ||
| } | ||
|
|
||
| /* Right column: expand toggle stacked above the send button */ | ||
| .send-col { | ||
| display: flex; | ||
| flex-direction: column; | ||
| align-items: center; | ||
| gap: 6px; | ||
| flex-shrink: 0; | ||
| } | ||
|
|
||
| .expand-btn { | ||
| width: 22px; | ||
| height: 22px; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| border: none; | ||
| border-radius: 6px; | ||
| background: transparent; | ||
| color: var(--dim); | ||
| cursor: pointer; | ||
| padding: 0; | ||
| transition: background 0.12s, color 0.12s; | ||
| } | ||
|
|
||
| .expand-btn:hover { | ||
| background: var(--panel2); | ||
| color: var(--ink); | ||
| } | ||
|
|
||
| .expand-btn:focus-visible { | ||
| outline: 2px solid var(--blue); | ||
| outline-offset: 2px; | ||
| } | ||
|
|
||
| .ph { | ||
| color: var(--faint); | ||
| flex: 1; | ||
|
|
@@ -1137,9 +1277,8 @@ function selectModel(modelId: string): void { | |
| font-family: var(--mono); | ||
| font-size: var(--ui-font-size); | ||
| background: transparent; | ||
| height: 56px; | ||
| min-height: 56px; | ||
| max-height: 56px; | ||
| max-height: calc(100vh / 4); | ||
| overflow-y: auto; | ||
| line-height: 1.5; | ||
| margin-bottom: 6px; | ||
|
|
@@ -1153,6 +1292,15 @@ function selectModel(modelId: string): void { | |
| color: var(--ink); | ||
| } | ||
|
|
||
| /* Expanded editor: a tall composing area at ~70% of the viewport — clearly | ||
| larger than the auto-grow cap, while leaving room for the chat header, the | ||
| bottom toolbar row, and padding so nothing gets clipped. Content beyond it | ||
| scrolls internally. */ | ||
| .composer.expanded .ph { | ||
| min-height: 70vh; | ||
| max-height: 70vh; | ||
| } | ||
|
|
||
| /* /compact chip */ | ||
| .compact-chip { | ||
| background: none; | ||
|
|
@@ -1740,13 +1888,11 @@ function selectModel(modelId: string): void { | |
| } | ||
|
|
||
| /* Bump mobile font sizes +2px and pin input at 16px to prevent iOS zoom. | ||
| Single-line-friendly height: 56px desktop default → 44px touch target. */ | ||
| Height (min 56px / max one quarter of the viewport) is inherited from the | ||
| base .ph rule so the box auto-grows the same way on touch and desktop. */ | ||
| .ph { | ||
| /* Pinned at 16px to prevent iOS auto-zoom on focus (not part of UI font scale). */ | ||
| font-size: 16px; | ||
| height: 44px; | ||
| min-height: 44px; | ||
| max-height: 44px; | ||
| } | ||
| .model-pill, | ||
| .attach-btn { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because
expandedis a component-level ref and is only reset after submit/steer, changingprops.sessionIdleaves the previous session's expanded state active whileuseComposerDraftloads a different draft. If a user expands the composer in one chat and then selects another session or a new empty draft, the new composer opens as a 70vh editor and plain Enter inserts newlines instead of sending, even though the user did not opt into expanded mode for that session. Reset this state when the session id changes, or scope it per session.Useful? React with 👍 / 👎.