diff options
| author | Adam Malczewski <[email protected]> | 2026-05-29 13:59:17 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-29 13:59:17 +0900 |
| commit | 520c9e30cc58b40d3b1ee9e7895f003c4f873206 (patch) | |
| tree | 7a10da94141d754dca25e728f4a388057a5d5981 | |
| parent | 0954c6520878e073c7822fc4a8f4a752474f5d7a (diff) | |
| download | dispatch-520c9e30cc58b40d3b1ee9e7895f003c4f873206.tar.gz dispatch-520c9e30cc58b40d3b1ee9e7895f003c4f873206.zip | |
feat: disappearing chat history — chunk-limited frontend window with backend pagination
Frontend keeps only a bounded window of chunks in memory (configurable via
settings slider, default 100). Older messages are evicted when at the bottom
and re-fetched from the backend on scroll-up.
- Backend: paginated GET /tabs/:id/messages with ?limit=N&before=seq
- Store: evictMessages trims oldest messages until total chunks ≤ limit
- Store: loadMoreMessages fetches next page and prepends with dedup
- ChatPanel: smart scroll hooks trigger eviction on return-to-bottom
- ChatPanel: onNearTop loads older history with scroll-position maintenance
- Settings: chunk limit slider in Memory section
- Fix: oldestLoadedSeq recalculated after eviction (pagination cursor stays valid)
- Fix: seq preserved on ChatMessage for cursor tracking
- Fix: scrolledUpTabs cleaned up on tab switch (no memory leak)
- Fix: evictMessages reads appSettings.chunkLimit directly (live updates)
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 17 | ||||
| -rw-r--r-- | packages/core/src/db/messages.ts | 77 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatPanel.svelte | 68 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 29 | ||||
| -rw-r--r-- | packages/frontend/src/lib/settings.svelte.ts | 7 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 219 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 1 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 14 |
9 files changed, 406 insertions, 27 deletions
diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts index 6e6734d..afa5735 100644 --- a/packages/api/src/routes/tabs.ts +++ b/packages/api/src/routes/tabs.ts @@ -5,6 +5,7 @@ import { getMessagesForTab, getSetting, getTab, + getTotalMessageCount, listOpenTabs, setSetting, updateTabModel, @@ -66,8 +67,20 @@ tabsRoutes.get("/:id", (c) => { tabsRoutes.get("/:id/messages", (c) => { const id = c.req.param("id"); - const messages = getMessagesForTab(id); - return c.json({ messages }); + const limitRaw = c.req.query("limit"); + const beforeRaw = c.req.query("before"); + const limit = limitRaw !== undefined ? Number(limitRaw) : undefined; + const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined; + const options = + limit !== undefined || before !== undefined + ? { + ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}), + ...(before !== undefined && Number.isFinite(before) ? { before } : {}), + } + : undefined; + const messages = getMessagesForTab(id, options); + const total = getTotalMessageCount(id); + return c.json({ messages, total }); }); tabsRoutes.patch("/:id", async (c) => { diff --git a/packages/core/src/db/messages.ts b/packages/core/src/db/messages.ts index 34b69e5..7fc6ccf 100644 --- a/packages/core/src/db/messages.ts +++ b/packages/core/src/db/messages.ts @@ -56,16 +56,26 @@ export function updateMessage(id: string, contentJson: string): void { } /** - * Read all messages for a tab in seq order. `content_json` is parsed into + * Read messages for a tab in seq order (ASC). `content_json` is parsed into * `Chunk[]` here so callers don't have to. If a row's JSON is malformed, * the message is returned with an empty chunk list rather than throwing. + * + * When `options` is omitted, returns ALL messages (backward compatible). + * + * When `options.before` is provided, returns messages with `seq < before`, + * taking the most recent ones first (DESC) up to `options.limit`, then + * reversing back to ASC before returning. + * + * When only `options.limit` is provided, returns the most recent `limit` + * messages, reversed back to ASC. */ -export function getMessagesForTab(tabId: string): MessageRow[] { +export function getMessagesForTab( + tabId: string, + options?: { limit?: number; before?: number }, +): MessageRow[] { const db = getDatabase(); - const rows = db - .query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC") - .all({ $tabId: tabId }) as Array<Record<string, unknown>>; - return rows.map((row) => { + + const mapRow = (row: Record<string, unknown>): MessageRow => { const rawJson = row.content_json as string; let chunks: Chunk[]; try { @@ -82,7 +92,60 @@ export function getMessagesForTab(tabId: string): MessageRow[] { chunks, createdAt: row.created_at as number, }; - }); + }; + + // Backward-compatible path: no options → ALL messages, seq ASC. + if (!options) { + const rows = db + .query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC") + .all({ $tabId: tabId }) as Array<Record<string, unknown>>; + return rows.map(mapRow); + } + + const { limit, before } = options; + + // Paginated path: fetch DESC, then reverse to ASC before returning. + if (before !== undefined) { + // `seq < before`, DESC, optionally limited. + if (limit !== undefined) { + const rows = db + .query( + "SELECT * FROM messages WHERE tab_id = $tabId AND seq < $before ORDER BY seq DESC LIMIT $limit", + ) + .all({ $tabId: tabId, $before: before, $limit: limit }) as Array<Record<string, unknown>>; + return rows.map(mapRow).reverse(); + } + const rows = db + .query("SELECT * FROM messages WHERE tab_id = $tabId AND seq < $before ORDER BY seq DESC") + .all({ $tabId: tabId, $before: before }) as Array<Record<string, unknown>>; + return rows.map(mapRow).reverse(); + } + + // Only `limit` provided: most recent `limit`, reversed to ASC. + if (limit !== undefined) { + const rows = db + .query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq DESC LIMIT $limit") + .all({ $tabId: tabId, $limit: limit }) as Array<Record<string, unknown>>; + return rows.map(mapRow).reverse(); + } + + // `options` was provided but empty → same as no options. + const rows = db + .query("SELECT * FROM messages WHERE tab_id = $tabId ORDER BY seq ASC") + .all({ $tabId: tabId }) as Array<Record<string, unknown>>; + return rows.map(mapRow); +} + +/** + * Return the total number of persisted messages for a tab. + * Used by the API to advertise total history size alongside a paginated window. + */ +export function getTotalMessageCount(tabId: string): number { + const db = getDatabase(); + const row = db + .query("SELECT COUNT(*) as count FROM messages WHERE tab_id = $tabId") + .get({ $tabId: tabId }) as { count: number } | null; + return row?.count ?? 0; } export function clearMessagesForTab(tabId: string): void { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 74cb159..e33ad2f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export { appendMessage, clearMessagesForTab, getMessagesForTab, + getTotalMessageCount, type MessageRow, updateMessage, } from "./db/messages.js"; diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte index 82bd4ee..8170d43 100644 --- a/packages/frontend/src/lib/components/ChatPanel.svelte +++ b/packages/frontend/src/lib/components/ChatPanel.svelte @@ -1,11 +1,12 @@ <script lang="ts"> -import { untrack } from "svelte"; +import { tick, untrack } from "svelte"; import { tabStore } from "../tabs.svelte.js"; import ChatMessageComponent from "./ChatMessage.svelte"; let messagesEl: HTMLDivElement | undefined; let userScrolledUp = $state(false); let isAutoScrolling = false; +let isLoadingMore = $state(false); const messages = $derived(tabStore.activeTab?.messages ?? []); const activeTabId = $derived(tabStore.activeTab?.id); @@ -22,14 +23,64 @@ function scrollToBottom(animate = false) { }); } +async function onNearTop() { + if (isLoadingMore) return; + const tab = tabStore.activeTab; + if (!tab) return; + // Nothing older to load if we're already at the very first message, or if + // we already hold every message the backend has for this tab. + if (tab.oldestLoadedSeq !== null && tab.oldestLoadedSeq <= 0) return; + + isLoadingMore = true; + const prevScrollHeight = messagesEl?.scrollHeight ?? 0; + const prevScrollTop = messagesEl?.scrollTop ?? 0; + try { + await tabStore.loadMoreMessages(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 + // correction would be computed against a stale height and the + // viewport would jump. `tick()` resolves once the DOM reflects the + // new message list. + await tick(); + if (messagesEl) { + const newScrollHeight = messagesEl.scrollHeight; + const delta = newScrollHeight - prevScrollHeight; + // Only adjust when content was actually prepended above the + // viewport. If nothing was added (all duplicates / nothing older), + // `delta` is 0 and we leave the user where they are instead of + // snapping to the top. + if (delta > 0) { + messagesEl.scrollTop = prevScrollTop + delta; + } + } + } finally { + isLoadingMore = false; + } +} + function handleScroll() { if (!messagesEl || isAutoScrolling) return; + const wasScrolledUp = userScrolledUp; userScrolledUp = !isNearBottom(messagesEl); + 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); + } + // Near the top — pull in older history. + if (userScrolledUp && messagesEl.scrollTop < 200) { + void onNearTop(); + } } function resumeAutoScroll() { userScrolledUp = false; isAutoScrolling = true; + if (activeTabId) { + tabStore.setScrolledUp(activeTabId, false); + tabStore.evictMessages(activeTabId); + } scrollToBottom(true); } @@ -42,6 +93,18 @@ $effect(() => { }); } }); + +$effect(() => { + const prevTabId = activeTabId; + // Reset scroll state when switching tabs + userScrolledUp = false; + isAutoScrolling = false; + return () => { + if (prevTabId) { + tabStore.setScrolledUp(prevTabId, false); + } + }; +}); </script> <div class="flex flex-col h-full"> @@ -55,6 +118,9 @@ $effect(() => { isAutoScrolling = false; }} > + {#if isLoadingMore} + <div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div> + {/if} {#if messages.length === 0} <div class="flex items-center justify-center h-full text-base-content/40 text-sm"> Send a message to start a conversation diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index eadfef8..392852a 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -16,9 +16,19 @@ let titleModelId = $state<string | null>(null); let availableModels = $state<string[]>([]); let loadingModels = $state(false); let autoExpandThinking = $state(appSettings.autoExpandThinking); +let localChunkLimit = $state(appSettings.chunkLimit); let backendUrl = $state(config.apiBase); let backendUrlSaved = $state(false); +function onChunkLimitChange(e: Event): void { + const input = e.target as HTMLInputElement; + const val = parseInt(input.value, 10); + if (val >= 10 && val <= 2000) { + appSettings.chunkLimit = val; + localChunkLimit = val; + } +} + function saveBackendUrl(): void { const trimmed = backendUrl.trim().replace(/\/+$/, ""); if (!trimmed) return; @@ -169,6 +179,25 @@ $effect(() => { <div class="divider my-0"></div> + <p class="text-xs text-base-content/70">Memory</p> + <label class="flex flex-col gap-1"> + <span class="text-xs text-base-content/70"> + Max chunks in memory: <span class="font-semibold">{localChunkLimit}</span> + </span> + <input + type="range" + min="20" + max="1000" + step="10" + class="range range-xs" + value={localChunkLimit} + oninput={onChunkLimitChange} + /> + <span class="text-[10px] text-base-content/40">Lower = less RAM. Higher = less re-fetching.</span> + </label> + + <div class="divider my-0"></div> + <p class="text-xs text-base-content/70">Backend URL</p> <p class="text-xs text-base-content/40">API server address. Default: {config.defaultApiBase}</p> <div class="flex gap-1"> diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts index 2828e7c..29aca5e 100644 --- a/packages/frontend/src/lib/settings.svelte.ts +++ b/packages/frontend/src/lib/settings.svelte.ts @@ -22,8 +22,15 @@ let savedToolPerms = $state<Record<string, boolean>>({ youtube_transcribe: false, }); let skillChecks = $state<Record<string, boolean>>({}); +let chunkLimit = $state(100); export const appSettings = { + get chunkLimit() { + return chunkLimit; + }, + set chunkLimit(v: number) { + chunkLimit = v; + }, get autoExpandThinking() { return autoExpandThinking; }, diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 326d288..b2c4480 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -34,6 +34,22 @@ function generateId(): string { }); } +/** + * Extract the smallest `seq` from a list of raw API message rows. The backend + * tags each persisted message with a monotonic `seq`; we track the oldest one + * currently loaded so `loadMoreMessages` can page backwards via `?before=seq`. + * Returns null when no row carries a usable seq. + */ +function oldestSeqOf(messages: Array<{ seq?: number }>): number | null { + let min: number | null = null; + for (const m of messages) { + if (typeof m.seq === "number" && (min === null || m.seq < min)) { + min = m.seq; + } + } + return min; +} + function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo { return { timestamp: new Date().toISOString(), @@ -67,6 +83,12 @@ export interface Tab { workingDirectory: string | null; /** Messages queued to be sent once the agent finishes its current run */ queuedMessages: QueuedMessage[]; + /** Max chunks to keep in memory before evicting oldest messages */ + chunkLimit: number; + /** Seq of the oldest message currently loaded, or null if unknown/none */ + oldestLoadedSeq: number | null; + /** Total number of messages for this tab on the backend */ + totalMessages: number; } /** @@ -91,6 +113,12 @@ export function createTabStore() { // Keyed by queueId — if consumed before we process the response, we skip the queued state. const recentlyConsumedIds = new Set<string>(); + // Tabs whose UI is currently scrolled up (viewing older history). While a + // tab is in this set, automatic eviction is suppressed so messages don't + // vanish out from under the user's viewport. ChatPanel toggles this via + // `setScrolledUp`. A `force` eviction ignores this set entirely. + const scrolledUpTabs = new Set<string>(); + // Clear any stale listeners from HMR reloads, then register wsClient.clearCallbacks(); wsClient.onEvent((event) => { @@ -144,6 +172,9 @@ export function createTabStore() { agentModels: null, workingDirectory: null, queuedMessages: [], + chunkLimit: appSettings.chunkLimit, + oldestLoadedSeq: null, + totalMessages: 0, }; tabs = [...tabs, tab]; activeTabId = id; @@ -191,7 +222,7 @@ export function createTabStore() { parentTabId?: string | null; }; - const messagesRes = await fetch(`${config.apiBase}/tabs/${agentId}/messages`); + const messagesRes = await fetch(`${config.apiBase}/tabs/${agentId}/messages?limit=100`); // The backend's `getMessagesForTab` (packages/core/src/db/messages.ts) // already parses `content_json` into a `Chunk[]` and serves it as // `chunks` over the wire — NOT the raw `contentJson` string. Earlier @@ -204,15 +235,18 @@ export function createTabStore() { id?: string; role: string; chunks?: Chunk[]; + seq?: number; }>; + total?: number; }) - : { messages: [] }; + : { messages: [], total: 0 }; const chatMessages: ChatMessage[] = messagesData.messages.map((m) => ({ id: m.id ?? generateId(), role: m.role as ChatMessage["role"], chunks: Array.isArray(m.chunks) ? m.chunks : [], isStreaming: false, + seq: m.seq, })); const newTab: Tab = { @@ -233,9 +267,13 @@ export function createTabStore() { agentModels: null, workingDirectory: null, queuedMessages: [], + chunkLimit: appSettings.chunkLimit, + oldestLoadedSeq: oldestSeqOf(messagesData.messages), + totalMessages: messagesData.total ?? messagesData.messages.length, }; tabs = [...tabs, newTab]; activeTabId = agentId; + evictMessages(agentId); } catch (err) { console.error("openAgentTab failed:", err); } @@ -272,6 +310,135 @@ export function createTabStore() { tabs = tabs.map((t) => (t.id === id ? { ...t, ...patch } : t)); } + /** + * Record whether a tab's chat view is scrolled up (viewing older history). + * Used to suppress automatic eviction while the user is reading old + * messages — we don't want to delete what they're currently looking at. + */ + function setScrolledUp(tabId: string, scrolledUp: boolean): void { + if (scrolledUp) scrolledUpTabs.add(tabId); + else scrolledUpTabs.delete(tabId); + } + + /** + * Trim a tab's in-memory message history down to its `chunkLimit`. + * + * Counts the total number of chunks across all messages and removes the + * oldest messages (from the front of the array) until the count is at or + * below the limit — but never drops below a coherent conversation bottom: + * - the in-flight streaming assistant message is always pinned; + * - the most recent user+assistant pair (last 2 messages) is always pinned. + * + * Evicted messages are fully removed from `tab.messages` — there is no + * stub or hidden cache; scrolling up re-fetches them via `loadMoreMessages`. + * + * Eviction is suppressed while the user is scrolled up (reading history), + * unless `force` is true. + */ + function evictMessages(tabId: string, force = false): void { + const tab = getTabById(tabId); + if (!tab) return; + if (!force && scrolledUpTabs.has(tabId)) return; + + const limit = appSettings.chunkLimit; + if (!Number.isFinite(limit) || limit <= 0) return; + + const countChunks = (msgs: ChatMessage[]) => msgs.reduce((sum, m) => sum + m.chunks.length, 0); + + // Work on a shallow copy we can shift from. + const working = [...tab.messages]; + let total = countChunks(working); + let removed = false; + + // `chunkLimit` is a SOFT target for trimming OLD history, not a hard + // cap. We always keep at least the latest user+assistant pair (the + // `working.length > 2` floor) so the view is never blank and an answer + // always has its preceding question on screen. Consequently a single + // very long turn (one message holding e.g. 150 chunks) can briefly push + // the in-memory total above `limit` — that is intentional and accepted. + // We never trim chunks from WITHIN a message: messages are the + // persistence/pagination unit (the backend stores whole rows keyed by + // `seq` and `loadMoreMessages` pages by whole messages via `?before=`), + // so a partially-trimmed message would just be re-fetched from the DB + // and bounce back, and each message's `role` would be lost in a flat + // chunk array. Whole-message eviction from the front is the correct + // granularity. + while (total > limit && working.length > 2) { + const candidate = working[0]; + if (!candidate) break; + // Never evict the in-flight streaming assistant message. + if (candidate.isStreaming || candidate.id === tab.currentAssistantId) break; + working.shift(); + total -= candidate.chunks.length; + removed = true; + } + + if (!removed) return; + // Recalculate oldestLoadedSeq from remaining messages after eviction so + // the `?before=` pagination cursor doesn't point at an evicted seq. + const remainingSeq = (working as Array<ChatMessage & { seq?: number }>).reduce<number | null>( + (min, m) => (typeof m.seq === "number" && (min === null || m.seq < min) ? m.seq : min), + null, + ); + updateTab(tabId, { + messages: working, + oldestLoadedSeq: remainingSeq ?? tab.oldestLoadedSeq, + }); + } + + /** + * Fetch and prepend the next page of older messages for a tab. Called when + * the user scrolls toward the top. Pages backwards using the oldest loaded + * `seq` (`?before=`). Does NOT trigger eviction — the user is reading + * history, so we keep everything they've pulled in. + */ + async function loadMoreMessages(tabId: string): Promise<void> { + const tab = getTabById(tabId); + if (!tab) return; + + const beforeParam = tab.oldestLoadedSeq !== null ? `&before=${tab.oldestLoadedSeq}` : ""; + try { + const res = await fetch(`${config.apiBase}/tabs/${tabId}/messages?limit=50${beforeParam}`); + if (!res.ok) return; + const data = (await res.json()) as { + messages?: Array<{ id?: string; role: string; chunks?: Chunk[]; seq?: number }>; + total?: number; + }; + const rawMessages = data.messages ?? []; + if (rawMessages.length === 0) { + // Nothing older to load; record the total if provided. + if (typeof data.total === "number") { + updateTab(tabId, { totalMessages: data.total }); + } + return; + } + + const older: ChatMessage[] = rawMessages.map((m) => ({ + id: m.id ?? generateId(), + role: m.role as ChatMessage["role"], + chunks: Array.isArray(m.chunks) ? m.chunks : [], + isStreaming: false, + seq: m.seq, + })); + + const current = getTabById(tabId); + if (!current) return; + + // Avoid duplicating messages we already have loaded. + const existingIds = new Set(current.messages.map((m) => m.id)); + const toPrepend = older.filter((m) => !existingIds.has(m.id)); + + const newOldestSeq = oldestSeqOf(rawMessages); + updateTab(tabId, { + messages: [...toPrepend, ...current.messages], + oldestLoadedSeq: newOldestSeq ?? current.oldestLoadedSeq, + totalMessages: data.total ?? current.totalMessages, + }); + } catch (err) { + console.warn("[loadMoreMessages] failed:", err); + } + } + function ensureAssistantMessage(tabId: string): ChatMessage | null { const tab = getTabById(tabId); if (!tab) return null; @@ -292,6 +459,7 @@ export function createTabStore() { currentAssistantId: id, messages: [...tab.messages, newMsg], }); + evictMessages(tabId); return newMsg; } @@ -380,21 +548,26 @@ export function createTabStore() { */ async function reloadTabMessagesFromApi(tabId: string): Promise<void> { try { - const res = await fetch(`${config.apiBase}/tabs/${tabId}/messages`); + const res = await fetch(`${config.apiBase}/tabs/${tabId}/messages?limit=100`); if (!res.ok) return; const data = (await res.json()) as { - messages: Array<{ id?: string; role: string; chunks?: Chunk[] }>; + messages: Array<{ id?: string; role: string; chunks?: Chunk[]; seq?: number }>; + total?: number; }; const reloaded: ChatMessage[] = data.messages.map((m) => ({ id: m.id ?? generateId(), role: m.role as ChatMessage["role"], chunks: Array.isArray(m.chunks) ? m.chunks : [], isStreaming: false, + seq: m.seq, })); updateTab(tabId, { messages: reloaded, currentAssistantId: null, + oldestLoadedSeq: oldestSeqOf(data.messages), + totalMessages: data.total ?? reloaded.length, }); + evictMessages(tabId); } catch (err) { console.warn("[reloadTabMessagesFromApi] failed:", err); } @@ -468,26 +641,39 @@ export function createTabStore() { // 3. For each tab, fetch its persisted messages in parallel. const messageFetches = tabRows.map(async (row) => { try { - const res = await fetch(`${config.apiBase}/tabs/${row.id}/messages`); - if (!res.ok) return { id: row.id, messages: [] as ChatMessage[] }; + const res = await fetch(`${config.apiBase}/tabs/${row.id}/messages?limit=100`); + if (!res.ok) + return { id: row.id, messages: [] as ChatMessage[], total: 0, oldestSeq: null }; const data = (await res.json()) as { - messages?: Array<{ id?: string; role: string; chunks?: Chunk[] }>; + messages?: Array<{ id?: string; role: string; chunks?: Chunk[]; seq?: number }>; + total?: number; }; - const messages: ChatMessage[] = (data.messages ?? []).map((m) => ({ + const rawMessages = data.messages ?? []; + const messages: ChatMessage[] = rawMessages.map((m) => ({ id: m.id ?? generateId(), role: m.role as ChatMessage["role"], chunks: Array.isArray(m.chunks) ? m.chunks : [], isStreaming: false, + seq: m.seq, })); - return { id: row.id, messages }; + return { + id: row.id, + messages, + total: data.total ?? messages.length, + oldestSeq: oldestSeqOf(rawMessages), + }; } catch { - return { id: row.id, messages: [] as ChatMessage[] }; + return { id: row.id, messages: [] as ChatMessage[], total: 0, oldestSeq: null }; } }); const messagesByTab = new Map<string, ChatMessage[]>(); + const totalByTab = new Map<string, number>(); + const oldestSeqByTab = new Map<string, number | null>(); for (const result of await Promise.all(messageFetches)) { messagesByTab.set(result.id, result.messages); + totalByTab.set(result.id, result.total); + oldestSeqByTab.set(result.id, result.oldestSeq); } // 4. Build the Tab objects, splicing in the in-flight snapshot for @@ -551,10 +737,17 @@ export function createTabStore() { agentModels: null, workingDirectory: null, queuedMessages: [], + chunkLimit: appSettings.chunkLimit, + oldestLoadedSeq: oldestSeqByTab.get(row.id) ?? null, + totalMessages: totalByTab.get(row.id) ?? finalMessages.length, }; }); tabs = restored; + // Trim each restored tab down to the chunk limit (user starts at bottom). + for (const t of restored) { + evictMessages(t.id); + } // Activate the first restored tab (the list is already ordered by // `position` from the backend). activeTabId = restored[0]?.id ?? null; @@ -796,6 +989,9 @@ export function createTabStore() { agentModels: null, workingDirectory: newTabEvent.workingDirectory ?? null, queuedMessages: [], + chunkLimit: appSettings.chunkLimit, + oldestLoadedSeq: null, + totalMessages: 0, }; tabs = [...tabs, tab]; } @@ -1503,6 +1699,9 @@ export function createTabStore() { // components — they should rely on the WS subscription instead. handleEvent, hydrateFromBackend, + loadMoreMessages, + evictMessages, + setScrolledUp, }; } diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 22837a0..f1ae5c4 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -72,6 +72,7 @@ export interface ChatMessage { chunks: Chunk[]; isStreaming?: boolean; debugInfo?: DebugInfo; + seq?: number; } export type ConnectionStatus = "connecting" | "connected" | "disconnected"; diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index 41adb84..bafd79e 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -702,7 +702,7 @@ describe("hydrateFromBackend", () => { json: () => Promise.resolve({ statuses: {} }), }); } - if (url.endsWith("/tabs/t1/messages")) { + if (url.split("?")[0]?.endsWith("/tabs/t1/messages")) { return Promise.resolve({ ok: true, json: () => @@ -718,7 +718,7 @@ describe("hydrateFromBackend", () => { }), }); } - if (url.endsWith("/tabs/t2/messages")) { + if (url.split("?")[0]?.endsWith("/tabs/t2/messages")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }), @@ -772,7 +772,7 @@ describe("hydrateFromBackend", () => { }), }); } - if (url.endsWith("/tabs/tr/messages")) { + if (url.split("?")[0]?.endsWith("/tabs/tr/messages")) { return Promise.resolve({ ok: true, json: () => @@ -882,7 +882,7 @@ describe("hydrateFromBackend", () => { if (url.endsWith("/status")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); } - if (url.endsWith("/tabs/ti/messages")) { + if (url.split("?")[0]?.endsWith("/tabs/ti/messages")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) }); } return Promise.reject(new Error(`unexpected fetch ${url}`)); @@ -939,7 +939,7 @@ describe("hydrateFromBackend", () => { if (url.endsWith("/status")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) }); } - if (url.endsWith("/tabs/tA/messages")) { + if (url.split("?")[0]?.endsWith("/tabs/tA/messages")) { return Promise.resolve({ ok: true, json: () => @@ -948,11 +948,11 @@ describe("hydrateFromBackend", () => { }), }); } - if (url.endsWith("/tabs/tB/messages")) { + if (url.split("?")[0]?.endsWith("/tabs/tB/messages")) { // HTTP error path: response is not ok. return Promise.resolve({ ok: false, json: () => Promise.resolve({}) }); } - if (url.endsWith("/tabs/tC/messages")) { + if (url.split("?")[0]?.endsWith("/tabs/tC/messages")) { // Network error path: the fetch itself rejects. return Promise.reject(new Error("simulated network failure")); } |
