diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 21:47:24 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 21:47:24 +0900 |
| commit | fd81987fcec0178ae2c466800b428e1b1dfc4ab0 (patch) | |
| tree | 646e39ed43c64f763721553ba7a7821d62730df8 /src | |
| parent | 90ab92626555bb6a764a3c15fc03ac3e36966226 (diff) | |
| download | dispatch-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.
Diffstat (limited to 'src')
| -rw-r--r-- | src/adapters/ws/index.test.ts | 24 | ||||
| -rw-r--r-- | src/adapters/ws/index.ts | 5 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 16 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 10 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 30 | ||||
| -rw-r--r-- | src/core/wire/conformance.test.ts | 11 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 2 |
7 files changed, 97 insertions, 1 deletions
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; } |
