summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 21:47:24 +0900
committerAdam Malczewski <[email protected]>2026-06-21 21:47:24 +0900
commitfd81987fcec0178ae2c466800b428e1b1dfc4ab0 (patch)
tree646e39ed43c64f763721553ba7a7821d62730df8
parent90ab92626555bb6a764a3c15fc03ac3e36966226 (diff)
downloaddispatch-web-fd81987fcec0178ae2c466800b428e1b1dfc4ab0.tar.gz
dispatch-web-fd81987fcec0178ae2c466800b428e1b1dfc4ab0.zip
feat(ws): handle conversation.open broadcast — open/focus tab from CLI --open
Consume the conversation.open handoff ([email protected], [email protected]). Re-pinned file: deps + re-mirrored .dispatch/*.reference.md. - WS adapter (logic.ts + index.ts): parse + route the new top-level "conversation.open" WsServerMessage to an onConversationOpen handler - app store: openConversation(id) opens (or focuses) a tab — creates a chat store, loads history, subscribes to live turns, creates+selects the tab - conformance guard + WS adapter tests cover the new type - backend also shipped conversation metadata endpoints (GET /conversations, GET /conversations/:id/last, GET/PUT /conversations/:id/title) — mirrored but not yet consumed by the FE 682 tests green.
-rw-r--r--.dispatch/transport-contract.reference.md80
-rw-r--r--.dispatch/wire.reference.md21
-rw-r--r--backend-handoff.md31
-rw-r--r--src/adapters/ws/index.test.ts24
-rw-r--r--src/adapters/ws/index.ts5
-rw-r--r--src/adapters/ws/logic.test.ts16
-rw-r--r--src/adapters/ws/logic.ts10
-rw-r--r--src/app/store.svelte.ts30
-rw-r--r--src/core/wire/conformance.test.ts11
-rw-r--r--src/core/wire/conformance.ts2
10 files changed, 217 insertions, 13 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 18d1a3d..d5f22c7 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -5,10 +5,21 @@
> hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally —
> this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (message queue + steering).
-> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see
+> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation.open broadcast).
+> Depends on `@dispatch/[email protected]` (see `wire.reference.md`) + `@dispatch/[email protected]` (see
> `ui-contract.reference.md`).
>
+> **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`
+> (`{ type: "conversation.open"; conversationId }`) to ALL connected WS clients. Additive to
+> `WsServerMessage`. The FE handles it by opening/focusing a tab for the `conversationId`. Also
+> adds conversation metadata endpoints (not yet consumed by the FE): `GET /conversations` (list,
+> `ConversationListResponse`/`ConversationMeta`), `GET /conversations/:id/last` (blocking last
+> message, `LastMessageResponse`), `GET`/`PUT /conversations/:id/title` (`TitleResponse`/
+> `SetTitleRequest`), and `POST /conversations/:id/open` (`OpenConversationResponse`). Re-exports
+> `ConversationMeta` from `[email protected]`.
+>
> **2026-06-21 delta (message-queue + steering handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):**
> adds the enqueue surface for the per-conversation message queue (the wire types `QueuedMessage` /
> `QueuePayload` + the new `steering` `AgentEvent` live in `[email protected]`, re-exported here). Two
@@ -200,6 +211,7 @@
import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
import type {
AgentEvent,
+ ConversationMeta,
QueuedMessage,
ReasoningEffort,
StoredChunk,
@@ -208,6 +220,7 @@ import type {
export type {
AgentEvent,
+ ConversationMeta,
QueuedMessage,
ReasoningEffort,
StepMetrics,
@@ -649,5 +662,66 @@ export type WsClientMessage =
* Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat
* ops. A client discriminates on `type`.
*/
-export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage;
+export type WsServerMessage =
+ | SurfaceServerMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ConversationOpenMessage;
+
+// ─── Conversation list + metadata ────────────────────────────────────────────
+
+/**
+ * Broadcast to all connected WS clients when a conversation is "opened" (e.g.
+ * via the CLI `--open` flag). The frontend decides whether to open/focus a tab
+ * — the backend just signals. Additive to `WsServerMessage`.
+ */
+export interface ConversationOpenMessage {
+ readonly type: "conversation.open";
+ readonly conversationId: string;
+}
+
+/**
+ * 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).
+ * Optional `?q=` query param filters by id prefix (short-id resolution).
+ */
+export interface ConversationListResponse {
+ readonly conversations: readonly ConversationMeta[];
+}
+
+/**
+ * Response for `GET /conversations/:id/last` — blocks server-side until the
+ * in-flight turn settles (if one is active), then returns the last assistant
+ * text message. `content` is empty if the conversation has no assistant message.
+ * `turnId` is the turn that produced the message (absent if no turn ran).
+ */
+export interface LastMessageResponse {
+ readonly conversationId: string;
+ readonly content: string;
+ readonly turnId?: string;
+}
+
+/**
+ * Response for `POST /conversations/:id/open` — confirms the conversation.open
+ * signal was broadcast to connected WS clients.
+ */
+export interface OpenConversationResponse {
+ readonly conversationId: string;
+}
+
+/**
+ * Request body for `PUT /conversations/:id/title` — set a human-readable title.
+ */
+export interface SetTitleRequest {
+ readonly title: string;
+}
+
+/**
+ * Response for `GET/PUT /conversations/:id/title` — the current title.
+ */
+export interface TitleResponse {
+ readonly conversationId: string;
+ readonly title: string;
+}
```
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index c2c4d43..e96c353 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,9 +4,13 @@
> 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]` (message queue + steering). Regenerate
+> **Orchestrator:** SNAPSHOT of `[email protected]` (conversation metadata). Regenerate
> whenever `@dispatch/wire` changes.
>
+> **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]`).
+>
> **2026-06-21 delta (message-queue + steering handoff — package bumped `0.7.0` → `0.8.0`, ADDITIVE):**
> adds the per-conversation **message queue** + **steering** feature. While a turn is GENERATING,
> a client enqueues a user message (via the `chat.queue` WS op or `POST /conversations/:id/queue`,
@@ -577,4 +581,19 @@ export interface TurnSteeringEvent {
readonly turnId: string;
readonly text: string;
}
+
+// ─── Conversation metadata ───────────────────────────────────────────────────
+
+/**
+ * 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;
+ * `lastActivityAt` is updated on every append.
+ */
+export interface ConversationMeta {
+ readonly id: string;
+ readonly createdAt: number;
+ readonly lastActivityAt: number;
+ readonly title: string;
+}
```
diff --git a/backend-handoff.md b/backend-handoff.md
index 5b54f2d..64147ff 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,18 +5,32 @@
> **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 (message-queue + steering handoff consumed). **FE is current on
-`[email protected]` / `[email protected]` / `[email protected]`.** All handoffs to date are
+_Last updated: 2026-06-21 (conversation.open 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)**, and the **message queue + steering** (below).
+(thinking-depth knob)**, the **message queue + steering**, the **todo task list**, and the
+**conversation.open broadcast** (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.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` +
+`index.ts`) parses + routes the new `"conversation.open"` top-level `WsServerMessage` to an
+`onConversationOpen` handler; the app store's `openConversation(conversationId)` opens (or
+focuses) a tab for the broadcast conversation — if not already open, creates a chat store,
+loads history, subscribes to live turns, creates the tab; then selects it + refreshes
+cwd/effort/surfaces. The conformance guard + WS adapter tests cover the new type. The backend
+also shipped conversation metadata endpoints (`GET /conversations`, `GET /conversations/:id/last`,
+`GET`/`PUT /conversations/:id/title`, `POST /conversations/:id/open`) — NOT yet consumed by the FE
+(mirrored for reference; wire when a conversation picker/list UI is built). 682 tests green. NO
+new backend ask._
+
**Message-queue + steering handoff (`frontend-message-queue-handoff.md`) → CONSUMED ✅.**
Re-pinned `[email protected]→0.8.0` + `[email protected]→0.12.0` (`ui-contract` unchanged —
the queue uses the existing `custom` surface field kind); re-mirrored both
@@ -101,13 +115,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`** |
-| `@dispatch/transport-contract` | `ChatRequest`(+`reasoningEffort`)/`ModelsResponse`/`ConversationHistoryResponse`/`ConversationMetricsResponse` + `WarmRequest`/`WarmResponse` + `CwdResponse`/`SetCwdRequest` + `ReasoningEffortResponse`/`SetReasoningEffortRequest` + **`QueueRequest`/`QueueResponse`/`ChatQueueMessage`** + 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`** |
+| `@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` |
Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`):
`POST /chat` (NDJSON) · `GET /models` ·
@@ -118,11 +132,12 @@ Endpoints in use (HTTP **24203**, WS **24205**, CORS `*` incl. `PUT`):
tab-close: abort turn + stop/disable warming) · **`POST /conversations/:id/queue`** (enqueue
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 `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).
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 961f919..e13f123 100644
--- a/src/adapters/ws/index.test.ts
+++ b/src/adapters/ws/index.test.ts
@@ -269,6 +269,30 @@ describe("createSurfaceSocket", () => {
expect(onMessage).not.toHaveBeenCalled();
});
+ it("routes conversation.open to onConversationOpen", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ const onChat = vi.fn();
+ const onConversationOpen = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ onChat,
+ onConversationOpen,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" }));
+ expect(onConversationOpen).toHaveBeenCalledOnce();
+ expect(onConversationOpen).toHaveBeenCalledWith({
+ type: "conversation.open",
+ conversationId: "c1",
+ });
+ expect(onMessage).not.toHaveBeenCalled();
+ expect(onChat).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 54a501c..18ebdf7 100644
--- a/src/adapters/ws/index.ts
+++ b/src/adapters/ws/index.ts
@@ -1,6 +1,7 @@
import type {
ChatDeltaMessage,
ChatErrorMessage,
+ ConversationOpenMessage,
WsClientMessage,
} from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
@@ -18,6 +19,8 @@ export interface SurfaceSocketOptions {
url: string;
onMessage: (msg: SurfaceServerMessage) => void;
onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void;
+ /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */
+ onConversationOpen?: (msg: ConversationOpenMessage) => void;
onReopen?: () => void;
socketFactory?: (url: string) => WebSocketLike;
}
@@ -60,6 +63,8 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
if (msg !== null) {
if (msg.type === "chat.delta" || msg.type === "chat.error") {
opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage);
+ } else if (msg.type === "conversation.open") {
+ opts.onConversationOpen?.(msg as ConversationOpenMessage);
} else {
opts.onMessage(msg as SurfaceServerMessage);
}
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts
index 2784295..ca129c0 100644
--- a/src/adapters/ws/logic.test.ts
+++ b/src/adapters/ws/logic.test.ts
@@ -217,6 +217,22 @@ describe("parseServerMessage", () => {
),
).toBeNull();
});
+
+ it("parses a conversation.open message", () => {
+ const data = JSON.stringify({ type: "conversation.open", conversationId: "c1" });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({ type: "conversation.open", conversationId: "c1" });
+ });
+
+ it("returns null for conversation.open with missing conversationId", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "conversation.open" }))).toBeNull();
+ });
+
+ it("returns null for conversation.open with non-string conversationId", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: 42 })),
+ ).toBeNull();
+ });
});
describe("round-trip: parseServerMessage(serialize(...))", () => {
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index 17e3951..a9b70ff 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -1,6 +1,7 @@
import type {
ChatDeltaMessage,
ChatErrorMessage,
+ ConversationOpenMessage,
WsClientMessage,
WsServerMessage,
} from "@dispatch/transport-contract";
@@ -18,6 +19,7 @@ const VALID_SERVER_TYPES = new Set([
"error",
"chat.delta",
"chat.error",
+ "conversation.open",
]);
/** Serialize a client message to a JSON string for the wire. */
@@ -107,6 +109,14 @@ export function parseServerMessage(data: string): WsServerMessage | null {
: { type: "chat.error", message: parsed.message };
return msg;
}
+ case "conversation.open": {
+ if (typeof parsed.conversationId !== "string") return null;
+ const msg: ConversationOpenMessage = {
+ type: "conversation.open",
+ conversationId: parsed.conversationId,
+ };
+ return msg;
+ }
default:
return null;
}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index dc06ea1..5159353 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -3,6 +3,7 @@ import type {
ChatErrorMessage,
ConversationHistoryResponse,
ConversationMetricsResponse,
+ ConversationOpenMessage,
CwdResponse,
LspStatusResponse,
ModelsResponse,
@@ -432,10 +433,39 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
let socket: ReturnType<typeof createSurfaceSocket> | null = null;
+ /**
+ * Open (or focus) a conversation tab — used by the `conversation.open` WS
+ * broadcast (CLI `--open` flag). If the conversation is already open, just
+ * focus it; otherwise create a chat store, load its history, subscribe to its
+ * live turns, and create+select the tab.
+ */
+ function openConversation(conversationId: string): void {
+ const alreadyOpen = chatStores.has(conversationId);
+ if (!alreadyOpen) {
+ const store = createChatFor(conversationId, activeModel);
+ chatStores.set(conversationId, store);
+ void store.load();
+ subscribeChat(conversationId);
+ tabsStore.createTab({
+ conversationId,
+ model: activeModel,
+ title: "Conversation",
+ });
+ }
+ tabsStore.selectTab(conversationId);
+ refreshActiveChat();
+ syncSubscriptions();
+ void refreshCwd();
+ void refreshReasoningEffort();
+ }
+
const socketOpts: SurfaceSocketOptions = {
url: wsUrl,
onMessage: handleServerMessage,
onChat: handleChatMessage,
+ onConversationOpen(msg: ConversationOpenMessage): void {
+ openConversation(msg.conversationId);
+ },
onReopen() {
// The server forgot our subscriptions on reconnect; re-send each with the
// conversation it was subscribed under (protocolSubscribe would no-op since
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts
index 2fdd3cb..f5d6608 100644
--- a/src/core/wire/conformance.test.ts
+++ b/src/core/wire/conformance.test.ts
@@ -139,9 +139,18 @@ describe("classifies every WsServerMessage type", () => {
event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" },
},
{ type: "chat.error" as const, message: "e" },
+ { type: "conversation.open" as const, conversationId: "c1" },
];
const labels = msgs.map(assertWsServerMessageExhaustive);
- expect(labels).toEqual(["catalog", "surface", "update", "error", "chat.delta", "chat.error"]);
+ expect(labels).toEqual([
+ "catalog",
+ "surface",
+ "update",
+ "error",
+ "chat.delta",
+ "chat.error",
+ "conversation.open",
+ ]);
});
});
diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts
index 6e87e5c..05a15aa 100644
--- a/src/core/wire/conformance.ts
+++ b/src/core/wire/conformance.ts
@@ -81,6 +81,8 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
return "chat.delta";
case "chat.error":
return "chat.error";
+ case "conversation.open":
+ return "conversation.open";
default:
return msg satisfies never;
}