diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.test.ts b/packages/web-shell/client/hooks/useQueuedPrompts.test.ts new file mode 100644 index 00000000000..e94ef463471 --- /dev/null +++ b/packages/web-shell/client/hooks/useQueuedPrompts.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { mergeRestoredPromptText } from './useQueuedPrompts'; + +// Regression for #7128: restoration paths can fire more than once for the +// same prompt (failed submit + reconnect/refresh, queue clear racing an +// abort), and a user retrying an identical message restores identical text. +// Stacking those copies is what surfaced as "sent messages concatenated back +// into the input box after refresh". +describe('mergeRestoredPromptText', () => { + it('fills an empty editor with the restored text', () => { + expect(mergeRestoredPromptText('', 'hello')).toBe('hello'); + expect(mergeRestoredPromptText(' ', 'hello')).toBe('hello'); + }); + + it('prepends above a different draft the user is typing', () => { + expect(mergeRestoredPromptText('draft', 'restored')).toBe( + 'restored\ndraft', + ); + }); + + it('is a no-op when the same text was already restored', () => { + expect(mergeRestoredPromptText('hello', 'hello')).toBe('hello'); + }); + + it('is a no-op when the text already sits at the top of the editor', () => { + expect(mergeRestoredPromptText('hello\ndraft', 'hello')).toBe( + 'hello\ndraft', + ); + }); + + it('stays idempotent across repeated restores of the same prompt', () => { + let editor = ''; + for (let i = 0; i < 3; i++) { + editor = mergeRestoredPromptText(editor, '用python写一个hello world'); + } + expect(editor).toBe('用python写一个hello world'); + }); + + it('does not treat a same-prefix but different first line as a duplicate', () => { + expect(mergeRestoredPromptText('hello world\ndraft', 'hello')).toBe( + 'hello\nhello world\ndraft', + ); + }); +}); diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index df98c6a623b..0f6c7d23d77 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -53,6 +53,21 @@ interface UseQueuedPromptsArgs { const MAX_COMPLETED_PROMPT_IDS = 100; +/** + * Merge a restored prompt's text into the editor content. Restoration paths + * (failed submits, failed mid-turn inserts, queue clears) prepend the prompt + * above whatever the user is currently typing — but several of them can fire + * for the same prompt across reconnects/refreshes, and a user retrying an + * identical message produces the same text twice. Stacking those copies is + * what #7128 reports as "inputs concatenated after refresh", so restoring + * text that is already present at the top of the editor is a no-op. + */ +export function mergeRestoredPromptText(current: string, text: string): string { + if (!current.trim()) return text; + if (current === text || current.startsWith(`${text}\n`)) return current; + return `${text}\n${current}`; +} + type RefreshPendingPromptsResult = | 'refreshed' | 'skipped' @@ -286,8 +301,10 @@ export function useQueuedPrompts({ return; } const current = editorRef.current?.getText() ?? ''; - const next = current.trim() ? `${text}\n${current}` : text; - editorRef.current?.setText(next); + const next = mergeRestoredPromptText(current, text); + if (next !== current) { + editorRef.current?.setText(next); + } if (images && images.length > 0) { editorRef.current?.restoreImages(images); }