summaryrefslogtreecommitdiffhomepage
path: root/src/app
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/app
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/app')
-rw-r--r--src/app/App.svelte1
-rw-r--r--src/app/store.svelte.ts144
2 files changed, 130 insertions, 15 deletions
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 {