diff options
| -rw-r--r-- | src/app/store.svelte.ts | 56 | ||||
| -rw-r--r-- | src/app/store.test.ts | 255 |
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(); + } + }); +}); |
