summaryrefslogtreecommitdiffhomepage
path: root/src
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 /src
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.
Diffstat (limited to 'src')
-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
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;
}