From d32f8961b1780eed2d08547d2418032cfe8c7fee Mon Sep 17 00:00:00 2001 From: sid Date: Tue, 7 Jul 2026 18:20:42 +0800 Subject: [PATCH] fix(webui): bind landing first message to created chat --- webui/src/components/thread/ThreadShell.tsx | 18 +++++- webui/src/tests/thread-shell.test.tsx | 65 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 6f619fd7..40f1ddde 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -339,6 +339,7 @@ export function ThreadShell({ const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH); const filePreviewCloseTimerRef = useRef(null); const pendingFirstRef = useRef(null); + const [pendingFirstTargetChatId, setPendingFirstTargetChatId] = useState(null); const viewportRef = useRef(null); const messageCacheRef = useRef>(new Map()); /** Last chatId we associated with the in-memory thread (for cache-on-switch). */ @@ -555,15 +556,22 @@ export function ThreadShell({ messageCacheRef.current.set(chatId, projectWebuiThreadMessages(messages)); }, [chatId, loading, messages]); + // The landing composer queues the first message while `new_chat` is in flight. + // Only the chat created for that send may consume it; selecting another chat + // while creation is pending must not leak the message there. useEffect(() => { - if (!chatId) return; + if (!chatId || pendingFirstTargetChatId !== chatId) return; const pending = pendingFirstRef.current; - if (!pending) return; + if (!pending) { + setPendingFirstTargetChatId(null); + return; + } pendingFirstRef.current = null; + setPendingFirstTargetChatId(null); setScrollToLatestUserPromptSignal((value) => value + 1); send(pending.content, pending.images, pending.options); setBooting(false); - }, [chatId, send]); + }, [chatId, pendingFirstTargetChatId, send]); useEffect(() => { let cancelled = false; @@ -585,11 +593,15 @@ export function ThreadShell({ if (booting) return; setBooting(true); pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) }; + setPendingFirstTargetChatId(null); const newId = await onCreateChat?.(workspaceScope); if (!newId) { pendingFirstRef.current = null; + setPendingFirstTargetChatId(null); setBooting(false); + return; } + setPendingFirstTargetChatId(newId); }, [booting, onCreateChat, withWorkspaceScope, workspaceScope], ); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index c7490be0..ea25653a 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -507,6 +507,71 @@ describe("ThreadShell", () => { expect(onNewChat).not.toHaveBeenCalled(); }); + it("binds a pending landing message to the chat created for it", async () => { + const client = makeClient(); + let resolveCreate: ((chatId: string) => void) | null = null; + const onCreateChat = vi.fn(() => new Promise((resolve) => { + resolveCreate = resolve; + })); + + const { rerender } = render( + wrap( + client, + {}} + onCreateChat={onCreateChat} + />, + ), + ); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "must not leak" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1)); + + await act(async () => { + rerender( + wrap( + client, + {}} + onCreateChat={onCreateChat} + />, + ), + ); + }); + + await act(async () => { + resolveCreate?.("chat-new"); + }); + + expect(client.sendMessage).not.toHaveBeenCalled(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onCreateChat={onCreateChat} + />, + ), + ); + }); + + await waitFor(() => + expectSendMessageWithTurn(client, "chat-new", "must not leak"), + ); + }); + it("keeps the first landing message when new chat history is still empty", async () => { const client = makeClient(); const onCreateChat = vi.fn().mockResolvedValue("chat-new");