diff options
| author | Adam Malczewski <[email protected]> | 2026-05-30 23:14:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-30 23:14:55 +0900 |
| commit | 624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47 (patch) | |
| tree | 869d34092345344ff13953398f876c8b38c8116a /packages/frontend/src/lib/components | |
| parent | b19f1aafc43141a865ecd40a813ed3212e77d95e (diff) | |
| download | dispatch-624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47.tar.gz dispatch-624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47.zip | |
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.
Diffstat (limited to 'packages/frontend/src/lib/components')
| -rw-r--r-- | packages/frontend/src/lib/components/ChatPanel.svelte | 33 |
1 files changed, 25 insertions, 8 deletions
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> |
