mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(web): auto-grow composer and add expandable editing mode (#1121)
* feat(web): auto-grow composer and add expandable editing mode - Grow the chat textarea with its content up to a 1/4-viewport cap. - Add an expand toggle above the send button for a taller editor; in that mode Enter inserts a newline and Cmd/Ctrl+Enter or the button sends, then the editor collapses back. * fix(web): reset expanded composer state on session change The composer instance is reused across sessions (not keyed by session id), so the expanded preference leaked into the next session's draft, leaving it stuck in the tall editor with Enter inserting newlines. Collapse back when the active session changes. * fix(web): match expand-toggle threshold to theme resting height The modern/kimi global theme overrides the composer min-height to 40px (the scoped default is 56px), so a hard-coded 56px threshold kept the expand toggle hidden until a third line under the default theme. Read the computed min-height from the element instead. * fix(web): recompute expand-toggle visibility after collapsing While expanded the computed min-height is 70vh, so a multi-line draft measured there sets isGrown=false. Collapsing did not recompute it, hiding the toggle even though the collapsed draft was still multi-line. Recompute growth after every toggle via a shared helper. The expanded state itself is unchanged and stays at 70vh until toggled or sent. * fix(web): collapse expanded editor on slash-command submit Known slash commands return early from handleSubmit, above the post-send collapse, so sending an expanded /goal, /btw, /compact, or skill command left an empty 70vh editor. Collapse in the slash-command path too. * fix(web): refocus textarea after toggling expand Clicking the expand toggle leaves focus on the button, so subsequent keystrokes do not reach the textarea and Enter would activate the button again instead of inserting a newline. Return focus to the textarea after toggling. * fix(web): refit textarea when collapsing after image-only sends When the expanded editor collapses on an image-only send, the text is already empty so the draft watcher never re-runs autosize; the textarea kept the inline height measured at 70vh and the collapsed cap left an oversized empty box. Route all send/steer collapses through a helper that re-runs autosize after the 70vh min-height is removed. --------- Co-authored-by: liruifengv <liruifeng1024@gmail.com>
This commit is contained in:
parent
0886bff2bc
commit
81ba48f455
6 changed files with 232 additions and 43 deletions
5
.changeset/web-composer-auto-grow-expand.md
Normal file
5
.changeset/web-composer-auto-grow-expand.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ export function useComposerDraft(deps: ComposerDraftDeps) {
|
|||
function autosize(): void {
|
||||
const el = textareaRef.value;
|
||||
if (!el) return;
|
||||
el.style.removeProperty('height');
|
||||
// Reset to measure the natural content height, then fit the box to it.
|
||||
// The resting height and the upper cap live in CSS (`min-height` /
|
||||
// `max-height`); once the content outgrows the cap, `overflow-y: auto`
|
||||
// scrolls internally. This keeps a single source of truth for the bounds.
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
watch(text, (value) => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export default {
|
|||
interruptTitle: 'Interrupt current operation',
|
||||
steerNow: 'Steer now ⌃S',
|
||||
steerTitle: 'Inject into the running turn without waiting (Ctrl+S / ⌘S)',
|
||||
expandTitle: 'Expand input for multi-line editing',
|
||||
collapseTitle: 'Collapse input',
|
||||
emptyConversationTitle: 'Kimi Code',
|
||||
emptyConversation: 'No messages yet — type below to start the conversation',
|
||||
quickStartPlaceholder: 'Type a message to start a new conversation…',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export default {
|
|||
interruptTitle: '中断当前操作',
|
||||
steerNow: '立即插入 ⌃S',
|
||||
steerTitle: '不等当前回合结束,把消息直接插进正在运行的任务(Ctrl+S / ⌘S)',
|
||||
expandTitle: '展开输入框进行多行编辑',
|
||||
collapseTitle: '收起输入框',
|
||||
emptyConversationTitle: 'Kimi Code',
|
||||
emptyConversation: '还没有消息 —— 在下方输入开始对话',
|
||||
quickStartPlaceholder: '输入消息开始新对话…',
|
||||
|
|
|
|||
|
|
@ -103,4 +103,33 @@ describe('useComposerDraft', () => {
|
|||
draft.loadForEdit('edit me');
|
||||
expect(draft.text.value).toBe('edit me');
|
||||
});
|
||||
|
||||
it('autosize fits the textarea height to its content', () => {
|
||||
const { draft } = setup('s1');
|
||||
const style: Record<string, string> = {};
|
||||
const el = { scrollHeight: 120, style };
|
||||
draft.textareaRef.value = el as unknown as HTMLTextAreaElement;
|
||||
|
||||
draft.autosize();
|
||||
expect(style.height).toBe('120px');
|
||||
});
|
||||
|
||||
it('autosize shrinks the textarea when content is removed', () => {
|
||||
const { draft } = setup('s1');
|
||||
const style: Record<string, string> = {};
|
||||
const el = { scrollHeight: 120, style };
|
||||
draft.textareaRef.value = el as unknown as HTMLTextAreaElement;
|
||||
|
||||
draft.autosize();
|
||||
el.scrollHeight = 40;
|
||||
draft.autosize();
|
||||
expect(style.height).toBe('40px');
|
||||
});
|
||||
|
||||
it('autosize is a no-op before the textarea mounts', () => {
|
||||
const { draft } = setup('s1');
|
||||
expect(() => {
|
||||
draft.autosize();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue