summaryrefslogtreecommitdiffhomepage
path: root/src/adapters
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 00:36:31 +0900
committerAdam Malczewski <[email protected]>2026-06-22 00:36:31 +0900
commit54e88b71efd9a6fd9d880b6e90d844a875808662 (patch)
tree7d8292486f845225f4f03801531db2dc6ba8b7b1 /src/adapters
parenta8de5b2b9bec07a5ed5df54b859fa6ff5f98406f (diff)
downloaddispatch-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.ts28
-rw-r--r--src/adapters/ws/index.ts10
-rw-r--r--src/adapters/ws/logic.test.ts31
-rw-r--r--src/adapters/ws/logic.ts29
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;
}