summaryrefslogtreecommitdiffhomepage
path: root/src
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
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')
-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
-rw-r--r--src/app/App.svelte1
-rw-r--r--src/app/store.svelte.ts144
-rw-r--r--src/core/wire/conformance.test.ts13
-rw-r--r--src/core/wire/conformance.ts4
-rw-r--r--src/features/tabs/ui/TabBar.svelte6
9 files changed, 251 insertions, 15 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;
}
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 2b3b250..e065759 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -257,6 +257,7 @@
<TabBar
tabs={store.tabs}
activeConversationId={store.activeConversationId}
+ statusFor={(id) => store.conversationStatus(id)}
onSelect={(id) => store.selectTab(id)}
onClose={(id) => store.closeTab(id)}
onNewDraft={() => store.newDraft()}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 5a5245d..6fd8e5e 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -1,9 +1,12 @@
import type {
ChatDeltaMessage,
ChatErrorMessage,
+ ConversationCompactedMessage,
ConversationHistoryResponse,
+ ConversationListResponse,
ConversationMetricsResponse,
ConversationOpenMessage,
+ ConversationStatusChangedMessage,
CwdResponse,
LspStatusResponse,
ModelsResponse,
@@ -15,6 +18,7 @@ import type {
WarmResponse,
} from "@dispatch/transport-contract";
import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { ConversationStatus } from "@dispatch/wire";
import { createIdbChunkStore } from "../adapters/idb";
import { createLocalStore } from "../adapters/local-storage";
import type { WebSocketLike } from "../adapters/ws";
@@ -126,6 +130,12 @@ export interface AppStore {
/** The persisted chat limit (max loaded chunks per conversation). */
readonly chatLimit: number;
/**
+ * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or
+ * `undefined` when unknown. Drives the tab-bar generating indicator
+ * (cross-device: a tab spinning because another device's turn is running).
+ */
+ conversationStatus(conversationId: string): ConversationStatus | undefined;
+ /**
* Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to
* localStorage and propagates to every live chat store (trim if lower,
* deferred via the unload gate while a reader is scrolled up; no-op if
@@ -453,6 +463,81 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
});
}
+ /**
+ * Remove a tab + its chat store locally (NO `POST /close` — used when the
+ * backend already marked the conversation `closed` via `conversation.statusChanged`).
+ */
+ function removeTabLocally(conversationId: string): void {
+ unsubscribeChat(conversationId);
+ const store = chatStores.get(conversationId);
+ if (store !== undefined) {
+ store.dispose();
+ chatStores.delete(conversationId);
+ }
+ void cache.delete(conversationId);
+ tabsStore.closeTab(conversationId);
+ conversationStatuses.delete(conversationId);
+ refreshActiveChat();
+ syncSubscriptions();
+ void refreshCwd();
+ void refreshReasoningEffort();
+ }
+
+ // Conversation lifecycle status (backend-owned, pushed via WS +
+ // fetched on connect). Keyed by conversationId.
+ let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
+
+ /**
+ * Fetch `GET /conversations?status=active,idle` on connect to restore the
+ * tab bar across devices. Merges: opens tabs for conversations not already
+ * open, removes tabs for conversations that are no longer active/idle
+ * (closed on another device), and subscribes to `active` conversations'
+ * live streams.
+ */
+ async function fetchOpenConversations(): Promise<void> {
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ConversationListResponse;
+
+ // Update the status map from the authoritative backend list.
+ const newStatuses = new Map<string, ConversationStatus>();
+ for (const conv of data.conversations) {
+ newStatuses.set(conv.id, conv.status);
+ }
+ conversationStatuses = newStatuses;
+
+ // Open tabs for conversations not already open.
+ const existingIds = new Set(chatStores.keys());
+ for (const conv of data.conversations) {
+ if (!existingIds.has(conv.id)) {
+ const store = createChatFor(conv.id, activeModel);
+ chatStores.set(conv.id, store);
+ void store.load();
+ subscribeChat(conv.id);
+ tabsStore.openTab({
+ conversationId: conv.id,
+ model: activeModel,
+ title: conv.title,
+ });
+ } else {
+ // Already open — update the title from the backend if it differs.
+ tabsStore.setTitle(conv.id, conv.title);
+ }
+ }
+
+ // Remove tabs for conversations no longer active/idle (closed elsewhere).
+ const backendIds = new Set(data.conversations.map((c) => c.id));
+ for (const tab of tabsStore.tabs) {
+ if (!backendIds.has(tab.conversationId)) {
+ removeTabLocally(tab.conversationId);
+ }
+ }
+ } catch {
+ // Non-fatal: fall back to the localStorage-restored tabs.
+ }
+ }
+
const socketOpts: SurfaceSocketOptions = {
url: wsUrl,
onMessage: handleServerMessage,
@@ -460,6 +545,39 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
onConversationOpen(msg: ConversationOpenMessage): void {
openConversation(msg.conversationId);
},
+ onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
+ const { conversationId, status } = msg;
+ if (status === "closed") {
+ // Closed on another device (or the backend) — remove the tab locally.
+ if (chatStores.has(conversationId)) {
+ removeTabLocally(conversationId);
+ }
+ return;
+ }
+ // active / idle — update the status map (drives the tab spinner).
+ conversationStatuses = new Map(conversationStatuses).set(conversationId, status);
+ // If this is a new active conversation we don't have a tab for, open one.
+ if (status === "active" && !chatStores.has(conversationId)) {
+ openConversation(conversationId);
+ }
+ },
+ onConversationCompacted(msg: ConversationCompactedMessage): void {
+ // The conversation's history was summarized — reload it from the server.
+ // Dispose the old store (stale cache) + create a fresh one + load.
+ const cid = msg.conversationId;
+ const wasActive = tabsStore.activeConversationId === cid;
+ const store = chatStores.get(cid);
+ if (store !== undefined) {
+ store.dispose();
+ }
+ void cache.delete(cid);
+ const fresh = createChatFor(cid, activeModel);
+ chatStores.set(cid, fresh);
+ void fresh.load();
+ if (wasActive) {
+ refreshActiveChat();
+ }
+ },
onReopen() {
// The server forgot our subscriptions on reconnect; re-send each with the
// conversation it was subscribed under (protocolSubscribe would no-op since
@@ -533,6 +651,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshCwd();
void refreshReasoningEffort();
+ // Fetch the authoritative open-conversation list from the backend (cross-
+ // device tab sync). Merges with the localStorage-restored tabs: opens new
+ // ones, removes closed ones, updates titles + statuses.
+ void fetchOpenConversations();
+
return {
get tabs(): readonly Tab[] {
return tabsStore.tabs;
@@ -572,6 +695,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
get chatLimit(): number {
return chatLimit;
},
+ conversationStatus(conversationId: string): ConversationStatus | undefined {
+ return conversationStatuses.get(conversationId);
+ },
get currentConversationId(): string {
return workspaceConversationId();
},
@@ -655,22 +781,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
closeTab(conversationId: string): void {
- tabsStore.closeTab(conversationId);
- // The user is DONE with this chat for now: abort any in-flight turn and
- // stop + disable its cache-warming, server-side.
+ // The user is DONE with this chat: abort any in-flight turn + stop/disable
+ // its cache-warming, server-side (POST /close sets status → "closed").
closeConversation(conversationId);
- // Stop watching the closed conversation's turns.
- unsubscribeChat(conversationId);
- const store = chatStores.get(conversationId);
- if (store !== undefined) {
- store.dispose();
- chatStores.delete(conversationId);
- }
- void cache.delete(conversationId);
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshReasoningEffort();
+ removeTabLocally(conversationId);
},
invoke(surfaceId: string, actionId: string, payload?: unknown): void {
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts
index f5d6608..58cba3a 100644
--- a/src/core/wire/conformance.test.ts
+++ b/src/core/wire/conformance.test.ts
@@ -140,6 +140,17 @@ describe("classifies every WsServerMessage type", () => {
},
{ type: "chat.error" as const, message: "e" },
{ type: "conversation.open" as const, conversationId: "c1" },
+ {
+ type: "conversation.statusChanged" as const,
+ conversationId: "c1",
+ status: "active" as const,
+ },
+ {
+ type: "conversation.compacted" as const,
+ conversationId: "c1",
+ messagesSummarized: 10,
+ messagesKept: 5,
+ },
];
const labels = msgs.map(assertWsServerMessageExhaustive);
expect(labels).toEqual([
@@ -150,6 +161,8 @@ describe("classifies every WsServerMessage type", () => {
"chat.delta",
"chat.error",
"conversation.open",
+ "conversation.statusChanged",
+ "conversation.compacted",
]);
});
});
diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts
index 05a15aa..07808fc 100644
--- a/src/core/wire/conformance.ts
+++ b/src/core/wire/conformance.ts
@@ -83,6 +83,10 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
return "chat.error";
case "conversation.open":
return "conversation.open";
+ case "conversation.statusChanged":
+ return "conversation.statusChanged";
+ case "conversation.compacted":
+ return "conversation.compacted";
default:
return msg satisfies never;
}
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte
index 812a663..9d224b9 100644
--- a/src/features/tabs/ui/TabBar.svelte
+++ b/src/features/tabs/ui/TabBar.svelte
@@ -5,12 +5,15 @@
let {
tabs,
activeConversationId,
+ statusFor,
onSelect,
onClose,
onNewDraft,
}: {
tabs: readonly Tab[];
activeConversationId: string | null;
+ /** Returns the conversation's lifecycle status, or undefined when unknown. */
+ statusFor?: (conversationId: string) => string | undefined;
onSelect: (conversationId: string) => void;
onClose: (conversationId: string) => void;
onNewDraft: () => void;
@@ -84,6 +87,9 @@
{handles.get(tab.conversationId) ?? tab.conversationId}
</span>
<span class="min-w-0 flex-1 truncate text-left">{tab.title}</span>
+ {#if statusFor?.(tab.conversationId) === "active"}
+ <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span>
+ {/if}
<button
class="btn btn-ghost btn-xs shrink-0"
aria-label="Close tab"