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 /.dispatch | |
| 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.
Diffstat (limited to '.dispatch')
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 42 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 19 |
2 files changed, 57 insertions, 4 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; } ``` |
