diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 1ccc0838f..17a7dc537 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -1,3 +1,4 @@ +import { Children, isValidElement } from "react"; import ReactMarkdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; @@ -46,11 +47,19 @@ export default function MarkdownTextRenderer({ components={{ code({ className: cls, children: kids, ...props }) { const match = /language-(\w+)/.exec(cls || ""); - if (!match) { + if (match) { + const code = String(kids).replace(/\n$/, ""); + return ; + } + const raw = String(kids).replace(/\n$/, ""); + /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */ + const widePlainBlock = raw.includes("\n") || raw.length > 120; + if (widePlainBlock) { return ( ); } - const code = String(kids).replace(/\n$/, ""); - return ; + return ( + + {kids} + + ); }, pre({ children: markdownChildren }) { - return <>{markdownChildren}; + const kids = Children.toArray(markdownChildren); + const lone = kids.length === 1 ? kids[0] : null; + /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */ + if (lone != null && isValidElement(lone) && lone.type === CodeBlock) { + return <>{markdownChildren}; + } + return ( +
+                {markdownChildren}
+              
+ ); }, a({ href, children: markdownChildren, ...props }) { return ( diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 67d128ed5..ae15ced62 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -101,6 +101,7 @@ export function MessageBubble({ const reasoning = message.role === "assistant" ? message.reasoning ?? "" : ""; const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming); const hasReasoning = reasoning.length > 0 || reasoningStreaming; + const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; const showCopyButton = showAssistantCopyAction && showAssistantActions; const latencyMs = message.latencyMs; diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index e7f8fd45e..309f206c5 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -114,10 +114,8 @@ export function ThreadShell({ return messageCacheRef.current.get(chatId) ?? historical; }, [chatId, historical]); const handleTurnEnd = useCallback(() => { - if (chatId) pendingCanonicalHydrateRef.current.add(chatId); - refreshHistory(); onTurnEnd?.(); - }, [chatId, onTurnEnd, refreshHistory]); + }, [onTurnEnd]); const { messages, isStreaming, @@ -147,8 +145,8 @@ export function ThreadShell({ // When the user switches away and back, keep the local in-memory thread // state (including not-yet-persisted messages) instead of replacing it with // whatever the history endpoint currently knows about. Once a fresh - // canonical replay arrives after turn_end, prefer it so live Markdown/tool - // rendering converges to the same shape as a manual refresh. + // canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it + // so rendering converges to the same shape as a manual refresh. setMessages((prev) => { if (hasNewCanonicalHistory && historical.length > 0) { pendingCanonicalHydrateRef.current.delete(chatId); diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index bb416d351..0ac02023d 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -14,10 +14,19 @@ import type { } from "@/lib/types"; interface StreamBuffer { - /** ID of the assistant message currently receiving deltas. */ + /** ID of the assistant message currently receiving deltas (cleared on ``stream_end``). */ messageId: string; - /** Sequence of deltas accumulated in order. */ - parts: string[]; +} + +/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */ +function findStreamingAssistantId(prev: UIMessage[]): string | null { + for (let i = prev.length - 1; i >= 0; i -= 1) { + const m = prev[i]; + if (m.kind === "trace") continue; + if (m.role === "assistant" && m.isStreaming) return m.id; + if (m.role === "user") break; + } + return null; } /** @@ -286,25 +295,22 @@ export function useNanobotStream( if (ev.event === "delta") { if (suppressStreamUntilTurnEndRef.current) return; - const chunk = ev.text; + const chunk = typeof ev.text === "string" ? ev.text : ""; setIsStreaming(true); setMessages((prev) => { - // Reuse an in-flight assistant placeholder (typically created by - // ``reasoning_delta``) so the answer renders below its own - // thinking trace instead of in a parallel row. - const adopted = !buffer.current ? findActiveAssistantPlaceholder(prev) : null; + const adopted = findActiveAssistantPlaceholder(prev); + const streamingAssistId = findStreamingAssistantId(prev); let targetId: string; let next: UIMessage[]; - if (buffer.current) { - targetId = buffer.current.messageId; - next = prev; - } else if (adopted) { + + if (adopted) { targetId = adopted; - buffer.current = { messageId: targetId, parts: [] }; + next = prev; + } else if (streamingAssistId) { + targetId = streamingAssistId; next = prev; } else { targetId = crypto.randomUUID(); - buffer.current = { messageId: targetId, parts: [] }; next = [ ...prev, { @@ -316,8 +322,11 @@ export function useNanobotStream( }, ]; } - buffer.current.parts.push(chunk); - const combined = buffer.current.parts.join(""); + + buffer.current = { messageId: targetId }; + + const priorContent = next.find((m) => m.id === targetId)?.content ?? ""; + const combined = priorContent + chunk; return next.map((m) => m.id === targetId ? { ...m, content: combined, isStreaming: true } : m, ); diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index d992816e4..ded368741 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -12,6 +12,44 @@ import type { const WS_OPEN = 1; const WS_CLOSING = 2; +/** Inbound WebSocket ``console.log`` / parse-failure ``console.warn``. + * + * - **Dev** (non-production bundle): **on by default** — messages appear at default log level. + * - **Production**: off unless ``localStorage.setItem('nanobot_debug_ws','1')`` (or ``true``). + * - **Silence anywhere**: ``localStorage.setItem('nanobot_debug_ws','0')`` (or ``false`` / ``off``). + * Values are read on every frame; no reload needed. + */ +function wsInboundDebugEnabled(): boolean { + if (typeof globalThis === "undefined") return false; + try { + if (import.meta.env.MODE === "test") return false; + const ls = (globalThis as unknown as { localStorage?: Storage }).localStorage; + const raw = ls?.getItem("nanobot_debug_ws")?.trim().toLowerCase() ?? ""; + if (raw === "0" || raw === "false" || raw === "off" || raw === "no") { + return false; + } + if (raw === "1" || raw === "true" || raw === "on" || raw === "yes") { + return true; + } + return !import.meta.env.PROD; + } catch { + return !import.meta.env.PROD; + } +} + +/** Shorten streaming text fields so logging stays usable for huge deltas. */ +function summarizeInboundWsPayload(ev: InboundEvent): unknown { + const kind = (ev as { event?: string }).event; + if (kind !== "delta" && kind !== "reasoning_delta") return ev; + const row = { ...(ev as object) } as Record; + const text = typeof row.text === "string" ? row.text : ""; + const max = 240; + if (text.length > max) { + row.text = `${text.slice(0, max)}… (${text.length} chars)`; + } + return row; +} + type Unsubscribe = () => void; type EventHandler = (ev: InboundEvent) => void; type StatusHandler = (status: ConnectionStatus) => void; @@ -289,9 +327,20 @@ export class NanobotClient { try { parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "") as InboundEvent; } catch { + if (wsInboundDebugEnabled()) { + const raw = typeof ev.data === "string" ? ev.data : String(ev.data); + console.warn( + "[nanobot ws inbound] invalid JSON", + raw.length > 400 ? `${raw.slice(0, 400)}… (${raw.length} chars)` : raw, + ); + } return; } + if (wsInboundDebugEnabled()) { + console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed)); + } + if (parsed.event === "ready") { this.readyChatId = parsed.chat_id; this.knownChats.add(parsed.chat_id); diff --git a/webui/src/main.tsx b/webui/src/main.tsx index 009052602..75460720f 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -24,8 +24,5 @@ if (typeof globalThis.crypto !== "undefined" && !("randomUUID" in globalThis.cry const root = document.getElementById("root"); if (!root) throw new Error("root element missing"); -ReactDOM.createRoot(root).render( - - - , -); +/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */ +ReactDOM.createRoot(root).render(); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 87b6fb790..c768b5a42 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -593,7 +593,7 @@ describe("ThreadShell", () => { await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument()); }); - it("replaces live streamed content with canonical history after turn end", async () => { + it("does not refetch thread history on turn_end", async () => { const client = makeClient(); let historyCalls = 0; vi.stubGlobal( @@ -646,8 +646,9 @@ describe("ThreadShell", () => { }); }); - await waitFor(() => expect(screen.getByText("canonical markdown answer")).toBeInTheDocument()); - expect(screen.queryByText("live half-parsed | markdown")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByText("live half-parsed | markdown")).toBeInTheDocument()); + expect(screen.queryByText("canonical markdown answer")).not.toBeInTheDocument(); + expect(historyCalls).toBe(1); }); it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {