diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/adapters/ws/index.test.ts | 28 | ||||
| -rw-r--r-- | src/adapters/ws/index.ts | 10 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 31 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 29 | ||||
| -rw-r--r-- | src/app/App.svelte | 1 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 144 | ||||
| -rw-r--r-- | src/core/wire/conformance.test.ts | 13 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 4 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabBar.svelte | 6 |
9 files changed, 251 insertions, 15 deletions
diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index e13f123..92d57a8 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -293,6 +293,34 @@ describe("createSurfaceSocket", () => { expect(onChat).not.toHaveBeenCalled(); }); + it("routes conversation.statusChanged to onConversationStatusChanged", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onConversationStatusChanged = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onConversationStatusChanged, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }), + ); + expect(onConversationStatusChanged).toHaveBeenCalledOnce(); + expect(onConversationStatusChanged).toHaveBeenCalledWith({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }); + expect(onMessage).not.toHaveBeenCalled(); + }); + it("still routes surface catalog/surface to onMessage", () => { const ws = fakeSocket(); const onMessage = vi.fn(); diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 18ebdf7..d2bc13d 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,7 +1,9 @@ import type { ChatDeltaMessage, ChatErrorMessage, + ConversationCompactedMessage, ConversationOpenMessage, + ConversationStatusChangedMessage, WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -21,6 +23,10 @@ export interface SurfaceSocketOptions { onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ onConversationOpen?: (msg: ConversationOpenMessage) => void; + /** Broadcast when a conversation's lifecycle status changes (active/idle/closed). */ + onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; + /** Broadcast when a conversation's history has been compacted (reload needed). */ + onConversationCompacted?: (msg: ConversationCompactedMessage) => void; onReopen?: () => void; socketFactory?: (url: string) => WebSocketLike; } @@ -65,6 +71,10 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); } else if (msg.type === "conversation.open") { opts.onConversationOpen?.(msg as ConversationOpenMessage); + } else if (msg.type === "conversation.statusChanged") { + opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); + } else if (msg.type === "conversation.compacted") { + opts.onConversationCompacted?.(msg as ConversationCompactedMessage); } else { opts.onMessage(msg as SurfaceServerMessage); } diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index ca129c0..2463519 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -233,6 +233,37 @@ describe("parseServerMessage", () => { parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: 42 })), ).toBeNull(); }); + + it("parses a conversation.statusChanged message", () => { + const data = JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }); + expect(parseServerMessage(data)).toEqual({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }); + }); + + it("returns null for conversation.statusChanged with invalid status", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "done", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with missing conversationId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.statusChanged", status: "idle" })), + ).toBeNull(); + }); }); describe("round-trip: parseServerMessage(serialize(...))", () => { diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index a9b70ff..53955f8 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -1,7 +1,9 @@ import type { ChatDeltaMessage, ChatErrorMessage, + ConversationCompactedMessage, ConversationOpenMessage, + ConversationStatusChangedMessage, WsClientMessage, WsServerMessage, } from "@dispatch/transport-contract"; @@ -20,6 +22,8 @@ const VALID_SERVER_TYPES = new Set([ "chat.delta", "chat.error", "conversation.open", + "conversation.statusChanged", + "conversation.compacted", ]); /** Serialize a client message to a JSON string for the wire. */ @@ -117,6 +121,31 @@ export function parseServerMessage(data: string): WsServerMessage | null { }; return msg; } + case "conversation.statusChanged": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.status !== "string") return null; + if (parsed.status !== "active" && parsed.status !== "idle" && parsed.status !== "closed") { + return null; + } + const msg: ConversationStatusChangedMessage = { + type: "conversation.statusChanged", + conversationId: parsed.conversationId, + status: parsed.status, + }; + return msg; + } + case "conversation.compacted": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.messagesSummarized !== "number") return null; + if (typeof parsed.messagesKept !== "number") return null; + const msg: ConversationCompactedMessage = { + type: "conversation.compacted", + conversationId: parsed.conversationId, + messagesSummarized: parsed.messagesSummarized, + messagesKept: parsed.messagesKept, + }; + return msg; + } default: return null; } diff --git a/src/app/App.svelte b/src/app/App.svelte index 2b3b250..e065759 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -257,6 +257,7 @@ <TabBar tabs={store.tabs} activeConversationId={store.activeConversationId} + statusFor={(id) => store.conversationStatus(id)} onSelect={(id) => store.selectTab(id)} onClose={(id) => store.closeTab(id)} onNewDraft={() => store.newDraft()} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 5a5245d..6fd8e5e 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,9 +1,12 @@ import type { ChatDeltaMessage, ChatErrorMessage, + ConversationCompactedMessage, ConversationHistoryResponse, + ConversationListResponse, ConversationMetricsResponse, ConversationOpenMessage, + ConversationStatusChangedMessage, CwdResponse, LspStatusResponse, ModelsResponse, @@ -15,6 +18,7 @@ import type { WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; +import type { ConversationStatus } from "@dispatch/wire"; import { createIdbChunkStore } from "../adapters/idb"; import { createLocalStore } from "../adapters/local-storage"; import type { WebSocketLike } from "../adapters/ws"; @@ -126,6 +130,12 @@ export interface AppStore { /** The persisted chat limit (max loaded chunks per conversation). */ readonly chatLimit: number; /** + * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or + * `undefined` when unknown. Drives the tab-bar generating indicator + * (cross-device: a tab spinning because another device's turn is running). + */ + conversationStatus(conversationId: string): ConversationStatus | undefined; + /** * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to * localStorage and propagates to every live chat store (trim if lower, * deferred via the unload gate while a reader is scrolled up; no-op if @@ -453,6 +463,81 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }); } + /** + * Remove a tab + its chat store locally (NO `POST /close` — used when the + * backend already marked the conversation `closed` via `conversation.statusChanged`). + */ + function removeTabLocally(conversationId: string): void { + unsubscribeChat(conversationId); + const store = chatStores.get(conversationId); + if (store !== undefined) { + store.dispose(); + chatStores.delete(conversationId); + } + void cache.delete(conversationId); + tabsStore.closeTab(conversationId); + conversationStatuses.delete(conversationId); + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshReasoningEffort(); + } + + // Conversation lifecycle status (backend-owned, pushed via WS + + // fetched on connect). Keyed by conversationId. + let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map()); + + /** + * Fetch `GET /conversations?status=active,idle` on connect to restore the + * tab bar across devices. Merges: opens tabs for conversations not already + * open, removes tabs for conversations that are no longer active/idle + * (closed on another device), and subscribes to `active` conversations' + * live streams. + */ + async function fetchOpenConversations(): Promise<void> { + try { + const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); + if (!res.ok) return; + const data = (await res.json()) as ConversationListResponse; + + // Update the status map from the authoritative backend list. + const newStatuses = new Map<string, ConversationStatus>(); + for (const conv of data.conversations) { + newStatuses.set(conv.id, conv.status); + } + conversationStatuses = newStatuses; + + // Open tabs for conversations not already open. + const existingIds = new Set(chatStores.keys()); + for (const conv of data.conversations) { + if (!existingIds.has(conv.id)) { + const store = createChatFor(conv.id, activeModel); + chatStores.set(conv.id, store); + void store.load(); + subscribeChat(conv.id); + tabsStore.openTab({ + conversationId: conv.id, + model: activeModel, + title: conv.title, + }); + } else { + // Already open — update the title from the backend if it differs. + tabsStore.setTitle(conv.id, conv.title); + } + } + + // 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)) { + removeTabLocally(tab.conversationId); + } + } + } catch { + // Non-fatal: fall back to the localStorage-restored tabs. + } + } + const socketOpts: SurfaceSocketOptions = { url: wsUrl, onMessage: handleServerMessage, @@ -460,6 +545,39 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { onConversationOpen(msg: ConversationOpenMessage): void { openConversation(msg.conversationId); }, + onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { + const { conversationId, status } = msg; + if (status === "closed") { + // Closed on another device (or the backend) — remove the tab locally. + if (chatStores.has(conversationId)) { + removeTabLocally(conversationId); + } + return; + } + // active / idle — update the status map (drives the tab spinner). + conversationStatuses = new Map(conversationStatuses).set(conversationId, status); + // If this is a new active conversation we don't have a tab for, open one. + if (status === "active" && !chatStores.has(conversationId)) { + openConversation(conversationId); + } + }, + onConversationCompacted(msg: ConversationCompactedMessage): void { + // The conversation's history was summarized — reload it from the server. + // Dispose the old store (stale cache) + create a fresh one + load. + const cid = msg.conversationId; + const wasActive = tabsStore.activeConversationId === cid; + const store = chatStores.get(cid); + if (store !== undefined) { + store.dispose(); + } + void cache.delete(cid); + const fresh = createChatFor(cid, activeModel); + chatStores.set(cid, fresh); + void fresh.load(); + if (wasActive) { + refreshActiveChat(); + } + }, onReopen() { // The server forgot our subscriptions on reconnect; re-send each with the // conversation it was subscribed under (protocolSubscribe would no-op since @@ -533,6 +651,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshCwd(); void refreshReasoningEffort(); + // Fetch the authoritative open-conversation list from the backend (cross- + // device tab sync). Merges with the localStorage-restored tabs: opens new + // ones, removes closed ones, updates titles + statuses. + void fetchOpenConversations(); + return { get tabs(): readonly Tab[] { return tabsStore.tabs; @@ -572,6 +695,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get chatLimit(): number { return chatLimit; }, + conversationStatus(conversationId: string): ConversationStatus | undefined { + return conversationStatuses.get(conversationId); + }, get currentConversationId(): string { return workspaceConversationId(); }, @@ -655,22 +781,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }, closeTab(conversationId: string): void { - tabsStore.closeTab(conversationId); - // The user is DONE with this chat for now: abort any in-flight turn and - // stop + disable its cache-warming, server-side. + // The user is DONE with this chat: abort any in-flight turn + stop/disable + // its cache-warming, server-side (POST /close sets status → "closed"). closeConversation(conversationId); - // Stop watching the closed conversation's turns. - unsubscribeChat(conversationId); - const store = chatStores.get(conversationId); - if (store !== undefined) { - store.dispose(); - chatStores.delete(conversationId); - } - void cache.delete(conversationId); - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshReasoningEffort(); + removeTabLocally(conversationId); }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index f5d6608..58cba3a 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -140,6 +140,17 @@ describe("classifies every WsServerMessage type", () => { }, { type: "chat.error" as const, message: "e" }, { type: "conversation.open" as const, conversationId: "c1" }, + { + type: "conversation.statusChanged" as const, + conversationId: "c1", + status: "active" as const, + }, + { + type: "conversation.compacted" as const, + conversationId: "c1", + messagesSummarized: 10, + messagesKept: 5, + }, ]; const labels = msgs.map(assertWsServerMessageExhaustive); expect(labels).toEqual([ @@ -150,6 +161,8 @@ describe("classifies every WsServerMessage type", () => { "chat.delta", "chat.error", "conversation.open", + "conversation.statusChanged", + "conversation.compacted", ]); }); }); diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index 05a15aa..07808fc 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -83,6 +83,10 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { return "chat.error"; case "conversation.open": return "conversation.open"; + case "conversation.statusChanged": + return "conversation.statusChanged"; + case "conversation.compacted": + return "conversation.compacted"; default: return msg satisfies never; } diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte index 812a663..9d224b9 100644 --- a/src/features/tabs/ui/TabBar.svelte +++ b/src/features/tabs/ui/TabBar.svelte @@ -5,12 +5,15 @@ let { tabs, activeConversationId, + statusFor, onSelect, onClose, onNewDraft, }: { tabs: readonly Tab[]; activeConversationId: string | null; + /** Returns the conversation's lifecycle status, or undefined when unknown. */ + statusFor?: (conversationId: string) => string | undefined; onSelect: (conversationId: string) => void; onClose: (conversationId: string) => void; onNewDraft: () => void; @@ -84,6 +87,9 @@ {handles.get(tab.conversationId) ?? tab.conversationId} </span> <span class="min-w-0 flex-1 truncate text-left">{tab.title}</span> + {#if statusFor?.(tab.conversationId) === "active"} + <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span> + {/if} <button class="btn btn-ghost btn-xs shrink-0" aria-label="Close tab" |
