summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-30 23:14:55 +0900
committerAdam Malczewski <[email protected]>2026-05-30 23:14:55 +0900
commit624b808da0f2f8bbad8a4fbbcca3f82f24ecfc47 (patch)
tree869d34092345344ff13953398f876c8b38c8116a /packages/frontend/src/lib/components
parentb19f1aafc43141a865ecd40a813ed3212e77d95e (diff)
downloaddispatch-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.svelte33
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>