diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts
index c2377cdf..d1a28fa1 100644
--- a/app/api/chat/route.ts
+++ b/app/api/chat/route.ts
@@ -416,6 +416,15 @@ export async function POST(request: Request) {
let messages = [...history];
let round = 0;
let assistantText = '';
+ // Per-round text segments. The model emits one `resp.text` per
+ // tool-loop round; we used to concatenate them all into one
+ // `assistantText` blob and render that as a single chat bubble.
+ // That made multi-round turns look like one giant run-on
+ // paragraph ("now.Spinning up...first boot...The dev container
+ // is ready!" with no breaks). Keeping them separate on the
+ // server lets the client render each as its own bubble and
+ // restores the segmentation on reload.
+ const assistantTextSegments: string[] = [];
const assistantToolCalls: ToolCall[] = [];
let aborted = clientSignal.aborted;
const onAbort = () => {
@@ -463,7 +472,8 @@ export async function POST(request: Request) {
// Stream user-facing text to client
if (resp.text) {
- assistantText += resp.text;
+ assistantText += (assistantText ? '\n\n' : '') + resp.text;
+ assistantTextSegments.push(resp.text);
emit({ type: 'text', text: resp.text });
roundsSinceText = 0;
} else if (resp.toolCalls.length) {
@@ -562,6 +572,7 @@ export async function POST(request: Request) {
? '\n\n_(stopped by user)_'
: '_(stopped by user before any response)_';
assistantText += stopMarker;
+ assistantTextSegments.push(stopMarker.trimStart());
emit({ type: 'text', text: stopMarker });
emit({ type: 'aborted' });
}
@@ -597,32 +608,41 @@ export async function POST(request: Request) {
temperature: 0.3,
});
if (summary.text && summary.text.trim()) {
- assistantText += summary.text;
+ assistantText += (assistantText ? '\n\n' : '') + summary.text;
+ assistantTextSegments.push(summary.text);
emit({ type: 'text', text: summary.text });
} else {
// Gemini returned empty — fall back to a deterministic
// status so the user never sees silent ✓ pills.
const fallback = loopBreakReason
- ? `\n\nI hit a loop while working on this — ${loopBreakReason}. Want me to try a different approach, or do you want to take a look?`
- : `\n\nI ran a chain of ${assistantToolCalls.length} tool calls but didn't reach a clean stopping point. Want me to keep going, or take a different angle?`;
- assistantText += fallback;
+ ? `I hit a loop while working on this — ${loopBreakReason}. Want me to try a different approach, or do you want to take a look?`
+ : `I ran a chain of ${assistantToolCalls.length} tool calls but didn't reach a clean stopping point. Want me to keep going, or take a different angle?`;
+ assistantText += (assistantText ? '\n\n' : '') + fallback;
+ assistantTextSegments.push(fallback);
emit({ type: 'text', text: fallback });
}
if (summary.thoughts) {
emit({ type: 'thinking', text: summary.thoughts });
}
} catch {
- const fallback = `\n\nI ran ${assistantToolCalls.length} tool calls but the wrap-up failed. Want me to retry, or try a different approach?`;
- assistantText += fallback;
+ const fallback = `I ran ${assistantToolCalls.length} tool calls but the wrap-up failed. Want me to retry, or try a different approach?`;
+ assistantText += (assistantText ? '\n\n' : '') + fallback;
+ assistantTextSegments.push(fallback);
emit({ type: 'text', text: fallback });
}
}
- // Persist final assistant message
- const finalMsg: ChatMessage = {
+ // Persist final assistant message. We include `textSegments`
+ // alongside the legacy concatenated `content` so the client
+ // can render reloaded threads with the same per-round bubble
+ // segmentation it shows during streaming. Older messages
+ // (pre-this-fix) won't have textSegments and fall back to
+ // single-bubble content rendering.
+ const finalMsg: ChatMessage & { textSegments?: string[] } = {
role: 'assistant',
content: assistantText,
toolCalls: assistantToolCalls.length ? assistantToolCalls : undefined,
+ textSegments: assistantTextSegments.length ? assistantTextSegments : undefined,
};
await query(
`INSERT INTO fs_chat_messages (thread_id, user_id, data) VALUES ($1, $2, $3)`,
diff --git a/components/vibn-chat/chat-panel.tsx b/components/vibn-chat/chat-panel.tsx
index 8a353f96..3a7c2956 100644
--- a/components/vibn-chat/chat-panel.tsx
+++ b/components/vibn-chat/chat-panel.tsx
@@ -47,7 +47,13 @@ interface Message {
type TimelineEntry =
| { kind: "thought"; text: string }
- | { kind: "tool"; name: string; status: "running" | "done"; result?: string };
+ | { kind: "tool"; name: string; status: "running" | "done"; result?: string }
+ // A text segment from one round of the assistant's tool loop.
+ // Each text SSE event from the server starts a new entry; subsequent
+ // streaming chunks for that same round append to the most-recent
+ // text entry. Tool/thought entries between text segments break the
+ // accumulation so multi-round turns render as separate bubbles.
+ | { kind: "text"; text: string };
interface ToolEvent {
name: string;
@@ -183,7 +189,18 @@ function MessageBubble({ msg }: { msg: Message }) {
{!isUser && msg.timeline && msg.timeline.length > 0 && (