From ff6e8bbd7c328dcc6575902cfd0cb3e522f20948 Mon Sep 17 00:00:00 2001 From: qer Date: Sun, 28 Jun 2026 01:55:04 +0800 Subject: [PATCH] fix(web): clear composer draft synchronously on send (#1163) When the first message of an empty session is submitted, the optimistic user turn unmounts the empty-session composer before the post-flush text watcher can persist the cleared draft. The docked composer then mounts and reloads the stale text from localStorage. Clear the persisted draft synchronously in the submit / steer / slash command paths instead of relying on the text watcher, so the next mount always starts empty. --- .changeset/fix-web-composer-draft-clear.md | 5 +++++ .../kimi-web/src/components/chat/Composer.vue | 6 +++++- .../src/composables/useComposerDraft.ts | 13 ++++++++++++- apps/kimi-web/src/composables/useSlashMenu.ts | 9 ++++++++- apps/kimi-web/test/composer-draft.test.ts | 19 +++++++++++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-web-composer-draft-clear.md diff --git a/.changeset/fix-web-composer-draft-clear.md b/.changeset/fix-web-composer-draft-clear.md new file mode 100644 index 000000000..e12aefffb --- /dev/null +++ b/.changeset/fix-web-composer-draft-clear.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web composer occasionally keeping typed text after sending the first message of a new session. diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 874aa3511..8d0d3a0fc 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -83,7 +83,7 @@ const { t } = useI18n(); // --------------------------------------------------------------------------- // Textarea + per-session draft persistence — see useComposerDraft. // --------------------------------------------------------------------------- -const { text, textareaRef, autosize, loadForEdit } = useComposerDraft({ +const { text, textareaRef, autosize, loadForEdit, clearDraft } = useComposerDraft({ sessionId: () => props.sessionId, }); @@ -180,6 +180,7 @@ const { skills: () => props.skills, emitCommand: (cmd) => emit('command', cmd), historyPush: (entry) => history.push(entry), + clearDraft, }); // --------------------------------------------------------------------------- @@ -293,6 +294,7 @@ function handleSubmit(): void { : false; if (parsed && known) { text.value = ''; + clearDraft(); slashOpen.value = false; collapseAndRefit(); emit('command', parsed.arg ? `${parsed.cmd} ${parsed.arg}` : parsed.cmd); @@ -310,6 +312,7 @@ function handleSubmit(): void { clearAfterSubmit(); text.value = ''; + clearDraft(); slashOpen.value = false; mentionOpen.value = false; collapseAndRefit(); @@ -336,6 +339,7 @@ function handleSteer(): void { clearAfterSubmit(); history.push(trimmed); text.value = ''; + clearDraft(); slashOpen.value = false; mentionOpen.value = false; collapseAndRefit(); diff --git a/apps/kimi-web/src/composables/useComposerDraft.ts b/apps/kimi-web/src/composables/useComposerDraft.ts index 40590bc45..827681baf 100644 --- a/apps/kimi-web/src/composables/useComposerDraft.ts +++ b/apps/kimi-web/src/composables/useComposerDraft.ts @@ -72,5 +72,16 @@ export function useComposerDraft(deps: ComposerDraftDeps) { }); } - return { text, textareaRef, autosize, loadForEdit }; + /** + * Synchronously clear the persisted draft for the current session. + * Call this right after clearing `text.value` on send/steer; relying on the + * text watcher is unsafe because the Composer may unmount before the watcher + * flushes (e.g. when the optimistic user message replaces the empty-session + * composer), causing the next mount to reload the stale draft. + */ + function clearDraft(): void { + saveDraft(sessionId(), ''); + } + + return { text, textareaRef, autosize, loadForEdit, clearDraft }; } diff --git a/apps/kimi-web/src/composables/useSlashMenu.ts b/apps/kimi-web/src/composables/useSlashMenu.ts index 1f1bcbcf1..97338f2aa 100644 --- a/apps/kimi-web/src/composables/useSlashMenu.ts +++ b/apps/kimi-web/src/composables/useSlashMenu.ts @@ -16,6 +16,12 @@ export interface SlashMenuDeps { emitCommand: (cmd: string) => void; /** Record a sent command for ↑/↓ recall. */ historyPush: (entry: string) => void; + /** + * Synchronously clear the persisted draft when a bare command is chosen. + * Mirrors the explicit clear in Composer's submit/steer paths so a draft + * is not left behind if the Composer unmounts before the text watcher flushes. + */ + clearDraft?: () => void; } /** @@ -27,7 +33,7 @@ export interface SlashMenuDeps { * when an item is chosen. */ export function useSlashMenu(deps: SlashMenuDeps) { - const { text, textareaRef, autosize, skills, emitCommand, historyPush } = deps; + const { text, textareaRef, autosize, skills, emitCommand, historyPush, clearDraft } = deps; const open = ref(false); const items = ref([]); @@ -61,6 +67,7 @@ export function useSlashMenu(deps: SlashMenuDeps) { return; } text.value = ''; + clearDraft?.(); // Menu-selected bare commands (e.g. /model, /login) reach here directly and // never go through handleSubmit, so record them for recall too. acceptsInput // commands are pushed later by handleSubmit together with their argument. diff --git a/apps/kimi-web/test/composer-draft.test.ts b/apps/kimi-web/test/composer-draft.test.ts index 2d383a4c7..90b2ce22a 100644 --- a/apps/kimi-web/test/composer-draft.test.ts +++ b/apps/kimi-web/test/composer-draft.test.ts @@ -132,4 +132,23 @@ describe('useComposerDraft', () => { draft.autosize(); }).not.toThrow(); }); + + it('clearDraft removes the persisted draft synchronously', async () => { + // Regression: when the first message of an empty session is submitted, the + // optimistic user turn unmounts the composer before the post-flush text + // watcher can clear the draft. clearDraft must therefore clear it + // synchronously so a remount does not reload the stale text. + globalThis.localStorage.setItem(draftStorageKey('s1'), 'stale draft'); + const { draft } = setup('s1'); + draft.clearDraft(); + // No nextTick — the write is synchronous. + expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBeNull(); + + // Simulate the remount after the optimistic turn: a fresh composable + // instance for the same session should start empty, not restore the draft. + const { text } = setup('s1'); + expect(text.value).toBe(''); + await nextTick(); + expect(globalThis.localStorage.getItem(draftStorageKey('s1'))).toBeNull(); + }); });