diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.test.ts | 1 | ||||
| -rw-r--r-- | src/app/chat-registry.svelte.ts | 59 | ||||
| -rw-r--r-- | src/app/open-conversations.svelte.ts | 16 | ||||
| -rw-r--r-- | src/app/store.test.ts | 87 | ||||
| -rw-r--r-- | src/app/subscription-budget.test.ts | 20 | ||||
| -rw-r--r-- | src/app/subscription-budget.ts | 15 | ||||
| -rw-r--r-- | src/app/surface-protocol.svelte.ts | 30 | ||||
| -rw-r--r-- | src/app/surface-protocol.test.ts | 31 | ||||
| -rw-r--r-- | src/app/views/ChatPane.svelte | 5 | ||||
| -rw-r--r-- | src/app/ws-handlers.ts | 37 |
10 files changed, 245 insertions, 56 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 62798d6..42d30ed 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -470,6 +470,7 @@ describe("App component interaction tests", () => { expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([ { type: "chat.queue", + requestId: expect.any(String), conversationId, workspaceId: "default", text, diff --git a/src/app/chat-registry.svelte.ts b/src/app/chat-registry.svelte.ts index a9b28ff..63f58ad 100644 --- a/src/app/chat-registry.svelte.ts +++ b/src/app/chat-registry.svelte.ts @@ -3,10 +3,11 @@ import { createLocalStore } from "../adapters/local-storage"; import type { SurfaceSocketHandle } from "../adapters/ws"; import { normalizeChatLimit } from "../core/chunks"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; -import { createChatStore } from "../features/chat"; +import { createChatStore, createPendingInputStorage } from "../features/chat"; import type { ConversationCache } from "../features/conversation-cache"; import type { TabsStore } from "../features/tabs"; import type { ChatLimitResult, CreateAppStoreOptions } from "./store-types"; +import { liveConversationIds } from "./subscription-budget"; import { randomId } from "./uuid"; /** App-owned tab/watch stores and idempotent transcript loading; no singleton state. */ @@ -46,6 +47,7 @@ export function createChatRegistry(deps: { const chatStores = new Map<string, ChatStore>(); // Includes in-flight loads: repeated activation must not start a second load. const loadedConversationIds = new Set<string>(); + const loadingConversationIds = 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 @@ -53,6 +55,26 @@ export function createChatRegistry(deps: { // and a tab's conversation reuses its own store (see `watchConversation`). // Deltas are routed here in addition to `chatStores`. const watchStores = new Map<string, ChatStore>(); + const watchedIds = new Set<string>(); + const subscribedIds = new Set<string>(); + + function syncChatSubscriptions(): void { + const desired = liveConversationIds(deps.tabsStore.activeConversationId, watchedIds); + for (const id of subscribedIds) { + if (!desired.has(id)) { + deps.send({ type: "chat.unsubscribe", conversationId: id }); + subscribedIds.delete(id); + } + } + for (const id of desired) { + if (subscribedIds.has(id)) continue; + subscribedIds.add(id); + deps.send({ type: "chat.subscribe", conversationId: id }); + if (loadedConversationIds.has(id) && !loadingConversationIds.has(id)) { + (chatStores.get(id) ?? watchStores.get(id))?.resync(); + } + } + } /** * Coalesce live chat events to at most one fold + render per animation @@ -77,6 +99,11 @@ export function createChatRegistry(deps: { function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { return createChatStore({ + ...(deps.storage === undefined + ? {} + : { + pendingInputStorage: createPendingInputStorage(deps.storage, conversationId), + }), conversationId, createRequestId: randomId, model, @@ -128,30 +155,28 @@ export function createChatRegistry(deps: { const store = chatStores.get(conversationId) ?? watchStores.get(conversationId); if (store === undefined) return; loadedConversationIds.add(conversationId); - void store.load(); + loadingConversationIds.add(conversationId); + void store.load().finally(() => loadingConversationIds.delete(conversationId)); } function refreshActiveChat(): void { + syncChatSubscriptions(); const activeId = deps.tabsStore.activeConversationId; if (activeId !== null) ensureLoaded(activeId); activeChat = getActiveChat(); } - /** - * Start watching a conversation's live turn events (`chat.subscribe`). Sent for - * EVERY open conversation — not just the active one — so a backgrounded tab keeps - * streaming a running turn, and a reloaded/second client re-attaches to an - * in-flight turn (the server replays it from `turn-start`). Idempotent server-side; - * the socket queues it until the connection is open. NOT needed right after - * `chat.send` (that auto-subscribes the sending connection). - */ + /** Discovery creates metadata; only the focused chat and explicit viewers watch live. */ function subscribeChat(conversationId: string): void { - deps.send({ type: "chat.subscribe", conversationId }); + void conversationId; + syncChatSubscriptions(); } /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ function unsubscribeChat(conversationId: string): void { - deps.send({ type: "chat.unsubscribe", conversationId }); + if (subscribedIds.delete(conversationId)) { + deps.send({ type: "chat.unsubscribe", conversationId }); + } } /** @@ -164,9 +189,11 @@ export function createChatRegistry(deps: { * Pair with {@link unwatchConversation} on close. */ function watchConversation(conversationId: string): ChatStore { + watchedIds.add(conversationId); // An open tab already has a live store + subscription — reuse it. const tabStore = chatStores.get(conversationId); if (tabStore !== undefined) { + syncChatSubscriptions(); ensureLoaded(conversationId); return tabStore; } @@ -174,8 +201,8 @@ export function createChatRegistry(deps: { if (existing !== undefined) return existing; const store = createChatFor(conversationId, deps.activeModel(), deps.activeWorkspaceId()); watchStores.set(conversationId, store); - ensureLoaded(conversationId); subscribeChat(conversationId); + ensureLoaded(conversationId); return store; } @@ -186,6 +213,8 @@ export function createChatRegistry(deps: { * tab strip). Only the ephemeral watch store is disposed + unsubscribed. */ function unwatchConversation(conversationId: string): void { + watchedIds.delete(conversationId); + syncChatSubscriptions(); // A tab reuses its own store — leave it (and its subscription) intact. if (chatStores.has(conversationId)) return; const store = watchStores.get(conversationId); @@ -207,6 +236,10 @@ export function createChatRegistry(deps: { refreshActiveChat, subscribeChat, unsubscribeChat, + reconnect(): void { + subscribedIds.clear(); + syncChatSubscriptions(); + }, watchConversation, unwatchConversation, get draftStore() { diff --git a/src/app/open-conversations.svelte.ts b/src/app/open-conversations.svelte.ts index d97e999..9c8c25b 100644 --- a/src/app/open-conversations.svelte.ts +++ b/src/app/open-conversations.svelte.ts @@ -130,7 +130,15 @@ export function createOpenConversations(deps: { * (closed on another device), and subscribes to `active` conversations' * live streams. */ - async function fetchOpenConversations(): Promise<void> { + let pendingFetch: Promise<void> | undefined; + function fetchOpenConversations(): Promise<void> { + pendingFetch ??= loadOpenConversations().finally(() => { + pendingFetch = undefined; + }); + return pendingFetch; + } + + async function loadOpenConversations(): Promise<void> { try { const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); if (!res.ok) return; @@ -203,10 +211,14 @@ export function createOpenConversations(deps: { // Remove tabs for conversations no longer active/idle (closed elsewhere). const backendIds = new Set(data.conversations.map((c) => c.id)); for (const tab of tabsStore.tabs) { - if (!backendIds.has(tab.conversationId)) { + if ( + !backendIds.has(tab.conversationId) && + !registry.chatStores.get(tab.conversationId)?.pendingInputs.length + ) { removeTabLocally(tab.conversationId); } } + registry.refreshActiveChat(); } catch (err) { deps.reportError( `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 33f93bb..90c11b3 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -270,8 +270,8 @@ function createFakeStorage(): Storage { getItem(key: string): string | null { return map.get(key) ?? null; }, - key(_index: number): string | null { - return null; + key(index: number): string | null { + return [...map.keys()][index] ?? null; }, removeItem(key: string) { map.delete(key); @@ -2711,13 +2711,14 @@ describe("conversation load-on-demand", () => { ws: WebSocketLike, count = 3, activeConversationId: string | null = "lazy-0", + workspaceCount = 1, ) { const storage = createFakeStorage(); const conversations = Array.from({ length: count }, (_, i) => ({ id: `lazy-${i}`, title: `Conversation ${i}`, model: "openai/gpt-4o", - workspaceId: "default", + workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`, status: i === 1 ? "active" : "idle", lastActivityAt: 1234, })); @@ -2763,7 +2764,7 @@ describe("conversation load-on-demand", () => { 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)); + await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount))); if (activeConversationId !== null) { await vi.waitFor(() => expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`), @@ -2785,7 +2786,7 @@ describe("conversation load-on-demand", () => { ]); expect( parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), - ).toHaveLength(300); + ).toHaveLength(1); expect(store.tabs[299]?.title).toBe("Conversation 299"); expect(store.conversationStatus("lazy-1")).toBe("active"); ws.feedServerMessage({ @@ -2801,6 +2802,24 @@ describe("conversation load-on-demand", () => { } }, 20_000); + it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => { + const ws = fakeSocket(); + const { store } = await setup(ws, 339, "lazy-0", 24); + try { + ws.resolveOpen(); + const subscriptions = parseSent(ws).filter( + (message) => (message as { type: string }).type === "chat.subscribe", + ); + expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]); + store.setActiveWorkspace("workspace-1"); + store.selectTab("lazy-1"); + expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" }); + expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" }); + } finally { + store.dispose(); + } + }, 20_000); + it("does not load any history while the active slot is a draft", async () => { const { store, requests } = await setup(fakeSocket(), 300, null); try { @@ -2813,13 +2832,13 @@ describe("conversation load-on-demand", () => { } }, 20_000); - it("loads a tab once, including repeated activation while its load is pending", async () => { + it("loads a tab once while pending and catches up a previously loaded tab on reactivation", 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)); + await vi.waitFor(() => expect(requests()).toHaveLength(6)); expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2); expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234); } finally { @@ -2827,7 +2846,7 @@ describe("conversation load-on-demand", () => { } }); - it("re-subscribes all tabs on reconnect but resyncs only loaded tabs and watches", async () => { + it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => { const ws = reconnectableSocket(); const { store, requests } = await setup(ws); try { @@ -2847,7 +2866,7 @@ describe("conversation load-on-demand", () => { ).toBe(true); expect( parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), - ).toHaveLength(4); + ).toHaveLength(2); } finally { store.dispose(); } @@ -3129,3 +3148,53 @@ it("routes omitted replay only to the matching watch/tab without WS resend or an store.dispose(); } }); + +describe("unconfirmed first-message recovery", () => { + it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => { + const storage = createFakeStorage(); + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + if (String(input).endsWith("/conversations?status=active,idle")) + return Response.json({ conversations: [] }); + return base(input, init); + }; + const ws = fakeSocket(); + const first = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => ws, + }); + ws.resolveOpen(); + first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]); + const id = first.activeConversationId; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(first.activeChat.pendingInputs).toHaveLength(1); + first.dispose(); + const nextSocket = fakeSocket(); + const next = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => nextSocket, + }); + try { + nextSocket.resolveOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true); + expect(next.activeChat.pendingInputs).toMatchObject([ + { request: { message: "must survive" } }, + ]); + const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? ""; + nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId }); + expect(next.activeChat.pendingInputs).toEqual([]); + expect( + parseSent(nextSocket).some((message) => + ["chat.send", "chat.queue"].includes((message as { type: string }).type), + ), + ).toBe(false); + } finally { + next.dispose(); + } + }); +}); diff --git a/src/app/subscription-budget.test.ts b/src/app/subscription-budget.test.ts new file mode 100644 index 0000000..03cdaa1 --- /dev/null +++ b/src/app/subscription-budget.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + liveConversationIds, + MAX_CHAT_SUBSCRIPTIONS, + MAX_SURFACE_SUBSCRIPTIONS, +} from "./subscription-budget"; + +describe("live subscription budget", () => { + it("reserves the focused conversation even when hundreds of viewers exist", () => { + const viewers = Array.from({ length: 339 }, (_, i) => `viewer-${i}`); + const selected = liveConversationIds("focused", viewers); + expect(selected.size).toBe(MAX_CHAT_SUBSCRIPTIONS); + expect(selected.has("focused")).toBe(true); + expect(selected.size + MAX_SURFACE_SUBSCRIPTIONS).toBeLessThanOrEqual(256); + }); + it("deduplicates a modal viewing the focused chat and permits drafts", () => { + expect([...liveConversationIds("same", ["same", "other"])]).toEqual(["same", "other"]); + expect([...liveConversationIds(null, [])]).toEqual([]); + }); +}); diff --git a/src/app/subscription-budget.ts b/src/app/subscription-budget.ts new file mode 100644 index 0000000..fc0ccb2 --- /dev/null +++ b/src/app/subscription-budget.ts @@ -0,0 +1,15 @@ +/** Split the server's 256 shared slots so catalog growth cannot evict chat watchers. */ +export const MAX_CHAT_SUBSCRIPTIONS = 128; +export const MAX_SURFACE_SUBSCRIPTIONS = 128; + +export function liveConversationIds( + activeId: string | null, + watchedIds: Iterable<string>, +): Set<string> { + return new Set( + [...new Set([...(activeId === null ? [] : [activeId]), ...watchedIds])].slice( + 0, + MAX_CHAT_SUBSCRIPTIONS, + ), + ); +} diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts index 400f612..c79c8b6 100644 --- a/src/app/surface-protocol.svelte.ts +++ b/src/app/surface-protocol.svelte.ts @@ -9,6 +9,7 @@ import { subscribe as protocolSubscribe, unsubscribe as protocolUnsubscribe, } from "../core/protocol"; +import { MAX_SURFACE_SUBSCRIPTIONS } from "./subscription-budget"; /** Shared app/plugin surface operations over the single app protocol state. */ export function createSurfaceProtocol(deps: { @@ -43,7 +44,19 @@ export function createSurfaceProtocol(deps: { */ function syncSubscriptions(): void { const cid = deps.focusedConversationId(); - for (const entry of protocol.catalog) { + const catalogIds = new Set( + protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS).map((e) => e.id), + ); + for (const id of [...protocol.subscriptions.keys()]) { + if (!catalogIds.has(id)) { + const result = protocolUnsubscribe(protocol, id); + protocol = result.state; + for (const msg of result.outgoing) { + deps.send(msg); + } + } + } + for (const entry of protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS)) { // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id // so a conversation switch doesn't churn a redundant unsubscribe+subscribe // round trip ([email protected] catalog `scope`; ABSENT = assume @@ -55,16 +68,6 @@ export function createSurfaceProtocol(deps: { deps.send(msg); } } - const catalogIds = new Set(protocol.catalog.map((e) => e.id)); - for (const id of [...protocol.subscriptions.keys()]) { - if (!catalogIds.has(id)) { - const result = protocolUnsubscribe(protocol, id); - protocol = result.state; - for (const msg of result.outgoing) { - deps.send(msg); - } - } - } receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id))); } @@ -93,6 +96,11 @@ export function createSurfaceProtocol(deps: { } function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + if ( + !protocol.subscriptions.has(surfaceId) && + protocol.subscriptions.size >= MAX_SURFACE_SUBSCRIPTIONS + ) + return; const result = protocolSubscribe( protocol, surfaceId, diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts index 5ac4f70..405e561 100644 --- a/src/app/surface-protocol.test.ts +++ b/src/app/surface-protocol.test.ts @@ -63,3 +63,34 @@ describe("conversation-scoped surface lookup", () => { expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull(); }); }); + +describe("surface subscription budget", () => { + it("bounds a large catalog and releases old slots before subscribing a replacement catalog", () => { + const live = new Set<string>(); + let peak = 0; + const surfaces = createSurfaceProtocol({ + focusedConversationId: () => "chat", + send(message) { + if (message.type === "subscribe") live.add(message.surfaceId); + if (message.type === "unsubscribe") live.delete(message.surfaceId); + peak = Math.max(peak, live.size); + }, + }); + for (const prefix of ["old", "new"]) { + surfaces.handleServerMessage({ + type: "catalog", + catalog: Array.from({ length: 339 }, (_, i) => ({ + id: `${prefix}-${i}`, + region: "inline", + title: "Surface", + scope: "global" as const, + })), + }); + expect(live.size).toBe(128); + expect([...live].every((id) => id.startsWith(prefix))).toBe(true); + } + surfaces.subscribeSurfaceOp("overflow"); + expect(live.has("overflow")).toBe(false); + expect(peak).toBe(128); + }); +}); diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte index 62b9344..d132fad 100644 --- a/src/app/views/ChatPane.svelte +++ b/src/app/views/ChatPane.svelte @@ -7,7 +7,7 @@ * preservation, the ScrollToBottom affordance, and the empty-state * watermark. Keyed on the active conversation exactly like today's ChatView. */ - import { ChatView, isClaudeSubscriptionModel } from "../../features/chat"; + import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat"; import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll"; import { preserveViewportAcross, type AppViewPorts } from "../view-ports"; @@ -91,9 +91,10 @@ {cacheTimer} /> {/key} + <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} /> </div> </div> - {#if store.activeChat.chunks.length === 0} + {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0} <div class="pointer-events-none absolute inset-0 flex items-center justify-center" aria-hidden="true" diff --git a/src/app/ws-handlers.ts b/src/app/ws-handlers.ts index b9adb30..e4fd916 100644 --- a/src/app/ws-handlers.ts +++ b/src/app/ws-handlers.ts @@ -1,8 +1,10 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, ChatQueuedMessage, ChatResyncRequiredMessage, + ChatSubscriptionRejectedMessage, ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, @@ -55,7 +57,13 @@ export function createWsHandlers(deps: { tileLayoutStore, } = deps; function handleChatMessage( - msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage, + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, ): void { let targetId: string | undefined; if (msg.type === "chat.delta") { @@ -72,7 +80,13 @@ export function createWsHandlers(deps: { } } - if (msg.type === "chat.queued" || msg.type === "chat.resync-required") return; + if ( + msg.type === "chat.accepted" || + msg.type === "chat.subscription-rejected" || + msg.type === "chat.queued" || + msg.type === "chat.resync-required" + ) + return; // fallback: try all stores (chat.error without conversationId) for (const store of registry.chatStores.values()) { @@ -166,23 +180,8 @@ export function createWsHandlers(deps: { // workspace too. Re-declare it so the re-pushed catalog is filtered // (plan 06 §6.4). deps.send(deps.workspaceSetMsg(deps.activeWorkspaceId())); - // Re-attach to every open conversation's turn stream. A turn that kept - // 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) { - registry.subscribeChat(tab.conversationId); - if (registry.loadedConversationIds.has(tab.conversationId)) { - registry.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 registry.watchStores) { - registry.subscribeChat(watchId); - if (registry.loadedConversationIds.has(watchId)) watchStore.resync(); - } + registry.reconnect(); + void conversations.fetchOpenConversations(); }, onExtensionsChanged(_msg: ExtensionsChangedMessage) { // §11 multi-client coherence: another client (or this one) mutated the |
