From 624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47 Mon Sep 17 00:00:00 2001 From: Adam Malczewski 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') 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(); + 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}
Loading earlier messages...
{/if} - {#if messages.length === 0} + {#if renderGroups.length === 0}
Send a message to start a conversation
{/if} - {#each messages as message (message.id)} - + {#each keyedMessages as { m, key } (key)} + {/each} -- cgit v1.2.3