summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-29 13:59:17 +0900
committerAdam Malczewski <[email protected]>2026-05-29 13:59:17 +0900
commit520c9e30cc58b40d3b1ee9e7895f003c4f873206 (patch)
tree7a10da94141d754dca25e728f4a388057a5d5981
parent0954c6520878e073c7822fc4a8f4a752474f5d7a (diff)
downloaddispatch-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.ts17
-rw-r--r--packages/core/src/db/messages.ts77
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte68
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte29
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts7
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts219
-rw-r--r--packages/frontend/src/lib/types.ts1
-rw-r--r--packages/frontend/tests/chat-store.test.ts14
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"));
}