From 8f68040f05cc0378fb3888ece6aaddb947ee5f96 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 6 Jul 2026 10:56:52 +0800 Subject: [PATCH] fix(webui): keep slash commands out of streaming state --- .../src/components/thread/ThreadComposer.tsx | 13 +- .../src/components/thread/ThreadViewport.tsx | 7 + webui/src/hooks/useNanobotStream.ts | 125 +++++++++++++----- webui/src/tests/thread-composer.test.tsx | 17 +++ webui/src/tests/thread-viewport.test.tsx | 124 ++++++++++++++--- webui/src/tests/useNanobotStream.test.tsx | 91 ++++++++++++- 6 files changed, 318 insertions(+), 59 deletions(-) diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 07f82780..e2967f2b 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -1480,7 +1480,17 @@ export function ThreadComposer({ ...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}), } : undefined; - onSend(content, payload, options); + const commandName = content.split(/\s+/, 1)[0]; + const isSlashSideChannel = + payload === undefined + && attachedCliApps.length === 0 + && attachedMcpPresets.length === 0 + && visibleSlashCommands.some((command) => command.command === commandName); + onSend( + content, + payload, + isSlashSideChannel ? { ...options, sideChannel: true } : options, + ); setQueuedPrompts([]); // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. @@ -1497,6 +1507,7 @@ export function ThreadComposer({ onSend, readyImages, value, + visibleSlashCommands, ]); const onKeyDown = (e: ReactKeyboardEvent) => { diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 4032afd0..9646d6bc 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -455,6 +455,13 @@ export const ThreadViewport = forwardRef { + if (!hasMessages || userReadingHistoryRef.current) return; + const promptId = activeTurnPromptRef.current; + if (promptId && scrollToPromptTopNow(promptId)) return; + scrollToBottom(false, 2); + }, [composerDockHeight, hasMessages, scrollToBottom, scrollToPromptTopNow]); + useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]); useEffect(() => { diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index cc54a339..6df6d53b 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -42,6 +42,7 @@ type PendingStreamEvent = type UIMessageTurnFields = Pick; const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]); +const STREAM_END_IDLE_DELAY_MS = 1000; function turnFieldsFromEvent( ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number }, @@ -446,6 +447,32 @@ export interface SendOptions { cliApps?: OutboundCliAppMention[]; mcpPresets?: OutboundMcpPresetMention[]; workspaceScope?: WorkspaceScopePayload | null; + sideChannel?: boolean; +} + +function eventExtendsModelActivity(ev: InboundEvent): boolean { + if ( + ev.event === "delta" + || ev.event === "reasoning_delta" + || ev.event === "file_edit" + ) return true; + return ev.event === "message" + && (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning"); +} + +function finalizeStreamedTurn( + prev: UIMessage[], + turn: UIMessageTurnFields = {}, +): UIMessage[] { + return prev.map((m) => + m.isStreaming && matchesTurn(m, turn) + ? { ...m, isStreaming: false, reasoningStreaming: false } + : m, + ); +} + +function eventTurnId(ev: InboundEvent): string | undefined { + return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined; } export function useNanobotStream( @@ -489,6 +516,7 @@ export function useNanobotStream( const pendingStreamEventsRef = useRef([]); const streamFrameRef = useRef(null); const suppressStreamUntilTurnEndRef = useRef(false); + const sideChannelTurnIdsRef = useRef>(new Set()); /** Timer that defers ``isStreaming = false`` after ``stream_end``. * * When the model finishes a text segment and calls a tool, the server @@ -512,6 +540,26 @@ export function useNanobotStream( pendingStreamEventsRef.current = []; }, []); + const cancelStreamEndTimer = useCallback(() => { + if (streamEndTimerRef.current === null) return; + clearTimeout(streamEndTimerRef.current); + streamEndTimerRef.current = null; + }, []); + + const isSideChannelEvent = useCallback((ev: InboundEvent) => { + const turnId = eventTurnId(ev); + return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId); + }, []); + + const scheduleStreamEndTimer = useCallback((turn: UIMessageTurnFields = {}) => { + cancelStreamEndTimer(); + streamEndTimerRef.current = setTimeout(() => { + streamEndTimerRef.current = null; + setIsStreaming(false); + setMessages((prev) => finalizeStreamedTurn(prev, turn)); + }, STREAM_END_IDLE_DELAY_MS); + }, [cancelStreamEndTimer]); + const createActivitySegmentId = useCallback((activate = true) => { activitySegmentCounterRef.current += 1; const id = `activity-${activitySegmentCounterRef.current}`; @@ -723,13 +771,11 @@ export function useNanobotStream( closedAssistantStreamIdsRef.current.clear(); clearActivitySegment(); clearPendingStreamWork(); + sideChannelTurnIdsRef.current.clear(); suppressStreamUntilTurnEndRef.current = false; - if (streamEndTimerRef.current !== null) { - clearTimeout(streamEndTimerRef.current); - streamEndTimerRef.current = null; - } + cancelStreamEndTimer(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chatId, client, clearActivitySegment, clearPendingStreamWork]); + }, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]); useEffect(() => { if (hasPendingToolCalls) setIsStreaming(true); @@ -739,13 +785,12 @@ export function useNanobotStream( if (!chatId) return; const handle = (ev: InboundEvent) => { - // Any incoming event while the debounce timer is alive means the model - // is still working (e.g. tool result arrived, more text to stream). - // Cancel the pending "stream ended" timer so we don't hide the spinner. - if (streamEndTimerRef.current !== null) { - clearTimeout(streamEndTimerRef.current); - streamEndTimerRef.current = null; - } + const sideChannelEvent = isSideChannelEvent(ev); + if ( + streamEndTimerRef.current !== null + && !sideChannelEvent + && eventExtendsModelActivity(ev) + ) cancelStreamEndTimer(); if (ev.event === "delta") { if (suppressStreamUntilTurnEndRef.current) return; @@ -778,15 +823,14 @@ export function useNanobotStream( } if (ev.event === "stream_end") { + const turn = turnFieldsFromEvent(ev, "answer"); flushPendingStreamEvents({ closeAnswerSegment: true, ...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}), - turn: turnFieldsFromEvent(ev, "answer"), + turn, }); if (suppressStreamUntilTurnEndRef.current) return; - // stream_end only means the text segment finished — the model may - // still be executing tools. Do NOT reset isStreaming here; the - // definitive "turn is complete" signal is ``turn_end``. + scheduleStreamEndTimer(turn); return; } @@ -825,10 +869,7 @@ export function useNanobotStream( setRunStartedAt(null); // Definitive signal that the turn is fully complete. Cancel any // pending debounce timer and stop the loading indicator immediately. - if (streamEndTimerRef.current !== null) { - clearTimeout(streamEndTimerRef.current); - streamEndTimerRef.current = null; - } + cancelStreamEndTimer(); setIsStreaming(false); setMessages((prev) => { let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); @@ -943,11 +984,21 @@ export function useNanobotStream( ? ev.media_urls.map((m) => toMediaAttachment(m)) : ev.media?.map((url) => toMediaAttachment({ url })); const hasMedia = !!media && media.length > 0; + if (sideChannelEvent) { + setMessages((prev) => absorbCompleteAssistantMessage(prev, { + content: ev.text, + ...(hasMedia ? { media } : {}), + ...(ev.source ? { source: ev.source } : {}), + ...turnFieldsFromEvent(ev, "answer"), + })); + if (typeof ev.turn_id === "string") sideChannelTurnIdsRef.current.delete(ev.turn_id); + return; + } // A complete (non-streamed) assistant message. If a stream was in // flight, drop the placeholder so we don't render the text twice. - // Do NOT reset isStreaming here — only ``turn_end`` signals that - // the full turn (all tool calls + final text) is complete. + // Streaming state is closed by ``stream_end`` when present, or by + // ``turn_end`` for non-streamed and tool-heavy turns. clearActivitySegment(); setMessages((prev) => { const activeId = buffer.current?.messageId; @@ -1034,12 +1085,10 @@ export function useNanobotStream( closedAssistantStreamIdsRef.current.clear(); clearActivitySegment(); clearPendingStreamWork(); - if (streamEndTimerRef.current !== null) { - clearTimeout(streamEndTimerRef.current); - streamEndTimerRef.current = null; - } + cancelStreamEndTimer(); }; }, [ + cancelStreamEndTimer, chatId, client, clearActivitySegment, @@ -1047,8 +1096,10 @@ export function useNanobotStream( detachedActivitySegmentId, ensureActivitySegmentId, flushPendingStreamEvents, + isSideChannelEvent, onTurnEnd, schedulePendingStreamFlush, + scheduleStreamEndTimer, ]); const send = useCallback( @@ -1059,16 +1110,20 @@ export function useNanobotStream( // the image blocks via ``media`` paths. if (!hasImages && !content.trim()) return; + const sideChannel = options?.sideChannel === true; flushPendingStreamEvents(); const turnId = crypto.randomUUID(); + if (sideChannel) sideChannelTurnIdsRef.current.add(turnId); const previews = hasImages ? images!.map((i) => i.preview) : undefined; setMessages((prev) => { - buffer.current = null; - activeAssistantRef.current = null; - closedAssistantStreamIdsRef.current.clear(); - clearActivitySegment(); + if (!sideChannel) { + buffer.current = null; + activeAssistantRef.current = null; + closedAssistantStreamIdsRef.current.clear(); + clearActivitySegment(); + } return [ - ...pruneReasoningOnlyPlaceholders(prev), + ...(sideChannel ? prev : pruneReasoningOnlyPlaceholders(prev)), { id: crypto.randomUUID(), role: "user", @@ -1083,11 +1138,11 @@ export function useNanobotStream( }, ]; }); - // Mark streaming immediately so the UI shows the loading indicator - // right away, before the first delta arrives from the server. - setIsStreaming(true); + if (!sideChannel) setIsStreaming(true); const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; - client.sendMessage(chatId, content, wireMedia, { ...options, turnId }); + const wireOptions = { ...options, turnId }; + delete wireOptions.sideChannel; + client.sendMessage(chatId, content, wireMedia, wireOptions); }, [chatId, clearActivitySegment, client, flushPendingStreamEvents], ); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index c5e0ba0a..f4d27039 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1312,6 +1312,23 @@ describe("ThreadComposer", () => { expect(onSend).toHaveBeenCalledWith("Draw a friendly robot", undefined, undefined); }); + it("marks known slash commands as side-channel sends", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/history" } }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("/history", undefined, { sideChannel: true }); + }); + it("shows a stop button while streaming", () => { const onStop = vi.fn(); render( diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 7450a5fd..e8524fa6 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -82,6 +82,30 @@ function stubVisualViewport({ }; } +function stubResizeObserver() { + const original = globalThis.ResizeObserver; + const observers: ResizeObserverInstance[] = []; + class MockResizeObserver { + element?: Element; + callback: ResizeObserverCallback; + disconnect = vi.fn(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + observers.push(this); + } + + observe(element: Element) { + this.element = element; + } + } + vi.stubGlobal("ResizeObserver", MockResizeObserver); + return { + observers, + restore: () => vi.stubGlobal("ResizeObserver", original), + }; +} + function makeLongMessages(count: number): UIMessage[] { return Array.from({ length: count }, (_, index) => ({ id: `m${index}`, @@ -362,23 +386,7 @@ describe("ThreadViewport", () => { }); it("keeps the scroll-to-bottom button above a growing composer", () => { - const originalResizeObserver = globalThis.ResizeObserver; - const resizeObservers: ResizeObserverInstance[] = []; - class MockResizeObserver { - element?: Element; - callback: ResizeObserverCallback; - disconnect = vi.fn(); - - constructor(callback: ResizeObserverCallback) { - this.callback = callback; - resizeObservers.push(this); - } - - observe(element: Element) { - this.element = element; - } - } - vi.stubGlobal("ResizeObserver", MockResizeObserver); + const resizeObserver = stubResizeObserver(); try { const { container } = render( @@ -418,7 +426,7 @@ describe("ThreadViewport", () => { toJSON: () => ({}), }) as DOMRect; - const composerObserver = resizeObservers.find( + const composerObserver = resizeObserver.observers.find( (observer) => observer.element === composerDock, ); expect(composerObserver).toBeDefined(); @@ -429,7 +437,85 @@ describe("ThreadViewport", () => { expect(buttonPositioner).toHaveStyle({ bottom: "256px" }); } finally { - vi.stubGlobal("ResizeObserver", originalResizeObserver); + resizeObserver.restore(); + } + }); + + it("keeps the active prompt visible when the composer grows", async () => { + const resizeObserver = stubResizeObserver(); + + try { + const threaded: UIMessage[] = [ + { id: "u1", role: "user", content: "old question", createdAt: 1 }, + { id: "a1", role: "assistant", content: "old answer", createdAt: 2 }, + { id: "u2", role: "user", content: "new question", createdAt: 3 }, + ]; + const scrollTo = vi.fn(); + const { container, rerender } = render( + composer} + scrollToLatestUserPromptSignal={0} + />, + ); + + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 1200 }, + clientHeight: { configurable: true, value: 500 }, + scrollTop: { configurable: true, writable: true, value: 700 }, + scrollTo: { configurable: true, value: scrollTo }, + }); + const prompt = container.querySelector('[data-user-prompt-id="u2"]'); + expect(prompt).not.toBeNull(); + Object.defineProperty(prompt, "offsetTop", { + configurable: true, + value: 420, + }); + + await act(async () => { + rerender( + composer} + scrollToLatestUserPromptSignal={1} + />, + ); + }); + + scrollTo.mockClear(); + const composerDock = screen.getByTestId("thread-composer-dock"); + composerDock.getBoundingClientRect = () => + ({ + height: 240, + width: 800, + top: 0, + right: 800, + bottom: 240, + left: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + const composerObserver = resizeObserver.observers.find( + (observer) => observer.element === composerDock, + ); + expect(composerObserver).toBeDefined(); + + act(() => { + composerObserver!.callback([], composerObserver as unknown as ResizeObserver); + }); + + await waitFor(() => + expect(scrollTo).toHaveBeenCalledWith({ + top: 404, + behavior: "auto", + }), + ); + } finally { + resizeObserver.restore(); } }); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index f67e1a55..1d44021f 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -1622,7 +1622,89 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].content).toBe("long task"); }); - it("keeps streaming alive across stream_end and completes on turn_end", async () => { + it("does not mark side-channel slash commands as streaming", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-status", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + result.current.send("/status", undefined, { sideChannel: true }); + }); + + const call = fake.client.sendMessage.mock.calls.at(-1)!; + const turnId = call[3]?.turnId; + expect(call[3]).not.toHaveProperty("sideChannel"); + expect(result.current.isStreaming).toBe(false); + + act(() => { + fake.emit("chat-status", { + event: "message", + chat_id: "chat-status", + text: "status reply", + turn_id: turnId, + }); + }); + + expect(result.current.isStreaming).toBe(false); + expect(result.current.messages.map((message) => message.content)).toEqual([ + "/status", + "status reply", + ]); + }); + + it("lets stream_end finish streaming while side-channel status replies arrive", () => { + vi.useFakeTimers(); + try { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-status-loop", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + result.current.send("write normally"); + }); + const promptTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId; + + act(() => { + fake.emit("chat-status-loop", { + event: "stream_end", + chat_id: "chat-status-loop", + text: "done", + turn_id: promptTurnId, + }); + }); + + act(() => { + result.current.send("/status", undefined, { sideChannel: true }); + }); + const statusTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId; + + act(() => { + fake.emit("chat-status-loop", { + event: "message", + chat_id: "chat-status-loop", + text: "status reply", + turn_id: statusTurnId, + }); + }); + + expect(result.current.isStreaming).toBe(true); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.isStreaming).toBe(false); + expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({ + isStreaming: false, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps streaming alive across stream_end when tool activity follows", async () => { const fake = fakeClient(); const onTurnEnd = vi.fn(); const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), { @@ -1660,14 +1742,15 @@ describe("useNanobotStream", () => { fake.emit("chat-s", { event: "message", chat_id: "chat-s", - text: "Hello world", + kind: "progress", + text: "Calling tool", }); }); expect(result.current.isStreaming).toBe(true); expect(result.current.messages.at(-1)).toMatchObject({ - role: "assistant", - content: "Hello world", + role: "tool", + content: "Calling tool", }); act(() => {