summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.test.ts1
-rw-r--r--src/app/chat-registry.svelte.ts59
-rw-r--r--src/app/open-conversations.svelte.ts16
-rw-r--r--src/app/store.test.ts87
-rw-r--r--src/app/subscription-budget.test.ts20
-rw-r--r--src/app/subscription-budget.ts15
-rw-r--r--src/app/surface-protocol.svelte.ts30
-rw-r--r--src/app/surface-protocol.test.ts31
-rw-r--r--src/app/views/ChatPane.svelte5
-rw-r--r--src/app/ws-handlers.ts37
10 files changed, 245 insertions, 56 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 62798d6..42d30ed 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -470,6 +470,7 @@ describe("App component interaction tests", () => {
expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([
{
type: "chat.queue",
+ requestId: expect.any(String),
conversationId,
workspaceId: "default",
text,
diff --git a/src/app/chat-registry.svelte.ts b/src/app/chat-registry.svelte.ts
index a9b28ff..63f58ad 100644
--- a/src/app/chat-registry.svelte.ts
+++ b/src/app/chat-registry.svelte.ts
@@ -3,10 +3,11 @@ import { createLocalStore } from "../adapters/local-storage";
import type { SurfaceSocketHandle } from "../adapters/ws";
import { normalizeChatLimit } from "../core/chunks";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
-import { createChatStore } from "../features/chat";
+import { createChatStore, createPendingInputStorage } from "../features/chat";
import type { ConversationCache } from "../features/conversation-cache";
import type { TabsStore } from "../features/tabs";
import type { ChatLimitResult, CreateAppStoreOptions } from "./store-types";
+import { liveConversationIds } from "./subscription-budget";
import { randomId } from "./uuid";
/** App-owned tab/watch stores and idempotent transcript loading; no singleton state. */
@@ -46,6 +47,7 @@ export function createChatRegistry(deps: {
const chatStores = new Map<string, ChatStore>();
// Includes in-flight loads: repeated activation must not start a second load.
const loadedConversationIds = new Set<string>();
+ const loadingConversationIds = new Set<string>();
// Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a
// watch on a conversation's live turn stream WITHOUT opening a tab. Separate
@@ -53,6 +55,26 @@ export function createChatRegistry(deps: {
// and a tab's conversation reuses its own store (see `watchConversation`).
// Deltas are routed here in addition to `chatStores`.
const watchStores = new Map<string, ChatStore>();
+ const watchedIds = new Set<string>();
+ const subscribedIds = new Set<string>();
+
+ function syncChatSubscriptions(): void {
+ const desired = liveConversationIds(deps.tabsStore.activeConversationId, watchedIds);
+ for (const id of subscribedIds) {
+ if (!desired.has(id)) {
+ deps.send({ type: "chat.unsubscribe", conversationId: id });
+ subscribedIds.delete(id);
+ }
+ }
+ for (const id of desired) {
+ if (subscribedIds.has(id)) continue;
+ subscribedIds.add(id);
+ deps.send({ type: "chat.subscribe", conversationId: id });
+ if (loadedConversationIds.has(id) && !loadingConversationIds.has(id)) {
+ (chatStores.get(id) ?? watchStores.get(id))?.resync();
+ }
+ }
+ }
/**
* Coalesce live chat events to at most one fold + render per animation
@@ -77,6 +99,11 @@ export function createChatRegistry(deps: {
function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
return createChatStore({
+ ...(deps.storage === undefined
+ ? {}
+ : {
+ pendingInputStorage: createPendingInputStorage(deps.storage, conversationId),
+ }),
conversationId,
createRequestId: randomId,
model,
@@ -128,30 +155,28 @@ export function createChatRegistry(deps: {
const store = chatStores.get(conversationId) ?? watchStores.get(conversationId);
if (store === undefined) return;
loadedConversationIds.add(conversationId);
- void store.load();
+ loadingConversationIds.add(conversationId);
+ void store.load().finally(() => loadingConversationIds.delete(conversationId));
}
function refreshActiveChat(): void {
+ syncChatSubscriptions();
const activeId = deps.tabsStore.activeConversationId;
if (activeId !== null) ensureLoaded(activeId);
activeChat = getActiveChat();
}
- /**
- * Start watching a conversation's live turn events (`chat.subscribe`). Sent for
- * EVERY open conversation — not just the active one — so a backgrounded tab keeps
- * streaming a running turn, and a reloaded/second client re-attaches to an
- * in-flight turn (the server replays it from `turn-start`). Idempotent server-side;
- * the socket queues it until the connection is open. NOT needed right after
- * `chat.send` (that auto-subscribes the sending connection).
- */
+ /** Discovery creates metadata; only the focused chat and explicit viewers watch live. */
function subscribeChat(conversationId: string): void {
- deps.send({ type: "chat.subscribe", conversationId });
+ void conversationId;
+ syncChatSubscriptions();
}
/** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */
function unsubscribeChat(conversationId: string): void {
- deps.send({ type: "chat.unsubscribe", conversationId });
+ if (subscribedIds.delete(conversationId)) {
+ deps.send({ type: "chat.unsubscribe", conversationId });
+ }
}
/**
@@ -164,9 +189,11 @@ export function createChatRegistry(deps: {
* Pair with {@link unwatchConversation} on close.
*/
function watchConversation(conversationId: string): ChatStore {
+ watchedIds.add(conversationId);
// An open tab already has a live store + subscription — reuse it.
const tabStore = chatStores.get(conversationId);
if (tabStore !== undefined) {
+ syncChatSubscriptions();
ensureLoaded(conversationId);
return tabStore;
}
@@ -174,8 +201,8 @@ export function createChatRegistry(deps: {
if (existing !== undefined) return existing;
const store = createChatFor(conversationId, deps.activeModel(), deps.activeWorkspaceId());
watchStores.set(conversationId, store);
- ensureLoaded(conversationId);
subscribeChat(conversationId);
+ ensureLoaded(conversationId);
return store;
}
@@ -186,6 +213,8 @@ export function createChatRegistry(deps: {
* tab strip). Only the ephemeral watch store is disposed + unsubscribed.
*/
function unwatchConversation(conversationId: string): void {
+ watchedIds.delete(conversationId);
+ syncChatSubscriptions();
// A tab reuses its own store — leave it (and its subscription) intact.
if (chatStores.has(conversationId)) return;
const store = watchStores.get(conversationId);
@@ -207,6 +236,10 @@ export function createChatRegistry(deps: {
refreshActiveChat,
subscribeChat,
unsubscribeChat,
+ reconnect(): void {
+ subscribedIds.clear();
+ syncChatSubscriptions();
+ },
watchConversation,
unwatchConversation,
get draftStore() {
diff --git a/src/app/open-conversations.svelte.ts b/src/app/open-conversations.svelte.ts
index d97e999..9c8c25b 100644
--- a/src/app/open-conversations.svelte.ts
+++ b/src/app/open-conversations.svelte.ts
@@ -130,7 +130,15 @@ export function createOpenConversations(deps: {
* (closed on another device), and subscribes to `active` conversations'
* live streams.
*/
- async function fetchOpenConversations(): Promise<void> {
+ let pendingFetch: Promise<void> | undefined;
+ function fetchOpenConversations(): Promise<void> {
+ pendingFetch ??= loadOpenConversations().finally(() => {
+ pendingFetch = undefined;
+ });
+ return pendingFetch;
+ }
+
+ async function loadOpenConversations(): Promise<void> {
try {
const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`);
if (!res.ok) return;
@@ -203,10 +211,14 @@ export function createOpenConversations(deps: {
// 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)) {
+ if (
+ !backendIds.has(tab.conversationId) &&
+ !registry.chatStores.get(tab.conversationId)?.pendingInputs.length
+ ) {
removeTabLocally(tab.conversationId);
}
}
+ registry.refreshActiveChat();
} catch (err) {
deps.reportError(
`Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`,
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 33f93bb..90c11b3 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -270,8 +270,8 @@ function createFakeStorage(): Storage {
getItem(key: string): string | null {
return map.get(key) ?? null;
},
- key(_index: number): string | null {
- return null;
+ key(index: number): string | null {
+ return [...map.keys()][index] ?? null;
},
removeItem(key: string) {
map.delete(key);
@@ -2711,13 +2711,14 @@ describe("conversation load-on-demand", () => {
ws: WebSocketLike,
count = 3,
activeConversationId: string | null = "lazy-0",
+ workspaceCount = 1,
) {
const storage = createFakeStorage();
const conversations = Array.from({ length: count }, (_, i) => ({
id: `lazy-${i}`,
title: `Conversation ${i}`,
model: "openai/gpt-4o",
- workspaceId: "default",
+ workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`,
status: i === 1 ? "active" : "idle",
lastActivityAt: 1234,
}));
@@ -2763,7 +2764,7 @@ describe("conversation load-on-demand", () => {
fetchImpl.mock.calls
.map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search)
.filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url));
- await vi.waitFor(() => expect(store.tabs).toHaveLength(count));
+ await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount)));
if (activeConversationId !== null) {
await vi.waitFor(() =>
expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`),
@@ -2785,7 +2786,7 @@ describe("conversation load-on-demand", () => {
]);
expect(
parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
- ).toHaveLength(300);
+ ).toHaveLength(1);
expect(store.tabs[299]?.title).toBe("Conversation 299");
expect(store.conversationStatus("lazy-1")).toBe("active");
ws.feedServerMessage({
@@ -2801,6 +2802,24 @@ describe("conversation load-on-demand", () => {
}
}, 20_000);
+ it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => {
+ const ws = fakeSocket();
+ const { store } = await setup(ws, 339, "lazy-0", 24);
+ try {
+ ws.resolveOpen();
+ const subscriptions = parseSent(ws).filter(
+ (message) => (message as { type: string }).type === "chat.subscribe",
+ );
+ expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]);
+ store.setActiveWorkspace("workspace-1");
+ store.selectTab("lazy-1");
+ expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" });
+ expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" });
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
it("does not load any history while the active slot is a draft", async () => {
const { store, requests } = await setup(fakeSocket(), 300, null);
try {
@@ -2813,13 +2832,13 @@ describe("conversation load-on-demand", () => {
}
}, 20_000);
- it("loads a tab once, including repeated activation while its load is pending", async () => {
+ it("loads a tab once while pending and catches up a previously loaded tab on reactivation", async () => {
const { store, requests } = await setup(fakeSocket());
try {
store.selectTab("lazy-2");
store.selectTab("lazy-0");
store.selectTab("lazy-2");
- await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2);
expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234);
} finally {
@@ -2827,7 +2846,7 @@ describe("conversation load-on-demand", () => {
}
});
- it("re-subscribes all tabs on reconnect but resyncs only loaded tabs and watches", async () => {
+ it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => {
const ws = reconnectableSocket();
const { store, requests } = await setup(ws);
try {
@@ -2847,7 +2866,7 @@ describe("conversation load-on-demand", () => {
).toBe(true);
expect(
parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
- ).toHaveLength(4);
+ ).toHaveLength(2);
} finally {
store.dispose();
}
@@ -3129,3 +3148,53 @@ it("routes omitted replay only to the matching watch/tab without WS resend or an
store.dispose();
}
});
+
+describe("unconfirmed first-message recovery", () => {
+ it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => {
+ const storage = createFakeStorage();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ if (String(input).endsWith("/conversations?status=active,idle"))
+ return Response.json({ conversations: [] });
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const first = createAppStore({
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ fetchImpl,
+ socketFactory: () => ws,
+ });
+ ws.resolveOpen();
+ first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]);
+ const id = first.activeConversationId;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(first.activeChat.pendingInputs).toHaveLength(1);
+ first.dispose();
+ const nextSocket = fakeSocket();
+ const next = createAppStore({
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ fetchImpl,
+ socketFactory: () => nextSocket,
+ });
+ try {
+ nextSocket.resolveOpen();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true);
+ expect(next.activeChat.pendingInputs).toMatchObject([
+ { request: { message: "must survive" } },
+ ]);
+ const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? "";
+ nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId });
+ expect(next.activeChat.pendingInputs).toEqual([]);
+ expect(
+ parseSent(nextSocket).some((message) =>
+ ["chat.send", "chat.queue"].includes((message as { type: string }).type),
+ ),
+ ).toBe(false);
+ } finally {
+ next.dispose();
+ }
+ });
+});
diff --git a/src/app/subscription-budget.test.ts b/src/app/subscription-budget.test.ts
new file mode 100644
index 0000000..03cdaa1
--- /dev/null
+++ b/src/app/subscription-budget.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import {
+ liveConversationIds,
+ MAX_CHAT_SUBSCRIPTIONS,
+ MAX_SURFACE_SUBSCRIPTIONS,
+} from "./subscription-budget";
+
+describe("live subscription budget", () => {
+ it("reserves the focused conversation even when hundreds of viewers exist", () => {
+ const viewers = Array.from({ length: 339 }, (_, i) => `viewer-${i}`);
+ const selected = liveConversationIds("focused", viewers);
+ expect(selected.size).toBe(MAX_CHAT_SUBSCRIPTIONS);
+ expect(selected.has("focused")).toBe(true);
+ expect(selected.size + MAX_SURFACE_SUBSCRIPTIONS).toBeLessThanOrEqual(256);
+ });
+ it("deduplicates a modal viewing the focused chat and permits drafts", () => {
+ expect([...liveConversationIds("same", ["same", "other"])]).toEqual(["same", "other"]);
+ expect([...liveConversationIds(null, [])]).toEqual([]);
+ });
+});
diff --git a/src/app/subscription-budget.ts b/src/app/subscription-budget.ts
new file mode 100644
index 0000000..fc0ccb2
--- /dev/null
+++ b/src/app/subscription-budget.ts
@@ -0,0 +1,15 @@
+/** Split the server's 256 shared slots so catalog growth cannot evict chat watchers. */
+export const MAX_CHAT_SUBSCRIPTIONS = 128;
+export const MAX_SURFACE_SUBSCRIPTIONS = 128;
+
+export function liveConversationIds(
+ activeId: string | null,
+ watchedIds: Iterable<string>,
+): Set<string> {
+ return new Set(
+ [...new Set([...(activeId === null ? [] : [activeId]), ...watchedIds])].slice(
+ 0,
+ MAX_CHAT_SUBSCRIPTIONS,
+ ),
+ );
+}
diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts
index 400f612..c79c8b6 100644
--- a/src/app/surface-protocol.svelte.ts
+++ b/src/app/surface-protocol.svelte.ts
@@ -9,6 +9,7 @@ import {
subscribe as protocolSubscribe,
unsubscribe as protocolUnsubscribe,
} from "../core/protocol";
+import { MAX_SURFACE_SUBSCRIPTIONS } from "./subscription-budget";
/** Shared app/plugin surface operations over the single app protocol state. */
export function createSurfaceProtocol(deps: {
@@ -43,7 +44,19 @@ export function createSurfaceProtocol(deps: {
*/
function syncSubscriptions(): void {
const cid = deps.focusedConversationId();
- for (const entry of protocol.catalog) {
+ const catalogIds = new Set(
+ protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS).map((e) => e.id),
+ );
+ for (const id of [...protocol.subscriptions.keys()]) {
+ if (!catalogIds.has(id)) {
+ const result = protocolUnsubscribe(protocol, id);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+ }
+ for (const entry of protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS)) {
// A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id
// so a conversation switch doesn't churn a redundant unsubscribe+subscribe
// round trip ([email protected] catalog `scope`; ABSENT = assume
@@ -55,16 +68,6 @@ export function createSurfaceProtocol(deps: {
deps.send(msg);
}
}
- const catalogIds = new Set(protocol.catalog.map((e) => e.id));
- for (const id of [...protocol.subscriptions.keys()]) {
- if (!catalogIds.has(id)) {
- const result = protocolUnsubscribe(protocol, id);
- protocol = result.state;
- for (const msg of result.outgoing) {
- deps.send(msg);
- }
- }
- }
receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id)));
}
@@ -93,6 +96,11 @@ export function createSurfaceProtocol(deps: {
}
function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ if (
+ !protocol.subscriptions.has(surfaceId) &&
+ protocol.subscriptions.size >= MAX_SURFACE_SUBSCRIPTIONS
+ )
+ return;
const result = protocolSubscribe(
protocol,
surfaceId,
diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts
index 5ac4f70..405e561 100644
--- a/src/app/surface-protocol.test.ts
+++ b/src/app/surface-protocol.test.ts
@@ -63,3 +63,34 @@ describe("conversation-scoped surface lookup", () => {
expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
});
});
+
+describe("surface subscription budget", () => {
+ it("bounds a large catalog and releases old slots before subscribing a replacement catalog", () => {
+ const live = new Set<string>();
+ let peak = 0;
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId: () => "chat",
+ send(message) {
+ if (message.type === "subscribe") live.add(message.surfaceId);
+ if (message.type === "unsubscribe") live.delete(message.surfaceId);
+ peak = Math.max(peak, live.size);
+ },
+ });
+ for (const prefix of ["old", "new"]) {
+ surfaces.handleServerMessage({
+ type: "catalog",
+ catalog: Array.from({ length: 339 }, (_, i) => ({
+ id: `${prefix}-${i}`,
+ region: "inline",
+ title: "Surface",
+ scope: "global" as const,
+ })),
+ });
+ expect(live.size).toBe(128);
+ expect([...live].every((id) => id.startsWith(prefix))).toBe(true);
+ }
+ surfaces.subscribeSurfaceOp("overflow");
+ expect(live.has("overflow")).toBe(false);
+ expect(peak).toBe(128);
+ });
+});
diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte
index 62b9344..d132fad 100644
--- a/src/app/views/ChatPane.svelte
+++ b/src/app/views/ChatPane.svelte
@@ -7,7 +7,7 @@
* preservation, the ScrollToBottom affordance, and the empty-state
* watermark. Keyed on the active conversation exactly like today's ChatView.
*/
- import { ChatView, isClaudeSubscriptionModel } from "../../features/chat";
+ import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat";
import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll";
import { preserveViewportAcross, type AppViewPorts } from "../view-ports";
@@ -91,9 +91,10 @@
{cacheTimer}
/>
{/key}
+ <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} />
</div>
</div>
- {#if store.activeChat.chunks.length === 0}
+ {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.length === 0}
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center"
aria-hidden="true"
diff --git a/src/app/ws-handlers.ts b/src/app/ws-handlers.ts
index b9adb30..e4fd916 100644
--- a/src/app/ws-handlers.ts
+++ b/src/app/ws-handlers.ts
@@ -1,8 +1,10 @@
import type {
+ ChatAcceptedMessage,
ChatDeltaMessage,
ChatErrorMessage,
ChatQueuedMessage,
ChatResyncRequiredMessage,
+ ChatSubscriptionRejectedMessage,
ConversationCompactedMessage,
ConversationOpenMessage,
ConversationStatusChangedMessage,
@@ -55,7 +57,13 @@ export function createWsHandlers(deps: {
tileLayoutStore,
} = deps;
function handleChatMessage(
- msg: ChatDeltaMessage | ChatErrorMessage | ChatQueuedMessage | ChatResyncRequiredMessage,
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
): void {
let targetId: string | undefined;
if (msg.type === "chat.delta") {
@@ -72,7 +80,13 @@ export function createWsHandlers(deps: {
}
}
- if (msg.type === "chat.queued" || msg.type === "chat.resync-required") return;
+ if (
+ msg.type === "chat.accepted" ||
+ msg.type === "chat.subscription-rejected" ||
+ msg.type === "chat.queued" ||
+ msg.type === "chat.resync-required"
+ )
+ return;
// fallback: try all stores (chat.error without conversationId)
for (const store of registry.chatStores.values()) {
@@ -166,23 +180,8 @@ export function createWsHandlers(deps: {
// workspace too. Re-declare it so the re-pushed catalog is filtered
// (plan 06 §6.4).
deps.send(deps.workspaceSetMsg(deps.activeWorkspaceId()));
- // Re-attach to every open conversation's turn stream. A turn that kept
- // running while we were disconnected resumes streaming (server replays it
- // from `turn-start`); one that sealed while we were gone is committed from
- // history by `resync()` (which also clears a now-stale "generating").
- // Unloaded tabs stay lazy — only their cheap live subscription is restored.
- for (const tab of tabsStore.tabs) {
- registry.subscribeChat(tab.conversationId);
- if (registry.loadedConversationIds.has(tab.conversationId)) {
- registry.chatStores.get(tab.conversationId)?.resync();
- }
- }
- // Re-attach to every MODAL watch too (a run-chat modal open across a
- // reconnect keeps streaming). Watch stores are separate from tabs.
- for (const [watchId, watchStore] of registry.watchStores) {
- registry.subscribeChat(watchId);
- if (registry.loadedConversationIds.has(watchId)) watchStore.resync();
- }
+ registry.reconnect();
+ void conversations.fetchOpenConversations();
},
onExtensionsChanged(_msg: ExtensionsChangedMessage) {
// §11 multi-client coherence: another client (or this one) mutated the