From 54e88b71efd9a6fd9d880b6e90d844a875808662 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 22 Jun 2026 00:36:31 +0900 Subject: feat(tabs): cross-device tab sync via conversation lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the conversation lifecycle handoff (wire@0.10.0, transport-contract@0.14.0). 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. --- src/adapters/ws/index.test.ts | 28 ++++++++++++++++++++++++++++ src/adapters/ws/index.ts | 10 ++++++++++ src/adapters/ws/logic.test.ts | 31 +++++++++++++++++++++++++++++++ src/adapters/ws/logic.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) (limited to 'src/adapters') 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; } -- cgit v1.2.3