summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 00:36:31 +0900
committerAdam Malczewski <[email protected]>2026-06-22 00:36:31 +0900
commit54e88b71efd9a6fd9d880b6e90d844a875808662 (patch)
tree7d8292486f845225f4f03801531db2dc6ba8b7b1
parenta8de5b2b9bec07a5ed5df54b859fa6ff5f98406f (diff)
downloaddispatch-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.md42
-rw-r--r--.dispatch/wire.reference.md19
-rw-r--r--ROADMAP.md7
-rw-r--r--backend-handoff.md31
-rw-r--r--src/adapters/ws/index.test.ts28
-rw-r--r--src/adapters/ws/index.ts10
-rw-r--r--src/adapters/ws/logic.test.ts31
-rw-r--r--src/adapters/ws/logic.ts29
-rw-r--r--src/app/App.svelte1
-rw-r--r--src/app/store.svelte.ts144
-rw-r--r--src/core/wire/conformance.test.ts13
-rw-r--r--src/core/wire/conformance.ts4
-rw-r--r--src/features/tabs/ui/TabBar.svelte6
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;
}
```
diff --git a/ROADMAP.md b/ROADMAP.md
index b1d9b11..c1c5c30 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
-`[email protected]` / `[email protected]` / `[email protected]`.** All handoffs to date are
+_Last updated: 2026-06-22 (conversation lifecycle handoff consumed). **FE is current on
+`[email protected]` / `[email protected]` / `[email protected]`.** All handoffs to date are
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)
-Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
+Pinned as `file:` deps: **`[email protected]`; `[email protected]`; `[email protected]`**.
| 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]` /
## 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"