From ca6ee91c5e1167b1929eedbb96c76dfa24e7d026 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Wed, 27 May 2026 18:35:18 +0900
Subject: refactor: ChatMessage.chunks[] union — interleaved thinking, tool
 batching, error/system chunks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../frontend/src/lib/components/ChatMessage.svelte | 76 +++++++++++++++-------
 1 file changed, 53 insertions(+), 23 deletions(-)

(limited to 'packages/frontend/src/lib/components/ChatMessage.svelte')

diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
index c6b6034..0c85349 100644
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
 import { appSettings } from "../settings.svelte.js";
 import { tabStore } from "../tabs.svelte.js";
-import type { ChatMessage } from "../types.js";
+import type { ChatMessage, Chunk, SystemChunkKind } from "../types.js";
 import MarkdownRenderer from "./MarkdownRenderer.svelte";
 import ToolCallDisplay from "./ToolCallDisplay.svelte";
 
@@ -21,37 +21,67 @@ function cancelQueued() {
 		void tabStore.cancelQueuedMessage(tabId, queuedMessageId);
 	}
 }
+
+function chunkKey(chunk: Chunk, i: number): string {
+	if (chunk.type === "tool-batch") {
+		// Stable-ish: first call id + count keeps re-renders sane while streaming.
+		return `tb-${chunk.calls[0]?.id ?? i}-${chunk.calls.length}`;
+	}
+	return `${chunk.type}-${i}`;
+}
+
+const SYSTEM_KIND_LABEL: Record<SystemChunkKind, string> = {
+	notice: "Notice",
+	"model-changed": "Model changed",
+	"config-reload": "Config reload",
+	cancelled: "Cancelled",
+};
 </script>
 
-{#if isSystem}
-	<div class="flex justify-center my-2">
-		<div class="badge badge-ghost gap-1 text-xs opacity-60">
-			{#each message.content as segment}
-				{#if segment.type === "text"}
-					{segment.text}
-				{/if}
-			{/each}
-		</div>
-	</div>
-{:else}
-<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full {isQueued ? 'opacity-60' : ''}">
-	<div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}">
-		{#if message.thinking}
+{#snippet renderChunks(chunks: Chunk[], streaming: boolean | undefined)}
+	{#each chunks as chunk, i (chunkKey(chunk, i))}
+		{#if chunk.type === "text"}
+			<MarkdownRenderer text={chunk.text} {streaming} />
+		{:else if chunk.type === "thinking"}
 			<div class="collapse collapse-arrow mb-2 p-1">
 				<input type="checkbox" checked={appSettings.autoExpandThinking} />
 				<div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div>
 				<div class="collapse-content text-sm opacity-60 italic p-0">
-					<p class="whitespace-pre-wrap mt-1">{message.thinking}</p>
+					<p class="whitespace-pre-wrap mt-1">{chunk.text}</p>
 				</div>
 			</div>
+		{:else if chunk.type === "tool-batch"}
+			{#each chunk.calls as call (call.id)}
+				<ToolCallDisplay toolCall={call} />
+			{/each}
+		{:else if chunk.type === "error"}
+			<div class="alert alert-error my-2 py-2 px-3 text-sm rounded border border-error/60 bg-error/10 text-error">
+				<div class="flex flex-col gap-0.5 w-full">
+					<span class="break-words">{chunk.message}</span>
+					{#if chunk.statusCode !== undefined}
+						<span class="text-xs opacity-70">status {chunk.statusCode}</span>
+					{/if}
+				</div>
+			</div>
+		{:else if chunk.type === "system"}
+			<div class="my-1 text-xs italic opacity-50 flex gap-1 items-baseline">
+				<span class="font-semibold not-italic">{SYSTEM_KIND_LABEL[chunk.kind]}:</span>
+				<span class="break-words">{chunk.text}</span>
+			</div>
 		{/if}
-		{#each message.content as segment, i (segment.type === "tool-call" ? segment.id : i)}
-			{#if segment.type === "text"}
-				<MarkdownRenderer text={segment.text} streaming={message.isStreaming} />
-			{:else if segment.type === "tool-call"}
-				<ToolCallDisplay toolCall={segment} />
-			{/if}
-		{/each}
+	{/each}
+{/snippet}
+
+{#if isSystem}
+	<div class="flex justify-center my-2 px-4">
+		<div class="max-w-full text-center">
+			{@render renderChunks(message.chunks, false)}
+		</div>
+	</div>
+{:else}
+<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full {isQueued ? 'opacity-60' : ''}">
+	<div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}">
+		{@render renderChunks(message.chunks, message.isStreaming)}
 		{#if message.isStreaming}
 			<span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5 align-middle rounded-sm"></span>
 		{/if}
-- 
cgit v1.2.3

