From 812ee4bfec2a2b6fdf0a3442d3ea21d19ac8be70 Mon Sep 17 00:00:00 2001 From: mawkone Date: Mon, 15 Jun 2026 12:23:45 -0700 Subject: [PATCH] Polish thinking streaming UI and ordering --- vibn-frontend/app/api/chat/route.ts | 35 ++++-- .../components/vibn-chat/chat-panel.tsx | 102 +++++++++++++++--- 2 files changed, 112 insertions(+), 25 deletions(-) diff --git a/vibn-frontend/app/api/chat/route.ts b/vibn-frontend/app/api/chat/route.ts index 4b180d89..9af6133e 100644 --- a/vibn-frontend/app/api/chat/route.ts +++ b/vibn-frontend/app/api/chat/route.ts @@ -1182,13 +1182,39 @@ export async function POST(request: Request) { error: undefined as string | undefined, }; + let currentTimelineKind: "thought" | "text" | null = null; + let currentTimelineText = ""; + + const flushTimeline = () => { + if (!currentTimelineKind) return; + if (currentTimelineKind === "thought") { + assistantTimeline.push({ kind: "thought", text: currentTimelineText }); + } else if (currentTimelineKind === "text") { + assistantText += (assistantText ? "\n\n" : "") + currentTimelineText; + assistantTextSegments.push(currentTimelineText); + assistantTimeline.push({ kind: "text", text: currentTimelineText }); + } + currentTimelineKind = null; + currentTimelineText = ""; + }; + for await (const chunk of stream) { if (aborted) break; if (chunk.type === "thinking_delta" && chunk.text) { + if (currentTimelineKind !== "thought") { + flushTimeline(); + currentTimelineKind = "thought"; + } + currentTimelineText += chunk.text; resp.thoughts += chunk.text; emit({ type: "thinking_delta", text: chunk.text }); } else if (chunk.type === "text_delta" && chunk.text) { + if (currentTimelineKind !== "text") { + flushTimeline(); + currentTimelineKind = "text"; + } + currentTimelineText += chunk.text; resp.text += chunk.text; emit({ type: "text_delta", text: chunk.text }); } else if (chunk.type === "tool_calls" && chunk.toolCalls) { @@ -1197,16 +1223,9 @@ export async function POST(request: Request) { resp.error = chunk.error; } } + flushTimeline(); - // If the model produced any thoughts or text, record them in the timeline once stream is complete. - // (The UI handles the delta-rendering live, but we save the complete chunk to Postgres). - if (resp.thoughts) { - assistantTimeline.push({ kind: "thought", text: resp.thoughts }); - } if (resp.text) { - assistantText += (assistantText ? "\n\n" : "") + resp.text; - assistantTextSegments.push(resp.text); - assistantTimeline.push({ kind: "text", text: resp.text }); roundsSinceText = 0; toolCallsSinceText = 0; } else if (resp.toolCalls.length) { diff --git a/vibn-frontend/components/vibn-chat/chat-panel.tsx b/vibn-frontend/components/vibn-chat/chat-panel.tsx index 5477e15d..6c9d4840 100644 --- a/vibn-frontend/components/vibn-chat/chat-panel.tsx +++ b/vibn-frontend/components/vibn-chat/chat-panel.tsx @@ -635,23 +635,7 @@ function Timeline({ entries }: { entries: TimelineEntry[] }) {
{items.map((item, i) => { if (item.kind === "thought") { - return ( -
- Thinking: {item.text} -
- ); + return ; } if (item.kind === "text") { return ; @@ -2903,3 +2887,87 @@ export function ChatPanel({
); } + +function TimelineThought({ text }: { text: string }) { + // Auto-expand if the thought is actively streaming (i.e., less than a full turn old) + // but let the user collapse it. To keep it simple, we default to false (collapsed), + // but if the component mounts and text is very short (just starting to stream), we could + // expand it. A better UX is to collapse it by default but allow expanding. + // However, you asked that the final message display before the final thinking hides the response. + // Wait, the prompt: "most tools collapse the thinking text once the next action starts to make the chat less noisy. But also gives the user to the option to expand it." + + // We can track if we are the "latest" thought, but an easier way is to just use a ref + // to see if we're actively receiving new props (streaming). + + const [expanded, setExpanded] = React.useState(true); + const textLenRef = React.useRef(text.length); + + React.useEffect(() => { + // If text stops growing for a bit, auto-collapse + let t = setTimeout(() => { + if (text.length === textLenRef.current) { + setExpanded(false); + } + }, 1500); + return () => clearTimeout(t); + }, [text]); + + React.useEffect(() => { + textLenRef.current = text.length; + }, [text]); + + return ( +
+ + {expanded && ( +
+ {text.trim()} +
+ )} +
+ ); +}