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.
This commit is contained in:
qer 2026-06-28 01:55:04 +08:00 committed by GitHub
parent b0708464f4
commit ff6e8bbd7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 49 additions and 3 deletions

View file

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

View file

@ -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();

View file

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

View file

@ -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<SlashCommand[]>([]);
@ -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.

View file

@ -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();
});
});