diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 16:28:07 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 16:28:07 +0900 |
| commit | 4001274e3ba25a3946df1e9f2dc82ca6781cd2bf (patch) | |
| tree | 24af95e69bda5c38ab7eefd6b71d55b4c247040a /src/app | |
| parent | e6f6bd86eab07954d8f06e740659969c3dfecc7f (diff) | |
| download | dispatch-web-4001274e3ba25a3946df1e9f2dc82ca6781cd2bf.tar.gz dispatch-web-4001274e3ba25a3946df1e9f2dc82ca6781cd2bf.zip | |
feat(cache-warming): consume CR-4 lifecycle — tab-close cancel + scope-aware subscriptions
- closeTab now POSTs /conversations/:id/close (abort in-flight turn + stop/disable
warming server-side); disconnect still leaves both running ([email protected])
- syncSubscriptions honors catalog scope ([email protected]): global surfaces are
not re-subscribed on conversation switch
- fix(ws): the surface-message parser dropped the conversationId echo (CR-4d was
ours, not the backend's) — preserved + unit-tested
- secondsUntilNext: 3s stale guard — a past nextWarmAt renders as waiting, not 0s
- re-pinned + re-mirrored [email protected] / [email protected]
- scripts/probe-cache-warming.ts: live CR-4 probe (default-off, future nextWarmAt,
repeated warms, mid-turn close abort, idempotent re-close) — 17/17 against bin/up
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/store.svelte.ts | 29 | ||||
| -rw-r--r-- | src/app/store.test.ts | 72 |
2 files changed, 99 insertions, 2 deletions
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index df92b31..2837bb5 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -256,6 +256,23 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { socket?.send({ type: "chat.unsubscribe", conversationId }); } + /** + * Tell the backend the user EXPLICITLY closed this conversation's tab + * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with + * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF). + * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave + * both running. Fire-and-forget: a failure is non-fatal (worst case the + * warming keeps running until a later close/toggle), and the endpoint is + * idempotent server-side. + */ + function closeConversation(conversationId: string): void { + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, { + method: "POST", + }).catch(() => { + // Non-fatal — see doc comment. + }); + } + /** The conversation the surfaces should scope to (undefined for a draft). */ function focusedConversationId(): string | undefined { return tabsStore.activeConversationId ?? undefined; @@ -289,7 +306,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { function syncSubscriptions(): void { const cid = focusedConversationId(); for (const entry of protocol.catalog) { - const result = protocolSubscribe(protocol, entry.id, cid); + // 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 + // conversation-scoped, the conservative pre-0.2.0 policy). + const scoped = entry.scope === "global" ? undefined : cid; + const result = protocolSubscribe(protocol, entry.id, scoped); protocol = result.state; for (const msg of result.outgoing) { socket?.send(msg); @@ -489,7 +511,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { closeTab(conversationId: string): void { tabsStore.closeTab(conversationId); - // Stop watching the closed conversation's turns (does NOT stop the turn). + // The user is DONE with this chat for now: abort any in-flight turn and + // stop + disable its cache-warming, server-side. + closeConversation(conversationId); + // Stop watching the closed conversation's turns. unsubscribeChat(conversationId); const store = chatStores.get(conversationId); if (store !== undefined) { diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 803d7dc..f4b5a0f 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -674,6 +674,78 @@ describe("createAppStore", () => { store.dispose(); }); + it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => { + const calls: { url: string; method: string }[] = []; + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, method: init?.method ?? "GET" }); + if (url.endsWith("/close")) { + return new Response( + JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }), + { status: 200 }, + ); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + store.closeTab(convId); + await Promise.resolve(); // flush the fire-and-forget fetch + + const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`)); + expect(close).toBeDefined(); + expect(close?.method).toBe("POST"); + + store.dispose(); + }); + + it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s-global", region: "side", title: "Global", scope: "global" }, + { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" }, + ], + }); + + ws.sent.length = 0; + store.send("promote the draft"); // draft → real conversation: surfaces re-scope + const convId = activeConversationId(store); + + const surfaceMsgs = parseSent(ws).filter( + (p): p is { type: string; surfaceId: string; conversationId?: string } => + (p as { type: string }).type === "subscribe" || + (p as { type: string }).type === "unsubscribe", + ); + // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id. + expect( + surfaceMsgs.some( + (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId, + ), + ).toBe(true); + // The global surface is untouched — no redundant unsubscribe+subscribe round trip. + expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false); + + store.dispose(); + }); + it("tabs persist to the injected storage and restore on a new store", () => { const ws = fakeSocket(); const storage = createFakeStorage(); |
