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 /src/adapters | |
| 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 'src/adapters')
| -rw-r--r-- | src/adapters/ws/index.test.ts | 28 | ||||
| -rw-r--r-- | src/adapters/ws/index.ts | 10 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 31 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 29 |
4 files changed, 98 insertions, 0 deletions
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; } |
