From 8a231b6e4df3ea5e006b7bf6f453137c96c2a553 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 6 Jul 2026 18:23:51 +0800 Subject: [PATCH] fix(webui): finalize turn-ending slash commands maintainer edit: keep /new and manually submitted /stop from leaving stale WebUI streaming state after they cancel or reset the active turn. --- .../src/components/thread/ThreadComposer.tsx | 46 +++++++++++++-- webui/src/hooks/useNanobotStream.ts | 15 ++++- webui/src/tests/thread-composer.test.tsx | 41 +++++++++++++ webui/src/tests/useNanobotStream.test.tsx | 57 +++++++++++++++++++ 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index e1d7d0a4..b0e48f18 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -109,8 +109,21 @@ const FALLBACK_SIDE_CHANNEL_COMMANDS = new Set([ ]); type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows"; +function slashCommandName(content: string): string { + return content.split(/\s+/, 1)[0]; +} + +function isExactSlashCommand(content: string, commandName: string): boolean { + if (slashCommandName(content) !== commandName) return false; + return content.slice(commandName.length).trim().length === 0; +} + +function shouldFinalizeActiveTurn(content: string): boolean { + return isExactSlashCommand(content, "/new"); +} + function isSlashCommandSideChannel(content: string, visibleSlashCommands: SlashCommand[]): boolean { - const commandName = content.split(/\s+/, 1)[0]; + const commandName = slashCommandName(content); if (!commandName.startsWith("/")) return false; if (commandName === "/goal" && content.slice(commandName.length).trim().length > 0) { return false; @@ -1509,15 +1522,37 @@ export function ThreadComposer({ ...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}), } : undefined; - const isSlashSideChannel = + const hasPlainTextCommandPayload = payload === undefined && attachedCliApps.length === 0 - && attachedMcpPresets.length === 0 + && attachedMcpPresets.length === 0; + if ( + hasPlainTextCommandPayload + && isStreaming + && onStop + && isExactSlashCommand(content, "/stop") + ) { + handleStop(); + setQueuedPrompts([]); + clear(); + clearComposerText(); + return; + } + const isSlashSideChannel = + hasPlainTextCommandPayload && isSlashCommandSideChannel(content, visibleSlashCommands); + const finalizeActiveTurn = + isSlashSideChannel && shouldFinalizeActiveTurn(content); onSend( content, payload, - isSlashSideChannel ? { ...options, sideChannel: true } : options, + isSlashSideChannel + ? { + ...options, + sideChannel: true, + ...(finalizeActiveTurn ? { finalizeActiveTurn } : {}), + } + : options, ); setQueuedPrompts([]); // Bubble owns the data URL copy; safe to revoke every staged blob @@ -1530,9 +1565,12 @@ export function ThreadComposer({ canSend, clear, clearComposerText, + handleStop, + isStreaming, modelNeedsSetup, onModelBadgeClick, onSend, + onStop, readyImages, value, visibleSlashCommands, diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 6df6d53b..55c83e03 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -448,6 +448,7 @@ export interface SendOptions { mcpPresets?: OutboundMcpPresetMention[]; workspaceScope?: WorkspaceScopePayload | null; sideChannel?: boolean; + finalizeActiveTurn?: boolean; } function eventExtendsModelActivity(ev: InboundEvent): boolean { @@ -1111,19 +1112,26 @@ export function useNanobotStream( if (!hasImages && !content.trim()) return; const sideChannel = options?.sideChannel === true; + const finalizeActiveTurn = options?.finalizeActiveTurn === true; flushPendingStreamEvents(); + if (finalizeActiveTurn) { + cancelStreamEndTimer(); + setIsStreaming(false); + } const turnId = crypto.randomUUID(); if (sideChannel) sideChannelTurnIdsRef.current.add(turnId); const previews = hasImages ? images!.map((i) => i.preview) : undefined; setMessages((prev) => { - if (!sideChannel) { + if (!sideChannel || finalizeActiveTurn) { buffer.current = null; activeAssistantRef.current = null; closedAssistantStreamIdsRef.current.clear(); clearActivitySegment(); + suppressStreamUntilTurnEndRef.current = false; } + const base = finalizeActiveTurn ? finalizeStreamedTurn(prev) : prev; return [ - ...(sideChannel ? prev : pruneReasoningOnlyPlaceholders(prev)), + ...(sideChannel ? base : pruneReasoningOnlyPlaceholders(base)), { id: crypto.randomUUID(), role: "user", @@ -1142,9 +1150,10 @@ export function useNanobotStream( const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; const wireOptions = { ...options, turnId }; delete wireOptions.sideChannel; + delete wireOptions.finalizeActiveTurn; client.sendMessage(chatId, content, wireMedia, wireOptions); }, - [chatId, clearActivitySegment, client, flushPendingStreamEvents], + [cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents], ); const stop = useCallback(() => { diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index b0459e32..c86b3f59 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1345,6 +1345,47 @@ describe("ThreadComposer", () => { expect(onSend).toHaveBeenCalledWith("/status", undefined, { sideChannel: true }); }); + it("marks new chat commands as side-channel sends that finalize the active turn", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/new" } }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith( + "/new", + undefined, + { sideChannel: true, finalizeActiveTurn: true }, + ); + }); + + it("routes a manually submitted stop command through the stop handler", () => { + const onSend = vi.fn(); + const onStop = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/stop" } }); + fireEvent.keyDown(input, { key: "Escape" }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onStop).toHaveBeenCalledTimes(1); + expect(onSend).not.toHaveBeenCalled(); + }); + it("keeps goal task commands on the normal agent turn path", () => { const onSend = vi.fn(); render( diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 1d44021f..97f46da8 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -1653,6 +1653,63 @@ describe("useNanobotStream", () => { ]); }); + it("finalizes active streaming before turn-ending side-channel commands", async () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-new", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + result.current.send("long task"); + }); + const activeTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId; + + act(() => { + fake.emit("chat-new", { + event: "delta", + chat_id: "chat-new", + text: "partial answer", + turn_id: activeTurnId, + }); + }); + await flushStreamFrame(); + + expect(result.current.isStreaming).toBe(true); + expect(result.current.messages.find((message) => message.content === "partial answer")) + .toMatchObject({ isStreaming: true }); + + act(() => { + result.current.send("/new", undefined, { + sideChannel: true, + finalizeActiveTurn: true, + }); + }); + + const newCall = fake.client.sendMessage.mock.calls.at(-1)!; + expect(newCall[3]).not.toHaveProperty("sideChannel"); + expect(newCall[3]).not.toHaveProperty("finalizeActiveTurn"); + expect(result.current.isStreaming).toBe(false); + expect(result.current.messages.find((message) => message.content === "partial answer")) + .toMatchObject({ isStreaming: false }); + + act(() => { + fake.emit("chat-new", { + event: "message", + chat_id: "chat-new", + text: "New session started.", + turn_id: newCall[3]?.turnId, + }); + }); + + expect(result.current.isStreaming).toBe(false); + expect(result.current.messages.map((message) => message.content)).toEqual([ + "long task", + "partial answer", + "/new", + "New session started.", + ]); + }); + it("lets stream_end finish streaming while side-channel status replies arrive", () => { vi.useFakeTimers(); try {