);
}
- 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 () => {