From 624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47 Mon Sep 17 00:00:00 2001
From: Adam Malczewski <github@tradam.dev>
Date: Sat, 30 May 2026 23:14:55 +0900
Subject: feat(chunks): chunk-native frontend store with turn-sealed reconcile
 + per-chunk eviction

Replace the stored ChatMessage[] with a chunk-native model: tab.chunks (sealed
ChunkRow[]) + tab.live (transient in-flight turn buffer) + derived tab.renderGroups.
This enables per-chunk eviction (trimming WITHIN a large turn) and raw-chunk
pagination (loadOlderChunks), removing the whole-message eviction limitation.

Backend:
- Emit turn-start/turn-sealed around each turn; expose currentTurnId in the status
  snapshot. turn-sealed fires after the durable write (status:idle fires before it).
- New GET /tabs/:id/chunks raw paginated endpoint (limit/before).
- Wrap appendChunks in a single SQLite transaction.

Frontend:
- turn-sealed drives a turn-aware reconcile that folds the sealed turn into chunks
  while preserving a concurrent newer in-flight turn and pending queued messages;
  deferred while the user is scrolled up.
- Stable turn-scoped render keys (${turnId}:${role}:${n}) avoid remount/flash.

Reconcile correctness (three review passes):
- preserve a concurrent newer turn when an earlier deferred reconcile flushes;
- keep optimistic queued user messages (no loss);
- turn-start backfill skips pending queued rows and tags only the turn initiator;
- bind consumed interrupt messages to the in-flight turn so they collapse on seal
  (no lingering/duplicated bubble).

Tests: chat-store reconcile/eviction/pagination suite; api chunks endpoint + events.
---
 .../frontend/src/lib/components/ChatPanel.svelte   | 33 ++++++++++++++++------
 1 file changed, 25 insertions(+), 8 deletions(-)

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

diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
index 8170d43..abdec17 100644
--- a/packages/frontend/src/lib/components/ChatPanel.svelte
+++ b/packages/frontend/src/lib/components/ChatPanel.svelte
@@ -8,9 +8,26 @@ let userScrolledUp = $state(false);
 let isAutoScrolling = false;
 let isLoadingMore = $state(false);
 
-const messages = $derived(tabStore.activeTab?.messages ?? []);
+const renderGroups = $derived(tabStore.activeTab?.renderGroups ?? []);
 const activeTabId = $derived(tabStore.activeTab?.id);
 
+// Stable, turn-scoped render keys. A bubble's identity is `${turnId}:${role}:${n}`
+// (n = its index among same-(turn,role) messages) rather than the underlying
+// row/client id, so when the live turn reconciles into sealed chunk rows the
+// bubble keeps its identity and does NOT remount (no flash). Falls back to the
+// message id for anything without a turnId (e.g. optimistic/queued messages
+// before turn-start, standalone system notices).
+const keyedMessages = $derived.by(() => {
+	const counts = new Map<string, number>();
+	return renderGroups.map((m) => {
+		if (!m.turnId) return { m, key: m.id };
+		const base = `${m.turnId}:${m.role}`;
+		const n = counts.get(base) ?? 0;
+		counts.set(base, n + 1);
+		return { m, key: `${base}:${n}` };
+	});
+});
+
 function isNearBottom(el: HTMLElement): boolean {
 	return el.scrollHeight - el.scrollTop - el.clientHeight < 64;
 }
@@ -35,7 +52,7 @@ async function onNearTop() {
 	const prevScrollHeight = messagesEl?.scrollHeight ?? 0;
 	const prevScrollTop = messagesEl?.scrollTop ?? 0;
 	try {
-		await tabStore.loadMoreMessages(tab.id);
+		await tabStore.loadOlderChunks(tab.id);
 		// Wait for Svelte to flush the prepended messages into the DOM.
 		// Reading `scrollHeight` synchronously after the await would observe
 		// the OLD layout (reactive updates are batched), so the scroll
@@ -66,7 +83,7 @@ function handleScroll() {
 	if (activeTabId) tabStore.setScrolledUp(activeTabId, userScrolledUp);
 	// User just scrolled back to the bottom manually — safe to evict now.
 	if (wasScrolledUp && !userScrolledUp && activeTabId) {
-		tabStore.evictMessages(activeTabId);
+		tabStore.evictChunks(activeTabId);
 	}
 	// Near the top — pull in older history.
 	if (userScrolledUp && messagesEl.scrollTop < 200) {
@@ -79,13 +96,13 @@ function resumeAutoScroll() {
 	isAutoScrolling = true;
 	if (activeTabId) {
 		tabStore.setScrolledUp(activeTabId, false);
-		tabStore.evictMessages(activeTabId);
+		tabStore.evictChunks(activeTabId);
 	}
 	scrollToBottom(true);
 }
 
 $effect(() => {
-	const count = messages.length;
+	const count = renderGroups.length;
 	void count;
 	if (messagesEl) {
 		untrack(() => {
@@ -121,13 +138,13 @@ $effect(() => {
 			{#if isLoadingMore}
 				<div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div>
 			{/if}
-			{#if messages.length === 0}
+			{#if renderGroups.length === 0}
 				<div class="flex items-center justify-center h-full text-base-content/40 text-sm">
 					Send a message to start a conversation
 				</div>
 			{/if}
-			{#each messages as message (message.id)}
-				<ChatMessageComponent {message} tabId={activeTabId} />
+			{#each keyedMessages as { m, key } (key)}
+				<ChatMessageComponent message={m} tabId={activeTabId} />
 			{/each}
 		</div>
 
-- 
cgit v1.2.3

