diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 00:36:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 00:36:31 +0900 |
| commit | 54e88b71efd9a6fd9d880b6e90d844a875808662 (patch) | |
| tree | 7d8292486f845225f4f03801531db2dc6ba8b7b1 | |
| parent | a8de5b2b9bec07a5ed5df54b859fa6ff5f98406f (diff) | |
| download | dispatch-web-54e88b71efd9a6fd9d880b6e90d844a875808662.tar.gz dispatch-web-54e88b71efd9a6fd9d880b6e90d844a875808662.zip | |
feat(tabs): cross-device tab sync via conversation lifecycle
Consume the conversation lifecycle handoff ([email protected], [email protected]).
Re-pinned file: deps + re-mirrored .dispatch/*.reference.md.
- fetchOpenConversations() on connect: GET /conversations?status=active,idle
restores the tab bar across devices (merges with localStorage — opens new
tabs, removes closed ones, updates titles from backend)
- conversation.statusChanged WS handler: closed → removeTabLocally (no
re-POST); active → open tab + spinner; idle → update status map
- conversation.compacted WS handler: dispose stale store + cache, reload
history from server
- TabBar shows a spinner on active conversations (statusFor prop)
- closeTab refactored to use removeTabLocally (extracted cleanup)
- conformance guards + WS adapter tests cover all 3 new WsServerMessage types
686 tests green.
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 42 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 19 | ||||
| -rw-r--r-- | ROADMAP.md | 7 | ||||
| -rw-r--r-- | backend-handoff.md | 31 | ||||
| -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 |
13 files changed, 334 insertions, 31 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index d5f22c7..e599eb3 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -5,10 +5,19 @@ > hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — > this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation.open broadcast). -> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see +> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation lifecycle). +> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see > `ui-contract.reference.md`). > +> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.13.0` → `0.14.0`, ADDITIVE):** +> adds conversation lifecycle **status** (`active`/`idle`/`closed`) for cross-device tab +> persistence. `ConversationMeta` (re-exported from `[email protected]`) gains a `status` field. New +> WS message `ConversationStatusChangedMessage` (`{ type: "conversation.statusChanged"; +> conversationId; status }`) is broadcast to ALL clients on every status change. `GET +> /conversations` gains an optional `?status=active,idle` filter (comma-separated; default = all). +> `POST /conversations/:id/close` now also sets status to `closed` (persists across restarts). +> The FE fetches `?status=active,idle` on connect to restore the tab bar across devices. +> > **2026-06-21 delta (conversation.open handoff — package bumped `0.12.0` → `0.13.0`, ADDITIVE):** > adds the `conversation.open` WS broadcast — when the CLI's `--open` flag fires > (`POST /conversations/:id/open`), the backend broadcasts a `ConversationOpenMessage` @@ -212,6 +221,7 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-co import type { AgentEvent, ConversationMeta, + ConversationStatus, QueuedMessage, ReasoningEffort, StoredChunk, @@ -221,6 +231,7 @@ import type { export type { AgentEvent, ConversationMeta, + ConversationStatus, QueuedMessage, ReasoningEffort, StepMetrics, @@ -666,7 +677,9 @@ export type WsServerMessage = | SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage - | ConversationOpenMessage; + | ConversationOpenMessage + | ConversationStatusChangedMessage + | ConversationCompactedMessage; // ─── Conversation list + metadata ──────────────────────────────────────────── @@ -681,6 +694,29 @@ export interface ConversationOpenMessage { } /** + * Broadcast to all connected WS clients when a conversation's lifecycle status + * changes (`active`/`idle`/`closed`). The FE uses this for cross-device tab + * sync: `closed` → remove the tab; `active` → show a generating indicator. + */ +export interface ConversationStatusChangedMessage { + readonly type: "conversation.statusChanged"; + readonly conversationId: string; + readonly status: ConversationStatus; +} + +/** + * Broadcast to all connected WS clients when a conversation's history has been + * compacted (summarized). The frontend should reload the conversation history + * via `GET /conversations/:id` to reflect the compacted state. + */ +export interface ConversationCompactedMessage { + readonly type: "conversation.compacted"; + readonly conversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; +} + +/** * Response for `GET /conversations` — the list of all known conversations, * sorted by `lastActivityAt` descending (most recent first). Each entry carries * enough metadata for a conversation picker UI (id, title, timestamps). diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index e96c353..ead4d9c 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -4,9 +4,17 @@ > types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission > prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only. > -> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation metadata). Regenerate +> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation lifecycle status). Regenerate > whenever `@dispatch/wire` changes. > +> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.9.0` → `0.10.0`, ADDITIVE):** +> adds `ConversationStatus` (`"active" | "idle" | "closed"`) — the per-conversation lifecycle +> status. `ConversationMeta` gains a `status` field. `active` = a turn is generating; `idle` = +> exists, not generating; `closed` = dismissed (hidden from the tab bar). Transitions are +> backend-owned: `idle → active` on turn start, `active → idle` on turn settle, `→ closed` on +> `POST /conversations/:id/close`. Pushed to all WS clients via `conversation.statusChanged` +> (see `[email protected]`). +> > **2026-06-21 delta (conversation.open handoff — package bumped `0.8.0` → `0.9.0`, ADDITIVE):** > adds `ConversationMeta` — metadata for a conversation (id, title, createdAt, lastActivityAt), > returned by `GET /conversations` (the list endpoint, see `[email protected]`). @@ -585,6 +593,14 @@ export interface TurnSteeringEvent { // ─── Conversation metadata ─────────────────────────────────────────────────── /** + * The per-conversation lifecycle status. `active` = a turn is generating; + * `idle` = exists, not generating; `closed` = dismissed (hidden from the tab + * bar, not deleted). Transitions are backend-owned and pushed via the + * `conversation.statusChanged` WS message (see `transport-contract`). + */ +export type ConversationStatus = "active" | "idle" | "closed"; + +/** * Metadata for a conversation, returned by `GET /conversations` (the list * endpoint). The title defaults to the first user message (truncated) and can * be set via `PUT /conversations/:id/title`. `createdAt` is set on first write; @@ -595,5 +611,6 @@ export interface ConversationMeta { readonly createdAt: number; readonly lastActivityAt: number; readonly title: string; + readonly status: ConversationStatus; } ``` @@ -16,15 +16,16 @@ - **Reasoning effort** — sticky per-conversation thinking-depth knob (`GET`/`PUT /reasoning-effort`, `null` ⇒ default `high`). - **Message queue + steering** — `chat.queue` WS op, `steering` AgentEvent → user bubble in transcript, message-queue surface panel above composer. - **Todo task list** — `rendererId: "todo"` custom renderer, dedicated "Tasks" sidebar view (status indicators: pending/in_progress/completed/cancelled). -- **Conversation.open broadcast** — `conversation.open` WS message handler, opens/focuses a tab from CLI `--open` flag. +- **Conversation.open broadcast** — `conversation.open` WS message handler, opens a tab (without auto-switching) from CLI `--open` flag. +- **Conversation lifecycle (cross-device tab sync)** — `GET /conversations?status=active,idle` on connect restores tabs across devices; `conversation.statusChanged` WS handler updates tab status + removes closed tabs; TabBar shows a spinner on `active` conversations. ## Next up ### Conversation list + title editing (`frontend-conversation-list-handoff.md`) -Types already in `[email protected]` + `[email protected]` (mirrored, deps re-pinned). The `conversation.open` WS handler is already consumed. Remaining FE work: +Types already in `[email protected]` + `[email protected]` (mirrored, deps re-pinned). The `conversation.open` + lifecycle handlers are already consumed. Remaining FE work: -1. **Conversation list sidebar view** — `GET /conversations` → `ConversationListResponse` (`ConversationMeta[]` with id, title, createdAt, lastActivityAt). Render a "Conversations" sidebar view (title + relative time). Click to open (create tab + load history + subscribe). Fetch on mount + on focus / manual refresh. +1. **Conversation list sidebar view** — `GET /conversations` → `ConversationListResponse` (`ConversationMeta[]` with id, title, createdAt, lastActivityAt, status). Render a "Conversations" sidebar view (title + relative time + status). Click to open (create tab + load history + subscribe). Fetch on mount + on focus / manual refresh. Filter by status (active/idle vs closed for a history view). 2. **Title editing** — `GET`/`PUT /conversations/:id/title` (`TitleResponse`/`SetTitleRequest`). Inline rename affordance on the active conversation's title. Auto-title from first user message is backend-owned; FE overrides via PUT. ## Backlog (likely next backend asks — not yet requested) diff --git a/backend-handoff.md b/backend-handoff.md index 64147ff..ff4b961 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,19 +5,31 @@ > **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. -_Last updated: 2026-06-21 (conversation.open handoff consumed). **FE is current on +_Last updated: 2026-06-22 (conversation lifecycle handoff consumed). **FE is current on consumed: surfaces + WS, conversation transcript/metrics, tabs + model selector, cache-warming (incl. authoritative timer + retention + cache-rate fix + the CR-4 lifecycle below), **per-conversation cwd + LSP status**, **context size**, **turn continuity + multi-client live view**, the **chat limit + CR-5 history windowing**, the **reasoning effort -(thinking-depth knob)**, the **message queue + steering**, the **todo task list**, and the -**conversation.open broadcast** (below). +(thinking-depth knob)**, the **message queue + steering**, the **todo task list**, the +**conversation.open broadcast**, and the **conversation lifecycle (cross-device tab sync)** +(below). **Open asks: NONE.** CR-1/CR-2/CR-4/CR-5 all RESOLVED ✅ (see §2); §3 lists likely next asks. **CR-3 (watcher couldn't see the USER prompt until seal) → RESOLVED ✅** — backend shipped the `user-message` turn event; FE re-pinned + consumption live. The cwd/LSP draft-path verification (`backend-handoff-cwd-lsp.md`) came back **all ✅ confirmed**._ +**Conversation lifecycle handoff (`frontend-conversation-lifecycle-handoff.md`) → CONSUMED ✅.** +Re-pinned `[email protected]→0.10.0` + `[email protected]→0.14.0` (`ui-contract` unchanged); +re-mirrored both `.dispatch/*.reference.md`. FE work: `fetchOpenConversations()` on connect fetches +`GET /conversations?status=active,idle` to restore the tab bar across devices (merges with +localStorage-restored tabs — opens new ones, removes closed ones, updates titles). The +`conversation.statusChanged` WS message handler updates a per-conversation status map: `closed` → +`removeTabLocally` (FE cleanup without re-POSTing `/close`); `active` → opens the tab if not +already open + shows a spinner in the TabBar (via `statusFor` prop). The `closeTab` path now uses +`removeTabLocally` (extracted from the old inline cleanup). Conformance guards + WS adapter tests +cover the new type. 686 tests green. NO new backend ask._ + **Conversation.open handoff (`frontend-conversation-open-handoff.md`) → CONSUMED ✅.** Re-pinned `[email protected]→0.9.0` + `[email protected]→0.13.0` (`ui-contract` unchanged); re-mirrored both `.dispatch/*.reference.md`. FE work: the WS adapter (`adapters/ws/logic.ts` + @@ -115,13 +127,13 @@ backend ask — but the max-limit denominator is now a live FE need; see §3. ## 1. Pinned backend contracts (consumed by the FE) | Package | Used for | |---|---| | `@dispatch/ui-contract` | surfaces + surface WS protocol | -| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, **`done.contextSize`/`TurnMetrics.contextSize`**, **`ReasoningEffort`**, **`QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`**, **`ConversationMeta`** | -| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + **`QueueRequest`/`QueueResponse`/`ChatQueueMessage`** + **`ConversationOpenMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse`** + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | +| `@dispatch/wire` | `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`Usage`/`StepId` + metrics: `StepMetrics`/`TurnMetrics`, `usage.stepId`, `step-complete`, `done.durationMs`/`done.usage`, `tool-result.durationMs`, **`done.contextSize`/`TurnMetrics.contextSize`**, **`ReasoningEffort`**, **`QueuedMessage`/`QueuePayload`/`TurnSteeringEvent`**, **`ConversationMeta`/`ConversationStatus`** | +| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + **`QueueRequest`/`QueueResponse`/`ChatQueueMessage`** + **`ConversationOpenMessage`/`ConversationStatusChangedMessage`/`ConversationListResponse`/`LastMessageResponse`/`OpenConversationResponse`/`SetTitleRequest`/`TitleResponse`** + LSP (`LspStatusResponse`/`LspServerInfo`/`LspServerState`) + WS chat ops + `WsClientMessage`/`WsServerMessage` | Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`): `POST /chat` (NDJSON) · `GET /models` · @@ -133,11 +145,12 @@ tab-close: abort turn + stop/disable warming) · **`POST /conversations/:id/queu steering message; auto-starts a turn if idle) · WS `chat.send`→`chat.delta` · WS `chat.subscribe`/`chat.unsubscribe` (watch a conversation's turns without sending; replay + live) · **WS `chat.queue`** (enqueue steering; fire-and-forget — surface updates on success) · -**WS `conversation.open`** (broadcast: CLI `--open` flag signals the FE to open/focus a tab). +**WS `conversation.open`** (broadcast: CLI `--open` flag signals the FE to open/focus a tab) · +**WS `conversation.statusChanged`** (broadcast: lifecycle status change — `active`/`idle`/`closed`). Mirrored in-repo for headless agents: `.dispatch/{ui-contract,wire,transport-contract}.reference.md` (regenerate on any contract bump; all current as of `[email protected]` / -`[email protected]` / `[email protected]`). +`[email protected]` / `[email protected]`). ## 2. Open asks FOR THE BACKEND 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" |
