Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-composer-auto-grow-expand.md
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.
230 changes: 188 additions & 42 deletions apps/kimi-web/src/components/chat/Composer.vue
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';
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset expanded mode on session changes

Because expanded is a component-level ref and is only reset after submit/steer, changing props.sessionId leaves the previous session's expanded state active while useComposerDraft loads 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 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Refocus the textarea after toggling expand

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
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
}
Expand All @@ -232,6 +307,7 @@ function handleSubmit(): void {
text.value = '';
slashOpen.value = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refit the textarea when collapsing after image-only sends

If the editor is expanded, the user deletes all text and sends an attachment-only message, text.value is already empty, so the draft composable's text watcher never runs autosize() after expanded flips false. The textarea can keep the inline height measured while the 70vh min-height was active; the collapsed CSS only caps it at 100vh / 4, leaving an empty oversized composer until the next input. Re-run autosize when collapsing on submit paths that do not change the text.

Useful? React with 👍 / 👎.

mentionOpen.value = false;
collapseAndRefit();
emit('submit', payload);
}

Expand All @@ -257,6 +333,7 @@ function handleSteer(): void {
text.value = '';
slashOpen.value = false;
mentionOpen.value = false;
collapseAndRefit();
emit('steer', payload);
}

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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>

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion apps/kimi-web/src/composables/useComposerDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/i18n/locales/en/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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…',
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/i18n/locales/zh/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export default {
interruptTitle: '中断当前操作',
steerNow: '立即插入 ⌃S',
steerTitle: '不等当前回合结束,把消息直接插进正在运行的任务(Ctrl+S / ⌘S)',
expandTitle: '展开输入框进行多行编辑',
collapseTitle: '收起输入框',
emptyConversationTitle: 'Kimi Code',
emptyConversation: '还没有消息 —— 在下方输入开始对话',
quickStartPlaceholder: '输入消息开始新对话…',
Expand Down
29 changes: 29 additions & 0 deletions apps/kimi-web/test/composer-draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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