summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/store.svelte.ts56
-rw-r--r--src/app/store.test.ts255
2 files changed, 296 insertions, 15 deletions
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index fafd4d7..d75495a 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -903,6 +903,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const metricsSync = createMetricsSync(httpBase, fetchImpl);
const chatStores = new Map<string, ChatStore>();
+ // Includes in-flight loads: repeated activation must not start a second load.
+ const loadedConversationIds = new Set<string>();
// Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a
// watch on a conversation's live turn stream WITHOUT opening a tab. Separate
@@ -1141,7 +1143,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
return chatStores.get(activeId) ?? draftStore;
}
+ function ensureLoaded(conversationId: string): void {
+ if (loadedConversationIds.has(conversationId)) return;
+ const store = chatStores.get(conversationId) ?? watchStores.get(conversationId);
+ if (store === undefined) return;
+ loadedConversationIds.add(conversationId);
+ void store.load();
+ }
+
function refreshActiveChat(): void {
+ const activeId = tabsStore.activeConversationId;
+ if (activeId !== null) ensureLoaded(activeId);
activeChat = getActiveChat();
}
@@ -1199,12 +1211,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
function watchConversation(conversationId: string): ChatStore {
// An open tab already has a live store + subscription — reuse it.
const tabStore = chatStores.get(conversationId);
- if (tabStore !== undefined) return tabStore;
+ if (tabStore !== undefined) {
+ ensureLoaded(conversationId);
+ return tabStore;
+ }
const existing = watchStores.get(conversationId);
if (existing !== undefined) return existing;
const store = createChatFor(conversationId, activeModel, activeWorkspaceId);
watchStores.set(conversationId, store);
- void store.load();
+ ensureLoaded(conversationId);
subscribeChat(conversationId);
return store;
}
@@ -1222,6 +1237,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (store === undefined) return;
store.dispose();
watchStores.delete(conversationId);
+ loadedConversationIds.delete(conversationId);
unsubscribeChat(conversationId);
}
@@ -1355,7 +1371,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
* Open a conversation tab — used by the `conversation.open` WS broadcast
* (CLI `--open` flag) and by `conversation.statusChanged` when a new active
* conversation is discovered. If the conversation is already open, this is a
- * no-op; otherwise create a chat store, load its history, subscribe to its live
+ * no-op; otherwise create a chat store, subscribe to its live
* turns, and add the tab WITHOUT switching the active conversation (the user
* stays on their current tab; the new tab appears in the strip). The tab is
* stamped with the conversation's actual `workspaceId`, NOT the viewer's
@@ -1373,9 +1389,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// never let the globally-active model overwrite a conversation's real one
// (fix-toolcall-duplicate-keys plan Part 4).
const tabModel = model ?? activeModel;
- const store = createChatFor(conversationId, tabModel, workspaceId);
+ const store =
+ watchStores.get(conversationId) ?? createChatFor(conversationId, tabModel, workspaceId);
+ watchStores.delete(conversationId);
+ store.setModel(tabModel);
chatStores.set(conversationId, store);
- void store.load();
subscribeChat(conversationId);
tabsStore.openTab({
conversationId,
@@ -1397,6 +1415,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
store.dispose();
chatStores.delete(conversationId);
}
+ loadedConversationIds.delete(conversationId);
void cache.delete(conversationId);
tabsStore.closeTab(conversationId);
conversationStatuses.delete(conversationId);
@@ -1472,11 +1491,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// tabs showing/overwriting the wrong model
// (fix-toolcall-duplicate-keys plan Part 4).
const model = conv.model ?? activeModel;
- const store = createChatFor(conv.id, model, conv.workspaceId);
+ const store = watchStores.get(conv.id) ?? createChatFor(conv.id, model, conv.workspaceId);
+ watchStores.delete(conv.id);
+ store.setModel(model);
chatStores.set(conv.id, store);
// Seed the cache timer's turn gap (only an idle conversation is waiting on the user).
if (conv.status === "idle") store.noteLastActivity(conv.lastActivityAt);
- void store.load();
subscribeChat(conv.id);
tabsStore.openTab({
conversationId: conv.id,
@@ -1605,14 +1625,20 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// history (dispose stale store + cache + re-fetch).
const cid = msg.conversationId;
const wasActive = tabsStore.activeConversationId === cid;
+ void cache.delete(cid);
+ if (!chatStores.has(cid) || !loadedConversationIds.delete(cid)) return;
const store = chatStores.get(cid);
if (store !== undefined) {
store.dispose();
}
- void cache.delete(cid);
- const fresh = createChatFor(cid, activeModel, activeWorkspaceId);
+ const tab = tabsStore.tabs.find((t) => t.conversationId === cid);
+ const fresh = createChatFor(
+ cid,
+ tab?.model ?? activeModel,
+ tab?.workspaceId ?? activeWorkspaceId,
+ );
chatStores.set(cid, fresh);
- void fresh.load();
+ ensureLoaded(cid);
if (wasActive) {
refreshActiveChat();
}
@@ -1636,15 +1662,18 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// running while we were disconnected resumes streaming (server replays it
// from `turn-start`); one that sealed while we were gone is committed from
// history by `resync()` (which also clears a now-stale "generating").
+ // Unloaded tabs stay lazy — only their cheap live subscription is restored.
for (const tab of tabsStore.tabs) {
subscribeChat(tab.conversationId);
- chatStores.get(tab.conversationId)?.resync();
+ if (loadedConversationIds.has(tab.conversationId)) {
+ chatStores.get(tab.conversationId)?.resync();
+ }
}
// Re-attach to every MODAL watch too (a run-chat modal open across a
// reconnect keeps streaming). Watch stores are separate from tabs.
for (const [watchId, watchStore] of watchStores) {
subscribeChat(watchId);
- watchStore.resync();
+ if (loadedConversationIds.has(watchId)) watchStore.resync();
}
},
onExtensionsChanged(_msg: ExtensionsChangedMessage) {
@@ -1753,7 +1782,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
const store = createChatFor(tab.conversationId, model, tab.workspaceId);
chatStores.set(tab.conversationId, store);
- void store.load();
// Watch each restored conversation's live turns: after a reload mid-turn the
// server replays the in-flight turn so we keep rendering it. Queued until the
// socket opens.
@@ -1919,7 +1947,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
...(snapshot ? { snapshot } : {}),
});
chatStores.set(conversationId, draftStore);
- void draftStore.load();
// Prepare next draft
const nextDraftId = randomId();
@@ -3048,6 +3075,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
store.dispose();
}
watchStores.clear();
+ loadedConversationIds.clear();
draftStore.dispose();
socket?.close();
socket = null;
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 675fa64..bfd4039 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1,6 +1,7 @@
import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
import type { NamedAgent } from "@dispatch/wire";
+import { IDBFactory } from "fake-indexeddb";
import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import { createAppStore } from "./store.svelte";
@@ -1484,7 +1485,7 @@ describe("createAppStore", () => {
store.dispose();
});
- it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
+ it("re-subscribes and resyncs a loaded conversation on reconnect", async () => {
const fetchedUrls: string[] = [];
const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
@@ -2580,3 +2581,255 @@ describe("createAppStore — Named Agents", () => {
store.dispose();
});
});
+
+describe("conversation load-on-demand", () => {
+ async function setup(
+ ws: WebSocketLike,
+ count = 3,
+ activeConversationId: string | null = "lazy-0",
+ ) {
+ const storage = createFakeStorage();
+ const conversations = Array.from({ length: count }, (_, i) => ({
+ id: `lazy-${i}`,
+ title: `Conversation ${i}`,
+ model: "openai/gpt-4o",
+ workspaceId: "default",
+ status: i === 1 ? "active" : "idle",
+ lastActivityAt: 1234,
+ }));
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: conversations.slice(0, Math.ceil(count / 2)).map((c) => ({
+ conversationId: c.id,
+ title: c.title,
+ model: c.model,
+ workspaceId: c.workspaceId,
+ })),
+ activeConversationId,
+ }),
+ );
+ const base = fakeFetchImpl();
+ const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+ const url = String(input);
+ if (url.includes("/conversations?status=")) {
+ return Response.json({ conversations });
+ }
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json({
+ chunks: [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ ],
+ latestSeq: 2,
+ });
+ }
+ if (url.endsWith("/metrics")) return Response.json({ turns: [] });
+ return base(input, init);
+ });
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ chatFlushScheduler: null,
+ });
+ const requests = () =>
+ fetchImpl.mock.calls
+ .map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search)
+ .filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url));
+ await vi.waitFor(() => expect(store.tabs).toHaveLength(count));
+ if (activeConversationId !== null) {
+ await vi.waitFor(() =>
+ expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`),
+ );
+ }
+ return { store, requests };
+ }
+
+ it("boots 300 persisted/discovered tabs with only active history + metrics", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws, 300);
+ try {
+ ws.resolveOpen();
+ expect(requests()).toEqual([
+ "/conversations/lazy-0?sinceSeq=0&limit=192",
+ "/conversations/lazy-0/metrics",
+ ]);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(300);
+ expect(store.tabs[299]?.title).toBe("Conversation 299");
+ expect(store.conversationStatus("lazy-1")).toBe("active");
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "lazy-1",
+ workspaceId: "default",
+ status: "queued",
+ });
+ expect(store.conversationStatus("lazy-1")).toBe("queued");
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("does not load any history while the active slot is a draft", async () => {
+ const { store, requests } = await setup(fakeSocket(), 300, null);
+ try {
+ expect(requests()).toEqual([]);
+ store.selectTab("lazy-0");
+ await vi.waitFor(() => expect(requests()).toContain("/conversations/lazy-0/metrics"));
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("loads a tab once, including repeated activation while its load is pending", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ store.selectTab("lazy-2");
+ store.selectTab("lazy-0");
+ store.selectTab("lazy-2");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2);
+ expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("re-subscribes all tabs on reconnect but resyncs only loaded tabs and watches", async () => {
+ const ws = reconnectableSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.open();
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.sent.length = 0;
+ const before = requests().length;
+ ws.closeRemote();
+ await new Promise((r) => setTimeout(r, 800));
+ ws.open();
+ await vi.waitFor(() => expect(requests()).toHaveLength(before + 4));
+ expect(
+ requests()
+ .slice(before)
+ .every((u) => /lazy-(0|watch)[/?]/.test(u)),
+ ).toBe(true);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(4);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("loads watches, including unloaded tabs, and loads disposed watches again", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ const watch = store.watchConversation("lazy-1");
+ store.watchConversation("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(watch.chunks).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(requests()).toHaveLength(4);
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ store.unwatchConversation("lazy-watch");
+ const reopened = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(8));
+ expect(reopened.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("merges pre-load live deltas with history without duplicate bubbles", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "lazy-1", turnId: "t1", text: "hi" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "t1", delta: "hello" },
+ });
+ expect(requests()).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(store.activeChat.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "hi" },
+ { type: "text", text: "hello" },
+ ]);
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ expect(store.activeChat.chunks.every((c) => !c.provisional)).toBe(true);
+ expect(store.activeChat.generating).toBe(true);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("compaction reloads loaded stores but does not load unseen conversations", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ for (const id of ["lazy-1", "unknown", "lazy-0"]) {
+ ws.feedServerMessage({
+ type: "conversation.compacted",
+ conversationId: id,
+ newConversationId: `${id}-archive`,
+ messagesSummarized: 10,
+ messagesKept: 2,
+ });
+ }
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(requests().every((u) => u.includes("lazy-0"))).toBe(true);
+ store.selectTab("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("keeps a watched store loaded when it becomes a tab, and reloads a closed tab", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ const watch = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ store.selectTab("lazy-watch");
+ expect(store.activeChat.chunks).toEqual(watch.chunks);
+ store.unwatchConversation("lazy-watch");
+ expect(requests()).toHaveLength(4);
+ store.newDraft(); // Closing a background tab must not activate/load its neighbor.
+ store.closeTab("lazy-watch");
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ expect(requests()).toHaveLength(4);
+ store.selectTab("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ } finally {
+ store.dispose();
+ }
+ });
+});