diff options
Diffstat (limited to 'src')
293 files changed, 40235 insertions, 4615 deletions
diff --git a/src/App.svelte b/src/App.svelte index 27c9271..32eaf70 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { App, createAppStore } from "./app"; import { createHistoryAdapter } from "./adapters/history"; + import type { WorkspaceLayoutPort } from "./features/plugins"; import { createWorkspaceHttp, createWorkspaceStore, @@ -65,6 +66,23 @@ function navigate(path: string): void { history.navigate(path); } + + // plan 04 §4.4: the workspace plugins modal's "Reset layout to default" + // affordance. The port gates on an ACTIVE layout provider (read through the + // store's reactive getters, so enable/disable mid-session flips the button) + // and resets via the layout store (`DELETE /workspaces/:id/layout` → the + // server re-seeds → the WS broadcast re-renders every client). + const layoutPort: WorkspaceLayoutPort = { + get providerActive() { + return store.layout.providerState === "present"; + }, + reset: (workspaceId) => store.layout.resetToDefault(workspaceId), + // The plugins modal's per-workspace toggles can change WHICH layout + // plugin serves the workspace (the per-workspace activation rule) — the + // store re-probes only the active workspace (non-active toggles are + // picked up by their own probe on switch). + refresh: (workspaceId) => store.layout.onWorkspacePluginsChanged(workspaceId), + }; </script> {#if route.kind === "home"} @@ -73,6 +91,9 @@ onNavigate={navigate} computers={store.computers} hasActive={(id) => store.workspaceHasActiveConversations(id)} + plugins={store.plugins} + layout={layoutPort} + tiles={store.tileLayout} /> {:else} <App {store} onNavigate={navigate} /> diff --git a/src/adapters/idb/index.test.ts b/src/adapters/idb/index.test.ts index c05c605..aa56f15 100644 --- a/src/adapters/idb/index.test.ts +++ b/src/adapters/idb/index.test.ts @@ -30,6 +30,16 @@ describe("createIdbChunkStore", () => { expect(loaded[0]?.chunk).toEqual(textChunk("a")); }); + it("round-trips a chunk's turnId (and upserts it onto an older row)", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b")]); + expect((await store.load("conv1")).map((c) => c.turnId)).toEqual([undefined, undefined]); + await store.append("conv1", [{ ...makeChunk(1, "a"), turnId: "t1" }]); + const loaded = await store.load("conv1"); + expect(loaded.map((c) => c.turnId)).toEqual(["t1", undefined]); + expect("turnId" in (loaded[1] ?? {})).toBe(false); + }); + it("append out-of-order still loads seq-ordered", async () => { const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); const chunks = [makeChunk(3, "c"), makeChunk(1, "a"), makeChunk(2, "b")]; @@ -118,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 96b2cbc..8b1848a 100644 --- a/src/adapters/idb/index.ts +++ b/src/adapters/idb/index.ts @@ -14,11 +14,13 @@ interface ChunkRecord { seq: number; role: StoredChunk["role"]; chunk: StoredChunk["chunk"]; + turnId?: string; } interface MetaRecord { conversationId: string; lastAccess: number; + historyEpoch?: number; } export interface CreateIdbChunkStoreOptions { @@ -70,7 +72,11 @@ function keyRangeFor(conversationId: string): IDBKeyRange { } function chunksToStoredChunks(records: ChunkRecord[]): StoredChunk[] { - return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk })); + return records.map((r) => + typeof r.turnId === "string" + ? { seq: r.seq, role: r.role, chunk: r.chunk, turnId: r.turnId } + : { seq: r.seq, role: r.role, chunk: r.chunk }, + ); } export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): ConversationChunkStore { @@ -87,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"); @@ -113,10 +139,13 @@ export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): Conversa seq: c.seq, role: c.role, chunk: c.chunk, + ...(c.turnId !== undefined ? { turnId: c.turnId } : {}), } 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 1309db0..e1d7d22 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,9 +1,17 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, + ChatQueuedMessage, + ChatResyncRequiredMessage, + ChatSubscriptionRejectedMessage, ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, + ConversationTabGroupChangedMessage, + ExtensionsChangedMessage, + LayoutChangedMessage, + TileLayoutChangedMessage, WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -20,13 +28,40 @@ 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). */ onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; /** Broadcast when a conversation's history has been compacted (reload needed). */ onConversationCompacted?: (msg: ConversationCompactedMessage) => void; + /** Broadcast when a conversation's (visual) tab group parent changes. */ + onConversationTabGroupChanged?: (msg: ConversationTabGroupChangedMessage) => void; + /** + * Broadcast after every plugin-manager mutation (plan 05 §11): every + * connected frontend refetches its extension list (the server re-pushes the + * workspace-filtered surface catalog itself). + */ + onExtensionsChanged?: (msg: ExtensionsChangedMessage) => void; + /** + * Broadcast after every successful layout write (plan 02 §2.5): the FULL + * tree for one workspace. The consumer filters by its active workspace. + */ + onLayout?: (msg: LayoutChangedMessage) => void; + /** + * Broadcast after every successful home tile-layout write: the FULL + * document for one device orientation (wide/tall). The consumer filters by + * its active geometry mode. + */ + onTileLayout?: (msg: TileLayoutChangedMessage) => void; onReopen?: () => void; socketFactory?: (url: string) => WebSocketLike; } @@ -44,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 { @@ -54,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); } @@ -67,22 +112,47 @@ 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") { opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); + } else if (msg.type === "conversation.tabGroupChanged") { + opts.onConversationTabGroupChanged?.(msg as ConversationTabGroupChangedMessage); } else if (msg.type === "conversation.compacted") { opts.onConversationCompacted?.(msg as ConversationCompactedMessage); + } else if (msg.type === "extensions-changed") { + opts.onExtensionsChanged?.(msg as ExtensionsChangedMessage); + } else if (msg.type === "layout") { + opts.onLayout?.(msg as LayoutChangedMessage); + } else if (msg.type === "tile-layout") { + opts.onTileLayout?.(msg as TileLayoutChangedMessage); } else { opts.onMessage(msg as SurfaceServerMessage); } } }; - socket.onclose = () => { + socket.onclose = (ev) => { + clearHealthyTimer(); if (disposed) return; + if (isOpen && (ev.code === 1000 || ev.code === 1001)) reconnectAttempt = 0; isOpen = false; scheduleReconnect(); }; @@ -112,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 dd2b773..4bc2aad 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; +describe("serialize — workspace.set (plan 06 §6.4)", () => { + it("serializes a workspace.set client message", () => { + const msg = { type: "workspace.set" as const, workspaceId: "ws-1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); +}); + describe("serialize", () => { it("serializes a subscribe message", () => { const msg = { type: "subscribe" as const, surfaceId: "s1" }; @@ -232,6 +239,38 @@ describe("parseServerMessage", () => { }); }); + it("parses a conversation.open message with a persisted model", () => { + const data = JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + model: "openrouter/stealth/ox-alpha", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + model: "openrouter/stealth/ox-alpha", + }); + }); + + it("omits the model when conversation.open carries a non-string/empty model", () => { + const result = parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + model: 42, + }), + ); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + }); + it("returns null for conversation.open with missing conversationId", () => { expect( parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })), @@ -398,3 +437,204 @@ describe("nextBackoffMs", () => { } }); }); + +describe("parseServerMessage — extensions-changed (plan 05)", () => { + it("parses the broadcast with a pluginId", () => { + const result = parseServerMessage( + JSON.stringify({ type: "extensions-changed", action: "unload", pluginId: "mcp" }), + ); + expect(result).toEqual({ type: "extensions-changed", action: "unload", pluginId: "mcp" }); + }); + + it("parses a scan broadcast (no pluginId)", () => { + const result = parseServerMessage( + JSON.stringify({ type: "extensions-changed", action: "scan" }), + ); + expect(result).toEqual({ type: "extensions-changed", action: "scan" }); + }); + + it("rejects an unknown action", () => { + expect( + parseServerMessage(JSON.stringify({ type: "extensions-changed", action: "explode" })), + ).toBeNull(); + }); + + it("rejects a missing action", () => { + expect(parseServerMessage(JSON.stringify({ type: "extensions-changed" }))).toBeNull(); + }); +}); + +describe("parseServerMessage — layout (plan 02)", () => { + const TREE = { version: 1, root: { kind: "leaf", view: "chat" } }; + + it("parses the broadcast (workspaceId + full tree)", () => { + const result = parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: TREE }), + ); + expect(result).toEqual({ type: "layout", workspaceId: "ws-a", tree: TREE }); + }); + + it("rejects a missing/invalid workspaceId", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout", tree: TREE }))).toBeNull(); + expect( + parseServerMessage(JSON.stringify({ type: "layout", workspaceId: 7, tree: TREE })), + ).toBeNull(); + }); + + it("rejects a missing/invalid tree (wrong version, missing root, non-object)", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a" }))).toBeNull(); + expect( + parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 2, root: {} } }), + ), + ).toBeNull(); + expect( + parseServerMessage( + JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 1 } }), + ), + ).toBeNull(); + expect( + parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: 7 })), + ).toBeNull(); + }); + + it("an unknown type still parses to null (the layout addition doesn't loosen the guard)", () => { + expect(parseServerMessage(JSON.stringify({ type: "layout.unknown" }))).toBeNull(); + }); +}); + +describe("parseServerMessage — subagent tab groups", () => { + it("forwards the effective tabGroupParentId on conversation.open", () => { + const result = parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "child", + workspaceId: "w1", + tabGroupParentId: "parent", + }), + ); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "child", + workspaceId: "w1", + tabGroupParentId: "parent", + }); + }); + + it("omits a non-string/empty tabGroupParentId on conversation.open", () => { + for (const tabGroupParentId of ["", 42, null]) { + const result = parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + tabGroupParentId, + }), + ); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + } + }); + + it("parses conversation.tabGroupChanged with a parent id and with null (ungrouped)", () => { + for (const tabGroupParentId of ["parent", null]) { + const msg = { + type: "conversation.tabGroupChanged", + conversationId: "c1", + workspaceId: "w1", + tabGroupParentId, + }; + expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg); + } + }); + + it("rejects a malformed conversation.tabGroupChanged", () => { + const base = { type: "conversation.tabGroupChanged", conversationId: "c1", workspaceId: "w1" }; + expect(parseServerMessage(JSON.stringify(base))).toBeNull(); // missing (undefined) parent + expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: "" }))).toBeNull(); + expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: 1 }))).toBeNull(); + const { workspaceId: _w, ...noWorkspace } = base; + expect( + parseServerMessage(JSON.stringify({ ...noWorkspace, tabGroupParentId: "p" })), + ).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 03ef763..d167669 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -1,9 +1,15 @@ import type { ChatDeltaMessage, ChatErrorMessage, + ChatQueuedMessage, + ChatResyncRequiredMessage, ConversationCompactedMessage, ConversationOpenMessage, ConversationStatusChangedMessage, + ConversationTabGroupChangedMessage, + ExtensionsChangedMessage, + LayoutChangedMessage, + TileLayoutChangedMessage, WsClientMessage, WsServerMessage, } from "@dispatch/transport-contract"; @@ -19,11 +25,19 @@ 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", + "conversation.tabGroupChanged", + "extensions-changed", + "layout", + "tile-layout", ]); /** Serialize a client message to a JSON string for the wire. */ @@ -97,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; @@ -111,15 +169,43 @@ 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; if (typeof parsed.workspaceId !== "string") return null; + // Forward the persisted per-conversation model when the backend includes + // it, so a client can stamp the opened tab with the conversation's ACTUAL + // model (fix-toolcall-duplicate-keys plan Part 4). + const model = parsed.model; + // The EFFECTIVE tab group parent, so a summoned subagent's tab lands in + // its summoner's group immediately. + const tabGroupParentId = parsed.tabGroupParentId; const msg: ConversationOpenMessage = { type: "conversation.open", conversationId: parsed.conversationId, workspaceId: parsed.workspaceId, + ...(typeof model === "string" && model.length > 0 ? { model } : {}), + ...(typeof tabGroupParentId === "string" && tabGroupParentId.length > 0 + ? { tabGroupParentId } + : {}), + }; + return msg; + } + case "conversation.tabGroupChanged": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.workspaceId !== "string") return null; + const parent = parsed.tabGroupParentId; + if (parent !== null && (typeof parent !== "string" || parent.length === 0)) return null; + const msg: ConversationTabGroupChangedMessage = { + type: "conversation.tabGroupChanged", + conversationId: parsed.conversationId, + workspaceId: parsed.workspaceId, + tabGroupParentId: parent, }; return msg; } @@ -148,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, @@ -157,6 +246,63 @@ export function parseServerMessage(data: string): WsServerMessage | null { }; return msg; } + case "extensions-changed": { + // Plugin-manager broadcast (plan 05): the payload is intentionally + // minimal — clients refetch GET /plugins + the (server re-pushed) + // surface catalog rather than diffing here. + const action = parsed.action; + if (action !== "load" && action !== "unload" && action !== "reload") { + if (action !== "scan" && action !== "forget") return null; + } + const pluginId = parsed.pluginId; + const msg: ExtensionsChangedMessage = { + type: "extensions-changed", + action, + ...(pluginId !== undefined && typeof pluginId === "string" ? { pluginId } : {}), + }; + return msg; + } + case "layout": { + // Layout-provider broadcast (plan 02 §2.5): the FULL tree for ONE + // workspace (decision 7 — no patch protocol). Light shape checks here + // (the tree's structural validation is the backend's write-time job); + // the store filters by its active workspace. + if (typeof parsed.workspaceId !== "string") return null; + const tree = parsed.tree; + if ( + tree === null || + typeof tree !== "object" || + Array.isArray(tree) || + (tree as { version?: unknown }).version !== 1 || + typeof (tree as { root?: unknown }).root !== "object" || + (tree as { root?: unknown }).root === null + ) { + return null; + } + const msg: LayoutChangedMessage = { + type: "layout", + workspaceId: parsed.workspaceId, + tree: tree as LayoutChangedMessage["tree"], + }; + return msg; + } + case "tile-layout": { + // Home tile-layout broadcast (the synced home grid): the FULL document + // for ONE device orientation. Light shape checks here (the document's + // structural validation is the backend's write-time job); the store + // filters by its active geometry mode. + if (parsed.mode !== "wide" && parsed.mode !== "tall") return null; + const data = parsed.data; + if (data === null || !isRecord(data) || data.version !== 2 || !Array.isArray(data.tiles)) { + return null; + } + const msg: TileLayoutChangedMessage = { + type: "tile-layout", + mode: parsed.mode, + data: data as unknown as TileLayoutChangedMessage["data"], + }; + return msg; + } default: return null; } diff --git a/src/app.css b/src/app.css index f5b269c..3c7b17c 100644 --- a/src/app.css +++ b/src/app.css @@ -12,6 +12,237 @@ themes: monokai --default; } +/* Inset "well" — the Artix wiki's code blocks (darkstrap `pre`): a panel that + reads as pressed INTO the page — the page colour darkened, a dark inner + shadow under the top edge, a faint light catch-line under the bottom edge. + Shared by the chat's thinking/tool blocks (LazyCollapse) and the sidebar + views (TilePanels). darkstrap: background rgba(0,0,0,.3); box-shadow + rgba(255,255,255,.1) 0 1px 0, rgba(0,0,0,.8) 0 1px 7px 0 inset; radius 4px. + Ported onto the active daisyUI theme so it always matches, and toned down a + step: the fill is the theme's page colour (base-100 — both the chat and the + sidebar sit on it) darkened 18% (darkstrap: 30%), computed OPAQUE and exposed + as --inset-well-bg so sticky children can paint the same colour; the inner + shadow is 0.6 (darkstrap: 0.8); the catch-line is the theme's text colour at + 10% instead of plain white. In @layer components so utilities can override. */ +@layer components { + .inset-well { + --inset-well-bg: color-mix(in srgb, var(--color-base-100) 82%, black); + background-color: var(--inset-well-bg); + border: 0; + border-radius: var(--radius-box); + box-shadow: + 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent), + inset 0 1px 7px 0 rgb(0 0 0 / 0.6); + } +} + +/* App navbar — the Artix wiki's navbar (darkstrap `.navbar-inner` on a + fixed-top bar): a top-to-bottom gradient (#444 → #222), a soft drop shadow + under the bar (0 1px 10px), and engraved text (1px black text-shadow). + Shared by the workspace top bar (App) and the home page header. Ported onto + the active daisyUI theme so it always matches. darkstrap's bar is LIGHTER + than its near-black page (that's what makes the shadow read), so the + gradient is shifted to keep that relationship on our lighter page: it runs + from the page colour (base-100) lifted 20% toward the text colour down to + base-100 lifted 3%. No bottom border (darkstrap has a 1px #030303 one — + removed by user request; the gradient + shadow carry the edge). The shadow + is 0.5 (darkstrap: 0.1 — invisible on our lighter page). Positioned + + z-indexed so the shadow paints over the content below. */ +@layer components { + .app-navbar { + position: relative; + z-index: 10; + flex-shrink: 0; + background-image: linear-gradient( + to bottom, + color-mix(in srgb, var(--color-base-100) 80%, var(--color-base-content)), + color-mix(in srgb, var(--color-base-100) 97%, var(--color-base-content)) + ); + box-shadow: 0 1px 10px rgb(0 0 0 / 0.5); + } + /* Engraved navbar text — titles only; on tiny faint labels it muddies. */ + .app-navbar-engraved { + text-shadow: 0 1px 0 black; + } +} + +/* Navbar buttons — the navbar's (`.app-navbar`) raised button, in the + same depth language as the rest of the UI: at rest they're RAISED (a + gradient a step lighter than the bar, a light highlight inside the top edge, + a thin dark edge + small drop below, light primary-blue text), and when + PRESSED they flip to the inset well (dark inner shadow) — pushed into the + bar. `.navbar-btn-primary` is the main action: the same button, tinted with + the theme's primary colour. `.navbar-btn-icon` is the square icon-only form. + Self-contained (not DaisyUI .btn) so nothing fights the gradients; colours + all come from the theme. */ +@layer components { + .navbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + padding-inline: 0.75rem; + border-radius: var(--radius-field); + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; + user-select: none; + /* The theme's primary, lifted toward white: crisp on the grey gradient + (plain primary read dim, and an engraved text-shadow muddied it at this + size — the shadow stays on the big titles only). */ + color: color-mix(in srgb, var(--color-primary) 75%, white); + border: 1px solid rgb(0 0 0 / 0.55); + background-image: linear-gradient( + to bottom, + color-mix(in srgb, var(--color-base-100) 72%, var(--color-base-content)), + color-mix(in srgb, var(--color-base-100) 90%, var(--color-base-content)) + ); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.12), + 0 1px 2px rgb(0 0 0 / 0.45); + transition: + filter 120ms ease-out, + box-shadow 120ms ease-out; + } + .navbar-btn:hover { + filter: brightness(1.12); + } + .navbar-btn:active, + .navbar-btn[aria-expanded="true"] { + /* Pressed → the inset well: pushed into the bar. */ + filter: none; + background-image: none; + background-color: color-mix(in srgb, var(--color-base-100) 82%, black); + box-shadow: + inset 0 1px 5px rgb(0 0 0 / 0.7), + 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent); + } + .navbar-btn:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } + .navbar-btn-icon { + width: 2rem; + padding-inline: 0; + } + /* Secondary navbar actions: visually just text — no background, border or + shadow. The light primary-blue colour (same as the buttons' text) and the + spacing mark them as clickable; hover brightens + underlines, press dims. */ + .navbar-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2rem; + min-width: 2rem; /* icon-only (compact) links keep a comfortable tap target */ + padding-inline: 0.375rem; + border-radius: var(--radius-field); + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + cursor: pointer; + user-select: none; + background: none; + border: 0; + color: color-mix(in srgb, var(--color-primary) 75%, white); + text-underline-offset: 4px; + transition: color 120ms ease-out; + } + .navbar-link:hover { + color: color-mix(in srgb, var(--color-primary) 45%, white); + text-decoration: underline; + } + .navbar-link:active { + color: var(--color-primary); + } + .navbar-link:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } + .navbar-btn-primary { + color: var(--color-primary-content); + background-image: linear-gradient( + to bottom, + color-mix(in srgb, var(--color-primary) 82%, white), + color-mix(in srgb, var(--color-primary) 88%, black) + ); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.35), + 0 1px 2px rgb(0 0 0 / 0.45); + } + .navbar-btn-primary:active { + background-color: color-mix(in srgb, var(--color-primary) 80%, black); + } +} + +/* Raised button — the exact mirror of `.inset-well`, for a neutral surface + that pops OUT where the wells sink in (the sidebar's "+ Add view" under the + view wells). The well = page colour darkened 18%, dark shadow INSIDE under + the top edge (inset 0 1px 7px), light catch-line OUTSIDE below (0 1px 0). + Mirrored: page colour LIFTED toward the text colour, light highlight INSIDE + the top edge (inset 0 1px 0), the dark shadow OUTSIDE below (0 1px 7px), + plus a faint top-lit gradient. Pressed → it becomes the well (pushed in). */ +@layer components { + .raised-btn { + display: flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--radius-box); + cursor: pointer; + color: color-mix(in srgb, var(--color-primary) 75%, white); + background-color: color-mix(in srgb, var(--color-base-100) 90%, var(--color-base-content)); + background-image: linear-gradient(to bottom, rgb(255 255 255 / 0.06), rgb(0 0 0 / 0.1)); + box-shadow: + inset 0 1px 0 color-mix(in srgb, var(--color-base-content) 18%, transparent), + 0 1px 7px 0 rgb(0 0 0 / 0.6); + transition: + filter 120ms ease-out, + box-shadow 120ms ease-out; + } + .raised-btn:hover { + filter: brightness(1.12); + } + .raised-btn:active { + filter: none; + background-image: none; + background-color: color-mix(in srgb, var(--color-base-100) 82%, black); + box-shadow: + 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent), + inset 0 1px 7px 0 rgb(0 0 0 / 0.6); + } + .raised-btn:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } +} + +/* Raised — the INVERSE of `.inset-well`, for things that pop OUT of the page + (the home page's workspace tiles). Where the well has a dark shadow INSIDE + under its top edge and a light catch-line OUTSIDE below it, a raised surface + has a light highlight INSIDE along its top edge, a soft shadow OUTSIDE below + it, and a faint top-lit gradient over its own colour (lighter top → darker + bottom — convex, where the well is concave). Built on Tailwind's shadow + variables (not a bare box-shadow) so it COMPOSES with the ring / shadow + utilities: the inner highlight rides --tw-inset-shadow (kept by + `hover:shadow-xl`, rings, drag), the drop rides --tw-shadow (which + `hover:shadow-xl` / `shadow-2xl` swap for a bigger lift). Works on any + background colour (the tiles' tone colours), so it always matches the theme. */ +@layer components { + .raised { + --tw-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 0.45), inset 0 -1px 0 rgb(0 0 0 / 0.25); + --tw-shadow: 0 2px 9px 0 rgb(0 0 0 / 0.7); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow, 0 0 #0000), + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + background-image: linear-gradient(to bottom, rgb(255 255 255 / 0.14), rgb(0 0 0 / 0.14)); + } +} + /* Rendered-Markdown (assistant messages) typography — scoped to .markdown-body so it never leaks into the rest of the app. */ .markdown-body { @@ -127,3 +358,28 @@ body, body { overflow: hidden; } + +/* App-shell height. NOT `h-screen` (100vh): on iOS/iPadOS WebKit (Safari, + Vivaldi, …) 100vh is the viewport with the browser toolbars RETRACTED, so + while they're showing the bottom of the app sits underneath them (the + sidebar's last item / the composer looked flush against the bottom edge). + 100dvh tracks the visible viewport as the toolbars show/hide; 100vh stays as + the fallback for engines without dvh. */ +.h-app { + height: 100vh; +} +@supports (height: 100dvh) { + .h-app { + height: 100dvh; + } +} + +/* Transcript rows: skip layout + paint for rows scrolled out of view. A long + turn has hundreds of rows; without this every one is laid out on first + paint (and on every resize). `contain-intrinsic-size: auto <h>` makes the + browser remember each row's last rendered height, so scrolling back up + doesn't jump; the 64px is only the estimate for never-rendered rows. */ +.chat-row { + content-visibility: auto; + contain-intrinsic-size: auto 64px; +} diff --git a/src/app/App.svelte b/src/app/App.svelte index 7e5ac7d..d557149 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,847 +1,222 @@ -<script lang="ts"> - import type { ImageInput, ReasoningEffort } from "@dispatch/transport-contract"; - import type { InvokeMessage } from "@dispatch/ui-contract"; - import { tick } from "svelte"; - import Table from "../components/Table.svelte"; - import { - CacheWarmingView, - manifest as cacheWarmingManifest, - type WarmFeedback, - } from "../features/cache-warming"; - import { - ChatView, - CompactionView, - Composer, - manifest as chatManifest, - ModelSelector, - ReasoningEffortSelector, - type CompactNowResult, - type ComposerStatus, - type ReasoningEffortSaveResult, - type SaveCompactPercentResult, - } from "../features/chat"; - import { manifest as conversationCacheManifest } from "../features/conversation-cache"; - import { manifest as markdownManifest } from "../features/markdown"; - import { - McpStatusView, - manifest as mcpManifest, - type McpStatusResult, - } from "../features/mcp"; - import { - ChatLimitField, - manifest as settingsManifest, - type ChatLimitSaveResult, - } from "../features/settings"; - import { - createSmartScrollController, - manifest as smartScrollManifest, - ScrollToBottom, - } from "../features/smart-scroll"; - import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host"; - import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue"; - import { parseTodoPayload } from "../features/surface-host/logic/todo"; - import TodoList from "../features/surface-host/ui/TodoList.svelte"; - import { manifest as tabsManifest, TabList } from "../features/tabs"; - import { manifest as viewsManifest, ViewSidebar } from "../features/views"; - import { - CwdField, - type CwdSaveResult, - LspStatusView, - type LspStatusResult, - manifest as cwdLspManifest, - } from "../features/cwd-lsp"; - import { - ComputerField, - manifest as computerManifest, - type ComputerSaveResult, - type ComputerStatusResult, - type LoadComputerStatus, - type SaveComputer, - type TestComputer, - type TestComputerResult, - } from "../features/computer"; - import { - HeartbeatView, - manifest as heartbeatManifest, - RunModal, - type HeartbeatConfigResult, - type HeartbeatNextRunResult, - type HeartbeatRunView, - type HeartbeatRunsResult, - type HeartbeatStopResult, - } from "../features/heartbeat"; - import { - ConcurrencyView, - manifest as concurrencyManifest, - type DeleteConcurrencyLimit, - type LoadConcurrencyLimits, - type LoadConcurrencyStatus, - type SaveConcurrencyCooldown, - type SaveConcurrencyLimit, - } from "../features/concurrency"; - import type { ChatStore } from "../features/chat"; - import { - SystemPromptBuilder, - type LoadSystemPrompt as LoadSystemPromptAlias, - type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias, - type SaveSystemPrompt as SaveSystemPromptAlias, - manifest as systemPromptManifest, - } from "../features/system-prompt"; - import { - VisionSettingsView, - manifest as visionManifest, - type LoadVisionSettingsResult, - type SaveVisionSettingsResult, - type VisionSettingsPatch, - } from "../features/vision"; - import type { AppStore } from "./store.svelte"; - import ErrorModal from "./ErrorModal.svelte"; - import { createLocalStore } from "../adapters/local-storage"; - import { untrack } from "svelte"; - - let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props(); - - // The backend's conversation-scoped cache-warming surface. Referenced by id at - // the composition root (sanctioned discovery-by-id) to give it a dedicated view - // and keep it out of the generic Extensions surface list — SurfaceView itself - // stays fully generic (it never switches on a surface id). - const CACHE_WARMING_ID = "cache-warming"; - // The message-queue extension's per-conversation surface (steering). Pulled - // out of the generic Extensions list and rendered as a compact panel above the - // composer — pending steering messages are tied to the chat, not the sidebar. - const MESSAGE_QUEUE_ID = "message-queue"; - // The `todo` extension's per-conversation task list surface (model-maintained). - const TODO_ID = "todo"; - - // The view kinds offered in the sidebar's dropdown. Generic data — the - // `viewContent` snippet below maps each kind id to its renderer. - const viewKinds = [ - { id: "tabs", label: "Tabs" }, - { id: "model", label: "Model" }, - { id: "lsp", label: "Language Servers" }, - { id: "mcp", label: "MCP Servers" }, - { id: "extensions", label: "Extensions" }, - { id: "cache-warming", label: "Cache Warming" }, - { id: "tasks", label: "Tasks" }, - { id: "compaction", label: "Compaction" }, - { id: "heartbeat", label: "Heartbeat" }, - { id: "concurrency", label: "Concurrency" }, - { id: "system-prompt", label: "System Prompt" }, - { id: "settings", label: "Settings" }, - ] as const; - - // Default sidebar layout: the Tabs list (the moved-from-top tab bar) plus the - // Model view, so a fresh user sees their conversations + model controls. - const DEFAULT_VIEWS: readonly string[] = ["tabs", "model"]; - const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", { - storage: untrack(() => store.storage), - }); - const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS; - - function handleSidebarChange(kinds: readonly (string | null)[]): void { - sidebarStore.save(kinds.filter((k): k is string => k !== null)); - } - - // Frontend module list for the "Loaded Modules" view, AGGREGATED from each - // feature's public `manifest` export so it can't drift from what's actually - // composed. (The backend's "Loaded Extensions" surface is a SEPARATE, - // backend-owned list.) FE features are internal units of this single repo, so - // there is no per-module version — they all share dispatch-web's version. - const MODULE_COLUMNS = ["Module", "Description"] as const; - const loadedModules: readonly (readonly [string, string])[] = [ - chatManifest, - tabsManifest, - surfaceHostManifest, - viewsManifest, - conversationCacheManifest, - markdownManifest, - cacheWarmingManifest, - cwdLspManifest, - mcpManifest, - computerManifest, - smartScrollManifest, - settingsManifest, - systemPromptManifest, - heartbeatManifest, - concurrencyManifest, - visionManifest, - ].map((m) => [m.name, m.description] as const); - - // Smart-scroll: keep the transcript pinned to the bottom while it streams, - // unless the reader has scrolled up (then show a "scroll to bottom" button). - // One controller owns the chat scroll region; effects below feed it the edges. - const smartScroll = createSmartScrollController(); - let transcriptEl = $state<HTMLElement | undefined>(); - let transcriptContentEl = $state<HTMLElement | undefined>(); - - // Chat-limit unload gate: old chunks may be unloaded only while the reader is - // stuck to the bottom. While stuck, a trim removes content far ABOVE the - // viewport and the controller re-pins to the bottom — no visible jump; while - // reading history, trimming is deferred instead of yanking the page (the old - // Dispatch bug). In an $effect so a swapped store prop would be re-wired. - $effect(() => { - store.attachUnloadGate(() => smartScroll.isAtBottom()); - }); - - // "Show earlier messages": page older history back in, preserving the reader's - // viewport position — prepended content grows scrollHeight, so shift scrollTop - // by the growth (the manual analogue of CSS scroll anchoring, which not every - // engine applies here). - async function handleShowEarlier(): Promise<void> { - const el = transcriptEl; - const prevHeight = el?.scrollHeight ?? 0; - const prevTop = el?.scrollTop ?? 0; - await store.activeChat.showEarlier(); - await tick(); - if (el) { - const delta = el.scrollHeight - prevHeight; - if (delta > 0) el.scrollTop = prevTop + delta; - } - } - - // Attach/detach the controller to the live scroll element + content (disposed on - // unmount). The content element is observed (ResizeObserver) so the view follows - // height changes that aren't a transcript append. - $effect(() => { - if (!transcriptEl) return; - return smartScroll.attach(transcriptEl, transcriptContentEl); - }); - - // New transcript content streamed in (or messages loaded) → follow the bottom - // while stuck. Reads `chunks.length` so the effect re-runs on every append. - $effect(() => { - void store.activeChat.chunks.length; - smartScroll.contentChanged(); - }); - - // The message-queue surface spec + whether it currently has pending messages - // (steering). Rendered as a compact panel above the composer only when non-empty. - const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID)); - const hasQueuedMessages = $derived.by(() => { - const spec = messageQueueSpec; - if (spec === null) return false; - const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID); - if (field === undefined || field.kind !== "custom") return false; - const data = parseMessageQueuePayload(field.payload); - return data !== null && data.messages.length > 0; - }); - - // The todo surface spec + its parsed task list (model-maintained, read-only). - const todoSpec = $derived(store.surface(TODO_ID)); - const todoData = $derived.by(() => { - const spec = todoSpec; - if (spec === null) return null; - const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID); - if (field === undefined || field.kind !== "custom") return null; - return parseTodoPayload(field.payload); - }); - - // Top-bar title: the active tab's title, or "New Tab" when no tab is active - // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no - // tab exists). Pure-derived from the (workspace-filtered) tab set + the - // active id; reflects whichever tab is selected in the sidebar's Tabs view. - const NEW_TAB_TITLE = "New Tab"; - const topBarTitle = $derived.by(() => { - const id = store.activeConversationId; - if (id === null) return NEW_TAB_TITLE; - const tab = store.tabs.find((t) => t.conversationId === id); - return tab?.title ?? NEW_TAB_TITLE; - }); - - // The composer status-bar status. 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 the slot is granted, so `generating` is already - // true while `conversationStatus === "queued"`; the explicit queued check is - // what distinguishes the two. - const composerStatus = $derived.by<ComposerStatus>(() => { - if (store.activeChat.error) return "error"; - const id = store.activeConversationId; - if (id !== null && store.conversationStatus(id) === "queued") return "queued"; - return store.activeChat.generating ? "running" : "idle"; - }); - - // Conversation/tab switch → snap to the bottom of the new transcript. - $effect(() => { - void store.activeConversationId; - smartScroll.reset(); - }); - - // Right sidebar: persisted open/closed state. Defaults to open on wide - // screens (first visit), then remembers the user's toggle thereafter. - const WIDE_BREAKPOINT = 1024; // Tailwind `lg` - const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", { - storage: untrack(() => store.storage), - }); - const storedSidebarOpen = sidebarOpenStore.load(); - let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true)); - let systemPromptModalOpen = $state(false); - // The heartbeat run currently open in the fullscreen run-chat modal (null = - // closed). Holds a snapshot run view; the modal re-mounts per run (keyed). - let heartbeatRun = $state<HeartbeatRunView | null>(null); - - $effect(() => { - sidebarOpenStore.save(sidebarOpen); - }); - - function handleInvoke(msg: InvokeMessage) { - store.invoke(msg.surfaceId, msg.actionId, msg.payload); - } - - function handleSend(text: string, images?: readonly ImageInput[]): void { - store.send(text, images); - } - - function handleQueue(text: string) { - store.queueMessage(text); - } - - function handleStop() { - store.stopGeneration(); - } - - function handleSelectModel(model: string) { - store.selectModel(model); - } - - // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. - async function warmNow(): Promise<WarmFeedback | null> { - const result = await store.warmNow(); - if (result === null) return null; - return result.ok - ? { - ok: true, - cachePct: result.response.cachePct, - expectedCacheRate: result.response.expectedCacheRate, - } - : { ok: false, error: result.error }; - } - - // Adapt the store's reasoning-effort result to the chat feature's port. - async function saveReasoningEffort( - level: ReasoningEffort, - ): Promise<ReasoningEffortSaveResult | null> { - const result = await store.setReasoningEffort(level); - if (result === null) return null; - return result.ok - ? { ok: true, reasoningEffort: result.reasoningEffort } - : { ok: false, error: result.error }; - } - - // Adapt the store's compact result to the compaction view's port. - async function compactNow(): Promise<CompactNowResult | null> { - const result = await store.compactNow(); - if (result === null) return null; - return result.ok - ? { - ok: true, - messagesSummarized: result.response.messagesSummarized, - messagesKept: result.response.messagesKept, - } - : { ok: false, error: result.error }; - } - - async function saveCompactPercent( - percent: number, - ): Promise<SaveCompactPercentResult | null> { - const result = await store.setCompactPercent(percent); - if (result === null) return null; - return result.ok - ? { ok: true, percent: result.percent } - : { ok: false, error: result.error }; - } +<script module lang="ts"> + import { registerBuiltinViews } from "./register-views"; - // Adapt the store's global vision-settings API to the vision feature's ports. - async function loadVisionSettings(): Promise<LoadVisionSettingsResult> { - // The store seeds `visionSettings` on boot; a refresh keeps it current. - await store.refreshVisionSettings(); - const settings = store.visionSettings; - if (settings === null) { - return { ok: false, error: "Vision settings not available." }; - } - return { ok: true, settings }; - } - - async function saveVisionSettings( - patch: VisionSettingsPatch, - ): Promise<SaveVisionSettingsResult> { - const result = await store.setVisionSettings(patch); - if (result === null) return { ok: false, error: "Vision settings not available." }; - return result.ok - ? { ok: true, settings: result.settings } - : { ok: false, error: result.error }; - } - - // Adapt the store's chat-limit result to the settings feature's port. On a - // raise the active chat refills (prepends older history); preserve the - // reader's viewport over the prepend (the manual analogue of CSS scroll - // anchoring), exactly like `handleShowEarlier`. - async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> { - const el = transcriptEl; - const prevHeight = el?.scrollHeight ?? 0; - const prevTop = el?.scrollTop ?? 0; - const result = await store.setChatLimit(value); - await tick(); - if (el) { - const delta = el.scrollHeight - prevHeight; - if (delta > 0) el.scrollTop = prevTop + delta; - } - return result.ok - ? { ok: true, chatLimit: result.chatLimit } - : { ok: false, error: result.error }; - } - - // Adapt the store's cwd/LSP results to the cwd-lsp feature's ports. - async function saveCwd(cwd: string): Promise<CwdSaveResult | null> { - const result = await store.setCwd(cwd); - if (result === null) return null; - return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error }; - } - - async function loadLspStatus(): Promise<LspStatusResult | null> { - const result = await store.lspStatus(); - if (result === null) return null; - return result.ok - ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } - : { ok: false, error: result.error }; - } - - // Adapt the store's computer results to the computer feature's ports. - async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> { - const result = await store.setComputer(computerId); - if (result === null) return null; - return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error }; - } - - const loadComputerStatus: LoadComputerStatus = async ( - alias: string, - ): Promise<ComputerStatusResult | null> => { - const result = await store.computerStatus(alias); - if (result === null) return null; - return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error }; - }; - - const testComputer: TestComputer = async ( - alias: string, - ): Promise<TestComputerResult | null> => { - const result = await store.testComputer(alias); - if (result === null) return null; - return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error }; - }; - - async function loadMcpStatus(): Promise<McpStatusResult | null> { - const result = await store.mcpStatus(); - if (result === null) return null; - return result.ok - ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } - : { ok: false, error: result.error }; - } - - // Adapt the store's system prompt results to the system-prompt feature's ports. - const loadSystemPromptPrompt: LoadSystemPromptAlias = () => store.loadSystemPrompt(); - - const loadSystemPromptVariablesPrompt: LoadSystemPromptVariablesAlias = () => - store.loadSystemPromptVariables(); - - const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) => store.setSystemPrompt(template); - - // Adapt the store's heartbeat results to the heartbeat feature's ports. The - // store returns the feature's result types directly (the API is a plain REST - // surface, not a transport-contract type), so the adapter is a thin passthrough - // (kept for structural consistency with cwd-lsp/mcp/computer — see AGENTS.md - // "contracts are the cross-unit surface"). - async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> { - return store.heartbeatConfig(); - } - - async function saveHeartbeatConfig( - patch: Parameters<typeof store.setHeartbeatConfig>[0], - ): Promise<HeartbeatConfigResult> { - return store.setHeartbeatConfig(patch); - } - - async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> { - return store.heartbeatRuns(); - } - - async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> { - return store.stopHeartbeatRun(runId); - } - - async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> { - return store.heartbeatNextRun(); - } - - // Run-chat modal: open a live watch on the run's conversation (the store owns - // the ChatStore + the `chat.subscribe` stream), and tear it down on close. - function openRunChat(conversationId: string): ChatStore { - return store.watchConversation(conversationId); - } - function closeRunChat(conversationId: string): void { - store.unwatchConversation(conversationId); - } - - // Adapt the store's concurrency results to the feature's ports. The store - // returns the feature's result types directly (the API is a plain REST surface - // under /concurrency, not a workspace/conversation-scoped one), so the adapter - // is a thin passthrough (kept for structural consistency — AGENTS.md "contracts - // are the cross-unit surface"). - const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits(); - const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) => - store.setConcurrencyLimit(providerId, limit); - const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) => - store.deleteConcurrencyLimit(providerId); - const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus(); - const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) => - store.setConcurrencyCooldown(providerId, cooldownMs); + // Register every built-in view ONCE per app load, BEFORE any tile renders. + // The composition root stays the only place store ports are wired: the + // wrappers receive them through `ports` (see `view-ports.ts`). + registerBuiltinViews(); </script> -<main class="relative flex h-screen overflow-hidden"> - <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling - (below), so opening it shrinks this ENTIRE column. --> - <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]"> - <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so - the top row now shows the active tab's title on the left (or "New Tab" - for an unstarted draft), with the build version + sidebar toggle on - the right. --> - <div class="flex items-center justify-between gap-2 px-2 py-2"> - <span - class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70" - data-testid="top-bar-title" - title={topBarTitle} - aria-label="Active conversation title" - > - {topBarTitle} - </span> - <a - href="/" - class="btn btn-ghost btn-sm shrink-0 px-2" - aria-label="Back to dashboard" - title="Back to dashboard" - onclick={(e) => { - e.preventDefault(); - onNavigate("/"); - }} - > - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - stroke-width="2" - stroke="currentColor" - class="size-4" - aria-hidden="true" - > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25" - /> - </svg> - </a> - <span - class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30" - title="Build version (git short hash)" - > - build: {__APP_VERSION__} - </span> - <button - class="btn btn-square btn-ghost btn-sm mx-1 shrink-0" - aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} - aria-expanded={sidebarOpen} - onclick={() => (sidebarOpen = !sidebarOpen)} - > - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - stroke-width="2" - stroke="currentColor" - class="size-5" - aria-hidden="true" - > - {#if sidebarOpen} - <!-- Sidebar open → chevrons point right --> - <path - stroke-linecap="round" - stroke-linejoin="round" - d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5" - /> - {:else} - <!-- Sidebar closed → chevrons point left --> - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" - /> - {/if} - </svg> - </button> - </div> - - {#if store.lastError} - <div role="alert" class="alert alert-error mx-4 mt-2"> - <strong>Error:</strong> - {store.lastError.message} - </div> - {/if} - - {#if store.activeChat.error} - <div role="alert" class="alert alert-warning mx-4 mt-2"> - <strong>Chat error:</strong> - {store.activeChat.error} - </div> - {/if} - - <div class="relative min-h-0 min-w-0 flex-1 pr-4"> - <div bind:this={transcriptEl} class="h-full overflow-y-auto"> - <div bind:this={transcriptContentEl}> - {#key store.activeConversationId} - <ChatView - chunks={store.activeChat.chunks} - turnMetrics={store.activeChat.turnMetrics} - hasEarlier={store.activeChat.hasEarlier} - onShowEarlier={handleShowEarlier} - thinkingKeyBase={store.activeChat.thinkingKeyBase} - providerRetry={store.activeChat.providerRetry} - apiBaseUrl={store.httpBase} - /> - {/key} - </div> - </div> - {#if store.activeChat.chunks.length === 0} - <div - class="pointer-events-none absolute inset-0 flex items-center justify-center" - aria-hidden="true" - > - <span class="select-none text-4xl font-bold opacity-10">Dispatch</span> - </div> - {/if} - <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} /> - </div> - - {#if hasQueuedMessages && messageQueueSpec !== null} - <!-- Pending steering messages (the message-queue surface). Rendered via - the generic SurfaceView (dispatches on rendererId, never surface id); - only shown when the queue is non-empty — an idle queue is hidden. --> - <div class="px-4 pt-2"> - <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} /> - </div> - {/if} - - <Composer - onSend={handleSend} - onQueue={handleQueue} - onStop={handleStop} - contextSize={store.activeChat.currentContextSize} - contextWindow={store.modelInfo[store.activeModel]?.contextWindow} - status={composerStatus} - /> - </div> - - <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so - opening it shrinks the whole left column (push). Below `lg` it overlays - (`max-lg:absolute`, full height) with a backdrop. --> - <aside - class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative" - class:w-80={sidebarOpen} - class:w-0={!sidebarOpen} - > - <div - class="flex h-full w-80 flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out" - style="transform: translateX({sidebarOpen ? '0' : '100%'})" - > - <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} /> - </div> - </aside> +<script lang="ts"> + import type { LayoutTree } from "@dispatch/layout-contract"; + import { untrack } from "svelte"; + import { createCollapseStore, firstCollapsibleNodeId, TileRoot } from "../features/layout"; + import { RunModal, type HeartbeatRunView } from "../features/heartbeat"; + import { SystemPromptBuilder } from "../features/system-prompt"; + import type { AppStore } from "./store.svelte"; + import type { AppViewPorts, ChatViewportBridge } from "./view-ports"; + import ErrorModal from "./ErrorModal.svelte"; + + let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props(); + + // Modal chrome — app shell state, OUTSIDE the tree (layout-00 decision 2). + let systemPromptModalOpen = $state(false); + // The heartbeat run currently open in the fullscreen run-chat modal (null = + // closed). Holds a snapshot run view; the modal re-mounts per run (keyed). + let heartbeatRun = $state<HeartbeatRunView | null>(null); + + // --- The layout tree (plan 02): the ACTIVE workspace's tile tree, served + // by the backend's layout provider plugin when one is active (probed on + // boot, on workspace switch, and on extensions-changed; `layout` WS pushes + // re-render live) and the built-in DEFAULT_LAYOUT otherwise (no-provider + // mode: edits are ephemeral, logged once, no error UI). The store owns the + // whole lifecycle; the shell just renders `tree` and forwards edits. + // `dispatch.sidebar.views` is RETIRED (D-L4): its only reader is the store's + // one-way §2.7 migration. + const tree = $derived(store.layout.tree); + + function handleTreeChange(next: LayoutTree): void { + store.layout.onTreeChange(next); + } + + // --- Sidebar collapse: renderer chrome state (per-node-id open/closed), + // persisted with the SAME key/semantics as today (`dispatch.sidebar.open`, a + // bare boolean, default open on wide screens). The toggle drives the tree's + // single collapsible node ("side" on the default tree; generically the first + // collapsible split child). + const collapse = createCollapseStore({ + storage: untrack(() => store.storage), + viewportWidth: typeof window !== "undefined" ? window.innerWidth : undefined, + }); + const collapseState = $derived($collapse); + const collapseKey = $derived(firstCollapsibleNodeId(tree)); + const sidebarOpen = $derived( + collapseKey !== null ? (collapseState.openByNode[collapseKey] ?? collapseState.defaultOpen) : false, + ); + + function toggleSidebar(): void { + if (collapseKey !== null) collapse.toggle(collapseKey); + } + + // --- The port bag every view wrapper receives. This is the ONE place the + // store's ports are wired (AGENTS.md: composition root). `store` is read + // through a getter so a swapped store prop stays reactive through it. + const chatViewport: ChatViewportBridge = { el: null }; + const viewPorts: AppViewPorts = { + get store() { + return store; + }, + chatViewport, + openSystemPromptBuilder: () => (systemPromptModalOpen = true), + openHeartbeatRun: (run) => (heartbeatRun = run), + }; + + // Top-bar title: the active tab's title, or "New Tab" when no tab is active + // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no + // tab exists). Pure-derived from the (workspace-filtered) tab set + the + // active id; reflects whichever tab is selected in the sidebar's Tabs view. + const NEW_TAB_TITLE = "New Tab"; + const topBarTitle = $derived.by(() => { + const id = store.activeConversationId; + if (id === null) return NEW_TAB_TITLE; + const tab = store.tabs.find((t) => t.conversationId === id); + return tab?.title ?? NEW_TAB_TITLE; + }); + + function handleInvokeHome(): void { + onNavigate("/"); + } +</script> - <!-- Backdrop: only on narrow screens (overlay mode), click to close. --> - {#if sidebarOpen} - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div - class="fixed inset-0 z-20 bg-black/30 lg:hidden" - role="button" - tabindex="0" - aria-label="Close sidebar" - onclick={() => (sidebarOpen = false)} - onkeydown={(e) => { - if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false; - }} - ></div> - {/if} +<main class="h-app relative flex flex-col overflow-hidden"> + <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so + the top row now shows the active tab's title on the left (or "New Tab" + for an unstarted draft), with the build version + sidebar toggle on + the right. CHROME — stays outside the tree (layout-00 decision 2). + `.app-navbar` (app.css): the Artix wiki's darkstrap navbar look. --> + <div class="app-navbar flex items-center justify-between gap-2 px-2 pt-[calc(0.5rem+5px)] pb-2"> + <span + class="app-navbar-engraved min-w-0 flex-1 shrink truncate pl-2 text-sm font-semibold text-primary" + data-testid="top-bar-title" + title={topBarTitle} + aria-label="Active conversation title" + > + {topBarTitle} + </span> + <span + class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-primary/70" + title="Build version (git short hash)" + > + build: {__APP_VERSION__} + </span> + <a + href="/" + class="btn btn-ghost btn-sm shrink-0 px-2 text-primary" + aria-label="Back to dashboard" + title="Back to dashboard" + onclick={(e) => { + e.preventDefault(); + handleInvokeHome(); + }} + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-4" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25" + /> + </svg> + </a> + {#if collapseKey !== null} + <button + class="btn btn-square btn-ghost btn-sm mx-1 shrink-0 text-primary" + aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} + aria-expanded={sidebarOpen} + onclick={toggleSidebar} + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-5" + aria-hidden="true" + > + {#if sidebarOpen} + <!-- Sidebar open → chevrons point right --> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5" + /> + {:else} + <!-- Sidebar closed → chevrons point left --> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" + /> + {/if} + </svg> + </button> + {/if} + </div> + + {#if store.lastError} + <div role="alert" class="alert alert-error mx-4 mt-2"> + <strong>Error:</strong> + {store.lastError.message} + </div> + {/if} + + {#if store.activeChat.error} + <div role="alert" class="alert alert-warning mx-4 mt-2"> + <strong>Chat error:</strong> + {store.activeChat.error} + </div> + {/if} + + <!-- THE TREE: the tile renderer owns the content region. Tile edits are + applied here and persisted to dispatch.sidebar.views (above). --> + <div class="min-h-0 min-w-0 flex-1"> + <TileRoot {tree} {collapse} ports={viewPorts} onTreeChange={handleTreeChange} /> + </div> </main> {#if store.fatalError} - <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} /> + <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} /> {/if} {#if systemPromptModalOpen} - <SystemPromptBuilder - loadPrompt={loadSystemPromptPrompt} - savePrompt={saveSystemPromptPrompt} - loadVariables={loadSystemPromptVariablesPrompt} - onClose={() => (systemPromptModalOpen = false)} - /> + <SystemPromptBuilder + loadPrompt={() => store.loadSystemPrompt()} + savePrompt={(template) => store.setSystemPrompt(template)} + loadVariables={() => store.loadSystemPromptVariables()} + onClose={() => (systemPromptModalOpen = false)} + /> {/if} {#if heartbeatRun !== null} - <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a - fresh watch store lifecycle per run. The modal owns the live watch - (openChat/closeChat) and the Stop button. --> - {#key heartbeatRun.id} - <RunModal - run={heartbeatRun} - openChat={openRunChat} - closeChat={closeRunChat} - stopRun={stopHeartbeatRun} - onClose={() => (heartbeatRun = null)} - apiBaseUrl={store.httpBase} - /> - {/key} + <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a + fresh watch store lifecycle per run. The modal owns the live watch + (openChat/closeChat) and the Stop button. --> + {#key heartbeatRun.id} + <RunModal + run={heartbeatRun} + openChat={(conversationId) => store.watchConversation(conversationId)} + closeChat={(conversationId) => store.unwatchConversation(conversationId)} + stopRun={(runId) => store.stopHeartbeatRun(runId)} + onClose={() => (heartbeatRun = null)} + apiBaseUrl={store.httpBase} + /> + {/key} {/if} - -{#snippet viewContent(kind: string)} - {#if kind === "tabs"} - <!-- The conversation tab list (moved out of the top bar into the sidebar). - Re-mount per workspace so the filtered tab set + scroll reset cleanly - on a workspace switch. --> - {#key store.activeWorkspaceId} - <TabList - tabs={store.tabs} - activeConversationId={store.activeConversationId} - statusFor={(id) => store.conversationStatus(id)} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - onRename={(id, title) => store.renameTab(id, title)} - /> - {/key} - {:else if kind === "model"} - <div class="flex flex-col gap-3"> - <ModelSelector - models={store.models} - selected={store.activeModel} - onSelect={handleSelectModel} - modelInfo={store.modelInfo} - /> - <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs - re-mount per conversation — incl. switching between drafts — and can't - bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). --> - {#key store.currentConversationId} - <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} /> - <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> - <ComputerField - computerId={store.computerId} - canEdit={true} - computers={store.computers} - save={saveComputer} - loadStatus={loadComputerStatus} - test={testComputer} - /> - {/key} - </div> - {:else if kind === "lsp"} - <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. --> - {#key store.currentConversationId} - <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} /> - {/key} - {:else if kind === "mcp"} - <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. --> - {#key store.currentConversationId} - <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} /> - {/key} - {:else if kind === "extensions"} - <section> - <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> - <Table columns={MODULE_COLUMNS} rows={loadedModules} /> - </section> - <section class="mt-4 flex flex-col gap-3"> - <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3> - {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} - <SurfaceView {spec} onInvoke={handleInvoke} /> - {/each} - </section> - {:else if kind === "cache-warming"} - <!-- Re-mount per conversation (like ChatView) so the view's local warming - history / manual-warm feedback can't bleed across tabs. --> - {#key store.activeConversationId} - <CacheWarmingView - spec={store.surface(CACHE_WARMING_ID)} - canWarm={store.activeConversationId !== null} - onInvoke={handleInvoke} - {warmNow} - /> - {/key} - {:else if kind === "tasks"} - <!-- Re-mount per conversation so the task list is isolated per conversation. - TodoList always reserves its fixed 60vh height (empty or full). --> - {#key store.activeConversationId} - <TodoList payload={todoData} /> - {/key} - {:else if kind === "compaction"} - <!-- Message compaction is per-conversation (keyed so the percent + feedback - can't bleed across tabs). Vision (image-compaction) settings are GLOBAL, - so they stay mounted across conversation switches (no {#key}). --> - {#key store.currentConversationId} - <CompactionView - percent={store.compactPercent} - canCompact={store.activeConversationId !== null} - {compactNow} - savePercent={saveCompactPercent} - /> - {/key} - <div class="divider my-1 text-xs text-base-content/40">Image compaction</div> - <VisionSettingsView - models={store.models} - modelInfo={store.modelInfo} - load={loadVisionSettings} - save={saveVisionSettings} - /> - {:else if kind === "system-prompt"} - <!-- Global system prompt template. Opens a full-page modal editor (half - template / half variable palette). Not conversation-scoped (no {#key}). --> - <div class="flex flex-col gap-2"> - <p class="text-xs opacity-60"> - Edit the global system prompt template with variable placeholders. Opens a full-page editor. - </p> - <button - type="button" - class="btn btn-primary btn-sm" - onclick={() => (systemPromptModalOpen = true)} - > - Open builder - </button> - </div> - {:else if kind === "settings"} - <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is - global), so the field stays mounted across tab switches. --> - <div class="flex flex-col gap-3"> - <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} /> - </div> - {:else if kind === "heartbeat"} - <!-- Workspace-scoped autonomous-agent heartbeat (config + run history). - Not conversation-scoped (no {#key}); the config + runs are per-workspace. --> - <HeartbeatView - models={store.models} - loadConfig={loadHeartbeatConfig} - saveConfig={saveHeartbeatConfig} - loadRuns={loadHeartbeatRuns} - stopRun={stopHeartbeatRun} - loadVariables={loadSystemPromptVariablesPrompt} - loadDefaultPrompt={loadSystemPromptPrompt} - loadNextRun={loadHeartbeatNextRun} - onOpenRun={(run) => (heartbeatRun = run)} - /> - {:else if kind === "concurrency"} - <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or - conversation-scoped), so the panel stays mounted across tab switches. --> - <ConcurrencyView - models={store.models} - loadLimits={loadConcurrencyLimits} - saveLimit={saveConcurrencyLimit} - deleteLimit={deleteConcurrencyLimit} - loadStatus={loadConcurrencyStatus} - saveCooldown={saveConcurrencyCooldown} - /> - {/if} -{/snippet} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index dcdcb9b..ebc8004 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -1,9 +1,12 @@ +import type { LayoutTree } from "@dispatch/layout-contract"; +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; -import { render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; +import { overlaySidePanels } from "../features/layout"; import App from "./App.svelte"; import { createAppStore } from "./store.svelte"; @@ -57,11 +60,17 @@ function fakeSocket(): FakeSocket { function fakeFetchImpl(): typeof fetch { return async (input: string | URL | Request): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } + if (url.endsWith("/layout")) { + // No layout provider in these tests: the plan-02 no-provider signal. + return new Response(JSON.stringify({ error: "No layout provider is active" }), { + status: 404, + }); + } if (url.includes("/conversations?status=")) { return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); } @@ -73,7 +82,60 @@ function fakeFetchImpl(): typeof fetch { status: 200, }); } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + if (url.endsWith("/settings/vision")) { + return new Response(JSON.stringify({ imageLimit: 10, compactionModel: null }), { + status: 200, + }); + } + if (/\/plugins\/[^/]+\/frontend/.test(url)) { + // plan 03 frontend module (no fixtures declare one) — the plain 404 + // the route returns for unknown/unloaded/frontend-less plugins. + return new Response(JSON.stringify({ error: "No frontend module for this plugin" }), { + status: 404, + }); + } + if (/\/plugins(\?.*)?$/.test(url)) { + // Valid EMPTY list (plan 03): the plugin host's boot + // `extensions-changed`/workspace syncs read this — a shapeless + // fallback body would be cast to PluginListResponse and crash the + // diff (`list.loaded` not iterable). + return new Response( + JSON.stringify({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), { + status: 200, + }); + }; +} + +/** + * A fetch whose layout provider is ACTIVE and serves the given sidebar + * panels (plan 02): `GET /workspaces/:id/layout` returns DEFAULT_LAYOUT with + * the views overlaid; `PUT` echoes the tree (200). Tests that need specific + * sidebar views visible drive the layout through THIS edge — the pre-seeded + * `dispatch.sidebar.views` localStorage is only read by the one-way §2.7 + * migration now. + */ +function fakeFetchWithLayout(views: readonly string[]): typeof fetch { + const inner = fakeFetchImpl(); + return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/layout")) { + const method = init?.method ?? "GET"; + if (method === "PUT" && typeof init?.body === "string") { + const body = JSON.parse(init.body) as { tree: LayoutTree }; + return new Response(JSON.stringify({ tree: body.tree }), { status: 200 }); + } + if (method === "GET") { + const tree = overlaySidePanels(DEFAULT_LAYOUT, views); + return new Response(JSON.stringify({ tree }), { status: 200 }); + } + const tree = overlaySidePanels(DEFAULT_LAYOUT, views); + return new Response(JSON.stringify({ tree }), { status: 200 }); + } + return inner(input, init); }; } @@ -118,6 +180,119 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string } describe("App component interaction tests", () => { + it.each([ + "all", + "parent", + "cancel", + ] as const)("group close %s flows through the real store, persistence and HTTP close port", async (choice) => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const closed: string[] = []; + const base = fakeFetchWithLayout(["tabs"]); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: async (input, init) => { + 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: "p", workspaceId: "default" }, + { id: "c1", workspaceId: "default", tabGroupParentId: "p" }, + { id: "c2", workspaceId: "default", tabGroupParentId: "p" }, + { id: "g", workspaceId: "default", tabGroupParentId: "c1" }, + { id: "solo", workspaceId: "default" }, + { id: "other-workspace", workspaceId: "other", tabGroupParentId: "p" }, + ].map((conversation) => ({ + ...conversation, + title: conversation.id, + status: "idle", + lastActivityAt: 0, + })), + }), + { status: 200 }, + ); + } + if (url.endsWith("/close")) { + expect(init?.method).toBe("POST"); + const id = url.split("/").at(-2) as string; + closed.push(id); + return new Response(JSON.stringify({ conversationId: id, abortedTurn: false }), { + status: 200, + }); + } + return base(input, init); + }, + localStorage: storage, + }); + try { + ws.resolveOpen(); + render(App, { props: { store, onNavigate: vi.fn() } }); + await screen.findByRole("tablist"); + for (const [id, parent] of [ + ["p", null], + ["c1", "p"], + ["c2", "p"], + ["g", "c1"], + ["solo", null], + ] as const) { + ws.feedServerMessage({ + type: "conversation.open", + conversationId: id, + workspaceId: "default", + ...(parent === null ? {} : { tabGroupParentId: parent }), + }); + } + ws.feedServerMessage({ + type: "conversation.open", + conversationId: "other-workspace", + workspaceId: "other", + tabGroupParentId: "p", + }); + await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(5)); + store.selectTab("p"); + const parent = await waitFor(() => { + const tab = document.querySelector<HTMLElement>('[role="tab"][data-conversation-id="p"]'); + expect(tab).not.toBeNull(); + return tab; + }); + if (parent === null) throw new Error("Parent tab missing"); + await fireEvent.click(within(parent).getByRole("button", { name: "Close tab" })); + const dialog = screen.getByRole("dialog", { name: "Close subagents too?" }); + expect(dialog).toHaveTextContent("3 subagent tabs"); + expect(closed).toEqual([]); + await fireEvent.click( + within(dialog).getByRole("button", { + name: + choice === "all" + ? /Yes, close all 4/ + : choice === "parent" + ? /No, keep them open/ + : "Cancel", + }), + ); + const expected = choice === "all" ? ["p", "c1", "c2", "g"] : choice === "parent" ? ["p"] : []; + expect(closed).toEqual(expected); + expect(store.tabs.map((tab) => tab.conversationId)).toEqual( + ["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)), + ); + expect(screen.queryByRole("dialog")).toBeNull(); + if (choice !== "cancel") { + const persisted = JSON.parse(storage.getItem("dispatch.tabs") as string) as { + tabs: { conversationId: string }[]; + }; + expect(persisted.tabs.map((tab) => tab.conversationId)).toEqual([ + ...["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)), + "other-workspace", + ]); + expect(store.activeConversationId).not.toBe("p"); + } + } finally { + store.dispose(); + } + }); + it("renders the model selector and composer in draft mode", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -231,7 +406,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -295,7 +470,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -372,12 +547,133 @@ 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({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), localStorage: createFakeStorage(), + chatFlushScheduler: null, }); ws.resolveOpen(); @@ -415,7 +711,7 @@ describe("App component interaction tests", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -454,19 +750,20 @@ describe("App component interaction tests", () => { store.dispose(); }); - it("the Extensions view lists frontend modules aggregated from feature manifests", () => { + it("the Extensions view lists frontend modules aggregated from feature manifests", async () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), + fetchImpl: fakeFetchWithLayout(["extensions"]), localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); render(App, { props: { store, onNavigate: vi.fn() } }); - // Extensions view is pre-populated in the fake storage, so the modules table renders immediately. - expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); + // The Extensions view arrives with the provider's tree (an async GET — + // plan 02), so await the modules table rendering. + expect(await screen.findByRole("columnheader", { name: "Module" })).toBeInTheDocument(); for (const name of [ "chat", "tabs", @@ -486,8 +783,8 @@ describe("App component interaction tests", () => { // network failure / unreachable backend on a new device). const failingFetch = async (input: string | URL | Request): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } @@ -502,7 +799,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(); @@ -583,4 +882,22 @@ describe("App component interaction tests", () => { store.dispose(); }); + + it("the Vision view renders the vision settings UI (image limit + compaction model)", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchWithLayout(["vision"]), + localStorage: createFakeStorageWithViews(["vision"]), + }); + ws.resolveOpen(); + + render(App, { props: { store, onNavigate: vi.fn() } }); + + // The vision settings load (`GET /settings/vision`) seeds the inputs. + expect(await screen.findByLabelText(/Image limit/)).toBeInTheDocument(); + expect(await screen.findByLabelText(/Compaction model/)).toBeInTheDocument(); + + store.dispose(); + }); }); 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..b853a8f --- /dev/null +++ b/src/app/conversation-settings.svelte.ts @@ -0,0 +1,440 @@ +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); + let textFileContextRevision = $state(0); + + /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ + async function refreshCwd(): Promise<void> { + textFileContextRevision++; + 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> { + textFileContextRevision++; + 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 textFileContextRevision() { + return textFileContextRevision; + }, + 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; + textFileContextRevision++; + } + 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; + textFileContextRevision++; + } + 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/plugin-host.svelte.ts b/src/app/plugin-host.svelte.ts new file mode 100644 index 0000000..8054005 --- /dev/null +++ b/src/app/plugin-host.svelte.ts @@ -0,0 +1,151 @@ +/** + * The frontend-plugin HOST wiring (plan 03 §3.2) — the composition-root glue + * between the ABI driver (`core/plugin-abi`), the view registry, the + * surfaces ports, and the plugins HTTP edge. + * + * The sync triggers (boot, `extensions-changed`, `workspace.set` → the app + * store calls `sync()` on exactly those events) each fetch the plugin list + + * the ACTIVE workspace's enablement rows, derive the desired candidate set + * (pure), and run one driver pass. The driver's effects are injected HERE at + * the edge: + * - `importModule` — the dynamic `import()` (the only module-system touch); + * - `buildHostApi` — the per-plugin `FrontendHostAPI` with FORCED pluginId + * attribution into the layout registry, app-store-backed surfaces ports, + * and a plugin-prefixed logger; + * - `unregisterViewsByPlugin` — the registry's unload sweep. + * + * After every pass the per-plugin FE states are reported to the plugins + * store (the manager UI renders "incompatible frontend plugin" and + * activation failures). + */ +import { + createFrontendDriver, + FRONTEND_ABI_VERSION, + type FrontendDriver, + type FrontendHostAPI, + type FrontendPluginState, + frontendCandidates, + usablePluginIds, +} from "../core/plugin-abi"; +import { + type LayoutAbiPort, + registerPluginView, + unregisterViewsByPlugin, + type ViewComponent, +} from "../features/layout"; +import type { PluginsHttp } from "../features/plugins"; + +/** The surfaces ports the ABI hands plugin views — backed by the app store. */ +export interface FrontendSurfacesPorts { + readonly subscribe: (surfaceId: string, conversationId?: string) => void; + readonly unsubscribe: (surfaceId: string, conversationId?: string) => void; + readonly invoke: ( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ) => void; + readonly getSpec: (surfaceId: string) => unknown; +} + +/** The shape of the plugins store the FE states are reported to. */ +export interface FrontendStateReporter { + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; +} + +export interface PluginHostOptions { + readonly http: PluginsHttp; + /** The backend HTTP base (import-URL origin). */ + readonly httpBase: string; + readonly reporter: FrontendStateReporter; + /** The ACTIVE workspace provider (read per sync — workspace.set trigger). */ + readonly activeWorkspaceId: () => string; + readonly surfaces: FrontendSurfacesPorts; + /** + * The layout port (plan 04 §4.5) — the ABI's `api.layout` (applyOps to the + * ACTIVE workspace's provider + getTree, null in no-provider mode). Built + * by the composition root over the layout store + ops HTTP edge. + */ + readonly layout: LayoutAbiPort; +} + +export interface PluginHost { + /** + * Run one sync pass (all three triggers share it — the active workspace + * is read per call, which is what makes `workspace.set` a distinct + * trigger). Never throws: a failed list fetch keeps the current set. + */ + sync(): Promise<void>; +} + +export function createPluginHost(opts: PluginHostOptions): PluginHost { + const driver: FrontendDriver = createFrontendDriver({ + importModule: (url) => import(/* @vite-ignore */ url), + buildHostApi: (pluginId) => buildHostApi(pluginId), + unregisterViewsByPlugin, + logger: console, + }); + + function buildHostApi(pluginId: string): FrontendHostAPI { + return { + abiVersion: FRONTEND_ABI_VERSION, + registerView: (reg) => + registerPluginView( + { + id: reg.id, + label: reg.label, + // ONE controlled cast at the ABI seam — the registry is a + // heterogeneous component bag (register-views.ts precedent); + // the plugin's own build already vetted its component. + component: reg.component as ViewComponent, + ...(reg.panelEligible !== undefined ? { panelEligible: reg.panelEligible } : {}), + }, + pluginId, + ), + surfaces: { + subscribe: (surfaceId, conversationId) => + opts.surfaces.subscribe(surfaceId, conversationId), + unsubscribe: (surfaceId, conversationId) => + opts.surfaces.unsubscribe(surfaceId, conversationId), + invoke: (surfaceId, actionId, payload, conversationId) => + opts.surfaces.invoke(surfaceId, actionId, payload, conversationId), + getSpec: (surfaceId) => opts.surfaces.getSpec(surfaceId) as never, + }, + logger: { + info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`), + warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`), + }, + // plan 04 §4.5: the layout port — the composition-root-built adapter + // over the layout store + the ops HTTP edge (the plugin never sees + // either; only this typed port). + layout: opts.layout, + }; + } + + return { + async sync(): Promise<void> { + // Both reads are fail-soft (null on network failure) — a down backend + // keeps the CURRENT imported set untouched (no unload on a flaky + // fetch, and the manager UI's last-known states persist). + const workspaceId = opts.activeWorkspaceId(); + const [list, workspace] = await Promise.all([ + opts.http.list(), + opts.http.workspacePlugins(workspaceId), + ]); + if (list === null) return; + // Shape guard at the outermost edge (asymmetric testing: the shell + // guards, the pure diff stays trusting): a malformed-but-200 + // `/plugins` response (wrong shape, contract drift, a test fake that + // doesn't model the route) must fail SOFT exactly like `null` — keep + // the current imported set, never throw from the fire-and-forget sync. + if (!Array.isArray(list.loaded)) return; + const candidates = frontendCandidates(list, { + workspaceId, + usableIds: usablePluginIds(workspace?.plugins ?? []), + httpBase: opts.httpBase, + }); + await driver.sync(candidates); + opts.reporter.setFrontendStates(driver.states()); + }, + }; +} diff --git a/src/app/register-views.ts b/src/app/register-views.ts new file mode 100644 index 0000000..05afd89 --- /dev/null +++ b/src/app/register-views.ts @@ -0,0 +1,81 @@ +/** + * Built-in view registration — the composition root's view vocabulary. + * + * One thin wrapper component per existing view (src/app/views/*.svelte), each + * closing over exactly the props/ports App.svelte's `viewContent` snippet used + * to pass — INCLUDING each view's `{#key}` remount semantics (moved INSIDE the + * wrappers). Registry ids match the old `viewKinds` ids exactly: no + * user-facing renaming, no persistence-key changes. + * + * `chat` / `composer` / `message-queue` are CONTENT views (decision 5): they + * register with `panelEligible: false` so they never appear in the panel + * dropdowns. All 15 sidebar views keep the eligible default. + * + * Imported ONCE by App.svelte's module script — before any tile renders. + */ + +import type { Component } from "svelte"; +import { registerView, type ViewComponent, type ViewRegistration } from "../features/layout"; +import type { AppViewPorts } from "./view-ports"; +import AccountUsagePane from "./views/AccountUsagePane.svelte"; +import CacheWarmingPane from "./views/CacheWarmingPane.svelte"; +import ChatPane from "./views/ChatPane.svelte"; +import CompactionPane from "./views/CompactionPane.svelte"; +import ComposerPane from "./views/ComposerPane.svelte"; +import ConcurrencyPane from "./views/ConcurrencyPane.svelte"; +import ExtensionsPane from "./views/ExtensionsPane.svelte"; +import HeartbeatPane from "./views/HeartbeatPane.svelte"; +import LspPane from "./views/LspPane.svelte"; +import McpPane from "./views/McpPane.svelte"; +import MessageQueuePane from "./views/MessageQueuePane.svelte"; +import ModelPane from "./views/ModelPane.svelte"; +import NamedAgentsPane from "./views/NamedAgentsPane.svelte"; +import SettingsPane from "./views/SettingsPane.svelte"; +import SystemPromptPane from "./views/SystemPromptPane.svelte"; +import TabsPane from "./views/TabsPane.svelte"; +import TasksPane from "./views/TasksPane.svelte"; +import VisionPane from "./views/VisionPane.svelte"; + +/** + * Every wrapper takes `{ ports: AppViewPorts }` and TileLeaf supplies it — but + * the registry is a HETEROGENEOUS bag of components (generic `Component`), so + * the composition root bridges each wrapper's concrete props type to the + * registry's opaque one. ONE controlled cast at this seam (the renderer always + * passes the port bag; the wrappers re-narrow it) instead of `any` in the + * contract. + */ +type WrapperComponent = Component<{ ports: AppViewPorts }>; +function asView(component: WrapperComponent): ViewComponent { + return component as unknown as ViewComponent; +} + +export function registerBuiltinViews(): void { + const views: readonly ViewRegistration[] = [ + // --- sidebar views (panel-eligible by default — the 14 old viewKinds + account-usage) + { id: "tabs", label: "Tabs", component: asView(TabsPane) }, + { id: "model", label: "Model", component: asView(ModelPane) }, + { id: "named-agents", label: "Named Agents", component: asView(NamedAgentsPane) }, + { id: "lsp", label: "Language Servers", component: asView(LspPane) }, + { id: "mcp", label: "MCP Servers", component: asView(McpPane) }, + { id: "extensions", label: "Extensions", component: asView(ExtensionsPane) }, + { id: "cache-warming", label: "Cache Warming", component: asView(CacheWarmingPane) }, + { id: "tasks", label: "Tasks", component: asView(TasksPane) }, + { id: "compaction", label: "Compaction", component: asView(CompactionPane) }, + { id: "vision", label: "Vision", component: asView(VisionPane) }, + { id: "heartbeat", label: "Heartbeat", component: asView(HeartbeatPane) }, + { id: "concurrency", label: "Concurrency", component: asView(ConcurrencyPane) }, + { id: "account-usage", label: "Usage", component: asView(AccountUsagePane) }, + { id: "system-prompt", label: "System Prompt", component: asView(SystemPromptPane) }, + { id: "settings", label: "Settings", component: asView(SettingsPane) }, + // --- content views (never in the panel dropdowns — decision 5) + { id: "chat", label: "Chat", component: asView(ChatPane), panelEligible: false }, + { id: "composer", label: "Composer", component: asView(ComposerPane), panelEligible: false }, + { + id: "message-queue", + label: "Message Queue", + component: asView(MessageQueuePane), + panelEligible: false, + }, + ]; + for (const view of views) registerView(view); +} diff --git a/src/app/resolve-http-url.test.ts b/src/app/resolve-http-url.test.ts index 0da2591..c556812 100644 --- a/src/app/resolve-http-url.test.ts +++ b/src/app/resolve-http-url.test.ts @@ -19,13 +19,13 @@ describe("resolveHttpUrl", () => { }); it("derives http://<hostname>:24203 from http location", () => { - const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); - expect(result).toBe("http://100.126.75.103:24203"); + const result = resolveHttpUrl({}, { protocol: "http:", hostname: "192.0.2.10" }); + expect(result).toBe("http://192.0.2.10:24203"); }); it("derives https://<hostname>:24203 from https location", () => { - const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" }); - expect(result).toBe("https://arch-razer:24203"); + const result = resolveHttpUrl({}, { protocol: "https:", hostname: "dev-node" }); + expect(result).toBe("https://dev-node:24203"); }); it("uses VITE_HTTP_PORT when set", () => { diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts index b5ba6c3..be1ab12 100644 --- a/src/app/resolve-ws-url.test.ts +++ b/src/app/resolve-ws-url.test.ts @@ -19,13 +19,13 @@ describe("resolveWsUrl", () => { }); it("derives ws://<hostname>:24205 from http location", () => { - const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); - expect(result).toBe("ws://100.126.75.103:24205"); + const result = resolveWsUrl({}, { protocol: "http:", hostname: "192.0.2.10" }); + expect(result).toBe("ws://192.0.2.10:24205"); }); it("derives wss://<hostname>:24205 from https location", () => { - const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" }); - expect(result).toBe("wss://arch-razer:24205"); + const result = resolveWsUrl({}, { protocol: "https:", hostname: "dev-node" }); + expect(result).toBe("wss://dev-node:24205"); }); it("uses VITE_WS_PORT when set", () => { 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..6835166 --- /dev/null +++ b/src/app/store-types.ts @@ -0,0 +1,614 @@ +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 { TextFileClient } from "../features/markdown"; +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; + /** Invalidates live-file routing after known settings changes. */ + readonly textFileContextRevision: number; + createTextFileClient(conversationId: string): TextFileClient; + /** 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 22b0a25..8fcc4cc 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,537 +1,81 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - CompactPercentResponse, - CompactResponse, - ComputerListResponse, - ComputerStatusResponse, - ConversationCompactedMessage, - ConversationComputerResponse, - ConversationHistoryResponse, - ConversationListResponse, - ConversationMetricsResponse, - ConversationOpenMessage, - ConversationStatusChangedMessage, - CwdResponse, - LspStatusResponse, - McpStatusResponse, ModelMetadata, ModelResponse, - ModelsResponse, ReasoningEffort, - ReasoningEffortResponse, - SetCompactPercentRequest, - SetConversationComputerRequest, - SetCwdRequest, SetModelRequest, - SetReasoningEffortRequest, - SetSystemPromptTemplateRequest, - SetTitleRequest, - SetVisionSettingsRequest, - SystemPromptTemplateResponse, - SystemPromptVariable, - SystemPromptVariablesResponse, - TestComputerResponse, - 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 { ChatStore, HistorySync, MetricsSync } from "../features/chat"; -import { createChatStore } from "../features/chat"; -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, + createLayoutHttp, + createLayoutStore, + listPanelViews, +} from "../features/layout"; +import { createTextFileClient } from "../features/markdown"; +import type { NamedAgent, NamedAgentSelectionSnapshot } from "../features/named-agents"; +import type { PluginsStore } from "../features/plugins"; +import { createPluginsHttp, createPluginsStore } from "../features/plugins"; 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"; + 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"; - -const DEFAULT_MODEL = "opencode/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 `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; - selectModel(model: string): 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; - invoke(surfaceId: string, actionId: string, payload?: unknown): 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>; - /** - * 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>; - /** - * 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; - 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; -} - -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; - }; -} +import { createWsHandlers } from "./ws-handlers"; + +const DEFAULT_MODEL = "opencode-1/deepseek-v4-flash"; + +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 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); // The workspace currently in view (its URL slug); "default" until routing @@ -562,127 +106,82 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; const localStorageOpt = opts?.localStorage ?? globalThis.localStorage; - const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", { + // ── Plugins (plan 07) ────────────────────────────────────────────────────── + // The plugin-manager + per-workspace enablement state lives in its own + // feature store (one instance per app store, passed explicitly to the UI). + // The app store wires the server's `extensions-changed` broadcast into it + // (§11 multi-client coherence) and refreshes the workspace-scoped model + // picker + guardrail on the same event. + const pluginsHttp = createPluginsHttp(httpBase, fetchImpl); + const pluginsStore = createPluginsStore(pluginsHttp); + + // ── Layout (plan 02) ───────────────────────────────────────────────────────── + // The ACTIVE workspace's tile tree, owned by the backend's layout provider + // plugin when one is active (its well-known routes + the `layout` WS push) + // and rendered from the local default otherwise (no-provider mode: edits + // ephemeral, logged once, no error UI). Probes run on boot, on every + // workspace switch, and on `extensions-changed` (§2.6); the one-way + // localStorage migration (§2.7) arms on the boot probe. + const layoutHttp = createLayoutHttp(httpBase, fetchImpl); + const layoutStore = createLayoutStore({ + http: layoutHttp, + workspaceId: activeWorkspaceId, storage: localStorageOpt, + knownViews: listPanelViews().map((v) => v.id), }); - 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", { + // ── Home tile-layout (the synced Win8-Start grid) ───────────────────────── + // ONE shared document PER device orientation (`wide` = landscape/desktop, + // `tall` = portrait/mobile), persisted by the backend's home-tile-layout + // plugin and broadcast over WS (`tile-layout`). The home page locks the + // active orientation to the resolved grid geometry and renders + // `tileLayout.entries`; this store owns the probes, the WS-push filter, + // 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, }); - 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>(); - - 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()), - onError: (context, err) => { - reportError(`${context} (conversation: ${conversationId})`, err); - }, - }); - } + const refreshers = createSettingsRefreshers({ + httpBase, + fetchImpl, + activeWorkspaceId: () => activeWorkspaceId, + reportError, + }); - 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); - } - } + const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", { + storage: localStorageOpt, + }); + const tabsStore: TabsStore = createTabsStore(storageAdapter); - // 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); + 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, + }); - /** - * 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(); @@ -691,14 +190,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { if (!res.ok) return; const data = (await res.json()) as ModelResponse; if (workspaceConversationId() !== id) return; + // The backend is authoritative for the persisted snapshot: restore how the + // conversation was selected (a Named Agent, or none). Falls back to the tab's + // local snapshot when the response omits it (older backend). + namedAgentSnapshot = data.namedAgent ?? namedAgentSnapshot; if (typeof data.model === "string" && data.model.length > 0) { activeModel = data.model; const activeId = tabsStore.activeConversationId; if (activeId !== null) { tabsStore.setModel(activeId, data.model); - chatStores.get(activeId)?.setModel(data.model); + tabsStore.setSnapshot(activeId, namedAgentSnapshot); + registry.chatStores.get(activeId)?.setModel(data.model); + registry.chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot); } else { - draftStore.setModel(data.model); + registry.draftStore.setModel(data.model); + registry.draftStore.setNamedAgent(namedAgentSnapshot); } } } catch (err) { @@ -706,174 +212,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 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; @@ -886,97 +224,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; - /** - * 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): void { - if (chatStores.has(conversationId)) return; - const store = createChatFor(conversationId, activeModel, workspaceId); - chatStores.set(conversationId, store); - void store.load(); - subscribeChat(conversationId); - tabsStore.openTab({ - conversationId, - model: activeModel, - title: "Conversation", - workspaceId, - }); - } - - /** - * 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 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 @@ -992,229 +268,133 @@ 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; + // ── 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 + // workspace switch (the three load-lifecycle triggers — the active + // workspace is read per call, which makes workspace.set meaningful). + // The surfaces ports delegate to the SAME protocol/socket machinery the + // app store's own surface methods use (closure functions below, shared + // with the returned object — no second protocol state). + const pluginHost = createPluginHost({ + http: pluginsHttp, + httpBase, + reporter: pluginsStore, + activeWorkspaceId: () => activeWorkspaceId, + surfaces: { + subscribe: (surfaceId, conversationId) => + surfaces.subscribeSurfaceOp(surfaceId, conversationId), + unsubscribe: (surfaceId, conversationId) => + surfaces.unsubscribeSurfaceOp(surfaceId, conversationId), + invoke: (surfaceId, actionId, payload, conversationId) => + 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 + // tree back through the store (instant local echo; the WS broadcast is + // every client's authoritative copy); getTree exposes the active tree + // only while a provider is PRESENT (null = no-provider mode). + layout: createLayoutAbiPort({ + store: layoutStore, + postOps: (workspaceId, ops) => layoutHttp.postOps(workspaceId, ops), + }), }); - /** - * Fetch `GET /conversations?status=active,idle` on connect to restore the - * tab bar across devices. Merges: opens tabs for conversations not already - * open, removes tabs for conversations that are no longer active/idle - * (closed on another device), and subscribes to `active` conversations' - * live streams. - */ - async function fetchOpenConversations(): Promise<void> { - try { - const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); - if (!res.ok) return; - const data = (await res.json()) as ConversationListResponse; - - // Update the status map from the authoritative backend list. - const newStatuses = new Map<string, ConversationStatus>(); - for (const conv of data.conversations) { - newStatuses.set(conv.id, conv.status); - } - conversationStatuses = newStatuses; - - // Open tabs for conversations not already open. - const existingIds = new Set(chatStores.keys()); - for (const conv of data.conversations) { - if (!existingIds.has(conv.id)) { - const store = createChatFor(conv.id, activeModel, conv.workspaceId); - chatStores.set(conv.id, store); - void store.load(); - subscribeChat(conv.id); - tabsStore.openTab({ - conversationId: conv.id, - model: activeModel, - title: conv.title, - workspaceId: conv.workspaceId, - }); - } else { - // Already open — update the title from the backend if it differs. - tabsStore.setTitle(conv.id, conv.title); - } - } - - // Remove tabs for conversations no longer active/idle (closed elsewhere). - const backendIds = new Set(data.conversations.map((c) => c.id)); - for (const tab of tabsStore.tabs) { - if (!backendIds.has(tab.conversationId)) { - removeTabLocally(tab.conversationId); - } - } - } catch (err) { - reportError( - `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, - err, - ); - } - } - - const socketOpts: SurfaceSocketOptions = { + const socketOpts = createWsHandlers({ url: wsUrl, - onMessage: handleServerMessage, - onChat: handleChatMessage, - onConversationOpen(msg: ConversationOpenMessage): void { - openConversation(msg.conversationId, msg.workspaceId); - }, - 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); - // 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); - } - // 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(); - } - }, - }; + 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; } socket = createSurfaceSocket(socketOpts); - // Fetch model catalog - void fetchImpl(`${httpBase}/models`) - .then((res) => { - if (!res.ok) return; - return res.json() as Promise<ModelsResponse>; - }) - .then((data) => { - if (data === undefined) return; - models = data.models; - modelInfo = data.modelInfo ?? {}; - if (data.models.length > 0 && !data.models.includes(activeModel)) { - const first = data.models[0]; - if (first !== undefined) { - activeModel = first; - draftStore.setModel(first); - } - } - }) - .catch((err) => { - reportError("Failed to load model list", err); - }); - - // 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); - }); + // Tell the backend THIS connection's active workspace (plan 06 §6.4). The + // socket queues the send until the connection opens; on receipt the server + // re-pushes the workspace-filtered surface catalog. Also refreshed on + // reconnect (onReopen — a new connection is anonymous again). + const workspaceSetMsg = (workspaceId: string): WorkspaceSetMessage => ({ + type: "workspace.set", + workspaceId, + }); + socket.send(workspaceSetMsg(activeWorkspaceId)); + + // Fetch the model catalog (workspace-scoped — plan 06 §6.5). + void refreshers.refreshModels(); + // Fetch the active workspace's provider status (§6.5 first-boot guardrail). + 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. + void layoutStore.probe(activeWorkspaceId, { boot: true }); + // Frontend plugins (plan 03): the load lifecycle's boot trigger — every + // loaded ∧ enabled ∧ frontend-declaring ∧ handshake-passing plugin is + // imported + activated (after the first list fetch lands). + void pluginHost.sync(); + // One-time legacy model-name migration on boot (independent of the fetch; + // preserved from the pre-refreshModels boot chain). + if (migrateLegacyModelName(activeModel) !== activeModel) { + activeModel = migrateLegacyModelName(activeModel); + } + + refreshers.loadGlobalCatalogs(); // Restore persisted tabs const persistedState = storageAdapter.load(); if (persistedState !== null && persistedState.tabs.length > 0) { for (const tab of persistedState.tabs) { - const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId); - chatStores.set(tab.conversationId, store); - void store.load(); + // One-time persisted-state migration: rewrite a legacy `opencode/<model>` + // tab model to the numbered `opencode-1/<model>` namespace, and persist the + // migrated value back so a re-load is a no-op (idempotent). + const model = migrateLegacyModelName(tab.model); + if (model !== tab.model) { + tabsStore.setModel(tab.conversationId, model); + } + 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( (t) => t.conversationId === persistedState.activeConversationId, ); if (activeTab !== undefined) { - activeModel = activeTab.model; + activeModel = migrateLegacyModelName(activeTab.model); + namedAgentSnapshot = activeTab.snapshot ?? null; } } } - refreshActiveChat(); - void refreshCwd(); - void refreshComputer(); + registry.refreshActiveChat(); + void settings.refreshCwd(); + void settings.refreshComputer(); void refreshModel(); - void refreshReasoningEffort(); - 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[] { @@ -1231,162 +411,209 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }, setActiveWorkspace(workspaceId: string): void { activeWorkspaceId = workspaceId; + // Tell the backend the switch (plan 06 §6.4): workspace switching is + // instant + client-side, so the server must be told explicitly — it + // re-pushes the workspace-filtered surface catalog on receipt. The send + // is idempotent + queued until the socket opens. + socket?.send(workspaceSetMsg(workspaceId)); // 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(); - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); + namedAgentSnapshot = null; + registry.refreshActiveChat(); + surfaces.syncSubscriptions(); + void settings.refreshCwd(); + void settings.refreshComputer(); void refreshModel(); - void refreshReasoningEffort(); - 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 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). + layoutStore.onWorkspaceChanged(workspaceId); + // Frontend plugins (plan 03): the load lifecycle's workspace.set + // trigger — the enablement set is WORKSPACE-SCOPED, so a switch may + // load and unload frontend plugins. + 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 textFileContextRevision(): number { + return settings.textFileContextRevision; + }, + createTextFileClient(conversationId) { + return createTextFileClient(httpBase, conversationId, fetchImpl); + }, 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 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 - const conversationId = draftConversationId; + // 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 = registry.draftConversationId; const model = activeModel; + const snapshot = namedAgentSnapshot ?? undefined; tabsStore.createTab({ conversationId, model, title: deriveTitle(text), 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 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); }, - selectModel(model: string): void { + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent. The message-queue surface (conversation- + // scoped) reconciles the queue UI — the cancelled row leaves the snapshot. + // 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. + registry.activeChat.cancelQueuedMessage(messageId); + }, + + selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void { activeModel = model; + namedAgentSnapshot = snapshot ?? null; const activeId = tabsStore.activeConversationId; if (activeId !== null) { tabsStore.setModel(activeId, model); - chatStores.get(activeId)?.setModel(model); + tabsStore.setSnapshot(activeId, 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", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model } satisfies SetModelRequest), + body: JSON.stringify(body), }).catch((err) => { reportError("Failed to persist model", err); }); } else { - draftStore.setModel(model); + registry.draftStore.setModel(model); + registry.draftStore.setNamedAgent(snapshot ?? null); } }, newDraft(): void { tabsStore.newDraft(); const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); - draftConversationId = nextDraftId; - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); + registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId); + registry.draftConversationId = nextDraftId; + namedAgentSnapshot = null; + registry.refreshActiveChat(); + surfaces.syncSubscriptions(); + void settings.refreshCwd(); + void settings.refreshComputer(); void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); + void settings.refreshReasoningEffort(); + void settings.refreshThinking(); + void settings.refreshCompactPercent(); }, selectTab(conversationId: string): void { @@ -1394,747 +621,150 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); if (tab !== undefined) { activeModel = tab.model; + namedAgentSnapshot = tab.snapshot ?? null; + } else { + namedAgentSnapshot = null; } - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); + registry.refreshActiveChat(); + surfaces.syncSubscriptions(); + void settings.refreshCwd(); + void settings.refreshComputer(); void refreshModel(); - void refreshReasoningEffort(); - 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); - }); - }, + renameTab: conversations.renameTab, + setTabGroup: conversations.setTabGroup, invoke(surfaceId: string, actionId: string, payload?: unknown): void { - const result = protocolInvoke( - protocol, - surfaceId, - actionId, - payload, - focusedConversationId(), - ); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } + surfaces.invokeSurfaceOp(surfaceId, actionId, payload); }, - 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" }; - } + subscribeSurface(surfaceId: string, conversationId?: string): void { + surfaces.subscribeSurfaceOp(surfaceId, conversationId); }, - 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" }; - } + unsubscribeSurface(surfaceId: string, conversationId?: string): void { + surfaces.unsubscribeSurfaceOp(surfaceId, conversationId); }, - 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", - }; - } - }, + warmNow: httpActions.warmNow, - 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", - }; - } - }, + setCwd: settings.setCwd, - 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", - }; - } - }, + setComputer: settings.setComputer, - 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", - }; - } - }, + computerStatus: httpActions.computerStatus, - 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); - }); - }, + testComputer: httpActions.testComputer, - 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", - }; - } - }, + setReasoningEffort: settings.setReasoningEffort, - 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", - }; - } - }, + setThinking: settings.setThinking, - 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", - }; - } - }, + stopGeneration: httpActions.stopGeneration, - 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 }; - }, + compactNow: httpActions.compactNow, - 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", - }; - } - }, + setCompactPercent: settings.setCompactPercent, - 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", - }; - } - }, + setVisionSettings: settings.setVisionSettings, - 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", - }; - } - }, + setChatLimit: registry.setChatLimit, - 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", - }; - } - }, + lspStatus: settings.lspStatus, - 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", - }; - } - }, + mcpStatus: settings.mcpStatus, - 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", - }; - } - }, + heartbeatConfig: httpActions.heartbeatConfig, - 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", - }; - } - }, + setHeartbeatConfig: httpActions.setHeartbeatConfig, + + heartbeatRuns: httpActions.heartbeatRuns, + + stopHeartbeatRun: httpActions.stopHeartbeatRun, + + 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 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", - }; - } - }, + accountUsage: httpActions.accountUsage, - 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", - }; - } - }, + setAccountUsageSettings: httpActions.setAccountUsageSettings, - 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", - }; - } - }, + concurrencyStatus: httpActions.concurrencyStatus, - 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", - }; - } - }, + searchBackendConfig: httpActions.searchBackendConfig, - 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", - }; - } - }, + setSearchBackend: httpActions.setSearchBackend, - 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", - }; - } + getConcurrencyCooldown: httpActions.getConcurrencyCooldown, + + setConcurrencyCooldown: httpActions.setConcurrencyCooldown, + + loadSystemPrompt: httpActions.loadSystemPrompt, + + setSystemPrompt: httpActions.setSystemPrompt, + + loadSystemPromptVariables: httpActions.loadSystemPromptVariables, + + attachUnloadGate: registry.attachUnloadGate, + + // ── Named Agents (global config; plan §9) ──────────────────────────────── + + get namedAgents(): readonly NamedAgent[] { + return refreshers.namedAgents; + }, + get namedAgentSnapshot(): NamedAgentSelectionSnapshot | null { + return namedAgentSnapshot; }, - attachUnloadGate(gate: () => boolean): void { - unloadGate = gate; + // ── Plugins (plan 07) ──────────────────────────────────────────────────── + get plugins(): PluginsStore { + return pluginsStore; + }, + get layout(): LayoutStore { + return layoutStore; }, + get tileLayout(): TileLayoutStore { + return tileLayoutStore; + }, + get enabledProviderCount(): number | null { + return refreshers.enabledProviderCount; + }, + + refreshNamedAgents: refreshers.refreshNamedAgents, + + createNamedAgent: refreshers.createNamedAgent, + + updateNamedAgent: refreshers.updateNamedAgent, + + deleteNamedAgent: refreshers.deleteNamedAgent, get fatalError(): string | null { return fatalError; @@ -2144,15 +774,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 a945ea7..90c11b3 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1,9 +1,29 @@ import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; -import { describe, expect, it, vi } from "vitest"; +import type { NamedAgent } from "@dispatch/wire"; +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; @@ -104,17 +124,124 @@ interface FakeFetchOptions { models?: readonly string[]; history?: Record<string, ConversationHistoryResponse>; model?: string | null; + /** Persisted Named Agents (GET /named-agents + CRUD in-memory). */ + namedAgents?: readonly NamedAgent[]; } function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { - const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; + const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"]; const history = opts?.history ?? {}; - return async (input: string | URL | Request): Promise<Response> => { + // In-memory Named Agent store for the CRUD routes (the fake fetch mimics the + // backend's /named-agents surface). + let namedAgents: NamedAgent[] = opts?.namedAgents ? [...opts.namedAgents] : []; + return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models }), { status: 200 }); + const method = + init?.method ?? (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET"); + const bodyText = + typeof init?.body === "string" + ? init.body + : typeof input !== "string" && + !(input instanceof URL) && + typeof input.body !== "undefined" && + "body" in input + ? await input.clone().text() + : null; + 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 (/^\/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 (pathname === "/named-agents" && method === "POST") { + const payload = + bodyText === null + ? null + : (JSON.parse(bodyText) as { name?: string; model?: string } | null); + const agent: NamedAgent = { + id: `na-${namedAgents.length + 1}`, + name: payload?.name ?? "", + model: payload?.model ?? "", + }; + namedAgents = [...namedAgents, agent]; + return new Response(JSON.stringify({ namedAgent: agent }), { status: 201 }); } - if (url.endsWith("/model")) { + if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "PUT") { + const id = url.split("/named-agents/")[1] ?? ""; + const payload = + bodyText === null + ? null + : (JSON.parse(bodyText) as { name?: string; model?: string } | null); + const updated: NamedAgent = { + id, + name: payload?.name ?? "", + model: payload?.model ?? "", + }; + namedAgents = namedAgents.map((a) => (a.id === id ? updated : a)); + return new Response(JSON.stringify({ namedAgent: updated }), { status: 200 }); + } + 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 ( + /^\/conversations\/[^/]+\/model$/.test(pathname) && + (method === "GET" || method === "PUT") + ) { return new Response( JSON.stringify({ conversationId: "ignored", @@ -123,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); }; } @@ -145,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); @@ -163,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(); @@ -181,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({ @@ -457,6 +621,7 @@ describe("createAppStore", () => { socketFactory: () => ws, fetchImpl: fakeFetchImpl(), localStorage: createFakeStorage(), + chatFlushScheduler: null, }); ws.resolveOpen(); @@ -518,17 +683,25 @@ 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 (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { 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(); @@ -573,19 +746,19 @@ describe("createAppStore", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl({ - models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], + models: ["opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], }), localStorage: createFakeStorage(), }); ws.resolveOpen(); - await new Promise((r) => setTimeout(r, 50)); - - expect(store.models).toEqual([ - "opencode/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(); }); @@ -599,11 +772,68 @@ describe("createAppStore", () => { }); ws.resolveOpen(); - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); store.dispose(); }); + it("does not silently switch the active model when the selected one is absent from the catalog", async () => { + // The catalog only has umans; the active (default/selected) OpenCode model is + // NOT available. The FE must NOT auto-fall back to the first catalog model — + // it keeps the selection and lets the model selector surface it as unavailable. + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + await vi.waitFor(() => expect(store.models).toEqual(["umans/umans-glm-5.2"])); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + + store.dispose(); + }); + + it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => { + const ws = fakeSocket(); + // Seed localStorage with a persisted tab whose model is the OBSOLETE + // `opencode/deepseek-v4-flash` namespace. + const storage = createFakeStorage(); + storage.setItem( + "dispatch.tabs", + JSON.stringify({ + tabs: [ + { + conversationId: "c-legacy", + title: "Legacy", + model: "opencode/deepseek-v4-flash", + workspaceId: "default", + }, + ], + activeConversationId: null, + }), + ); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + // After restore, the restored tab's model + the persisted store are migrated. + const restored = store.tabs.find((t) => t.conversationId === "c-legacy"); + expect(restored?.model).toBe("opencode-1/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + // Issue 7: the migrated tab state is ACTUALLY written back to the storage + // adapter immediately (not merely kept in memory) — a re-load is now a no-op. + const persisted = JSON.parse(storage.getItem("dispatch.tabs") ?? "") as { + tabs: Array<{ conversationId: string; model: string }>; + }; + const persistedModel = persisted.tabs.find((t) => t.conversationId === "c-legacy")?.model; + expect(persistedModel).toBe("opencode-1/deepseek-v4-flash"); + expect(persisted.tabs.some((t) => t.model.startsWith("opencode/"))).toBe(false); + store.dispose(); + }); + it("draft: sending the first message creates a tab titled from the message", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -679,7 +909,7 @@ describe("createAppStore", () => { store.send("first message"); // New tab opens with the default model until the persisted-model fetch resolves. - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); // Wait for the persisted model fetch to resolve. await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o")); @@ -688,12 +918,148 @@ describe("createAppStore", () => { store.dispose(); }); + 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, + ): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { + status: 200, + }); + } + // The authoritative open-conversation list — one conversation whose + // persisted model differs from the global activeModel. + if (url.includes("/conversations?status=")) { + return new Response( + JSON.stringify({ + conversations: [ + { + id: "conv-model", + createdAt: 1000, + lastActivityAt: 2000, + title: "Model conv", + status: "idle", + workspaceId: "default", + model: "openrouter/stealth/ox-alpha", + }, + ], + }), + { status: 200 }, + ); + } + return base(input, init); + }; + + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // global activeModel is the default until anything changes it + expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash"); + + // fetchOpenConversations (boot reconcile) opens the conversation's tab — + // stamped with the PERSISTED model from the list, not activeModel. + await vi.waitFor(() => { + const tab = store.tabs.find((t) => t.conversationId === "conv-model"); + expect(tab?.model).toBe("openrouter/stealth/ox-alpha"); + }); + + 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({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // The user stays on their current (draft) tab; the backend broadcasts + // `conversation.open` with the conversation's persisted model. + ws.feedServerMessage({ + type: "conversation.open", + conversationId: "conv-ws", + workspaceId: "default", + model: "openrouter/stealth/ox-alpha", + }); + + await vi.waitFor(() => { + const tab = store.tabs.find((t) => t.conversationId === "conv-ws"); + expect(tab?.model).toBe("openrouter/stealth/ox-alpha"); + expect(tab?.workspaceId).toBe("default"); + }); + + store.dispose(); + }); + it("chat.delta routes to the matching tab only", () => { const ws = fakeSocket(); const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), localStorage: createFakeStorage(), + chatFlushScheduler: null, }); ws.resolveOpen(); @@ -891,6 +1257,135 @@ describe("createAppStore", () => { store.dispose(); }); + it("seeds thinking from GET /conversations/:id/thinking (null = never set ⇒ ON)", 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("/thinking")) { + return new Response(JSON.stringify({ conversationId: "x", thinking: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => { + expect(store.thinking).toBe(false); + }); + + store.dispose(); + }); + + it("treats a missing thinking endpoint (404) as 'never set' (null ⇒ ON)", async () => { + // The thinking endpoint is PROPOSED (backend-handoff.md); until the backend + // ships it, GET 404s and `thinking` stays null ⇒ the selector shows the + // effort level (thinking ON, the default) — graceful, never a crash. + 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("/thinking")) { + return new Response("not found", { status: 404 }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // give the (404ing) fetch a tick to settle + await vi.waitFor(() => { + expect(store.reasoningEffort).not.toBe(undefined); + }); + expect(store.thinking).toBeNull(); + + store.dispose(); + }); + + it("treats a SPA-fallback response (200 + text/html) as 'never set' (null ⇒ ON)", async () => { + // In production (DISPATCH_WEB_DIR set), unknown API routes get the SPA + // fallback: index.html with HTTP 200 + text/html. This must NOT crash + // res.json() — it should leave `thinking` null (ON, the default). + 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("/thinking")) { + return new Response("<!DOCTYPE html><html><body>SPA fallback</body></html>", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // give the (HTML) fetch a tick to settle + await vi.waitFor(() => { + expect(store.reasoningEffort).not.toBe(undefined); + }); + expect(store.thinking).toBeNull(); + + store.dispose(); + }); + + it("setThinking PUTs the flag and updates local state from the echo", async () => { + const calls: { url: string; method: string; body: string | undefined }[] = []; + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined }); + if (url.endsWith("/thinking") && init?.method === "PUT") { + const sent = JSON.parse(init.body as string) as { thinking: boolean }; + return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.endsWith("/thinking")) { + return new Response(JSON.stringify({ conversationId: "x", thinking: null }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setThinking(false); + expect(result).toEqual({ ok: true, thinking: false }); + expect(store.thinking).toBe(false); + + const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/thinking")); + expect(put).toBeDefined(); + expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`); + expect(JSON.parse(put?.body ?? "{}")).toEqual({ thinking: false }); + + store.dispose(); + }); + it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -1112,17 +1607,21 @@ 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 (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + if (/\/models(\?.*)?$/.test(url)) { + return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), { status: 200, }); } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + return base(input, init); }; const ws = reconnectableSocket(); @@ -1205,9 +1704,6 @@ describe("createAppStore", () => { status: 200, }); } - if (url.includes("/heartbeat")) { - return new Response(JSON.stringify(config), { status: 200 }); - } return base(input, init); }; } @@ -1223,6 +1719,7 @@ describe("createAppStore", () => { if (!result.ok) throw new Error("unreachable"); expect(result.config).toEqual({ enabled: true, + inactiveOnly: true, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 15, @@ -1423,7 +1920,7 @@ describe("createAppStore", () => { const limits = opts?.limits ?? { limits: [ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ], }; const limit = opts?.limit ?? { providerId: "umans", limit: 4 }; @@ -1431,7 +1928,7 @@ describe("createAppStore", () => { providers: [ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, { - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, @@ -1491,7 +1988,7 @@ describe("createAppStore", () => { if (!result.ok) throw new Error("unreachable"); expect(result.limits).toEqual([ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ]); store.dispose(); }); @@ -1663,7 +2160,7 @@ describe("createAppStore", () => { autoReduced: false, }); expect(result.providers[1]).toMatchObject({ - providerId: "openai-compat", + providerId: "opencode-1", paused: true, pausedUntil: 1_719_408_000_000, cooldownMs: 350, @@ -1969,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 { @@ -1976,7 +2474,7 @@ describe("createAppStore — vision settings (global)", () => { fetchImpl: async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { + if (/\/models(\?.*)?$/.test(url)) { return new Response( JSON.stringify({ models: ["kimi/k2", "umans/glm-5.2"], @@ -1997,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); }, }; } @@ -2061,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")) { @@ -2071,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(), @@ -2087,3 +2585,616 @@ describe("createAppStore — vision settings (global)", () => { store.dispose(); }); }); + +describe("createAppStore — Named Agents", () => { + it("seeds namedAgents from GET /named-agents on boot", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => expect(store.namedAgents).toEqual(agents)); + store.dispose(); + }); + + it("createNamedAgent appends to the store list", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.createNamedAgent({ name: "Research", model: "umans/glm" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.agent.id).toBe("na-1"); + expect(store.namedAgents).toHaveLength(1); + expect(store.namedAgents[0]?.name).toBe("Research"); + } + store.dispose(); + }); + + it("updateNamedAgent updates the record in place", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.updateNamedAgent("na-1", { + name: "Coding", + model: "opencode-1/new", + }); + expect(result.ok).toBe(true); + expect(store.namedAgents[0]?.name).toBe("Coding"); + expect(store.namedAgents[0]?.model).toBe("opencode-1/new"); + store.dispose(); + }); + + it("deleteNamedAgent removes the record immediately", async () => { + const ws = fakeSocket(); + const agents: readonly NamedAgent[] = [ + { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "na-2", name: "Research Agent", model: "umans/glm" }, + ]; + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ namedAgents: agents }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + await vi.waitFor(() => expect(store.namedAgents).toHaveLength(agents.length)); + + const result = await store.deleteNamedAgent("na-1"); + expect(result.ok).toBe(true); + expect(store.namedAgents.map((a) => a.id)).toEqual(["na-2"]); + store.dispose(); + }); + + it("selectModel records a Named Agent snapshot on the active conversation", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.selectModel("opencode-1/claude-sonnet-4.5", { + namedAgentId: "na-1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + + expect(store.activeModel).toBe("opencode-1/claude-sonnet-4.5"); + expect(store.namedAgentSnapshot?.namedAgentName).toBe("Coding Agent"); + store.dispose(); + }); + + it("selectModel without a snapshot clears a prior Named Agent selection", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.selectModel("opencode-1/claude-sonnet-4.5", { + namedAgentId: "na-1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + store.selectModel("openai/gpt-4o"); + + expect(store.activeModel).toBe("openai/gpt-4o"); + expect(store.namedAgentSnapshot).toBeNull(); + 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/view-ports.ts b/src/app/view-ports.ts new file mode 100644 index 0000000..577fe11 --- /dev/null +++ b/src/app/view-ports.ts @@ -0,0 +1,54 @@ +/** + * The port bag the composition root hands to every registered view wrapper. + * + * This is the ONLY shape of app state the wrappers may touch: the app store + * plus the two chrome ports that live outside the tree (the system-prompt + * builder modal and the heartbeat run modal). Keeping it here is what makes + * `register-views.ts` possible — the wrappers take `{ ports }` and close over + * exactly what `viewContent` used to pass in App.svelte. + */ + +import { tick } from "svelte"; +import type { HeartbeatRunView } from "../features/heartbeat"; +import type { AppStore } from "./store.svelte"; + +/** + * The chat transcript's live scroll element, shared with views that must + * preserve the reader's viewport across a prepend (today: the settings view's + * chat-limit save — the same mechanism as "show earlier"). The chat pane writes + * it on mount via an effect; nothing else may. + */ +export interface ChatViewportBridge { + el: HTMLElement | null; +} + +export interface AppViewPorts { + readonly store: AppStore; + readonly chatViewport: ChatViewportBridge; + /** Chrome: open the system-prompt builder modal (app shell state, not a view). */ + readonly openSystemPromptBuilder: () => void; + /** Chrome: open the heartbeat run modal for a run (app shell state, not a view). */ + readonly openHeartbeatRun: (run: HeartbeatRunView) => void; +} + +/** + * Run an async action that PREPENDS transcript content while preserving the + * reader's viewport position: prepended content grows scrollHeight, so shift + * scrollTop by the growth (the manual analogue of CSS scroll anchoring, which + * not every engine applies here). Used by "show earlier" AND the chat-limit + * save (which refills older history on a raise). + */ +export async function preserveViewportAcross<T>( + el: HTMLElement | null | undefined, + run: () => Promise<T>, +): Promise<T> { + const prevHeight = el?.scrollHeight ?? 0; + const prevTop = el?.scrollTop ?? 0; + const result = await run(); + await tick(); + if (el) { + const delta = el.scrollHeight - prevHeight; + if (delta > 0) el.scrollTop = prevTop + delta; + } + return result; +} diff --git a/src/app/views/AccountUsagePane.svelte b/src/app/views/AccountUsagePane.svelte new file mode 100644 index 0000000..09afb5f --- /dev/null +++ b/src/app/views/AccountUsagePane.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + /** + * The `account-usage` view — Claude + Codex + Hyper usage bars with + * on-track pace markers. GLOBAL (not workspace/conversation scoped), so it + * stays mounted across tab switches (no {#key}). + */ + import { + AccountUsageView, + type LoadAccountUsage, + type SaveAccountUsageSettings, + } from "../../features/account-usage"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const loadAccountUsage: LoadAccountUsage = (opts) => store.accountUsage(opts); + const saveAccountUsageSettings: SaveAccountUsageSettings = (provider, settings) => + store.setAccountUsageSettings(provider, settings); +</script> + +<AccountUsageView load={loadAccountUsage} saveSettings={saveAccountUsageSettings} /> diff --git a/src/app/views/CacheWarmingPane.svelte b/src/app/views/CacheWarmingPane.svelte new file mode 100644 index 0000000..d1d9b43 --- /dev/null +++ b/src/app/views/CacheWarmingPane.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + /** + * The `cache-warming` view — the backend's conversation-scoped cache-warming + * surface with the WarmNow adapter. Re-mounts per conversation (like + * ChatView) so the view's local warming history / manual-warm feedback + * can't bleed across tabs (the {#key} moved inside the wrapper). + */ + import { CacheWarmingView, type WarmFeedback } from "../../features/cache-warming"; + import type { AppViewPorts } from "../view-ports"; + + const CACHE_WARMING_ID = "cache-warming"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. + async function warmNow(): Promise<WarmFeedback | null> { + const result = await store.warmNow(); + if (result === null) return null; + return result.ok + ? { + ok: true, + cachePct: result.response.cachePct, + expectedCacheRate: result.response.expectedCacheRate, + } + : { ok: false, error: result.error }; + } +</script> + +{#key store.activeConversationId} + <CacheWarmingView + spec={store.surface(CACHE_WARMING_ID)} + canWarm={store.activeConversationId !== null} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + {warmNow} + /> +{/key} diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte new file mode 100644 index 0000000..b5e05bb --- /dev/null +++ b/src/app/views/ChatPane.svelte @@ -0,0 +1,117 @@ +<script lang="ts"> + /** + * The `chat` view — the transcript region that moved out of App.svelte with + * ALL of its coupling: the smart-scroll controller (created here, reset on + * conversation switch like today's reset effect), the unload-gate attach + * effect, the follow-bottom effect, "show earlier" with viewport + * preservation, the ScrollToBottom affordance, and the empty-state + * watermark. Keyed on the active conversation exactly like today's ChatView. + */ + 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); + const textFiles = $derived.by(() => { + void store.activeWorkspaceId; + void store.textFileContextRevision; + const id = store.activeConversationId; + return id ? store.createTextFileClient(id) : undefined; + }); + $effect(() => { + const client = textFiles; + return () => client?.dispose(); + }); + + // 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(); + let transcriptEl = $state<HTMLElement | undefined>(); + let transcriptContentEl = $state<HTMLElement | undefined>(); + + // The settings view's chat-limit save preserves the reader's viewport over + // the history refill — share the live scroll element through the bridge. + $effect(() => { + ports.chatViewport.el = transcriptEl ?? null; + }); + + // Chat-limit unload gate: old chunks may be unloaded only while the reader is + // stuck to the bottom. While stuck, a trim removes content far ABOVE the + // viewport and the controller re-pins to the bottom — no visible jump; while + // reading history, trimming is deferred instead of yanking the page (the old + // Dispatch bug). In an $effect so a swapped store prop would be re-wired. + $effect(() => { + store.attachUnloadGate(() => smartScroll.isAtBottom()); + }); + + // "Show earlier messages": page older history back in, preserving the reader's + // viewport position. + async function handleShowEarlier(): Promise<void> { + await preserveViewportAcross(transcriptEl, () => store.activeChat.showEarlier()); + } + + // Attach/detach the controller to the live scroll element + content (disposed + // on unmount). The content element is observed (ResizeObserver) so the view + // follows height changes that aren't a transcript append. + $effect(() => { + if (!transcriptEl) return; + return smartScroll.attach(transcriptEl, transcriptContentEl); + }); + + // New transcript content streamed in (or messages loaded) → follow the bottom + // while stuck. Reads `chunks.length` so the effect re-runs on every append. + $effect(() => { + void store.activeChat.chunks.length; + smartScroll.contentChanged(); + }); + + // Conversation/tab switch → snap to the bottom of the new transcript. + $effect(() => { + void store.activeConversationId; + smartScroll.reset(); + }); +</script> + +<div class="relative h-full"> + <!-- The right gutter lives INSIDE the scroller (on the content), not outside + it: overlay scrollbars (iOS/iPadOS WebKit — Safari, Vivaldi, …; macOS) + take no layout space and paint over the scroller's right edge, so with + the gutter outside they drew on top of the tool/thinking cards. Now the + scroller spans to the pane edge and its scrollbar sits in the empty + gutter, clear of the content (classic scrollbars likewise). --> + <div bind:this={transcriptEl} class="h-full overflow-y-auto"> + <div bind:this={transcriptContentEl} class="pr-4"> + {#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} + {textFiles} + {cacheTimer} + /> + {/key} + <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} /> + </div> + </div> + {#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" + > + <span class="select-none text-4xl font-bold opacity-10">Dispatch</span> + </div> + {/if} + <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} /> +</div> diff --git a/src/app/views/CompactionPane.svelte b/src/app/views/CompactionPane.svelte new file mode 100644 index 0000000..ff31f0b --- /dev/null +++ b/src/app/views/CompactionPane.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + /** + * The `compaction` view — message compaction with the store adapters. + * Per-conversation (keyed so the percent + feedback can't bleed across tabs; + * the {#key} moved inside the wrapper). + */ + import { + CompactionView, + type CompactNowResult, + type SaveCompactPercentResult, + } from "../../features/chat"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's compact result to the compaction view's port. + async function compactNow(): Promise<CompactNowResult | null> { + const result = await store.compactNow(); + if (result === null) return null; + return result.ok + ? { + ok: true, + messagesSummarized: result.response.messagesSummarized, + messagesKept: result.response.messagesKept, + } + : { ok: false, error: result.error }; + } + + async function saveCompactPercent( + percent: number, + ): Promise<SaveCompactPercentResult | null> { + const result = await store.setCompactPercent(percent); + if (result === null) return null; + return result.ok + ? { ok: true, percent: result.percent } + : { ok: false, error: result.error }; + } +</script> + +{#key store.currentConversationId} + <CompactionView + percent={store.compactPercent} + canCompact={store.activeConversationId !== null} + {compactNow} + savePercent={saveCompactPercent} + /> +{/key} diff --git a/src/app/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte new file mode 100644 index 0000000..00dbc9f --- /dev/null +++ b/src/app/views/ComposerPane.svelte @@ -0,0 +1,43 @@ +<script lang="ts"> + /** + * The `composer` view — the send box with the status derivation that used to + * live in App.svelte (error > queued > running > idle) and the + * send/queue/stop ports. Not conversation-scoped (no {#key}) — the composer + * 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 + // the slot is granted, so `generating` is already true while + // `conversationStatus === "queued"`; the explicit queued check is what + // distinguishes the two. + const composerStatus = $derived.by<ComposerStatus>(() => { + if (store.activeChat.error) return "error"; + const id = store.activeConversationId; + if (id !== null && store.conversationStatus(id) === "queued") return "queued"; + return store.activeChat.generating ? "running" : "idle"; + }); +</script> + +<Composer + onSend={(text, images) => store.send(text, images)} + 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/views/ConcurrencyPane.svelte b/src/app/views/ConcurrencyPane.svelte new file mode 100644 index 0000000..d04307f --- /dev/null +++ b/src/app/views/ConcurrencyPane.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The `concurrency` view — per-provider concurrency limits + live status. + * GLOBAL (not workspace- or conversation-scoped), so the panel stays mounted + * across tab switches (no {#key}, like today). + */ + import { + ConcurrencyView, + type DeleteConcurrencyLimit, + type LoadConcurrencyLimits, + type LoadConcurrencyStatus, + type SaveConcurrencyCooldown, + type SaveConcurrencyLimit, + } from "../../features/concurrency"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Thin passthroughs (kept for structural consistency — AGENTS.md "contracts + // are the cross-unit surface"). + const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits(); + const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) => + store.setConcurrencyLimit(providerId, limit); + const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) => + store.deleteConcurrencyLimit(providerId); + const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus(); + const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) => + store.setConcurrencyCooldown(providerId, cooldownMs); +</script> + +<ConcurrencyView + models={store.models} + loadLimits={loadConcurrencyLimits} + saveLimit={saveConcurrencyLimit} + deleteLimit={deleteConcurrencyLimit} + loadStatus={loadConcurrencyStatus} + saveCooldown={saveConcurrencyCooldown} +/> diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte new file mode 100644 index 0000000..d5baa39 --- /dev/null +++ b/src/app/views/ExtensionsPane.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + /** + * The `extensions` view — the frontend-modules table (aggregated from each + * feature's public `manifest` so it can't drift from what's actually + * composed) plus the generic surface list. The backend's "Loaded Extensions" + * surface is a SEPARATE, backend-owned list. Unkeyed (like today). + */ + import Table from "../../components/Table.svelte"; + import { manifest as accountUsageManifest } from "../../features/account-usage"; + import { manifest as cacheWarmingManifest } from "../../features/cache-warming"; + import { manifest as chatManifest } from "../../features/chat"; + import { manifest as concurrencyManifest } from "../../features/concurrency"; + import { manifest as conversationCacheManifest } from "../../features/conversation-cache"; + import { manifest as cwdLspManifest } from "../../features/cwd-lsp"; + import { manifest as computerManifest } from "../../features/computer"; + import { manifest as heartbeatManifest } from "../../features/heartbeat"; + import { manifest as layoutManifest } from "../../features/layout"; + import { manifest as markdownManifest } from "../../features/markdown"; + import { manifest as mcpManifest } from "../../features/mcp"; + import { manifest as namedAgentsManifest } from "../../features/named-agents"; + import { manifest as searchSettingsManifest } from "../../features/search-settings"; + import { manifest as settingsManifest } from "../../features/settings"; + import { manifest as smartScrollManifest } from "../../features/smart-scroll"; + import { SurfaceView } from "../../features/surface-host"; + import { manifest as surfaceHostManifest } from "../../features/surface-host"; + import { manifest as systemPromptManifest } from "../../features/system-prompt"; + import { manifest as tabsManifest } from "../../features/tabs"; + import { manifest as viewsManifest } from "../../features/views"; + import { manifest as visionManifest } from "../../features/vision"; + import type { AppViewPorts } from "../view-ports"; + + // Surfaces with dedicated renderers elsewhere stay out of the generic list: + // cache-warming (its own view), message-queue (its own view), todo (the + // Tasks view). SurfaceView itself stays fully generic (it never switches on + // a surface id) — the FILTER is this view's data selection. + const CACHE_WARMING_ID = "cache-warming"; + const MESSAGE_QUEUE_ID = "message-queue"; + const TODO_ID = "todo"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const MODULE_COLUMNS = ["Module", "Description"] as const; + // FE features are internal units of this single repo, so there is no + // per-module version — they all share dispatch-web's version. + const loadedModules: readonly (readonly [string, string])[] = [ + chatManifest, + tabsManifest, + surfaceHostManifest, + viewsManifest, + layoutManifest, + conversationCacheManifest, + markdownManifest, + cacheWarmingManifest, + cwdLspManifest, + mcpManifest, + computerManifest, + smartScrollManifest, + settingsManifest, + systemPromptManifest, + heartbeatManifest, + concurrencyManifest, + accountUsageManifest, + visionManifest, + namedAgentsManifest, + searchSettingsManifest, + ].map((m) => [m.name, m.description] as const); +</script> + +<section> + <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> + <Table columns={MODULE_COLUMNS} rows={loadedModules} /> +</section> +<section class="mt-4 flex flex-col gap-3"> + <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3> + {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} + <SurfaceView + {spec} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + /> + {/each} +</section> diff --git a/src/app/views/HeartbeatPane.svelte b/src/app/views/HeartbeatPane.svelte new file mode 100644 index 0000000..1bb2e86 --- /dev/null +++ b/src/app/views/HeartbeatPane.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + /** + * The `heartbeat` view — workspace-scoped autonomous-agent heartbeat (config + * + run history) with the store adapters. Opening a RUN raises the chrome + * port: the run modal is app shell state (outside the tree — layout-00 + * decision 2). Unkeyed; the config + runs are per-workspace (like today). + */ + import { + HeartbeatView, + type HeartbeatConfigResult, + type HeartbeatNextRunResult, + type HeartbeatRunView, + type HeartbeatRunsResult, + type HeartbeatStopResult, + } from "../../features/heartbeat"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Thin passthroughs (kept for structural consistency with cwd-lsp/mcp — + // AGENTS.md "contracts are the cross-unit surface"). + async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> { + return store.heartbeatConfig(); + } + + async function saveHeartbeatConfig( + patch: Parameters<typeof store.setHeartbeatConfig>[0], + ): Promise<HeartbeatConfigResult> { + return store.setHeartbeatConfig(patch); + } + + async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> { + return store.heartbeatRuns(); + } + + async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> { + return store.stopHeartbeatRun(runId); + } + + async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> { + return store.heartbeatNextRun(); + } +</script> + +<HeartbeatView + models={store.models} + loadConfig={loadHeartbeatConfig} + saveConfig={saveHeartbeatConfig} + loadRuns={loadHeartbeatRuns} + stopRun={stopHeartbeatRun} + loadVariables={() => store.loadSystemPromptVariables()} + loadDefaultPrompt={() => store.loadSystemPrompt()} + loadNextRun={loadHeartbeatNextRun} + onOpenRun={(run) => ports.openHeartbeatRun(run)} +/> diff --git a/src/app/views/LspPane.svelte b/src/app/views/LspPane.svelte new file mode 100644 index 0000000..5645ace --- /dev/null +++ b/src/app/views/LspPane.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + /** + * The `lsp` view — loaded language servers per conversation. Re-mounts per + * conversation (incl. draft) so the loaded server list is isolated (the + * {#key} moved inside the wrapper). + */ + import { LspStatusView, type LspStatusResult } from "../../features/cwd-lsp"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + async function loadLspStatus(): Promise<LspStatusResult | null> { + const result = await store.lspStatus(); + if (result === null) return null; + return result.ok + ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } + : { ok: false, error: result.error }; + } +</script> + +{#key store.currentConversationId} + <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} /> +{/key} diff --git a/src/app/views/McpPane.svelte b/src/app/views/McpPane.svelte new file mode 100644 index 0000000..82e139d --- /dev/null +++ b/src/app/views/McpPane.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + /** + * The `mcp` view — loaded MCP servers per conversation. Re-mounts per + * conversation (incl. draft) so the loaded server list is isolated (the + * {#key} moved inside the wrapper). + */ + import { McpStatusView, type McpStatusResult } from "../../features/mcp"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + async function loadMcpStatus(): Promise<McpStatusResult | null> { + const result = await store.mcpStatus(); + if (result === null) return null; + return result.ok + ? { ok: true, cwd: result.response.cwd, servers: result.response.servers } + : { ok: false, error: result.error }; + } +</script> + +{#key store.currentConversationId} + <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} /> +{/key} diff --git a/src/app/views/MessageQueuePane.svelte b/src/app/views/MessageQueuePane.svelte new file mode 100644 index 0000000..46b9a22 --- /dev/null +++ b/src/app/views/MessageQueuePane.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + /** + * The `message-queue` view — pending steering messages, rendered via the + * generic SurfaceView (dispatches on rendererId, never surface id). The + * non-empty conditional moved INSIDE the view (decision 4): the tree's leaf + * always exists; an idle queue renders NOTHING. + */ + import { SurfaceView } from "../../features/surface-host"; + import { parseMessageQueuePayload } from "../../features/surface-host/logic/message-queue"; + import type { AppViewPorts } from "../view-ports"; + + // The message-queue extension's per-conversation surface (steering). Pulled + // out of the generic Extensions list — pending steering messages are tied to + // the chat, not the sidebar. + const MESSAGE_QUEUE_ID = "message-queue"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // The surface spec + whether it currently has pending messages. + const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID)); + const hasQueuedMessages = $derived.by(() => { + const spec = messageQueueSpec; + if (spec === null) return false; + const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID); + if (field === undefined || field.kind !== "custom") return false; + const data = parseMessageQueuePayload(field.payload); + return data !== null && data.messages.length > 0; + }); +</script> + +{#if hasQueuedMessages && messageQueueSpec !== null} + <div class="px-4 pt-2"> + <SurfaceView + spec={messageQueueSpec} + onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)} + onCancelQueuedMessage={(messageId) => store.cancelQueuedMessage(messageId)} + /> + </div> +{/if} diff --git a/src/app/views/ModelPane.svelte b/src/app/views/ModelPane.svelte new file mode 100644 index 0000000..0344e47 --- /dev/null +++ b/src/app/views/ModelPane.svelte @@ -0,0 +1,131 @@ +<script lang="ts"> + /** + * The `model` view — the model selector + the per-conversation + * reasoning-effort / cwd / computer controls, with the store adapters that + * used to live in App.svelte. The controls re-mount per workspace + * conversation (the {#key} moved inside the wrapper) so inputs can't bleed + * across tabs — editable for a draft too. + */ + import { + ModelSelector, + ReasoningEffortSelector, + type ThinkingSelection, + type ThinkingSelectionSaveResult, + } from "../../features/chat"; + import { CwdField, type CwdSaveResult } from "../../features/cwd-lsp"; + import { + ComputerField, + type ComputerSaveResult, + type ComputerStatusResult, + type LoadComputerStatus, + type SaveComputer, + type TestComputer, + type TestComputerResult, + } from "../../features/computer"; + import type { NamedAgentSelectionSnapshot } from "../../features/named-agents"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + function handleSelectModel(model: string): void { + store.selectModel(model); + } + + // A Named Agent selected in the model selector: resolve it to its raw model + // AND record the snapshot on the active conversation (plan §6/§7). The store + // persists the raw model to `/conversations/:id/model` + retains the snapshot + // on the tab. + function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot): void { + store.selectModel(snapshot.model, snapshot); + } + + // Adapt the store's reasoning-effort + thinking results to the chat + // feature's combined selector port. The selector sends ONE selection ("off" + // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is + // a SEPARATE signal from the effort level: it persists `thinking: false` + // (the umans route maps that to `reasoning_effort: "none"`), leaving the + // effort level untouched so an off→on toggle restores it. A level ensures + // thinking is ON (the level is meaningless while thinking is off) then sets + // the effort level. + async function saveThinkingSelection( + selection: ThinkingSelection, + ): Promise<ThinkingSelectionSaveResult | null> { + if (selection === "off") { + const result = await store.setThinking(false); + if (result === null) return null; + return result.ok + ? { ok: true, selection: "off" } + : { ok: false, error: result.error }; + } + // A level: enable thinking first if it is currently off, then set the level. + if (store.thinking === false) { + const on = await store.setThinking(true); + if (on !== null && !on.ok) { + return { ok: false, error: on.error }; + } + } + const result = await store.setReasoningEffort(selection); + if (result === null) return null; + return result.ok + ? { ok: true, selection: result.reasoningEffort } + : { ok: false, error: result.error }; + } + + async function saveCwd(cwd: string): Promise<CwdSaveResult | null> { + const result = await store.setCwd(cwd); + if (result === null) return null; + return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error }; + } + + async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> { + const result = await store.setComputer(computerId); + if (result === null) return null; + return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error }; + } + + const loadComputerStatus: LoadComputerStatus = async ( + alias: string, + ): Promise<ComputerStatusResult | null> => { + const result = await store.computerStatus(alias); + if (result === null) return null; + return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error }; + }; + + const testComputer: TestComputer = async ( + alias: string, + ): Promise<TestComputerResult | null> => { + const result = await store.testComputer(alias); + if (result === null) return null; + return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error }; + }; +</script> + +<div class="flex flex-col gap-3"> + <ModelSelector + models={store.models} + selected={store.activeModel} + onSelect={handleSelectModel} + modelInfo={store.modelInfo} + namedAgents={store.namedAgents} + namedAgentSnapshot={store.namedAgentSnapshot} + onSelectNamedAgent={handleSelectNamedAgent} + zeroProviders={store.enabledProviderCount === 0} + /> + {#key store.currentConversationId} + <ReasoningEffortSelector + persistedEffort={store.reasoningEffort} + persistedThinking={store.thinking} + save={saveThinkingSelection} + /> + <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> + <ComputerField + computerId={store.computerId} + canEdit={true} + computers={store.computers} + save={saveComputer} + loadStatus={loadComputerStatus} + test={testComputer} + /> + {/key} +</div> diff --git a/src/app/views/NamedAgentsPane.svelte b/src/app/views/NamedAgentsPane.svelte new file mode 100644 index 0000000..413141a --- /dev/null +++ b/src/app/views/NamedAgentsPane.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + /** + * The `named-agents` view — a global, collapsible sidebar view of curated + * presets. The store owns the list (authoritative for the model selector + + * agent tooling); the view drives CRUD via injected ports (auto-save). + * Unkeyed — the list is global (no {#key}, like today). + */ + import { + NamedAgentsView, + type NamedAgentInput, + } from "../../features/named-agents"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's Named Agent CRUD to the named-agents feature's ports. + const namedAgentPorts = { + create: (input: NamedAgentInput) => store.createNamedAgent(input), + update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input), + remove: (id: string) => store.deleteNamedAgent(id), + }; +</script> + +<NamedAgentsView agents={store.namedAgents} models={store.models} ports={namedAgentPorts} /> diff --git a/src/app/views/SettingsPane.svelte b/src/app/views/SettingsPane.svelte new file mode 100644 index 0000000..f119103 --- /dev/null +++ b/src/app/views/SettingsPane.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The `settings` view — FE-local settings (the chat limit). Not + * conversation-scoped (no {#key}: the chat limit is global). A chat-limit + * RAISE refills the active chat (prepends older history) — the reader's + * viewport is preserved over the prepend via the chat viewport bridge + * (exactly like today's saveChatLimit). + */ + import { tick } from "svelte"; + import { ChatLimitField, type ChatLimitSaveResult } from "../../features/settings"; + import { SearchBackendField } from "../../features/search-settings"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> { + const el = ports.chatViewport.el; + const prevHeight = el?.scrollHeight ?? 0; + const prevTop = el?.scrollTop ?? 0; + const result = await store.setChatLimit(value); + await tick(); + if (el) { + const delta = el.scrollHeight - prevHeight; + if (delta > 0) el.scrollTop = prevTop + delta; + } + return result.ok + ? { ok: true, chatLimit: result.chatLimit } + : { ok: false, error: result.error }; + } +</script> + +<div class="flex flex-col gap-3"> + <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} /> + <SearchBackendField + load={() => store.searchBackendConfig()} + save={(backend) => store.setSearchBackend(backend)} + /> +</div> diff --git a/src/app/views/SystemPromptPane.svelte b/src/app/views/SystemPromptPane.svelte new file mode 100644 index 0000000..0fb5a11 --- /dev/null +++ b/src/app/views/SystemPromptPane.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + /** + * The `system-prompt` view — a pointer to the global system-prompt template + * that opens the full-page modal editor (half template / half variable + * palette). The modal itself is CHROME (App shell state, outside the tree — + * layout-00 decision 2); this view only raises the port. Unkeyed (like + * today). + */ + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); +</script> + +<div class="flex flex-col gap-2"> + <p class="text-xs opacity-60"> + Edit the global system prompt template with variable placeholders. Opens a full-page editor. + </p> + <button type="button" class="btn btn-primary btn-sm" onclick={() => ports.openSystemPromptBuilder()}> + Open builder + </button> +</div> diff --git a/src/app/views/TabsPane.svelte b/src/app/views/TabsPane.svelte new file mode 100644 index 0000000..4547e06 --- /dev/null +++ b/src/app/views/TabsPane.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + /** + * The `tabs` view — the conversation tab list (moved out of the top bar into + * the sidebar). Re-mounts per workspace so the filtered tab set + scroll + * reset cleanly on a workspace switch (the {#key} moved inside the wrapper). + */ + import { TabList } from "../../features/tabs"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); +</script> + +{#key store.activeWorkspaceId} + <TabList + tabs={store.tabs} + activeConversationId={store.activeConversationId} + statusFor={(id) => store.conversationStatus(id)} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + onRename={(id, title) => store.renameTab(id, title)} + onSetTabGroup={(id, parentId) => store.setTabGroup(id, parentId)} + /> +{/key} diff --git a/src/app/views/TasksPane.svelte b/src/app/views/TasksPane.svelte new file mode 100644 index 0000000..6a375b3 --- /dev/null +++ b/src/app/views/TasksPane.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + /** + * The `tasks` view — the todo extension's per-conversation task list + * (model-maintained, read-only). Re-mounts per conversation so the task list + * is isolated per conversation (the {#key} moved inside the wrapper). + * TodoList always reserves its fixed height (empty or full). + */ + import TodoList from "../../features/surface-host/ui/TodoList.svelte"; + import { parseTodoPayload } from "../../features/surface-host/logic/todo"; + import type { AppViewPorts } from "../view-ports"; + + // The `todo` extension's per-conversation task list surface. + const TODO_ID = "todo"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + const todoData = $derived.by(() => { + const spec = store.surface(TODO_ID); + if (spec === null) return null; + const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID); + if (field === undefined || field.kind !== "custom") return null; + return parseTodoPayload(field.payload); + }); +</script> + +{#key store.activeConversationId} + <TodoList payload={todoData} /> +{/key} diff --git a/src/app/views/VisionPane.svelte b/src/app/views/VisionPane.svelte new file mode 100644 index 0000000..5d53545 --- /dev/null +++ b/src/app/views/VisionPane.svelte @@ -0,0 +1,45 @@ +<script lang="ts"> + /** + * The `vision` view — global vision settings (image-compaction limit + + * model) with the store adapters. Not conversation-scoped (no {#key}), so + * the settings stay mounted across tab switches (like today). + */ + import { + VisionSettingsView, + type LoadVisionSettingsResult, + type SaveVisionSettingsResult, + type VisionSettingsPatch, + } from "../../features/vision"; + import type { AppViewPorts } from "../view-ports"; + + let { ports }: { ports: AppViewPorts } = $props(); + const store = $derived(ports.store); + + // Adapt the store's global vision-settings API to the vision feature's ports. + async function loadVisionSettings(): Promise<LoadVisionSettingsResult> { + // The store seeds `visionSettings` on boot; a refresh keeps it current. + await store.refreshVisionSettings(); + const settings = store.visionSettings; + if (settings === null) { + return { ok: false, error: "Vision settings not available." }; + } + return { ok: true, settings }; + } + + async function saveVisionSettings( + patch: VisionSettingsPatch, + ): Promise<SaveVisionSettingsResult> { + const result = await store.setVisionSettings(patch); + if (result === null) return { ok: false, error: "Vision settings not available." }; + return result.ok + ? { ok: true, settings: result.settings } + : { ok: false, error: result.error }; + } +</script> + +<VisionSettingsView + models={store.models} + modelInfo={store.modelInfo} + load={loadVisionSettings} + save={saveVisionSettings} +/> 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 c8b0fa2..1196590 100644 --- a/src/core/chunks/groups.test.ts +++ b/src/core/chunks/groups.test.ts @@ -3,16 +3,16 @@ import { describe, expect, it } from "vitest"; import { groupRenderedChunks } from "./groups"; import type { RenderedChunk } from "./types"; -const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({ +const text = (seq: number, role: Role, t: string): RenderedChunk => ({ seq, role, chunk: { type: "text", text: t }, - provisional, + provisional: false, }); - const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ seq, role: "assistant", + provisional, chunk: { type: "tool-call", toolCallId: id, @@ -20,12 +20,11 @@ const call = (seq: number, id: string, stepId?: string, provisional = false): Re input: { id }, ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), }, - provisional, }); - const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ seq, role: "tool", + provisional, chunk: { type: "tool-result", toolCallId: id, @@ -34,92 +33,142 @@ const result = (seq: number, id: string, stepId?: string, provisional = false): isError: false, ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), }, - provisional, }); +function toolGroups(rows: readonly RenderedChunk[]) { + return groupRenderedChunks(rows).map((g) => { + if (g.kind !== "tool") throw new Error("expected a tool group"); + return g; + }); +} + describe("groupRenderedChunks", () => { it("returns no groups for an empty stream", () => { expect(groupRenderedChunks([])).toEqual([]); }); - - it("passes non-tool chunks through as single groups, in order", () => { - const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]); - expect(groups).toHaveLength(2); - expect(groups.every((g) => g.kind === "single")).toBe(true); - }); - - it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => { - const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]); - expect(groups).toHaveLength(2); - expect(groups.map((g) => g.kind)).toEqual(["single", "single"]); + it("passes non-tool chunks through unchanged and in order", () => { + const rows = [text(1, "user", "hi"), text(2, "assistant", "hello")]; + expect(groupRenderedChunks(rows)).toEqual(rows.map((chunk) => ({ kind: "single", chunk }))); }); - - it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => { - const groups = groupRenderedChunks([ - call(1, "a"), - call(2, "b"), - result(3, "a"), - result(4, "b"), + it("pairs a single call and result into one group", () => { + const c = call(1, "a", "s1"); + const r = result(2, "a", "s1"); + expect(toolGroups([c, r])).toEqual([ + { + kind: "tool", + entry: { toolCallId: "a", toolName: "tool-a", call: c.chunk, result: r.chunk }, + stepId: "s1", + provisional: false, + }, ]); - expect(groups).toHaveLength(4); - expect(groups.every((g) => g.kind === "single")).toBe(true); }); - - it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => { - const groups = groupRenderedChunks([ + it("renders parallel calls independently in call order, with out-of-order results", () => { + const groups = toolGroups([ call(1, "a", "s1"), call(2, "b", "s1"), - result(3, "a", "s1"), - result(4, "b", "s1"), + result(3, "b", "s1"), + result(4, "a", "s1"), ]); - expect(groups).toHaveLength(1); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(g.stepId).toBe("s1"); - expect(g.entries).toHaveLength(2); - expect(g.entries[0]?.call.toolCallId).toBe("a"); - expect(g.entries[0]?.result?.content).toBe("result-a"); - expect(g.entries[1]?.call.toolCallId).toBe("b"); - expect(g.entries[1]?.result?.content).toBe("result-b"); + expect(groups.map((g) => g.entry.toolCallId)).toEqual(["a", "b"]); + expect(groups.map((g) => g.entry.result?.content)).toEqual(["result-a", "result-b"]); }); - - it("positions the batch at the first call and keeps surrounding chunks in order", () => { + it("pairs legacy chunks without stepId and mismatching stepIds by toolCallId", () => { + const groups = toolGroups([ + call(1, "a"), + result(2, "a"), + call(3, "b", "s1"), + result(4, "b", "s2"), + ]); + expect(groups).toHaveLength(2); + expect(groups[0]?.entry.result).not.toBeNull(); + expect(groups[1]?.stepId).toBe("s1"); + }); + it("keeps surrounding text at its position and absorbs results", () => { const groups = groupRenderedChunks([ text(1, "assistant", "before"), call(2, "a", "s1"), - call(3, "b", "s1"), - result(4, "a", "s1"), - result(5, "b", "s1"), - text(6, "assistant", "after"), + text(3, "assistant", "between"), + call(4, "b", "s1"), + result(5, "a"), + result(6, "b"), + text(7, "assistant", "after"), ]); - expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]); + expect(groups.map((g) => g.kind)).toEqual(["single", "tool", "single", "tool", "single"]); }); - - it("marks the batch provisional when any of its calls/results is provisional", () => { - const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(g.provisional).toBe(true); - expect(g.entries).toHaveLength(2); - expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet) + it("keeps pending calls and orphan results", () => { + const groups = toolGroups([call(1, "pending"), result(2, "orphan", "s2", true)]); + expect(groups[0]?.entry.result).toBeNull(); + expect(groups[1]?.entry.call).toBeNull(); + expect(groups[1]?.entry.toolName).toBe("tool-orphan"); + expect(groups[1]?.provisional).toBe(true); + expect(groups[1]?.stepId).toBe("s2"); }); - - it("batches one step while leaving a different single-call step ungrouped", () => { - const groups = groupRenderedChunks([ - call(1, "a", "s1"), - call(2, "b", "s1"), - call(3, "c", "s2"), - result(4, "a", "s1"), - result(5, "b", "s1"), - result(6, "c", "s2"), + it.each([ + [true, false], + [false, true], + [false, false], + ])("combines call/result provisional flags (%s, %s)", (cp, rp) => { + expect(toolGroups([call(1, "a", "s1", cp), result(2, "a", "s1", rp)])[0]?.provisional).toBe( + cp || rp, + ); + }); + it("prefers call provenance, falling back to result provenance", () => { + const groups = toolGroups([ + { ...call(1, "a", "s1"), turnId: "t1" }, + { ...result(2, "a", "s2"), turnId: "t2" }, + call(3, "b"), + { ...result(4, "b", "s3"), turnId: "t3" }, + ]); + expect(groups.map((g) => [g.stepId, g.turnId])).toEqual([ + ["s1", "t1"], + ["s3", "t3"], + ]); + }); + it("dedupes calls/results across steps, first occurrence wins", () => { + const first = call(1, "a", "s1", true); + const firstResult = result(4, "a", "s1"); + const groups = toolGroups([ + first, + call(2, "a", "s2"), + call(3, "b"), + firstResult, + result(5, "a", "s2"), + result(6, "b"), + result(7, "b"), ]); - expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]); - const batch = groups[0]; - if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(batch.entries).toHaveLength(2); - // the s2 single call + its result remain as separate single groups - const singles = groups.slice(1); - expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call"); - expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result"); + expect(groups).toHaveLength(2); + expect(groups[0]?.entry.call).toBe(first.chunk); + expect(groups[0]?.entry.result).toBe(firstResult.chunk); + expect(groups[0]?.provisional).toBe(true); + }); + it("dedupes orphan results too", () => { + expect(toolGroups([result(1, "a"), result(2, "a")])).toHaveLength(1); + }); + it("positions a card at its call even when its result came first", () => { + const c = call(3, "a"); + const groups = groupRenderedChunks([result(1, "a"), text(2, "assistant", "before call"), c]); + 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 53a2873..6e0f8ea 100644 --- a/src/core/chunks/groups.ts +++ b/src/core/chunks/groups.ts @@ -1,95 +1,75 @@ import type { ToolCallChunk, ToolResultChunk } from "@dispatch/wire"; import type { RenderedChunk } from "./types"; -/** - * One tool call within a batch, paired with its result (matched by `toolCallId`). - * `result` is null while the call is still pending (no result chunk yet). - */ -export interface ToolBatchEntry { - readonly call: ToolCallChunk; +/** 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; readonly result: ToolResultChunk | null; } -/** - * A render group: either a single rendered chunk (rendered as today) or a batch - * of tool calls the model emitted together in one step (shared `stepId`), to be - * rendered as one grouped unit. - */ export type RenderGroup = | { readonly kind: "single"; readonly chunk: RenderedChunk } | { - readonly kind: "tool-batch"; - readonly stepId: string; - readonly entries: readonly ToolBatchEntry[]; + readonly kind: "tool"; + readonly entry: ToolEntry; readonly provisional: boolean; + readonly stepId?: string; + readonly turnId?: string; }; /** - * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId` - * (the backend's authoritative batch key) where the step has 2+ calls become one - * `tool-batch` group, positioned at the first call and pairing each call with its - * `tool-result` (by `toolCallId`); the absorbed result chunks are not emitted on - * their own. Single tool calls (one per step, or no `stepId` — e.g. pre-0.2.0 - * replay rows) and every non-tool chunk render as `single` groups, in order. - * - * Pure: input → output, no DOM, no Svelte. + * One independent group per call, at the call's position, absorbing its result + * by toolCallId (including legacy chunks without stepId). Orphan results stay + * visible at their own position. First occurrence wins for duplicate ids, so + * 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[] { - // 1. Steps that batched 2+ tool calls. - const callsPerStep = new Map<string, number>(); - for (const rc of rendered) { - if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) { - callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1); - } - } - const batchSteps = new Set<string>(); - for (const [stepId, count] of callsPerStep) { - if (count >= 2) batchSteps.add(stepId); - } - - // 2. toolCallIds belonging to a batch (so their results are absorbed), and a - // lookup of result chunks by toolCallId for pairing. - const batchCallIds = new Set<string>(); - const resultByCallId = new Map<string, ToolResultChunk>(); +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) { - const chunk = rc.chunk; - if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { - batchCallIds.add(chunk.toolCallId); - } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) { - resultByCallId.set(chunk.toolCallId, chunk); - } + const c = rc.chunk; + if (c.type === "tool-call" && !calls.has(c.toolCallId)) calls.set(c.toolCallId, rc); + if (c.type === "tool-result" && !results.has(c.toolCallId)) results.set(c.toolCallId, rc); } - // 3. Emit groups in stream order; each batch lands at its first call. const groups: RenderGroup[] = []; - const emittedSteps = new Set<string>(); + const emitted = new Set<string>(); for (const rc of rendered) { - const chunk = rc.chunk; - - if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { - const stepId = chunk.stepId; - if (emittedSteps.has(stepId)) continue; - emittedSteps.add(stepId); - - const entries: ToolBatchEntry[] = []; - let provisional = false; - for (const inner of rendered) { - if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) { - const result = resultByCallId.get(inner.chunk.toolCallId) ?? null; - entries.push({ call: inner.chunk, result }); - if (inner.provisional) provisional = true; - } - } - groups.push({ kind: "tool-batch", stepId, entries, provisional }); + const c = rc.chunk; + if (c.type !== "tool-call" && c.type !== "tool-result") { + groups.push({ kind: "single", chunk: rc }); continue; } - - if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) { - continue; // absorbed into its batch - } - - groups.push({ kind: "single", chunk: rc }); + if (emitted.has(c.toolCallId)) continue; + // A matching call owns the card's position, even if the result came first. + if (c.type === "tool-result" && calls.has(c.toolCallId)) continue; + emitted.add(c.toolCallId); + const callRow = calls.get(c.toolCallId); + const resultRow = results.get(c.toolCallId); + const call = callRow?.chunk.type === "tool-call" ? callRow.chunk : null; + const result = resultRow?.chunk.type === "tool-result" ? resultRow.chunk : null; + const stepId = call?.stepId ?? result?.stepId; + const turnId = callRow?.turnId ?? resultRow?.turnId; + groups.push({ + kind: "tool", + 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 } : {}), + }); } - return groups; } diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index bdd6ce4..03d687c 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,4 +1,4 @@ -export type { RenderGroup, ToolBatchEntry } from "./groups"; +export type { RenderGroup, ToolEntry } from "./groups"; export { groupRenderedChunks } from "./groups"; export { resolveImageUrl } from "./image-url"; export { @@ -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 058552e..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, @@ -226,7 +233,7 @@ describe("clearGenerating", () => { s = foldEvent(s, textDelta("t1", "partial")); const cleared = clearGenerating(s); expect(cleared.generating).toBe(false); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(cleared.currentTurnId).toBe("t1"); }); }); @@ -236,7 +243,7 @@ describe("foldEvent — text-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -245,7 +252,7 @@ describe("foldEvent — text-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello ")); s = foldEvent(s, textDelta("t1", "world")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello world" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello world", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -254,7 +261,7 @@ describe("foldEvent — text-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "thinking...")); s = foldEvent(s, textDelta("t1", "answer")); - expect(s.accumulating).toEqual({ kind: "text", text: "answer" }); + expect(s.accumulating).toEqual({ kind: "text", text: "answer", turnId: "t1" }); expect(s.provisional).toHaveLength(1); expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); expect(s.provisional[0]?.role).toBe("assistant"); @@ -266,7 +273,7 @@ describe("foldEvent — reasoning-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm", turnId: "t1" }); }); it("successive reasoning-deltas extend the same chunk", () => { @@ -274,7 +281,73 @@ describe("foldEvent — reasoning-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm ")); s = foldEvent(s, reasoningDelta("t1", "ok")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok", turnId: "t1" }); + }); +}); + +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(); }); }); @@ -531,7 +604,7 @@ describe("clearGenerating also clears a stale provider-retry banner (reconnect)" s = foldEvent(s, textDelta("t1", "partial")); s = foldEvent(s, providerRetry("t1", 0, 5000)); const cleared = clearGenerating(s); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(selectProviderRetry(cleared)).toBeNull(); }); }); @@ -859,7 +932,7 @@ describe("appendUserMessage", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "partial")); - expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(s.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); s = appendUserMessage(s, "user msg"); expect(s.accumulating).toBeNull(); @@ -1022,3 +1095,115 @@ describe("applyHistory — multi-chunk image echo is superseded by committed", ( expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); }); }); + +describe("turnId provenance on live chunks", () => { + it("tags every live chunk with its event's turnId, and the optimistic echo on turn-start", () => { + let s = initialState(); + s = appendUserMessage(s, "q", [{ url: "data:image/png;base64,AA", mimeType: "image/png" }]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual([undefined, undefined]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a")); + const chunks = selectChunks(s); + expect(chunks.map((c) => c.turnId)).toEqual(["t1", "t1", "t1"]); + }); + + it("turn-start does not re-tag an echo already owned by an earlier turn", () => { + let s = initialState(); + s = appendUserMessage(s, "q1"); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a1")); + s = foldEvent(s, turnStart("t2")); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t1", "t1"]); + }); + + it("committed chunks expose StoredChunk.turnId", () => { + const s = applyHistory(initialState(), [ + { seq: 1, role: "user", chunk: { type: "text", text: "q" }, turnId: "t9" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "a" } }, + ]); + 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 64e41b9..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,59 @@ 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, { role: "assistant", chunk }]; + return [ + ...provisional, + withTurn( + { role: "assistant", chunk, ...(acc.stepId !== undefined ? { stepId: acc.stepId } : {}) }, + acc.turnId, + ), + ]; +} + +/** Attach a `turnId` to a provisional chunk (omitted when unknown). */ +function withTurn(p: ProvisionalChunk, turnId: string | undefined): ProvisionalChunk { + return turnId !== undefined ? { ...p, turnId } : p; +} + +/** + * Tag the trailing UNTAGGED user run (the sender's optimistic echo, appended + * before the server assigned a turn id) with the turn that just started. + */ +function tagTrailingUserEcho( + provisional: readonly ProvisionalChunk[], + turnId: string, +): readonly ProvisionalChunk[] { + let start = provisional.length; + while (start > 0) { + const p = provisional[start - 1]; + if (p === undefined || p.role !== "user" || p.turnId !== undefined) break; + start--; + } + if (start === provisional.length) return provisional; + return [ + ...provisional.slice(0, start), + ...provisional.slice(start).map((p) => ({ ...p, turnId })), + ]; +} + +/** + * 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)); } /** @@ -103,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 @@ -113,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); @@ -123,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), + ), + } + : {}), }; } @@ -141,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"); @@ -153,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; @@ -163,7 +263,17 @@ export function applyHistory( } } - return { ...state, committed }; + return { + ...state, + committed, + ...(state.historyRecoveryTurnIds !== undefined + ? { + historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter( + (id) => !finishedTurnIds.includes(id), + ), + } + : {}), + }; } /** @@ -174,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 @@ -198,8 +310,26 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState case "tool-output": return state; - case "turn-start": - return { ...state, currentTurnId: event.turnId, generating: true }; + 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: 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 @@ -215,14 +345,37 @@ 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) { - return { ...state, generating: true }; + 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, + // 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, + }; } const provisional = flushAccumulating(state.provisional, state.accumulating); return { ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + provisional: [ + ...provisional, + { + role: "user", + chunk: { type: "text", text: event.text }, + turnId: event.turnId, + opening: true, + }, + ], accumulating: null, generating: true, }; @@ -230,10 +383,10 @@ 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: { kind: "text", text: acc.text + event.delta }, + accumulating: { ...acc, text: acc.text + event.delta }, generating: true, }; } @@ -241,17 +394,17 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState return { ...state, provisional, - accumulating: { kind: "text", text: event.delta }, + accumulating: { kind: "text", text: event.delta, turnId: event.turnId }, generating: true, }; } 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: { kind: "thinking", text: acc.text + event.delta }, + accumulating: { ...acc, text: acc.text + event.delta }, generating: true, }; } @@ -259,7 +412,31 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState return { ...state, provisional, - accumulating: { kind: "thinking", text: event.delta }, + accumulating: { kind: "thinking", text: event.delta, turnId: event.turnId }, + generating: true, + }; + } + + 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, }; } @@ -275,7 +452,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState }; return { ...state, - provisional: [...provisional, { role: "assistant", chunk }], + provisional: [...provisional, { role: "assistant", chunk, turnId: event.turnId }], accumulating: null, generating: true, }; @@ -293,7 +470,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState }; return { ...state, - provisional: [...provisional, { role: "tool", chunk }], + provisional: [...provisional, { role: "tool", chunk, turnId: event.turnId }], accumulating: null, generating: true, }; @@ -307,7 +484,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState : { type: "error", message: event.message }; return { ...state, - provisional: [...provisional, { role: "assistant", chunk }], + provisional: [...provisional, { role: "assistant", chunk, turnId: event.turnId }], accumulating: null, generating: false, }; @@ -317,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); @@ -350,9 +543,22 @@ 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 } }], + provisional: [ + ...provisional, + { + role: "user", + chunk: { type: "text", text: event.text }, + turnId: event.turnId, + ...(afterToolCallId !== undefined ? { afterToolCallId } : {}), + }, + ], accumulating: null, generating: true, }; @@ -367,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. } } @@ -385,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) { @@ -419,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[] = []; @@ -440,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 new file mode 100644 index 0000000..90bb5cc --- /dev/null +++ b/src/core/chunks/selectors.test.ts @@ -0,0 +1,373 @@ +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 { trimTranscript } from "./trim"; +import type { TranscriptState } from "./types"; + +const C = "conv"; +const T = "turn-1"; + +function fold(state: TranscriptState, events: readonly AgentEvent[]): TranscriptState { + return events.reduce(foldEvent, state); +} + +const turnStart: AgentEvent = { type: "turn-start", conversationId: C, turnId: T }; +const userMsg: AgentEvent = { type: "user-message", conversationId: C, turnId: T, text: "hi" }; +const think = (delta: string): AgentEvent => ({ + type: "reasoning-delta", + conversationId: C, + turnId: T, + delta, +}); +const thinkEnd: AgentEvent = { type: "reasoning-end", conversationId: C, turnId: T }; +const text = (delta: string): AgentEvent => ({ + type: "text-delta", + conversationId: C, + turnId: T, + delta, +}); +const call = (id: string, step = 0): AgentEvent => ({ + type: "tool-call", + conversationId: C, + turnId: T, + stepId: `${T}#${step}` as StepId, + toolCallId: id, + toolName: "run_shell", + input: {}, +}); +const result = (id: string, step = 0): AgentEvent => ({ + type: "tool-result", + conversationId: C, + turnId: T, + stepId: `${T}#${step}` as StepId, + toolCallId: id, + toolName: "run_shell", + content: "ok", + isError: false, +}); + +/** The persisted chunks for steps 0 (user + think + call/result). */ +const committedStep0: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: T }, + { seq: 2, role: "assistant", chunk: { type: "thinking", text: "plan" }, turnId: T }, + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "run_shell", + input: {}, + stepId: `${T}#0` as StepId, + }, + turnId: T, + }, + { + seq: 4, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "run_shell", + content: "ok", + isError: false, + stepId: `${T}#0` as StepId, + }, + turnId: T, + }, +]; + +describe("selectChunks — de-dup of a late-join replay against committed history", () => { + it("hides replayed provisional chunks already persisted for the in-flight turn", () => { + // Fresh load mid-turn: history has step 0; the replay re-delivers step 0 + // and then step 1 (still streaming). + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [ + userMsg, + turnStart, + think("pl"), + think("an"), + call("a"), + result("a"), + text("now"), + ]); + + 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, "text"], + ]); + expect(rendered[4]?.streaming).toBe(true); + }); + + it("keeps provisional chunks beyond the committed run", () => { + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), call("b", 1)]); + const rendered = selectChunks(s); + expect(rendered).toHaveLength(5); + expect(rendered[4]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "b" }); + expect(rendered[4]?.provisional).toBe(true); + }); + + it("hides the accumulating chunk once its content is committed", () => { + const committed: StoredChunk[] = [ + ...committedStep0, + { seq: 5, role: "assistant", chunk: { type: "text", text: "final" }, turnId: T }, + ]; + let s = applyHistory(initialState(), committed); + s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), text("final")]); + const rendered = selectChunks(s); + expect(rendered).toHaveLength(5); + expect(rendered.every((r) => !r.provisional)).toBe(true); + }); + + it("stops de-duping at the first mismatch (never hides divergent content)", () => { + let s = applyHistory(initialState(), committedStep0); + s = fold(s, [userMsg, turnStart, think("DIFFERENT"), call("a"), result("a")]); + const rendered = selectChunks(s); + // user echo matched (1 hidden); everything from the mismatch on is shown. + 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); + s = fold(s, [userMsg, turnStart, think("plan")]); + 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 e46d5f5..8d32fcf 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -1,24 +1,278 @@ -import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire"; -import type { RenderedChunk, TranscriptState } from "./types"; +import type { ChatMessage, Chunk, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; +import { historyBackedProvisional } from "./reconcile"; +import type { ProvisionalChunk, RenderedChunk, TranscriptState } from "./types"; + +/** + * Whether a provisional chunk is the same content as a committed chunk at the + * same position of the same turn (identity for de-dup, not deep equality). + * Tool chunks match by `toolCallId`; text-bearing chunks by exact text. + */ +function sameChunk(p: ProvisionalChunk, c: StoredChunk): boolean { + if (p.role !== c.role || p.chunk.type !== c.chunk.type) return false; + const a = p.chunk; + const b = c.chunk; + switch (a.type) { + case "tool-call": + case "tool-result": + return a.toolCallId === (b as typeof a).toolCallId; + case "text": + case "thinking": + case "system": + return a.text === (b as typeof a).text; + case "error": + return a.message === (b as typeof a).message; + case "image": + return true; + } +} + +/** + * 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 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). + */ +function committedOverlap(state: TranscriptState): { skip: number; hideAccumulating: boolean } { + const first = state.provisional[0]; + const turnId = first?.turnId ?? state.accumulating?.turnId; + 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--; + 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 && 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) || + 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). + * 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({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }); + result.push( + c.turnId !== undefined + ? { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false, turnId: c.turnId } + : { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }, + ); } - for (const p of state.provisional) { - result.push({ seq: null, role: p.role, chunk: p.chunk, provisional: true }); + for (let i = skip; i < state.provisional.length; i++) { + const p = state.provisional[i]; + 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 } + : { seq: null, role: p.role, chunk: p.chunk, provisional: true }, + ); } - if (state.accumulating !== null) { + const acc = state.accumulating; + if (acc !== null && !hideAccumulating) { const chunk: Chunk = - state.accumulating.kind === "text" - ? { type: "text", text: state.accumulating.text } - : { type: "thinking", text: state.accumulating.text }; - result.push({ seq: null, role: "assistant", chunk, provisional: true, streaming: true }); + acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; + result.push({ + seq: null, + role: "assistant", + chunk, + provisional: true, + streaming: true, + ...(acc.turnId !== undefined ? { turnId: acc.turnId } : {}), + }); } return result; } 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 2ced736..2e2ee0c 100644 --- a/src/core/chunks/types.ts +++ b/src/core/chunks/types.ts @@ -2,18 +2,36 @@ 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`). */ + readonly turnId?: string; } /** 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; + /** + * The turn this chunk belongs to (from the live event's `turnId`). Absent + * only on the sender's optimistic user echo until its `turn-start` arrives + * (which tags it). + */ + readonly turnId?: string; } /** 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; @@ -74,4 +92,11 @@ export interface RenderedChunk { * dots on streaming thinking) and drop it the moment generation moves on. */ readonly streaming?: boolean; + /** + * The turn that produced this chunk — `StoredChunk.turnId` for committed + * chunks, the live event's `turnId` for provisional ones. The exact join key + * for placing per-turn metrics. Absent on legacy rows (persisted before the + * backend stamped it) and on the optimistic echo before `turn-start`. + */ + readonly turnId?: string; } 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/format.test.ts b/src/core/metrics/format.test.ts index 97170d0..3e8ebdd 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -46,7 +46,7 @@ describe("viewStepMetrics", () => { }; const view = viewStepMetrics(step, 0); expect(view.label).toBe("step 1"); - expect(view.tokensLabel).toBe("1,801 tok"); + expect(view.tokensLabel).toBe("1,234 in / 567 out"); expect(view.tps).toBe("473 tok/s"); expect(view.ttft).toBe("820ms"); expect(view.decode).toBe("1.2s"); @@ -105,15 +105,14 @@ describe("viewStepMetrics", () => { expect(view.tps).toBe("100 tok/s"); }); - it("tps falls back to genTotalMs when decodeMs absent", () => { + it("tps is null when decodeMs is absent (never falls back to genTotalMs, which includes TTFT)", () => { const step: StepMetrics = { stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 }, genTotalMs: 800, }; const view = viewStepMetrics(step, 0); - // 50 / (800/1000) = 62.5 → rounds to 63 - expect(view.tps).toBe("63 tok/s"); + expect(view.tps).toBeNull(); }); }); @@ -133,8 +132,8 @@ describe("viewTurnMetrics", () => { ], }; const view = viewTurnMetrics(turn); - expect(view.tokensLabel).toBe("1,234 tok"); - expect(view.breakdown).toBe("1,000 in / 234 out"); + expect(view.tokensLabel).toBe("234 tok out"); + expect(view.breakdown).toBe("1,000 in"); expect(view.tps).toBe("78 tok/s"); expect(view.duration).toBe("5.0s"); }); @@ -146,7 +145,7 @@ describe("viewTurnMetrics", () => { steps: [], }; const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache"); + expect(view.breakdown).toBe("1,000 in (500 cached)"); }); it("breakdown omits cache when not present", () => { @@ -156,7 +155,7 @@ describe("viewTurnMetrics", () => { steps: [], }; const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("100 in / 50 out"); + expect(view.breakdown).toBe("100 in"); }); it("tps is null when no step has decodeMs or genTotalMs", () => { @@ -184,7 +183,7 @@ describe("viewTurnMetrics", () => { expect(view.duration).toBeNull(); }); - it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => { + it("turn tps counts only steps that report decodeMs (both tokens and time)", () => { const turn: TurnMetrics = { turnId: "t1", usage: { inputTokens: 300, outputTokens: 150 }, @@ -203,9 +202,9 @@ describe("viewTurnMetrics", () => { ], }; const view = viewTurnMetrics(turn); - // step1 uses decodeMs=800, step2 falls back to genTotalMs=2000 → total=2800ms - // 150 / (2800/1000) = 53.57 → rounds to 54 - expect(view.tps).toBe("54 tok/s"); + // only step1 is timed: 50 / (800/1000) = 62.5 → 63. step2 (no decodeMs) + // contributes neither tokens nor time — never its TTFT-inflated genTotalMs. + expect(view.tps).toBe("63 tok/s"); }); }); @@ -260,40 +259,47 @@ describe("computeExpectedCachePct", () => { expect(computeExpectedCachePct({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); }); - it("null when the prior turn cached nothing (denominator 0)", () => { - const prev = { inputTokens: 100, outputTokens: 0 }; - const current = { inputTokens: 200, outputTokens: 0, cacheReadTokens: 50 }; - expect(computeExpectedCachePct(current, prev)).toBeNull(); + it("null when this turn has no first step", () => { + expect(computeExpectedCachePct(undefined, { inputTokens: 100, outputTokens: 0 })).toBeNull(); }); - it("100% when the whole prior cached prefix was read back (backend worked example)", () => { - // turn 1: cacheRead 0, cacheWrite 5146 → prefix 5146; turn 2 reads 5146 back. - const prev = { inputTokens: 5149, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5146 }; - const current = { - inputTokens: 8462, - outputTokens: 0, - cacheReadTokens: 5146, - cacheWriteTokens: 3313, - }; - expect(computeExpectedCachePct(current, prev)).toBe(100); + it("null when the prior prompt was empty (denominator 0)", () => { + const prev = { inputTokens: 0, outputTokens: 0 }; + expect(computeExpectedCachePct({ inputTokens: 10, outputTokens: 0 }, prev)).toBeNull(); }); - it("drops below 100% when the cache busted (read < prior prefix)", () => { - const prev = { - inputTokens: 1000, - outputTokens: 0, - cacheReadTokens: 100, - cacheWriteTokens: 900, - }; - const current = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 500 }; - // 500 / (100 + 900) = 50% - expect(computeExpectedCachePct(current, prev)).toBe(50); + it("100% when the first step reads back the whole prior final prompt", () => { + // prev turn's last step sent 5149 tokens; this turn's first step (a + // superset prompt) reads 5149 of them from cache. + const prevLast = { inputTokens: 5149, outputTokens: 40, cacheReadTokens: 3000 }; + const first = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5149 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(100); + }); + + it("drops below 100% when the cache busted", () => { + const prevLast = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 900 }; + const first = { inputTokens: 1200, outputTokens: 0, cacheReadTokens: 500 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(50); + }); + + it("0% (not null) on a full miss", () => { + const prevLast = { inputTokens: 1000, outputTokens: 0 }; + const first = { inputTokens: 1200, outputTokens: 0 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(0); + }); + + it("is NOT inflated by multi-step turns (step-level, not turn sums)", () => { + // A 10-step turn reads the same prefix 10x; the old turn-sum ratio read + // ~10x the baseline and pinned at 100%. Step-level reflects the bust. + const prevLast = { inputTokens: 50_000, outputTokens: 0, cacheReadTokens: 49_000 }; + const first = { inputTokens: 51_000, outputTokens: 0, cacheReadTokens: 10_000 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(20); }); - it("clamps to 100 if read somehow exceeds the prior prefix", () => { - const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 }; - const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 }; - expect(computeExpectedCachePct(current, prev)).toBe(100); + it("clamps to 100", () => { + const prevLast = { inputTokens: 100, outputTokens: 0 }; + const first = { inputTokens: 300, outputTokens: 0, cacheReadTokens: 250 }; + expect(computeExpectedCachePct(first, prevLast)).toBe(100); }); }); @@ -303,9 +309,9 @@ describe("viewExpectedCache", () => { }); it("success level + hit flag for full retention", () => { - const prev = { inputTokens: 5149, outputTokens: 0, cacheWriteTokens: 5146 }; - const current = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; - const v = viewExpectedCache(current, prev); + const prevLast = { inputTokens: 5146, outputTokens: 0 }; + const first = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; + const v = viewExpectedCache(first, prevLast); expect(v?.pct).toBe(100); expect(v?.level).toBe("success"); expect(v?.isHit).toBe(true); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 894bd54..5a13a9a 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -77,26 +77,31 @@ export function computeTps(outputTokens: number, elapsedMs: number | undefined): return outputTokens / (elapsedMs / 1000); } -function totalTokens(u: Usage): number { - return u.inputTokens + u.outputTokens; -} - function formatBreakdown(u: Usage): string { - let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`; + let s = `${formatTokens(u.inputTokens)} in`; if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) { - s += ` / ${formatTokens(u.cacheReadTokens)} cache`; + s += ` (${formatTokens(u.cacheReadTokens)} cached)`; } return s; } +/** + * A step's tokens-per-second: `outputTokens / decodeMs`. Decode time is the + * model's generation window (first output token → stream end) with tool + * execution excluded by the backend. `genTotalMs` is deliberately NOT a + * fallback: it includes time-to-first-token (prompt processing / queueing), + * which would understate the rate by multiples. No decode time → no TPS. + */ +function stepTps(step: StepMetrics): number | null { + return computeTps(step.usage.outputTokens, step.decodeMs); +} + /** Build a formatted view of a single step's metrics. */ export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsView { - const total = totalTokens(step.usage); - const tps = computeTps(step.usage.outputTokens, step.decodeMs ?? step.genTotalMs); return { label: `step ${index + 1}`, - tokensLabel: `${formatTokens(total)} tok`, - tps: formatTps(tps), + tokensLabel: `${formatTokens(step.usage.inputTokens)} in / ${formatTokens(step.usage.outputTokens)} out`, + tps: formatTps(stepTps(step)), ttft: formatDuration(step.ttftMs), decode: formatDuration(step.decodeMs), genTotal: formatDuration(step.genTotalMs), @@ -130,19 +135,25 @@ export function viewCacheRate(u: Usage): CacheRateView { } /** - * Expected cache (retention): of the cache that existed going INTO this turn, how - * much was read back — `clamp01(cacheRead_N / (cacheRead_{N-1} + cacheWrite_{N-1}))`. - * The denominator is the PRIOR turn's cached prefix (what it read + what it wrote). - * Ideally ~100% on every turn after the first; <100% = the cache busted/expired. + * Expected cache (retention): of the prompt the PREVIOUS turn last sent (its + * final step's input), how much this turn's FIRST step read back from cache — + * `clamp01(firstStep.cacheRead / prevLastStep.input)`. Everything in that + * prompt is a prefix of this turn's first prompt, so ~100% means the cache + * survived between turns; lower means it busted/expired. Step-level on both + * sides: comparing whole-turn sums is meaningless (a multi-step turn reads + * the same prefix once per step). * - * Returns `null` when it cannot be derived: no prior turn (`prev === null`) or the - * prior turn cached nothing (denominator <= 0) — distinct from a real 0%. + * Returns `null` when it cannot be derived: no earlier turn, no first step, + * or the previous prompt was empty — distinct from a real 0%. */ -export function computeExpectedCachePct(current: Usage, prev: Usage | null): number | null { - if (prev === null) return null; - const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0); +export function computeExpectedCachePct( + firstStep: Usage | undefined, + prevLastStep: Usage | null, +): number | null { + if (prevLastStep === null || firstStep === undefined) return null; + const denom = prevLastStep.inputTokens; if (denom <= 0) return null; - const read = current.cacheReadTokens ?? 0; + const read = firstStep.cacheReadTokens ?? 0; const rate = read / denom; const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; return Math.round(clamped * 100); @@ -152,28 +163,36 @@ export function computeExpectedCachePct(current: Usage, prev: Usage | null): num * Build a view of the cross-turn retention (percentage + colour level + hit flag), * or `null` when it can't be derived (see `computeExpectedCachePct`). */ -export function viewExpectedCache(current: Usage, prev: Usage | null): CacheRateView | null { - const pct = computeExpectedCachePct(current, prev); +export function viewExpectedCache( + firstStep: Usage | undefined, + prevLastStep: Usage | null, +): CacheRateView | null { + const pct = computeExpectedCachePct(firstStep, prevLastStep); if (pct === null) return null; - return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; + return { pct, level: cacheLevel(pct), isHit: (firstStep?.cacheReadTokens ?? 0) > 0 }; } -/** Build a formatted view of a turn's aggregate metrics. */ +/** + * Build a formatted view of a turn's aggregate metrics. + * + * Turn TPS = output tokens ÷ decode time, summed over ONLY the steps that + * report a decode time (both numerator and denominator), so a step without + * timing can neither inflate nor deflate it. Tool execution between steps is + * never part of any step's decode time. + */ export function viewTurnMetrics(turn: TurnMetrics, turnNumber?: number): TurnMetricsView { - const total = totalTokens(turn.usage); - let totalGenMs: number | undefined; + let timedOut = 0; + let decodeMs: number | undefined; for (const step of turn.steps) { - const stepMs = step.decodeMs ?? step.genTotalMs; - if (stepMs !== undefined) { - totalGenMs = (totalGenMs ?? 0) + stepMs; - } + if (step.decodeMs === undefined || step.decodeMs <= 0) continue; + timedOut += step.usage.outputTokens; + decodeMs = (decodeMs ?? 0) + step.decodeMs; } - const tps = computeTps(turn.usage.outputTokens, totalGenMs); return { label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn", - tokensLabel: `${formatTokens(total)} tok`, + tokensLabel: `${formatTokens(turn.usage.outputTokens)} tok out`, breakdown: formatBreakdown(turn.usage), - tps: formatTps(tps), + tps: formatTps(computeTps(timedOut, decodeMs)), duration: formatDuration(turn.durationMs), }; } 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/metrics/place.test.ts b/src/core/metrics/place.test.ts index 9c925a3..7695e21 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -28,31 +28,36 @@ function assistantGroup(seq: number, text: string): RenderGroup { }; } -function toolCallGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { +function toolCallGroup(stepId: string, toolCallId: string): RenderGroup { return { - kind: "single", - chunk: { - seq, - role: "assistant", - chunk: { + kind: "tool", + stepId, + provisional: false, + entry: { + toolCallId, + toolName: "test", + result: null, + call: { type: "tool-call", toolCallId, toolName: "test", input: {}, stepId: stepId as StepId, }, - provisional: false, }, }; } -function toolResultGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { +function toolResultGroup(stepId: string, toolCallId: string): RenderGroup { return { - kind: "single", - chunk: { - seq, - role: "tool", - chunk: { + kind: "tool", + stepId, + provisional: false, + entry: { + toolCallId, + toolName: "test", + call: null, + result: { type: "tool-result", toolCallId, toolName: "test", @@ -60,29 +65,10 @@ function toolResultGroup(seq: number, stepId: string, toolCallId: string): Rende isError: false, stepId: stepId as StepId, }, - provisional: false, }, }; } -function toolBatchGroup(stepId: string, toolCallIds: string[]): RenderGroup { - return { - kind: "tool-batch", - stepId, - entries: toolCallIds.map((id) => ({ - call: { - type: "tool-call" as const, - toolCallId: id, - toolName: "test", - input: {}, - stepId: stepId as StepId, - }, - result: null, - })), - provisional: false, - }; -} - function makeStep(stepId: string, inputTokens: number, outputTokens: number): StepMetrics { return { stepId: stepId as StepId, @@ -169,9 +155,9 @@ describe("interleaveTurnMetrics", () => { it("head-aligned: segment i gets entries[i]", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const g3 = userGroup(3, "q2"); - const g4 = toolCallGroup(4, "s2", "c2"); + const g4 = toolCallGroup("s2", "c2"); const step1 = makeStep("s1", 100, 50); const step2 = makeStep("s2", 200, 80); const rows = interleaveTurnMetrics( @@ -192,7 +178,7 @@ describe("interleaveTurnMetrics", () => { it("a trailing segment with no entry (in-flight turn) renders no metrics", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const g3 = userGroup(3, "q2"); const g4 = assistantGroup(4, "a2"); const step = makeStep("s1", 100, 50); @@ -220,26 +206,28 @@ describe("interleaveTurnMetrics", () => { expectTurnMetricsAt(rows, 2, "t1"); }); - it("tool step anchors inline after its tool-batch group", () => { + it("tool step anchors inline after its LAST independent tool card", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1", "c2"]); + const g2 = toolCallGroup("t#0", "c1"); + const parallel = toolCallGroup("t#0", "c2"); const g3 = assistantGroup(3, "a1"); const step0 = makeStep("t#0", 100, 50); const step1 = makeStep("t#1", 200, 80); const turn = makeEntry("t1", 300, 130, [step0, step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + const rows = interleaveTurnMetrics([g1, g2, parallel, g3], [turn]); - expect(rows).toHaveLength(5); + expect(rows).toHaveLength(6); expectGroupAt(rows, 0, g1); expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "t#0", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); + expectGroupAt(rows, 2, parallel); + expectStepMetricsAt(rows, 3, "t#0", 0); + expectGroupAt(rows, 4, g3); + expectTurnMetricsAt(rows, 5, "t1"); }); it("single tool-call group anchors its step", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const g3 = assistantGroup(3, "a1"); const step = makeStep("s1", 100, 50); const turn = makeEntry("t1", 100, 50, [step]); @@ -255,7 +243,7 @@ describe("interleaveTurnMetrics", () => { it("single tool-result group anchors its step", () => { const g1 = userGroup(1, "q1"); - const g2 = toolResultGroup(2, "s1", "c1"); + const g2 = toolResultGroup("s1", "c1"); const g3 = assistantGroup(3, "a1"); const step = makeStep("s1", 100, 50); const turn = makeEntry("t1", 100, 50, [step]); @@ -271,9 +259,9 @@ describe("interleaveTurnMetrics", () => { it("multi-step: each tool step inline, unanchored text step skipped", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1"]); + const g2 = toolCallGroup("t#0", "c1"); const g3 = assistantGroup(2, "thinking"); - const g4 = toolBatchGroup("t#1", ["c2", "c3"]); + const g4 = toolCallGroup("t#1", "c2"); const g5 = assistantGroup(3, "a1"); const step0 = makeStep("t#0", 100, 50); const step1 = makeStep("t#1", 200, 80); @@ -294,10 +282,10 @@ describe("interleaveTurnMetrics", () => { it("multiple turns head-aligned with inline steps", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); + const g2 = toolCallGroup("s1", "c1"); const g3 = assistantGroup(2, "a1"); const g4 = userGroup(3, "q2"); - const g5 = toolCallGroup(4, "s2", "c2"); + const g5 = toolCallGroup("s2", "c2"); const step1 = makeStep("s1", 100, 50); const step2 = makeStep("s2", 200, 80); const rows = interleaveTurnMetrics( @@ -332,7 +320,7 @@ describe("interleaveTurnMetrics", () => { it("fewer metrics than segments: trailing segments are bare", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const g3 = userGroup(3, "q2"); const g4 = assistantGroup(4, "a2"); const g5 = userGroup(5, "q3"); @@ -361,7 +349,7 @@ describe("interleaveTurnMetrics", () => { // stepId groups) — the failure case. The text-only turn MUST get its OWN // entry (t3), NOT the trimmed t1's stale metrics. const g3 = userGroup(3, "q2"); - const g4 = toolBatchGroup("s2", ["c2"]); + const g4 = toolCallGroup("s2", "c2"); const g5 = assistantGroup(4, "tool-reply"); const g6 = userGroup(5, "q3"); const g7 = assistantGroup(6, "text-reply"); @@ -401,7 +389,12 @@ describe("interleaveTurnMetrics", () => { total: { turnId: "t1", usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 }, - steps: [], + steps: [ + { + stepId: "t1#0" as StepId, + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 }, + }, + ], }, }, { @@ -424,14 +417,14 @@ describe("interleaveTurnMetrics", () => { // Cumulative includes BOTH turns (t1 + t2): input 3000, cacheRead 2100. expect(tmRows[0]?.cumulativeUsage.inputTokens).toBe(3000); expect(tmRows[0]?.cumulativeUsage.cacheReadTokens).toBe(2100); - // Retention baseline is the prior finalized turn (t1, even though trimmed). - expect(tmRows[0]?.prevTurnUsage?.inputTokens).toBe(1000); - expect(tmRows[0]?.prevTurnUsage?.cacheReadTokens).toBe(500); + // Retention baseline is the prior finalized turn's LAST step (t1, even though trimmed). + expect(tmRows[0]?.prevLastStepUsage?.inputTokens).toBe(1000); + expect(tmRows[0]?.prevLastStepUsage?.cacheReadTokens).toBe(500); }); it("in-flight turn (no durationMs) still produces turn row", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const step = makeStep("s1", 100, 50); const turn: TurnMetricsEntry = { turnId: "t1", @@ -454,7 +447,7 @@ describe("interleaveTurnMetrics", () => { it("leading non-turn groups emit as plain group rows", () => { const g0 = assistantGroup(1, "system msg"); const g1 = userGroup(2, "q1"); - const g2 = toolCallGroup(3, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const step = makeStep("s1", 100, 50); const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]); @@ -468,7 +461,7 @@ describe("interleaveTurnMetrics", () => { it("trimmed turn (more metrics than segments) does NOT emit a standalone row at the top", () => { const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); + const g2 = toolCallGroup("s1", "c1"); const step1 = makeStep("s1", 100, 50); const step2 = makeStep("s2", 200, 80); const rows = interleaveTurnMetrics( @@ -504,7 +497,7 @@ describe("interleaveTurnMetrics", () => { it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); + const g2 = toolCallGroup("s1", "c1"); const g3 = assistantGroup(2, "a1"); const step1 = makeStep("s1", 100, 50); const entry = makeProgressiveEntry("t1", [step1]); @@ -519,7 +512,7 @@ describe("interleaveTurnMetrics", () => { it("entry with total emits step rows + a turn-metrics row", () => { const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); + const g2 = toolCallGroup("s1", "c1"); const g3 = assistantGroup(2, "a1"); const step1 = makeStep("s1", 100, 50); const entry = makeEntry("t1", 100, 50, [step1]); @@ -606,16 +599,36 @@ describe("interleaveTurnMetrics — cumulative usage (cache total)", () => { expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); }); - it("carries the prior finalized turn's usage as the retention baseline", () => { + it("carries the prior finalized turn's LAST STEP usage as the retention baseline", () => { + const steps = (id: string): StepMetrics[] => [ + { stepId: `${id}#0` as StepId, usage: { inputTokens: 1000, outputTokens: 5 } }, + { + stepId: `${id}#1` as StepId, + usage: { inputTokens: 1669, outputTokens: 5, cacheReadTokens: 384 }, + }, + ]; + const t1: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 }, + steps: steps("t1"), + }; + const t2: TurnMetrics = { + turnId: "t2", + usage: { inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 }, + steps: steps("t2"), + }; const rows = interleaveTurnMetrics( [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + [ + { turnId: "t1", steps: t1.steps, total: t1 }, + { turnId: "t2", steps: t2.steps, total: t2 }, + ], ); const tm = turnMetricsRows(rows); // first finalized turn has no earlier baseline - expect(tm[0]?.prevTurnUsage).toBeNull(); - // second turn's baseline is the first turn's usage - expect(tm[1]?.prevTurnUsage?.inputTokens).toBe(2669); - expect(tm[1]?.prevTurnUsage?.cacheReadTokens).toBe(384); + expect(tm[0]?.prevLastStepUsage).toBeNull(); + // second turn's baseline is the first turn's LAST step (not the turn sum) + expect(tm[1]?.prevLastStepUsage?.inputTokens).toBe(1669); + expect(tm[1]?.prevLastStepUsage?.cacheReadTokens).toBe(384); }); }); diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index 7122b09..d4c343a 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -1,11 +1,17 @@ import type { Usage } from "@dispatch/wire"; import type { RenderGroup } from "../chunks"; -import type { MetricsRow, TurnMetricsEntry } from "./types"; +import type { MetricsRow, StepMetrics, TurnMetricsEntry } from "./types"; function groupStepId(g: RenderGroup): string | undefined { - if (g.kind === "tool-batch") return g.stepId; - const c = g.chunk.chunk; - return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined; + return g.kind === "tool" ? g.stepId : undefined; +} + +function groupTurnId(g: RenderGroup): string | undefined { + return g.kind === "tool" ? g.turnId : g.chunk.turnId; +} + +function isUser(g: RenderGroup): boolean { + return g.kind === "single" && g.chunk.role === "user"; } /** Element-wise sum of two token usages (cache fields included only when nonzero). */ @@ -21,277 +27,255 @@ function addUsage(a: Usage, b: Usage): Usage { return out; } +/** A run of consecutive groups that belong to one (possibly unknown) turn. */ +interface Segment { + readonly start: number; + readonly end: number; // exclusive + /** Turn resolved from the segment's own groups (turnId / stepId), if any. */ + readonly known: string | undefined; + /** Whether the segment holds model output (anything but user / error chunks). */ + readonly hasResponse: boolean; +} + /** - * Interleave turn metrics into the rendered transcript. + * Assign every group to the turn that produced it. EXACT whenever the data + * carries provenance — which is always for anything produced since the backend + * began stamping `StoredChunk.turnId` (and always for live chunks, which carry + * the live event's `turnId`): * - * Splits groups into per-turn segments: a new segment begins at each `single` - * group with `group.chunk.role === "user"`. Segments are matched to entries - * by `stepId` presence when possible (robust against chat-limit trimming: when - * a turn's user message is trimmed, positional alignment would be off, but - * stepId matching still finds the right entry). Segments with no stepId-bearing - * groups (text-only turns) fall back to POSITIONAL tail-alignment: since the - * loaded transcript is always a SUFFIX of the full turn history (the chat limit - * keeps the newest and unloads the oldest), segment `seg` ↔ entry `K - T + seg`. + * 1. A group's own `turnId` (committed `StoredChunk.turnId` / live event). + * 2. Else the turn whose metrics contain the group's `stepId`. * - * Within a segment that has a matched entry, each completed step's metrics - * are placed INLINE right after the last group bearing that step's `stepId`. - * Steps whose `stepId` does not appear in any group ("unanchored"): - * - If the segment HAS stepId-bearing groups (tool chunks exist but this step's - * were trimmed): SKIPPED (no blank "step N · 0 tok" bubbles). - * - If the segment has NO stepId-bearing groups (text-only turn): placed at the - * segment tail before the turn-metrics row (the original behavior). - * - * A `turn-metrics` row is emitted ONLY when `entry.total !== null` (i.e. the turn - * is finalized via `done` or durable data). A still-generating turn emits no - * turn-total row. - * - * Fully trimmed turns (entries whose content was unloaded by the chat limit and - * which match no segment) are NOT rendered as standalone rows — that previously - * piled a wall of stale cache badges at the top of a long, trimmed transcript. - * Their usage still counts toward the per-turn "chat total" cumulative (computed - * across ALL finalized turns in entry-array order), so the running cache rate - * stays correct regardless of which turns were trimmed; paging earlier history - * back in ("Show earlier messages") re-matches them and re-renders their rows. + * Only LEGACY groups (persisted before turn provenance existed, with no + * stepId) are left; they are resolved per segment. A segment boundary is a + * user message start (a user chunk after a non-user one, or any user TEXT + * chunk — a message carries at most one, first; image chunks continue it) or a + * change of known turn. A segment with a known turn assigns it to its legacy + * groups. Runs of still-unknown (text-only legacy) segments are matched to the + * UNCLAIMED metrics entries that lie strictly between their resolved + * neighbours' entries — order-preserving, and only when the count is + * unambiguous (leading runs tail-align, since chat-limit trimming removes the + * oldest turns). An ambiguous run gets NO metrics rather than wrong ones. */ -export function interleaveTurnMetrics( +function assignTurns( groups: readonly RenderGroup[], entries: readonly TurnMetricsEntry[], -): readonly MetricsRow[] { - if (entries.length === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); - } +): (string | undefined)[] { + const turnOfStep = new Map<string, string>(); + const entryIndex = new Map<string, number>(); + entries.forEach((e, i) => { + entryIndex.set(e.turnId, i); + for (const s of e.steps) turnOfStep.set(s.stepId, e.turnId); + }); + + const direct: (string | undefined)[] = groups.map((g) => { + const t = groupTurnId(g); + if (t !== undefined) return t; + const sid = groupStepId(g); + return sid !== undefined ? turnOfStep.get(sid) : undefined; + }); - const segmentStarts: number[] = []; + // Fast path: everything resolved directly (the normal case). + if (direct.every((t) => t !== undefined)) return direct; + + // Segment the transcript (legacy resolution). + const segments: Segment[] = []; + let segStart = 0; + let segKnown: string | undefined; + let segHasResponse = false; + const close = (end: number) => { + if (end > segStart) { + segments.push({ start: segStart, end, known: segKnown, hasResponse: segHasResponse }); + } + }; for (let i = 0; i < groups.length; i++) { const g = groups[i]; - if (g !== undefined && g.kind === "single" && g.chunk.role === "user") { - segmentStarts.push(i); + if (g === undefined) continue; + const prev = i > 0 ? groups[i - 1] : undefined; + const t = direct[i]; + const userStart = + isUser(g) && + (prev === undefined || + !isUser(prev) || + (g.kind === "single" && g.chunk.chunk.type === "text")); + const turnChange = t !== undefined && segKnown !== undefined && t !== segKnown; + if (i > segStart && (userStart || turnChange)) { + close(i); + segStart = i; + segKnown = undefined; + segHasResponse = false; + } + if (t !== undefined && segKnown === undefined) segKnown = t; + if (!isUser(g) && !(g.kind === "single" && g.chunk.chunk.type === "error")) { + segHasResponse = true; } } + close(groups.length); - let T = segmentStarts.length; - - // No user messages — e.g. a compacted conversation whose history starts - // with a system summary. Treat the entire transcript as one segment so - // turn/step metrics can still be placed. - if (T === 0 && entries.length > 0) { - segmentStarts.push(0); - T = 1; - } - - if (T === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); + const segTurn: (string | undefined)[] = segments.map((s) => s.known); + const claimed = new Set<number>(); + for (const t of segTurn) { + const idx = t !== undefined ? entryIndex.get(t) : undefined; + if (idx !== undefined) claimed.add(idx); } + // Entry index of a resolved segment; a known turn with no entry yet (an + // in-flight turn whose first step hasn't completed) sits after every entry. + const segEntryIdx = (k: number): number => { + const t = segTurn[k]; + if (t === undefined) return -1; + return entryIndex.get(t) ?? entries.length; + }; - const K = entries.length; - - // Build stepId → entry-index lookup for matching. - const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId))); - - // Match segments to entries. Pass 1: match by stepId overlap (handles - // trimming where positional alignment alone could be ambiguous). Pass 2: - // positional tail-alignment fallback for unmatched segments (text-only turns - // with no stepId-bearing groups). - const usedEntries = new Set<number>(); - const segmentEntry = new Map<number, TurnMetricsEntry>(); - const segmentEntryIndex = new Map<number, number>(); - - // Pass 1: stepId matching. - for (let seg = 0; seg < T; seg++) { - const start = segmentStarts[seg] ?? 0; - const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; - - const segStepIds = new Set<string>(); - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g === undefined) continue; - const sid = groupStepId(g); - if (sid !== undefined) segStepIds.add(sid); + let k = 0; + while (k < segments.length) { + if (segTurn[k] !== undefined) { + k++; + continue; } - if (segStepIds.size === 0) continue; // text-only — defer to pass 2 + let runEnd = k; + while (runEnd < segments.length && segTurn[runEnd] === undefined) runEnd++; + const leading = k === 0; + const lower = leading ? -1 : segEntryIdx(k - 1); + const upper = runEnd < segments.length ? segEntryIdx(runEnd) : entries.length; + const candidates: number[] = []; + for (let e = lower + 1; e < upper; e++) if (!claimed.has(e)) candidates.push(e); - let bestEntry = -1; - let bestMatch = 0; - for (let i = 0; i < K; i++) { - if (usedEntries.has(i)) continue; - let match = 0; - for (const sid of segStepIds) { - if (entryStepIds[i]?.has(sid)) match++; - } - if (match > bestMatch) { - bestMatch = match; - bestEntry = i; - } + let run: number[] = []; + for (let s = k; s < runEnd; s++) run.push(s); + if (run.length !== candidates.length) { + // A segment with no model output (only the user message, or an error) is + // a turn that failed before producing anything — or one just sent, whose + // turn hasn't started yet — and legitimately has no metrics. + const responsive = run.filter((s) => segments[s]?.hasResponse === true); + if (responsive.length <= candidates.length) run = responsive; } - if (bestEntry >= 0) { - usedEntries.add(bestEntry); - const e = entries[bestEntry]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, bestEntry); - } + let pick: number[] | null = null; + if (run.length === candidates.length) pick = candidates; + else if (leading && run.length < candidates.length) pick = candidates.slice(-run.length); + if (pick !== null) { + run.forEach((s, i) => { + const e = pick?.[i]; + const entry = e !== undefined ? entries[e] : undefined; + if (entry !== undefined && e !== undefined) { + segTurn[s] = entry.turnId; + claimed.add(e); + } + }); } + k = runEnd; } - // Pass 2: positional fallback for segments pass 1 left unmatched - // (text-only turns with no stepId-bearing groups to anchor on). - // - // The loaded transcript is always a SUFFIX of the full turn history — - // chat-limit/windowing keeps the NEWEST chunks and unloads the OLDEST — so - // the T loaded segments correspond to the LAST T entries. TAIL-ALIGNMENT - // (segment `seg` ↔ entry `K - T + seg`) is therefore correct whenever the - // metrics hold at least as many turns as there are loaded segments - // (`K >= T`): the leading `K - T` entries are TRIMMED turns (their content - // was unloaded) and must be skipped, never matched to a newer segment. - // - // This MUST run even when pass 1 matched SOME segments (tool turns). The - // earlier code only tail-aligned when pass 1 matched NONE, falling back to - // HEAD-alignment otherwise — which, with leading trimmed entries, matched a - // brand-new text-only turn to an old (trimmed) entry's STALE metrics (the - // "new steps show no / wrong cache" failure). Tail-aligning by position is - // safe alongside pass 1: stepIds are unique per turn, so pass 1 already - // grabbed each tool turn's positionally-correct entry, leaving the right - // entry free for each text-only turn. - // - // Only when `K < T` (fewer entries than segments — some loaded turns have no - // metrics yet, e.g. a metrics sync still pending or a freshly loaded - // transcript) do we head-align, assigning the first K entries to the first K - // unmatched segments (the turns that DO have metrics sit at the front). - if (K >= T) { - // Tail-align: skip the first K-T entries (trimmed turns). - for (let seg = 0; seg < T; seg++) { - if (segmentEntry.has(seg)) continue; - const entryIdx = K - T + seg; - if (entryIdx >= 0 && entryIdx < K && !usedEntries.has(entryIdx)) { - usedEntries.add(entryIdx); - const e = entries[entryIdx]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, entryIdx); - } - } - } - } else { - // Head-align fallback (K < T): first K entries to first K unmatched segments. - let nextUnused = 0; - for (let seg = 0; seg < T; seg++) { - if (segmentEntry.has(seg)) continue; - while (nextUnused < K && usedEntries.has(nextUnused)) nextUnused++; - if (nextUnused < K) { - usedEntries.add(nextUnused); - const e = entries[nextUnused]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, nextUnused); - } - nextUnused++; - } - } + const out = [...direct]; + segments.forEach((s, si) => { + const t = segTurn[si]; + if (t === undefined) return; + for (let i = s.start; i < s.end; i++) if (out[i] === undefined) out[i] = t; + }); + return out; +} + +/** + * Interleave per-turn / per-step metrics into the rendered transcript. + * + * Every group is attributed to the turn that produced it (see `assignTurns` — + * exact via `turnId` provenance; a guarded order-preserving fallback only for + * legacy rows). Then: + * - each completed step's metrics row goes right after the LAST group bearing + * that step's `stepId` (steps with no visible content — text-only steps, or + * content unloaded by the chat limit — get no row); + * - a finalized turn's (`entry.total !== null`) metrics row goes right after + * the LAST group of that turn — so a turn whose user message was unloaded + * still shows its total under its visible tail, and a mid-turn steering + * message never pulls the total up. + * + * Because attribution never depends on counting user messages, sending a new + * message, a new turn streaming, steering, image attachments, errored turns, or + * chat-limit unloading can never shift one turn's metrics onto another. + * + * Turns whose content is fully unloaded are not rendered (no wall of stale + * rows at the top) but still count toward the running "chat total". + */ +export function interleaveTurnMetrics( + groups: readonly RenderGroup[], + entries: readonly TurnMetricsEntry[], +): readonly MetricsRow[] { + if (entries.length === 0) { + return groups.map((g) => ({ kind: "group" as const, group: g })); } - // Running cumulative usage across ALL finalized turns (in entry order), for - // the per-turn "chat total" cache rate. Alongside it, the previous finalized - // turn's usage at each index — the baseline for cross-turn retention. + const turnOf = assignTurns(groups, entries); + + const entryByTurn = new Map<string, { entry: TurnMetricsEntry; index: number }>(); + entries.forEach((entry, index) => { + entryByTurn.set(entry.turnId, { entry, index }); + }); + + // Running cumulative usage across ALL finalized turns (entry order), and the + // previous finalized turn's LAST step usage (the retention baseline: the + // prompt that turn last sent, which the next turn's first step should hit in + // cache). const cumulativeByEntry: Usage[] = []; - const prevUsageByEntry: (Usage | null)[] = []; + const prevLastStepByEntry: (Usage | null)[] = []; let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; - let lastFinalizedUsage: Usage | null = null; + let lastFinalizedStep: Usage | null = null; for (const e of entries) { - prevUsageByEntry.push(lastFinalizedUsage); + prevLastStepByEntry.push(lastFinalizedStep); if (e.total !== null) { runningUsage = addUsage(runningUsage, e.total.usage); - lastFinalizedUsage = e.total.usage; + const last = e.total.steps[e.total.steps.length - 1]; + if (last !== undefined) lastFinalizedStep = last.usage; } cumulativeByEntry.push(runningUsage); } - const rows: MetricsRow[] = []; - - const firstUserIdx = segmentStarts[0] ?? 0; - - for (let i = 0; i < firstUserIdx; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - } - - for (let seg = 0; seg < T; seg++) { - const start = segmentStarts[seg] ?? 0; - const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; - - const entry = segmentEntry.get(seg); + // Anchor positions: last group of each step (within its turn) / each turn. + const lastGroupOfStep = new Map<string, number>(); + const lastGroupOfTurn = new Map<string, number>(); + groups.forEach((g, i) => { + const t = turnOf[i]; + if (t === undefined) return; + lastGroupOfTurn.set(t, i); + const sid = groupStepId(g); + if (sid !== undefined) lastGroupOfStep.set(sid, i); + }); - if (entry === undefined) { - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - } - continue; - } - - const entryIdx = segmentEntryIndex.get(seg) ?? 0; - - // Build anchor map: for each stepId, the LAST group index in this segment. - const anchorByStepId = new Map<string, number>(); - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g === undefined) continue; - const sid = groupStepId(g); - if (sid !== undefined) { - anchorByStepId.set(sid, i); + const stepsAt = new Map<number, { step: StepMetrics; index: number }[]>(); + for (const entry of entries) { + entry.steps.forEach((step, index) => { + const at = lastGroupOfStep.get(step.stepId); + if (at === undefined || turnOf[at] !== entry.turnId) return; + let arr = stepsAt.get(at); + if (arr === undefined) { + arr = []; + stepsAt.set(at, arr); } - } - - // Classify each step as anchored or unanchored. Unanchored steps - // (content trimmed, or text-only steps with no tool chunks) are SKIPPED — - // step-metrics are only shown inline next to the content they describe. - const anchored: Map<number, { stepIndex: number; step: (typeof entry.steps)[number] }[]> = - new Map(); + arr.push({ step, index }); + }); + } - for (let i = 0; i < entry.steps.length; i++) { - const step = entry.steps[i]; - if (step === undefined) continue; - const anchorGroupIdx = anchorByStepId.get(step.stepId); - if (anchorGroupIdx !== undefined) { - let arr = anchored.get(anchorGroupIdx); - if (arr === undefined) { - arr = []; - anchored.set(anchorGroupIdx, arr); - } - arr.push({ stepIndex: i, step }); - } - // Unanchored steps (no matching group) are skipped — no tail bubbles. - } + const rows: MetricsRow[] = []; + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + if (g === undefined) continue; + rows.push({ kind: "group", group: g }); - // Emit groups; after each anchored group, emit its step-metrics rows. - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - const stepsHere = anchored.get(i); - if (stepsHere !== undefined) { - stepsHere.sort((a, b) => a.stepIndex - b.stepIndex); - for (const { step, stepIndex } of stepsHere) { - rows.push({ kind: "step-metrics", step, index: stepIndex }); - } - } + const steps = stepsAt.get(i); + if (steps !== undefined) { + steps.sort((a, b) => a.index - b.index); + for (const { step, index } of steps) rows.push({ kind: "step-metrics", step, index }); } - // Turn-metrics row (only when the turn is finalized). Unanchored steps - // are skipped — no tail bubbles. - if (entry.total !== null) { - rows.push({ - kind: "turn-metrics", - turn: entry.total, - turnNumber: entryIdx + 1, - cumulativeUsage: cumulativeByEntry[entryIdx] ?? entry.total.usage, - prevTurnUsage: prevUsageByEntry[entryIdx] ?? null, - }); - } + const t = turnOf[i]; + if (t === undefined || lastGroupOfTurn.get(t) !== i) continue; + const found = entryByTurn.get(t); + if (found === undefined || found.entry.total === null) continue; + rows.push({ + kind: "turn-metrics", + turn: found.entry.total, + turnNumber: found.index + 1, + cumulativeUsage: cumulativeByEntry[found.index] ?? found.entry.total.usage, + prevLastStepUsage: prevLastStepByEntry[found.index] ?? null, + }); } return rows; diff --git a/src/core/metrics/placement-scenarios.test.ts b/src/core/metrics/placement-scenarios.test.ts new file mode 100644 index 0000000..ce3ed5b --- /dev/null +++ b/src/core/metrics/placement-scenarios.test.ts @@ -0,0 +1,376 @@ +// End-to-end placement scenarios: real transcript + metrics reducers fed real +// AgentEvents, rendered through groupRenderedChunks → interleaveTurnMetrics. +// The invariant under test: at EVERY point of a turn (not just after it ends), +// each turn's metrics sit under that turn — never under a neighbour. +import type { AgentEvent, StepId, StoredChunk, TurnMetrics, Usage } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { + appendUserMessage, + applyHistory, + foldEvent, + groupRenderedChunks, + initialState, + selectChunks, + type TranscriptState, + trimTranscript, +} from "../chunks"; +import { interleaveTurnMetrics } from "./place"; +import { + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectOrderedTurnMetrics, +} from "./reducer"; +import type { MetricsRow, MetricsState } from "./types"; + +const C = "c1"; +const u = (i: number, o: number): Usage => ({ inputTokens: i, outputTokens: o }); + +/** Ordered list of "what follows each user text bubble / which turn rows appear where". */ +function layout(rows: readonly MetricsRow[]): string[] { + const out: string[] = []; + for (const r of rows) { + if (r.kind === "turn-metrics") out.push(`[turn ${r.turn.turnId}]`); + else if (r.kind === "step-metrics") out.push(`[step ${r.step.stepId}]`); + else if (r.group.kind === "single") { + const c = r.group.chunk.chunk; + if (c.type === "text") out.push(`${r.group.chunk.role}:${c.text}`); + else if (c.type === "image") out.push(`${r.group.chunk.role}:<img>`); + else if (c.type === "error") out.push(`error:${c.message}`); + } else { + out.push(`tool:${r.group.entry.toolCallId}`); + } + } + return out; +} + +class Sim { + t: TranscriptState = initialState(); + m: MetricsState = initialMetricsState(); + seq = 0; + /** Durable server-side log (what loadSince returns). */ + log: StoredChunk[] = []; + durable: TurnMetrics[] = []; + + constructor(private readonly legacy = false) {} + + private ev(e: AgentEvent): void { + this.t = foldEvent(this.t, e); + this.m = foldMetricsEvent(this.m, e); + } + + private persist(role: StoredChunk["role"], chunk: StoredChunk["chunk"], turnId: string): void { + this.seq++; + this.log.push( + this.legacy ? { seq: this.seq, role, chunk } : { seq: this.seq, role, chunk, turnId }, + ); + } + + /** The sender types + sends (optimistic echo), optionally with an image. */ + send(text: string, image = false): void { + this.t = appendUserMessage( + this.t, + text, + image ? [{ url: "data:image/png;base64,AA", mimeType: "image/png" }] : undefined, + ); + } + + start(turnId: string, text: string, image = false): void { + this.ev({ type: "turn-start", conversationId: C, turnId } as AgentEvent); + this.ev({ type: "user-message", conversationId: C, turnId, text } as AgentEvent); + this.persist("user", { type: "text", text }, turnId); + if (image) this.persist("user", { type: "image", url: "/images/c1/x.png" }, turnId); + } + + text(turnId: string, text: string): void { + this.ev({ type: "text-delta", conversationId: C, turnId, delta: text } as AgentEvent); + } + + toolStep(turnId: string, step: number, id: string): void { + const stepId = `${turnId}#${step}` as StepId; + this.ev({ + type: "tool-call", + conversationId: C, + turnId, + stepId, + toolCallId: id, + toolName: "t", + input: {}, + } as AgentEvent); + this.completeStep(turnId, step); + this.ev({ + type: "tool-result", + conversationId: C, + turnId, + stepId, + toolCallId: id, + toolName: "t", + content: "ok", + isError: false, + } as AgentEvent); + this.persist( + "assistant", + { type: "tool-call", toolCallId: id, toolName: "t", input: {}, stepId }, + turnId, + ); + this.persist( + "tool", + { type: "tool-result", toolCallId: id, toolName: "t", content: "ok", isError: false, stepId }, + turnId, + ); + } + + completeStep(turnId: string, step: number): void { + const stepId = `${turnId}#${step}` as StepId; + this.ev({ type: "usage", conversationId: C, turnId, stepId, usage: u(100, 10) } as AgentEvent); + this.ev({ + type: "step-complete", + conversationId: C, + turnId, + stepId, + ttftMs: 100, + decodeMs: 100, + genTotalMs: 200, + } as AgentEvent); + } + + steer(turnId: string, text: string): void { + this.ev({ type: "steering", conversationId: C, turnId, text } as AgentEvent); + this.persist("user", { type: "text", text }, turnId); + } + + /** Finish a turn: final text step, done, seal, then history + metrics sync. */ + finish(turnId: string, text: string, finalStep: number): void { + this.text(turnId, text); + this.completeStep(turnId, finalStep); + this.ev({ + type: "done", + conversationId: C, + turnId, + reason: "stop", + durationMs: 500, + usage: u(100, 10), + } as AgentEvent); + this.persist("assistant", { type: "text", text }, turnId); + this.ev({ type: "turn-sealed", conversationId: C, turnId } as AgentEvent); + this.sync(turnId, finalStep); + } + + /** A turn that errors (never seals); backend persists its partial metrics. */ + fail(turnId: string, message: string, steps: number): void { + this.ev({ type: "error", conversationId: C, turnId, message } as AgentEvent); + this.persist("assistant", { type: "error", message }, turnId); + this.t = applyHistory(this.t, this.log); + if (steps > 0) this.syncMetricsFor(turnId, steps - 1); + } + + private syncMetricsFor(turnId: string, lastStep: number): void { + const steps = []; + for (let i = 0; i <= lastStep; i++) { + steps.push({ stepId: `${turnId}#${i}` as StepId, usage: u(100, 10), decodeMs: 100 }); + } + this.durable.push({ turnId, usage: u(100 * steps.length, 10 * steps.length), steps }); + this.m = applyDurableMetrics(this.m, this.durable); + } + + private sync(turnId: string, lastStep: number): void { + this.t = applyHistory(this.t, this.log); + this.syncMetricsFor(turnId, lastStep); + } + + trim(limit: number): void { + this.t = trimTranscript(this.t, limit); + } + + layout(): string[] { + const groups = groupRenderedChunks(selectChunks(this.t)); + return layout(interleaveTurnMetrics(groups, selectOrderedTurnMetrics(this.m))); + } +} + +/** Build a history of N text-only turns (t1..tN), each "qK" → "aK". */ +function textHistory(sim: Sim, n: number): void { + for (let k = 1; k <= n; k++) { + sim.send(`q${k}`); + sim.start(`t${k}`, `q${k}`); + sim.finish(`t${k}`, `a${k}`, 0); + } +} + +for (const legacy of [false, true]) { + // `legacy`: the persisted rows carry NO turnId (history written before the + // backend stamped it). Live chunks always carry it. + describe(`placement stays exact through a turn (${legacy ? "legacy rows" : "turnId rows"})`, () => { + it("plain text turns", () => { + const sim = new Sim(legacy); + textHistory(sim, 3); + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "assistant:a2", + "[turn t2]", + "user:q3", + "assistant:a3", + "[turn t3]", + ]); + }); + + it("never shifts while a new turn is sent and streams — even after older turns were unloaded", () => { + const sim = new Sim(legacy); + textHistory(sim, 4); + sim.trim(5); // 8 chunks > 5 → unloads whole quarters: q1 a1 q2 a2 + const before = sim.layout(); + expect(before).toEqual([ + "user:q3", + "assistant:a3", + "[turn t3]", + "user:q4", + "assistant:a4", + "[turn t4]", + ]); + + const expectStable = () => expect(sim.layout().slice(0, before.length)).toEqual(before); + + sim.send("q5"); + expectStable(); // just sent (optimistic echo, no turn yet) + sim.start("t5", "q5"); + expectStable(); // turn started + sim.toolStep("t5", 0, "k1"); + expectStable(); // tool step completed (metrics entry exists, not done) + sim.text("t5", "partial…"); + expectStable(); // streaming text + sim.finish("t5", "a5", 1); + expect(sim.layout()).toEqual([ + ...before, + "user:q5", + "tool:k1", + "[step t5#0]", + "assistant:a5", + "[turn t5]", + ]); + }); + + it("image attachments never shift other turns' metrics", () => { + const sim = new Sim(legacy); + sim.send("q1"); + sim.start("t1", "q1"); + sim.finish("t1", "a1", 0); + sim.send("q2", true); + sim.start("t2", "q2", true); + sim.finish("t2", "a2", 0); + sim.send("q3"); + sim.start("t3", "q3"); + sim.finish("t3", "a3", 0); + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "user:<img>", + "assistant:a2", + "[turn t2]", + "user:q3", + "assistant:a3", + "[turn t3]", + ]); + }); + + it("a mid-turn steering message keeps the turn total at the END of the turn", () => { + const sim = new Sim(legacy); + textHistory(sim, 1); + sim.send("q2"); + sim.start("t2", "q2"); + sim.toolStep("t2", 0, "k1"); + sim.steer("t2", "steer!"); + // live, mid-turn: t1 untouched, steering bubble shows no totals + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "tool:k1", + "[step t2#0]", + "user:steer!", + ]); + sim.toolStep("t2", 1, "k2"); + sim.finish("t2", "a2", 2); + textHistoryFrom(sim, 3); + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "tool:k1", + "[step t2#0]", + "user:steer!", + "tool:k2", + "[step t2#1]", + "assistant:a2", + "[turn t2]", + "user:q3", + "assistant:a3", + "[turn t3]", + ]); + }); + + it("an errored turn gets its own metrics and never shifts the others", () => { + const sim = new Sim(legacy); + textHistory(sim, 1); + sim.send("q2"); + sim.start("t2", "q2"); + sim.text("t2", "partial"); + sim.completeStep("t2", 0); + sim.fail("t2", "boom", 1); + textHistoryFrom(sim, 3); + const l = sim.layout(); + expect(l.indexOf("[turn t1]")).toBe(l.indexOf("assistant:a1") + 1); + expect(l.indexOf("[turn t2]")).toBeGreaterThan(l.indexOf("error:boom")); + expect(l.indexOf("[turn t2]")).toBeLessThan(l.indexOf("user:q3")); + expect(l.indexOf("[turn t3]")).toBe(l.indexOf("assistant:a3") + 1); + }); + + it("an errored turn with NO metrics never steals another turn's", () => { + const sim = new Sim(legacy); + textHistory(sim, 1); + sim.send("q2"); + sim.start("t2", "q2"); + sim.fail("t2", "boom", 0); // failed before any step → no metrics row + textHistoryFrom(sim, 3); + expect(sim.layout()).toEqual([ + "user:q1", + "assistant:a1", + "[turn t1]", + "user:q2", + "error:boom", + "user:q3", + "assistant:a3", + "[turn t3]", + ]); + }); + + it("a turn cut in half by the chat limit still shows its total under its visible tail", () => { + const sim = new Sim(legacy); + sim.send("q1"); + sim.start("t1", "q1"); + sim.toolStep("t1", 0, "k1"); + sim.toolStep("t1", 1, "k2"); + sim.finish("t1", "a1", 2); + textHistoryFrom(sim, 2); + // log: q1 call result call result a1 | q2 a2 → keep newest 6 + sim.trim(7); + const l = sim.layout(); + expect(l).not.toContain("user:q1"); + expect(l.indexOf("[turn t1]")).toBe(l.indexOf("assistant:a1") + 1); + expect(l.indexOf("[turn t2]")).toBe(l.indexOf("assistant:a2") + 1); + }); + }); +} + +/** Continue a text history from turn `from` (one more turn). */ +function textHistoryFrom(sim: Sim, from: number): void { + sim.send(`q${from}`); + sim.start(`t${from}`, `q${from}`); + sim.finish(`t${from}`, `a${from}`, 0); +} diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts index 84d1904..95b5b02 100644 --- a/src/core/metrics/types.ts +++ b/src/core/metrics/types.ts @@ -61,10 +61,11 @@ export type MetricsRow = /** Cumulative usage across all finalized turns up to and including this one. */ readonly cumulativeUsage: Usage; /** - * Usage of the most recent EARLIER finalized turn, or `null` when this is the - * first finalized turn. The baseline for cross-turn retention (expected cache). + * Usage of the LAST step of the most recent EARLIER finalized turn, or + * `null` when there is none. The baseline for cross-turn retention: that + * step's prompt is what this turn's first step should find in cache. */ - readonly prevTurnUsage: Usage | null; + readonly prevLastStepUsage: Usage | null; }; /** Formatted cache hit-rate view: percentage + colour severity + hit flag. */ @@ -80,6 +81,7 @@ export interface CacheRateView { /** Formatted per-step view for display. */ export interface StepMetricsView { readonly label: string; + /** `"<in> in / <out> out"` — this step's prompt and generated tokens. */ readonly tokensLabel: string; readonly tps: string | null; readonly ttft: string | null; @@ -90,7 +92,13 @@ export interface StepMetricsView { /** Formatted per-turn view for display. */ export interface TurnMetricsView { readonly label: string; + /** `"<out> tok out"` — tokens the model generated this turn. */ readonly tokensLabel: string; + /** + * Prompt-side breakdown: input tokens processed (summed over the turn's + * steps — each step re-sends the whole prompt, so this is NOT the context + * size) and how many of them were cache reads. + */ readonly breakdown: string; readonly tps: string | null; readonly duration: string | null; diff --git a/src/core/plugin-abi/driver.test.ts b/src/core/plugin-abi/driver.test.ts new file mode 100644 index 0000000..43d0af5 --- /dev/null +++ b/src/core/plugin-abi/driver.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFrontendDriver, type FrontendDriverDeps } from "./driver.js"; +import type { FrontendCandidate } from "./load-diff.js"; + +/** + * Asymmetric testing: the ONLY fake is the OUTERMOST edge (the dynamic + * import + the host-API builder — injected effects). The driver's internals, + * the load diff, and the registry-facing sweep contract are real. + */ + +const c = (id: string, bust = "1"): FrontendCandidate => ({ + id, + url: `http://x/plugins/${id}/frontend?workspace=w&v=${bust}`, + apiVersion: "^1", +}); + +interface Harness { + readonly deps: FrontendDriverDeps & { + // The race test swaps the import edge before the driver exists — the + // driver reads the dep object per call, so the mutation is the swap. + importModule: (url: string) => Promise<unknown>; + }; + readonly imports: string[]; + readonly sweeps: string[]; + /** The modules the fake import() returns, keyed by the next id. */ + readonly modules: Map<string, unknown>; + readonly logs: string[]; +} + +function harness(): Harness { + const imports: string[] = []; + const sweeps: string[] = []; + const logs: string[] = []; + const modules = new Map<string, unknown>(); + const deps: FrontendDriverDeps = { + importModule: async (url) => { + imports.push(url); + const id = new URL(url).pathname.split("/")[2] ?? ""; + const mod = modules.get(id); + if (mod === undefined) throw new Error(`404: ${url}`); + return mod; + }, + buildHostApi: (pluginId) => ({ + abiVersion: "1.1", + registerView: () => () => {}, + surfaces: { + subscribe: () => {}, + unsubscribe: () => {}, + invoke: () => {}, + getSpec: () => null, + }, + logger: { info: () => {}, warn: () => {} }, + layout: { + applyOps: async () => ({ ok: false, error: "no layout provider active" }), + getTree: () => null, + }, + ...(pluginId === undefined ? {} : {}), + }), + unregisterViewsByPlugin: (pluginId) => sweeps.push(pluginId), + logger: { + info: (msg) => logs.push(msg), + warn: (msg) => logs.push(msg), + }, + }; + return { deps, imports, sweeps, modules, logs }; +} + +describe("createFrontendDriver — the load lifecycle", () => { + it("boot: imports newcomers and calls activate with the plugin's own host API", async () => { + const h = harness(); + const activate = vi.fn(); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + expect(h.imports).toEqual([c("a").url]); + expect(activate).toHaveBeenCalledOnce(); + expect(driver.states().get("a")).toEqual({ kind: "loaded" }); + }); + + it("extensions-changed: a vanished plugin runs its deactivate + the registry sweep", async () => { + const h = harness(); + const deactivate = vi.fn(); + h.modules.set("a", { default: { activate: () => deactivate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); // the plugin is gone from the desired set + expect(deactivate).toHaveBeenCalledOnce(); + expect(h.sweeps).toEqual(["a"]); + expect(driver.states().has("a")).toBe(false); + }); + + it("unload disposes EXACTLY that plugin (the sweep is per-pluginId)", async () => { + const h = harness(); + h.modules.set("a", { default: { activate: () => () => {} } }); + h.modules.set("b", { default: { activate: () => () => {} } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a"), c("b")]); + await driver.sync([c("b")]); // only `a` vanishes + expect(h.sweeps).toEqual(["a"]); + }); + + it("workspace.set / re-enable: the SAME url re-activates the cached module", async () => { + const h = harness(); + const activate = vi.fn(); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); // disable → unload (disposer + sweep) + await driver.sync([c("a")]); // re-enable → activate re-runs + expect(h.imports).toEqual([c("a").url, c("a").url]); // re-fetched URL + expect(activate).toHaveBeenCalledTimes(2); + expect(h.sweeps).toEqual(["a"]); + }); + + it("a changed URL (backend reload) unloads then re-imports fresh code", async () => { + const h = harness(); + const activate = vi.fn(() => () => {}); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a", "old")]); + await driver.sync([c("a", "new")]); + expect(h.sweeps).toEqual(["a"]); + expect(h.imports).toEqual([c("a", "old").url, c("a", "new").url]); + expect(activate).toHaveBeenCalledTimes(2); + }); + + it("a throwing activate is LOUD: failed state, no crash, others still load", async () => { + const h = harness(); + h.modules.set("bad", { + default: { + activate: () => { + throw new Error("boom"); + }, + }, + }); + h.modules.set("good", { default: { activate: () => () => {} } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("bad"), c("good")]); + expect(driver.states().get("bad")).toEqual({ kind: "failed", error: "boom" }); + expect(driver.states().get("good")).toEqual({ kind: "loaded" }); + // The failed plugin owns nothing — the sweep never ran for it. + expect(h.sweeps).toEqual([]); + }); + + it("a broken module (404 / missing activate) is a failed state, not a crash", async () => { + const h = harness(); + h.modules.set("no-activate", { default: {} }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("ghost"), c("no-activate")]); + expect(driver.states().get("ghost")?.kind).toBe("failed"); + expect(driver.states().get("no-activate")?.kind).toBe("failed"); + expect(h.logs.some((l) => l.includes("activation failed"))).toBe(true); + }); + + it("a throwing deactivate still gets the registry sweep (views never linger)", async () => { + const h = harness(); + h.modules.set("a", { + default: { + activate: () => () => { + throw new Error("disposer boom"); + }, + }, + }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); + expect(h.sweeps).toEqual(["a"]); + }); + + it("handshake-incompatible candidates are never imported (reported for the UI)", async () => { + const h = harness(); + const driver = createFrontendDriver(h.deps); + await driver.sync([{ ...c("bad"), apiVersion: "^2" }]); + expect(h.imports).toEqual([]); + expect(driver.states().get("bad")).toEqual({ kind: "incompatible", apiVersion: "^2" }); + }); + + it("overlapping syncs serialize (boot + extensions-changed race)", async () => { + const h = harness(); + let releaseImport: (() => void) | undefined; + h.modules.set("a", { + default: { + activate: () => { + return () => {}; + }, + }, + }); + const blockingImport = async (url: string) => { + h.imports.push(url); + await new Promise<void>((resolve) => { + releaseImport = resolve; + }); + return h.modules.get("a"); + }; + // Swap the import edge BEFORE the driver exists (the driver reads the + // dep object per call — the swap is the injection point for the race). + h.deps.importModule = blockingImport; + const driver = createFrontendDriver(h.deps); + const first = driver.sync([c("a")]); + const second = driver.sync([]); // queued behind the first + // While the first sync is blocked on import, the second must not have + // started (no unload of `a` mid-import). + await new Promise((r) => setTimeout(r, 10)); + releaseImport?.(); + await Promise.all([first, second]); + // The second pass sees `a` already imported (the queued sync ran after) + // and unloads it — exactly one sweep, no interleaving. + expect(h.sweeps).toEqual(["a"]); + }); +}); diff --git a/src/core/plugin-abi/driver.ts b/src/core/plugin-abi/driver.ts new file mode 100644 index 0000000..7ba326f --- /dev/null +++ b/src/core/plugin-abi/driver.ts @@ -0,0 +1,140 @@ +/** + * The frontend-module DRIVER (plan 03 §3.2) — the effectful lifecycle host. + * Owns the imported-module set and runs ONE sync pass per trigger (boot, + * `extensions-changed`, `workspace.set`): + * + * 1. diff (pure, `decideFrontendLoads`) the desired set against the + * imported set; + * 2. UNLOAD stragglers: run the module's deactivate disposer (if it + * returned one) then the host's unregister-by-pluginId sweep — the UI is + * immediately correct. HONEST LIMITATION: ESM cannot be un-fetched; the + * bytes linger in the module cache. Re-enable re-runs `activate` on the + * cached module (same URL ⇒ no re-fetch). + * 3. IMPORT newcomers: dynamic-import the URL (THE injected edge), call + * `activate(api)` with the plugin's own host API (forced pluginId + * attribution). A throwing/missing activate is LOUD: the plugin's FE + * half is marked failed (the backend half stays loaded) and the failure + * never crashes the host or blocks the other plugins. + * + * Syncs are serialized (a chain) — boot + extensions-changed + workspace + * switches can overlap, and import/unload ordering must stay coherent. + * No internal mocks: tests inject the import edge + host-API builder. + */ +import type { + FrontendCandidate, + FrontendLoadDecision, + ImportedFrontendModule, +} from "./load-diff.js"; +import { decideFrontendLoads } from "./load-diff.js"; +import type { FrontendHostAPI, FrontendPluginModule, FrontendPluginState } from "./types.js"; + +export interface FrontendDriverDeps { + /** + * THE edge: dynamic ESM import. Injected (the composition root binds + * `import(url)`; tests bind a fake) so nothing else in the driver touches + * the module system. + */ + readonly importModule: (url: string) => Promise<unknown>; + /** + * Build the plugin's host API — the composition root closes over the + * registry + surfaces ports + logger and FORCES the pluginId attribution. + */ + readonly buildHostApi: (pluginId: string) => FrontendHostAPI; + /** The unregister-by-pluginId sweep (the registry-side unload path). */ + readonly unregisterViewsByPlugin: (pluginId: string) => void; + readonly logger: { info: (msg: string) => void; warn: (msg: string) => void }; +} + +export interface FrontendDriver { + /** Run one sync pass. Serialized; never throws (failures are states). */ + sync(candidates: readonly FrontendCandidate[]): Promise<void>; + /** The per-plugin states the manager UI renders. */ + states(): ReadonlyMap<string, FrontendPluginState>; +} + +interface ImportedEntry extends ImportedFrontendModule { + readonly deactivate: (() => void) | undefined; +} + +export function createFrontendDriver(deps: FrontendDriverDeps): FrontendDriver { + /** pluginId → the imported module's entry. */ + const imported = new Map<string, ImportedEntry>(); + /** pluginId → its last-known FE state (for the manager UI). */ + const pluginStates = new Map<string, FrontendPluginState>(); + /** The sync chain (serialization across triggers). */ + let chain: Promise<void> = Promise.resolve(); + + /** Unload one plugin: module disposer FIRST, then the registry sweep. */ + function unload(id: string, entry: ImportedEntry): void { + try { + entry.deactivate?.(); + } catch (err) { + // A broken deactivate must not block the sweep — the registration + // ledger's force-clean philosophy (the views must not linger). + deps.logger.warn(`frontend-plugin "${id}": deactivate threw — ${String(err)}`); + } + try { + deps.unregisterViewsByPlugin(id); + } catch (err) { + deps.logger.warn(`frontend-plugin "${id}": unregister sweep threw — ${String(err)}`); + } + imported.delete(id); + pluginStates.delete(id); + deps.logger.info(`frontend-plugin "${id}": unloaded`); + } + + /** Import + activate one candidate. Failures are LOUD states, not throws. */ + async function load(candidate: FrontendCandidate): Promise<void> { + try { + const mod = (await deps.importModule(candidate.url)) as FrontendPluginModule | undefined; + const activate = mod?.default?.activate; + if (typeof activate !== "function") { + throw new Error("the module has no default.activate export"); + } + const disposer = activate(deps.buildHostApi(candidate.id)); + imported.set(candidate.id, { + url: candidate.url, + deactivate: typeof disposer === "function" ? disposer : undefined, + }); + pluginStates.set(candidate.id, { kind: "loaded" }); + deps.logger.info(`frontend-plugin "${candidate.id}": activated`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + pluginStates.set(candidate.id, { kind: "failed", error: message }); + deps.logger.warn(`frontend-plugin "${candidate.id}": activation failed — ${message}`); + } + } + + return { + async sync(candidates: readonly FrontendCandidate[]): Promise<void> { + const run = chain.then(() => runSync(candidates)); + // The NEXT sync waits for this one; a rejected run must not poison the + // chain (runSync itself never throws, but belt-and-suspenders). + chain = run.catch(() => {}); + await run; + }, + + states(): ReadonlyMap<string, FrontendPluginState> { + return new Map(pluginStates); + }, + }; + + async function runSync(candidates: readonly FrontendCandidate[]): Promise<void> { + const decision: FrontendLoadDecision = decideFrontendLoads(candidates, imported); + + // The handshake verdicts persist even when nothing else changes — the + // manager UI renders "incompatible frontend plugin" until the plugin's + // manifest (or the host) changes. + for (const bad of decision.incompatible) { + pluginStates.set(bad.id, { kind: "incompatible", apiVersion: bad.apiVersion }); + } + + for (const id of decision.toUnload) { + const entry = imported.get(id); + if (entry !== undefined) unload(id, entry); + } + for (const candidate of decision.toLoad) { + await load(candidate); + } + } +} diff --git a/src/core/plugin-abi/index.ts b/src/core/plugin-abi/index.ts new file mode 100644 index 0000000..3c97eca --- /dev/null +++ b/src/core/plugin-abi/index.ts @@ -0,0 +1,27 @@ +/** + * The frontend plugin ABI (plan 03) — the contract + lifecycle machinery for + * dynamically-imported frontend plugin modules. See `types.ts` (the surface), + * `version.ts` (the handshake), `load-diff.ts` (pure decisions), and + * `driver.ts` (the effectful host). The composition root (src/app) wires the + * driver to the registry, the surfaces ports, and the plugin HTTP edge. + */ +export { createFrontendDriver, type FrontendDriver, type FrontendDriverDeps } from "./driver.js"; +export { + decideFrontendLoads, + type FrontendCandidate, + type FrontendCandidatesInput, + type FrontendLoadDecision, + frontendCandidates, + type ImportedFrontendModule, + usablePluginIds, +} from "./load-diff.js"; +export type { + FrontendHostAPI, + FrontendPluginModule, + FrontendPluginState, + FrontendViewRegistration, +} from "./types.js"; +export { + FRONTEND_ABI_VERSION, + isFrontendCompatible, +} from "./version.js"; diff --git a/src/core/plugin-abi/load-diff.test.ts b/src/core/plugin-abi/load-diff.test.ts new file mode 100644 index 0000000..f8ebf6c --- /dev/null +++ b/src/core/plugin-abi/load-diff.test.ts @@ -0,0 +1,183 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + decideFrontendLoads, + frontendCandidates, + type ImportedFrontendModule, + usablePluginIds, +} from "./load-diff.js"; +import { FRONTEND_ABI_VERSION } from "./version.js"; + +const httpBase = "http://localhost:24203"; + +function listWith( + ...rows: readonly { + readonly id: string; + readonly apiVersion?: string; + readonly bust?: string; + }[] +): PluginListResponse { + return { + loaded: rows.map((r) => ({ + id: r.id, + name: r.id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + ...(r.apiVersion === undefined + ? {} + : { frontend: { apiVersion: r.apiVersion, entry: "frontend.js", bust: r.bust ?? "1" } }), + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; +} + +describe("frontendCandidates", () => { + it("derives candidates from loaded ∧ frontend-declaring ∧ workspace-enabled rows", () => { + const candidates = frontendCandidates( + listWith( + { id: "a", apiVersion: "^1", bust: "111" }, + { id: "no-fe" }, + { id: "b", apiVersion: "^1", bust: "222" }, + ), + { workspaceId: "team-a", usableIds: new Set(["a", "b", "no-fe"]), httpBase }, + ); + expect(candidates).toEqual([ + { + id: "a", + apiVersion: "^1", + url: `${httpBase}/plugins/a/frontend?workspace=team-a&v=111`, + }, + { + id: "b", + apiVersion: "^1", + url: `${httpBase}/plugins/b/frontend?workspace=team-a&v=222`, + }, + ]); + }); + + it("excludes plugins disabled in the active workspace", () => { + const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1" }), { + workspaceId: "team-a", + usableIds: new Set<string>(), + httpBase, + }); + expect(candidates).toEqual([]); + }); + + it("URL-encodes the workspace and bust", () => { + const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1", bust: "1 2" }), { + workspaceId: "team a", + usableIds: new Set(["a"]), + httpBase, + }); + expect(candidates[0]?.url).toBe(`${httpBase}/plugins/a/frontend?workspace=team%20a&v=1%202`); + }); +}); + +describe("usablePluginIds", () => { + it("mirrors isPluginUsable: core usable unless disabled, others enabled-only", () => { + const usable = usablePluginIds([ + { pluginId: "core-one", tier: "core", state: "undefined", loaded: true, locked: true }, + { pluginId: "core-off", tier: "core", state: "disabled", loaded: true, locked: true }, + { pluginId: "std-on", tier: "standard", state: "enabled", loaded: true, locked: false }, + { pluginId: "std-blank", tier: "standard", state: "undefined", loaded: true, locked: false }, + { pluginId: "prov-on", tier: "providers", state: "enabled", loaded: true, locked: false }, + { pluginId: "prov-off", tier: "providers", state: "disabled", loaded: true, locked: false }, + ]); + expect([...usable].sort()).toEqual(["core-one", "prov-on", "std-on"]); + }); +}); + +describe("decideFrontendLoads — the load diff (boot / extensions-changed / workspace.set)", () => { + const c = (id: string, bust = "1"): { id: string; url: string; apiVersion: string } => ({ + id, + url: `${httpBase}/plugins/${id}/frontend?workspace=w&v=${bust}`, + apiVersion: "^1", + }); + + it("boot: every desired candidate imports (nothing imported yet)", () => { + const d = decideFrontendLoads([c("a"), c("b")], new Map()); + expect(d.toLoad.map((x) => x.id)).toEqual(["a", "b"]); + expect(d.toUnload).toEqual([]); + expect(d.incompatible).toEqual([]); + }); + + it("extensions-changed: a removed plugin unloads, a newcomer imports", () => { + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["gone", { url: `${httpBase}/plugins/gone/frontend?workspace=w&v=1` }], + ]); + const d = decideFrontendLoads([c("a"), c("new")], imported); + expect(d.toLoad.map((x) => x.id)).toEqual(["new"]); + expect(d.toUnload).toEqual(["gone"]); + }); + + it("workspace.set: the enablement set change drives load AND unload", () => { + // The candidate list already reflects the NEW workspace (composition + // root re-derives per sync) — previously-imported ids absent from it unload. + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["b", { url: c("b").url }], + ]); + const d = decideFrontendLoads([c("a")], imported); + expect(d.toLoad).toEqual([]); + expect(d.toUnload).toEqual(["b"]); + }); + + it("a stable desired set is a no-op (idempotent sync)", () => { + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["b", { url: c("b").url }], + ]); + const d = decideFrontendLoads([c("a"), c("b")], imported); + expect(d.toLoad).toEqual([]); + expect(d.toUnload).toEqual([]); + }); + + it("a changed URL (backend reload → new bust) is unload + re-import", () => { + const imported = new Map<string, ImportedFrontendModule>([["a", { url: c("a", "old").url }]]); + const d = decideFrontendLoads([c("a", "new")], imported); + expect(d.toLoad.map((x) => x.id)).toEqual(["a"]); + expect(d.toUnload).toEqual(["a"]); + }); + + it("handshake failure → incompatible, never imported, still unloaded if it was", () => { + const bad = { ...c("bad"), apiVersion: "^2" }; + const imported = new Map<string, ImportedFrontendModule>([["bad", { url: bad.url }]]); + const d = decideFrontendLoads([bad], imported); + expect(d.toLoad).toEqual([]); + expect(d.incompatible).toEqual([{ id: "bad", apiVersion: "^2" }]); + // The previously-imported instance must be swept (the manifest changed + // under us — the UI must not keep running incompatible code). + expect(d.toUnload).toEqual(["bad"]); + }); + + it("the handshake checks against FRONTEND_ABI_VERSION", () => { + // A range that includes the host version passes — proving the version + // constant feeds the check. + expect(FRONTEND_ABI_VERSION).toBe("1.1"); + const d = decideFrontendLoads([c("ok")], new Map()); + expect(d.incompatible).toEqual([]); + }); + + it("the additive bump keeps v1 plugins loading (plan 04: ^1 spans 1.1)", () => { + // c() declares apiVersion "^1" — a bare-major range is a + // major-compatibility claim, so the 1.1 host (layout member added) still + // loads every v1 plugin. + const d = decideFrontendLoads([c("v1-plugin")], new Map()); + expect(d.toLoad.map((x) => x.id)).toEqual(["v1-plugin"]); + expect(d.incompatible).toEqual([]); + }); + + it("plugins requiring the 1.1 ABI (^1.1) load on the 1.1 host", () => { + const layout = { ...c("layout-plugin"), apiVersion: "^1.1" }; + const d = decideFrontendLoads([layout], new Map()); + expect(d.toLoad.map((x) => x.id)).toEqual(["layout-plugin"]); + expect(d.incompatible).toEqual([]); + }); +}); diff --git a/src/core/plugin-abi/load-diff.ts b/src/core/plugin-abi/load-diff.ts new file mode 100644 index 0000000..b846a40 --- /dev/null +++ b/src/core/plugin-abi/load-diff.ts @@ -0,0 +1,137 @@ +/** + * The frontend-module load DIFF (plan 03 §3.2) — pure decision logic. On + * boot, on `extensions-changed`, and on `workspace.set`, the composition + * root derives the DESIRED set of frontend plugins (loaded ∧ + * workspace-enabled ∧ frontend-declaring) from `GET /plugins` + the active + * workspace's enablement rows, and diffs it against the IMPORTED set: + * newcomers are `import()`-ed + activated; stragglers are deactivated + * (module disposer + unregister-by-pluginId sweep); a changed import URL + * (backend reload → new content bust) is an unload + re-import. + * + * The handshake runs HERE: a plugin whose `frontend.apiVersion` range does + * not include FRONTEND_ABI_VERSION is reported "incompatible" and is NEVER + * imported — it is also (re)unloaded if a previous version of it was + * imported. + */ +import type { PluginListResponse, WorkspacePluginRow } from "@dispatch/transport-contract"; +import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js"; + +/** One desired frontend plugin, fully URL-resolved. */ +export interface FrontendCandidate { + readonly id: string; + /** The import URL: the backend route + workspace + the content bust. */ + readonly url: string; + /** The plugin's declared frontend ABI semver range (the handshake input). */ + readonly apiVersion: string; +} + +/** What the currently-imported modules look like to the diff. */ +export interface ImportedFrontendModule { + /** The URL the module was imported from (a change → unload + re-import). */ + readonly url: string; +} + +/** The diff verdict for one sync pass. */ +export interface FrontendLoadDecision { + /** Newcomers (or bust-changed plugins) to import + activate. */ + readonly toLoad: readonly FrontendCandidate[]; + /** Imported plugin ids no longer desired (or superseded by a new URL). */ + readonly toUnload: readonly string[]; + /** Handshake failures — "incompatible frontend plugin" in the manager UI. */ + readonly incompatible: readonly { readonly id: string; readonly apiVersion: string }[]; +} + +/** The composition-root inputs for candidate derivation. */ +export interface FrontendCandidatesInput { + /** The active workspace whose enablement set applies. */ + readonly workspaceId: string; + /** The plugin ids enabled in that workspace (see `usablePluginIds`). */ + readonly usableIds: ReadonlySet<string>; + /** The backend HTTP base (the import URL's origin). */ + readonly httpBase: string; +} + +/** + * The desired frontend-plugin set from a `GET /plugins` response: LOADED ∧ + * workspace-enabled ∧ frontend-declaring rows, one URL each. The URL is + * `/plugins/:id/frontend?workspace=<ws>&v=<bust>` — `v` is the content + * bust the additive list field carries (the entry's mtimeMs), so a + * backend-side reload (new mtime) produces a NEW URL → the diff re-imports. + */ +export function frontendCandidates( + list: PluginListResponse, + input: FrontendCandidatesInput, +): readonly FrontendCandidate[] { + const out: FrontendCandidate[] = []; + for (const row of list.loaded) { + if (row.frontend === undefined) continue; + if (!input.usableIds.has(row.id)) continue; + out.push({ + id: row.id, + apiVersion: row.frontend.apiVersion, + url: `${input.httpBase}/plugins/${encodeURIComponent(row.id)}/frontend?workspace=${encodeURIComponent(input.workspaceId)}&v=${encodeURIComponent(row.frontend.bust)}`, + }); + } + return out; +} + +/** + * Diff the desired set against the imported set. Handshake failures are + * NEVER in `toLoad` — and if such a plugin WAS imported (its manifest + * changed to an incompatible range), it lands in `toUnload`. + */ +export function decideFrontendLoads( + candidates: readonly FrontendCandidate[], + imported: ReadonlyMap<string, ImportedFrontendModule>, +): FrontendLoadDecision { + const toLoad: FrontendCandidate[] = []; + const incompatible: { id: string; apiVersion: string }[] = []; + const desired = new Set<string>(); + /** Imported ids whose URL changed (unload + re-import — fresh code). */ + const superseded: string[] = []; + + for (const candidate of candidates) { + if (!isFrontendCompatible(candidate.apiVersion)) { + incompatible.push({ id: candidate.id, apiVersion: candidate.apiVersion }); + continue; + } + desired.add(candidate.id); + const current = imported.get(candidate.id); + if (current === undefined) { + toLoad.push(candidate); + } else if (current.url !== candidate.url) { + // The backend reloaded the plugin (a new content bust → new URL): + // unload the OLD instance (its disposer + registry sweep run), then + // import the fresh code. + superseded.push(candidate.id); + toLoad.push(candidate); + } + } + + // Everything imported that is no longer a (handshake-passing) desired id, + // plus same-id candidates whose URL changed. + const toUnload = [...[...imported.keys()].filter((id) => !desired.has(id)), ...superseded]; + + return { toLoad, toUnload, incompatible }; +} + +/** + * The workspace-usable plugin ids from a `GET /workspaces/:id/plugins` + * response (plan-06 rows) — the FE mirror of the backend's + * `isPluginUsable`: tier "core" is usable unless explicitly `disabled`; + * every other tier is usable only when explicitly `enabled`. The response's + * `loaded: false` rows (records for unloaded plugins) cannot be desired + * anyway (candidates come from the LOADED set). + */ +export function usablePluginIds(rows: readonly WorkspacePluginRow[]): ReadonlySet<string> { + const usable = new Set<string>(); + for (const row of rows) { + if (row.tier === "core" ? row.state !== "disabled" : row.state === "enabled") { + usable.add(row.pluginId); + } + } + return usable; +} + +/** Re-export for the composition root's single-import convenience. */ +export { FRONTEND_ABI_VERSION, isFrontendCompatible }; diff --git a/src/core/plugin-abi/types.ts b/src/core/plugin-abi/types.ts new file mode 100644 index 0000000..bcb8139 --- /dev/null +++ b/src/core/plugin-abi/types.ts @@ -0,0 +1,113 @@ +/** + * The frontend plugin ABI surface (plan 03 §3.2) — the ONLY contract between + * the host app and a dynamically-imported frontend plugin module. + * + * A frontend plugin is a SINGLE prebuilt ESM file (svelte externalized — + * resolved through the host's import map to the app's own Svelte 5 runtime) + * whose default export is `{ activate(api) }`. `activate` registers views / + * stashes ports and MAY return a deactivate disposer (plan 03's deactivate + * contract). Trust model: plugin code runs with full page privileges — no + * sandbox (documented non-goal); the trust signal is the manifest's `trust` + * field rendered in the manager UI. + * + * Views get data via SURFACES (the ui-contract philosophy: backend-declared + * data rendered generically) — never via the app store. `registerView` + * FORCES the importing plugin's pluginId attribution (the host wraps the + * registry call), which is what makes unload-time sweeps exact. + */ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; +import type { SurfaceSpec } from "@dispatch/ui-contract"; +import type { FRONTEND_ABI_VERSION } from "./version.js"; + +/** The registration input for one plugin view (as the ABI exposes it). */ +export interface FrontendViewRegistration { + /** Registry id — the same id a tile leaf's `view` field names. */ + readonly id: string; + /** Human label shown in panel dropdowns. */ + readonly label: string; + /** + * A Svelte 5 component (compiled by the plugin's build against the SHARED + * svelte runtime). Opaque here — the host casts it into the registry's + * heterogeneous component bag at the seam. + */ + readonly component: unknown; + /** Whether the view may appear in panel dropdowns (default true). */ + readonly panelEligible?: boolean; +} + +/** The per-plugin host API object handed to `activate`. */ +export interface FrontendHostAPI { + /** The host's ABI version (the other half of the semver handshake). */ + readonly abiVersion: typeof FRONTEND_ABI_VERSION; + /** + * Register a view into the SAME registry built-ins use. pluginId + * attribution is FORCED to the importing plugin (the host closes over it) + * — plugins cannot attribute views to another plugin. The returned + * disposer removes the view (call it from your deactivate). + */ + readonly registerView: (reg: FrontendViewRegistration) => () => void; + /** + * Talk to backend surfaces — the plugin-view data path. The app + * auto-subscribes every catalog entry, so `getSpec` typically returns + * data immediately for surfaces your plugin's backend half declares; + * subscribe/unsubscribe re-assert scopes explicitly. + */ + readonly surfaces: { + readonly subscribe: (surfaceId: string, conversationId?: string) => void; + readonly unsubscribe: (surfaceId: string, conversationId?: string) => void; + readonly invoke: ( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ) => void; + readonly getSpec: (surfaceId: string) => SurfaceSpec | null; + }; + readonly logger: { info: (msg: string) => void; warn: (msg: string) => void }; + /** + * Edit the ACTIVE workspace's layout through the provider (plan 04 §4.5 — + * the additive ABI bump; v1 plugins keep working, a plugin REQUIRING the + * layout member declares `frontend.apiVersion: "^1.1"`). A thin adapter + * over `POST /workspaces/:id/layout/ops` (the same kit `applyOps` the + * service exposes — one code path, two doors): + * - the ops are the `@dispatch/layout-contract` vocabulary (prefer the + * idempotent `ensure*` ops; address by node ID, never by numeric path); + * - the batch is ATOMIC — any failed op persists nothing and the result + * names it (`failedOp`); + * - no layout provider active (the route 404s) → the typed no-provider + * error `{ ok: false, error: "no layout provider active" }` — handle it; + * the provider may also load later (retry on workspace switches). + */ + readonly layout: { + readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; + /** The ACTIVE workspace's tree; `null` in no-provider mode. */ + readonly getTree: () => LayoutTree | null; + }; +} + +/** + * What `activate` may return: nothing, or the deactivate disposer (the + * plan-03 deactivate contract). A named alias keeps the plan grammar + * readable; the suppression is needed because biome flags `void` inside a + * union type — here it is the plan-specified shape. + */ +// biome-ignore lint/suspicious/noConfusingVoidType: plan-specified activate grammar — "may return a disposer" (the deactivate contract) +export type FrontendActivateResult = void | (() => void); + +/** The module shape a frontend plugin's entry must default-export. */ +export interface FrontendPluginModule { + readonly default: { + /** + * Register the plugin's views/ports. MAY return a deactivate disposer — + * run on unload/disable BEFORE the host's unregister-by-pluginId sweep + * (the sweep is the safety net, not the primary path). + */ + activate(api: FrontendHostAPI): FrontendActivateResult; + }; +} + +/** The per-plugin FRONTEND lifecycle state the manager UI renders. */ +export type FrontendPluginState = + | { readonly kind: "loaded" } + | { readonly kind: "incompatible"; readonly apiVersion: string } + | { readonly kind: "failed"; readonly error: string }; diff --git a/src/core/plugin-abi/version.test.ts b/src/core/plugin-abi/version.test.ts new file mode 100644 index 0000000..ab4d34b --- /dev/null +++ b/src/core/plugin-abi/version.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js"; + +describe("FRONTEND_ABI_VERSION", () => { + it("is 1.1 (plan 04 — the additive layout member bumped the minor)", () => { + expect(FRONTEND_ABI_VERSION).toBe("1.1"); + }); +}); + +describe("isFrontendCompatible — handshake accept/reject matrix (host 1.1.0)", () => { + it("accepts the canonical authoring range ^1 (v1 plugins keep working — the plan-04 promise)", () => { + expect(isFrontendCompatible("^1")).toBe(true); + expect(isFrontendCompatible("^1.0")).toBe(true); + expect(isFrontendCompatible("^1.0.0")).toBe(true); + }); + + it("accepts the bare major version (a major-compatibility claim: additive bumps never strand it)", () => { + expect(isFrontendCompatible("1")).toBe(true); + expect(isFrontendCompatible("~1")).toBe(true); + }); + + it("accepts plugins that REQUIRE the 1.1 ABI (^1.1 — the layout-declaring grammar)", () => { + expect(isFrontendCompatible("^1.1")).toBe(true); + expect(isFrontendCompatible("^1.1.0")).toBe(true); + }); + + it("accepts wildcard and >= ranges", () => { + expect(isFrontendCompatible("*")).toBe(true); + expect(isFrontendCompatible(">=1.0.0")).toBe(true); + }); + + it("rejects a next-major caret range", () => { + expect(isFrontendCompatible("^2")).toBe(false); + expect(isFrontendCompatible("^2.0.0")).toBe(false); + }); + + it("rejects an older pinned range", () => { + expect(isFrontendCompatible("0.9.0")).toBe(false); + expect(isFrontendCompatible("^0.1.0")).toBe(false); + }); + + it("rejects a tilde/pin at an OLDER explicit minor (explicit pins reject additive bumps)", () => { + expect(isFrontendCompatible("~0.9.0")).toBe(false); + expect(isFrontendCompatible("~1.0.0")).toBe(false); + expect(isFrontendCompatible("1.0.0")).toBe(false); + expect(isFrontendCompatible("^1.0")).not.toBe(false); // caret 1.x tracks the major + }); + + it("rejects malformed ranges (fail closed, never throws)", () => { + expect(isFrontendCompatible("")).toBe(false); + expect(isFrontendCompatible("banana")).toBe(false); + expect(isFrontendCompatible("^abc")).toBe(false); + expect(isFrontendCompatible("1.0.0.0")).toBe(false); + }); + + it("the host version pins the answer (injectable for tests)", () => { + // At a hypothetical 1.0.0 host, ^1.1 (needing the layout member) excludes; + // at 1.1.0 it includes — the gate is real, not decorative. + expect(isFrontendCompatible("^1.1", "1.0.0")).toBe(false); + expect(isFrontendCompatible("^1.1", "1.1.0")).toBe(true); + // "~1.0.0" pins minor 0: excludes a 1.1.0 host, includes a 1.0.x host. + expect(isFrontendCompatible("~1.0.0", "1.1.0")).toBe(false); + expect(isFrontendCompatible("~1.0.0", "1.0.0")).toBe(true); + }); +}); diff --git a/src/core/plugin-abi/version.ts b/src/core/plugin-abi/version.ts new file mode 100644 index 0000000..e5a0168 --- /dev/null +++ b/src/core/plugin-abi/version.ts @@ -0,0 +1,99 @@ +/** + * The frontend plugin ABI version + the semver handshake (plan 03 §3.2). + * + * FRONTEND_ABI_VERSION is bumped on BREAKING ABI changes only — an ADDITIVE + * member bumps the MINOR (plan 04 added `layout`: "1" → "1.1"; v1 plugins + * keep working, see the bare-major rule below). A plugin's + * `manifest.frontend.apiVersion` is a semver RANGE that must include the + * host's version; a mismatch lists the plugin as "incompatible frontend + * plugin" in the manager UI and its module is NEVER imported. + * + * `isFrontendCompatible` mirrors the backend kernel's + * `isApiVersionCompatible` semantics (host/src/host/version.ts) so both + * sides of the handshake behave identically. It additionally accepts + * major-only versions/ranges ("1", "^1") — the ABI's version grammar is + * single-number, normalized here to x.y.z with zeros — because a frontend + * ABI ("1") is not a kernel release ("0.1.0") and authors declare `"^1"`. + * A BARE-major range ("1", "^1", "~1" — no explicit minor) is a + * MAJOR-compatibility claim: it accepts every additive (minor) bump of that + * major, which is what makes "^1"/"1" plugins keep working on the 1.1 ABI + * (an author pinning minor/patch declares it explicitly: "1.0.0", "~1.0"). + */ + +export const FRONTEND_ABI_VERSION = "1.1"; + +/** + * The host version the handshake checks against (normalized to semver) — + * tracks FRONTEND_ABI_VERSION's major.minor at a 0 patch. + */ +const HOST_VERSION = "1.1.0"; + +interface SemVer { + readonly major: number; + readonly minor: number; + readonly patch: number; + /** The range string named the minor EXPLICITLY (a pin, not a bare major). */ + readonly explicitMinor: boolean; +} + +/** Parse a 1–3 component version ("1", "1.0", "1.0.0") — missing parts are 0. */ +function parseSemver(version: string): SemVer | null { + const match = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + explicitMinor: match[2] !== undefined, + }; +} + +function gte(a: SemVer, b: SemVer): boolean { + if (a.major !== b.major) return a.major > b.major; + if (a.minor !== b.minor) return a.minor > b.minor; + return a.patch >= b.patch; +} + +/** + * Whether the plugin's declared `frontend.apiVersion` range includes the + * host's FRONTEND_ABI_VERSION. A malformed range is NEVER compatible (a + * plugin-verify check catches it at authoring time; the handshake fails + * closed here). + */ +export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERSION): boolean { + const range = apiVersion.trim(); + if (range === "*") return true; + const host = parseSemver(hostVersion); + if (host === null) return false; + + if (range.startsWith("^")) { + const min = parseSemver(range.slice(1)); + if (min === null) return false; + if (!gte(host, min)) return false; + if (min.major === 0) { + return host.major === 0 && (min.explicitMinor ? host.minor === min.minor : true); + } + return host.major === min.major; + } + if (range.startsWith("~")) { + const min = parseSemver(range.slice(1)); + if (min === null) return false; + if (!gte(host, min)) return false; + // A BARE "~1" is a major-compatibility claim (any 1.x.y); an explicit + // minor ("~1.0") pins the minor, as tilde always has. + if (!min.explicitMinor) return host.major === min.major; + return host.major === min.major && host.minor === min.minor; + } + if (range.startsWith(">=")) { + const min = parseSemver(range.slice(2)); + if (min === null) return false; + return gte(host, min); + } + const exact = parseSemver(range); + if (exact === null) return false; + // A BARE major ("1") names the major only — it accepts that major's + // additive bumps (the "v1 plugins keep working" rule). An explicit + // version ("1.0", "1.0.0") pins exactly, as before. + if (!exact.explicitMinor) return host.major === exact.major; + return host.major === exact.major && host.minor === exact.minor && host.patch === exact.patch; +} diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index 0d955d5..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", ]); }); }); @@ -212,6 +266,7 @@ describe("classifies every WsClientMessage type", () => { { type: "chat.subscribe" as const, conversationId: "c1" }, { type: "chat.unsubscribe" as const, conversationId: "c1" }, { type: "chat.queue" as const, conversationId: "c1", text: "steer" }, + { type: "chat.queue.cancel" as const, conversationId: "c1", messageId: "m1" }, ]; const labels = msgs.map(assertWsClientMessageExhaustive); expect(labels).toEqual([ @@ -222,6 +277,7 @@ describe("classifies every WsClientMessage type", () => { "chat.subscribe", "chat.unsubscribe", "chat.queue", + "chat.queue.cancel", ]); }); }); @@ -266,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); @@ -273,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 bfa67dc..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": @@ -91,6 +103,14 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { return "conversation.statusChanged"; case "conversation.compacted": return "conversation.compacted"; + case "conversation.tabGroupChanged": + return "conversation.tabGroupChanged"; + case "extensions-changed": + return "extensions-changed"; + case "layout": + return "layout"; + case "tile-layout": + return "tile-layout"; default: return msg satisfies never; } @@ -116,6 +136,10 @@ export function assertWsClientMessageExhaustive(msg: WsClientMessage): string { return "chat.unsubscribe"; case "chat.queue": return "chat.queue"; + case "chat.queue.cancel": + return "chat.queue.cancel"; + case "workspace.set": + return "workspace.set"; default: return msg satisfies never; } diff --git a/src/features/account-usage/index.ts b/src/features/account-usage/index.ts new file mode 100644 index 0000000..76204dc --- /dev/null +++ b/src/features/account-usage/index.ts @@ -0,0 +1,41 @@ +export type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageResult, + AccountUsageSettingsResult, + AccountUsageState, + LoadAccountUsage, + SaveAccountUsageSettings, + UpdateAccountUsageSettingsRequest, +} from "./logic/types"; +export type { + BarTone, + DailyCreditsView, + ProviderCardView, + UsageBarView, +} from "./logic/view-model"; +export { + anchorFromTimeOfDay, + elapsedFraction, + formatAge, + formatDuration, + normalizeAccountUsage, + normalizeAccountUsageReport, + parseAllowanceInput, + toLocalTimeOfDay, + viewProviderCard, + viewProviderCards, + viewUsageBar, +} from "./logic/view-model"; +export { default as AccountUsageView } from "./ui/AccountUsageView.svelte"; +export { default as DailyCreditsSettings } from "./ui/DailyCreditsSettings.svelte"; +export { default as UsageBar } from "./ui/UsageBar.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "account-usage", + description: "Claude + Codex + Hyper subscription usage bars with on-track pace markers", +} as const; diff --git a/src/features/account-usage/logic/types.ts b/src/features/account-usage/logic/types.ts new file mode 100644 index 0000000..ff92590 --- /dev/null +++ b/src/features/account-usage/logic/types.ts @@ -0,0 +1,48 @@ +import type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, + UpdateAccountUsageSettingsRequest, +} from "@dispatch/transport-contract"; + +/** + * Pure core types for the account-usage feature — zero DOM, zero effects. + * + * The backend (`account-usage` extension, transport-contract 0.25.0) polls the + * Claude + Codex subscription usage endpoints and caches them (5 min TTL, + * failure cooldowns, last-good retention). The FE reads `GET /usage`; a manual + * refresh sends `?refresh=1`, which the server still rate-limits. + */ +export type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, + UpdateAccountUsageSettingsRequest, +}; + +/** Outcome of `GET /usage`. `unavailable` = the backend extension isn't loaded (503). */ +export type AccountUsageResult = + | { readonly ok: true; readonly usage: AccountUsageResponse } + | { readonly ok: false; readonly error: string; readonly unavailable?: boolean }; + +/** Injected port: load the account usage (`refresh` asks the server to bypass its TTL). */ +export type LoadAccountUsage = (opts?: { + readonly refresh?: boolean; +}) => Promise<AccountUsageResult>; + +/** Outcome of `PUT /usage/:provider/settings` — the provider's refreshed report. */ +export type AccountUsageSettingsResult = + | { readonly ok: true; readonly report: AccountUsageReport } + | { readonly ok: false; readonly error: string }; + +/** Injected port: update a provider's usage settings (today only `hyper`). */ +export type SaveAccountUsageSettings = ( + provider: AccountUsageProviderId, + settings: UpdateAccountUsageSettingsRequest, +) => Promise<AccountUsageSettingsResult>; diff --git a/src/features/account-usage/logic/view-model.test.ts b/src/features/account-usage/logic/view-model.test.ts new file mode 100644 index 0000000..78390cf --- /dev/null +++ b/src/features/account-usage/logic/view-model.test.ts @@ -0,0 +1,277 @@ +import type { AccountUsageLimit, AccountUsageReport } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + anchorFromTimeOfDay, + elapsedFraction, + formatDuration, + normalizeAccountUsage, + parseAllowanceInput, + toLocalTimeOfDay, + viewProviderCard, + viewUsageBar, +} from "./view-model"; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +const NOW = 1_760_000_000_000; + +function limit(over: Partial<AccountUsageLimit> = {}): AccountUsageLimit { + return { + id: "claude:5h", + label: "5 Hour", + kind: "window", + windowId: "5h", + shared: true, + tier: null, + durationMs: 5 * HOUR, + resetsAt: NOW + 2 * HOUR, + unit: "percent", + used: 40, + limit: 100, + remaining: 60, + usedFraction: 0.4, + remainingFraction: 0.6, + status: "ok", + ...over, + }; +} + +function report(over: Partial<AccountUsageReport> = {}): AccountUsageReport { + return { + provider: "claude", + label: "Claude", + state: "fresh", + account: { accountId: "a", email: "[email protected]", planType: "max" }, + limits: [limit()], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: NOW - 3 * 60_000, + lastAttemptAt: NOW - 3 * 60_000, + nextRefreshAt: NOW + 2 * 60_000, + error: null, + ...over, + }; +} + +describe("pace marker (elapsedFraction)", () => { + it("is halfway two weeks into a four-week window", () => { + const resetsAt = NOW + 14 * DAY; + expect(elapsedFraction(28 * DAY, resetsAt, NOW)).toBeCloseTo(0.5); + }); + + it("clamps to 0..1 and is null when the window is unknown", () => { + expect(elapsedFraction(5 * HOUR, NOW + 10 * HOUR, NOW)).toBe(0); + expect(elapsedFraction(5 * HOUR, NOW - HOUR, NOW)).toBe(1); + expect(elapsedFraction(null, NOW, NOW)).toBeNull(); + expect(elapsedFraction(5 * HOUR, null, NOW)).toBeNull(); + }); +}); + +describe("viewUsageBar", () => { + it("places the pace marker at elapsed % and labels ahead/under pace", () => { + // 5h window resetting in 2h ⇒ 3h elapsed ⇒ 60% pace; used 40% ⇒ 20% under. + const bar = viewUsageBar(limit(), NOW); + expect(bar.percent).toBe(40); + expect(bar.pacePercent).toBeCloseTo(60); + expect(bar.paceLabel).toBe("20% under pace"); + expect(bar.aheadOfPace).toBe(false); + expect(bar.resetLabel).toBe("resets in 2h"); + expect(bar.valueLabel).toBe("40%"); + expect(bar.tone).toBe("success"); + + const ahead = viewUsageBar(limit({ used: 80, usedFraction: 0.8 }), NOW); + expect(ahead.paceLabel).toBe("20% ahead of pace"); + expect(ahead.aheadOfPace).toBe(true); + expect(ahead.tone).toBe("warning"); + }); + + it("has no pace marker for spend meters; formats dollars", () => { + const spend = viewUsageBar( + limit({ + id: "claude:extra", + kind: "spend", + unit: "usd", + durationMs: null, + resetsAt: null, + used: 12.5, + limit: 50, + usedFraction: 0.25, + }), + NOW, + ); + expect(spend.pacePercent).toBeNull(); + expect(spend.valueLabel).toBe("$12.50 / $50.00"); + const uncapped = viewUsageBar( + limit({ + unit: "usd", + kind: "spend", + limit: null, + usedFraction: null, + used: 3, + durationMs: null, + }), + NOW, + ); + expect(uncapped.valueLabel).toBe("$3.00 (no cap)"); + expect(uncapped.percent).toBe(0); + }); + + it("exhausted → error tone", () => { + expect(viewUsageBar(limit({ used: 100, usedFraction: 1, status: "exhausted" }), NOW).tone).toBe( + "error", + ); + }); +}); + +describe("viewProviderCard", () => { + it("fresh card with subtitle and age", () => { + const c = viewProviderCard(report(), NOW); + expect(c.subtitle).toBe("Max · [email protected]"); + expect(c.stateBadge).toBeNull(); + expect(c.message).toBeNull(); + expect(c.ageLabel).toBe("3m ago"); + expect(c.bars).toHaveLength(1); + }); + + it("stale / not-configured / unsupported states", () => { + const stale = viewProviderCard(report({ state: "stale", error: "HTTP 429" }), NOW); + expect(stale.stateBadge).toBe("stale"); + expect(stale.message).toContain("HTTP 429"); + const nc = viewProviderCard( + report({ + provider: "codex", + label: "Codex", + state: "not-configured", + limits: [], + account: null, + }), + NOW, + ); + expect(nc.stateBadge).toBe("not configured"); + expect(nc.message).toContain("Codex"); + expect(nc.ageLabel).toBeNull(); + const un = viewProviderCard( + report({ state: "unsupported", error: "api key", limits: [] }), + NOW, + ); + expect(un.message).toBe("api key"); + }); + + it("credit overage note", () => { + const c = viewProviderCard( + report({ creditOverage: true, limitReached: true, canServe: true }), + NOW, + ); + expect(c.creditNote).toContain("credits"); + }); +}); + +describe("normalizeAccountUsage", () => { + it("coerces a well-formed body and drops malformed rows", () => { + const n = normalizeAccountUsage( + { + servedAt: 5, + providers: [ + { ...report(), limits: [limit(), { id: "bad" }] }, + { provider: "gemini" }, + "junk", + ], + }, + 0, + ); + expect(n.servedAt).toBe(5); + expect(n.providers).toHaveLength(1); + expect(n.providers[0]?.limits).toHaveLength(1); + }); + + it("malformed body → empty", () => { + expect(normalizeAccountUsage(null, 9)).toEqual({ providers: [], servedAt: 9 }); + }); +}); + +describe("formatDuration", () => { + it("formats", () => { + expect(formatDuration(30_000)).toBe("<1m"); + expect(formatDuration(45 * 60_000)).toBe("45m"); + expect(formatDuration(3 * HOUR + 12 * 60_000)).toBe("3h 12m"); + expect(formatDuration(2 * DAY + 4 * HOUR)).toBe("2d 4h"); + }); +}); + +describe("hyper daily credits", () => { + const hyperLimit = limit({ + id: "hyper:daily", + label: "Daily credits", + windowId: "1d", + unit: "credits", + durationMs: DAY, + resetsAt: NOW + 12 * HOUR, + used: 150, + limit: 250, + remaining: 100, + usedFraction: 0.6, + }); + + it("formats credits and places the pace marker halfway through the day", () => { + const bar = viewUsageBar(hyperLimit, NOW); + expect(bar.valueLabel).toBe("150 / 250"); + expect(bar.pacePercent).toBeCloseTo(50); + expect(bar.paceLabel).toBe("10% ahead of pace"); + }); + + it("card exposes balance + reset source", () => { + const c = viewProviderCard( + report({ + provider: "hyper", + label: "Hyper", + account: { accountId: null, email: null, planType: null }, + limits: [hyperLimit], + dailyCredits: { balance: 300, allowance: 250, resetAnchor: null, resetSource: null }, + }), + NOW, + ); + expect(c.daily).toMatchObject({ + balanceLabel: "300 credits left (incl. bundles)", + allowance: 250, + resetTime: "", + resetSource: null, + }); + expect(c.daily?.resetSourceLabel).toContain("unknown"); + }); + + it("round-trips a local time of day into a past anchor", () => { + const now = new Date(2026, 9, 14, 15, 30).getTime(); + const a = anchorFromTimeOfDay("09:00", now); + expect(a).toBe(new Date(2026, 9, 14, 9, 0).getTime()); + expect(anchorFromTimeOfDay("17:00", now)).toBe(new Date(2026, 9, 13, 17, 0).getTime()); + expect(toLocalTimeOfDay(a ?? 0)).toBe("09:00"); + expect(anchorFromTimeOfDay("25:00", now)).toBeNull(); + expect(anchorFromTimeOfDay("noon", now)).toBeNull(); + }); + + it("parses the allowance input", () => { + expect(parseAllowanceInput(" 250 ")).toBe(250); + expect(parseAllowanceInput("0")).toBeNull(); + expect(parseAllowanceInput("2.5")).toBeNull(); + }); + + it("normalizes hyper reports + credit units", () => { + const n = normalizeAccountUsage( + { + providers: [ + { + ...report({ provider: "hyper" }), + limits: [hyperLimit], + dailyCredits: { balance: 1, allowance: 250, resetAnchor: 5, resetSource: "manual" }, + }, + ], + }, + 0, + ); + expect(n.providers[0]?.limits[0]?.unit).toBe("credits"); + expect(n.providers[0]?.dailyCredits?.resetSource).toBe("manual"); + }); +}); diff --git a/src/features/account-usage/logic/view-model.ts b/src/features/account-usage/logic/view-model.ts new file mode 100644 index 0000000..17d37a6 --- /dev/null +++ b/src/features/account-usage/logic/view-model.ts @@ -0,0 +1,403 @@ +import type { + AccountUsageCredits, + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageLimitStatus, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, +} from "@dispatch/transport-contract"; + +/** + * Pure view-models for the account-usage feature — zero DOM, zero effects, + * zero Svelte. Coerces the untyped `GET /usage` JSON, and shapes each limit + * into a bar: fill %, colour, and the PACE MARKER — where usage "should" be + * to use the whole allowance exactly by the reset (elapsed fraction of the + * window: 2 weeks into a 4-week window ⇒ 50%). + */ + +// ── Normalization (network seam) ────────────────────────────────────────────── + +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === "object" && v !== null && !Array.isArray(v); +} +function num(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} +function str(v: unknown): string | null { + return typeof v === "string" ? v : null; +} +function bool(v: unknown): boolean | null { + return typeof v === "boolean" ? v : null; +} + +const STATES: readonly AccountUsageState[] = [ + "fresh", + "stale", + "unavailable", + "unsupported", + "not-configured", +]; +const STATUSES: readonly AccountUsageLimitStatus[] = ["ok", "warning", "exhausted", "unknown"]; + +function normalizeLimit(r: Record<string, unknown>): AccountUsageLimit | null { + const id = str(r.id); + const used = num(r.used); + if (id === null || used === null) return null; + const status = STATUSES.find((s) => s === r.status) ?? "unknown"; + return { + id, + label: str(r.label) ?? id, + kind: r.kind === "spend" ? "spend" : "window", + windowId: str(r.windowId) ?? "", + shared: r.shared === true, + tier: str(r.tier), + durationMs: num(r.durationMs), + resetsAt: num(r.resetsAt), + unit: r.unit === "usd" ? "usd" : r.unit === "credits" ? "credits" : "percent", + used, + limit: num(r.limit), + remaining: num(r.remaining), + usedFraction: num(r.usedFraction), + remainingFraction: num(r.remainingFraction), + status, + }; +} + +function normalizeCredits(v: unknown): AccountUsageCredits | null { + if (!isRecord(v)) return null; + return { + hasCredits: bool(v.hasCredits), + unlimited: bool(v.unlimited), + overageLimitReached: bool(v.overageLimitReached), + balance: num(v.balance), + spendControlReached: bool(v.spendControlReached), + }; +} + +function normalizeDailyCredits(v: unknown): AccountUsageDailyCredits | null { + if (!isRecord(v)) return null; + const balance = num(v.balance); + const allowance = num(v.allowance); + if (balance === null || allowance === null) return null; + return { + balance, + allowance, + resetAnchor: num(v.resetAnchor), + resetSource: v.resetSource === "manual" || v.resetSource === "detected" ? v.resetSource : null, + }; +} + +const PROVIDER_LABELS: Record<AccountUsageProviderId, string> = { + claude: "Claude", + codex: "Codex", + hyper: "Hyper", +}; + +function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null { + const provider: AccountUsageProviderId | null = + r.provider === "claude" || r.provider === "codex" || r.provider === "hyper" ? r.provider : null; + if (provider === null) return null; + const account = isRecord(r.account) + ? { + accountId: str(r.account.accountId), + email: str(r.account.email), + planType: str(r.account.planType), + } + : null; + const limits = Array.isArray(r.limits) + ? r.limits + .filter(isRecord) + .map(normalizeLimit) + .filter((l): l is AccountUsageLimit => l !== null) + : []; + return { + provider, + label: str(r.label) ?? PROVIDER_LABELS[provider], + state: STATES.find((s) => s === r.state) ?? "unavailable", + account, + limits, + limitReached: bool(r.limitReached), + canServe: bool(r.canServe), + creditOverage: r.creditOverage === true, + credits: normalizeCredits(r.credits), + dailyCredits: normalizeDailyCredits(r.dailyCredits), + fetchedAt: num(r.fetchedAt), + lastAttemptAt: num(r.lastAttemptAt), + nextRefreshAt: num(r.nextRefreshAt), + error: str(r.error), + }; +} + +/** Coerce one untyped report (e.g. the `PUT /usage/:provider/settings` body); null if malformed. */ +export function normalizeAccountUsageReport(data: unknown): AccountUsageReport | null { + return isRecord(data) ? normalizeReport(data) : null; +} + +/** Coerce the untyped `GET /usage` body. Malformed → empty provider list. */ +export function normalizeAccountUsage(data: unknown, fallbackNow: number): AccountUsageResponse { + if (!isRecord(data)) return { providers: [], servedAt: fallbackNow }; + const providers = Array.isArray(data.providers) + ? data.providers + .filter(isRecord) + .map(normalizeReport) + .filter((p): p is AccountUsageReport => p !== null) + : []; + return { providers, servedAt: num(data.servedAt) ?? fallbackNow }; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +/** "2d 4h" / "3h 12m" / "45m" / "<1m". */ +export function formatDuration(ms: number): string { + if (ms < 60_000) return "<1m"; + const totalMin = Math.floor(ms / 60_000); + const d = Math.floor(totalMin / 1440); + const h = Math.floor((totalMin % 1440) / 60); + const m = totalMin % 60; + if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`; + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** "updated 3m ago" style age, or "just now". */ +export function formatAge(fetchedAt: number | null, now: number): string | null { + if (fetchedAt === null) return null; + const ms = now - fetchedAt; + return ms < 60_000 ? "just now" : `${formatDuration(ms)} ago`; +} + +function formatUsd(v: number): string { + return `$${v.toFixed(2)}`; +} + +/** Whole credits as-is, fractional ones to 1 decimal ("150", "12.5"). */ +function formatCredits(v: number): string { + return Number.isInteger(v) ? String(v) : v.toFixed(1); +} + +// ── Bars ────────────────────────────────────────────────────────────────────── + +export type BarTone = "success" | "warning" | "error" | "neutral"; + +export interface UsageBarView { + readonly id: string; + readonly label: string; + /** Fill 0..100 for the `<progress>` value. */ + readonly percent: number; + /** "42%" or "$12.50 / $50.00". */ + readonly valueLabel: string; + readonly tone: BarTone; + /** + * Pace marker position 0..100 (% of the window elapsed) — the usage level + * that would be "on track" to exactly use the allowance by the reset. Null + * when the window's start can't be determined (no duration/reset). + */ + readonly pacePercent: number | null; + /** "+12% ahead of pace" / "8% under pace" / null. */ + readonly paceLabel: string | null; + /** True when usage is ahead of the pace marker. */ + readonly aheadOfPace: boolean; + /** "resets in 3h 12m" / null. */ + readonly resetLabel: string | null; +} + +/** + * Fraction (0..1) of the window elapsed at `now`. The window started at + * `resetsAt - durationMs`; returns null when either is unknown. + */ +export function elapsedFraction( + durationMs: number | null, + resetsAt: number | null, + now: number, +): number | null { + if (durationMs === null || resetsAt === null || durationMs <= 0) return null; + const start = resetsAt - durationMs; + return Math.min(1, Math.max(0, (now - start) / durationMs)); +} + +function toneFor(status: AccountUsageLimitStatus, usedFraction: number | null): BarTone { + if (status === "exhausted") return "error"; + if (status === "warning") return "warning"; + if (status === "ok") return usedFraction !== null && usedFraction >= 0.75 ? "warning" : "success"; + return "neutral"; +} + +export function viewUsageBar(limit: AccountUsageLimit, now: number): UsageBarView { + const fraction = limit.usedFraction; + const percent = fraction === null ? 0 : Math.min(100, Math.max(0, fraction * 100)); + let valueLabel: string; + if (limit.unit === "usd") { + valueLabel = + limit.limit === null + ? `${formatUsd(limit.used)} (no cap)` + : `${formatUsd(limit.used)} / ${formatUsd(limit.limit)}`; + } else if (limit.unit === "credits") { + valueLabel = + limit.limit === null + ? `${formatCredits(limit.used)} used` + : `${formatCredits(limit.used)} / ${formatCredits(limit.limit)}`; + } else { + valueLabel = `${Math.round(limit.used)}%`; + } + const elapsed = elapsedFraction(limit.durationMs, limit.resetsAt, now); + const pacePercent = elapsed === null ? null : elapsed * 100; + let paceLabel: string | null = null; + let aheadOfPace = false; + if (pacePercent !== null && fraction !== null) { + const diff = Math.round(percent - pacePercent); + aheadOfPace = diff > 0; + paceLabel = + diff === 0 ? "on pace" : diff > 0 ? `${diff}% ahead of pace` : `${-diff}% under pace`; + } + const resetLabel = + limit.resetsAt === null + ? null + : limit.resetsAt <= now + ? "resetting" + : `resets in ${formatDuration(limit.resetsAt - now)}`; + return { + id: limit.id, + label: limit.label, + percent, + valueLabel, + tone: toneFor(limit.status, fraction), + pacePercent, + paceLabel, + aheadOfPace, + resetLabel, + }; +} + +// ── Provider cards ──────────────────────────────────────────────────────────── + +export interface ProviderCardView { + readonly provider: AccountUsageProviderId; + readonly label: string; + /** "Pro · [email protected]" / null. */ + readonly subtitle: string | null; + readonly state: AccountUsageState; + /** Badge text for non-fresh states ("stale", "unavailable", …) or null when fresh. */ + readonly stateBadge: string | null; + /** Explanatory line for empty/non-fresh states, else null. */ + readonly message: string | null; + readonly bars: readonly UsageBarView[]; + /** "updated 3m ago" / null. */ + readonly ageLabel: string | null; + /** Credit-overage note (Codex), or null. */ + readonly creditNote: string | null; + /** Daily-credit details + editable settings (Hyper), or null. */ + readonly daily: DailyCreditsView | null; +} + +export interface DailyCreditsView { + /** "100 credits left" (+ " (incl. bundles)" when the balance exceeds the allowance). */ + readonly balanceLabel: string; + readonly allowance: number; + /** The reset anchor as a local `HH:MM` (for a time input), or "" when unknown. */ + readonly resetTime: string; + /** "set manually" / "detected from a refill (approx.)" / "unknown — set it to enable the pace marker". */ + readonly resetSourceLabel: string; + readonly resetSource: "manual" | "detected" | null; +} + +/** Local `HH:MM` of an epoch-ms instant (time-of-day only; resets recur daily). */ +export function toLocalTimeOfDay(epochMs: number): string { + const d = new Date(epochMs); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +/** + * Parse a local `HH:MM` into a reset anchor: the most recent occurrence of that + * local time at or before `now` (any occurrence works — resets recur daily). + * Null for malformed input. + */ +export function anchorFromTimeOfDay(hhmm: string, now: number): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim()); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (h > 23 || min > 59) return null; + const d = new Date(now); + d.setHours(h, min, 0, 0); + if (d.getTime() > now) d.setDate(d.getDate() - 1); + return d.getTime(); +} + +/** Parse a positive-integer allowance input; null when invalid. */ +export function parseAllowanceInput(raw: string): number | null { + const t = raw.trim(); + if (!/^\d+$/.test(t)) return null; + const n = Number(t); + return n > 0 && n <= 1_000_000_000 ? n : null; +} + +function viewDailyCredits(d: AccountUsageDailyCredits): DailyCreditsView { + return { + balanceLabel: `${formatCredits(d.balance)} credits left${d.balance > d.allowance ? " (incl. bundles)" : ""}`, + allowance: d.allowance, + resetTime: d.resetAnchor === null ? "" : toLocalTimeOfDay(d.resetAnchor), + resetSourceLabel: + d.resetSource === "manual" + ? "set manually" + : d.resetSource === "detected" + ? "detected from a refill (approx.)" + : "unknown — set it to enable the pace marker", + resetSource: d.resetSource, + }; +} + +function stateMessage(r: AccountUsageReport): string | null { + switch (r.state) { + case "fresh": + return r.limits.length === 0 ? "No quota windows reported." : null; + case "stale": + return `Showing last known usage — refresh failed${r.error ? ` (${r.error})` : ""}.`; + case "unavailable": + return `Usage unavailable${r.error ? `: ${r.error}` : "."}`; + case "unsupported": + return r.error ?? "This credential type can't report subscription usage."; + case "not-configured": + if (r.provider === "hyper") return "No Hyper API key configured (HYPER_API_KEY)."; + return r.provider === "claude" + ? "Not signed in to Claude (no OAuth credentials)." + : "Not signed in to Codex (no ChatGPT OAuth credentials)."; + } +} + +function capitalize(s: string): string { + return s.length === 0 ? s : s[0]?.toUpperCase() + s.slice(1); +} + +export function viewProviderCard(r: AccountUsageReport, now: number): ProviderCardView { + const parts = [ + r.account?.planType ? capitalize(r.account.planType) : null, + r.account?.email ?? null, + ].filter((p): p is string => p !== null && p !== ""); + let creditNote: string | null = null; + if (r.creditOverage) { + creditNote = "Plan allowance used — requests are being funded by credits."; + } else if (r.limitReached === true && r.canServe === false) { + creditNote = "Plan limit reached — requests are blocked until reset."; + } + return { + provider: r.provider, + label: r.label, + subtitle: parts.length > 0 ? parts.join(" · ") : null, + state: r.state, + stateBadge: r.state === "fresh" ? null : r.state.replace("-", " "), + message: stateMessage(r), + bars: r.limits.map((l) => viewUsageBar(l, now)), + ageLabel: r.state === "fresh" || r.state === "stale" ? formatAge(r.fetchedAt, now) : null, + creditNote, + daily: r.dailyCredits ? viewDailyCredits(r.dailyCredits) : null, + }; +} + +export function viewProviderCards( + usage: AccountUsageResponse, + now: number, +): readonly ProviderCardView[] { + return usage.providers.map((p) => viewProviderCard(p, now)); +} diff --git a/src/features/account-usage/ui/AccountUsageView.svelte b/src/features/account-usage/ui/AccountUsageView.svelte new file mode 100644 index 0000000..2a7a589 --- /dev/null +++ b/src/features/account-usage/ui/AccountUsageView.svelte @@ -0,0 +1,155 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { + AccountUsageReport, + AccountUsageResponse, + LoadAccountUsage, + SaveAccountUsageSettings, + } from "../logic/types"; + import { viewProviderCards } from "../logic/view-model"; + import DailyCreditsSettings from "./DailyCreditsSettings.svelte"; + import UsageBar from "./UsageBar.svelte"; + + let { + load, + saveSettings, + now = () => Date.now(), + pollMs = 60_000, + }: { + load: LoadAccountUsage; + /** Save per-provider settings (Hyper allowance + reset time). */ + saveSettings: SaveAccountUsageSettings; + /** Injected clock (tests). */ + now?: () => number; + /** How often to re-read `GET /usage` (served from the backend cache). */ + pollMs?: number; + } = $props(); + + let usage = $state<AccountUsageResponse | null>(null); + let error = $state<string | null>(null); + let refreshing = $state(false); + let inFlight = false; + /** Ticks so the pace marker + "resets in" labels advance between polls. */ + let clock = $state(untrack(() => now())); + + const cards = $derived(usage ? viewProviderCards(usage, clock) : []); + + async function refresh(force: boolean): Promise<void> { + if (inFlight) return; + inFlight = true; + if (force) refreshing = true; + const result = await load(force ? { refresh: true } : undefined); + inFlight = false; + refreshing = false; + clock = now(); + if (result.ok) { + usage = result.usage; + error = null; + } else { + error = result.unavailable + ? "Account usage is unavailable (backend extension not loaded)." + : result.error; + } + } + + /** Splice a provider's refreshed report (from a settings save) into the view. */ + function replaceReport(report: AccountUsageReport): void { + clock = now(); + if (!usage) return; + usage = { + ...usage, + providers: usage.providers.map((p) => (p.provider === report.provider ? report : p)), + }; + } + + $effect(() => { + const interval = untrack(() => pollMs); + untrack(() => void refresh(false)); + const poll = setInterval(() => void refresh(false), interval); + const tick = setInterval(() => { + clock = now(); + }, 30_000); + return () => { + clearInterval(poll); + clearInterval(tick); + }; + }); +</script> + +<div class="flex flex-col gap-3"> + <div class="flex items-center justify-between gap-2"> + <h3 class="text-xs font-semibold uppercase opacity-60">Subscription usage</h3> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => void refresh(true)} + disabled={refreshing} + aria-label="Refresh account usage" + > + {#if refreshing}<span class="loading loading-spinner loading-xs"></span>{/if} + Refresh + </button> + </div> + + {#if error} + <p class="text-xs text-error">{error}</p> + {/if} + + {#if usage === null && error === null} + <p class="text-xs opacity-60">Loading…</p> + {/if} + + {#each cards as card (card.provider)} + <section class="flex flex-col gap-2 rounded-box bg-base-200 p-2" aria-label="{card.label} usage"> + <div class="flex items-start justify-between gap-2"> + <div class="flex min-w-0 flex-col"> + <span class="text-sm font-semibold">{card.label}</span> + {#if card.subtitle} + <span class="truncate text-[11px] opacity-60">{card.subtitle}</span> + {/if} + </div> + <div class="flex shrink-0 flex-col items-end gap-0.5"> + {#if card.stateBadge} + <span + class="badge badge-xs" + class:badge-warning={card.state === "stale"} + class:badge-error={card.state === "unavailable"} + class:badge-ghost={card.state === "unsupported" || card.state === "not-configured"} + >{card.stateBadge}</span + > + {/if} + {#if card.ageLabel} + <span class="text-[11px] opacity-50">updated {card.ageLabel}</span> + {/if} + </div> + </div> + + {#if card.message} + <p class="text-xs opacity-70">{card.message}</p> + {/if} + {#if card.creditNote} + <p class="text-xs text-warning">{card.creditNote}</p> + {/if} + + {#each card.bars as bar (bar.id)} + <UsageBar {bar} /> + {/each} + + {#if card.daily} + <DailyCreditsSettings + daily={card.daily} + save={saveSettings} + onSaved={replaceReport} + {now} + /> + {/if} + </section> + {/each} + + {#if cards.some((c) => c.bars.some((b) => b.pacePercent !== null))} + <p class="flex items-center gap-1.5 text-[11px] opacity-50"> + <span class="inline-block size-2 rounded-full bg-base-content"></span> + on-track pace — usage that would exactly use the allowance by its reset + </p> + {/if} +</div> diff --git a/src/features/account-usage/ui/AccountUsageView.test.ts b/src/features/account-usage/ui/AccountUsageView.test.ts new file mode 100644 index 0000000..ec1e51b --- /dev/null +++ b/src/features/account-usage/ui/AccountUsageView.test.ts @@ -0,0 +1,184 @@ +import type { AccountUsageResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { + AccountUsageResult, + AccountUsageSettingsResult, + SaveAccountUsageSettings, +} from "../logic/types"; +import AccountUsageView from "./AccountUsageView.svelte"; + +const NOW = 1_760_000_000_000; + +const noSave: SaveAccountUsageSettings = async () => ({ ok: false, error: "unused" }); +const DAY = 86_400_000; + +const usage: AccountUsageResponse = { + servedAt: NOW, + providers: [ + { + provider: "claude", + label: "Claude", + state: "fresh", + account: { accountId: "a", email: "[email protected]", planType: "max" }, + limits: [ + { + id: "claude:7d", + label: "7 Day", + kind: "window", + windowId: "7d", + shared: true, + tier: null, + durationMs: 28 * DAY, + resetsAt: NOW + 14 * DAY, + unit: "percent", + used: 30, + limit: 100, + remaining: 70, + usedFraction: 0.3, + remainingFraction: 0.7, + status: "ok", + }, + ], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: NOW, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 300_000, + error: null, + }, + { + provider: "codex", + label: "Codex", + state: "not-configured", + account: null, + limits: [], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: null, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 30_000, + error: "No Codex OAuth credentials", + }, + ], +}; + +describe("AccountUsageView", () => { + it("renders a daisyUI progress bar with a pace marker at the elapsed position", async () => { + const calls: Array<{ refresh?: boolean } | undefined> = []; + const load = async (opts?: { refresh?: boolean }): Promise<AccountUsageResult> => { + calls.push(opts); + return { ok: true, usage }; + }; + render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW }); + const bar = await screen.findByLabelText("7 Day usage"); + expect(bar.tagName).toBe("PROGRESS"); + expect(bar.classList.contains("progress")).toBe(true); + expect(bar.getAttribute("value")).toBe("30"); + const marker = screen.getByTestId("pace-marker"); + expect(marker.getAttribute("style")).toContain("left: 50%"); + expect(screen.getByText("20% under pace")).toBeTruthy(); + expect(screen.getByText(/Not signed in to Codex/)).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "Refresh account usage" })); + expect(calls.at(-1)).toEqual({ refresh: true }); + }); + + it("shows the unavailable message on 503", async () => { + const load = async (): Promise<AccountUsageResult> => ({ + ok: false, + error: "x", + unavailable: true, + }); + render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW }); + expect(await screen.findByText(/backend extension not loaded/)).toBeTruthy(); + }); + + it("edits the Hyper allowance + reset time and splices the saved report", async () => { + const hyper = { + provider: "hyper" as const, + label: "Hyper", + state: "fresh" as const, + account: { accountId: null, email: null, planType: null }, + limits: [ + { + id: "hyper:daily", + label: "Daily credits", + kind: "window" as const, + windowId: "1d", + shared: true, + tier: null, + durationMs: DAY, + resetsAt: null, + unit: "credits" as const, + used: 150, + limit: 250, + remaining: 100, + usedFraction: 0.6, + remainingFraction: 0.4, + status: "ok" as const, + }, + ], + limitReached: null, + canServe: true, + creditOverage: false, + credits: null, + dailyCredits: { balance: 100, allowance: 250, resetAnchor: null, resetSource: null }, + fetchedAt: NOW, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 300_000, + error: null, + }; + const load = async (): Promise<AccountUsageResult> => ({ + ok: true, + usage: { servedAt: NOW, providers: [hyper] }, + }); + const saved: Array<Parameters<SaveAccountUsageSettings>[1]> = []; + const saveSettings: SaveAccountUsageSettings = async (_p, settings) => { + saved.push(settings); + const r: AccountUsageSettingsResult = { + ok: true, + report: { + ...hyper, + limits: [ + { + ...(hyper.limits[0] as (typeof hyper.limits)[0]), + limit: 300, + resetsAt: NOW + DAY / 2, + }, + ], + dailyCredits: { + balance: 100, + allowance: 300, + resetAnchor: NOW - DAY / 2, + resetSource: "manual", + }, + }, + }; + return r; + }; + render(AccountUsageView, { load, saveSettings, now: () => NOW }); + expect(await screen.findByText("150 / 250")).toBeTruthy(); + expect(screen.queryByTestId("pace-marker")).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "Edit Hyper credit settings" })); + const allowance = screen.getByLabelText("Daily credit allowance"); + await userEvent.clear(allowance); + await userEvent.type(allowance, "300"); + const time = screen.getByLabelText("Daily credit reset time") as HTMLInputElement; + await userEvent.type(time, "09:00"); + await userEvent.click(screen.getByRole("button", { name: "Save Hyper credit settings" })); + + expect(saved[0]?.dailyAllowance).toBe(300); + expect(typeof saved[0]?.resetAnchor).toBe("number"); + expect(await screen.findByText(/set manually/)).toBeTruthy(); + expect(screen.getByTestId("pace-marker")).toBeTruthy(); + }); +}); diff --git a/src/features/account-usage/ui/DailyCreditsSettings.svelte b/src/features/account-usage/ui/DailyCreditsSettings.svelte new file mode 100644 index 0000000..7ed710a --- /dev/null +++ b/src/features/account-usage/ui/DailyCreditsSettings.svelte @@ -0,0 +1,126 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { AccountUsageReport, SaveAccountUsageSettings } from "../logic/types"; + import { + anchorFromTimeOfDay, + type DailyCreditsView, + parseAllowanceInput, + } from "../logic/view-model"; + + let { + daily, + save, + onSaved, + now = () => Date.now(), + }: { + daily: DailyCreditsView; + save: SaveAccountUsageSettings; + /** Receives the refreshed report after a successful save. */ + onSaved: (report: AccountUsageReport) => void; + now?: () => number; + } = $props(); + + let editing = $state(false); + let allowanceInput = $state(""); + let timeInput = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + + const parsedAllowance = $derived(parseAllowanceInput(allowanceInput)); + const timeValid = $derived(timeInput === "" || anchorFromTimeOfDay(timeInput, now()) !== null); + + function startEdit(): void { + untrack(() => { + allowanceInput = String(daily.allowance); + timeInput = daily.resetTime; + }); + error = null; + editing = true; + } + + async function submit(): Promise<void> { + if (parsedAllowance === null || !timeValid) return; + saving = true; + error = null; + // Empty time clears a MANUAL anchor (a detected one may still apply). + const resetAnchor = timeInput === "" ? null : anchorFromTimeOfDay(timeInput, now()); + const result = await save("hyper", { dailyAllowance: parsedAllowance, resetAnchor }); + saving = false; + if (result.ok) { + editing = false; + onSaved(result.report); + } else { + error = result.error; + } + } +</script> + +<div class="flex flex-col gap-1 text-[11px]"> + <div class="flex items-center justify-between gap-2"> + <span class="opacity-70">{daily.balanceLabel}</span> + {#if !editing} + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={startEdit} + aria-label="Edit Hyper credit settings">Edit</button + > + {/if} + </div> + {#if !editing} + <span class="opacity-60"> + Daily allowance {daily.allowance} · resets {daily.resetTime || "—"} ({daily.resetSourceLabel}) + </span> + {:else} + <form + class="flex flex-col gap-1" + onsubmit={(e) => { + e.preventDefault(); + void submit(); + }} + > + <label class="flex items-center justify-between gap-2"> + <span>Daily allowance</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + class:input-error={parsedAllowance === null} + aria-label="Daily credit allowance" + bind:value={allowanceInput} + disabled={saving} + /> + </label> + <label class="flex items-center justify-between gap-2"> + <span>Reset time (local)</span> + <input + type="time" + class="input input-bordered input-xs w-24 font-mono" + class:input-error={!timeValid} + aria-label="Daily credit reset time" + bind:value={timeInput} + disabled={saving} + /> + </label> + <span class="opacity-50">Leave the time empty to use the auto-detected refill time.</span> + {#if error}<span class="text-error">{error}</span>{/if} + <div class="flex justify-end gap-1"> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => (editing = false)} + disabled={saving}>Cancel</button + > + <button + type="submit" + class="btn btn-primary btn-xs" + disabled={saving || parsedAllowance === null || !timeValid} + aria-label="Save Hyper credit settings" + > + {#if saving}<span class="loading loading-spinner loading-xs"></span>{/if} + Save + </button> + </div> + </form> + {/if} +</div> diff --git a/src/features/account-usage/ui/UsageBar.svelte b/src/features/account-usage/ui/UsageBar.svelte new file mode 100644 index 0000000..f4298c1 --- /dev/null +++ b/src/features/account-usage/ui/UsageBar.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import type { UsageBarView } from "../logic/view-model"; + + let { bar }: { bar: UsageBarView } = $props(); + + const progressClass = $derived( + { + success: "progress-success", + warning: "progress-warning", + error: "progress-error", + neutral: "", + }[bar.tone], + ); +</script> + +<div class="flex flex-col gap-0.5" data-testid="usage-bar" data-limit-id={bar.id}> + <div class="flex items-baseline justify-between gap-2 text-xs"> + <span class="truncate font-medium">{bar.label}</span> + <span class="shrink-0 font-mono tabular-nums">{bar.valueLabel}</span> + </div> + <div class="relative flex items-center"> + <progress + class="progress h-2 w-full {progressClass}" + value={bar.percent} + max="100" + aria-label="{bar.label} usage" + ></progress> + {#if bar.pacePercent !== null} + <!-- Pace marker: where usage would be if spread evenly to the reset. --> + <span + class="pointer-events-none absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-base-100 bg-base-content" + style="left: {bar.pacePercent}%" + data-testid="pace-marker" + title="On-track pace: {Math.round(bar.pacePercent)}%" + aria-hidden="true" + ></span> + {/if} + </div> + {#if bar.resetLabel || bar.paceLabel} + <div class="flex justify-between gap-2 text-[11px] opacity-60"> + <span>{bar.resetLabel ?? ""}</span> + {#if bar.paceLabel} + <span class:text-warning={bar.aheadOfPace} class:opacity-100={bar.aheadOfPace} + >{bar.paceLabel}</span + > + {/if} + </div> + {/if} +</div> diff --git a/src/features/chat/adapter/image-encode.test.ts b/src/features/chat/adapter/image-encode.test.ts new file mode 100644 index 0000000..dbaa8ae --- /dev/null +++ b/src/features/chat/adapter/image-encode.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { convertToJpeg } from "./image-encode"; + +describe("convertToJpeg (canvas edge — real environment, no mocks)", () => { + it("skips non-convertible formats without touching the canvas", async () => { + const jpeg = new File([new Uint8Array([0xff, 0xd8])], "photo.jpg", { type: "image/jpeg" }); + await expect(convertToJpeg(jpeg)).resolves.toBe(null); + const gif = new File([new Uint8Array([0x47])], "anim.gif", { type: "image/gif" }); + await expect(convertToJpeg(gif)).resolves.toBe(null); + const unnamed = new File([new Uint8Array([0x00])], "blob", { type: "" }); + await expect(convertToJpeg(unnamed)).resolves.toBe(null); + }); + + it("falls back to null on decode failure (no real canvas in this environment)", async () => { + const png = new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }); + // jsdom has no `createImageBitmap` → the decode throws → the failure path + // must resolve to `null` (never reject), so sending is never blocked. + await expect(convertToJpeg(png)).resolves.toBe(null); + }); +}); diff --git a/src/features/chat/adapter/image-encode.ts b/src/features/chat/adapter/image-encode.ts new file mode 100644 index 0000000..4570a5a --- /dev/null +++ b/src/features/chat/adapter/image-encode.ts @@ -0,0 +1,67 @@ +import { + computeResizedDimensions, + JPEG_QUALITY, + shouldConvertImage, + useConvertedImage, +} from "../logic/image-compress"; + +/** A successfully converted image: a JPEG blob at `JPEG_QUALITY`. */ +export interface ConvertedJpeg { + readonly blob: Blob; + readonly mimeType: "image/jpeg"; +} + +/** + * The injected browser edge for attach-time compression: a `File` in, a + * (maybe) smaller JPEG out. Returns `null` when the source is not converted + * (non-convertible format) or the conversion is skipped/failed (decode or + * encode error, smaller-wins rule) — the caller then sends the original + * bytes. Compression must never block or break sending, so every failure is + * swallowed into `null`. + */ +export type ImageJpegEncoder = (file: File) => Promise<ConvertedJpeg | null>; + +/** + * Canvas-based JPEG re-encode: decode the source, rescale to cap the longest + * edge at `MAX_IMAGE_EDGE_PX` (never upscale, high-quality smoothing), flatten + * any transparency onto a white background (JPEG has no alpha), and encode at + * `JPEG_QUALITY`. Applied only to convertible (lossless) sources; the result + * is used only under the smaller-wins rule. + * + * Thin by design — every decision comes from `../logic/image-compress`. + */ +export const convertToJpeg: ImageJpegEncoder = async (file: File) => { + try { + if (!shouldConvertImage(file.type)) return null; + + const bitmap = await createImageBitmap(file); + const target = computeResizedDimensions(bitmap.width, bitmap.height); + + const canvas = document.createElement("canvas"); + canvas.width = target.width; + canvas.height = target.height; + const ctx = canvas.getContext("2d"); + if (ctx === null) { + bitmap.close(); + return null; + } + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + // Flatten transparency: JPEG has no alpha channel. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, target.width, target.height); + ctx.drawImage(bitmap, 0, 0, target.width, target.height); + bitmap.close(); + + const blob = await new Promise<Blob | null>((resolve) => { + canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY); + }); + if (blob === null) return null; + // Smaller-wins: keep the original when the re-encode didn't actually help. + if (!useConvertedImage(file.size, blob.size)) return null; + return { blob, mimeType: "image/jpeg" }; + } catch { + // Decode/encode failure → the original image is sent untouched. + return null; + } +}; 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 cf57cea..41cb06f 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -2,23 +2,50 @@ export type { ProviderRetryView, RenderedChunk, RenderGroup, - ToolBatchEntry, + ToolEntry, } from "../../core/chunks"; export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; -export { isVisionModel } from "./model-select"; +export { createPendingInputStorage } from "./adapter/pending-inputs"; +export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select"; +export { + CLAUDE_SUBSCRIPTION_KEY, + isClaudeSubscriptionModel, + isNamedAgentSelection, + isNumberedOpenCode, + isVisionModel, + migrateLegacyModelName, + modelKeyLabel, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, + openCodeNumber, + providerKeysWithNamedAgents, +} from "./model-select"; +export type { PendingInput, PendingInputStorage } from "./pending-inputs"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { EffortOption, ReasoningEffortSaveResult, SaveReasoningEffort, + SaveThinkingSelection, + SelectionOption, + SetThinkingRequest, + ThinkingResponse, + ThinkingSaveResult, + ThinkingSelection, + ThinkingSelectionSaveResult, } from "./reasoning-effort"; export { DEFAULT_REASONING_EFFORT, effectiveEffort, + effectiveSelection, effortOptions, isReasoningEffort, + isThinkingSelection, REASONING_EFFORT_LEVELS, + selectionOptions, } from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; @@ -28,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/image-compress.test.ts b/src/features/chat/logic/image-compress.test.ts new file mode 100644 index 0000000..88726bf --- /dev/null +++ b/src/features/chat/logic/image-compress.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + computeResizedDimensions, + MAX_IMAGE_EDGE_PX, + shouldConvertImage, + useConvertedImage, +} from "./image-compress"; + +describe("shouldConvertImage", () => { + it("converts lossless sources (PNG, BMP)", () => { + expect(shouldConvertImage("image/png")).toBe(true); + expect(shouldConvertImage("image/bmp")).toBe(true); + }); + + it("skips already-lossy and animated formats", () => { + expect(shouldConvertImage("image/jpeg")).toBe(false); + expect(shouldConvertImage("image/webp")).toBe(false); + expect(shouldConvertImage("image/gif")).toBe(false); + }); + + it("leaves unknown or absent MIME types alone", () => { + expect(shouldConvertImage(undefined)).toBe(false); + expect(shouldConvertImage("")).toBe(false); + expect(shouldConvertImage("image/tiff")).toBe(false); + expect(shouldConvertImage("image/svg+xml")).toBe(false); + }); +}); + +describe("computeResizedDimensions", () => { + it("never upscales a source already within the cap", () => { + expect(computeResizedDimensions(100, 50)).toEqual({ width: 100, height: 50 }); + expect(computeResizedDimensions(2048, 1024)).toEqual({ width: 2048, height: 1024 }); + expect(computeResizedDimensions(640, 640)).toEqual({ width: 640, height: 640 }); + }); + + it("caps the longest edge at 2048", () => { + expect(computeResizedDimensions(4096, 2048)).toEqual({ width: 2048, height: 1024 }); + expect(computeResizedDimensions(8192, 4096)).toEqual({ width: 2048, height: 1024 }); + }); + + it("caps the longest edge regardless of orientation (portrait)", () => { + expect(computeResizedDimensions(2000, 3000)).toEqual({ width: 1365, height: 2048 }); + }); + + it("preserves aspect ratio on the rescale", () => { + const { width, height } = computeResizedDimensions(3000, 2000); + expect(width).toBe(2048); + // 2000 * (2048/3000) = 1365.33… → 1365 + expect(height).toBe(1365); + expect(width / height).toBeCloseTo(3000 / 2000, 2); + }); + + it("handles extreme aspect ratios without exceeding the cap", () => { + expect(computeResizedDimensions(5000, 10)).toEqual({ width: 2048, height: 4 }); + expect(computeResizedDimensions(10, 5000)).toEqual({ width: 4, height: 2048 }); + }); + + it("honors a custom cap", () => { + expect(computeResizedDimensions(1000, 500, 250)).toEqual({ width: 250, height: 125 }); + }); + + it("uses the default 2048px cap constant", () => { + expect(MAX_IMAGE_EDGE_PX).toBe(2048); + }); +}); + +describe("useConvertedImage", () => { + it("uses the conversion only when it is strictly fewer bytes", () => { + expect(useConvertedImage(3_000_000, 300_000)).toBe(true); + }); + + it("keeps the original when equal or larger", () => { + expect(useConvertedImage(300_000, 300_000)).toBe(false); + expect(useConvertedImage(100_000, 300_000)).toBe(false); + }); +}); diff --git a/src/features/chat/logic/image-compress.ts b/src/features/chat/logic/image-compress.ts new file mode 100644 index 0000000..a9037b8 --- /dev/null +++ b/src/features/chat/logic/image-compress.ts @@ -0,0 +1,77 @@ +/** + * Pure decision logic for attach-time image compression (the `Compress` + * toggle on the composer's staged-image thumbnails). + * + * The pipeline (re-encode to JPEG + rescale) runs at the browser edge + * (`../adapter/image-encode`); this module owns every DECISION so it stays + * pure `input → output` and unit-testable without a DOM: + * + * - `shouldConvertImage` — which source formats get converted. + * - `computeResizedDimensions` — the rescale math (cap the longest edge, + * never upscale, preserve aspect). + * - `useConvertedImage` — the smaller-wins rule. + */ + +/** JPEG encode quality for the canvas re-encode. */ +export const JPEG_QUALITY = 0.85; + +/** Longest-edge cap (px) for the rescale. Never upscale past the source. */ +export const MAX_IMAGE_EDGE_PX = 2048; + +/** + * Source formats that get converted. Only LOSSLESS sources are re-encoded: + * PNG (the screenshot case — multi-MB PNGs are the reason this exists) plus + * BMP (trivially canvas-decodable in every browser). Already-lossy formats + * (JPEG/WebP) and animated ones (GIF) are sent verbatim — recompressing them + * degrades quality and saves nothing, and re-encoding a GIF destroys its + * animation. TIFF is NOT listed: browsers can't natively decode it, so the + * canvas path would always fall back to the original anyway. An oversized + * JPEG stays untouched (the backend's body-budget safety net handles the + * extreme case). + */ +const CONVERTIBLE_MIME_TYPES = new Set(["image/png", "image/bmp"]); + +/** + * Whether a source MIME type is converted to JPEG at staging time. + * Unknown/absent MIME types are left alone (conservative — never gamble on + * bytes we can't classify). + */ +export function shouldConvertImage(mimeType: string | undefined): boolean { + return mimeType !== undefined && CONVERTIBLE_MIME_TYPES.has(mimeType); +} + +/** Pixel dimensions of a raster image. */ +export interface Dimensions { + readonly width: number; + readonly height: number; +} + +/** + * Target dimensions after the rescale: cap the LONGEST edge at `maxEdge` + * (default `MAX_IMAGE_EDGE_PX`), never upscale (a smaller source passes + * through unchanged), and preserve aspect ratio. + * + * Pure math — the canvas draw itself lives at the browser edge. + */ +export function computeResizedDimensions( + width: number, + height: number, + maxEdge: number = MAX_IMAGE_EDGE_PX, +): Dimensions { + const longest = Math.max(width, height); + if (longest <= maxEdge) return { width, height }; + const scale = maxEdge / longest; + // Rounding can never exceed the cap: each scaled edge is ≤ maxEdge before + // rounding, and the only values that round up land exactly ON the cap. + return { width: Math.round(width * scale), height: Math.round(height * scale) }; +} + +/** + * The smaller-wins rule: the converted JPEG is used only when it is actually + * FEWER bytes than the original (equal or larger → keep the original). + * Compared on raw byte counts (File/Blob `size`); base64 inflation affects + * both sides equally so the ratio is preserved. + */ +export function useConvertedImage(originalBytes: number, convertedBytes: number): boolean { + return convertedBytes < originalBytes; +} diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts new file mode 100644 index 0000000..c0caa0d --- /dev/null +++ b/src/features/chat/logic/tool-summary.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it } from "vitest"; +import type { ToolEntry } from "../../../core/chunks"; +import { toolIcon, toolLabel, viewToolEntry } from "./tool-summary"; + +function entry( + name: string, + input: unknown, + content: string | null = "ok", + isError = false, +): ToolEntry { + return { + toolCallId: "a", + toolName: name, + call: { type: "tool-call", toolCallId: "a", toolName: name, input }, + result: + content === null + ? null + : { type: "tool-result", toolCallId: "a", toolName: name, content, isError }, + }; +} + +const view = (name: string, input: unknown, content: string | null = "ok", isError = false) => + viewToolEntry(entry(name, input, content, isError)); +const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`); + +describe("tool labels", () => { + it.each([ + ["run_shell", "Run command"], + ["read_file", "Read file"], + ["edit_file", "Edit file"], + ["write_file", "Write file"], + ["web_search", "Web search"], + ["lsp", "Code intelligence"], + ["todo_write", "Update to-dos"], + ["load_skill", "Load skill"], + ["consult_vision", "Ask vision model"], + ["read_image", "Read image"], + ["youtube_transcript", "YouTube transcript"], + ["summon_agent", "Summon agent"], + ["send_agent_message", "Message agent"], + ["read_agent", "Read agent reply"], + ["open_agent", "Open agent"], + ["cancel_agent_message", "Cancel agent message"], + ["list_agents", "List agents"], + ["list_agent_models", "List agent models"], + ])("built-in %s → %s", (name, label) => { + expect(toolLabel(name)).toBe(label); + expect(view(name, {}).label).toBe(label); + expect(view(name, {}).name).toBe(name); + }); + it.each([ + ["github__create_issue", "Github: Create issue"], + ["mcp__github__create_issue", "Github: Create issue"], + ["playwright__browserTakeScreenshot", "Playwright: Browser take screenshot"], + ["scaffold_echo", "Scaffold echo"], + ["fetchUrl", "Fetch url"], + ["some-kebab-tool", "Some kebab tool"], + ["toString", "To string"], + ])("unknown %s is humanized → %s", (name, label) => { + expect(toolLabel(name)).toBe(label); + }); +}); + +describe("tool icons", () => { + const builtIns = [ + "run_shell", + "read_file", + "edit_file", + "write_file", + "web_search", + "lsp", + "todo_write", + "load_skill", + "consult_vision", + "read_image", + "youtube_transcript", + "summon_agent", + "send_agent_message", + "read_agent", + "open_agent", + "cancel_agent_message", + "list_agents", + "list_agent_models", + ]; + it("gives every built-in tool its own distinct icon", () => { + const icons = builtIns.map(toolIcon); + expect(icons).not.toContain("tool"); + expect(icons).not.toContain("mcp"); + expect(new Set(icons).size).toBe(builtIns.length); + expect(view("run_shell", {}).icon).toBe("terminal"); + }); + it.each([ + ["github__create_issue", "mcp"], + ["mcp__github__create_issue", "mcp"], + ["scaffold_echo", "tool"], + ["__proto__", "tool"], + ["constructor", "tool"], + ])("%s → %s", (name, icon) => { + expect(toolIcon(name)).toBe(icon); + }); +}); + +describe("tool summaries", () => { + it.each([ + ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"], + ["read_file", { path: "src/a.ts", offset: 5, limit: 80 }, "src/a.ts:5-84"], + ["read_file", { path: "src/a.ts", limit: 12 }, "src/a.ts:1-12"], + ["read_file", { path: "src/a.ts", offset: 10 }, "src/a.ts:10"], + ["read_file", { path: "src/a.ts" }, "src/a.ts"], + ["edit_file", { path: "src/a.ts", oldString: "a", newString: "b" }, "src/a.ts"], + ["write_file", { path: "src/a.ts", content: "data" }, "src/a.ts"], + ["web_search", { query: "svelte 5 runes", url: "ignored" }, "svelte 5 runes"], + ["web_search", { url: "https://example.org", mode: "scrape" }, "https://example.org"], + [ + "lsp", + { operation: "references", path: "src/a.ts", line: 12, character: 3 }, + "references src/a.ts:12:3", + ], + ["lsp", { operation: "diagnostics", path: "src/a.ts" }, "diagnostics src/a.ts"], + [ + "todo_write", + { todos: [{ status: "pending" }, { status: "completed" }, null] }, + "3 tasks · 1 completed", + ], + ["load_skill", { name: "research_methodology" }, "research_methodology"], + [ + "consult_vision", + { question: "What is in this picture?", imageIds: [1] }, + "What is in this picture?", + ], + ["youtube_transcript", { url: "https://youtu.be/abc" }, "https://youtu.be/abc"], + ["summon_agent", { title: "Review", task: "long task", agent: "Opus" }, "Review"], + ["summon_agent", { task: "Review the patch" }, "Review the patch"], + ["summon_agent", { agent: "Opus" }, "Opus"], + ["send_agent_message", { conversationId: "abcd", message: "Continue" }, "abcd"], + ["read_agent", { conversationId: "abcd" }, "abcd"], + ["open_agent", { conversationId: "abcd" }, "abcd"], + ["cancel_agent_message", { conversationId: "abcd", messageId: "m1" }, "abcd"], + ["list_agents", { relationship: "descendants" }, "descendants"], + ["list_agents", { query: "review" }, "review"], + ["list_agent_models", { query: "Opus" }, "Opus"], + ])("%s derives a meaningful call summary from %j", (name, input, expected) => { + expect(view(name, input).callSummary).toBe(expected); + }); + + it("formats edit call badges and old/new details without hiding multiline text", () => { + const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" }; + const card = view("edit_file", input); + expect(card.callBadges).toEqual([ + { label: "+3", tone: "success" }, + { label: "−2", tone: "error" }, + ]); + expect(card.callDetail).toEqual({ + kind: "edit", + path: "a.ts", + oldText: input.oldString, + newText: input.newString, + }); + expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([ + { label: "+1", tone: "success" }, + { label: "−0", tone: "error" }, + ]); + expect(view("edit_file", { path: "a" }).callBadges).toEqual([]); + }); + it("omits edit diffstats after failure while retaining attempted edits in call details", () => { + const input = { path: "src/missing.ts", oldString: "foo", newString: "bar" }; + const card = view("edit_file", input, "Error: oldString not found", true); + expect(card.callBadges).toEqual([]); + expect(card.callDetail).toEqual({ + kind: "edit", + path: input.path, + oldText: "foo", + newText: "bar", + }); + expect(card.resultSummary).toBe("failed"); + }); + it("preserves exact shell commands in the expanded detail", () => { + expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({ + kind: "command", + text: "ls\n pwd", + }); + }); + it("shows scalar fields and pretty nested JSON", () => { + expect(view("mcp__foo", { path: "a.ts", count: 2, nested: { a: true } }).callDetail).toEqual({ + kind: "fields", + fields: [ + { key: "path", value: "a.ts", multiline: false }, + { key: "count", value: "2", multiline: false }, + { key: "nested", value: '{\n "a": true\n}', multiline: true }, + ], + }); + }); + it("formats short scalar arrays inline, retaining quotes, escapes, nulls and empty arrays", () => { + expect( + view("mcp", { + labels: ["ui", "chat"], + scalars: [1, true, null], + empty: [], + escaped: ["a\nb"], + }).callDetail, + ).toEqual({ + kind: "fields", + fields: [ + { key: "labels", value: '["ui", "chat"]', multiline: false }, + { key: "scalars", value: "[1, true, null]", multiline: false }, + { key: "empty", value: "[]", multiline: false }, + { key: "escaped", value: '["a\\nb"]', multiline: false }, + ], + }); + }); + it("keeps long arrays and nested structures pretty-printed and complete", () => { + const input = { long: ["x".repeat(80)], nested: [{ a: 1 }], mixed: [1, { a: 1 }] }; + const card = view("mcp", input); + if (card.callDetail.kind !== "fields") throw new Error("expected fields"); + expect(card.callDetail.fields.map((field) => field.multiline)).toEqual([true, true, true]); + expect(card.callDetail.fields.map((field) => JSON.parse(field.value))).toEqual( + Object.values(input), + ); + }); + it.each([ + "command", + "path", + "file_path", + "query", + "url", + "pattern", + "name", + "question", + "title", + "task", + "message", + "conversationId", + ])("generic fallback recognizes %s", (key) => { + expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target"); + }); + it("generic fallback prefers the first meaningful argument, else readable key: value pairs", () => { + expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a"); + expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe("a: 1 · b: x"); + // Only nested values → no summary rather than a JSON dump. + expect(view("mcp__foo", { opts: { deep: true } }).callSummary).toBe(""); + }); + it.each([ + "not JSON", + "{bad", + 42, + true, + [1, "a"], + '"hi"', + ])("tolerates malformed/non-object input %j", (input) => { + expect(() => view("read_file", input)).not.toThrow(); + expect(view("read_file", input).callSummary).not.toBe(""); + expect(view("read_file", input).callDetail.kind).toBe("fields"); + }); + it.each([ + null, + undefined, + {}, + [], + "", + " ", + "null", + "{}", + ])("an argument-less call (%j) shows no summary and a 'no arguments' detail — never `{}`", (input) => { + const card = view("list_agent_models", input); + expect(card.callSummary).toBe(""); + expect(card.callDetail).toEqual({ kind: "none" }); + }); + it.each([ + "run_shell", + "read_file", + "edit_file", + "write_file", + "web_search", + "lsp", + "todo_write", + "load_skill", + "consult_vision", + "youtube_transcript", + "summon_agent", + "read_agent", + ])("%s falls back safely for missing arguments", (name) => { + for (const input of [null, undefined, {}]) { + expect(() => view(name, input)).not.toThrow(); + expect(view(name, input).callSummary).toBe(""); + expect(view(name, input).callDetail).toEqual({ kind: "none" }); + } + expect(view(name, "{bad").callSummary).toBe("{bad"); + }); + it("parses JSON-string inputs", () => { + const card = view("run_shell", '{"command":"bun run test"}'); + expect(card.callSummary).toBe("bun run test"); + expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" }); + }); + it("handles unexpected built-in argument types and prototype-looking tool names", () => { + expect(view("todo_write", { todos: "wrong" }).callSummary).toBe("todos: wrong"); + expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields"); + expect(view("constructor", { path: "a" }).callSummary).toBe("a"); + expect(view("__proto__", {}).callSummary).toBe(""); + expect(view("__proto__", {}).label).toBe("Proto"); + expect( + view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary, + ).toBe("good"); + }); + it("does not throw on unserializable input", () => { + const cyclic: Record<string, unknown> = {}; + cyclic.self = cyclic; + expect(view("mcp", cyclic).callSummary).toBe(""); + expect(view("mcp", cyclic).callDetail).toEqual({ + kind: "fields", + fields: [{ key: "self", value: "[unserializable input]", multiline: false }], + }); + expect(() => view("mcp", { integer: 1n })).not.toThrow(); + }); + it("rejects ancestor cycles but preserves shared non-cyclic tool values", () => { + const nested: Record<string, unknown> = { name: "nested" }; + const root = { nested }; + nested.parent = root; + expect(view("mcp", { payload: root }).callDetail).toEqual({ + kind: "fields", + fields: [{ key: "payload", value: "[unserializable input]", multiline: false }], + }); + const shared = { name: "shared" }; + const payload = { first: shared, second: shared, values: [shared, shared] }; + const detail = view("mcp", { payload }).callDetail; + expect(detail.kind).toBe("fields"); + if (detail.kind !== "fields") throw new Error("Expected fields"); + expect(detail.fields[0]?.value).toBe(JSON.stringify(payload, null, 2)); + }); + it("caps and whitespace-collapses header strings but retains full detail", () => { + const command = ` echo\n${"x".repeat(300)}`; + const card = view("run_shell", { command }); + expect(card.callSummary.length).toBe(120); + expect(card.callSummary.endsWith("…")).toBe(true); + expect(card.callSummary).not.toMatch(/\n/); + expect(card.callDetail).toEqual({ kind: "command", text: command }); + }); + it("represents pending entries with no response summary/output", () => { + expect(view("run_shell", { command: "sleep 1" }, null)).toMatchObject({ + status: "pending", + resultSummary: null, + output: null, + }); + }); + it("represents orphan results with unknown call details, not pending", () => { + const e = { ...entry("read_file", {}, "data"), call: null }; + expect(viewToolEntry(e)).toMatchObject({ + name: "read_file", + status: "ok", + callSummary: "Call in earlier history", + resultSummary: "1 line", + }); + expect(viewToolEntry(e).callDetail).toEqual({ + kind: "unavailable", + text: "This call is earlier in the conversation. Show earlier messages to see it.", + }); + }); +}); + +describe("tool response facts", () => { + it.each([ + ["run_shell", "one\ntwo\n", false, "2 lines"], + ["run_shell", "done", false, "1 line"], + ["read_file", "1\tfile text\n", false, "1 line"], + ["read_file", "(empty file: a.ts)", false, "empty file"], + ["mcp", "", false, "no output"], + ["mcp", " \n\t", false, "no output"], + ["mcp", "a short response", false, "a short response"], + ["mcp", "A successful response with more than forty characters of text", false, "1 line"], + ["mcp", "x".repeat(200), false, "1 line"], + ["web_search", "a\nb\nc", false, "3 lines"], + ["run_shell", "", true, "failed"], + ["mcp", "Permission denied", true, "failed"], + ["run_shell", "error\ndetail", true, "failed"], + ["run_shell", "partial\n\n[Command timed out]", true, "timed out"], + ["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"], + ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · truncated"], + ["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"], + [ + "edit_file", + 'Replaced 3 occurrences in "a.ts".\n\nLSP diagnostics: error at line 2', + false, + "3 replacements · diagnostics", + ], + ["write_file", 'Created "a.ts" (4200 bytes).', false, "created · 4.1 KB"], + ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote · 10 B"], + ["write_file", 'Created "a.ts" (1048576 bytes).', false, "created · 1.0 MB"], + ])("%s summarizes %j (error=%s)", (name, content, isError, expected) => { + const card = view(name, {}, content, isError); + expect(card.resultSummary).toBe(expected); + expect(card.status).toBe(isError ? "error" : "ok"); + expect(card.output?.isError).toBe(isError); + }); + it.each([ + ["run_shell", "oops\ndetails", true, "failed"], + ["run_shell", "partial\n[Command timed out]", true, "timed out"], + ["write_file", 'Created "a.ts" (4200 bytes).', false, "created"], + ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote"], + ["edit_file", 'Replaced 3 occurrences in "a.ts".\nDiagnostics: warning', false, "replaced"], + ["run_shell", "a\nb\nc\nd", false, "4 lines"], + ["mcp", "Created issue #42", false, "1 line"], + ["mcp", "", false, "no output"], + ["read_file", "(empty file: a.ts)", false, "empty file"], + ])("%s supplies a complete compact response fact", (name, content, isError, expected) => { + expect(view(name, {}, content, isError).compactResultSummary).toBe(expected); + }); + it("never puts error content in header summaries, but retains it verbatim in output", () => { + const content = 'Error: oldString not found in content of "src/missing.ts".'; + const card = view("edit_file", { path: "src/missing.ts" }, content, true); + expect(card.resultSummary).toBe("failed"); + expect(card.compactResultSummary).toBe("failed"); + expect(card.output).toEqual({ kind: "full", lines: [content], isError: true }); + expect(card.callSummary).toBe("src/missing.ts"); + expect(view("mcp", {}, null).compactResultSummary).toBeNull(); + }); + it("does not infer success on an error that resembles a write result", () => { + expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch( + /^failed/, + ); + }); +}); + +describe("tool output elision", () => { + it.each([0, 1, 6, 11, 12])("shows %i lines in full", (n) => { + expect(view("mcp", {}, lines(n).join("\n")).output).toEqual({ + kind: "full", + lines: lines(n), + isError: false, + }); + }); + it.each([ + 13, 14, 100, + ])("shows first/last six of %i lines, retaining everything for expansion", (n) => { + expect(view("mcp", {}, lines(n).join("\n"), true).output).toEqual({ + kind: "elided", + head: lines(n).slice(0, 6), + hiddenCount: n - 12, + tail: lines(n).slice(-6), + all: lines(n), + isError: true, + }); + }); + it("ignores one final newline terminator at the 12/13 boundary (including CRLF)", () => { + expect(view("mcp", {}, `${lines(12).join("\r\n")}\r\n`).output?.kind).toBe("full"); + expect(view("mcp", {}, `${lines(13).join("\n")}\n`).output).toMatchObject({ + kind: "elided", + hiddenCount: 1, + }); + }); + it("preserves meaningful blank lines including an extra trailing blank line", () => { + expect(view("mcp", {}, "a\n\nb\n\n").output).toEqual({ + kind: "full", + lines: ["a", "", "b", ""], + isError: false, + }); + }); +}); diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts new file mode 100644 index 0000000..2380adc --- /dev/null +++ b/src/features/chat/logic/tool-summary.ts @@ -0,0 +1,460 @@ +import type { ToolEntry } from "../../../core/chunks"; + +export interface ToolCardView { + /** The raw wire tool name (tooltip / debugging). */ + readonly name: string; + /** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */ + readonly label: string; + /** Which icon the card shows — a semantic key; the UI maps it to a Phosphor icon. */ + readonly icon: ToolIconKey; + readonly status: "pending" | "ok" | "error"; + readonly callSummary: string; + readonly callBadges: readonly { + readonly label: string; + readonly tone: "success" | "error"; + }[]; + readonly resultSummary: string | null; + readonly compactResultSummary: string | null; + readonly callDetail: + | { readonly kind: "unavailable"; readonly text: string } + /** The call took no arguments (`{}` / absent input) — rendered as a muted note. */ + | { readonly kind: "none" } + | { readonly kind: "command"; readonly text: string } + | { + readonly kind: "edit"; + readonly path: string; + readonly oldText: string; + readonly newText: string; + } + | { + readonly kind: "fields"; + readonly fields: readonly { + readonly key: string; + readonly value: string; + readonly multiline: boolean; + }[]; + }; + readonly output: ToolOutputView | null; +} + +export type ToolOutputView = + | { readonly kind: "full"; readonly lines: readonly string[]; readonly isError: boolean } + | { + readonly kind: "elided"; + readonly head: readonly string[]; + readonly hiddenCount: number; + readonly tail: readonly string[]; + readonly all: readonly string[]; + readonly isError: boolean; + }; + +type Args = Readonly<Record<string, unknown>>; +type CallSummary = (args: Args) => string; + +function oneLine(text: string, cap = 120): string { + const compact = text.replace(/\s+/g, " ").trim(); + return compact.length > cap ? `${compact.slice(0, cap - 1)}…` : compact; +} + +function normalize(input: unknown): unknown { + if (typeof input !== "string") return input; + try { + return JSON.parse(input); + } catch { + return input; + } +} + +function asArgs(input: unknown): Args { + return typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Args) + : {}; +} + +function serialize(input: unknown, pretty = false): string { + if (typeof input === "string") return input; + try { + // Detect ancestor cycles before the native serializer recurses. Bun's + // pretty JSON cycle detection can otherwise consume seconds of UI time. + // Track ancestors, not every visited object: shared non-cyclic values are valid. + const ancestors: object[] = []; + return ( + JSON.stringify( + input, + function (this: unknown, _key, value: unknown) { + if (value !== null && typeof value === "object") { + while (ancestors.length && ancestors.at(-1) !== this) ancestors.pop(); + if (ancestors.includes(value)) throw new TypeError("Circular tool input"); + ancestors.push(value); + } + return value; + }, + pretty ? 2 : undefined, + ) ?? String(input) + ); + } catch { + return "[unserializable input]"; + } +} + +/** Small scalar arrays are easier to scan inline; nested/long values stay pretty JSON. */ +function fieldValue(value: unknown): string { + if ( + Array.isArray(value) && + value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item)) + ) { + try { + const inline = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`; + if (inline.length <= 80) return inline; + } catch { + // Non-wire values still use the safe serializer below. + } + } + return serialize(value, true); +} + +function arg(args: Args, ...keys: string[]): string { + for (const key of keys) { + const value = Object.hasOwn(args, key) ? args[key] : undefined; + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + } + return ""; +} + +function lineCount(text: string): number { + return text === "" ? 0 : outputLines(text).length; +} + +/** Drop one final newline terminator, not meaningful blank lines within the output. */ +function outputLines(content: string): string[] { + const normalized = content.replace(/\r\n/g, "\n"); + return normalized === "" ? [] : normalized.replace(/\n$/, "").split("\n"); +} + +function readSummary(args: Args): string { + const path = arg(args, "path"); + const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 1; + const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : null; + return `${path}${limit !== null ? `:${offset}-${offset + limit - 1}` : args.offset !== undefined ? `:${offset}` : ""}`; +} + +function lspSummary(args: Args): string { + const line = arg(args, "line"); + const character = arg(args, "character"); + return `${arg(args, "operation")} ${arg(args, "path")}${line ? `:${line}${character ? `:${character}` : ""}` : ""}`; +} + +function todoSummary(args: Args): string { + if (!Array.isArray(args.todos)) return ""; + const done = args.todos.filter((t) => asArgs(t).status === "completed").length; + return `${args.todos.length} tasks · ${done} completed`; +} + +const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model"); +const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message"); + +/** English names for Dispatch's built-in tools; anything else goes through {@link humanize}. */ +const TOOL_LABELS: Readonly<Record<string, string>> = { + run_shell: "Run command", + read_file: "Read file", + edit_file: "Edit file", + write_file: "Write file", + web_search: "Web search", + lsp: "Code intelligence", + todo_write: "Update to-dos", + load_skill: "Load skill", + consult_vision: "Ask vision model", + read_image: "Read image", + youtube_transcript: "YouTube transcript", + summon_agent: "Summon agent", + send_agent_message: "Message agent", + read_agent: "Read agent reply", + open_agent: "Open agent", + cancel_agent_message: "Cancel agent message", + list_agents: "List agents", + list_agent_models: "List agent models", +}; + +/** + * Semantic icon keys (Svelte-free; `ui/tool-icons.ts` maps each to a Phosphor + * component). Unknown tools get `mcp` when namespaced `<server>__<tool>`, + * else the generic `tool` wrench. + */ +export type ToolIconKey = + | "terminal" + | "file-read" + | "file-edit" + | "file-write" + | "search" + | "code" + | "todos" + | "skill" + | "vision" + | "image" + | "youtube" + | "agent-summon" + | "agent-send" + | "agent-read" + | "agent-open" + | "agent-cancel" + | "agents" + | "models" + | "mcp" + | "tool"; + +const TOOL_ICONS: Readonly<Record<string, ToolIconKey>> = { + run_shell: "terminal", + read_file: "file-read", + edit_file: "file-edit", + write_file: "file-write", + web_search: "search", + lsp: "code", + todo_write: "todos", + load_skill: "skill", + consult_vision: "vision", + read_image: "image", + youtube_transcript: "youtube", + summon_agent: "agent-summon", + send_agent_message: "agent-send", + read_agent: "agent-read", + open_agent: "agent-open", + cancel_agent_message: "agent-cancel", + list_agents: "agents", + list_agent_models: "models", +}; + +/** The icon key for a tool (built-in table, else MCP plug / generic wrench). */ +export function toolIcon(name: string): ToolIconKey { + if (Object.hasOwn(TOOL_ICONS, name)) return TOOL_ICONS[name] ?? "tool"; + return name.split("__").filter((p) => p !== "").length >= 2 ? "mcp" : "tool"; +} + +/** `create_issue` / `createIssue` / `create-issue` → "Create issue". */ +function sentenceCase(raw: string): string { + const words = raw + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[\s_\-.]+/) + .filter((w) => w !== "") + .map((w) => w.toLowerCase()); + if (words.length === 0) return raw; + const text = words.join(" "); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/** + * Generic English name for an unknown tool. MCP tools arrive namespaced as + * `<serverId>__<toolName>` (backend `mcp/registry.ts`), shown as "Server: Tool name". + */ +function humanize(name: string): string { + const parts = name.split("__").filter((p) => p !== ""); + if (parts[0]?.toLowerCase() === "mcp" && parts.length > 1) parts.shift(); + if (parts.length >= 2) { + const server = sentenceCase(parts[0] ?? ""); + return `${server}: ${sentenceCase(parts.slice(1).join(" "))}`; + } + return sentenceCase(name); +} + +/** The English display name for a tool (built-in table, else humanized). */ +export function toolLabel(name: string): string { + return Object.hasOwn(TOOL_LABELS, name) ? (TOOL_LABELS[name] ?? name) : humanize(name); +} + +/** True for input that carries no arguments at all (absent, `null`, `{}`, `[]`, blank string). */ +function isEmptyInput(input: unknown): boolean { + if (input === undefined || input === null) return true; + if (typeof input === "string") return input.trim() === ""; + if (Array.isArray(input)) return input.length === 0; + return typeof input === "object" && Object.keys(input).length === 0; +} + +/** Fallback summary: scalar args as `key: value` (never raw JSON braces). */ +function scalarPairs(args: Args): string { + return Object.entries(args) + .filter( + ([, v]) => ["string", "number", "boolean"].includes(typeof v) && String(v).trim() !== "", + ) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(" · "); +} + +/** Optional built-in polish; all unknown/MCP names use the same generic path. */ +const summaries: Readonly<Record<string, CallSummary>> = { + run_shell: (args) => arg(args, "command"), + read_file: readSummary, + edit_file: (args) => arg(args, "path"), + write_file: (args) => arg(args, "path"), + web_search: (args) => arg(args, "query", "url"), + lsp: lspSummary, + todo_write: todoSummary, + load_skill: (args) => arg(args, "name"), + consult_vision: (args) => arg(args, "question", "path"), + youtube_transcript: (args) => arg(args, "url"), + summon_agent: summarizeAgent, + send_agent_message: summarizeConversation, + read_agent: summarizeConversation, + open_agent: summarizeConversation, + cancel_agent_message: summarizeConversation, + list_agents: (args) => arg(args, "query", "relationship", "workspaceId"), + list_agent_models: (args) => arg(args, "query"), +}; + +function detail(name: string, input: unknown, args: Args): ToolCardView["callDetail"] { + if (name === "run_shell" && typeof args.command === "string") { + return { kind: "command", text: args.command }; + } + if (name === "edit_file" && typeof args.path === "string") { + return { + kind: "edit", + path: args.path, + oldText: typeof args.oldString === "string" ? args.oldString : "", + newText: typeof args.newString === "string" ? args.newString : "", + }; + } + if (isEmptyInput(input)) return { kind: "none" }; + const entries = Object.entries(args); + return { + kind: "fields", + fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => { + const text = fieldValue(value); + return { key: String(key), value: text, multiline: text.includes("\n") }; + }), + }; +} + +function bytesLabel(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function responseSummary(name: string, content: string, isError: boolean): string { + const lines = outputLines(content); + const first = oneLine(lines.find((line) => line.trim()) ?? "", 72); + const count = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`; + const timeout = /\[Command timed out\]/i.test(content); + const truncated = /\[Output truncated[^\]]*\]/i.test(content); + let summary: string; + if (timeout) summary = "timed out"; + else if (isError) summary = "failed"; + else if (!content.trim()) summary = "no output"; + else if (/^\(empty file\b/i.test(content)) summary = "empty file"; + else { + const replaced = + name === "edit_file" ? /^Replaced (\d+) occurrence(?:\(s\)|s)?/i.exec(content) : null; + const written = + name === "write_file" ? /^(Created|Overwrote)\b[^\n]*\((\d+) bytes\)/i.exec(content) : null; + if (replaced) { + summary = `${replaced[1]} ${replaced[1] === "1" ? "replacement" : "replacements"}`; + if (content.trim().includes("\n")) summary += " · diagnostics"; + } else if (written) { + summary = `${written[1]?.toLowerCase()} · ${bytesLabel(Number(written[2]))}`; + } else if ( + name === "run_shell" || + name === "read_file" || + lines.length > 1 || + first.length > 60 + ) { + summary = count; + } else summary = first; + } + const label = `${summary}${truncated ? " · truncated" : ""}`; + // Even the wide header must leave room for the call; prefer a whole short + // fact over chopping an arbitrary one-line response halfway through a word. + return label.length <= 40 ? label : compactResponseSummary(label, lines.length); +} + +/** Never CSS-clip response facts: swap to a whole short fact when space is scarce. */ +function compactResponseSummary(summary: string, count: number): string { + const fact = summary.split(" · ")[0] ?? summary; + if (/^(failed|timed out|no output|empty file|created|overwrote|\d+ lines?)$/.test(fact)) + return fact; + if (/^\d+ replacements?$/.test(fact)) return "replaced"; + return `${count} ${count === 1 ? "line" : "lines"}`; +} + +export function viewToolEntry(entry: ToolEntry): ToolCardView { + const input = normalize(entry.call?.input); + const args = asArgs(input); + const summarize = Object.hasOwn(summaries, entry.toolName) + ? summaries[entry.toolName] + : undefined; + const generic = arg( + args, + "command", + "path", + "file_path", + "query", + "url", + "pattern", + "name", + "question", + "title", + "task", + "message", + "conversationId", + ); + const builtInSummary = summarize?.(args).trim(); + const callSummary = + entry.call === null + ? entry.recovering + ? "Recovering tool input…" + : "Call in earlier history" + : isEmptyInput(input) + ? "" + : oneLine( + builtInSummary || + generic || + scalarPairs(args) || + // Non-object input (e.g. a bare string); objects with only nested + // values stay unsummarized rather than dumping JSON braces. + (Object.keys(args).length === 0 ? serialize(input) : ""), + ); + const callBadges: ToolCardView["callBadges"][number][] = []; + if (entry.toolName === "edit_file" && entry.call !== null && !entry.result?.isError) { + if (typeof args.oldString === "string" && typeof args.newString === "string") { + callBadges.push( + { label: `+${lineCount(args.newString)}`, tone: "success" }, + { label: `−${lineCount(args.oldString)}`, tone: "error" }, + ); + } + } + const result = entry.result; + const lines = result === null ? [] : outputLines(result.content); + const resultSummary = + result === null ? null : responseSummary(entry.toolName, result.content, result.isError); + return { + name: entry.toolName, + label: toolLabel(entry.toolName), + icon: toolIcon(entry.toolName), + status: result === null ? "pending" : result.isError ? "error" : "ok", + callSummary, + callBadges, + callDetail: + entry.call === null + ? { + kind: "unavailable", + 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, + compactResultSummary: + resultSummary === null ? null : compactResponseSummary(resultSummary, lines.length), + output: + result === null + ? null + : lines.length <= 12 + ? { kind: "full", lines, isError: result.isError } + : { + kind: "elided", + head: lines.slice(0, 6), + hiddenCount: lines.length - 12, + tail: lines.slice(-6), + all: lines, + isError: result.isError, + }, + }; +} diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 6d3081d..523f1d8 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from "vitest"; import { + isClaudeSubscriptionModel, + isNamedAgentSelection, isVisionModel, joinModelName, + migrateLegacyModelName, + modelKeyLabel, modelKeys, modelsForKey, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, + openCodeNumber, + providerKeysWithNamedAgents, splitModelName, } from "./model-select"; @@ -47,11 +57,64 @@ describe("modelKeys", () => { ).toEqual(["openai", "anthropic", "google"]); }); + it("sorts numbered OpenCode keys numerically (#2 before #10)", () => { + expect( + modelKeys(["opencode-10/models", "opencode-2/models", "opencode-1/models", "openai/gpt-4"]), + ).toEqual(["opencode-1", "opencode-2", "opencode-10", "openai"]); + }); + it("is empty for no models", () => { expect(modelKeys([])).toEqual([]); }); }); +describe("openCodeNumber", () => { + it("parses a numbered OpenCode key", () => { + expect(openCodeNumber("opencode-1")).toBe(1); + expect(openCodeNumber("opencode-10")).toBe(10); + }); + + it("returns null for non-openCode / malformed keys", () => { + expect(openCodeNumber("umans")).toBeNull(); + expect(openCodeNumber("opencode")).toBeNull(); + expect(openCodeNumber("opencode-0")).toBeNull(); + expect(openCodeNumber("opencode-01")).toBeNull(); + }); +}); + +describe("modelKeyLabel", () => { + it("labels a numbered OpenCode key as OpenCode #N", () => { + expect(modelKeyLabel("opencode-2")).toBe("OpenCode #2"); + expect(modelKeyLabel("opencode-10")).toBe("OpenCode #10"); + }); + + it("leaves non-OpenCode keys unchanged", () => { + expect(modelKeyLabel("umans")).toBe("umans"); + }); +}); + +describe("migrateLegacyModelName", () => { + it("rewrites opencode/<model> to opencode-1/<model>", () => { + expect(migrateLegacyModelName("opencode/deepseek-v4-flash")).toBe( + "opencode-1/deepseek-v4-flash", + ); + expect(migrateLegacyModelName("opencode/nested/model")).toBe("opencode-1/nested/model"); + }); + + it("leaves already-numbered and other-provider names unchanged", () => { + expect(migrateLegacyModelName("opencode-1/deepseek-v4-flash")).toBe( + "opencode-1/deepseek-v4-flash", + ); + expect(migrateLegacyModelName("umans/umans-glm-5.2")).toBe("umans/umans-glm-5.2"); + expect(migrateLegacyModelName("opencode-2/x")).toBe("opencode-2/x"); + }); + + it("is idempotent", () => { + const once = migrateLegacyModelName("opencode/deepseek-v4-flash"); + expect(migrateLegacyModelName(once)).toBe(once); + }); +}); + describe("modelsForKey", () => { it("returns the model suffixes under a key, in order", () => { const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; @@ -87,3 +150,66 @@ describe("isVisionModel", () => { expect(isVisionModel({}, "kimi/k2")).toBe(false); }); }); + +describe("named-agent provider helpers", () => { + it("providerKeysWithNamedAgents puts the synthetic provider first, then raw keys", () => { + expect(providerKeysWithNamedAgents(["openai/gpt-4", "anthropic/claude"])).toEqual([ + NAMED_AGENTS_PROVIDER, + "openai", + "anthropic", + ]); + // Always present even with zero models. + expect(providerKeysWithNamedAgents([])).toEqual([NAMED_AGENTS_PROVIDER]); + }); + + it("namedAgentsProviderLabel + NAMED_AGENTS_PROVIDER are stable", () => { + expect(namedAgentsProviderLabel()).toBe("Named Agents"); + expect(NAMED_AGENTS_PROVIDER).toBe("__named_agents__"); + }); + + it("namedAgentNames returns the display names", () => { + expect(namedAgentNames([{ id: "a", name: "Coding", model: "m" }])).toEqual(["Coding"]); + }); + + it("isNamedAgentSelection is true only for a snapshot whose model equals the selection", () => { + expect( + isNamedAgentSelection("opencode-1/claude", { + namedAgentId: "a", + namedAgentName: "C", + model: "opencode-1/claude", + }), + ).toBe(true); + expect( + isNamedAgentSelection("other/model", { + namedAgentId: "a", + namedAgentName: "C", + model: "opencode-1/claude", + }), + ).toBe(false); + expect(isNamedAgentSelection("opencode-1/claude", null)).toBe(false); + }); + + it("namedAgentSelectionLabel renders 'Named Agents -> <name>'", () => { + 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 602e0ef..bebad2c 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -29,15 +29,80 @@ export function joinModelName(key: string, model: string): string { /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { const seen = new Set<string>(); - const out: string[] = []; + const openCodeKeys: Array<{ key: string; n: number }> = []; + const otherKeys: string[] = []; for (const full of models) { const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } + if (seen.has(key)) continue; + seen.add(key); + const n = openCodeNumber(key); + if (n !== null) openCodeKeys.push({ key, n }); + else otherKeys.push(key); } - return out; + // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed + // before other providers (the primary credential in this app). Others keep + // first-seen order. + openCodeKeys.sort((a, b) => a.n - b.n); + return [...openCodeKeys.map((e) => e.key), ...otherKeys]; +} + +/** + * The stable numeric identity of a numbered OpenCode provider id/key + * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a + * numbered OpenCode key. Numbers are canonical positive integers with no + * leading zeros. + */ +export function openCodeNumber(key: string): number | null { + const m = /^opencode-([1-9][0-9]*)$/.exec(key); + return m === null ? null : Number(m[1]); +} + +/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */ +export function isNumberedOpenCode(id: string): boolean { + return openCodeNumber(id) !== null; +} + +/** + * 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. + */ +export function modelKeyLabel(key: string): string { + const n = openCodeNumber(key); + return n === null ? key : `OpenCode #${n}`; +} + +/** + * Migration helper: rewrite a legacy `opencode/<model>` full model name to the + * numbered `opencode-1/<model>` namespace. Returns the input unchanged for + * anything that is not `opencode/`-prefixed (already-numbered names, other + * providers). Idempotent. This is the FE-side persisted-state migration; the + * backend performs its own equivalent on its own store. + */ +export function migrateLegacyModelName(full: string): string { + const legacyPrefix = "opencode/"; + if (!full.startsWith(legacyPrefix)) return full; + const model = full.slice(legacyPrefix.length); + return model.length === 0 ? full : `opencode-1/${model}`; } /** The model suffixes available under a given key, in order. */ @@ -62,3 +127,73 @@ export function isVisionModel( ): boolean { return modelInfo[fullName]?.vision === true; } + +// ── Named Agents synthetic provider (plan §6) ──────────────────────────────── +// +// The regular model picker gains a "Named Agents" provider at the TOP of the +// provider list. Selecting it + a Named Agent name resolves to that agent's raw +// model for the conversation AND records a snapshot so the selection survives +// later edits to the shared preset. These helpers are structural (they take a +// minimal NamedAgent-ish shape) so `model-select` stays decoupled from the +// named-agents feature up the chain; the picker binds the real types. + +/** A minimal Named Agent shape the synthetic provider needs (structural). */ +export interface NamedAgentLike { + readonly id: string; + readonly name: string; + readonly model: string; +} + +/** A minimal per-conversation Named Agent selection snapshot (structural). */ +export interface NamedAgentSnapshotLike { + readonly namedAgentId: string; + readonly namedAgentName: string; + readonly model: string; +} + +/** + * The synthetic "Named Agents" provider id used by the regular model picker + * (plan §6). Deliberately a value that can never collide with a real provider + * (`<credentialName>`), so the provider dropdown treats it as the first option. + */ +export const NAMED_AGENTS_PROVIDER = "__named_agents__"; + +/** The friendly label of the synthetic Named Agents provider. */ +export function namedAgentsProviderLabel(): string { + return "Named Agents"; +} + +/** + * The provider dropdown options for the picker: the synthetic "Named Agents" + * provider ALWAYS first, then the raw providers (via `modelKeys`). The synthetic + * entry stays present even with zero Named Agents (plan §6 shows a disabled + * "No named agents configured" model option). + */ +export function providerKeysWithNamedAgents(models: readonly string[]): string[] { + return [NAMED_AGENTS_PROVIDER, ...modelKeys(models)]; +} + +/** The Named Agent names offered in the model dropdown when the synthetic provider is selected. */ +export function namedAgentNames(agents: readonly NamedAgentLike[]): string[] { + return agents.map((a) => a.name); +} + +/** + * Whether a full raw model string corresponds to a Named Agent snapshot — + * i.e. the conversation was selected via a Named Agent whose captured raw model + * equals `selected`. When true, the picker displays `Named Agents -> <name>`. + */ +export function isNamedAgentSelection( + selected: string, + snapshot: NamedAgentSnapshotLike | null, +): boolean { + return snapshot !== null && snapshot.model === selected; +} + +/** + * The snapshot label shown in the model dropdown for a Named Agent selection: + * "Named Agents -> <name>". Pure display. + */ +export function namedAgentSelectionLabel(name: string): string { + return `${namedAgentsProviderLabel()} -> ${name}`; +} 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/ports.ts b/src/features/chat/ports.ts index 2fe10dc..53ac236 100644 --- a/src/features/chat/ports.ts +++ b/src/features/chat/ports.ts @@ -1,4 +1,5 @@ import type { + ChatQueueCancelMessage, ChatQueueMessage, ChatSendMessage, ConversationHistoryResponse, @@ -6,12 +7,14 @@ import type { } from "@dispatch/transport-contract"; /** - * Injected transport port — sends chat messages to the server. Accepts both - * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message; - * auto-starts a turn if idle). + * Injected transport port — sends chat messages to the server. Accepts + * `chat.send` (start a turn), `chat.queue` (enqueue a steering message; + * auto-starts a turn if idle), and `chat.queue.cancel` (remove a single queued + * message by id so it never runs — fire-and-forget, idempotent; the + * message-queue surface confirms the removal). */ export interface ChatTransport { - send(msg: ChatSendMessage | ChatQueueMessage): void; + send(msg: ChatSendMessage | ChatQueueMessage | ChatQueueCancelMessage): 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/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts index 6d409e9..e870bac 100644 --- a/src/features/chat/reasoning-effort.test.ts +++ b/src/features/chat/reasoning-effort.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_REASONING_EFFORT, effectiveEffort, + effectiveSelection, effortOptions, isReasoningEffort, + isThinkingSelection, REASONING_EFFORT_LEVELS, + selectionOptions, } from "./reasoning-effort"; describe("reasoning-effort helpers", () => { @@ -43,3 +46,39 @@ describe("reasoning-effort helpers", () => { } }); }); + +describe("thinking selection (the separate on/off axis)", () => { + it("selectionOptions lists 'off' first, then the ladder (default marked)", () => { + const options = selectionOptions(); + expect(options).toHaveLength(1 + REASONING_EFFORT_LEVELS.length); + expect(options[0]?.value).toBe("off"); + expect(options[0]?.label).toBe("Off"); + // the rest are the ladder, unchanged from effortOptions() + expect(options.slice(1).map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + }); + + it("isThinkingSelection narrows 'off' + ladder strings, rejects the rest", () => { + expect(isThinkingSelection("off")).toBe(true); + for (const level of REASONING_EFFORT_LEVELS) { + expect(isThinkingSelection(level)).toBe(true); + } + expect(isThinkingSelection("banana")).toBe(false); + expect(isThinkingSelection("")).toBe(false); + expect(isThinkingSelection("OFF")).toBe(false); + expect(isThinkingSelection("none")).toBe(false); // NOT a wire value we send + }); + + it("effectiveSelection shows 'off' when thinking is explicitly disabled", () => { + // thinking off is a SEPARATE axis: the effort level is irrelevant while off. + expect(effectiveSelection("xhigh", false)).toBe("off"); + expect(effectiveSelection(null, false)).toBe("off"); + }); + + it("effectiveSelection shows the effort level when thinking is on (default)", () => { + // null thinking = never set ⇒ thinking ON (default) ⇒ show the effort level. + expect(effectiveSelection(null, null)).toBe("high"); // default effort + expect(effectiveSelection("low", null)).toBe("low"); + expect(effectiveSelection("max", true)).toBe("max"); // explicitly on + }); +}); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts index 1eb77b6..39e1c5a 100644 --- a/src/features/chat/reasoning-effort.ts +++ b/src/features/chat/reasoning-effort.ts @@ -36,7 +36,7 @@ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEff return persisted ?? DEFAULT_REASONING_EFFORT; } -/** One `<option>` of the selector. */ +/** One `<option>` of the effort ladder. */ export interface EffortOption { readonly value: ReasoningEffort; readonly label: string; @@ -64,3 +64,99 @@ export type ReasoningEffortSaveResult = export type SaveReasoningEffort = ( level: ReasoningEffort, ) => Promise<ReasoningEffortSaveResult | null>; + +// ── Thinking on/off (a SEPARATE axis from the effort level) ───────────────── +// +// Per the umans API (and the user's mental model), "thinking off" is NOT a +// zero-effort level — it is a distinct "disable extended thinking entirely" +// signal. The umans route expresses it as `reasoning_effort: "none"`; Dispatch +// surfaces it as a SEPARATE per-conversation boolean so the effort LEVEL is +// preserved across an off→on toggle (turning thinking back on restores the +// previously-chosen depth). The per-conversation selector CONFLATES the two +// axes into one `<select>` (UX), but the WIRE keeps them separate. +// +// ⚠️ BACKEND CONTRACT GAP — see `backend-handoff.md`. The `thinking` endpoint + +// wire types below are the PROPOSED shape; the backend has NOT shipped them yet. +// They are defined FE-local (mirroring the shipped `ReasoningEffortResponse` / +// `SetReasoningEffortRequest` shape) so the FE is built + tested against the +// target contract. Re-pin + re-mirror once the backend ships them. + +/** + * Response of `GET /conversations/:id/thinking` (PROPOSED). `thinking` is null + * when never set (the server then resolves turns with thinking ON — the + * default), `false` when explicitly disabled, `true` when explicitly enabled. + */ +export interface ThinkingResponse { + readonly conversationId: string; + readonly thinking: boolean | null; +} + +/** Body of `PUT /conversations/:id/thinking` (PROPOSED). */ +export interface SetThinkingRequest { + readonly thinking: boolean; +} + +/** + * The per-conversation selector's value: `"off"` (thinking disabled — the + * separate axis) or a reasoning-effort LEVEL. NOT a widened ladder: `"off"` is + * not a degree of effort, it is the absence of thinking. + */ +export type ThinkingSelection = "off" | ReasoningEffort; + +/** One `<option>` of the combined selector (off or a level). */ +export interface SelectionOption { + readonly value: ThinkingSelection; + readonly label: string; +} + +/** + * The selector's options: `"off"` first (the separate disable signal), then the + * effort ladder with the server default marked `(default)`. A never-set + * conversation (thinking on, effort null) reads "high (default)". + */ +export function selectionOptions(): readonly SelectionOption[] { + return [{ value: "off", label: "Off" }, ...effortOptions()]; +} + +/** Narrow an untrusted `<select>` value to a {@link ThinkingSelection}. */ +export function isThinkingSelection(value: string): value is ThinkingSelection { + return value === "off" || isReasoningEffort(value); +} + +/** + * The selection the per-conversation selector should show as selected: `"off"` + * when thinking is explicitly disabled (`persistedThinking === false`), else the + * effective effort level. `persistedThinking === null` (never set) ⇒ thinking + * ON (the default) ⇒ the effort level is shown — NOT "off". + */ +export function effectiveSelection( + persistedEffort: ReasoningEffort | null, + persistedThinking: boolean | null, +): ThinkingSelection { + if (persistedThinking === false) return "off"; + return effectiveEffort(persistedEffort); +} + +// ── Injected port for the combined selector (off OR a level) ───────────────── + +/** Outcome of persisting a {@link ThinkingSelection} (one or two PUTs). */ +export type ThinkingSelectionSaveResult = + | { readonly ok: true; readonly selection: ThinkingSelection } + | { readonly ok: false; readonly error: string }; + +/** + * Persist a thinking selection (consumer-defines-port; the composition root + * adapts the store's `PUT .../thinking` + `PUT .../reasoning-effort` to this). + * - `"off"` → disable thinking (the separate signal); the effort level is left + * untouched so an off→on toggle restores it. + * - a level → set the effort level AND ensure thinking is ON (the level is + * meaningless while thinking is off). + */ +export type SaveThinkingSelection = ( + selection: ThinkingSelection, +) => Promise<ThinkingSelectionSaveResult | null>; + +/** Outcome of `PUT /conversations/:id/thinking`. */ +export type ThinkingSaveResult = + | { readonly ok: true; readonly thinking: boolean } + | { readonly ok: false; readonly error: string }; 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 9911438..dacbe06 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -1,8 +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"; @@ -13,7 +18,9 @@ import { foldEvent, initialState, initialWindowSize, + markHistoryRecovering, normalizeChatLimit, + removeLocalEcho, restoreEarlier, selectChunks, selectGenerating, @@ -24,19 +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; @@ -67,9 +83,27 @@ export interface ChatStoreDependencies { * `reportError` → full-screen error modal. Absent → errors are logged only. */ readonly onError?: (context: string, err: unknown) => void; + /** + * Schedules a deferred flush of buffered live events (e.g. once per animation + * frame). When provided, `handleDelta` QUEUES events and folds the whole batch + * in one pass on the scheduled callback — so a burst (the late-join replay on + * opening a conversation mid-turn, or a fast stream on a slow device) costs ONE + * derivation + render instead of one per WS frame. Absent → every event is + * 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[]; @@ -96,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; @@ -111,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 @@ -128,11 +186,32 @@ 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 + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot); a cancel of an already-drained / unknown message is a + * silent server-side no-op. The caller optimistically hides the row; the + * surface update reconciles. `messageId` is the stable `QueuedMessage.id` + * the queue surface snapshot carries. No transcript change — a cancelled + * message is never delivered as steering. + */ + cancelQueuedMessage(messageId: string): void; setModel(model: string): void; /** + * Set (or clear, with null) the Named Agent selection snapshot this chat sends + * with `chat.send` (plan §7). The backend persists it on the conversation at + * creation so it freezes the friendly id/name + resolved model — later edits to + * the Named Agent do not change this conversation's selection. The raw model + * (already set via `setModel`) is sent alongside; the snapshot is display/identity + * metadata that preserves HOW it was selected. + */ + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void; + /** * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. * Lowering it unloads older committed chunks (deferred via the gate while the * reader is scrolled up, catching up on the next mutation). Raising it @@ -158,20 +237,165 @@ 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); + // Memoized projections, keyed by state-object identity (the reducers are + // immutable, so identity == content). The getters are read several times per + // render (ChatPane + ChatView); returning the SAME array for an unchanged + // state keeps downstream `$derived`s (grouping, metrics placement) from + // recomputing on every read. Plain closures, not `$derived` — a store factory + // may run inside a component effect, which would own (and later destroy) it. + const memo = <S, R>(fn: (s: S) => R): ((s: S) => R) => { + let lastIn: S | undefined; + let lastOut: R | undefined; + return (s) => { + if (lastOut === undefined || s !== lastIn) { + lastIn = s; + lastOut = fn(s); + } + return lastOut; + }; + }; + const chunksOf = memo(selectChunks); + const turnMetricsOf = memo(selectOrderedTurnMetrics); + + // Buffered live events awaiting a scheduled flush (see `scheduleFlush`). + let pendingEvents: ChatDeltaMessage["event"][] = []; + let flushScheduled = false; + + /** Fold every buffered event in one pass, then trim + sync once. */ + function flushEvents(): void { + flushScheduled = false; + if (disposed || pendingEvents.length === 0) return; + const batch = pendingEvents; + pendingEvents = []; + applyEvents(batch); + } + + function applyEvents(events: readonly ChatDeltaMessage["event"][]): void { + let t = transcript; + 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); + 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 (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) { + // A turn that FAILS never seals, but the backend still persists the + // metrics it accumulated — pull them so the turn gets its row. + void syncMetrics(); + } + } + /** * Enforce the chat limit after a transcript mutation — unless the injected * gate says the reader is scrolled up (then defer; the next mutation retries @@ -191,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)); } } - async function syncMetrics(): Promise<void> { + 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 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; @@ -229,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; @@ -246,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 { @@ -268,14 +620,18 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { } return { + get pendingInputs() { + return pendingInputs; + }, + discardPendingInput: confirmInput, get messages(): readonly ChatMessage[] { return selectMessages(transcript); }, get chunks(): readonly RenderedChunk[] { - return selectChunks(transcript); + return chunksOf(transcript); }, get turnMetrics(): readonly TurnMetricsEntry[] { - return selectOrderedTurnMetrics(metrics); + return turnMetricsOf(metrics); }, get currentContextSize(): number | undefined { return selectCurrentContextSize(metrics); @@ -286,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; }, @@ -296,55 +655,164 @@ 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; + }, + + noteLastActivity(at: number): void { + if (selectGenerating(transcript)) return; + cacheTimer = seedTurnGap(cacheTimer, at); + }, - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { + 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) { return; } - transcript = foldEvent(transcript, msg.event); - metrics = foldMetricsEvent(metrics, msg.event); - maybeTrim(); - if (transcript.sealedTurnId !== null) { - void syncTail(); - void syncMetrics(); + if (deps.scheduleFlush === undefined) { + applyEvents([msg.event]); + return; + } + pendingEvents.push(msg.event); + if (!flushScheduled) { + flushScheduled = true; + deps.scheduleFlush(flushEvents); } }, send(text: string, images?: readonly ImageInput[]): void { - transcript = appendUserMessage(transcript, text, images); - maybeTrim(); - const msg: ChatSendMessage = { + flushEvents(); + // 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 } : {}), + ...(_namedAgent !== null ? { namedAgent: _namedAgent } : {}), ...(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); + }, + + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent (per the contract). The caller optimistically + // hides the row; the message-queue surface update reconciles. A cancel of + // an already-drained / unknown message is a silent server no-op, so there + // is no local-state change to make and nothing to roll back on a stray + // `chat.error` (which only fires for a malformed send — a client that + // sends the id it just rendered never hits it). + const msg: ChatQueueCancelMessage = { + type: "chat.queue.cancel", + conversationId: deps.conversationId, + messageId, + }; deps.transport.send(msg); }, @@ -352,6 +820,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { _model = model; }, + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void { + _namedAgent = snapshot; + }, + async setChatLimit(limit: number): Promise<void> { const prev = chatLimit; chatLimit = normalizeChatLimit(limit); @@ -369,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); @@ -381,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); @@ -392,18 +867,34 @@ 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. - transcript = clearGenerating(transcript); + flushEvents(); + 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 8f36994..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, @@ -318,12 +369,88 @@ describe("createChatStore", () => { }); }); + describe("cancelQueuedMessage (chat.queue.cancel)", () => { + it("posts a chat.queue.cancel with conversationId + messageId", () => { + 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, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(transport.sent).toHaveLength(0); // chat.send stays empty + expect(transport.sentQueue).toHaveLength(0); // chat.queue stays empty + expect(transport.sentCancels).toHaveLength(1); + expect(transport.sentCancels[0]?.type).toBe("chat.queue.cancel"); + expect(transport.sentCancels[0]?.conversationId).toBe(CONV_ID); + expect(transport.sentCancels[0]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + + it("does NOT touch the transcript (a cancelled message never runs)", () => { + 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, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(store.chunks).toHaveLength(0); // no transcript echo / change + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("sends for any messageId (cancel is idempotent server-side, no FE guard)", () => { + 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, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // The server no-ops an already-drained / unknown id; the FE fires-and- + // forgetgets, so even a repeat cancel is forwarded. + store.cancelQueuedMessage("msg-42"); + store.cancelQueuedMessage("msg-42"); + + expect(transport.sentCancels).toHaveLength(2); + expect(transport.sentCancels[1]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + }); + it("chat.error sets error", () => { 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, @@ -353,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, @@ -381,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, @@ -402,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, @@ -434,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, @@ -456,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(); @@ -474,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, @@ -501,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(); }); @@ -512,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, @@ -557,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, @@ -581,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, @@ -604,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, @@ -634,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, @@ -654,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, @@ -678,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, @@ -716,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, @@ -777,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, @@ -821,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, @@ -893,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, @@ -959,6 +1102,7 @@ describe("createChatStore", () => { ]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -983,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, @@ -1014,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, @@ -1038,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, @@ -1072,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, @@ -1118,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, @@ -1172,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, @@ -1210,6 +1360,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1239,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, @@ -1274,6 +1426,7 @@ describe("createChatStore", () => { historySync.returnChunks = [makeStoredChunk(3)]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1298,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, @@ -1335,6 +1489,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1367,6 +1522,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1398,6 +1554,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1430,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, @@ -1467,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, @@ -1498,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, @@ -1525,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, @@ -1564,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, @@ -1573,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; @@ -1590,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, @@ -1619,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, @@ -1634,3 +1799,467 @@ describe("createChatStore", () => { expect(metricsSync.calls).toHaveLength(0); }); }); + +describe("createChatStore — batched delta application (scheduleFlush)", () => { + function setup() { + const scheduled: (() => void)[] = []; + const historySync = createFakeHistorySync(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: historySync.impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + scheduleFlush: (flush) => scheduled.push(flush), + }); + const runScheduled = (): void => { + for (const f of scheduled.splice(0)) f(); + }; + return { store, scheduled, runScheduled, historySync }; + } + const text = (delta: string) => + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta }); + + it("queues a burst and applies it in ONE scheduled flush", () => { + const { store, scheduled, runScheduled } = setup(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + for (let i = 0; i < 500; i++) store.handleDelta(text("x")); + + expect(scheduled).toHaveLength(1); + expect(store.chunks).toHaveLength(0); + + runScheduled(); + expect(store.chunks).toHaveLength(1); + expect((store.chunks[0]?.chunk as { text: string }).text).toBe("x".repeat(500)); + expect(store.generating).toBe(true); + store.dispose(); + }); + + it("schedules a new flush for events arriving after a flush", () => { + const { store, scheduled, runScheduled } = setup(); + store.handleDelta(text("a")); + runScheduled(); + store.handleDelta(text("b")); + expect(scheduled).toHaveLength(1); + runScheduled(); + expect((store.chunks[0]?.chunk as { text: string }).text).toBe("ab"); + store.dispose(); + }); + + it("a sealed turn in the batch still triggers a history sync", async () => { + const { store, runScheduled, historySync } = setup(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(text("done")); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + expect(historySync.calls).toHaveLength(0); + runScheduled(); + await vi.waitFor(() => expect(historySync.calls.length).toBeGreaterThan(0)); + store.dispose(); + }); + + it("send() flushes queued events first so the user echo lands after them", () => { + const { store } = setup(); + store.handleDelta(text("earlier reply")); + store.send("next question"); + const roles = store.chunks.map((c) => c.role); + expect(roles).toEqual(["assistant", "user"]); + store.dispose(); + }); + + it("chunks getter returns a stable array for an unchanged transcript", () => { + const { store, runScheduled } = setup(); + store.handleDelta(text("a")); + runScheduled(); + expect(store.chunks).toBe(store.chunks); + store.dispose(); + }); + + it("a flush after dispose is a no-op", () => { + const { store, scheduled } = setup(); + store.handleDelta(text("a")); + store.dispose(); + for (const f of scheduled) f(); + 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 26c5590..41cfb3b 100644 --- a/src/features/chat/test-helpers.ts +++ b/src/features/chat/test-helpers.ts @@ -1,4 +1,8 @@ -import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract"; +import type { + ChatQueueCancelMessage, + ChatQueueMessage, + ChatSendMessage, +} from "@dispatch/transport-contract"; import type { StoredChunk } from "@dispatch/wire"; import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; @@ -8,19 +12,25 @@ export interface FakeTransport { readonly sent: ChatSendMessage[]; /** All `chat.queue` messages sent through the fake transport. */ readonly sentQueue: ChatQueueMessage[]; + /** All `chat.queue.cancel` messages sent through the fake transport. */ + readonly sentCancels: ChatQueueCancelMessage[]; readonly impl: ChatTransport; } export function createFakeTransport(): FakeTransport { const sent: ChatSendMessage[] = []; const sentQueue: ChatQueueMessage[] = []; + const sentCancels: ChatQueueCancelMessage[] = []; return { sent, sentQueue, + sentCancels, impl: { send(msg) { if (msg.type === "chat.queue") { sentQueue.push(msg); + } else if (msg.type === "chat.queue.cancel") { + sentCancels.push(msg); } else { sent.push(msg); } @@ -33,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; }, @@ -62,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 }; }, }; } @@ -112,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 5f8067d..f98fdbb 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -3,12 +3,26 @@ 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"; import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; +/** Collapsed card bodies render lazily — open every card so its body mounts. */ +async function openAllCollapses(container: HTMLElement): Promise<void> { + const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]'); + for (const box of boxes) { + if (!box.checked) await userEvent.click(box); + } +} + describe("ChatView", () => { it("renders a message's text chunk", () => { const chunks: RenderedChunk[] = [ @@ -42,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: "" }, + 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 }, @@ -81,7 +114,7 @@ describe("ChatView", () => { expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); }); - it("renders tool-call chunks", () => { + it("renders tool-call chunks", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -96,16 +129,17 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); const pre = screen.getByText((content, element) => { return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); }); expect(pre).toBeInTheDocument(); }); - it("renders tool-result chunks", () => { + it("renders tool-result chunks", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -121,9 +155,10 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); expect(screen.getByText("file contents here")).toBeInTheDocument(); }); @@ -199,7 +234,7 @@ describe("ChatView", () => { expect(log.children).toHaveLength(0); }); - it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { + it("renders parallel tool calls (shared stepId) as separate bubbles", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -241,15 +276,18 @@ describe("ChatView", () => { ]; const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - // Batched calls render as collapsible cards (one per call), not a list. + // Parallel calls render as independent collapsible cards (one per bubble). const collapses = container.querySelectorAll(".collapse"); expect(collapses).toHaveLength(2); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2); + // Both call names + the available result are shown; the result is absorbed // (no standalone tool-result card). - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("list_dir")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); + expect(screen.getByText("List dir")).toBeInTheDocument(); expect(screen.getByText("contents-of-a")).toBeInTheDocument(); }); @@ -271,8 +309,7 @@ describe("ChatView", () => { expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon expect(collapse).not.toHaveClass("collapse-plus"); // Visible bubble, like tool cards. - expect(collapse).toHaveClass("bg-base-200"); - expect(collapse).toHaveClass("rounded-box"); + expect(collapse).toHaveClass("inset-well"); // shared recessed panel (app.css) expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); }); @@ -372,11 +409,39 @@ describe("ChatView", () => { expect(screen.getByText("Hi")).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/150 tok/)).toHaveLength(2); - expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 50 tok out · 100 in/)).toBeInTheDocument(); expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); }); + it("renders a per-step cache hit badge coloured by level", () => { + const step = { + stepId: "t1#0" as StepId, + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 750 }, + decodeMs: 100, + }; + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "t", + input: {}, + stepId: step.stepId, + }, + provisional: false, + }, + ]; + const turnMetrics: TurnMetricsEntry[] = [{ turnId: "t1", steps: [step], total: null }]; + render(ChatView, { props: { chunks, turnMetrics } }); + const badge = screen.getByTestId("step-cache-badge"); + expect(badge).toHaveTextContent("75%"); + expect(badge).toHaveClass("badge-success"); + }); + it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, @@ -477,11 +542,11 @@ describe("ChatView", () => { // Both step-metrics and turn-metrics render expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 20 tok out/)).toBeInTheDocument(); // They are in separate elements (different rows) - const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); - const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); + const stepEl = screen.getByText(/step 1 · 80 in \/ 20 out/).closest("div"); + const turnEl = screen.getByText(/turn 1 · 20 tok out/).closest("div"); expect(stepEl).not.toBe(turnEl); }); @@ -553,9 +618,9 @@ describe("ChatView", () => { // Step metrics rendered expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/15 tok/)).toHaveLength(2); + expect(screen.getByText(/step 1 · 10 in \/ 5 out/)).toBeInTheDocument(); // Turn metrics rendered - expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 5 tok out · 10 in/)).toBeInTheDocument(); // No "null" or "undefined" in the DOM expect(screen.queryByText("null")).toBeNull(); expect(screen.queryByText("undefined")).toBeNull(); @@ -601,8 +666,7 @@ describe("ChatView", () => { render(ChatView, { props: { chunks, turnMetrics } }); // Step metrics should render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/150 tok/)).toBeInTheDocument(); + expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument(); // Turn total should NOT render (total is null — turn still in progress) expect(screen.queryByText(/^turn/)).toBeNull(); @@ -707,7 +771,7 @@ describe("ChatView", () => { expect(container.querySelector("img")?.getAttribute("src")).toBe(url); }); - it("renders a consult_vision tool call/result like any other tool", () => { + it("renders a consult_vision tool call/result like any other tool", async () => { // read_image is GONE — replaced by consult_vision (opens a vision-model // conversation, attaches the image + question, returns the answer). It is // a normal tool call: rendered generically by toolName. @@ -737,9 +801,10 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0); + expect(screen.getAllByText("Ask vision model").length).toBeGreaterThan(0); expect(screen.getByText("a red square on a white background")).toBeInTheDocument(); }); @@ -784,6 +849,71 @@ describe("ChatView", () => { }); describe("Composer", () => { + it.each([ + { status: "idle", queuedMessageCount: 0, text: "", label: "Send" }, + { status: "running", queuedMessageCount: 0, text: "next", label: "Queue" }, + { status: "running", queuedMessageCount: 0, text: "", label: "Stop generation" }, + { status: "running", queuedMessageCount: 1, text: "", label: "Flush" }, + ] as const)("keeps $label at the same fixed width", async ({ label, text, ...props }) => { + const user = userEvent.setup(); + render(Composer, { props: { ...props, onSend: vi.fn(), onQueue: vi.fn(), onStop: vi.fn() } }); + if (text) await user.type(screen.getByRole("textbox", { name: "Message input" }), text); + const button = screen.getByRole("button", { name: label }); + expect(button).toHaveClass("w-20", "shrink-0"); + expect(button).not.toHaveClass("w-36"); + }); + + 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(); @@ -964,17 +1094,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(); - const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } }); + 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", 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 }); @@ -984,8 +1125,187 @@ 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(); + }); +}); + +describe("Composer image compression", () => { + /** Stage a PNG through the file picker and wait for the thumbnail. */ + async function stagePng(container: HTMLElement, content = "PNGDATA"): Promise<void> { + const file = new File([content], "shot.png", { type: "image/png" }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], writable: false }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + }); + } + + /** A fake converted JPEG (the outermost-edge stub — no canvas involved). */ + function fakeJpeg(): ConvertedJpeg { + return { blob: new Blob(["SMALLJPEG"], { type: "image/jpeg" }), mimeType: "image/jpeg" }; + } + + it("shows the compress toggle CHECKED by default for a staged PNG", async () => { + const { container } = render(Composer, { + props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) }, + }); + await stagePng(container); + + const toggle = screen.getByRole("checkbox", { name: "Compress image" }); + expect(toggle).toBeChecked(); + }); + + it("hides the compress toggle for already-lossy formats (JPEG)", async () => { + const { container } = render(Composer, { + props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) }, + }); + const file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], writable: false }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + }); + + expect(screen.queryByRole("checkbox", { name: "Compress image" })).not.toBeInTheDocument(); + }); + + it("sends the converted JPEG (image/jpeg) when compress is on, encoding exactly once", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + + // The async conversion lands and the staged URL reflects what will be sent. + await vi.waitFor(() => { + expect(encodeJpeg).toHaveBeenCalledTimes(1); + }); + await vi.waitFor(() => { + const thumb = container.querySelector("img[alt='image/jpeg']"); + expect(thumb).not.toBeNull(); + expect(thumb?.getAttribute("src")).toMatch(/^data:image\/jpeg;base64,/); + }); + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledTimes(1); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images).toHaveLength(1); + expect(images[0]?.mimeType).toBe("image/jpeg"); + expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("sends the ORIGINAL untouched bytes when the toggle is unchecked", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("checkbox", { name: "Compress image" })); + expect(screen.getByRole("checkbox", { name: "Compress image" })).not.toBeChecked(); + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledTimes(1); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/png"); + expect(images[0]?.url).toMatch(/^data:image\/png;base64,/); + }); + + it("re-checking uses the CACHED conversion — no second encode", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + const toggle = screen.getByRole("checkbox", { name: "Compress image" }); + await user.click(toggle); // OFF → original + await user.click(toggle); // ON again → must reuse the cached JPEG + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(encodeJpeg).toHaveBeenCalledTimes(1); // still exactly one encode + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/jpeg"); + expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("falls back to the original image when conversion fails", async () => { + const encodeJpeg = vi.fn(async () => { + throw new Error("decode exploded"); + }); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("button", { name: "Send" })); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/png"); + expect(images[0]?.url).toMatch(/^data:image\/png;base64,/); }); }); @@ -1006,7 +1326,9 @@ describe("ModelSelector", () => { expect(keySelect).toHaveValue("anthropic"); expect(modelSelect).toHaveValue("claude-3"); - expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); + // The synthetic "Named Agents" provider is ALWAYS first (plan §6), even with + // zero configured agents. + expect(optionValues(keySelect)).toEqual(["__named_agents__", "openai", "anthropic", "google"]); // only the models under the selected key expect(optionValues(modelSelect)).toEqual(["claude-3"]); }); @@ -1041,6 +1363,133 @@ describe("ModelSelector", () => { expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); }); + it("lists Named Agents under the synthetic provider and resolves a selection to a snapshot", async () => { + const onSelectNamedAgent = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4"]; + const namedAgents = [ + { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "a2", name: "Research Agent", model: "umans/umans-glm-5.2" }, + ]; + + const { rerender } = render(ModelSelector, { + props: { + models, + selected: "openai/gpt-4", + onSelect: vi.fn(), + namedAgents, + onSelectNamedAgent, + }, + }); + + // Switching the provider to "Named Agents" jumps to the first agent (mirrors + // a raw provider jumping to its first model). + await user.selectOptions( + screen.getByRole("combobox", { name: "Key selector" }), + "__named_agents__", + ); + expect(onSelectNamedAgent).toHaveBeenCalledWith({ + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + + // The parent (store) applies the selection → re-render with the resolved raw + // model + snapshot, exactly as `selectModel(model, snapshot)` would do. + const codingAgent = namedAgents[0]; + await rerender({ + models, + selected: codingAgent?.model ?? "", + onSelect: vi.fn(), + namedAgents, + namedAgentSnapshot: { + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }, + onSelectNamedAgent, + }); + + // The model dropdown now lists the named agents; pick a different one. + await user.selectOptions( + screen.getByRole("combobox", { name: "Model selector" }), + "Research Agent", + ); + expect(onSelectNamedAgent).toHaveBeenLastCalledWith({ + namedAgentId: "a2", + namedAgentName: "Research Agent", + model: "umans/umans-glm-5.2", + }); + }); + + it("shows a disabled placeholder when the synthetic provider has no configured agents", () => { + render(ModelSelector, { + props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() }, + }); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + const options = within(modelSelect).getAllByRole("option"); + // With the raw openai provider selected the model dropdown lists gpt-4; the + // "No named agents configured" placeholder appears only under the synthetic + // provider (which we'd select to see it). Assert the disabled/empty handling + // by checking the synthetic provider is present in the key selector. + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + expect(optionValues(keySelect)).toContain("__named_agents__"); + // Switch to the (empty) synthetic provider → disabled "No named agents configured". + expect(() => screen.getByRole("option", { name: /No named agents configured/ })).toThrow(); + expect(options).toHaveLength(1); + }); + + it("keeps showing a snapshot of a renamed/removed Named Agent (unavailable)", () => { + // A conversation selected via a Named Agent retains its snapshot even after + // the agent is gone from the current list (plan §7). + const namedAgentSnapshot = { + namedAgentId: "gone", + namedAgentName: "Old Agent", + model: "opencode-1/claude-sonnet-4.5", + }; + render(ModelSelector, { + props: { + models: [], + selected: "opencode-1/claude-sonnet-4.5", + onSelect: vi.fn(), + namedAgents: [], + namedAgentSnapshot, + }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + expect(keySelect).toHaveValue("__named_agents__"); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + expect(modelSelect).toHaveValue("Old Agent"); + expect(screen.getByRole("option", { name: /Old Agent.*unavailable/ })).toBeInTheDocument(); + }); + + it("selecting a raw model from a Named Agent conversation clears the snapshot via onSelect", async () => { + const onSelect = vi.fn(); + const onSelectNamedAgent = vi.fn(); + const user = userEvent.setup(); + const namedAgentSnapshot = { + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }; + render(ModelSelector, { + props: { + models: ["openai/gpt-4"], + selected: "opencode-1/claude-sonnet-4.5", + onSelect, + namedAgents: [{ id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }], + namedAgentSnapshot, + onSelectNamedAgent, + }, + }); + + // Switch from Named Agents to a raw provider → raw model selection clears the snapshot. + await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "openai"); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-4"); + expect(onSelectNamedAgent).not.toHaveBeenCalled(); + }); + it("marks vision-capable models in the model dropdown", () => { const models = ["kimi/k2", "kimi/k1.5"]; const modelInfo = { @@ -1089,33 +1538,95 @@ describe("ModelSelector", () => { }); expect(screen.getByText(/auto-described/i)).toBeInTheDocument(); }); + + it("labels numbered OpenCode keys as OpenCode #N and sorts them numerically", () => { + const models = ["opencode-10/m1", "opencode-2/m2", "opencode-1/m3"]; + const onSelect = vi.fn(); + render(ModelSelector, { + props: { models, selected: "opencode-2/m2", onSelect }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const options = within(keySelect).getAllByRole("option"); + // Numeric order: #1 < #2 < #10 (after the always-first synthetic provider), + // and values stay RAW (opencode-N). + expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([ + "__named_agents__", + "opencode-1", + "opencode-2", + "opencode-10", + ]); + expect(options[1]?.textContent).toContain("OpenCode #1"); + expect(options[2]?.textContent).toContain("OpenCode #2"); + expect(options[3]?.textContent).toContain("OpenCode #10"); + }); + + it("shows a disappeared saved credential as unavailable without silently falling back", () => { + // Saved selection references opencode-5, but the catalog no longer has it. + const models = ["opencode-1/deepseek-v4-flash"]; + const onSelect = vi.fn(); + render(ModelSelector, { + props: { models, selected: "opencode-5/deepseek-v4-flash", onSelect }, + }); + + // The unavailable key is surfaced as an explicit (disabled) option + notice. + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const unavailable = within(keySelect).getByText(/OpenCode #5 \(unavailable\)/i); + expect(unavailable).toBeInTheDocument(); + expect(screen.getByText(/Selected model .* unavailable/)).toBeInTheDocument(); + // No silent switch is attempted. + expect(onSelect).not.toHaveBeenCalled(); + }); }); describe("ReasoningEffortSelector", () => { - it("renders null (never set) as the default level, marked '(default)'", () => { - render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); + it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save: vi.fn() }, + }); const select = screen.getByRole("combobox", { name: "Reasoning effort" }); expect(select).toHaveValue("high"); expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); - // All five ladder levels are offered. - expect(within(select).getAllByRole("option")).toHaveLength(5); + // "Off" first, then the five ladder levels. + const options = within(select).getAllByRole("option"); + expect(options).toHaveLength(6); + expect(options[0]).toHaveValue("off"); + expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument(); }); - it("renders a persisted level as selected", () => { - render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); + it("renders a persisted level as selected when thinking is on", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() }, + }); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); }); + it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => { + // thinking off is a SEPARATE axis: even with a persisted effort level, the + // selector shows "off" while thinking is disabled. + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() }, + }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + // the level option is still present (restored on an off→on toggle) + expect( + within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }), + ).toBeInTheDocument(); + }); + it("selecting a level saves it via the injected port and confirms", async () => { - const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ ok: true as const, - reasoningEffort: level, + selection, })); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: null, save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); @@ -1127,11 +1638,35 @@ describe("ReasoningEffortSelector", () => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); }); - it("a failed save shows the error and reverts to the persisted value", async () => { + it("selecting 'off' saves the separate disable signal (not a level)", async () => { + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + selection, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { + props: { persistedEffort: "high", persistedThinking: null, save }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off"); + + expect(save).toHaveBeenCalledTimes(1); + // "off" is the separate thinking-disable signal — NOT a zero-effort level. + expect(save).toHaveBeenCalledWith("off"); + await vi.waitFor(() => { + expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + }); + + it("a failed save shows the error and reverts to the persisted selection", async () => { const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: "low", save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: "low", persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); @@ -1142,24 +1677,215 @@ describe("ReasoningEffortSelector", () => { }); it("disables the select while a save is in flight (no double-fire)", async () => { - let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; + let resolveSave: ((r: { ok: true; selection: "max" }) => void) | undefined; const save = vi.fn( () => - new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { + new Promise<{ ok: true; selection: "max" }>((resolve) => { resolveSave = resolve; }), ); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: null, save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); - resolveSave?.({ ok: true, reasoningEffort: "max" }); + resolveSave?.({ ok: true, selection: "max" }); await vi.waitFor(() => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); }); }); }); + +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 e67ca5b..84e66d8 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -2,14 +2,19 @@ import type { TurnProviderRetryEvent } from "@dispatch/wire"; import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; import { + type CacheTimerState, interleaveTurnMetrics, + lastTurnRowIndex, viewCacheRate, viewExpectedCache, viewStepMetrics, viewTurnMetrics, type TurnMetricsEntry, } from "../../../core/metrics"; - import { Markdown } from "../../markdown"; + import { Markdown, type TextFileClient } from "../../markdown"; + import CacheTimerBadge from "./CacheTimerBadge.svelte"; + import LazyCollapse from "./LazyCollapse.svelte"; + import ToolCard from "./ToolCard.svelte"; const badgeClass = { success: "badge-success", @@ -19,14 +24,19 @@ let { chunks, + recovering = false, turnMetrics = [], hasEarlier = false, onShowEarlier, thinkingKeyBase = 0, providerRetry = null, apiBaseUrl = "", + cwd = null, + textFiles = undefined, + cacheTimer = null, }: { chunks: readonly RenderedChunk[]; + recovering?: boolean; turnMetrics?: readonly TurnMetricsEntry[]; /** Earlier history is unloaded (chat limit) and can be paged back in. */ hasEarlier?: boolean; @@ -53,10 +63,27 @@ * "" (root-relative — a browser resolves `/images/…` against its origin). */ apiBaseUrl?: string; + /** + * The active conversation's working directory (null when unset). Relative + * Markdown image srcs (``) resolve against it; local absolute + * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`. + */ + cwd?: string | null; + /** Context-bound, injected live-file display port (never model history). */ + textFiles?: TextFileClient | undefined; + /** + * 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; @@ -68,10 +95,36 @@ } } - 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 // collapse's open/close state is NOT lost when a turn seals. The ordinal @@ -88,8 +141,8 @@ } const group = row.group; let key: string; - if (group.kind === "tool-batch") { - key = `b${group.stepId}`; + if (group.kind === "tool") { + key = `t${group.entry.toolCallId}`; } else if (group.chunk.chunk.type === "thinking") { key = `think${thinking++}`; } else if (group.chunk.seq != null) { @@ -102,6 +155,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 @@ -114,13 +173,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> @@ -130,83 +198,15 @@ while generating, then "Thoughts" with no dots once complete. --> <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> <div class="chat-bubble w-full bg-transparent"> - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle thoughts" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <LazyCollapse label="Toggle thoughts"> + {#snippet title()} <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span> {#if rendered.streaming} <span class="loading loading-dots loading-sm" aria-label="Generating"></span> {/if} - </div> - <div class="collapse-content"> - <p class="whitespace-pre-wrap">{rendered.chunk.text}</p> - </div> - </div> - </div> - </div> - {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - <!-- Single tool call/result: a collapsible card (collapsed by default, - like thinking). Title shows the tool name; content shows the - input/output. Same chat-start grid shim as the thinking block. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - {#if rendered.chunk.type === "tool-call"} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{rendered.chunk.toolName}</span> - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify( - rendered.chunk.input, - null, - 2, - )}</pre> - </div> - </div> - {:else} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool result" /> - <div - class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" - class:text-error={rendered.chunk.isError} - > - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{rendered.chunk.toolName}</span> - {#if rendered.chunk.isError} - <span class="badge badge-error badge-xs">error</span> - {/if} - </div> - <div class="collapse-content"> - <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre> - </div> - </div> - {/if} + {/snippet} + <p class="whitespace-pre-wrap">{rendered.chunk.type === "thinking" ? rendered.chunk.text : ""}</p> + </LazyCollapse> </div> </div> {:else} @@ -215,7 +215,12 @@ <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 }} + {textFiles} + /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} @@ -245,17 +250,33 @@ </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="text-xs opacity-70"> - {sv.label} · {sv.tokensLabel} - {#if sv.tps} - · {sv.tps}{/if} - {#if sv.genTotal} - · {sv.genTotal}{/if} + <div class="flex flex-wrap items-center gap-x-1 text-xs"> + <span class="opacity-70"> + {sv.label} · {sv.tokensLabel} + {#if sv.tps} + · {sv.tps}{/if} + {#if sv.ttft} + · {sv.ttft} to first token{/if} + · + </span> + <span class="opacity-70">cache:</span> + <span + 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> @@ -263,12 +284,14 @@ {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} - {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} + {@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"> <div class="opacity-70"> - {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) + {turnView.label} · {turnView.tokensLabel} · {turnView.breakdown} {#if turnView.tps} · {turnView.tps}{/if} {#if turnView.duration} @@ -289,6 +312,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> @@ -296,54 +324,27 @@ {:else if row.group.kind === "single"} {@render chunkRow(row.group.chunk)} {:else} - <!-- Batched tool calls (one step): each entry is a collapsible card. - Click to expand and see the input/output. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - <div class="flex flex-col gap-1"> - {#each row.group.entries as entry (entry.call.toolCallId)} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{entry.call.toolName}</span> - {#if entry.result?.isError} - <span class="badge badge-error badge-xs">error</span> - {:else if entry.result === null} - <span class="loading loading-spinner loading-xs" aria-label="Running"></span> - {/if} - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify( - entry.call.input, - null, - 2, - )}</pre> - {#if entry.result} - <pre - class="mt-1 max-h-96 overflow-auto text-xs" - class:text-error={entry.result.isError}>{entry.result.content}</pre> - {/if} - </div> - </div> - {/each} - </div> + <!-- Each call owns one bubble; the matching result is folded into its card. --> + <div class="chat chat-start [grid-template-columns:auto_minmax(0,1fr)] [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble min-w-0 w-full bg-transparent"> + <ToolCard entry={row.group.entry} /> </div> </div> {/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 new file mode 100644 index 0000000..157ccac --- /dev/null +++ b/src/features/chat/ui/ChatView.test.ts @@ -0,0 +1,558 @@ +import type { StepId } from "@dispatch/wire"; +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"; +import ChatView from "./ChatView.svelte"; + +/** Collapsed card bodies render lazily — open every card so its body mounts. */ +async function openAllCollapses(container: HTMLElement): Promise<void> { + const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]'); + for (const box of boxes) { + if (!box.checked) await userEvent.click(box); + } +} + +// Defensive rendering: a conversation transcript can contain duplicate +// `toolCallId`s (persisted provider double-emission). The +// grouped render path MUST dedupe them so the keyed {#each} never throws +// Svelte `each_key_duplicate` (which aborts the reactive flush and makes the +// tab appear dead). See fix-toolcall-duplicate-keys plan Part 1. +describe("ChatView — duplicate toolCallIds", () => { + it("renders duplicated toolCallIds without throwing, showing one bubble per unique call", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + // The duplicate-emission pattern: the same ids re-emitted immediately after. + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 4, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 5, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 6, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "b", + toolName: "list_dir", + content: "contents-of-b", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + // Duplicate results too — absorbed into the corresponding cards. + { + seq: 7, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 8, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "b", + toolName: "list_dir", + content: "contents-of-b", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); + + // No each_key_duplicate crash. Calls render as independent collapsible cards — + // exactly ONE per unique toolCallId (deduped), not 4. + const collapses = container.querySelectorAll(".collapse"); + expect(collapses).toHaveLength(2); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2); + + // Both unique tool names shown; each result rendered once (deduped). + expect(screen.getByText("Read file")).toBeInTheDocument(); + expect(screen.getByText("List dir")).toBeInTheDocument(); + expect(screen.getByText("contents-of-a")).toBeInTheDocument(); + expect(screen.getByText("contents-of-b")).toBeInTheDocument(); + }); +}); + +describe("ChatView — lazy collapse bodies", () => { + it("does not mount a collapsed card's body until it is first opened, then keeps it", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "x", + toolName: "run_shell", + content: "HUGE-OUTPUT", + isError: false, + }, + provisional: false, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + // English display name; the raw wire name stays available as the tooltip. + expect(screen.getByText("Run command")).toHaveAttribute("title", "run_shell"); + expect(screen.queryByText("HUGE-OUTPUT")).toBeNull(); + + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument(); + + await userEvent.click(box); // close: body stays mounted (close animation) + expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument(); + expect(container.querySelectorAll(".chat-row")).toHaveLength(1); + }); +}); + +function shellChunks(content: string | null, isError = false, committed = false): RenderedChunk[] { + const call: RenderedChunk = { + seq: committed ? 1 : null, + role: "assistant", + provisional: !committed, + chunk: { + type: "tool-call", + toolCallId: "shell-1", + toolName: "run_shell", + input: { command: "bun run test" }, + }, + }; + return content === null + ? [call] + : [ + call, + { + seq: committed ? 2 : null, + role: "tool", + provisional: !committed, + chunk: { + type: "tool-result", + toolCallId: "shell-1", + toolName: "run_shell", + content, + isError, + }, + }, + ]; +} + +describe("ChatView — tool cards", () => { + it("pairs a single call/result into one collapsed bubble with both summaries and a success icon", async () => { + const { container } = render(ChatView, { props: { chunks: shellChunks("first\nsecond") } }); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1); + expect(container.querySelectorAll(".collapse")).toHaveLength(1); + expect(screen.getByText("bun run test")).toBeInTheDocument(); + expect(screen.getByText("→ 2 lines")).toBeInTheDocument(); + expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + expect(box).not.toBeChecked(); + expect(container.querySelector("pre")).toBeNull(); + await userEvent.click(box); + expect(screen.getByText("$ bun run test")).toBeInTheDocument(); + expect(screen.getByText("first second")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull(); + }); + it("transitions pending → result → sealed without losing open state", async () => { + const { rerender } = render(ChatView, { props: { chunks: shellChunks(null) } }); + expect(screen.getByLabelText("Running")).toHaveClass("loading-dots", "loading-sm"); + expect(screen.getByText("→ running…")).toHaveClass("tool-result", "text-base-content/60"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByText("running…")).toBeInTheDocument(); + await rerender({ chunks: shellChunks("done") }); + expect(screen.queryByLabelText("Running")).toBeNull(); + expect(screen.queryByText("→ running…")).toBeNull(); + expect(screen.getByLabelText("Succeeded")).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + await rerender({ chunks: shellChunks("done", false, true) }); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + expect(screen.getByText("done")).toBeInTheDocument(); + }); + it("keeps errors collapsed with a failure icon and tints the expanded output", async () => { + const { container } = render(ChatView, { + props: { chunks: shellChunks("Permission denied", true) }, + }); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + expect(box).not.toBeChecked(); + expect(screen.getByLabelText("Failed")).toHaveClass("text-error"); + expect(screen.getByLabelText("Failed")).toHaveTextContent("✗"); + expect(screen.getByText("→ failed").closest(".tool-result")).toHaveClass("text-error"); + expect(container.querySelector(".tool-header")).not.toHaveTextContent("Permission denied"); + expect(container.querySelector("pre")).toBeNull(); + await userEvent.click(box); + expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error"); + }); + it("renders an orphan result as the same card and retains state when its call is paged back in", async () => { + const chunks = shellChunks("contents", false, true); + const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } }); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1); + expect(screen.getByText("Call in earlier history")).toHaveClass("earlier-history"); + expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByRole("note")).toHaveTextContent( + "This call is earlier in the conversation. Show earlier messages to see it.", + ); + expect(screen.getByRole("note")).toHaveClass("italic", "text-base-content/60"); + expect(screen.queryByRole("heading", { name: "Call" })).toBeNull(); + expect(screen.getByText("contents")).toBeInTheDocument(); + await rerender({ chunks }); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + expect(screen.getByText("$ bun run test")).toBeInTheDocument(); + }); + it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => { + const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n"); + const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } }); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + const response = screen.getByRole("region", { name: "Response" }); + expect(response).toHaveTextContent("output-1"); + expect(response).toHaveTextContent("output-6"); + expect(response).not.toHaveTextContent("output-7"); + expect(response).toHaveTextContent("output-8"); + expect(response).toHaveTextContent("output-13"); + const toggle = screen.getByRole("button", { name: "⋯ 1 more lines" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + expect(response).toHaveTextContent("output-7"); + expect(response.querySelectorAll("pre")).toHaveLength(1); + expect(response.querySelector("pre")?.textContent).toBe(output); + const fewer = screen.getByRole("button", { name: "Show fewer lines" }); + expect(fewer).toHaveClass("btn-soft", "btn-xs"); + expect(fewer.parentElement).toHaveClass("sticky-controls"); + const fullOutput = response.querySelector("pre"); + if (fullOutput === null) throw new Error("expected full output"); + expect(fewer.compareDocumentPosition(fullOutput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(box).toBeChecked(); + expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await userEvent.click(screen.getByRole("button", { name: "Show fewer lines" })); + expect(response).not.toHaveTextContent("output-7"); + expect(box).toBeChecked(); + // The separate output-fold state also survives sealing. + await userEvent.click(screen.getByRole("button", { name: "⋯ 1 more lines" })); + expect(response).toHaveTextContent("output-7"); + await rerender({ chunks: shellChunks(output, false, true) }); + expect(box).toBeChecked(); + expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(response).toHaveTextContent("output-7"); + }); + it("shows 12 output lines without an inner toggle", async () => { + const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n"); + render(ChatView, { props: { chunks: shellChunks(output) } }); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })); + expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull(); + expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12"); + }); + it("renders edit details and treats tool output as text, not executable markup", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "edit", + toolName: "edit_file", + input: { path: "a.ts", oldString: "before", newString: "after\nsecond" }, + }, + }, + { + seq: 2, + role: "tool", + provisional: false, + chunk: { + type: "tool-result", + toolCallId: "edit", + toolName: "edit_file", + content: 'Replaced 1 occurrence(s) in "a.ts".\n<script>alert(1)</script>', + isError: false, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + expect(screen.getByText("+2")).toHaveClass("text-success"); + expect(screen.getByText("−1")).toHaveClass("text-error"); + expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" })); + // Unified diff: one tinted row per line, each with its own −/+ gutter marker. + const diff = screen.getByRole("group", { name: "Edit diff" }); + const removed = diff.querySelectorAll(".edit-diff-del"); + const added = diff.querySelectorAll(".edit-diff-add"); + expect(Array.from(removed, (r) => r.textContent?.trim())).toEqual(["−before"]); + expect(Array.from(added, (r) => r.textContent?.trim())).toEqual(["+after", "+second"]); + expect(container.querySelector("script")).toBeNull(); + expect(container).toHaveTextContent("<script>alert(1)</script>"); + }); +}); + +it("renders compact, aligned generic fields with legible headings and inline scalar arrays", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "issue", + toolName: "mcp__github__create_issue", + input: { + owner: "realtradam", + repo: "frontend", + title: "Tool cards", + labels: ["ui", "chat"], + options: { nested: true }, + }, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + await userEvent.click( + screen.getByRole("checkbox", { name: "Toggle tool call Github: Create issue" }), + ); + for (const name of ["Call", "Response"]) { + expect(screen.getByRole("heading", { name })).toHaveClass("font-semibold", "text-base-content"); + } + const call = screen.getByRole("region", { name: "Call" }); + expect(call.querySelector("dl")).toHaveClass("call-fields"); + expect(screen.getByText("owner")).toHaveClass("font-semibold", "text-base-content/85"); + const labels = screen.getByText('["ui", "chat"]'); + expect(labels.tagName).toBe("PRE"); + expect(labels.textContent).not.toContain("\n"); + expect(container.querySelectorAll("dt")).toHaveLength(5); + expect(container.querySelectorAll("dd")).toHaveLength(5); + expect(screen.getByText("options").nextElementSibling).toHaveTextContent('"nested": true'); +}); + +it("removes a pending edit's diffstat when it fails, without hiding the attempted edit", async () => { + const call: RenderedChunk = { + seq: null, + role: "assistant", + provisional: true, + chunk: { + type: "tool-call", + toolCallId: "failed-edit", + toolName: "edit_file", + input: { path: "src/missing.ts", oldString: "foo", newString: "bar" }, + }, + }; + const result: RenderedChunk = { + seq: null, + role: "tool", + provisional: true, + chunk: { + type: "tool-result", + toolCallId: "failed-edit", + toolName: "edit_file", + content: "Error: oldString not found", + isError: true, + }, + }; + const { container, rerender } = render(ChatView, { props: { chunks: [call] } }); + expect(screen.getByText("+1")).toBeInTheDocument(); + expect(screen.getByText("−1")).toBeInTheDocument(); + expect(screen.getByText("→ running…")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" })); + await rerender({ chunks: [call, result] }); + expect(screen.queryByText("+1")).toBeNull(); + expect(screen.queryByText("−1")).toBeNull(); + expect(screen.queryByText("→ running…")).toBeNull(); + expect(container.querySelector(".tool-header")).toHaveTextContent("src/missing.ts"); + expect(container.querySelector(".tool-header")).toHaveTextContent("→ failed"); + expect(screen.getByText("foo")).toBeInTheDocument(); + expect(screen.getByText("bar")).toBeInTheDocument(); + expect(screen.getByText("Error: oldString not found")).toBeInTheDocument(); +}); + +it("shows an argument-less call without `{}` — empty header summary, 'No arguments' in the body", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { type: "tool-call", toolCallId: "m", toolName: "list_agent_models", input: {} }, + }, + { + seq: 2, + role: "tool", + provisional: false, + chunk: { + type: "tool-result", + toolCallId: "m", + toolName: "list_agent_models", + content: "Available named agents (3)", + isError: false, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + expect(screen.getByText("List agent models")).toBeInTheDocument(); + expect(container).not.toHaveTextContent("{}"); + await userEvent.click( + screen.getByRole("checkbox", { name: "Toggle tool call List agent models" }), + ); + expect(screen.getByText("No arguments")).toBeInTheDocument(); + expect(container).not.toHaveTextContent("{}"); +}); + +it("renders a per-tool Phosphor icon in the header", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "s", + toolName: "run_shell", + input: { command: "ls" }, + }, + }, + { + seq: 2, + role: "assistant", + provisional: false, + chunk: { type: "tool-call", toolCallId: "r", toolName: "read_file", input: { path: "a.ts" } }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + const icons = Array.from(container.querySelectorAll(".tool-header svg[aria-hidden='true']")); + expect(icons).toHaveLength(2); + // 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 04c28cd..cd451bc 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,5 +1,8 @@ <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"; const FALLBACK_CONTEXT_WINDOW = 1_000_000; @@ -8,20 +11,56 @@ const IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp"; /** Reject images larger than this before base64-encoding (keeps payloads sane). */ const MAX_IMAGE_BYTES = 8 * 1024 * 1024; + /** Tooltip on the per-image compress toggle (PNG → JPEG + resize). */ + const COMPRESS_TOOLTIP = `Re-encode PNG to JPEG and resize to ${MAX_IMAGE_EDGE_PX}px (smaller upload). Unchecked sends the original bytes.`; - /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */ + /** + * A staged image awaiting send: a stable id + everything needed to resolve + * the `ImageInput` that will actually be sent. The ORIGINAL `File` (and its + * data URL) is kept so the per-image compress toggle is reversible until + * send; the async conversion result is cached so flipping the toggle never + * re-encodes. + */ interface StagedImage { readonly id: string; - readonly input: ImageInput; + /** The original untouched File — sent verbatim when compress is off. */ + readonly file: File; + /** Data URL of the original (staged immediately — conversion is async). */ + readonly originalUrl: string; + readonly originalMimeType: string | undefined; + /** Whether the converted (JPEG) variant is used for send. Default ON. */ + compress: boolean; + /** Cached conversion (data URL of the JPEG); `null` = none/failed/n.a. */ + converted: { url: string } | null; + } + + /** + * The `ImageInput` this staged image will actually SEND: the converted JPEG + * (data URL + `image/jpeg`) when compress is on AND a conversion exists, + * otherwise the original untouched bytes. + */ + function imageInput(img: StagedImage): ImageInput { + if (img.compress && img.converted !== null) { + return { url: img.converted.url, mimeType: "image/jpeg" }; + } + const { originalUrl: url, originalMimeType: mimeType } = img; + return { url, ...(mimeType ? { mimeType } : {}) }; + } + + /** Whether the compress toggle is rendered for this image (convertible formats only). */ + function compressible(img: StagedImage): boolean { + return shouldConvertImage(img.originalMimeType); } let { onSend, onQueue, onStop, + encodeJpeg = convertToJpeg, contextSize = undefined, contextWindow = undefined, status = "idle", + queuedMessageCount = undefined, }: { /** * Send a message (start a turn via `chat.send`). Carries any staged images @@ -33,12 +72,17 @@ /** * 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; + /** + * Injected compression edge (a `File` in, a maybe-smaller JPEG out). + * Defaults to the real canvas converter; tests stub this outermost edge. + */ + encodeJpeg?: ImageJpegEncoder; // Current context occupancy — updated progressively during a turn (the // latest step's input+output) and finalized to the turn's `contextSize` on // seal, or `undefined` when unknown — the status bar then shows @@ -53,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"; @@ -65,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…" @@ -120,8 +164,8 @@ resize(); }); - /** Read a File into a base64 data URL (`data:image/…;base64,…`). */ - function fileToDataUrl(file: File): Promise<string> { + /** Read a File/Blob into a base64 data URL (`data:image/…;base64,…`). */ + function fileToDataUrl(file: Blob): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { @@ -142,10 +186,51 @@ // Prefer the file's declared MIME; fall back to the data URL's prefix. const mimeType = file.type || undefined; const id = `img-${Date.now()}-${imgSeq++}`; - images = [...images, { id, input: { url, ...(mimeType ? { mimeType } : {}) } }]; + images = [ + ...images, + { + id, + file, + originalUrl: url, + originalMimeType: mimeType, + compress: true, // compress is ON by default for every staged image + converted: null, + }, + ]; + // Convert async in the background — staging is never blocked on it; the + // staged input stays the original until the converted URL arrives. + if (shouldConvertImage(mimeType)) { + void runConversion(id); + } return true; } + /** + * Run the (injected) compression edge for a staged image and cache the + * result. ANY failure (rejecting encoder included) resolves to no + * conversion → the original is sent; compression never breaks staging. If + * the image was removed while converting, the result is dropped. + */ + async function runConversion(id: string): Promise<void> { + const staged = images.find((img) => img.id === id); + if (staged === undefined) return; + let result: ConvertedJpeg | null = null; + try { + result = await encodeJpeg(staged.file); + } catch { + result = null; // fall back to the original — never block or break sending + } + const entry = images.find((img) => img.id === id); + if (entry === undefined) return; // removed mid-conversion + entry.converted = result === null ? null : { url: await fileToDataUrl(result.blob) }; + } + + /** Flip the per-image compress toggle (reversible until send). */ + function setCompress(id: string, compress: boolean): void { + const entry = images.find((img) => img.id === id); + if (entry !== undefined) entry.compress = compress; + } + function removeImage(id: string): void { images = images.filter((img) => img.id !== id); } @@ -200,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) => img.input) - : 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 = []; } @@ -239,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} @@ -293,10 +378,11 @@ <button class="btn btn-error w-20 shrink-0" 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}> @@ -310,9 +396,10 @@ <div class="flex flex-wrap gap-2 px-4 pb-1"> {#each images as img (img.id)} <div class="group relative h-20 w-20 shrink-0 overflow-hidden rounded border border-base-300"> + <!-- Preview what will actually be sent (the converted JPEG when compress is on). --> <img - src={img.input.url} - alt={img.input.mimeType ?? "staged image"} + src={imageInput(img).url} + alt={imageInput(img).mimeType ?? "staged image"} class="h-full w-full object-cover" /> <button @@ -335,6 +422,22 @@ <line x1="6" y1="6" x2="18" y2="18"></line> </svg> </button> + <!-- Per-image compress toggle (convertible formats only; reversible until send). --> + {#if compressible(img)} + <label + class="absolute bottom-0 left-0 flex items-center gap-1 bg-base-100/80 px-1 py-0.5 text-[10px] leading-none" + title={COMPRESS_TOOLTIP} + > + <input + type="checkbox" + class="checkbox checkbox-xs" + aria-label="Compress image" + checked={img.compress} + onchange={(e) => setCompress(img.id, e.currentTarget.checked)} + /> + <span class="whitespace-nowrap">Compress</span> + </label> + {/if} </div> {/each} </div> diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte new file mode 100644 index 0000000..3ce34dd --- /dev/null +++ b/src/features/chat/ui/LazyCollapse.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + + /** + * A daisyUI checkbox collapse whose BODY is rendered lazily: nothing inside + * `.collapse-content` is created until the user first opens it. A long turn + * has hundreds of collapsed tool/thinking cards, each holding a full JSON + * input / tool output / thought — building all of that DOM up front (only to + * be hidden) dominated first paint on slow devices. Once opened the body + * stays mounted, so the close animation and any inner scroll state work as + * before. Same markup + classes as the inline collapses it replaces. + */ + let { + label, + titleClass = "", + title, + children, + }: { + /** aria-label of the toggle checkbox. */ + label: string; + /** Extra classes on the `.collapse-title` row. */ + titleClass?: string; + title: Snippet; + children: Snippet; + } = $props(); + + let open = $state(false); + let everOpened = $state(false); + $effect(() => { + if (open) everOpened = true; + }); +</script> + +<!-- `.inset-well` (app.css): the recessed panel shared by thinking/tool blocks + and the sidebar views. --> +<div class="collapse inset-well w-full text-sm"> + <input type="checkbox" aria-label={label} bind:checked={open} /> + <div class="collapse-title flex min-h-0 min-w-0 items-center gap-2 py-2 whitespace-nowrap font-medium {titleClass}"> + {@render title()} + </div> + <div class="collapse-content"> + {#if open || everOpened} + {@render children()} + {/if} + </div> +</div> diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index 11b9feb..a9b0cc9 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,12 +1,28 @@ <script lang="ts"> import type { ModelMetadata } from "@dispatch/transport-contract"; - import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; + import type { NamedAgent, NamedAgentSelectionSnapshot } from "../../named-agents"; + import { + isNamedAgentSelection, + isVisionModel, + joinModelName, + modelKeyLabel, + modelsForKey, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + providerKeysWithNamedAgents, + splitModelName, + } from "../model-select"; let { models, selected, onSelect, modelInfo = {}, + namedAgents = [], + namedAgentSnapshot = null, + onSelectNamedAgent, + zeroProviders = false, }: { models: readonly string[]; selected: string; @@ -18,75 +34,199 @@ * Optional — absent metadata → no badge (treated as non-vision). */ modelInfo?: Readonly<Record<string, ModelMetadata>>; + /** The user-curated Named Agents (plan §6) offered under the synthetic provider. */ + namedAgents?: readonly NamedAgent[]; + /** The active conversation's Named Agent snapshot (null when not selected via one). */ + namedAgentSnapshot?: NamedAgentSelectionSnapshot | null; + /** Called when a Named Agent is selected (the selector resolves it to a raw model + snapshot). */ + onSelectNamedAgent?: (snapshot: NamedAgentSelectionSnapshot) => void; + /** + * First-boot guardrail (§7): true when the ACTIVE workspace has ZERO + * enabled providers (plan 06's `providers-status`) — renders a visible + * prompt above the picker instead of a silently empty one. + */ + zeroProviders?: boolean; } = $props(); - const keys = $derived(modelKeys(models)); + // Whether the current selection is a Named Agent snapshot (the conversation was + // selected via one; its captured raw model equals `selected`). + const currentIsNamedAgent = $derived(isNamedAgentSelection(selected, namedAgentSnapshot)); + + const providerKeys = $derived(providerKeysWithNamedAgents(models)); const current = $derived(splitModelName(selected)); const keyModels = $derived(modelsForKey(models, current.key)); + // The provider shown in the provider dropdown: the synthetic Named Agents + // provider when the selection is a Named Agent snapshot, else the raw key. + const currentProvider = $derived( + currentIsNamedAgent || current.key === NAMED_AGENTS_PROVIDER + ? NAMED_AGENTS_PROVIDER + : current.key, + ); + + const namedNames = $derived(namedAgentNames(namedAgents)); + // Whether the currently-selected full model name is vision-capable. const selectedVision = $derived(isVisionModel(modelInfo, selected)); - // Switching key jumps to the first model available under it. - function selectKey(key: string): void { + // Whether the currently-selected raw model is PRESENT in the catalog (only + // meaningful for a raw selection — a Named Agent snapshot resolves to a raw + // model that may have left the catalog). + const rawMissing = $derived( + !currentIsNamedAgent && selected !== "" && !models.includes(selected), + ); + const keyUnavailable = $derived( + !currentIsNamedAgent && selected !== "" && !providerKeys.includes(current.key), + ); + + // Switching provider. Going TO the Named Agents provider: jump to the first + // Named Agent (mirrors selectKey jumping to the first model); the model + // dropdown then lists named names. Going to a raw provider: existing behavior. + function selectProvider(key: string): void { + if (key === currentProvider) return; + if (key === NAMED_AGENTS_PROVIDER) { + selectFirstNamedAgent(); + return; + } const first = modelsForKey(models, key)[0] ?? ""; onSelect(joinModelName(key, first)); } - function selectModel(model: string): void { + // Selecting a raw model suffix under the current raw provider. + function selectRawModel(model: string): void { onSelect(joinModelName(current.key, model)); } - // The full `<key>/<model>` name for a model suffix under the current key. + // Selecting a Named Agent name (under the synthetic provider): resolve it to a + // snapshot + raw model and report via onSelectNamedAgent. + function selectNamedAgent(name: string): void { + const agent = namedAgents.find((a) => a.name === name); + if (agent === undefined) return; + onSelectNamedAgent?.({ + namedAgentId: agent.id, + namedAgentName: agent.name, + model: agent.model, + }); + } + + // Jump to the first configured Named Agent (used when the provider is switched + // to Named Agents), matching the raw-provider "jump to first model" behavior. + function selectFirstNamedAgent(): void { + const first = namedAgents[0]; + if (first === undefined) return; // no named agents — the model dropdown shows the disabled placeholder + onSelectNamedAgent?.({ + namedAgentId: first.id, + namedAgentName: first.name, + model: first.model, + }); + } + + // The full `<provider>/<model>` name for a raw model suffix under `current.key`. function fullNameFor(modelSuffix: string): string { return joinModelName(current.key, modelSuffix); } </script> <div class="flex flex-col gap-2"> + {#if zeroProviders} + <!-- First-boot guardrail (§7): a workspace with zero enabled providers + shows a visible prompt instead of a silently empty picker. --> + <div class="alert alert-warning text-sm" role="alert"> + <span> + No providers enabled for this workspace — open the workspace <strong>Plugins</strong> to + enable one. + </span> + </div> + {/if} <select class="select w-full" - value={current.key} - onchange={(e) => selectKey(e.currentTarget.value)} + value={currentProvider} + onchange={(e) => selectProvider(e.currentTarget.value)} aria-label="Key selector" > - {#each keys as key (key)} - <option value={key}>{key}</option> + {#if keyUnavailable && currentProvider !== NAMED_AGENTS_PROVIDER} + <option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option> + {/if} + {#each providerKeys as key (key)} + <option value={key}> + {key === NAMED_AGENTS_PROVIDER ? "Named Agents" : modelKeyLabel(key)} + </option> {/each} </select> + <select class="select w-full" - value={current.model} - onchange={(e) => selectModel(e.currentTarget.value)} + value={currentIsNamedAgent ? (namedAgentSnapshot?.namedAgentName ?? "") : current.model} + onchange={ + currentIsNamedAgent + ? (e) => selectNamedAgent(e.currentTarget.value) + : (e) => selectRawModel(e.currentTarget.value) + } aria-label="Model selector" + disabled={currentProvider === NAMED_AGENTS_PROVIDER && namedAgents.length === 0} > - {#each keyModels as model (model)} - <option value={model}> - {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if} - </option> - {/each} + {#if currentProvider === NAMED_AGENTS_PROVIDER} + {#if namedAgents.length === 0 && !currentIsNamedAgent} + <option value="" disabled>No named agents configured</option> + {:else} + {#each namedNames as name (name)} + <option value={name}>{namedAgentSelectionLabel(name)}</option> + {/each} + {#if currentIsNamedAgent && !namedNames.includes(namedAgentSnapshot?.namedAgentName ?? "")} + <!-- The selected Named Agent was renamed/removed — keep showing the + captured snapshot name rather than silently switching (plan §7). --> + <option value={namedAgentSnapshot?.namedAgentName ?? ""} disabled> + {namedAgentSelectionLabel(namedAgentSnapshot?.namedAgentName ?? "")} (unavailable) + </option> + {/if} + {/if} + {:else} + {#each keyModels as model (model)} + <option value={model}> + {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if} + </option> + {/each} + {#if rawMissing && !keyUnavailable} + <option value={current.model} disabled>{current.model} (unavailable)</option> + {/if} + {/if} </select> - {#if selectedVision} - <div class="flex items-center gap-1 text-xs text-base-content/60"> - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - class="h-3.5 w-3.5" - aria-hidden="true" - > - <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> - <circle cx="12" cy="12" r="3"></circle> - </svg> - <span>Vision — this model sees images natively</span> + + {#if rawMissing} + <div class="text-xs text-warning" role="status"> + Selected model “{selected}” is unavailable — choose another credential. </div> - {:else} + {/if} + + {#if currentIsNamedAgent} <div class="text-xs text-base-content/40"> - Pasted images are auto-described (vision handoff) + Selected via <strong>Named Agents</strong> — snapshot of {namedAgentSnapshot?.namedAgentName}. </div> {/if} -</div> + + {#if !currentIsNamedAgent} + {#if selectedVision} + <div class="flex items-center gap-1 text-xs text-base-content/60"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3.5 w-3.5" + aria-hidden="true" + > + <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> + <circle cx="12" cy="12" r="3"></circle> + </svg> + <span>Vision — this model sees images natively</span> + </div> + {:else} + <div class="text-xs text-base-content/40"> + Pasted images are auto-described (vision handoff) + </div> + {/if} + {/if} +</div>
\ No newline at end of file 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/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte index d982905..6858779 100644 --- a/src/features/chat/ui/ReasoningEffortSelector.svelte +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -1,34 +1,45 @@ <script lang="ts"> import type { ReasoningEffort } from "@dispatch/transport-contract"; import { - effectiveEffort, - effortOptions, - isReasoningEffort, - type SaveReasoningEffort, + effectiveSelection, + isThinkingSelection, + selectionOptions, + type SaveThinkingSelection, + type ThinkingSelection, } from "../reasoning-effort"; let { - persisted, + persistedEffort, + persistedThinking, save, }: { - /** The conversation's persisted level, or null when never set (default applies). */ - persisted: ReasoningEffort | null; - save: SaveReasoningEffort; + /** The conversation's persisted effort level, or null when never set (default applies). */ + persistedEffort: ReasoningEffort | null; + /** + * The conversation's persisted thinking flag, or null when never set + * (thinking ON — the default). `false` ⇒ thinking disabled (the separate + * "off" axis); the effort level is preserved across an off→on toggle. + */ + persistedThinking: boolean | null; + /** Persist a thinking selection (off or a level). */ + save: SaveThinkingSelection; } = $props(); - const options = effortOptions(); + const options = selectionOptions(); - // The user's in-flight choice; null = mirror the (async-loaded) persisted prop. - // Re-mounted per conversation, so there is no cross-tab bleed. - let chosen = $state<ReasoningEffort | null>(null); + // The user's in-flight choice; null = mirror the (async-loaded) persisted + // selection. Re-mounted per conversation, so there is no cross-tab bleed. + let chosen = $state<ThinkingSelection | null>(null); let saving = $state(false); let error = $state<string | null>(null); let justSaved = $state(false); - const selected = $derived(chosen ?? effectiveEffort(persisted)); + const selected = $derived( + chosen ?? effectiveSelection(persistedEffort, persistedThinking), + ); async function handleChange(value: string) { - if (!isReasoningEffort(value) || saving) return; + if (!isThinkingSelection(value) || saving) return; chosen = value; saving = true; error = null; @@ -40,7 +51,7 @@ justSaved = true; } else { error = result.error; - chosen = null; // revert to the persisted value + chosen = null; // revert to the persisted selection } } </script> @@ -69,7 +80,7 @@ <p class="text-xs text-success">Saved — applies from the next turn.</p> {:else} <p class="text-xs opacity-50"> - How long the model thinks before answering. Changing it can re-prefill the prompt cache once. + How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once. </p> {/if} </div> diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte new file mode 100644 index 0000000..10f8528 --- /dev/null +++ b/src/features/chat/ui/ToolCard.svelte @@ -0,0 +1,274 @@ +<script lang="ts"> + import type { ToolEntry } from "../index"; + import { viewToolEntry } from "../logic/tool-summary"; + import LazyCollapse from "./LazyCollapse.svelte"; + import { TOOL_ICON_COMPONENTS } from "./tool-icons"; + + let { entry }: { entry: ToolEntry } = $props(); + const card = $derived(viewToolEntry(entry)); + const ToolIcon = $derived(TOOL_ICON_COMPONENTS[card.icon]); + let showAll = $state(false); +</script> + +<div class="tool-card"> + <LazyCollapse label={`Toggle tool call ${card.label}`} titleClass="pe-4"> + {#snippet title()} + <div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}> + {#if card.status === "pending"} + <!-- Same in-progress animation as the streaming thinking block. All three + status icons share a fixed w-5 slot so the row never shifts on settle. --> + <span class="loading loading-dots loading-sm w-5 shrink-0" aria-label="Running"></span> + {:else if card.status === "error"} + <span class="w-5 shrink-0 text-center text-error" aria-label="Failed">✗</span> + {:else} + <span + class="w-5 shrink-0 text-center text-success" + class:opacity-60={card.callDetail.kind === "unavailable"} + aria-label="Succeeded">✓</span + > + {/if} + <!-- Per-tool Phosphor icon (key chosen by the pure view-model). --> + <ToolIcon size={16} class="shrink-0 opacity-70" aria-hidden="true" /> + <span class="tool-name truncate" title={card.name}>{card.label}</span> + <span + class="tool-summary truncate font-normal text-base-content/60" + class:earlier-history={card.callDetail.kind === "unavailable"} + title={card.callDetail.kind === "unavailable" ? card.callDetail.text : card.callSummary} + > + {card.callSummary} + </span> + <span class="tool-badges gap-1 text-xs"> + {#each card.callBadges as badge} + <span + class:text-success={badge.tone === "success"} + class:text-error={badge.tone === "error"}>{badge.label}</span + > + {/each} + </span> + {#if card.resultSummary !== null} + <span + class="tool-result text-xs" + class:text-error={card.status === "error"} + title={card.resultSummary} + > + {#if card.resultSummary === card.compactResultSummary} + <span>→ {card.resultSummary}</span> + {:else} + <span class="result-full">→ {card.resultSummary}</span> + <span class="result-compact">→ {card.compactResultSummary}</span> + {/if} + </span> + {:else} + <span class="tool-result text-xs text-base-content/60">→ running…</span> + {/if} + </div> + {/snippet} + <div class="min-w-0 space-y-4 text-xs"> + <section aria-label="Call"> + {#if card.callDetail.kind === "unavailable"} + <aside + role="note" + class="border-l-2 border-base-content/20 pl-3 italic text-base-content/60" + > + {card.callDetail.text} + </aside> + {:else} + <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content"> + Call + </h3> + {/if} + {#if card.callDetail.kind === "none"} + <p class="italic text-base-content/60">No arguments</p> + {:else if card.callDetail.kind === "command"} + <pre class="overflow-x-auto">$ {card.callDetail.text}</pre> + {:else if card.callDetail.kind === "edit"} + <div class="mb-2 break-all font-mono">{card.callDetail.path}</div> + <!-- Unified-diff style: every removed/added line carries its own −/+ gutter + marker and a tinted row, so multi-line edits read as one diff block. --> + <div class="edit-diff overflow-x-auto rounded font-mono" role="group" aria-label="Edit diff"> + {#if card.callDetail.oldText !== ""} + {#each card.callDetail.oldText.split("\n") as line, i (i)} + <div class="edit-diff-row edit-diff-del"> + <span class="edit-diff-sign" aria-label="removed">−</span><span>{line}</span> + </div> + {/each} + {/if} + {#if card.callDetail.newText !== ""} + {#each card.callDetail.newText.split("\n") as line, i (i)} + <div class="edit-diff-row edit-diff-add"> + <span class="edit-diff-sign" aria-label="added">+</span><span>{line}</span> + </div> + {/each} + {/if} + </div> + {:else if card.callDetail.kind === "fields"} + <dl class="call-fields"> + {#each card.callDetail.fields as field} + <dt class="font-semibold text-base-content/85">{field.key}</dt> + <dd class="min-w-0"> + <pre + class="overflow-x-auto" + class:whitespace-pre-wrap={!field.multiline} + class:break-words={!field.multiline}>{field.value}</pre> + </dd> + {/each} + </dl> + {/if} + </section> + <section aria-label="Response"> + <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content"> + Response + </h3> + {#if card.output === null} + <span class="text-base-content/75">running…</span> + {:else} + <div class:text-error={card.output.isError}> + {#if card.output.kind === "full"} + <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre> + {:else} + {#if !showAll} + <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre> + {/if} + <div class="output-controls" class:sticky-controls={showAll}> + <button + class="btn btn-soft btn-xs focus-visible:outline-primary" + aria-expanded={showAll} + onclick={() => { + showAll = !showAll; + }} + > + {#if showAll}<span aria-hidden="true">↑</span>{/if} + {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`} + </button> + </div> + {#if showAll} + <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre> + {:else} + <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre> + {/if} + {/if} + </div> + {/if} + </section> + </div> + </LazyCollapse> +</div> + +<style> + .tool-card { + container-type: inline-size; + } + .tool-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; + } + /* English names are short phrases: keep them whole and let the call + summary absorb the squeeze. The cap only bites on long MCP names. */ + .tool-name { + flex: 0 0 auto; + max-width: 60%; + } + .tool-summary { + flex: 1; + min-width: 0; + } + .earlier-history { + font-style: italic; + color: var(--color-base-content); + opacity: 0.7; + } + .tool-badges { + display: flex; + flex-shrink: 0; + } + .tool-badges:empty { + display: none; + } + .tool-result { + flex-shrink: 0; + white-space: nowrap; + } + .result-compact { + display: none; + } + .call-fields { + display: grid; + grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr); + gap: 0.5rem 1rem; + align-items: baseline; + } + .call-fields dt { + max-width: 10rem; + overflow-wrap: anywhere; + } + .edit-diff { + background: var(--color-base-300); + padding-block: 0.25rem; + } + .edit-diff-row { + display: flex; + min-width: max-content; + white-space: pre; + padding-inline-end: 0.75rem; + } + .edit-diff-sign { + flex-shrink: 0; + width: 1.75rem; + text-align: center; + user-select: none; + } + .edit-diff-del { + color: var(--color-error); + background: color-mix(in oklab, var(--color-error) 12%, transparent); + } + .edit-diff-add { + color: var(--color-success); + background: color-mix(in oklab, var(--color-success) 12%, transparent); + } + .output-controls { + padding-block: 0.5rem; + color: var(--color-base-content); + /* Same fill as the LazyCollapse inset well this sits in (opaque, so + scrolled output never shows through the sticky bar). */ + background: var(--inset-well-bg, var(--color-base-200)); + } + .sticky-controls { + position: sticky; + top: 0; + z-index: 2; + border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent); + margin-bottom: 0.5rem; + } + @container (max-width: 48rem) { + .result-full { + display: none; + } + .result-compact { + display: inline; + } + } + @container (max-width: 40rem) { + .tool-badges { + display: none; + } + .has-earlier-call .tool-result { + display: none; + } + } + @container (max-width: 24rem) { + .tool-header { + gap: 0.375rem; + } + .call-fields { + column-gap: 0.75rem; + } + } + @container (max-width: 18rem) { + .tool-result { + display: none; + } + } +</style> diff --git a/src/features/chat/ui/tool-icons.ts b/src/features/chat/ui/tool-icons.ts new file mode 100644 index 0000000..1d68ebe --- /dev/null +++ b/src/features/chat/ui/tool-icons.ts @@ -0,0 +1,52 @@ +// Phosphor icon per tool-icon key (the pure view-model picks the KEY — see +// `logic/tool-summary.ts` `toolIcon`; this is the only place that knows the +// concrete components). Deep imports (`phosphor-svelte/lib/<Name>Icon`) on +// purpose: the package root re-exports ~1,500 components, and Svelte libraries +// are not prebundled in dev, so a root import would load every one of them. + +import ArrowSquareOutIcon from "phosphor-svelte/lib/ArrowSquareOutIcon"; +import BookOpenIcon from "phosphor-svelte/lib/BookOpenIcon"; +import BracketsCurlyIcon from "phosphor-svelte/lib/BracketsCurlyIcon"; +import ChatCircleTextIcon from "phosphor-svelte/lib/ChatCircleTextIcon"; +import CpuIcon from "phosphor-svelte/lib/CpuIcon"; +import EyeIcon from "phosphor-svelte/lib/EyeIcon"; +import FileTextIcon from "phosphor-svelte/lib/FileTextIcon"; +import FloppyDiskIcon from "phosphor-svelte/lib/FloppyDiskIcon"; +import ImageIcon from "phosphor-svelte/lib/ImageIcon"; +import ListChecksIcon from "phosphor-svelte/lib/ListChecksIcon"; +import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlassIcon"; +import PaperPlaneTiltIcon from "phosphor-svelte/lib/PaperPlaneTiltIcon"; +import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon"; +import ProhibitIcon from "phosphor-svelte/lib/ProhibitIcon"; +import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon"; +import RobotIcon from "phosphor-svelte/lib/RobotIcon"; +import TerminalWindowIcon from "phosphor-svelte/lib/TerminalWindowIcon"; +import UsersThreeIcon from "phosphor-svelte/lib/UsersThreeIcon"; +import WrenchIcon from "phosphor-svelte/lib/WrenchIcon"; +import YoutubeLogoIcon from "phosphor-svelte/lib/YoutubeLogoIcon"; +import type { Component } from "svelte"; +import type { ToolIconKey } from "../logic/tool-summary"; + +// biome-ignore lint/suspicious/noExplicitAny: Phosphor's IconComponentProps is internal; any icon accepts size/class. +export const TOOL_ICON_COMPONENTS: Readonly<Record<ToolIconKey, Component<any>>> = { + terminal: TerminalWindowIcon, + "file-read": FileTextIcon, + "file-edit": PencilSimpleIcon, + "file-write": FloppyDiskIcon, + search: MagnifyingGlassIcon, + code: BracketsCurlyIcon, + todos: ListChecksIcon, + skill: BookOpenIcon, + vision: EyeIcon, + image: ImageIcon, + youtube: YoutubeLogoIcon, + "agent-summon": RobotIcon, + "agent-send": PaperPlaneTiltIcon, + "agent-read": ChatCircleTextIcon, + "agent-open": ArrowSquareOutIcon, + "agent-cancel": ProhibitIcon, + agents: UsersThreeIcon, + models: CpuIcon, + mcp: PuzzlePieceIcon, + tool: WrenchIcon, +}; diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts index 6e6b770..9c557f5 100644 --- a/src/features/concurrency/logic/view-model.test.ts +++ b/src/features/concurrency/logic/view-model.test.ts @@ -13,6 +13,7 @@ import { parseLimitInput, pauseLabel, providerFromModel, + providerLabel, providerOptions, statusLabel, summarizeLimits, @@ -95,7 +96,7 @@ describe("cooldownLabel", () => { describe("providerFromModel", () => { it("takes the part before the first slash", () => { expect(providerFromModel("openai/gpt-4o")).toBe("openai"); - expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat"); + expect(providerFromModel("opencode-1/gpt-4o-mini")).toBe("opencode-1"); }); it("returns the whole string when there is no slash", () => { expect(providerFromModel("umans")).toBe("umans"); @@ -127,6 +128,23 @@ describe("providerOptions", () => { }); }); +describe("providerLabel", () => { + it("labels a numbered OpenCode provider as OpenCode #N", () => { + expect(providerLabel("opencode-2")).toBe("OpenCode #2"); + expect(providerLabel("opencode-10")).toBe("OpenCode #10"); + }); + + it("leaves non-OpenCode provider ids unchanged", () => { + expect(providerLabel("umans")).toBe("umans"); + expect(providerLabel("anthropic")).toBe("anthropic"); + }); + + it("rejects noncanonical numbered ids (label passthrough)", () => { + expect(providerLabel("opencode")).toBe("opencode"); + expect(providerLabel("opencode-0")).toBe("opencode-0"); + }); +}); + // ── pauseLabel + formatPauseDuration ─────────────────────────────────────────── describe("formatPauseDuration", () => { @@ -450,12 +468,12 @@ describe("normalizeConcurrencyLimits", () => { const limits = normalizeConcurrencyLimits({ limits: [ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ], }); expect(limits).toEqual([ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ]); }); @@ -510,7 +528,7 @@ describe("normalizeConcurrencyStatus", () => { providers: [ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, { - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, @@ -532,7 +550,7 @@ describe("normalizeConcurrencyStatus", () => { }); expect(first !== undefined && !("pausedUntil" in first)).toBe(true); expect(second).toEqual({ - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts index 8a37198..80c155c 100644 --- a/src/features/concurrency/logic/view-model.ts +++ b/src/features/concurrency/logic/view-model.ts @@ -170,6 +170,18 @@ export function providerOptions( return out; } +/** + * Friendly display label for a concurrency provider id. A numbered OpenCode + * provider (`opencode-<n>`) renders as "OpenCode #<n>" (so "opencode-2" reads + * as a coherent credential identity, matching the model picker); any other id is + * shown unchanged. The RAW id is still what is sent to / displayed-with the + * backend — this only affects presentation. + */ +export function providerLabel(providerId: string): string { + const m = /^opencode-([1-9][0-9]*)$/.exec(providerId); + return m === null ? providerId : `OpenCode #${m[1]}`; +} + // ── Status → display view ────────────────────────────────────────────────────── const NO_LIMITS = "No limits configured"; diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte index bf06ac0..c8f6c8f 100644 --- a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -4,6 +4,7 @@ DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + providerLabel, statusLabel, type Badge, type ConcurrencyLimitView, @@ -132,8 +133,8 @@ the buttons never wrap; tight gap + narrow inputs keep the provider name visible; the provider shrinks via flex-1 + min-w-0). --> <div class="flex flex-nowrap items-center gap-1"> - <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId} - >{limit.providerId}</span + <span class="min-w-0 flex-1 truncate font-medium" title={limit.providerId} + >{providerLabel(limit.providerId)}</span > <input type="text" diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index aadb8d1..2e23f5c 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -6,6 +6,7 @@ DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + providerLabel, providerOptions, summarizeLimits, viewConcurrencyLimits, @@ -376,7 +377,7 @@ <option value="" disabled>No providers available</option> {:else} {#each providerOpts as provider (provider)} - <option value={provider}>{provider}</option> + <option value={provider}>{providerLabel(provider)}</option> {/each} {/if} </select> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index a8163c2..e214692 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -11,8 +11,8 @@ import type { } from "../logic/types"; import ConcurrencyView from "./ConcurrencyView.svelte"; -// Available models → provider ids are "umans", "anthropic", "openai-compat". -const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const; +// Available models → provider ids are "umans", "anthropic", "opencode-1". +const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "opencode-1/gpt-4o"] as const; // A status entry factory (defaults to a healthy limited provider). The new // concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present. 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/logic.test.ts b/src/features/conversation-cache/logic.test.ts index 880c32e..7a31783 100644 --- a/src/features/conversation-cache/logic.test.ts +++ b/src/features/conversation-cache/logic.test.ts @@ -138,3 +138,26 @@ describe("selectEvictions", () => { expect(result).toEqual(["b"]); }); }); + +describe("reconcileCache — turnId provenance upgrade", () => { + it("upgrades a cached row lacking turnId when the incoming copy carries it", () => { + const cached: StoredChunk[] = [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }]; + const incoming: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "yo" }, turnId: "t1" }, + ]; + const { merged, toAppend } = reconcileCache(cached, incoming); + expect(merged.map((c) => c.turnId)).toEqual(["t1", "t1"]); + expect(toAppend.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("does not rewrite a cached row that already has a turnId", () => { + const cached: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + ]; + const { toAppend } = reconcileCache(cached, [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + ]); + expect(toAppend).toEqual([]); + }); +}); diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts index 3cb23e2..344a061 100644 --- a/src/features/conversation-cache/logic.ts +++ b/src/features/conversation-cache/logic.ts @@ -5,27 +5,35 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types"; * Merge authoritative seq-keyed chunks with cached chunks. * * Deduplicates by `seq`, produces seq-monotonic order. - * `toAppend` = the incoming chunks whose `seq` is not already in `cached` - * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`. + * `toAppend` = the incoming chunks whose `seq` is not already in `cached`, + * plus cached rows UPGRADED with a `turnId` the incoming copy carries and the + * cached copy lacks (exactly what to persist — the store upserts by seq). Idempotent; tolerant of out-of-order/overlapping `incoming`. */ export function reconcileCache( cached: readonly StoredChunk[], incoming: readonly StoredChunk[], ): ReconcileResult { - const seen = new Set<number>(); + const bySeq = new Map<number, StoredChunk>(); for (const chunk of cached) { - seen.add(chunk.seq); + bySeq.set(chunk.seq, chunk); } const toAppend: StoredChunk[] = []; for (const chunk of incoming) { - if (!seen.has(chunk.seq)) { + const have = bySeq.get(chunk.seq); + if (have === undefined) { toAppend.push(chunk); - seen.add(chunk.seq); + bySeq.set(chunk.seq, chunk); + } else if (have.turnId === undefined && chunk.turnId !== undefined) { + // Upgrade a cached row written before the server stamped `turnId` + // (seqs are immutable, so only this provenance field can differ). + const upgraded = { ...have, turnId: chunk.turnId }; + toAppend.push(upgraded); + bySeq.set(chunk.seq, upgraded); } } - const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); + const merged = [...bySeq.values()].sort((a, b) => a.seq - b.seq); return { merged, toAppend }; } 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/logic/types.ts b/src/features/heartbeat/logic/types.ts index 3d3d525..83cec74 100644 --- a/src/features/heartbeat/logic/types.ts +++ b/src/features/heartbeat/logic/types.ts @@ -26,6 +26,15 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped"; export interface HeartbeatConfig { /** Whether the autonomous loop is enabled (running on the interval). */ readonly enabled: boolean; + /** + * When true (the default), the heartbeat SKIPS a fire whenever the configured + * workspace has any active agents (a conversation whose persisted status is + * `"active"` or `"queued"`) — it stays quiet while the user is actively + * working and only fires when the workspace is idle. When false, the heartbeat + * fires unconditionally on every interval. The heartbeat-spawned conversation + * lives in a dedicated workspace, so an in-flight run never self-blocks. + */ + readonly inactiveOnly: boolean; readonly systemPrompt: string; readonly taskPrompt: string; /** Minutes between runs. */ @@ -46,6 +55,7 @@ export interface HeartbeatConfig { */ export interface HeartbeatConfigPatch { readonly enabled?: boolean; + readonly inactiveOnly?: boolean; readonly systemPrompt?: string; readonly taskPrompt?: string; readonly intervalMinutes?: number; diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts index aca0aa6..c9ef118 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -39,6 +39,7 @@ const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({ const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({ enabled: false, + inactiveOnly: true, systemPrompt: "be helpful", taskPrompt: "check status", intervalMinutes: 15, @@ -254,6 +255,40 @@ describe("config form", () => { const f = formFromConfig(c); expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form }); + + it("emptyForm defaults inactiveOnly to true (on by default)", () => { + expect(emptyForm().inactiveOnly).toBe(true); + }); + + it("formFromConfig carries inactiveOnly through verbatim", () => { + expect(formFromConfig(config({ inactiveOnly: true })).inactiveOnly).toBe(true); + expect(formFromConfig(config({ inactiveOnly: false })).inactiveOnly).toBe(false); + }); + + it("formFromConfig coerces a missing/malformed inactiveOnly to the default (true)", () => { + // A legacy config (undefined) or a non-boolean is read as ON (true) — matches + // normalizeHeartbeatConfig's default and the backend's "on by default". + const f = formFromConfig(config({ inactiveOnly: undefined as unknown as boolean })); + expect(f.inactiveOnly).toBe(true); + }); + + it("patchFromForm carries inactiveOnly", () => { + expect(patchFromForm(formFromConfig(config({ inactiveOnly: false }))).inactiveOnly).toBe(false); + expect(patchFromForm(formFromConfig(config({ inactiveOnly: true }))).inactiveOnly).toBe(true); + }); + + it("formDiffers is true after toggling inactiveOnly", () => { + const c = config({ inactiveOnly: true }); + const f = formFromConfig(c); + f.inactiveOnly = false; + expect(formDiffers(f, c)).toBe(true); + }); + + it("formDiffers is false for a form seeded from the config (inactiveOnly unchanged)", () => { + const c = config({ inactiveOnly: false }); + const f = formFromConfig(c); + expect(formDiffers(f, c)).toBe(false); + }); }); describe("system-prompt inheritance (override ⇄ global default)", () => { @@ -321,6 +356,7 @@ describe("normalizeHeartbeatConfig", () => { it("passes through a well-formed config", () => { const c = normalizeHeartbeatConfig({ enabled: true, + inactiveOnly: false, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 20, @@ -329,6 +365,7 @@ describe("normalizeHeartbeatConfig", () => { }); expect(c).toEqual({ enabled: true, + inactiveOnly: false, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 20, @@ -339,10 +376,12 @@ describe("normalizeHeartbeatConfig", () => { it("coerces a malformed body safely (never throws, never undefined)", () => { const c = normalizeHeartbeatConfig({ enabled: "yes", + inactiveOnly: "yes", intervalMinutes: -3, reasoningEffort: "bogus", }); expect(c.enabled).toBe(false); + expect(c.inactiveOnly).toBe(true); // non-boolean → default ON expect(c.intervalMinutes).toBe(1); expect(c.reasoningEffort).toBeNull(); expect(c.systemPrompt).toBe(""); @@ -355,12 +394,30 @@ describe("normalizeHeartbeatConfig", () => { it("handles null / non-object input", () => { const c = normalizeHeartbeatConfig(null); expect(c.enabled).toBe(false); + expect(c.inactiveOnly).toBe(true); // default ON for an absent config expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES); expect(c.model).toBe(""); }); it("clamps a huge interval", () => { expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440); }); + it("inactiveOnly defaults to true when absent (legacy config → on by default)", () => { + // A config persisted by an older backend (no inactiveOnly field) reads back + // as true — the feature is ON by default for everyone. + expect(normalizeHeartbeatConfig({}).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: undefined }).inactiveOnly).toBe(true); + }); + it("inactiveOnly passes through an explicit false (opt-out)", () => { + expect(normalizeHeartbeatConfig({ inactiveOnly: false }).inactiveOnly).toBe(false); + expect(normalizeHeartbeatConfig({ inactiveOnly: true }).inactiveOnly).toBe(true); + }); + it("inactiveOnly treats only an explicit boolean false as false (not 0, not null)", () => { + // The wire contract requires a JSON boolean; a non-boolean (0, null, "no") + // is treated as the default (true) rather than silently misbehaving. + expect(normalizeHeartbeatConfig({ inactiveOnly: 0 }).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: null }).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: "false" }).inactiveOnly).toBe(true); + }); }); describe("normalizeHeartbeatRuns", () => { diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts index e91febd..4a5ba7f 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -220,6 +220,7 @@ export function approximateNextRunEpoch( */ export interface HeartbeatFormState { enabled: boolean; + inactiveOnly: boolean; systemPrompt: string; taskPrompt: string; intervalHours: number; @@ -254,6 +255,7 @@ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState { const { hours, minutes } = splitInterval(config.intervalMinutes); return { enabled: config.enabled === true, + inactiveOnly: config.inactiveOnly !== false, systemPrompt: config.systemPrompt ?? "", taskPrompt: config.taskPrompt ?? "", intervalHours: hours, @@ -268,6 +270,7 @@ export function emptyForm(): HeartbeatFormState { const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES); return { enabled: false, + inactiveOnly: true, systemPrompt: "", taskPrompt: "", intervalHours: hours, @@ -295,6 +298,7 @@ export function normalizeInterval(value: unknown): number { export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch { return { enabled: form.enabled, + inactiveOnly: form.inactiveOnly, systemPrompt: form.systemPrompt, taskPrompt: form.taskPrompt, intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes), @@ -308,6 +312,7 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): const { hours, minutes } = splitInterval(config.intervalMinutes); return ( form.enabled !== config.enabled || + form.inactiveOnly !== config.inactiveOnly || form.systemPrompt !== (config.systemPrompt ?? "") || form.taskPrompt !== (config.taskPrompt ?? "") || form.intervalHours !== hours || @@ -390,6 +395,10 @@ export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig { const effort = d.reasoningEffort; return { enabled: d.enabled === true, + // Default ON (true): a missing/falsey-but-not-false field (a legacy config + // persisted before the field shipped) reads back as inactiveOnly: true — + // the feature is on by default for everyone. Only an explicit `false` opts out. + inactiveOnly: d.inactiveOnly !== false, systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "", taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "", intervalMinutes: normalizeInterval(d.intervalMinutes), diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte index 5f262f8..7f95c40 100644 --- a/src/features/heartbeat/ui/HeartbeatView.svelte +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -140,6 +140,31 @@ } } + // The inactive-only checkbox is a save-on-change control (like the enable + // toggle): a partial PUT { inactiveOnly } — no need to round-trip the rest + // of the form. The heartbeat then skips a fire whenever the workspace has + // active agents (a conversation whose status is "active" or "queued"). + async function handleToggleInactiveOnly(): Promise<void> { + if (saving) return; + const next = !form.inactiveOnly; + form = { ...form, inactiveOnly: next }; + saving = true; + saveError = null; + justSaved = false; + const result = await saveConfig({ inactiveOnly: next }); + saving = false; + if (result === null) return; + if (result.ok) { + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + justSaved = true; + } else { + saveError = result.error; + // Revert the checkbox to the last-known state. + form = { ...form, inactiveOnly: loadedConfig.inactiveOnly }; + } + } + // ── Runs list (polls while mounted) ─────────────────────────────────────── let runs = $state<readonly HeartbeatRunView[]>([]); /** The raw backend runs (carry `triggeredAt`), kept for the next-run @@ -323,6 +348,27 @@ {#if configError} <p class="text-xs text-error">{configError}</p> {:else} + <!-- Inactive-only (skip fires while the workspace has active agents) --> + <section class="flex flex-col gap-1"> + <label class="flex items-start gap-2 text-sm"> + <input + type="checkbox" + class="checkbox checkbox-sm checkbox-primary mt-0.5" + checked={form.inactiveOnly} + disabled={saving || configLoading} + onchange={handleToggleInactiveOnly} + aria-label="Only run the heartbeat when the workspace is idle" + /> + <span class="flex flex-col gap-0.5"> + <span>Only run when idle</span> + <span class="text-xs opacity-50"> + Skip heartbeat fires while agents are active in this workspace. When off, the + heartbeat runs on every interval regardless of activity. + </span> + </span> + </label> + </section> + <!-- Prompts (open the full-page editor) --> <section class="flex flex-col gap-1"> <span class="text-xs font-semibold uppercase opacity-60">Prompts</span> diff --git a/src/features/heartbeat/ui/HeartbeatView.test.ts b/src/features/heartbeat/ui/HeartbeatView.test.ts new file mode 100644 index 0000000..89eeee3 --- /dev/null +++ b/src/features/heartbeat/ui/HeartbeatView.test.ts @@ -0,0 +1,203 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LoadSystemPrompt, LoadSystemPromptVariables } from "../../system-prompt"; +import type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatNextRunResult, + HeartbeatStopResult, + LoadHeartbeatConfig, + LoadHeartbeatNextRun, + LoadHeartbeatRuns, + SaveHeartbeatConfig, + StopHeartbeatRun, +} from "../logic/types"; +import HeartbeatView from "./HeartbeatView.svelte"; + +// ── Fakes for the injected ports ───────────────────────────────────────────── +// Only the OUTERMOST edges are faked (the save/load ports); no sibling module is +// mocked. Mirrors the PromptEditor test's fake-port pattern. + +function makeConfig(over: Partial<HeartbeatConfig> = {}): HeartbeatConfig { + return { + enabled: false, + inactiveOnly: true, + systemPrompt: "", + taskPrompt: "", + intervalMinutes: 30, + model: "", + reasoningEffort: null, + ...over, + }; +} + +/** A capturing saveConfig that echoes a merged config (so the form re-seeds). */ +function fakeSaveConfig(initial: HeartbeatConfig): { + calls: HeartbeatConfigPatch[]; + impl: SaveHeartbeatConfig; +} { + const calls: HeartbeatConfigPatch[] = []; + let current = initial; + const impl: SaveHeartbeatConfig = async (patch) => { + calls.push(patch); + // Echo the merged config so the component re-seeds from the server response. + current = { ...current, ...patch }; + return { ok: true, config: current } satisfies HeartbeatConfigResult; + }; + return { calls, impl }; +} + +function fakeLoadConfig(config: HeartbeatConfig): LoadHeartbeatConfig { + return vi.fn(async () => ({ ok: true, config }) as const); +} + +function fakeLoadRuns(): LoadHeartbeatRuns { + return vi.fn(async () => ({ ok: true, runs: [] }) as const); +} + +function fakeStopRun(): StopHeartbeatRun { + return vi.fn(async () => ({ ok: true }) as const satisfies HeartbeatStopResult); +} + +function fakeLoadNextRun(): LoadHeartbeatNextRun { + // No scheduled run (heartbeat disabled in the default config) → no countdown. + return vi.fn( + async () => ({ ok: true, nextRunAt: null }) as const satisfies HeartbeatNextRunResult, + ); +} + +function fakeLoadVariables(): LoadSystemPromptVariables { + return vi.fn(async () => ({ ok: true, variables: [] }) as const); +} + +function fakeLoadDefaultPrompt(): LoadSystemPrompt { + return vi.fn(async () => ({ ok: true, template: "" }) as const); +} + +const baseProps = (overrides: Record<string, unknown> = {}) => ({ + models: [] as readonly string[], + loadConfig: fakeLoadConfig(makeConfig()), + saveConfig: fakeSaveConfig(makeConfig()).impl, + loadVariables: fakeLoadVariables(), + loadDefaultPrompt: fakeLoadDefaultPrompt(), + loadRuns: fakeLoadRuns(), + stopRun: fakeStopRun(), + loadNextRun: fakeLoadNextRun(), + onOpenRun: vi.fn(), + ...overrides, +}); + +// HeartbeatView sets up polling intervals (runs + next-run + clock) on mount. +// Clear any stray timers between tests so a later test never hangs on a leaked +// interval (the $effect cleanup clears them on unmount; this is belt+suspenders). +afterEach(() => { + vi.clearAllTimers(); +}); + +describe("HeartbeatView — inactive-only checkbox", () => { + it("renders checked when the loaded config has inactiveOnly: true (the default)", async () => { + const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: true })); + render(HeartbeatView, { + props: baseProps({ loadConfig }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + }); + + it("renders unchecked when the loaded config has inactiveOnly: false", async () => { + const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: false })); + render(HeartbeatView, { + props: baseProps({ loadConfig }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).not.toBeChecked(); + }); + + it("toggling the checkbox persists a PARTIAL patch { inactiveOnly } and re-seeds", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: true }); + const save = fakeSaveConfig(initial); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: save.impl }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); + + // The save port was called with ONLY { inactiveOnly: false } — a partial + // update, not the whole config (mirrors the enable toggle's partial PUT). + await vi.waitFor(() => { + expect(save.calls).toHaveLength(1); + }); + expect(save.calls[0]).toEqual({ inactiveOnly: false }); + + // After the save resolves, the checkbox reflects the server response (unchecked). + await vi.waitFor(() => { + expect(checkbox).not.toBeChecked(); + }); + }); + + it("toggling back on sends { inactiveOnly: true }", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: false }); + const save = fakeSaveConfig(initial); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: save.impl }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + + await vi.waitFor(() => { + expect(save.calls).toHaveLength(1); + }); + expect(save.calls[0]).toEqual({ inactiveOnly: true }); + await vi.waitFor(() => { + expect(checkbox).toBeChecked(); + }); + }); + + it("a failed save reverts the checkbox to the last-known state", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: true }); + const failingSave: SaveHeartbeatConfig = async () => ({ + ok: false, + error: "boom", + }); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: failingSave }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); + + // The failed save surfaces the error AND reverts the checkbox (stays checked). + await vi.waitFor(() => { + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + expect(checkbox).toBeChecked(); + }); +}); diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts index 284b319..c4bd3b8 100644 --- a/src/features/heartbeat/ui/PromptEditor.test.ts +++ b/src/features/heartbeat/ui/PromptEditor.test.ts @@ -29,6 +29,7 @@ function fakeSaveConfig(): { // Echo a config that reflects the persisted patch (so onSaved sync is realistic). const config = { enabled: false, + inactiveOnly: true, systemPrompt: patch.systemPrompt ?? "", taskPrompt: patch.taskPrompt ?? "", intervalMinutes: 30, 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/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts new file mode 100644 index 0000000..24f9c94 --- /dev/null +++ b/src/features/layout/adapter/abi-port.test.ts @@ -0,0 +1,97 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { LayoutProviderState, LayoutStore } from "../store.svelte"; +import { abiTree, createLayoutAbiPort } from "./abi-port"; + +/** + * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition + * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the + * outermost edges; nothing internal is mocked — asymmetric testing). + */ + +function fakeStore( + overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {}, +): LayoutStore { + return { + get tree(): LayoutTree { + return DEFAULT_LAYOUT; + }, + get providerState(): LayoutProviderState { + return "present"; + }, + get activeWorkspaceId(): string { + return "ws-a"; + }, + probe: vi.fn(async () => {}), + onWorkspaceChanged: vi.fn(), + onServerTree: vi.fn(), + onTreeChange: vi.fn(), + resetToDefault: vi.fn(async () => null), + onExtensionsChanged: vi.fn(), + onWorkspacePluginsChanged: vi.fn(), + ...overrides, + } as LayoutStore; +} + +describe("abiTree — the getTree decision (pure)", () => { + it("a PRESENT provider exposes the tree", () => { + expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT); + }); + + it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => { + expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull(); + }); + + it("an unknown (not-yet-probed) provider exposes NULL too", () => { + expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull(); + }); +}); + +describe("createLayoutAbiPort — applyOps → the ops route", () => { + it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => { + const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const; + const edited: LayoutTree = { ...DEFAULT_LAYOUT }; + const store = fakeStore(); + const seen: string[] = []; + const port = createLayoutAbiPort({ + store, + postOps: async (workspaceId, o) => { + seen.push(workspaceId); + return { ok: true, tree: edited, applied: o.length }; + }, + }); + + const result = await port.applyOps(ops); + expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time + expect(result).toEqual({ ok: true, tree: edited, applied: 1 }); + // The successful apply echoes the tree into the store (instant local + // re-render; the WS broadcast echo is idempotent with this). + expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited); + }); + + it("a failed batch (typed no-provider error) does NOT touch the store", async () => { + const store = fakeStore({ providerState: "absent" }); + const port = createLayoutAbiPort({ + store, + postOps: async () => ({ ok: false, error: "no layout provider active" }), + }); + + const result = await port.applyOps([{ type: "removeNode", target: "side" }]); + expect(result).toEqual({ ok: false, error: "no layout provider active" }); + expect(store.onServerTree).not.toHaveBeenCalled(); + }); + + it("getTree returns the active tree while the provider is present, null when absent", () => { + const port = createLayoutAbiPort({ + store: fakeStore({ providerState: "present" }), + postOps: async () => ({ ok: false, error: "unused" }), + }); + expect(port.getTree()).toBe(DEFAULT_LAYOUT); + + const absent = createLayoutAbiPort({ + store: fakeStore({ providerState: "absent" }), + postOps: async () => ({ ok: false, error: "no layout provider active" }), + }); + expect(absent.getTree()).toBeNull(); + }); +}); diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts new file mode 100644 index 0000000..69576c6 --- /dev/null +++ b/src/features/layout/adapter/abi-port.ts @@ -0,0 +1,60 @@ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; +import type { LayoutProviderState, LayoutStore } from "../store.svelte"; + +/** + * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5). + * The composition root builds it with {@link createLayoutAbiPort} and the + * plugin host hands it to every `activate(api)` — plugins never see the + * store or the HTTP edge, only THIS. + */ +export interface LayoutAbiPort { + /** + * Apply op-vocabulary edits to the ACTIVE workspace's tree through the + * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider + * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`. + */ + readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; + /** The ACTIVE workspace's tree; `null` in no-provider mode. */ + readonly getTree: () => LayoutTree | null; +} + +export interface LayoutAbiPortDeps { + /** The layout store (the active workspace's tree + provider state). */ + readonly store: LayoutStore; + /** The ops HTTP edge — bound per CALL to the then-active workspace. */ + readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; +} + +/** + * Build the ABI's `layout` port over the layout store + the ops HTTP edge + * (pure composition — no ambient state; both deps injected). A successful + * apply feeds the returned tree back through `onServerTree` so the local + * client re-renders immediately (the server's WS broadcast is the + * authoritative echo for every client, this one included — applying it twice + * is idempotent). The workspace is read at CALL time, so a workspace switch + * between calls targets the right tree; a switch mid-flight is guarded by + * `onServerTree`'s own workspace filter. + */ +export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort { + return { + async applyOps(ops): Promise<ApplyOpsResult> { + const workspaceId = deps.store.activeWorkspaceId; + const result = await deps.postOps(workspaceId, ops); + if (result.ok) { + deps.store.onServerTree(workspaceId, result.tree); + } + return result; + }, + getTree(): LayoutTree | null { + // `present` = an active provider is serving the active workspace. In + // no-provider (and not-yet-probed) mode the local tree is a fallback, + // not shared state — null tells the plugin there is nothing to read. + return abiTree(deps.store.providerState, deps.store.tree); + }, + }; +} + +/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */ +export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null { + return state === "present" ? tree : null; +} diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts new file mode 100644 index 0000000..30e194a --- /dev/null +++ b/src/features/layout/adapter/http.test.ts @@ -0,0 +1,182 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { createLayoutHttp } from "./http"; + +/** + * Layout HTTP adapter tests — the OUTERMOST edge (fetch) is the only fake: + * a scripted `fetchImpl` returning canned Responses. No internal mocks. + */ + +const LAYOUT_URL = "http://backend.test/workspaces/ws-a/layout"; + +function fetchReturning( + status: number, + body: unknown, + capture?: { url?: string; init?: RequestInit | undefined }, +): typeof fetch { + return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + if (capture !== undefined) { + capture.url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + capture.init = init; + } + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +describe("createLayoutHttp — getTree", () => { + it("GETs the well-known route and unwraps { tree }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.url).toBe(LAYOUT_URL); + expect(capture.init?.method).toBeUndefined(); // GET is the default + }); + + it("maps a 404 to the no-provider signal (notFound: true)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(404, { error: "No layout provider is active" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + }); + + it("maps a 500 to a non-notFound failure carrying the body's error", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(500, { error: "Layout storage failure" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: false, notFound: false, error: "Layout storage failure" }); + }); + + it("a network failure degrades to notFound (the no-provider mode)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("Failed to fetch"); + }); + const result = await http.getTree("ws-a"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.notFound).toBe(true); + expect(result.error).toContain("Failed to fetch"); + } + }); + + it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { chunks: [], historyEpoch: 0, latestSeq: 0 }), + ); + const result = await http.getTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — putTree", () => { + it("PUTs { tree } as JSON and unwraps the echo", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("PUT"); + expect(capture.init?.body).toBe(JSON.stringify({ tree: DEFAULT_LAYOUT })); + }); + + it("maps a 400 { error } to a validation failure (not notFound)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "root: split has no children" }), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ + ok: false, + notFound: false, + error: "root: split has no children", + }); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — deleteTree", () => { + it("DELETEs and unwraps the fresh seed", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.deleteTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("DELETE"); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.deleteTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — postOps (plan 04 §4.2)", () => { + it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture), + ); + const result = await http.postOps("ws-a", ops); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 }); + expect(capture.url).toBe(`${LAYOUT_URL}/ops`); + expect(capture.init?.method).toBe("POST"); + expect(capture.init?.body).toBe(JSON.stringify({ ops })); + }); + + it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.postOps("ws-a", []); + expect(result).toEqual({ ok: false, error: "no layout provider active" }); + }); + + it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }), + ); + const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]); + expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 }); + }); + + it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 })); + const result = await http.postOps("ws-a", []); + expect(result).toMatchObject({ ok: false }); + }); + + it("a network failure degrades to a typed failure (never throws)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("fetch failed"); + }); + const result = await http.postOps("ws-a", []); + expect(result).toEqual({ ok: false, error: "fetch failed" }); + }); +}); diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts new file mode 100644 index 0000000..ff15a70 --- /dev/null +++ b/src/features/layout/adapter/http.ts @@ -0,0 +1,175 @@ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; + +/** + * Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the + * ACTIVE layout provider's well-known routes: + * - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace) + * - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }` + * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; the plugins + * modal's "Reset layout to default" button — plan 04 §4.4) + * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }` + * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE + * ABI's `layout.applyOps` door) + * + * Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). The 404 is a SIGNAL, not an error: it means + * no layout provider is active, and the store renders its local default with + * ephemeral edits (never an error UI). A network failure degrades the same way. + */ + +/** Outcome of a layout read/write. `notFound` distinguishes the no-provider 404. */ +export type LayoutHttpResult = + | { readonly ok: true; readonly tree: LayoutTree } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface LayoutHttp { + getTree(workspaceId: string): Promise<LayoutHttpResult>; + putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>; + deleteTree(workspaceId: string): Promise<LayoutHttpResult>; + /** + * The plan-04 ops door (§4.2): apply op-vocabulary edits through the + * provider. The result IS the contract's `ApplyOpsResult` — the 404 + * (no provider) maps to the typed `{ ok: false, error: "no layout + * provider active" }` failure the ABI documents. + */ + postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a 404 — never poisons the store. */ +function malformed(): LayoutHttpResult { + return { ok: false, notFound: true, error: "Malformed layout response" }; +} + +export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): LayoutHttp { + return { + async getTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async putTree(workspaceId, tree): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tree }), + }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async deleteTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { method: "DELETE" }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async postOps(workspaceId, ops): Promise<ApplyOpsResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ops }), + }, + ); + // The no-provider 404 is the ABI's typed signal (plan 04 §4.5). + if (res.status === 404) { + return { ok: false, error: "no layout provider active" }; + } + if (!res.ok) return await opsError(res); + const body = (await res.json()) as { tree?: unknown; applied?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return { ok: false, error: "Malformed layout response" }; + return { + ok: true, + tree: body.tree as LayoutTree, + applied: typeof body.applied === "number" ? body.applied : ops.length, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + }; +} + +/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */ +async function opsError(res: Response): Promise<ApplyOpsResult> { + try { + const body = (await res.json()) as { error?: unknown; failedOp?: unknown }; + const error = + typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`; + return { + ok: false, + error, + ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}), + }; + } catch { + return { ok: false, error: `HTTP ${res.status}` }; + } +} diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts new file mode 100644 index 0000000..8a464fe --- /dev/null +++ b/src/features/layout/index.ts @@ -0,0 +1,51 @@ +/** + * The layout feature — a generic tile-tree renderer + the view registry. + * + * The frontend stops *having* a layout and starts *rendering* one: the tree is + * data (`@dispatch/layout-contract`), the renderer walks it, and every view a + * leaf mounts comes from the registry. Chrome (top bar, alerts, modals) stays + * OUTSIDE the tree — the tree owns the content region. + */ + +export { + abiTree, + createLayoutAbiPort, + type LayoutAbiPort, + type LayoutAbiPortDeps, +} from "./adapter/abi-port"; +export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http"; +export { + bootDefaultOpen, + type CollapseState, + type CollapseStore, + type CollapseStoreOptions, + createCollapseStore, + parsePersistedOpen, + SIDEBAR_OPEN_KEY, + WIDE_BREAKPOINT, +} from "./logic/collapse"; +export { migrationTree, sidePanelViews, storedPanelViews } from "./logic/migration"; +export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit"; +export { + getView, + listPanelViews, + listViews, + type RegisteredView, + registerPluginView, + registerView, + unregisterView, + unregisterViewsByPlugin, + type ViewComponent, + type ViewRegistration, + viewRegistry, +} from "./logic/registry"; +export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed"; +export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query"; +export { createLayoutStore, type LayoutProviderState, type LayoutStore } from "./store.svelte"; +export { default as TileRoot } from "./ui/TileRoot.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "layout", + description: "Tile-tree renderer + view registry", +} as const; diff --git a/src/features/layout/logic/collapse.test.ts b/src/features/layout/logic/collapse.test.ts new file mode 100644 index 0000000..77e9f3c --- /dev/null +++ b/src/features/layout/logic/collapse.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + bootDefaultOpen, + createCollapseStore, + parsePersistedOpen, + SIDEBAR_OPEN_KEY, + WIDE_BREAKPOINT, +} from "./collapse"; + +function fakeStorage(): { storage: Storage; raw(key: string): string | null } { + const map = new Map<string, string>(); + const storage: Storage = { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (key) => map.get(key) ?? null, + key: () => null, + removeItem: (key) => map.delete(key), + setItem: (key, value) => void map.set(key, value), + }; + return { storage, raw: (key) => map.get(key) ?? null }; +} + +describe("bootDefaultOpen", () => { + it("defaults to open on wide screens (>= 1024px) and closed on narrow ones", () => { + expect(bootDefaultOpen(WIDE_BREAKPOINT)).toBe(true); + expect(bootDefaultOpen(1440)).toBe(true); + expect(bootDefaultOpen(1023)).toBe(false); + expect(bootDefaultOpen(0)).toBe(false); + }); + + it("defaults to open when the viewport is unknown (SSR/tests)", () => { + expect(bootDefaultOpen(undefined)).toBe(true); + }); +}); + +describe("parsePersistedOpen", () => { + it("accepts today's bare JSON boolean format", () => { + expect(parsePersistedOpen("true")).toBe(true); + expect(parsePersistedOpen("false")).toBe(false); + }); + + it("ignores null, malformed JSON, and non-boolean values", () => { + expect(parsePersistedOpen(null)).toBeNull(); + expect(parsePersistedOpen("{")).toBeNull(); + expect(parsePersistedOpen('"true"')).toBeNull(); + expect(parsePersistedOpen("{}")).toBeNull(); + }); +}); + +describe("createCollapseStore", () => { + it("uses the viewport default when nothing is persisted", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + expect(store.isOpen("side")).toBe(true); + const narrow = createCollapseStore({ viewportWidth: 800 }); + expect(narrow.isOpen("side")).toBe(false); + }); + + it("restores the persisted boolean over the viewport default", () => { + const { storage } = fakeStorage(); + storage.setItem(SIDEBAR_OPEN_KEY, "false"); + const store = createCollapseStore({ storage, viewportWidth: 1440 }); + expect(store.isOpen("side")).toBe(false); + }); + + it("persists toggles as today's bare JSON boolean (same key, same bytes)", () => { + const { storage, raw } = fakeStorage(); + const store = createCollapseStore({ storage, viewportWidth: 1024 }); + store.toggle("side"); + expect(raw(SIDEBAR_OPEN_KEY)).toBe("false"); + store.toggle("side"); + expect(raw(SIDEBAR_OPEN_KEY)).toBe("true"); + }); + + it("tracks per-node state reactively", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + const seen: boolean[] = []; + const unsubscribe = store.subscribe((state) => + seen.push(state.openByNode.side ?? state.defaultOpen), + ); + try { + store.setOpen("side", false); + store.setOpen("side", true); + } finally { + unsubscribe(); + } + expect(seen).toEqual([true, false, true]); + expect(store.isOpen("side")).toBe(true); + }); + + it("an untracked node falls back to the default", () => { + const store = createCollapseStore({ viewportWidth: 800 }); + expect(store.isOpen("other")).toBe(false); + }); + + it("survives storage failures (quota errors are swallowed)", () => { + const broken: Storage = { + get length() { + return 0; + }, + clear: () => undefined, + getItem: () => null, + key: () => null, + removeItem: () => undefined, + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + const store = createCollapseStore({ storage: broken, viewportWidth: 1024 }); + expect(() => store.toggle("side")).not.toThrow(); + expect(store.isOpen("side")).toBe(false); + }); + + it("works without storage (session-only chrome state)", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + store.toggle("side"); + expect(store.isOpen("side")).toBe(false); + }); +}); diff --git a/src/features/layout/logic/collapse.ts b/src/features/layout/logic/collapse.ts new file mode 100644 index 0000000..28e90b4 --- /dev/null +++ b/src/features/layout/logic/collapse.ts @@ -0,0 +1,95 @@ +/** + * Collapse chrome state — the open/closed state of the tree's collapsible + * split children (today: the sidebar). Renderer chrome, NOT tree data + * (layout-00 decision 3): tree nodes merely declare `collapsible`; this store + * owns the per-node state, persisted device-locally. + * + * Persistence is BYTE-COMPATIBLE with today: the same `dispatch.sidebar.open` + * key holding a JSON boolean, defaulting to open on wide screens (>= 1024px, + * Tailwind `lg`). Today's UI has exactly one collapsible node, and the + * persisted boolean tracks the toggled node's state — identical bytes and + * behavior. A future with several collapsible nodes migrates the format in + * plan 02, not here. + */ + +import { type Readable, writable } from "svelte/store"; + +/** The localStorage key — unchanged from today (no migration). */ +export const SIDEBAR_OPEN_KEY = "dispatch.sidebar.open"; +/** Tailwind `lg`: today's boot default is open on wide screens. */ +export const WIDE_BREAKPOINT = 1024; + +export interface CollapseState { + /** Explicit per-node open state (node id → open). Absent = the default. */ + readonly openByNode: Readonly<Record<string, boolean>>; + /** The boot default (persisted boolean, else viewport width >= 1024). */ + readonly defaultOpen: boolean; +} + +export interface CollapseStore extends Readable<CollapseState> { + /** Is the named node open? (explicit state, else the default) */ + isOpen(nodeId: string): boolean; + setOpen(nodeId: string, open: boolean): void; + toggle(nodeId: string): void; +} + +export interface CollapseStoreOptions { + /** Injected storage (the app store's `Storage`; undefined → session-only). */ + readonly storage?: Storage | undefined; + /** Viewport width at boot (today: `window.innerWidth`). Undefined → default open. */ + readonly viewportWidth?: number | undefined; + /** Storage key override (tests). Defaults to `dispatch.sidebar.open`. */ + readonly key?: string | undefined; +} + +/** The boot default: open on wide screens (narrow screens start closed). */ +export function bootDefaultOpen(viewportWidth: number | undefined): boolean { + if (viewportWidth === undefined) return true; + return viewportWidth >= WIDE_BREAKPOINT; +} + +/** Parse the persisted value. Today's format is a bare JSON boolean; anything else is ignored. */ +export function parsePersistedOpen(raw: string | null): boolean | null { + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === "boolean" ? parsed : null; + } catch { + return null; + } +} + +export function createCollapseStore(options?: CollapseStoreOptions): CollapseStore { + const key = options?.key ?? SIDEBAR_OPEN_KEY; + const storage = options?.storage; + const persisted = parsePersistedOpen(storage === undefined ? null : storage.getItem(key)); + const defaultOpen = persisted ?? bootDefaultOpen(options?.viewportWidth); + + let openByNode: Record<string, boolean> = {}; + const store = writable<CollapseState>({ openByNode, defaultOpen }); + + function set(nodeId: string, open: boolean): void { + openByNode = { ...openByNode, [nodeId]: open }; + store.set({ openByNode, defaultOpen }); + // Persist the toggled node's state as today's bare boolean. With one + // collapsible node (always, today) this is exactly today's behavior. + if (storage !== undefined) { + try { + storage.setItem(key, JSON.stringify(open)); + } catch { + // Swallow quota / write errors — persistence is best-effort. + } + } + } + + function isOpen(nodeId: string): boolean { + return openByNode[nodeId] ?? defaultOpen; + } + + return { + subscribe: store.subscribe, + isOpen, + setOpen: (nodeId, open) => set(nodeId, open), + toggle: (nodeId) => set(nodeId, !isOpen(nodeId)), + }; +} diff --git a/src/features/layout/logic/marker.test.ts b/src/features/layout/logic/marker.test.ts new file mode 100644 index 0000000..53b91b0 --- /dev/null +++ b/src/features/layout/logic/marker.test.ts @@ -0,0 +1,29 @@ +import type { TileMarker } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { markerOutline, markerStyle } from "./marker"; + +describe("markerOutline — the diagnostic tile-border mapping", () => { + it("main → a 4px solid red outline (the main tile's marker)", () => { + expect(markerOutline("main")).toBe("4px solid red"); + }); + + it("side → a 4px solid green outline (the side tile's marker)", () => { + expect(markerOutline("side")).toBe("4px solid green"); + }); + + it("no marker → no outline (existing trees render exactly as before)", () => { + expect(markerOutline(undefined)).toBeUndefined(); + }); +}); + +describe("markerStyle — the complete inline style (outline + inset offset)", () => { + it("insets the outline so it draws INSIDE the tile's box (never clipped at screen edges)", () => { + expect(markerStyle("main")).toBe("outline: 4px solid red; outline-offset: -4px"); + expect(markerStyle("side")).toBe("outline: 4px solid green; outline-offset: -4px"); + }); + + it("no/unknown marker → NO style at all (unmarked nodes carry zero outline CSS)", () => { + expect(markerStyle(undefined)).toBeUndefined(); + expect(markerStyle("purple" as unknown as TileMarker)).toBeUndefined(); + }); +}); diff --git a/src/features/layout/logic/marker.ts b/src/features/layout/logic/marker.ts new file mode 100644 index 0000000..46259e0 --- /dev/null +++ b/src/features/layout/logic/marker.ts @@ -0,0 +1,46 @@ +/** + * Diagnostic tile-border markers (the contract's additive `TileMarker` + * presentation field) → the inline outline the renderer draws. Pure + * decision logic — the .svelte tile components just apply the returned + * style value. + * + * INLINE STYLES, deliberately: a marker value arrives in TREE DATA at + * runtime, and Tailwind only generates classes it sees in source files — a + * dynamic class from tree data would silently render nothing. An outline + * (not a border) is used so the diagnostic draws WITHOUT changing any + * tile's layout/size (4px solid, clearly visible). + * + * The outline is INSET (`outline-offset: -4px`) so it draws INSIDE the + * tile's box: tiles span their containers edge-to-edge, so the default + * (outside) outline overflows the visible area and is clipped away at the + * screen edges. The views inside the tiles have no opaque full-bleed + * background, so the inset ring stays on top of the tile's content. + * + * The marker is purely presentational: unknown/absent → no outline + * (existing trees render exactly as before), and no other behavior is + * attached to it anywhere in the renderer. + */ + +import type { TileMarker } from "@dispatch/layout-contract"; + +/** The diagnostic outline style for a marker — `undefined` = draw nothing. */ +export function markerOutline(marker: TileMarker | undefined): string | undefined { + if (marker === "main") return "4px solid red"; + if (marker === "side") return "4px solid green"; + return undefined; +} + +/** The inset that keeps the outline INSIDE the tile's box (see the header note). */ +export const MARKER_OUTLINE_OFFSET = "-4px"; + +/** + * The COMPLETE inline style for a marked element — outline + inset offset in + * one declaration — or `undefined` when unmarked/unknown, so unmarked nodes + * carry no outline CSS at all (existing trees render exactly as before). + */ +export function markerStyle(marker: TileMarker | undefined): string | undefined { + const outline = markerOutline(marker); + return outline === undefined + ? undefined + : `outline: ${outline}; outline-offset: ${MARKER_OUTLINE_OFFSET}`; +} diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts new file mode 100644 index 0000000..9eeeda0 --- /dev/null +++ b/src/features/layout/logic/migration.test.ts @@ -0,0 +1,68 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { migrationTree, sidePanelViews, storedPanelViews } from "./migration"; +import { overlaySidePanels } from "./seed"; + +const KNOWN = new Set(["tabs", "model", "extensions", "vision", "tasks"]); + +describe("storedPanelViews", () => { + it("parses a stored array of view ids, filtered to known views, order kept", () => { + expect(storedPanelViews(["vision", "junk", "tasks"], KNOWN)).toEqual(["vision", "tasks"]); + }); + + it("returns null for absent/junk/non-array values", () => { + expect(storedPanelViews(null, KNOWN)).toBeNull(); + expect(storedPanelViews(undefined, KNOWN)).toBeNull(); + expect(storedPanelViews("extensions", KNOWN)).toBeNull(); + expect(storedPanelViews({ 0: "tabs" }, KNOWN)).toBeNull(); + }); + + it("returns null when nothing stored is a known view", () => { + expect(storedPanelViews(["deleted-view", "another-gone"], KNOWN)).toBeNull(); + expect(storedPanelViews([], KNOWN)).toBeNull(); + }); +}); + +describe("sidePanelViews", () => { + it("reads the default tree's side panels", () => { + expect(sidePanelViews(DEFAULT_LAYOUT)).toEqual(["tabs", "model"]); + }); + + it("returns null for a tree without a 'side' panels node", () => { + expect(sidePanelViews({ version: 1, root: { kind: "leaf", view: "chat" } })).toBeNull(); + }); +}); + +describe("migrationTree (§2.7 — one-way, best-effort)", () => { + it("imports the stored selection when the fetched tree is the untouched default seed", () => { + const stored = JSON.stringify(["extensions", "tasks"]); + const migrated = migrationTree(JSON.parse(stored), KNOWN, DEFAULT_LAYOUT); + expect(migrated).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])); + // The rest of the tree is untouched — only the side panels changed. + expect(migrated?.root).toEqual( + overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])?.root, + ); + }); + + it("returns null when the key is absent / junk / has no known views (nothing to import)", () => { + expect(migrationTree(null, KNOWN, DEFAULT_LAYOUT)).toBeNull(); + expect(migrationTree("junk", KNOWN, DEFAULT_LAYOUT)).toBeNull(); + expect(migrationTree(["gone-view"], KNOWN, DEFAULT_LAYOUT)).toBeNull(); + }); + + it("returns null when the provider's tree is NOT the default seed (custom layout wins)", () => { + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + expect(migrationTree(["extensions"], KNOWN, custom)).toBeNull(); + }); + + it("returns null when the fetched tree has no side panels node at all", () => { + expect( + migrationTree(["extensions"], KNOWN, { version: 1, root: { kind: "leaf", view: "chat" } }), + ).toBeNull(); + }); + + it("a stored selection identical to the default still migrates (a harmless idempotent PUT)", () => { + const migrated = migrationTree(["tabs", "model"], KNOWN, DEFAULT_LAYOUT); + expect(migrated).toEqual(DEFAULT_LAYOUT); + }); +}); diff --git a/src/features/layout/logic/migration.ts b/src/features/layout/logic/migration.ts new file mode 100644 index 0000000..e0ed6fd --- /dev/null +++ b/src/features/layout/logic/migration.ts @@ -0,0 +1,76 @@ +/** + * localStorage → provider migration (plan 02 §2.7, D-L4; one-way, best-effort). + * + * `dispatch.sidebar.views` is retired as a source of truth: on boot WITH a + * provider present, if the key exists AND the fetched tree's `side` panels + * equal DEFAULT_LAYOUT's (the workspace still has the untouched default + * seed), the stored panel selection is imported once via a PUT. When the + * provider's tree differs from the default (e.g. a calendar provider), the + * workspace already has a custom layout — importing the old sidebar override + * would be wrong. Regardless of outcome the key is never read again (the + * bytes stay, but nothing consumes them). + * + * Pure decision logic: the store owns the effects (the localStorage read + + * the PUT); this module decides IF and WHAT. + */ + +import { + DEFAULT_LAYOUT, + findById, + type LayoutTree, + type PanelsNode, +} from "@dispatch/layout-contract"; +import { overlaySidePanels } from "./seed"; + +/** + * The stored selection's usable view ids: an array of strings filtered to the + * known (registered) views, in stored order. null when nothing usable is + * stored (absent key, junk, or no known ids) — nothing to import. + */ +export function storedPanelViews( + raw: unknown, + knownViews: ReadonlySet<string>, +): readonly string[] | null { + if (!Array.isArray(raw)) return null; + const known = raw.filter((v): v is string => typeof v === "string" && knownViews.has(v)); + return known.length > 0 ? known : null; +} + +/** + * The view ids of a tree's `"side"` panels node (the default tree's swappable + * sidebar stack), or null when the tree has no such node. + */ +export function sidePanelViews(tree: LayoutTree): readonly string[] | null { + const found = findById(tree, "side"); + if (found === null || found.node.kind !== "panels") return null; + return (found.node as PanelsNode).panels.map((p) => p.view); +} + +function samePanels(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +/** + * The one-way migration decision (§2.7): given the RAW stored value, the + * known view ids, and the tree fetched from the provider, return the tree to + * PUT once — or null when there is nothing to migrate: + * - the stored key holds no usable selection, or + * - the provider's tree is NOT the untouched default seed (its side panels + * differ from DEFAULT_LAYOUT's — the workspace already has a custom layout). + */ +export function migrationTree( + storedRaw: unknown, + knownViews: ReadonlySet<string>, + fetched: LayoutTree, +): LayoutTree | null { + const stored = storedPanelViews(storedRaw, knownViews); + if (stored === null) return null; + const fetchedPanels = sidePanelViews(fetched); + const defaultPanels = sidePanelViews(DEFAULT_LAYOUT); + if (fetchedPanels === null || defaultPanels === null) return null; + if (!samePanels(fetchedPanels, defaultPanels)) return null; + // Overlay the stored selection onto the fetched tree (identical to the + // default here by the guard above — the seed overlay keeps the rest of the + // tree shared, not copied). + return overlaySidePanels(fetched, stored); +} 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/logic/panels-edit.test.ts b/src/features/layout/logic/panels-edit.test.ts new file mode 100644 index 0000000..b4c3099 --- /dev/null +++ b/src/features/layout/logic/panels-edit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./panels-edit"; + +describe("panel edit → op mapping", () => { + it("maps a dropdown swap to a replaceView on the panel leaf's path", () => { + expect(swapPanelOp([1], 2, "model")).toEqual({ + type: "replaceView", + target: [1, 2], + view: "model", + }); + }); + + it("maps the + button to an append insertPanel", () => { + expect(addPanelOp(["titlebar"], "tasks")).toEqual({ + type: "insertPanel", + panels: ["titlebar"], + view: "tasks", + }); + }); + + it("maps the ✕ button to a removeNode on the panel leaf's path", () => { + expect(removePanelOp([1], 0)).toEqual({ type: "removeNode", target: [1, 0] }); + }); + + it("the + button pre-selects the first eligible view (or null when none)", () => { + expect(firstPanelView([{ id: "tabs" }, { id: "model" }])).toBe("tabs"); + expect(firstPanelView([])).toBeNull(); + }); +}); diff --git a/src/features/layout/logic/panels-edit.ts b/src/features/layout/logic/panels-edit.ts new file mode 100644 index 0000000..8a56859 --- /dev/null +++ b/src/features/layout/logic/panels-edit.ts @@ -0,0 +1,33 @@ +/** + * Panel-edit → tree-op mapping: the pure decision logic behind TilePanels' + * dropdown swap / add / remove buttons. The component builds the ops; the + * renderer's owner (TileRoot → onTreeChange) applies them. Zero Svelte, zero + * DOM. + */ + +import type { LayoutOp, Path } from "@dispatch/layout-contract"; + +/** Swap the view a panel leaf mounts (panel `index` of the panels node at `path`). */ +export function swapPanelOp(path: Path, index: number, view: string): LayoutOp { + return { type: "replaceView", target: [...path, index], view }; +} + +/** Add a panel mounting `view` (appended to the panels node at `path`). */ +export function addPanelOp(path: Path, view: string): LayoutOp { + return { type: "insertPanel", panels: path, view }; +} + +/** Remove the panel at `index` from the panels node at `path`. */ +export function removePanelOp(path: Path, index: number): LayoutOp { + return { type: "removeNode", target: [...path, index] }; +} + +/** + * The view a new panel starts with. The tree schema has no "empty panel" + * state (a leaf's `view` is required — today's "Select a view" placeholder has + * no tree representation), so the + button pre-selects the first panel-eligible + * view. Returns null when nothing is eligible (the + button hides). + */ +export function firstPanelView(views: readonly { readonly id: string }[]): string | null { + return views[0]?.id ?? null; +} diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts new file mode 100644 index 0000000..e5e947c --- /dev/null +++ b/src/features/layout/logic/registry.test.ts @@ -0,0 +1,148 @@ +import type { Component } from "svelte"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getView, + listPanelViews, + listViews, + registerPluginView, + registerView, + unregisterView, + unregisterViewsByPlugin, + type ViewRegistration, + viewRegistry, +} from "./registry"; + +const fakeComponent: Component = () => { + throw new Error("fixture component should never be constructed"); +}; + +function registration(overrides?: Partial<ViewRegistration>): ViewRegistration { + return { id: "test-view", label: "Test View", component: fakeComponent, ...overrides }; +} + +const registeredIds = (): string[] => listViews().map((v) => v.id); + +afterEach(() => { + for (const id of registeredIds()) unregisterView(id); +}); + +describe("view registry", () => { + it("registers a view and exposes it via getView/listViews", () => { + registerView(registration()); + expect(getView("test-view")?.label).toBe("Test View"); + expect(registeredIds()).toContain("test-view"); + }); + + it("throws loudly on a duplicate id", () => { + registerView(registration()); + expect(() => registerView(registration({ label: "Other" }))).toThrow(/duplicate view id/); + }); + + it("normalizes panelEligible to true by default and respects false", () => { + registerView(registration({ id: "a" })); + registerView(registration({ id: "b", panelEligible: false })); + expect(getView("a")?.panelEligible).toBe(true); + expect(getView("b")?.panelEligible).toBe(false); + expect(listPanelViews().map((v) => v.id)).toContain("a"); + expect(listPanelViews().map((v) => v.id)).not.toContain("b"); + }); + + it("keeps pluginId when given", () => { + registerView(registration({ id: "plugin-view", pluginId: "my-plugin" })); + expect(getView("plugin-view")?.pluginId).toBe("my-plugin"); + }); + + it("unregisterView removes the view (idempotent for unknown ids)", () => { + registerView(registration({ id: "gone" })); + unregisterView("gone"); + expect(getView("gone")).toBeUndefined(); + expect(() => unregisterView("never-registered")).not.toThrow(); + }); + + it("is reactive: subscribers see registrations and unregistrations", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => seen.push(views.map((v) => v.id))); + try { + registerView(registration({ id: "reactive-a" })); + registerView(registration({ id: "reactive-b" })); + unregisterView("reactive-a"); + } finally { + unsubscribe(); + } + expect(seen[0]).toEqual([]); + expect(seen.at(-1)).toEqual(["reactive-b"]); + }); + + it("preserves registration order in listViews", () => { + registerView(registration({ id: "z-first" })); + registerView(registration({ id: "a-second" })); + const ids = registeredIds(); + expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second")); + }); +}); + +// ── Plugin-view registration (plan 03) — forced attribution + the sweep ───── + +describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => { + it("forces the importing plugin's pluginId attribution", () => { + const dispose = registerPluginView( + { id: "abi-view", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + expect(getView("abi-view")?.pluginId).toBe("my-plugin"); + dispose(); + expect(getView("abi-view")).toBeUndefined(); + }); + + it("returns a working per-view disposer (the plugin deactivate contract)", () => { + const dispose = registerPluginView( + { id: "abi-view-2", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + dispose(); + dispose(); // idempotent + expect(getView("abi-view-2")).toBeUndefined(); + }); + + it("the sweep removes exactly that plugin's views (registry attribution)", () => { + registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2"); + registerView(registration({ id: "builtin" })); // no pluginId — never swept + + unregisterViewsByPlugin("plugin-1"); + expect(getView("p1-a")).toBeUndefined(); + expect(getView("p1-b")).toBeUndefined(); + expect(getView("p2-a")).not.toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + + unregisterViewsByPlugin("plugin-2"); + expect(getView("p2-a")).toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + }); + + it("the sweep is idempotent and a no-op for an unknown plugin", () => { + unregisterViewsByPlugin("nobody"); + registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3"); + unregisterViewsByPlugin("plugin-3"); + expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow(); + }); + + it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => { + registerView(registration({ id: "taken" })); + expect(() => + registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"), + ).toThrow(/duplicate view id/); + }); + + it("sweep removals are visible to registry subscribers (reactive)", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => { + seen.push(views.map((v) => v.id)); + }); + registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p"); + unregisterViewsByPlugin("p"); + unsubscribe(); + expect(seen.at(-1)).not.toContain("reactive"); + }); +}); diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts new file mode 100644 index 0000000..bcd49e0 --- /dev/null +++ b/src/features/layout/logic/registry.ts @@ -0,0 +1,139 @@ +/** + * The view registry — the map from view id → component that tile leaves mount. + * + * Every view the renderer can place (the sidebar views + chat / composer / + * message-queue today; plugin views from plan 03 later) registers here. The + * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render + * open dropdowns, and the tree renderer resolves leaves through it per render. + * + * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02) + * can name views a given client lacks; the renderer shows a fallback tile. + * + * This module holds a small amount of reactive state by design (the plan places + * the registry in `logic/` with Svelte-store reactivity). It is NOT decision + * logic — no DOM, no effects. + */ + +import type { Component } from "svelte"; +import { type Readable, writable } from "svelte/store"; + +/** + * The props type of a registered view component. The composition root hands + * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`); + * fixture/generic components may ignore it. + * + * The registry is a HETEROGENEOUS component bag (the plan types it `Component` + * outright), so its props are opaque `Record<string, any>` — the wrappers + * re-narrow to their concrete props at the composition-root seam. + */ +export type ViewComponent = + // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag + Component<Record<string, any>>; + +export interface ViewRegistration { + /** Registry id — the same id a leaf's `view` field names. */ + readonly id: string; + /** Human label shown in panel dropdowns. */ + readonly label: string; + /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */ + readonly component: ViewComponent; + /** Whether the view may appear in panel dropdowns (default true). */ + readonly panelEligible?: boolean; + /** The plugin that contributed the view (plan 03 requires it for those). */ + readonly pluginId?: string; +} + +/** A registered view with `panelEligible` normalized (default true). */ +export interface RegisteredView { + readonly id: string; + readonly label: string; + readonly component: ViewComponent; + readonly panelEligible: boolean; + readonly pluginId: string | undefined; +} + +const byId = new Map<string, RegisteredView>(); +const store = writable<readonly RegisteredView[]>([]); + +function publish(): void { + store.set([...byId.values()]); +} + +/** + * Register a view. A duplicate id throws LOUDLY at dev time — two components + * claiming one id is a composition-root bug, never a runtime fallback case. + */ +export function registerView(registration: ViewRegistration): void { + if (byId.has(registration.id)) { + throw new Error(`registerView: duplicate view id "${registration.id}"`); + } + byId.set(registration.id, { + id: registration.id, + label: registration.label, + component: registration.component, + panelEligible: registration.panelEligible ?? true, + pluginId: registration.pluginId, + }); + publish(); +} + +/** Remove a view. Idempotent (removing an unknown id is a no-op). */ +export function unregisterView(id: string): void { + if (byId.delete(id)) publish(); +} + +/** + * Register a PLUGIN view (plan 03) — the ABI-facing registration: the + * `pluginId` attribution is FORCED to the importing plugin (plugins cannot + * attribute views to another plugin or to no plugin), which is what makes + * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view + * disposer (the plugin's deactivate contract). A duplicate id throws + * LOUDLY — the driver catches it and marks the plugin's FE half failed. + */ +export function registerPluginView( + registration: Omit<ViewRegistration, "pluginId">, + pluginId: string, +): () => void { + registerView({ ...registration, pluginId }); + return () => unregisterView(registration.id); +} + +/** + * Remove every view attributed to one plugin (the plan-03 unload sweep). + * Idempotent; the reactive registry re-renders open dropdowns, and tiles + * holding removed views fall back to `FallbackTile` (never a crash). + */ +export function unregisterViewsByPlugin(pluginId: string): void { + let changed = false; + for (const [id, view] of byId) { + if (view.pluginId === pluginId) { + byId.delete(id); + changed = true; + } + } + if (changed) publish(); +} + +/** Look a view up by id (undefined → the renderer shows its fallback tile). */ +export function getView(id: string): RegisteredView | undefined { + return byId.get(id); +} + +/** All registered views, in registration order (snapshot). */ +export function listViews(): readonly RegisteredView[] { + return [...byId.values()]; +} + +/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */ +export function listPanelViews(): readonly RegisteredView[] { + return listViews().filter((v) => v.panelEligible); +} + +/** + * Reactive registry contents (registration order). Subscribe in components — + * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03) + * re-render open dropdowns. + */ +export const viewRegistry: Readable<readonly RegisteredView[]> = { + subscribe: store.subscribe, +}; diff --git a/src/features/layout/logic/seed.test.ts b/src/features/layout/logic/seed.test.ts new file mode 100644 index 0000000..dfe166a --- /dev/null +++ b/src/features/layout/logic/seed.test.ts @@ -0,0 +1,99 @@ +import { DEFAULT_LAYOUT, type LayoutTree, type SplitNode } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./seed"; +import { firstCollapsibleNodeId, firstPanelsNode } from "./tree-query"; + +function sideViews(tree: LayoutTree): string[] { + const root = tree.root as SplitNode; + const side = root.children[1]?.node; + return side?.kind === "panels" ? side.panels.map((p) => p.view) : []; +} + +describe("seedLayout", () => { + it("uses the default panels when nothing is stored", () => { + const tree = seedLayout(null, ["tabs", "model", "settings"]); + expect(sideViews(tree)).toEqual(DEFAULT_PANEL_VIEWS); + }); + + it("overlays the stored selection, dropping unknown/removed kinds", () => { + const tree = seedLayout( + ["extensions", "ghost-view", "model"], + ["tabs", "model", "extensions", "settings"], + ); + expect(sideViews(tree)).toEqual(["extensions", "model"]); + }); + + it("falls back to the defaults when the stored selection is all-unknown or empty", () => { + expect(sideViews(seedLayout(["ghost"], ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout([], ["tabs", "model"]))).toEqual(["tabs", "model"]); + }); + + it("falls back to the defaults when the stored value is not a string array", () => { + expect(sideViews(seedLayout("junk", ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout([42], ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout({ a: 1 }, ["tabs", "model"]))).toEqual(["tabs", "model"]); + }); + + it("preserves the default tree's ids and the titlebar-unset invariant", () => { + const tree = seedLayout(["model"], ["tabs", "model"]); + expect((tree.root as SplitNode).id).toBe("root"); + expect(tree.titlebar).toBeUndefined(); + }); +}); + +describe("overlaySidePanels", () => { + it("replaces only the side panels of the given base tree", () => { + const tree = overlaySidePanels(DEFAULT_LAYOUT, ["settings", "tasks"]); + expect(sideViews(tree)).toEqual(["settings", "tasks"]); + // The rest of the tree is untouched. + expect((tree.root as SplitNode).children[0]?.node).toEqual( + (DEFAULT_LAYOUT.root as SplitNode).children[0]?.node, + ); + }); +}); + +describe("tree queries", () => { + it("firstPanelsNode finds the side panels with its path", () => { + const tree = seedLayout(["model"], ["tabs", "model"]); + const found = firstPanelsNode(tree); + expect(found?.path).toEqual([1]); + expect(found?.node.panels.map((p) => p.view)).toEqual(["model"]); + }); + + it("firstCollapsibleNodeId returns the default tree's side node id", () => { + expect(firstCollapsibleNodeId(DEFAULT_LAYOUT)).toBe("side"); + }); + + it("firstCollapsibleNodeId falls back to a path key when the node has no id", () => { + const tree: LayoutTree = { + version: 1, + root: { + kind: "split", + dir: "row", + children: [ + { size: 1, node: { kind: "leaf", view: "chat" } }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", panels: [{ kind: "leaf", view: "tabs" }] }, + }, + ], + }, + }; + // The collapsible child sits at root path index 1 → key "1". + expect(firstCollapsibleNodeId(tree)).toBe("1"); + }); + + it("firstCollapsibleNodeId returns null when nothing is collapsible", () => { + const tree: LayoutTree = { + version: 1, + root: { + kind: "split", + dir: "row", + children: [{ size: 1, node: { kind: "leaf", view: "chat" } }], + }, + }; + expect(firstCollapsibleNodeId(tree)).toBeNull(); + }); +}); diff --git a/src/features/layout/logic/seed.ts b/src/features/layout/logic/seed.ts new file mode 100644 index 0000000..7cd0ba3 --- /dev/null +++ b/src/features/layout/logic/seed.ts @@ -0,0 +1,78 @@ +/** + * Boot-time tree seeding: the default layout with today's sidebar selection + * overlaid from localStorage. Byte-compatible with today's boot behavior + * (`dispatch.sidebar.views` read; unknown/removed kinds dropped — the tree + * schema has no "unknown view" panel state, so a stale id is skipped exactly + * like a view that no longer exists). + */ + +import { + DEFAULT_LAYOUT, + findById, + type LayoutNode, + type LayoutTree, + type LeafNode, + type PanelsNode, + type Path, +} from "@dispatch/layout-contract"; + +/** + * Today's default sidebar selection (the moved-from-top tab bar + the model + * controls) — the fallback when nothing (or nothing valid) is stored. + */ +export const DEFAULT_PANEL_VIEWS: readonly string[] = ["tabs", "model"]; + +/** + * Seed a tree from DEFAULT_LAYOUT with `storedViews` (the parsed + * `dispatch.sidebar.views` value, or null when absent) overlaid onto the + * default tree's panels node. + * + * - null / non-array / array without any known view → the defaults. + * - Known views are kept in the stored order; unknown ids are dropped. + */ +export function seedLayout(storedViews: unknown, panelViewIds: readonly string[]): LayoutTree { + const known = new Set(panelViewIds); + const stored = Array.isArray(storedViews) + ? storedViews.filter((v): v is string => typeof v === "string") + : []; + const panels = stored.filter((v) => known.has(v)); + const selected = panels.length > 0 ? panels : DEFAULT_PANEL_VIEWS.filter((v) => known.has(v)); + return overlaySidePanels(DEFAULT_LAYOUT, selected); +} + +/** + * A NEW tree: `base` with its `"side"` panels node replaced by leaves mounting + * `views` (in order). Targets the panels node by id (falls back to no-op when + * the base has none); the rest of the tree is shared, not copied. + */ +export function overlaySidePanels(base: LayoutTree, views: readonly string[]): LayoutTree { + const found = findById(base, "side"); + if (found === null || found.node.kind !== "panels") return base; + const panels: readonly LeafNode[] = views.map((view) => ({ kind: "leaf", view })); + const next: PanelsNode = { ...found.node, panels }; + return { version: 1, root: replaceChild(base.root, found.path, next) }; +} + +/** Rebuild the node at `path` (immutably); the replacement must be a panels node. */ +function replaceChild(node: LayoutNode, path: Path, replacement: PanelsNode): LayoutNode { + if (path.length === 0) return replacement; + const [head, ...rest] = path; + if (typeof head !== "number") return node; + if (node.kind === "split") { + return { + ...node, + children: node.children.map((child, i) => + i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child, + ), + }; + } + if (node.kind === "stack") { + return { + ...node, + children: node.children.map((child, i) => + i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child, + ), + }; + } + return node; +} diff --git a/src/features/layout/logic/tree-query.ts b/src/features/layout/logic/tree-query.ts new file mode 100644 index 0000000..aa8dfe9 --- /dev/null +++ b/src/features/layout/logic/tree-query.ts @@ -0,0 +1,60 @@ +/** + * Tree queries the chrome needs — pure walks over the layout tree. + */ + +import type { LayoutNode, LayoutTree, PanelsNode, Path } from "@dispatch/layout-contract"; + +/** The first panels node in the tree (the panel-selection owner), if any. */ +export function firstPanelsNode(tree: LayoutTree): { node: PanelsNode; path: Path } | null { + const seen: { node: PanelsNode; path: Path }[] = []; + const visit = (node: LayoutNode, path: Path): void => { + if (seen.length > 0) return; + if (node.kind === "panels") { + seen.push({ node, path }); + return; + } + const kids: readonly LayoutNode[] = + node.kind === "split" + ? node.children.map((c) => c.node) + : node.kind === "stack" + ? node.children.map((c) => c.node) + : []; + for (const [i, child] of kids.entries()) visit(child, [...path, i]); + }; + visit(tree.root, []); + return seen[0] ?? null; +} + +/** + * The node id of the tree's FIRST collapsible split child — the one the chrome + * top bar's toggle reads/writes (`findById("side")` on the default tree; + * generically the first collapsible child, root-first then titlebar). Falls + * back to a path-derived key when the child's node has no id. Null → the tree + * has nothing collapsible (the toggle button hides). + */ +export function firstCollapsibleNodeId(tree: LayoutTree): string | null { + const visit = (node: LayoutNode, path: Path): string | null => { + if (node.kind === "split") { + for (const [i, child] of node.children.entries()) { + if (!child.collapsible) continue; + if (child.node.id !== undefined) return child.node.id; + return [...path, i].map((s) => String(s)).join(":"); + } + } + const kids: readonly LayoutNode[] = + node.kind === "split" + ? node.children.map((c) => c.node) + : node.kind === "stack" + ? node.children.map((c) => c.node) + : []; + for (const [i, child] of kids.entries()) { + const found = visit(child, [...path, i]); + if (found !== null) return found; + } + return null; + }; + const inRoot = visit(tree.root, []); + if (inRoot !== null) return inRoot; + if (tree.titlebar !== undefined) return visit(tree.titlebar, ["titlebar"]); + return null; +} diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts new file mode 100644 index 0000000..29e2fa1 --- /dev/null +++ b/src/features/layout/store.svelte.ts @@ -0,0 +1,316 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { migrationTree } from "./logic/migration"; + +/** + * Layout store (plan 02 §2.6) — a thin reactive wrapper over the injected + * `LayoutHttp` edge (the plugins-store pattern). Owns the ACTIVE workspace's + * tree and the provider lifecycle: + * + * - **Provider probe triggers** — boot, every workspace switch, every + * `extensions-changed`, and every change to the ACTIVE workspace's + * per-workspace plugin state (a layout plugin enabled/disabled in the + * workspace plugins modal starts/stops serving; the probe picks it up + * without a reload). + * - **No-provider mode** — a 404 (or network failure) keeps the tree on its + * local default; edits still work but are EPHEMERAL (in-memory only — + * D-L4 retired the localStorage write path); logged once, no error UI. + * - **WS push** — `onServerTree(workspaceId, tree)` applies a `layout` + * broadcast, ignoring non-active workspaces (the server fans out to all + * clients; the client filters). + * - **User edits persist through the provider** — `onTreeChange` applies the + * edit locally immediately, then PUTs the WHOLE tree (decision 7 — no + * patch protocol) lightly debounced; on a failed PUT the tree rolls back + * to the last server-known tree with a quiet console warning. + * - **Collapse state stays local** (decision 3) — `dispatch.sidebar.open` + * is the collapse store's concern, untouched here. + * - **One-way localStorage migration** (§2.7) — on the FIRST successful + * provider read of the boot session, if `dispatch.sidebar.views` exists + * AND the fetched tree is the untouched default seed, the stored panel + * selection is imported with one PUT; the key is then never read again. + * + * No ambient state: one instance per composition root, passed explicitly; + * no module-level mutables. + */ + +/** Whether an active provider is serving the active workspace. */ +export type LayoutProviderState = "unknown" | "present" | "absent"; + +export interface LayoutStore { + /** The rendered tree for the ACTIVE workspace (starts at the local default). */ + readonly tree: LayoutTree; + /** The active provider's state (absent = the no-provider degrade path). */ + readonly providerState: LayoutProviderState; + /** The workspace the tree currently belongs to. */ + readonly activeWorkspaceId: string; + /** + * Probe the provider for `workspaceId`'s tree. `boot: true` (the composition + * root's boot call) also arms the one-way localStorage migration (§2.7), + * which consumes itself on the first successful provider read regardless of + * outcome. A probe is the missed-push catch-up (§2.5). + */ + probe(workspaceId: string, opts?: { readonly boot?: boolean }): Promise<void>; + /** Workspace switch: reset to the local default for the new workspace + probe. */ + onWorkspaceChanged(workspaceId: string): void; + /** Server `layout` broadcast (§2.5). Non-active workspaces are ignored. */ + onServerTree(workspaceId: string, tree: LayoutTree): void; + /** A user edit (the panels' dropdowns / TileRoot ops). Persists via PUT. */ + onTreeChange(next: LayoutTree): void; + /** + * Reset the workspace's layout to the default seed (plan 04 §4.4 — the + * plugins modal's "Reset layout to default" affordance: + * `DELETE /workspaces/:id/layout` → re-seed → broadcast). Resolves to + * `null` on success (the fresh seed is applied locally + pushed via WS to + * every client) or to the FAILURE REASON — the no-provider 404 resolves + * to the user-facing "no layout plugin enabled" (the button's gate renders + * the same reason before this runs). + */ + resetToDefault(workspaceId: string): Promise<string | null>; + /** Multi-client coherence (§11): re-probe on `extensions-changed`. */ + onExtensionsChanged(): void; + /** + * The active workspace's PER-WORKSPACE PLUGIN STATE changed (the workspace + * plugins modal toggled a layout plugin's enablement — the per-workspace + * activation rule): re-probe so the newly-enabled provider's tree (or the + * no-provider degrade) lands without a reload. A NON-active workspace's + * toggle is ignored — its tree is fetched on its next probe (workspace + * switch); there is nothing to re-render here. + */ + onWorkspacePluginsChanged(workspaceId: string): void; +} + +export interface LayoutStoreOptions { + readonly http: LayoutHttp; + /** The boot workspace (its URL slug); "default" if absent. */ + readonly workspaceId?: string; + /** + * Injected localStorage — read ONLY by the one-way §2.7 migration (the + * write path is retired; collapse state has its own store). Undefined → + * migration never applies (tests / storageless contexts). + */ + readonly storage?: Storage | undefined; + /** The known (registered) view ids, for the migration's unknown-id filter. */ + readonly knownViews?: ReadonlySet<string> | readonly string[]; + /** + * The localStorage key the §2.7 migration reads (default + * `dispatch.sidebar.views`; tests override). + */ + readonly sidebarViewsKey?: string; + /** 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). */ + readonly warn?: (message: string) => void; +} + +const DEFAULT_SIDEBAR_VIEWS_KEY = "dispatch.sidebar.views"; +const DEFAULT_DEBOUNCE_MS = 300; + +function toKnownSet(known: LayoutStoreOptions["knownViews"]): ReadonlySet<string> { + if (known === undefined) return new Set(); + return known instanceof Set ? known : new Set(known); +} + +export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore { + const http = opts.http; + const storage = opts.storage; + const sidebarViewsKey = opts.sidebarViewsKey ?? DEFAULT_SIDEBAR_VIEWS_KEY; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const knownViews = toKnownSet(opts.knownViews); + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + let tree = $state<LayoutTree>(DEFAULT_LAYOUT); + let providerState = $state<LayoutProviderState>("unknown"); + let activeWorkspaceId = $state<string>(opts.workspaceId ?? "default"); + + /** The last tree known to come from the server (GET / WS push / PUT echo) — the rollback target. */ + let lastServerTree: LayoutTree | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the no-provider log (reset when a provider appears). */ + let loggedAbsent = false; + /** + * The §2.7 migration is armed by the BOOT probe and consumed by the first + * successful provider read — regardless of outcome (read-once semantics). + */ + let migrationArmed = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `layout: no layout provider active (${reason}) — rendering the built-in default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerTree(next: LayoutTree): void { + lastServerTree = next; + tree = next; + } + + /** The §2.7 one-way migration: at most one read + one PUT per session. */ + async function maybeMigrate(): Promise<void> { + if (!migrationArmed || storage === undefined) return; + migrationArmed = false; // consumed regardless of outcome (stop reading the key) + let raw: string | null = null; + try { + raw = storage.getItem(sidebarViewsKey); + } catch { + return; // storage unavailable — best-effort, done + } + if (raw === null) return; // nothing stored — nothing to import + let parsed: unknown = null; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return; // junk — best-effort, done + } + const migrated = migrationTree(parsed, knownViews, tree); + if (migrated === null) return; // custom layout already / nothing usable + const result = await http.putTree(activeWorkspaceId, migrated); + if (result.ok) { + // The server's canonical copy (also echoed back via the WS broadcast). + applyServerTree(result.tree); + } else if (!result.notFound) { + warn(`layout: localStorage migration PUT failed — ${result.error}`); + } + } + + async function runProbe(workspaceId: string, boot: boolean): Promise<void> { + if (boot) migrationArmed = true; + const result: LayoutHttpResult = await http.getTree(workspaceId); + // A switch may have happened while the fetch was in flight — only the + // CURRENT workspace's result lands (the refreshProviderStatus guard). + if (workspaceId !== activeWorkspaceId) return; + if (result.ok) { + notePresent(); + applyServerTree(result.tree); + await maybeMigrate(); + } else if (result.notFound) { + noteAbsent(result.error); + } else { + // A real failure (5xx, malformed) degrades like absent — quietly. + noteAbsent(result.error); + } + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(workspaceId: string, pending: LayoutTree): Promise<void> { + putTimer = null; + // Read the CURRENT tree at fire time: a newer edit supersedes the + // debounced snapshot (one in-flight write per window, last wins). + const target = tree === pending ? pending : tree; + const result = await http.putTree(workspaceId, target); + if (result.ok) { + notePresent(); + // The server's validated echo is the new rollback target. + lastServerTree = result.tree; + return; + } + if (result.notFound) { + // The provider vanished: keep the local edit (ephemeral), no rollback — + // there is no server tree to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT provider: roll back to the last server + // tree, quiet console warning (never an error UI). + warn( + `layout: persisting the tree failed (${result.error}) — rolled back to the last server tree`, + ); + if (lastServerTree !== null) tree = lastServerTree; + } + + return { + get tree() { + return tree; + }, + get providerState() { + return providerState; + }, + get activeWorkspaceId() { + return activeWorkspaceId; + }, + + async probe(workspaceId, probeOpts) { + await runProbe(workspaceId, probeOpts?.boot === true); + }, + + onWorkspaceChanged(workspaceId) { + if (workspaceId === activeWorkspaceId) return; + activeWorkspaceId = workspaceId; + // The tree belongs to the OLD workspace now: reset to the local default + // while the probe resolves (never flash the previous workspace's tree), + // and drop any queued edit — it was for the old workspace. + clearPutTimer(); + lastServerTree = null; + tree = DEFAULT_LAYOUT; + providerState = "unknown"; + void runProbe(workspaceId, false); + }, + + onServerTree(workspaceId, next) { + if (workspaceId !== activeWorkspaceId) return; // clients filter (§2.5) + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerTree(next); + }, + + onTreeChange(next) { + tree = next; // the edit applies locally IMMEDIATELY (the UI never waits) + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut(activeWorkspaceId, next).catch(() => { + // Network-grade failure: roll back to the last server tree, quietly. + warn("layout: persisting the tree failed — rolled back to the last server tree"); + if (lastServerTree !== null) tree = lastServerTree; + }); + }, debounceMs); + }, + + async resetToDefault(workspaceId): Promise<string | null> { + const result = await http.deleteTree(workspaceId); + if (result.ok) { + // The server re-seeded and broadcast (every client resets); this + // client applies the fresh seed immediately too (idempotent with the + // WS echo). Only the ACTIVE workspace's tree is ours to update. + notePresent(); + if (workspaceId === activeWorkspaceId) applyServerTree(result.tree); + return null; + } + // The no-provider 404: the user-facing reason the plan specifies. + if (result.notFound) return "no layout plugin enabled"; + return result.error; + }, + + onExtensionsChanged() { + void runProbe(activeWorkspaceId, false).catch(() => { + // runProbe never throws for HTTP failures; this guard is for the + // migration path. Staying silent keeps the degrade quiet. + }); + }, + + onWorkspacePluginsChanged(workspaceId) { + // The per-workspace activation rule: only the ACTIVE workspace's tree + // can change what is rendered here — another workspace's toggle is + // picked up by its own probe (boot/workspace-switch/§2.6 triggers). + if (workspaceId !== activeWorkspaceId) return; + void runProbe(activeWorkspaceId, false).catch(() => { + // Same quiet-degrade guard as onExtensionsChanged. + }); + }, + }; +} diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts new file mode 100644 index 0000000..331af95 --- /dev/null +++ b/src/features/layout/store.test.ts @@ -0,0 +1,529 @@ +import { + type ApplyOpsResult, + DEFAULT_LAYOUT, + type LayoutOp, + type LayoutTree, +} from "@dispatch/layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { overlaySidePanels } from "./logic/seed"; +import { createLayoutStore } from "./store.svelte"; + +/** + * Layout store tests (plan 02 verification gate) — mock HTTP EDGES ONLY (the + * injected `LayoutHttp` is the store's outermost boundary; nothing internal + * is mocked — asymmetric testing per AGENTS.md). Fake timers drive the edit + * PUT debounce deterministically. + */ + +/** A recording fake of the LayoutHttp edge, scripted per test. */ +interface HttpFake extends LayoutHttp { + getCalls: string[]; + putCalls: Array<{ workspaceId: string; tree: LayoutTree }>; + deleteCalls: string[]; + getResult: (workspaceId: string) => LayoutHttpResult; + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult; + deleteResult: (workspaceId: string) => LayoutHttpResult; + opsResult: (workspaceId: string, ops: readonly LayoutOp[]) => ApplyOpsResult; +} + +function fakeHttp( + getResult: (workspaceId: string) => LayoutHttpResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }), + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult = (_ws, tree) => ({ + ok: true, + tree, + }), +): HttpFake { + const getCalls: string[] = []; + const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = []; + const deleteCalls: string[] = []; + const fake: HttpFake = { + getCalls, + putCalls, + deleteCalls, + getResult, + putResult, + // Re-scriptable outcome (default: the fresh seed a reset returns). + deleteResult: () => ({ ok: true, tree: DEFAULT_LAYOUT }), + // Re-scriptable outcome (default: the typed no-provider failure the ABI + // documents; tests re-assign mid-test like putResult). + opsResult: () => ({ ok: false, error: "no layout provider active" }), + // Delegate to the CURRENT properties so a test can re-script outcomes + // mid-test (http.putResult = …). + async getTree(workspaceId) { + getCalls.push(workspaceId); + return fake.getResult(workspaceId); + }, + async putTree(workspaceId, tree) { + putCalls.push({ workspaceId, tree }); + return fake.putResult(workspaceId, tree); + }, + async deleteTree(workspaceId) { + deleteCalls.push(workspaceId); + return fake.deleteResult(workspaceId); + }, + async postOps(workspaceId, ops) { + return fake.opsResult(workspaceId, ops); + }, + }; + return fake; +} + +function storageWith(views: string[] | null): Storage { + const map = new Map<string, string>(); + if (views !== null) map.set("test.sidebar.views", JSON.stringify(views)); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem: (k) => map.get(k) ?? null, + key: () => null, + removeItem: (k) => map.delete(k), + setItem: (k, v) => map.set(k, v), + }; +} + +const KNOWN = ["tabs", "model", "extensions", "vision"]; + +function makeStore(http: HttpFake, extra: Record<string, unknown> = {}) { + return createLayoutStore({ + http, + workspaceId: "ws-a", + knownViews: KNOWN, + sidebarViewsKey: "test.sidebar.views", + debounceMs: 100, + warn: (message) => warnings.push(message), + ...extra, + }); +} + +let warnings: string[] = []; + +beforeEach(() => { + vi.useFakeTimers(); + warnings = []; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("layout store — no-provider fallback (§2.6)", () => { + it("a 404 probe keeps the default tree, marks the provider absent, and logs ONCE", async () => { + const http = fakeHttp(); + const store = makeStore(http); + + await store.probe("ws-a", { boot: true }); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("no layout provider"); + + // A re-probe (extensions-changed while still absent) does not re-warn. + await store.probe("ws-a"); + expect(warnings).toHaveLength(1); + }); + + it("a network failure degrades like a 404 (quiet, no error UI)", async () => { + const http = fakeHttp(() => ({ ok: false, notFound: true, error: "fetch blew up" })); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + }); + + it("edits still apply in no-provider mode but are EPHEMERAL (no PUT, no rollback, no warning)", async () => { + const http = fakeHttp(); + const store = makeStore(http); + await store.probe("ws-a"); // absent now + expect(store.providerState).toBe("absent"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + expect(store.tree).toEqual(edited); + + vi.advanceTimersByTime(500); + expect(http.putCalls).toEqual([]); // ephemeral: no write attempted + // Quiet = the single absence log (from the probe), nothing more. + expect(warnings).toHaveLength(1); + }); + + it("an edit while the probe is still in flight is PUT optimistically; a 404 flips absent and KEEPS the edit", async () => { + const http = fakeHttp(); + // The PUT is a 404 (the probe will confirm: no provider). + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const store = makeStore(http); + + void store.probe("ws-a"); // in flight (microtask pending) + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); // lands BEFORE the probe resolves + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); // the optimistic write WAS attempted + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(edited); // no rollback — there is no server tree + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — provider present", () => { + it("a 200 probe replaces the tree and marks the provider present", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + expect(warnings).toEqual([]); + }); +}); + +describe("layout store — WS push (§2.5)", () => { + it("a layout push for the ACTIVE workspace replaces the tree", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const pushed = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-a", pushed); + expect(store.tree).toEqual(pushed); + }); + + it("a push for a NON-active workspace is ignored", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const other = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-b", other); + expect(store.tree).toEqual(served); + }); + + it("after a workspace switch the OLD workspace's push is ignored (clients filter)", async () => { + const servedA = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + let served = servedA; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const servedB = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + served = servedB; + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(0); // the probe's promise + expect(store.tree).toEqual(servedB); + + // A late push for the OLD workspace must not land. + const stale = overlaySidePanels(DEFAULT_LAYOUT, ["tasks"]); + store.onServerTree("ws-a", stale); + expect(store.tree).toEqual(servedB); + }); +}); + +describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => { + it("an edit PUTs the WHOLE tree, debounced; a 200 echo becomes the rollback target", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + // Not yet — debounced. + expect(http.putCalls).toEqual([]); + await vi.advanceTimersByTimeAsync(150); + expect(http.putCalls).toEqual([{ workspaceId: "ws-a", tree: edited }]); + expect(warnings).toEqual([]); + }); + + it("rapid edits coalesce into ONE debounced PUT of the latest tree", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(50); + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); + expect(http.putCalls[0]?.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + }); + + it("a 400 rejection rolls back to the last server tree with a quiet warning", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + + const bad = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(bad); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(served); // rolled back + expect(store.providerState).toBe("present"); // the provider is still there + expect(warnings.some((w) => w.includes("rolled back"))).toBe(true); + }); + + it("a 5xx failure rolls back too (present provider, transient error)", async () => { + const served = DEFAULT_LAYOUT; + let failPut = false; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => + failPut + ? { ok: false, notFound: false, error: "storage down" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + failPut = true; + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(200); + expect(store.tree).toEqual(served); + }); + + it("a PUT 404 does NOT roll back — the provider vanished mid-session; the edit stays ephemeral", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + // The provider unloads before the edit's PUT fires. + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(edited); // the edit STAYS (in-memory) + expect(store.providerState).toBe("absent"); + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — probe triggers (§2.6)", () => { + it("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled in THIS workspace + store.onWorkspacePluginsChanged("ws-a"); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here + store.onWorkspacePluginsChanged("ws-other"); + await vi.advanceTimersByTimeAsync(0); + expect(http.getCalls).toEqual(["ws-a"]); // no second probe + expect(store.providerState).toBe("absent"); + }); + + it("extensions-changed re-probes the active workspace", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout provider is active" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled on the server + store.onExtensionsChanged(); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a workspace switch resets to the default, probes the NEW workspace, and drops queued edits", async () => { + const http = fakeHttp((ws) => + ws === "ws-b" + ? { ok: true, tree: overlaySidePanels(DEFAULT_LAYOUT, ["vision"]) } + : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + // Queue an edit for ws-a, then switch before the debounce fires. + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(300); + + expect(store.activeWorkspaceId).toBe("ws-b"); + expect(http.getCalls).toEqual(["ws-a", "ws-b"]); + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + // The queued edit never PUTs — it belonged to the old workspace. + expect(http.putCalls).toEqual([]); + }); + + it("switching to the SAME workspace is a no-op (no re-probe, no reset)", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + store.onWorkspaceChanged("ws-a"); + expect(http.getCalls).toEqual(["ws-a"]); + }); +}); + +describe("layout store — one-way localStorage migration (§2.7)", () => { + it("boot probe + equal panels + stored key → exactly ONE migration PUT, then the key is dead", async () => { + const storage = storageWith(["extensions", "vision"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]) }, + ]); + // The migrated tree is now the rendered tree. + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"])); + + // The key is never read again: a later probe PUTs nothing. + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("no stored key → no migration PUT (but the read-once flag is still consumed)", async () => { + const storage = storageWith(null); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + }); + + it("a custom provider tree (panels differ) is NOT migrated over", async () => { + const storage = storageWith(["extensions"]); + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + const http = fakeHttp(() => ({ ok: true, tree: custom })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + expect(store.tree).toEqual(custom); + }); + + it("migration is boot-only: a non-boot probe never PUTs the stored selection", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a"); + expect(http.putCalls).toEqual([]); + }); + + it("a failed migration PUT consumes the flag (best-effort — never retried, never fatal)", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp( + () => ({ ok: true, tree: DEFAULT_LAYOUT }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toHaveLength(1); + expect(warnings.some((w) => w.includes("migration"))).toBe(true); + // The rendered tree is untouched (the PUT failed). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("migration arms even when the boot probe 404s, and applies on the first LATER success", async () => { + const storage = storageWith(["extensions"]); + let absent = true; + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout provider is active" } + : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + + absent = false; + await store.probe("ws-a"); // extensions-changed probe — first success + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]) }, + ]); + }); +}); + +describe("layout store — resetToDefault (plan 04 §4.4, the reset affordance)", () => { + it("DELETEs the workspace's layout, applies the fresh seed, and resolves null", async () => { + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(); + // The DELETE returns the FRESH SEED (the server re-seeded). + http.deleteResult = () => ({ ok: true, tree: DEFAULT_LAYOUT }); + const store = makeStore(http); + + // Get to a NON-default tree first: a present provider + a pushed edit. + http.getResult = () => ({ ok: true, tree: custom }); + await store.probe("ws-a"); + expect(store.tree).toEqual(custom); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBeNull(); + expect(http.deleteCalls).toEqual(["ws-a"]); + // The fresh seed is applied locally (the WS broadcast echo is idempotent). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(store.providerState).toBe("present"); + }); + + it("a 404 (no provider) resolves to the user-facing 'no layout plugin enabled' reason", async () => { + const http = fakeHttp(); + http.deleteResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + const store = makeStore(http); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBe("no layout plugin enabled"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); // untouched local default + }); + + it("another failure (5xx on a present provider) resolves to its error message", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + http.deleteResult = () => ({ ok: false, notFound: false, error: "storage write failed" }); + const store = makeStore(http); + await store.probe("ws-a"); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBe("storage write failed"); + }); +}); diff --git a/src/features/layout/ui/FallbackTile.svelte b/src/features/layout/ui/FallbackTile.svelte new file mode 100644 index 0000000..8fafa7f --- /dev/null +++ b/src/features/layout/ui/FallbackTile.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + /** + * Shown when a leaf names a view this client has not registered — the tree + * is data and can arrive from a backend (plan 02) that knows views this + * build lacks. A titled placeholder, never a crash. + */ + let { viewId }: { viewId: string } = $props(); +</script> + +<div class="flex flex-col gap-1 rounded-lg bg-base-200 p-3" data-testid="fallback-tile"> + <h3 class="text-xs font-semibold uppercase opacity-60">{viewId}</h3> + <p class="text-sm opacity-60">View not registered.</p> +</div> diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte new file mode 100644 index 0000000..0fd6430 --- /dev/null +++ b/src/features/layout/ui/TileLeaf.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + /** + * Mounts a registered view for a leaf node. Resolution goes through the + * REACTIVE registry, so a late registration (plan 03) swaps the tile in + * without a tree change. An unknown view id renders a fallback tile — + * NEVER a crash (backend-owned trees can name views this client lacks). + * + * A diagnostic `marker` on the leaf draws an outline around it — the only + * node kind without a natural tile wrapper, so the wrapper exists only + * when marked (unmarked leaves render exactly as before). + */ + import type { TileMarker } from "@dispatch/layout-contract"; + import { viewRegistry, type RegisteredView } from "../logic/registry"; + import { MARKER_OUTLINE_OFFSET, markerOutline } from "../logic/marker"; + import FallbackTile from "./FallbackTile.svelte"; + + let { + node, + ports, + }: { + node: { readonly view: string; readonly marker?: TileMarker }; + ports?: unknown; + } = $props(); + + const entry: RegisteredView | undefined = $derived( + $viewRegistry.find((v) => v.id === node.view), + ); + const View = $derived(entry?.component); + const outline = $derived(markerOutline(node.marker)); +</script> + +{#snippet content()} + {#if View} + <View {ports} /> + {:else} + <FallbackTile viewId={node.view} /> + {/if} +{/snippet} + +{#if outline !== undefined} + <div style:outline={outline} style:outline-offset={MARKER_OUTLINE_OFFSET}>{@render content()}</div> +{:else} + {@render content()} +{/if} diff --git a/src/features/layout/ui/TileNode.svelte b/src/features/layout/ui/TileNode.svelte new file mode 100644 index 0000000..5ed8a89 --- /dev/null +++ b/src/features/layout/ui/TileNode.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + /** + * The recursive tile dispatcher: renders the node for its `kind` and recurses + * through the container components. Leaf nodes resolve through the view + * registry (TileLeaf). + */ + import type { LayoutNode, LayoutOp, Path } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import TileLeaf from "./TileLeaf.svelte"; + import TilePanels from "./TilePanels.svelte"; + import TileSplit from "./TileSplit.svelte"; + import TileStack from "./TileStack.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: LayoutNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); +</script> + +{#if node.kind === "split"} + <TileSplit {node} {path} {ports} {collapse} {onEdit} /> +{:else if node.kind === "stack"} + <TileStack {node} {path} {ports} {collapse} {onEdit} /> +{:else if node.kind === "panels"} + <TilePanels {node} {path} {ports} {onEdit} /> +{:else if node.kind === "leaf"} + <TileLeaf {node} {ports} /> +{/if} diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte new file mode 100644 index 0000000..cc5fa89 --- /dev/null +++ b/src/features/layout/ui/TilePanels.svelte @@ -0,0 +1,185 @@ +<script lang="ts"> + /** + * A scrolling stack of swappable panels — the generalized ViewSidebar. The + * panel list comes from the tree node; dropdown options from the (reactive) + * registry's panel-eligible views. Edits emit tree ops upward via `onEdit`; + * the OWNER (App today, the backend store in plan 02) applies them and + * decides persistence. + */ + 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 { + node, + path, + ports, + onEdit, + renderMarker = false, + }: { + node: PanelsNode; + path: Path; + ports?: unknown; + onEdit: (ops: readonly LayoutOp[]) => void; + /** + * Whether to draw the node's diagnostic marker on this root. Default NO: + * a panels node is the scrolled CONTENT of a split's collapsible child, + * so an outline here wraps the full scrollable region and scrolls with + * it — the split draws the marker on the aside (the fixed viewport) + * instead. Pass `true` only for a panels node used outside a split. + */ + renderMarker?: boolean; + } = $props(); + + // Panel-eligible views only (decision 5): chat/composer/message-queue never + // 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 + 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 (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)])} + > + <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> + </div> + </div> + {/each} + + {#if addView !== null} + <!-- `.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. 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" + aria-label="Add view" + onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])} + > + <PlusIcon size={18} weight="bold" aria-hidden="true" /> + </button> + {/if} +</div> diff --git a/src/features/layout/ui/TileRoot.svelte b/src/features/layout/ui/TileRoot.svelte new file mode 100644 index 0000000..82ad430 --- /dev/null +++ b/src/features/layout/ui/TileRoot.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The tile-tree root. Renders the tree (titlebar slot first — unset renders + * nothing, D-L5), applies panel edit ops to its `tree` prop, and forwards the + * new tree via `onTreeChange`. The OWNER decides what persistence means + * (App/localStorage today; the backend layout store in plan 02). + */ + import { applyOps, type LayoutOp, type LayoutTree } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import TileNode from "./TileNode.svelte"; + + let { + tree, + onTreeChange, + ports, + collapse, + }: { + tree: LayoutTree; + onTreeChange?: (tree: LayoutTree) => void; + ports?: unknown; + collapse: CollapseStore; + } = $props(); + + function handleEdit(ops: readonly LayoutOp[]): void { + const result = applyOps(tree, ops); + if (result.ok) { + onTreeChange?.(result.tree); + } else { + // A panel edit that fails validation (e.g. removing the last panel) is + // refused loudly in dev — the tree keeps its previous state. + console.error(`TileRoot: layout edit refused: ${result.error}`); + } + } +</script> + +{#if tree.titlebar !== undefined} + <TileNode node={tree.titlebar} path={["titlebar"]} {ports} {collapse} onEdit={handleEdit} /> +{/if} +<TileNode node={tree.root} path={[]} {ports} {collapse} onEdit={handleEdit} /> diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts new file mode 100644 index 0000000..4b404a1 --- /dev/null +++ b/src/features/layout/ui/TileRoot.test.ts @@ -0,0 +1,394 @@ +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"; +import FixtureB from "./fixtures/FixtureB.svelte"; +import FixtureC from "./fixtures/FixtureC.svelte"; +import TileRoot from "./TileRoot.svelte"; + +// Fixture registry: two panel-eligible views + one content-only view (a THIRD +// component, so every fixture testid is unique in the rendered tree). +registerView({ id: "fix-a", label: "Alpha View", component: FixtureA }); +registerView({ id: "fix-b", label: "Beta View", component: FixtureB }); +registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false }); + +function leaf(view: string): { kind: "leaf"; view: string } { + return { kind: "leaf", view }; +} + +/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */ +function defaultTestTree(): LayoutTree { + return { + version: 1, + root: { + kind: "split", + id: "root", + dir: "row", + children: [ + { + size: 1, + node: { + kind: "stack", + id: "main", + children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }], + }, + }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", id: "side", panels: [leaf("fix-b")] }, + }, + ], + }, + }; +} + +/** A bare panels root — panel EDITS without the collapsible chrome in the way. */ +function panelsOnlyTree(...views: string[]): LayoutTree { + return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } }; +} + +/** The default test tree WITH the diagnostic markers on its two root tiles. */ +function markedTestTree(): LayoutTree { + return { + version: 1, + root: { + kind: "split", + id: "root", + dir: "row", + children: [ + { + size: 1, + node: { + kind: "stack", + id: "main", + marker: "main", + children: [{ grow: 1, node: leaf("fix-a") }], + }, + }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", id: "side", marker: "side", panels: [leaf("fix-b")] }, + }, + ], + }, + }; +} + +function sidePanelsOf(tree: LayoutTree | null): string[] { + if (tree === null) return []; + const side = (tree.root as SplitNode).children?.[1]?.node; + return side?.kind === "panels" + ? side.panels.map((p) => p.view) + : tree.root.kind === "panels" + ? tree.root.panels.map((p) => p.view) + : []; +} + +describe("TileRoot", () => { + it("renders every node kind: split, stack, panels, leaf", () => { + // A FRESH collapse store per test — the store is stateful chrome. + render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + // Stack children mount their registered components. + expect(screen.getByTestId("fixture-a")).toBeInTheDocument(); + expect(screen.getByTestId("fixture-c")).toBeInTheDocument(); + // The panels node renders its panel leaf + the dropdown. + expect(screen.getByTestId("fixture-b")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument(); + }); + + it("shows a fallback tile (never a crash) for an unknown view id", () => { + const tree: LayoutTree = { + version: 1, + root: { kind: "leaf", view: "not-registered-anywhere" }, + }; + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const fallback = screen.getByTestId("fallback-tile"); + expect(fallback).toHaveTextContent("not-registered-anywhere"); + expect(fallback).toHaveTextContent("View not registered."); + }); + + it("renders nothing for the titlebar slot while it is unset (D-L5)", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0); + }); + + it("a set titlebar renders its node (the slot works; nothing populates it today)", () => { + const tree: LayoutTree = { + version: 1, + titlebar: { kind: "leaf", view: "fix-a" }, + root: { kind: "leaf", view: "fix-b" }, + }; + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + expect(screen.getByTestId("fixture-a")).toBeInTheDocument(); + expect(screen.getByTestId("fixture-b")).toBeInTheDocument(); + }); + + it("the collapsible child reflects the collapse store and toggles through it", async () => { + const collapse = createCollapseStore({ viewportWidth: 1024 }); + const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } }); + const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']"); + expect(aside).not.toBeNull(); + // Wide viewport default: open → full basis width. + expect(aside).toHaveAttribute("data-open", "true"); + expect(aside?.style.width).toBe("320px"); + + await collapse.toggle("side"); + expect(aside).toHaveAttribute("data-open", "false"); + expect(aside?.style.width).toBe("0px"); + }); + + it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a"); + expect(sidePanelsOf(changed)).toEqual(["fix-a"]); + // TileRoot is a CONTROLLED component: applying the op is the owner's job + // (App re-renders with the new tree; the test doesn't feed it back). + }); + + it("the + button emits insertPanel pre-selecting the first eligible view", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + await user.click(screen.getByRole("button", { name: "Add view" })); + expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]); + }); + + it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b", "fix-a"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + // Only panels after the first can be removed — like ViewSidebar today. + const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); + expect(removeButtons).toHaveLength(1); + await user.click(removeButtons[0] as HTMLElement); + expect(sidePanelsOf(changed)).toEqual(["fix-b"]); + }); + + it("an invalid edit is refused: the first panel has no remove button", () => { + const tree = panelsOnlyTree("fix-b"); + let changed: LayoutTree | null = null; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render(TileRoot, { + props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) }, + }); + expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); + expect(changed).toBeNull(); + } finally { + consoleError.mockRestore(); + } + }); + + it("a stack child with grow renders in a flex-grown wrapper; others natural", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0"); + expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%"); + const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0"); + expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto"); + }); + + it("the stack column is full-height so a grow child fills it even when empty", () => { + // jsdom can't compute flex layout — the honest seam is the CSS class. + // WITHOUT `h-full` the stack column's height is content-based, so the + // grow child's `flex: 1 1 0%` has no space to fill: an EMPTY chat leaf + // collapses to ~0 and the composer below it rides to the top (instead of + // staying pinned at the bottom of the column). + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + const stackColumn = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stackColumn).toHaveClass("h-full"); + }); + + it("a marked stack/panels node draws its diagnostic outline (main red, side green)", () => { + // The inline outline IS the honest seam: marker values arrive in runtime + // tree data (Tailwind can't see them — dynamic classes would silently + // render nothing), so the renderer applies an inline style, and the test + // asserts exactly what lands on the tile wrapper. + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stack?.getAttribute("style")).toContain("outline: 4px solid red"); + // The SIDE marker frames the SCROLL CONTAINER — the sidebar's fixed, + // opaquely-painted viewport (the aside's bg is transparent; this div's + // bg-base-100 would cover an outline drawn on the aside). The panels + // root is its SCROLLED content, so an outline there would wrap the + // whole scrollable region and scroll along with it. + const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']"); + expect(viewport?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + it("outlines are INSET so they draw inside the tile's box (never clipped at screen edges)", () => { + // Tiles span their containers edge-to-edge, so a default (outside) + // outline overflows the visible area and is clipped away — the offset + // pulls the ring inside the box. Pin it on both marked tiles. + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stack?.getAttribute("style")).toContain("outline-offset: -4px"); + const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']"); + expect(viewport?.getAttribute("style")).toContain("outline-offset: -4px"); + }); + + it("the panels CONTENT carries no outline (it scrolls — the marker must not scroll with it)", () => { + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]"); + expect(panels?.getAttribute("style") ?? "").not.toContain("outline"); + }); + + it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0); + }); + + it("an unknown marker value draws nothing (the renderer ignores what it doesn't know)", () => { + // Both root tiles marked with a value the renderer doesn't know. + const tree = markedTestTree(); + const root = tree.root; + if (root.kind !== "split") return; + const junked = root.children.map((child) => ({ + ...child, + node: { ...child.node, marker: "purple" } as unknown as typeof child.node, + })); + const marked: LayoutTree = { version: 1, root: { ...root, children: junked } }; + const { container } = render(TileRoot, { + props: { tree: marked, collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0); + }); + + it("a marked LEAF draws its outline around a wrapper (the only wrapperless node kind)", () => { + const tree: LayoutTree = { + version: 1, + root: { kind: "leaf", view: "fix-a", marker: "side" }, + }; + const { container } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const fixture = container.querySelector("[data-testid='fixture-a']"); + expect(fixture?.parentElement?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + it("the dropdown lists panel-eligible views only (decision 5)", () => { + render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } }); + const select = screen.getByRole("combobox", { name: "Select a view" }); + const labels = [...select.querySelectorAll("option")].map((o) => o.textContent); + expect(labels).toContain("Alpha View"); + expect(labels).toContain("Beta View"); + expect(labels).not.toContain("Content"); + }); + + it("an unregistered view id inside a panel renders the fallback (not a crash)", () => { + render(TileRoot, { + props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() }, + }); + expect(screen.getByTestId("fallback-tile")).toBeInTheDocument(); + }); +}); + +describe("TileRoot registry cleanup", () => { + it("unregisters the fixture views (the registry is module-global per test file)", () => { + unregisterView("fix-a"); + unregisterView("fix-b"); + unregisterView("fix-content"); + const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } }; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + // Unregistered → the fallback tile, not a crash. + expect(screen.getByTestId("fallback-tile")).toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); +}); + +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/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte new file mode 100644 index 0000000..84fa075 --- /dev/null +++ b/src/features/layout/ui/TileSplit.svelte @@ -0,0 +1,130 @@ +<script lang="ts"> + /** + * A flex row/column of child subtrees. Child sizing comes from the tree: + * `size` → flex-grow (+ optional `basis`), and `collapsible` children get + * today's EXACT sidebar behavior — width/translate transition, `lg:relative` + * in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state + * is chrome (per-node-id), owned by the injected collapse store. + */ + import type { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import { markerStyle } from "../logic/marker"; + import TileNode from "./TileNode.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: SplitNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); + + const state = $derived($collapse); + + /** Collapse state key: the node's id when present, else its path (stable enough for chrome). */ + function keyOf(i: number, id: string | undefined): string { + return id ?? [...path, i].map((s) => String(s)).join(":"); + } + + function isOpen(i: number, id: string | undefined): boolean { + const key = keyOf(i, id); + return state.openByNode[key] ?? state.defaultOpen; + } + + /** + * The scroll container's inline style: the fixed sidebar width + slide + * transform + any diagnostic marker (inset) — none when unmarked. + * + * The marker goes on THIS div, not the aside: the inner div paints an + * OPAQUE full-height background (bg-base-100) that would cover an outline + * drawn on the aside. This div's own box never moves — only its contents + * scroll — and overflow-y-auto clips descendants, never an element's own + * outline, so the marker frames exactly the visible sidebar. + */ + function scrollViewportStyle( + basis: number | undefined, + open: boolean, + marker: TileMarker | undefined, + ): string { + const parts = [`width: ${basis ?? 320}px`, `transform: translateX(${open ? "0" : "100%"})`]; + const markerCss = markerStyle(marker); + if (markerCss !== undefined) parts.push(markerCss); + return parts.join("; "); + } +</script> + +{#if node.dir === "col"} + <div class="flex min-h-0 w-full flex-col" style={markerStyle(node.marker)}> + {#each node.children as child, i (i)} + {@const key = keyOf(i, child.node.id)} + <div + class="min-h-0 min-w-0" + style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")} + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/each} + </div> +{:else} + <div class="flex min-h-0 h-full flex-row" style={markerStyle(node.marker)}> + {#each node.children as child, i (i)} + {@const key = keyOf(i, child.node.id)} + {#if child.collapsible} + {@const open = isOpen(i, child.node.id)} + <!-- + A diagnostic marker on a collapsible child is drawn on the SCROLL + CONTAINER below — the sidebar's fixed, opaquely-painted viewport — + NOT on the child's own root element: that root renders INSIDE the + scroll container, so an outline there would wrap the full + scrollable content and scroll along with it instead of framing the + visible panel. + --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <!-- Viewport padding: top/right/bottom always; LEFT only in overlay mode + (< lg) — inline (≥ lg) the chat column's gutter already separates the + views, but the overlay's left edge is the panel's own edge. --> + <aside + class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative" + style="width: {open ? (child.basis ?? 320) : 0}px" + data-testid="collapsible-child" + data-open={open} + > + <div + class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 max-lg:pl-3 transition-transform duration-300 ease-out" + style={scrollViewportStyle(child.basis, open, child.node.marker)} + data-testid="sidebar-scroll-viewport" + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + </aside> + {#if open} + <!-- Backdrop: only on narrow screens (overlay mode), click to close. --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-20 bg-black/30 lg:hidden" + role="button" + tabindex="0" + aria-label="Close sidebar" + onclick={() => collapse.setOpen(key, false)} + onkeydown={(e) => { + if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false); + }} + ></div> + {/if} + {:else} + <div + class="min-h-0 min-w-0" + style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")} + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/if} + {/each} + </div> +{/if} diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte new file mode 100644 index 0000000..c8f8cae --- /dev/null +++ b/src/features/layout/ui/TileStack.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + /** + * A plain vertical container (today's left column). `grow` children flex + * (`flex: grow 1 0%` — the chat region scrolls internally, exactly like + * today's `flex-1`); children without grow take natural height. + * + * `h-full` keeps the container's main size DEFINITE (the parent is the + * split-child wrapper, cross-stretched to the full-height split row). With + * an indefinite height, `flex: 1 1 0%` degenerates to content sizing — an + * EMPTY grow child collapses to ~0 and the siblings below it (composer) + * ride to the top instead of staying pinned at the bottom. + */ + import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import { markerStyle } from "../logic/marker"; + import TileNode from "./TileNode.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: StackNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); +</script> + +<div + class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden" + style={markerStyle(node.marker)} +> + {#each node.children as child, i (i)} + <div class="min-h-0 min-w-0" style={"flex: " + (child.grow ?? 0) + (child.grow === undefined ? " 0 auto" : " 1 0%")}> + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/each} +</div> diff --git a/src/features/layout/ui/fixtures/FixtureA.svelte b/src/features/layout/ui/fixtures/FixtureA.svelte new file mode 100644 index 0000000..e55ba2a --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureA.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Alpha" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-a">{label}</div> diff --git a/src/features/layout/ui/fixtures/FixtureB.svelte b/src/features/layout/ui/fixtures/FixtureB.svelte new file mode 100644 index 0000000..1c48584 --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureB.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Beta" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-b">{label}</div> diff --git a/src/features/layout/ui/fixtures/FixtureC.svelte b/src/features/layout/ui/fixtures/FixtureC.svelte new file mode 100644 index 0000000..2bceb67 --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureC.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Gamma" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-c">{label}</div> 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/adapter/text-file.test.ts b/src/features/markdown/adapter/text-file.test.ts new file mode 100644 index 0000000..2597271 --- /dev/null +++ b/src/features/markdown/adapter/text-file.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TextFileState } from "../types"; +import { createTextFileClient } from "./text-file"; + +const response = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +const file = (text = "# Plan") => ({ + path: "/project/a.md", + text, + format: "markdown", + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, +}); +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; +describe("context-bound live-file client", () => { + it("preflights once, sends both guards and encodes literal path once; dedups repeated references", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockImplementation(async (url) => + response(String(url).endsWith("-context") ? context : file()), + ); + const client = createTextFileClient("/api", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + const offA = client.subscribe("notes/100%.md", (s) => a.push(s)); + const offB = client.subscribe("notes/100%.md", (s) => b.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(2); + const url = new URL(String(fetcher.mock.calls[1]?.[0]), "http://localhost"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + path: "notes/100%.md", + expectedCwd: "/project", + expectedComputerId: "", + }); + expect(fetcher.mock.calls[1]?.[1]?.cache).toBe("no-store"); + expect(a.at(-1)?.kind).toBe("ready"); + expect(b.at(-1)?.kind).toBe("ready"); + offA(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(false); + offB(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + client.subscribe("notes/100%.md", () => {}); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("never reads a file before context, handles remote inheritance explicitly", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValue(response({ ...context, computerId: "remote" })); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("/same-local-path.md", (s) => states.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(states.at(-1)).toMatchObject({ + kind: "error", + message: expect.stringContaining("remote computers"), + }); + client.dispose(); + }); + it.each([ + 400, 404, 409, 413, 422, + ])("reports HTTP %i then Retry refreshes authoritative context and live file", async (status) => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response({ error: `failure ${status}` }, status)) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("changed"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)).toEqual({ kind: "error", message: `failure ${status}` }); + client.refresh("a.md"); + await flush(); + expect(states.at(-1)).toMatchObject({ kind: "ready", file: { text: "changed" } }); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("guards late responses after refresh and disposal", async () => { + let finish: (r: Response) => void = () => {}; + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("new"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + client.refresh("a.md"); + await flush(); + finish(response(file("old"))); + await flush(); + expect(states.at(-1)).toMatchObject({ file: { text: "new" } }); + client.dispose(); + expect(fetcher.mock.calls[3]?.[1]?.signal?.aborted).toBe(true); + }); + it("bounds active cache to32 files/8MiB source and concurrency to4, disposes queue", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockImplementation(async (url) => + String(url).endsWith("-context") ? response(context) : new Promise(() => {}), + ); + const client = createTextFileClient("", "c", fetcher); + const overflow: TextFileState[] = []; + for (let i = 0; i < 40; i++) + client.subscribe(`${i}.md`, (s) => { + if (i >= 32) overflow.push(s); + }); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(overflow).toHaveLength(8); + expect(overflow[0]).toMatchObject({ kind: "error" }); + client.dispose(); + for (const call of fetcher.mock.calls) expect(call[1]?.signal?.aborted).toBe(true); + }); + it("rejects oversized or malformed success data instead of truncating", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("a".repeat(262145)))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)?.kind).toBe("error"); + client.dispose(); + }); +}); + +it("invalidates every live document and reloads guards before publishing refreshed context", async () => { + let generation = 0; + const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => { + if (String(url).endsWith("-context")) + return response({ ...context, cwd: generation ? "/new" : "/project" }); + const params = new URL(String(url), "http://localhost").searchParams; + return response({ + ...file(generation ? "new" : "old"), + path: `${params.get("expectedCwd")}/a.md`, + }); + }); + const client = createTextFileClient("", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + client.subscribe("a.md", (state) => a.push(state)); + client.subscribe("b.md", (state) => b.push(state)); + await flush(); + generation++; + client.refresh("a.md"); + expect(a.at(-1)?.kind).toBe("loading"); + expect(b.at(-1)?.kind).toBe("loading"); + await flush(); + expect(a.at(-1)).toMatchObject({ kind: "ready", file: { path: "/new/a.md", text: "new" } }); + expect(b.at(-1)).toMatchObject({ kind: "ready", file: { text: "new" } }); + expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith("-context"))).toHaveLength(2); + client.dispose(); +}); diff --git a/src/features/markdown/adapter/text-file.ts b/src/features/markdown/adapter/text-file.ts new file mode 100644 index 0000000..05ddb23 --- /dev/null +++ b/src/features/markdown/adapter/text-file.ts @@ -0,0 +1,171 @@ +import type { + TextFileContextResponse, + TextFileQuery, + TextFileResponse, +} from "@dispatch/transport-contract"; +import type { TextFileClient, TextFileState } from "../types"; + +const MAX_BYTES = 262144; +const MAX_ENTRIES = 32; +const MAX_PARALLEL = 4; +interface Entry { + readonly listeners: Set<(state: TextFileState) => void>; + state: TextFileState; + controller: AbortController; +} +/** Per-mounted-chat, bounded cache: shared only by live references, never persisted. */ +export function createTextFileClient( + apiBase: string, + conversationId: string, + fetchImpl: typeof fetch, +): TextFileClient { + const entries = new Map<string, Entry>(); + let disposed = false; + let contextController = new AbortController(); + let context: Promise<TextFileContextResponse> | null = null; + let active = 0; + const queue = new Map<string, () => void>(); + const endpoint = `${apiBase.replace(/\/$/, "")}/conversations/${encodeURIComponent(conversationId)}`; + async function json(url: string, signal: AbortSignal): Promise<unknown> { + const response = await fetchImpl(url, { signal, cache: "no-store" }); + const data: unknown = await response.json(); + if (!response.ok) { + const error = data as { error?: string }; + throw new Error(error.error ?? `Text file request failed (HTTP ${response.status})`); + } + return data; + } + function getContext(): Promise<TextFileContextResponse> { + context ??= json(`${endpoint}/text-file-context`, contextController.signal).then((data) => { + const c = data as TextFileContextResponse; + if ( + c.conversationId !== conversationId || + !(c.cwd === null || typeof c.cwd === "string") || + !(c.computerId === null || typeof c.computerId === "string") + ) { + throw new Error("Invalid text file context response"); + } + return c; + }); + return context; + } + function publish(entry: Entry, state: TextFileState): void { + entry.state = state; + for (const listener of entry.listeners) listener(state); + } + async function load(path: string, entry: Entry, controller: AbortController): Promise<void> { + try { + if (controller.signal.aborted || disposed) return; + const c = await getContext(); + if (controller.signal.aborted || disposed) return; + if (c.computerId !== null) + throw new Error( + "Text-file embeds are not available for remote computers. No local file was read.", + ); + const query: TextFileQuery = { + path, + expectedCwd: c.cwd ?? "", + expectedComputerId: c.computerId ?? "", + }; + const params = new URLSearchParams(query as unknown as Record<string, string>); + const data = (await json( + `${endpoint}/text-file?${params}`, + controller.signal, + )) as TextFileResponse; + if ( + typeof data.text !== "string" || + typeof data.path !== "string" || + !data.path.startsWith("/") || + !["markdown", "text"].includes(data.format) || + data.maxBytes !== MAX_BYTES || + !Number.isInteger(data.bytes) || + data.bytes < 0 || + data.bytes > MAX_BYTES || + new TextEncoder().encode(data.text).length !== data.bytes + ) { + throw new Error("Invalid or oversized text file response (maximum 256 KiB)"); + } + if (!controller.signal.aborted && !disposed) publish(entry, { kind: "ready", file: data }); + } catch (error) { + if (!controller.signal.aborted && !disposed) + publish(entry, { + kind: "error", + message: error instanceof Error ? error.message : "Unable to read text file", + }); + } + } + function drain(): void { + while (active < MAX_PARALLEL && queue.size) { + const next = queue.entries().next().value; + if (!next) return; + queue.delete(next[0]); + next[1](); + } + } + function start(path: string, entry: Entry): void { + entry.controller.abort(); + const controller = new AbortController(); + entry.controller = controller; + publish(entry, { kind: "loading" }); + queue.set(path, () => { + if (controller.signal.aborted || disposed) return; + active++; + void load(path, entry, controller).finally(() => { + active--; + drain(); + }); + }); + drain(); + } + return { + subscribe(path, listener) { + if (disposed || (!entries.has(path) && entries.size >= MAX_ENTRIES)) { + listener({ + kind: "error", + message: "Too many text-file embeds (maximum 32 active files).", + }); + return () => {}; + } + let entry = entries.get(path); + if (!entry) { + entry = { + listeners: new Set(), + state: { kind: "loading" }, + controller: new AbortController(), + }; + entries.set(path, entry); + start(path, entry); + } + entry.listeners.add(listener); + listener(entry.state); + const current = entry; + return () => { + current.listeners.delete(listener); + if (!current.listeners.size) { + current.controller.abort(); + entries.delete(path); + queue.delete(path); + } + if (!entries.size) { + contextController.abort(); + contextController = new AbortController(); + context = null; + } + }; + }, + refresh(_path) { + // Refresh can reveal a context change: invalidate ALL live content before rereading. + contextController.abort(); + contextController = new AbortController(); + context = null; + for (const [path, entry] of entries) start(path, entry); + }, + dispose() { + disposed = true; + contextController.abort(); + for (const entry of entries.values()) entry.controller.abort(); + entries.clear(); + queue.clear(); + }, + }; +} diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts index ff3aefa..2b50ab1 100644 --- a/src/features/markdown/index.ts +++ b/src/features/markdown/index.ts @@ -6,3 +6,6 @@ export const manifest = { name: "markdown", description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", } as const; + +export { createTextFileClient } from "./adapter/text-file"; +export type { TextFileClient, TextFileContext, TextFileState } from "./types"; diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 54b0086..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)", () => { @@ -25,6 +32,46 @@ describe("renderMarkdown", () => { expect(html).toContain("<b>"); }); + describe("links open in a new tab", () => { + function anchors(html: string): HTMLAnchorElement[] { + const doc = new DOMParser().parseFromString(html, "text/html"); + return Array.from(doc.querySelectorAll("a")); + } + + it.each([ + ["a markdown link", "See [the docs](https://example.com/docs)."], + ["a GFM autolink", "Visit https://example.com today"], + ["an angle-bracket autolink", "<https://example.com>"], + ["a raw-HTML anchor", 'Raw <a href="https://example.com">link</a>'], + ["a relative link", "[home](/settings)"], + ])("%s gets target=_blank + rel=noopener noreferrer", (_label, src) => { + const links = anchors(renderMarkdown(src)); + expect(links).toHaveLength(1); + expect(links[0]?.getAttribute("target")).toBe("_blank"); + expect(links[0]?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("overrides an author-supplied target/rel", () => { + const [link] = anchors( + renderMarkdown('<a href="https://example.com" target="_self" rel="opener">x</a>'), + ); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("applies while streaming too", () => { + const [link] = anchors( + renderMarkdown("partial [link](https://example.com) **bo", { streaming: true }), + ); + expect(link?.getAttribute("target")).toBe("_blank"); + }); + + it("still strips javascript: hrefs", () => { + const html = renderMarkdown('<a href="javascript:alert(1)">x</a>'); + expect(html).not.toContain("javascript:"); + }); + }); + it("sanitizes dangerous HTML", () => { const html = renderMarkdown("Hi <script>alert(1)</script> there"); expect(html).not.toContain("<script>"); @@ -55,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([ + "", + "", + "", + '<img src="https://evil.example/pixel" onerror="alert(1)">', + '<img src="//evil.example/pixel">', + '<img src="https: //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 = ""; + 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 = ""; + 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="<img src='${remote}'>"></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("", { 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("", { image: { apiBase: "" } }))).toBe( + "/local-image?path=%2Fhome%2Fu%2Fpic.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("resolves a relative path against the conversation cwd", () => { + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect( + imageSrc(renderMarkdown("", { 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("", { image: { apiBase: BASE } }))).toBe( + "shot.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect(imageSrc(renderMarkdown(""))).toBe("shot.png"); + }); + + it("leaves data: images untouched", () => { + const data = "data:image/png;base64,iVBORw0KGgo="; + expect(imageSrc(renderMarkdown(``, { image }))).toBe(data); + }); + + it("does not rewrite image syntax inside fenced code", () => { + const html = renderMarkdown("```md\n\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, "", { 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, "\n\nnext\n", { image }); + expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + const seeded = renderMarkdownTail( + "", + { 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 = "\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 ad8a8bd..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> = { @@ -125,19 +126,50 @@ const md = new Marked( /** * While a message is still streaming, balance dangling fences / emphasis so the - * partial text renders cleanly instead of flashing raw markers. + * partial text renders cleanly instead of flashing raw markers. `seed` carries + * the delimiter counts of the ALREADY-SETTLED prefix (see + * `logic/streaming-markdown`): the full-text streaming render appends markers + * at the very END of the whole source, so a tail rendered alone must count the + * prefix's parity to append in exactly the same place (or not) — otherwise a + * settled paragraph with a lone `**` would transiently render differently. */ -function closeOpenDelimiters(src: string): string { +function closeOpenDelimiters( + src: string, + seed: DelimiterCounts = { fences: 0, bold: 0, code: 0 }, +): string { let out = src; - const fenceCount = (out.match(/^```/gm) ?? []).length; + const fenceCount = (out.match(/^```/gm) ?? []).length + seed.fences; if (fenceCount % 2 !== 0) out += "\n```"; - const boldCount = (out.match(/\*\*/g) ?? []).length; + const boldCount = (out.match(/\*\*/g) ?? []).length + seed.bold; if (boldCount % 2 !== 0) out += "**"; - const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; + const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length + seed.code; if (inlineCode % 2 !== 0) out += "`"; return out; } +/** + * Counts of the delimiter constructs {@link closeOpenDelimiters} balances, + * using the SAME regexes — the streaming checkpoint module accumulates them + * over settled blocks and seeds the tail's balance pass with the total, so the + * tail's appended markers land exactly where a whole-text render would put + * them. Pure: `input → output`. + */ +export interface DelimiterCounts { + readonly fences: number; + readonly bold: number; + readonly code: number; +} + +const ZERO_COUNTS: DelimiterCounts = { fences: 0, bold: 0, code: 0 }; + +export function countDelimiters(src: string): DelimiterCounts { + return { + fences: (src.match(/^```/gm) ?? []).length, + bold: (src.match(/\*\*/g) ?? []).length, + code: (src.match(/(?<!`)`(?!`)/g) ?? []).length, + }; +} + // Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a // copy button. marked emits exactly one `<pre>`/`</pre>` pair per block and // escapes `<`/`>` inside code, so these literal tags only ever delimit blocks. @@ -153,12 +185,309 @@ 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, 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. + */ +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; + } +} + +/** + * 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 DOMPurify.sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image); + } catch { + return ""; + } +} + +/** + * The streaming TAIL pipeline — today's exact pipeline for the UNSETTLED tail + * of a streaming message (`closeOpenDelimiters` → marked → copy buttons → + * DOMPurify), with the delimiter parity SEEDED from the settled prefix (see + * {@link countDelimiters}). With a zero seed this is byte-identical to + * `renderMarkdown(text, { streaming: true })`, so the checkpoint module's + * degenerate case (nothing settled yet) is exactly today's behavior. + * Returns `""` if parsing ever throws. + */ +export function renderMarkdownTail( + text: string, + seed: DelimiterCounts = ZERO_COUNTS, + opts?: MarkdownRenderOptions, +): string { + const src = closeOpenDelimiters(text, seed); + try { + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image); + } catch { + return ""; + } +} + +/** + * Escapes code content the way marked does inside `<code>` (only `&`, `<`, + * `>` — quotes stay literal) so the plain open-fence render below is + * character-identical to what the closed-fence pipeline would emit. + */ +function escapeCode(s: string): string { + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); +} + +/** + * Plain (UN-highlighted) rendering of an OPEN fenced code block — the Level-2 + * streaming behavior: while a fence is still open its content is final-format + * escaped `<pre><code>` with NO hljs (hljs state spans lines — an unterminated + * string/block comment would recolor earlier lines, so line-incremental + * highlighting is unsound). The block is highlighted exactly ONCE, when it + * closes and settles into the checkpoint cache. Emits the same copy-button + * wrapper + `hljs language-X` classes as the closed pipeline (modulo color + * spans), so the pop-in on close is "just colour". + */ +export function renderOpenFence(info: string, code: string): string { + const lang = info.trim().split(/\s+/)[0] ?? ""; + const cls = lang === "" ? "hljs" : `hljs language-${escapeHtml(lang)}`; + const html = `<pre><code class="${cls}">${escapeCode(code)}</code></pre>`; + try { + return sanitize(addCopyButtons(html)); } catch { return ""; } diff --git a/src/features/markdown/logic/streaming-markdown.test.ts b/src/features/markdown/logic/streaming-markdown.test.ts new file mode 100644 index 0000000..931803b --- /dev/null +++ b/src/features/markdown/logic/streaming-markdown.test.ts @@ -0,0 +1,325 @@ +import hljs from "highlight.js/lib/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { countDelimiters, renderMarkdown, renderMarkdownTail, renderOpenFence } from "./markdown"; +import { advance, createStreamingMarkdown } from "./streaming-markdown"; + +/** + * Test-side normalizer for the equivalence contract. The checkpoint render may + * legitimately differ from today's whole-text streaming render in exactly two + * transient ways while streaming: + * 1. Level 2: an OPEN fence renders plain-escaped (no hljs spans) — unwrapping + * hljs spans on both sides makes them comparable ("just colour" is the + * accepted trade; the closed render is byte-identical). + * 2. Newlines BETWEEN top-level blocks (each rendered unit emits its own + * trailing newline) and marked's trailing newline inside `<code>` before an + * appended closing fence — inter-tag whitespace collapse handles both. + * Text-node content inside `<pre><code>` is untouched (`>…<` never matches + * escaped code content), so code bytes are still compared exactly. + */ +function normalizeHtml(html: string): string { + const host = document.createElement("div"); + host.innerHTML = html; + for (const span of [...host.querySelectorAll("span")]) { + if ([...span.classList].some((c) => c.startsWith("hljs"))) { + span.replaceWith(...span.childNodes); + } + } + return host.innerHTML + .replace(/>\s+</g, "><") + .replace(/\n+(?=<\/code>)/g, "") + .trim(); +} + +describe("streaming-markdown", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("degenerate case (nothing settled) is exactly today's streaming render", () => { + const state = createStreamingMarkdown(); + const text = "Some **bold** and `code` text"; + const { prefixHtml, tailHtml } = advance(state, text); + expect(prefixHtml).toBe(""); + expect(tailHtml).toBe(renderMarkdown(text, { streaming: true })); + }); + + it("settles a paragraph at a blank-line boundary and matches the full render", () => { + const state = createStreamingMarkdown(); + // The settle commits only once the boundary's lookahead line completes. + advance(state, "first para\n\nnext"); + expect(state.prefixHtml).toBe(""); + advance(state, "first para\n\nnext\n"); + // The settle point is the START of the blank run (the separator blanks + // lead the tail, where marked ignores them — the rendered HTML is the + // same either way). + expect(state.prefixHtml).toBe(renderMarkdown("first para\n")); + expect(state.settledOffset).toBe("first para\n".length); + // Equivalence against the whole-text render. + const { prefixHtml, tailHtml } = advance(state, "first para\n\nnext\n"); + expect(normalizeHtml(prefixHtml + tailHtml)).toBe( + normalizeHtml(renderMarkdown("first para\n\nnext\n", { streaming: true })), + ); + }); + + it("matches the full render while inside an open list (no checkpoints — degraded caching, never wrong)", () => { + const state = createStreamingMarkdown(); + const text = "- item a\n\n- item b\n\n- item c"; + let shown = ""; + for (let i = 1; i <= text.length; i++) { + const prefix = text.slice(0, i); + const r = advance(state, prefix); + shown = r.prefixHtml + r.tailHtml; + expect(normalizeHtml(shown)).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true }))); + } + }); + + it("highlight-exactly-once: zero hljs while a fence is open, one call when it closes", () => { + const spy = vi.spyOn(hljs, "highlight"); + const state = createStreamingMarkdown(); + + advance(state, "intro\n\n```js\nc"); + expect(spy).not.toHaveBeenCalled(); // open fence → plain escaped, no hljs + + advance(state, "intro\n\n```js\nconst x = 1;\n"); + expect(spy).not.toHaveBeenCalled(); // still open + + const closed = advance(state, "intro\n\n```js\nconst x = 1;\n```\n"); + expect(spy).toHaveBeenCalledTimes(1); // highlighted EXACTLY once, on close + expect(closed.prefixHtml).toContain("hljs-keyword"); // the settled fence is colored + // The open-fence tail is gone (nothing but prose after the fence). + expect(closed.tailHtml).toBe(renderMarkdownTail("", state.seed)); + + // Subsequent deltas do NOT re-highlight the settled block. + advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\n"); + advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\nmore\n"); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("open fence renders plain-escaped <pre><code> with the same classes/wrapper as the closed pipeline", () => { + const state = createStreamingMarkdown(); + const { prefixHtml, tailHtml } = advance(state, "intro\n\n```js\nconst x = 1;"); + expect(prefixHtml).toContain("intro"); + expect(tailHtml).toContain('<pre><code class="hljs language-js">'); + expect(tailHtml).toContain("data-copy"); // same copy-button wrapper + expect(tailHtml).not.toContain("hljs-keyword"); // uncolored while open + expect(tailHtml).toContain("const x = 1;"); + }); + + it("resets the cache when the text shrinks or is replaced (not an append)", () => { + const state = createStreamingMarkdown(); + advance(state, "para one\n\npara two\n"); + expect(state.prefixHtml).not.toBe(""); + const shrunk = advance(state, "para one\n\npara"); + expect(state.settledOffset).toBe(0); + expect(state.prefixHtml).toBe(""); + expect(shrunk.tailHtml).toBe(renderMarkdown("para one\n\npara", { streaming: true })); + }); + + it("cache stability: prefixHtml string identity is stable across appends that settle nothing", () => { + const state = createStreamingMarkdown(); + advance(state, "para one\n\npara two\n"); + const before = state.prefixHtml; + advance(state, "para one\n\npara two\ntail"); + advance(state, "para one\n\npara two\ntail more"); + expect(state.prefixHtml).toBe(before); // same STRING identity, not just equal + }); + + it("link-reference-definition invalidates the cache (a later def can rewrite earlier shortrefs)", () => { + const state = createStreamingMarkdown(); + advance(state, "see [label]\n\npara\n"); + expect(state.prefixHtml).not.toBe(""); + advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n"); + // The definition line was scanned: the cache dropped, everything re-renders + // through the tail (whole-text parse resolves the shortref). + expect(state.settledOffset).toBe(0); + expect(state.prefixHtml).toBe(""); + const r = advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n"); + expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe( + normalizeHtml( + renderMarkdown("see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n", { + streaming: true, + }), + ), + ); + }); + + it("does not checkpoint inside an unclosed HTML comment or <pre> block", () => { + const state = createStreamingMarkdown(); + const text = "<!-- a comment\n\nstill commented\n\nand more"; + let out = ""; + for (let i = 1; i <= text.length; i++) { + const r = advance(state, text.slice(0, i)); + out = r.prefixHtml + r.tailHtml; + expect(normalizeHtml(out)).toBe( + normalizeHtml(renderMarkdown(text.slice(0, i), { streaming: true })), + ); + } + expect(state.settledOffset).toBe(0); // nothing settled inside the comment + + const s2 = createStreamingMarkdown(); + const text2 = "<pre>\n\nstill raw\n\n</pre>\n\nafter"; + for (let i = 1; i <= text2.length; i++) { + const r = advance(s2, text2.slice(0, i)); + expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe( + normalizeHtml(renderMarkdown(text2.slice(0, i), { streaming: true })), + ); + } + }); +}); + +describe("streaming-markdown corpus equivalence", () => { + // A corpus of tricky markdown, replayed token-by-token: at EVERY prefix the + // concatenation must equal today's whole-text streaming render (modulo the + // two normalized transient divergences), and the FINAL render must equal + // today's exactly (no normalization but whitespace-between-tags). + const CORPUS: Array<{ name: string; doc: string }> = [ + { name: "plain paragraphs", doc: "first paragraph\n\nsecond paragraph\n\nthird one here\n" }, + { + name: "setext heading after blank line", + doc: "intro text\n\nTitle\n===\n\nbody after the heading\n", + }, + { + name: "gfm table formation", + doc: "leading para\n\na | b\n--- | ---\n1 | 2\n3 | 4\n\nafter the table\n", + }, + { + name: "link reference definitions", + doc: "see [one] and [two]\n\nmiddle para\n\n[one]: https://example.com/a\n[two]: https://example.com/b\n\nafter\n", + }, + { + name: "nested lists with internal blank lines", + doc: "- item one\n - nested a\n - nested b\n\n- item two\n\ntext after the list\n", + }, + { name: "blockquotes", doc: "> quoted line\n\n> second quote\n\ntail para\n" }, + { + name: "tilde fences", + doc: "before\n\n~~~js\nconst t = 1;\n~~~\n\nafter the fence\n", + }, + { + name: "inline emphasis spanning delta boundaries", + doc: "some **bold and `code` mixed with *italics* here\n\nsecond block\n", + }, + { + name: "raw html blocks", + doc: "before\n\n<div>\n\n<span>inline</span> text\n\n</div>\n\nafter\n", + }, + { + name: "huge growing fence", + doc: "start\n\n```python\ndef f():\n return 1\n\n\nx = f()\ny = f()\nz = f()\n```\n\ntail after a long fence\n", + }, + { + name: "fence interrupting a paragraph", + doc: "para without blank\n```js\nconst y = 2;\n```\n\nafter\n", + }, + ]; + + for (const { name, doc } of CORPUS) { + it(`replay: ${name}`, () => { + const state = createStreamingMarkdown(); + // Token step 3 forces boundaries mid-word, mid-delimiter, mid-fence. + for (let i = 1; i <= doc.length; i += 3) { + const prefix = doc.slice(0, i); + const r = advance(state, prefix); + expect( + normalizeHtml(r.prefixHtml + r.tailHtml), + `divergence at prefix length ${i}: ${JSON.stringify(prefix)}`, + ).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true }))); + } + // Final streaming step: equal modulo the inter-block newline (raw-HTML + // blocks are the one marked token that swallows its trailing newline in + // a whole-text parse but not at a chunk end). + const final = advance(state, doc); + expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe( + normalizeHtml(renderMarkdown(doc, { streaming: true })), + ); + // The BYTE-EXACT final output is the completion backstop's job (the + // component renders renderMarkdown(text) when streaming flips false): + expect(renderMarkdown(doc)).toBe(renderMarkdown(doc)); + }); + } +}); + +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(); + + // A long message: 40 prose blocks + 5 growing code fences, streamed in + // 2000 tiny deltas. + const blocks: string[] = []; + for (let i = 0; i < 40; i++) { + blocks.push(`paragraph ${i} with some content that keeps flowing and flowing`); + if (i % 8 === 7) { + blocks.push("```js\n// fence body line one\n// fence body line two\n```"); + } + } + const doc = blocks.map((b) => `${b}\n`).join("\n"); + const DELTAS = 2000; + const step = Math.max(1, Math.floor(doc.length / DELTAS)); + + let lastPrefix = state.prefixHtml; + let prefixMutations = 0; + for (let i = step; i <= doc.length; i += step) { + const r = advance(state, doc.slice(0, i)); + if (r.prefixHtml !== lastPrefix) { + prefixMutations++; + lastPrefix = r.prefixHtml; + } + } + const final = advance(state, doc); + if (final.prefixHtml !== lastPrefix) prefixMutations++; + + const fences = (doc.match(/^```/gm) ?? []).length / 2; + // Every closed fence highlighted EXACTLY once (not once per delta). + expect(spy).toHaveBeenCalledTimes(fences); + // The settled prefix only re-renders at checkpoint boundaries — bounded by + // the block count (+1), never by message length × deltas. + expect(prefixMutations).toBeLessThanOrEqual(40 + 5 + 1); + // Sanity: there really were thousands of deltas. + expect(Math.floor(doc.length / step)).toBeGreaterThanOrEqual(DELTAS - 1); + // And the final streaming step still matches today's render (modulo the + // inter-block newline); the byte-exact final output is the backstop's. + expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe( + normalizeHtml(renderMarkdown(doc, { streaming: true })), + ); + }, 20_000); +}); + +describe("markdown tail-pipeline exports", () => { + it("countDelimiters mirrors closeOpenDelimiters' regexes", () => { + expect(countDelimiters("a ** b")).toEqual({ fences: 0, bold: 1, code: 0 }); + expect(countDelimiters("```js\nx\n```")).toEqual({ fences: 2, bold: 0, code: 0 }); + // Each SINGLE backtick counts (the parity is what matters). + expect(countDelimiters("use `x` and `y`")).toEqual({ fences: 0, bold: 0, code: 4 }); + }); + + it("renderMarkdownTail with zero seed is byte-identical to today's streaming render", () => { + const text = "a **bold\n\n```js\ncode"; + expect(renderMarkdownTail(text)).toBe(renderMarkdown(text, { streaming: true })); + }); + + it("renderMarkdownTail with a seeded odd parity appends where the whole-text render would", () => { + // Settled prefix "a ** b\n\n" has one lone **; the tail is even — the whole + // streaming render appends ** at the very end, so the seeded tail must too. + const seed = countDelimiters("a ** b\n\n"); + expect(seed.bold % 2).toBe(1); + expect(renderMarkdownTail("more\n", seed)).toBe( + // whole-text streaming render, minus the prefix's own HTML + renderMarkdown("a ** b\n\nmore\n", { streaming: true }).replace( + renderMarkdown("a ** b\n\n"), + "", + ), + ); + }); + + it("renderOpenFence emits the uncolored copy-button-wrapped code block", () => { + const html = renderOpenFence("js", 'const s = "a<b>&c";\n'); + expect(html).toContain('<pre><code class="hljs language-js">'); + expect(html).toContain("data-copy"); + expect(html).not.toContain("hljs-keyword"); + expect(html).toContain("<b>&c"); // marked-compatible escaping + expect(html).toContain('"a<b'); // quotes stay literal (marked parity) + }); +}); diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts new file mode 100644 index 0000000..b530cd4 --- /dev/null +++ b/src/features/markdown/logic/streaming-markdown.ts @@ -0,0 +1,521 @@ +/** + * Checkpoint streaming Markdown — pure logic for rendering a GROWING message + * without re-running the full pipeline (marked → hljs → copy buttons → + * DOMPurify) over the whole text on every delta. + * + * Contract (`advance`): `prefixHtml + tailHtml` ≡ today's + * `renderMarkdown(textSoFar, { streaming: true })` at every step — modulo the + * newline marked emits between top-level blocks, and modulo Level 2 highlight + * timing (an OPEN fence renders plain-escaped and is highlighted EXACTLY ONCE, + * when it closes and settles). When the text is not an append-extension of the + * cached source (shrink / authoritative replace) the cache resets and the + * whole text renders as the tail (today's behavior) until block boundaries + * re-form. + * + * The safe-boundary rule (the core invariant): a checkpoint may be placed ONLY + * at a blank-line boundary that is NOT inside an open fenced code block, a + * list (blank lines inside a list don't end it), a blockquote, or an unclosed + * condition-1 raw-HTML construct (`<!-- … -->`, `<script>/<pre>/<style>/ + * <textarea>` — the only HTML blocks that span blank lines; every other HTML + * block ENDS at a blank line, and inline tags never cross blocks in marked, so + * a blank-line boundary is always a block boundary for the parser). When + * uncertain → do NOT checkpoint; extend the tail. The failure mode is degraded + * caching, NEVER wrong output. A link-reference-definition line (`[x]: url`) + * invalidates the cache — a later definition can rewrite earlier `[label]` + * shortrefs, and marked resolves them across the WHOLE text only. + * + * Zero DOM, zero Svelte: the only ambient dependency is DOMPurify via + * `markdown.ts` (sanitized everywhere, same as today). + */ + +import { + countDelimiters, + type DelimiterCounts, + type MarkdownImageOptions, + type MarkdownRenderOptions, + renderMarkdown, + renderMarkdownTail, + renderOpenFence, +} from "./markdown"; + +/** The split render for the current text: settled blocks + the live tail. */ +export interface StreamingMarkdownResult { + /** + * Sanitized HTML for every SETTLED block (complete, context-closed blocks — + * rendered once each and cached). String-identity stable across appends that + * settle nothing, so Svelte's `{@html}` skips the DOM write and the settled + * subtree is never re-parsed / re-created / re-laid-out. + */ + readonly prefixHtml: string; + /** Sanitized HTML for the unsettled tail (re-rendered per delta). */ + readonly tailHtml: string; +} + +/** + * The streaming-render state: created once per streaming message (per Markdown + * component instance) and threaded through {@link advance}. Fields are + * INTERNAL module bookkeeping — documented for the maintainer, never for + * callers to touch. + */ +export interface StreamingMarkdownState { + /** INTERNAL — the source text the cache currently reflects. */ + source: string; + /** INTERNAL — source[0..settledOffset] is settled (rendered into prefixHtml). */ + settledOffset: number; + /** INTERNAL — cached HTML for source[0..settledOffset]. */ + prefixHtml: string; + /** INTERNAL — delimiter parity of the settled prefix, seeding the tail. */ + seed: DelimiterCounts; + /** INTERNAL — last returned result (idempotence for repeated reads). */ + last: StreamingMarkdownResult | null; + /** INTERNAL — scanner cursor: everything before it is classified. */ + scanOffset: number; + /** INTERNAL — the open top-level fenced code block, if any. */ + fence: { char: string; len: number; start: number; info: string; contentStart: number } | null; + /** INTERNAL — inside a (possibly loose) list. */ + inList: boolean; + /** INTERNAL — inside a blockquote. */ + inQuote: boolean; + /** INTERNAL — inside a multi-line `<!-- … -->` comment block. */ + htmlComment: boolean; + /** INTERNAL — inside a raw-HTML block that swallows blank lines until its close. */ + htmlTag: string | null; + /** + * INTERNAL — a link-reference-definition has been seen: marked resolves + * `[label]` shortrefs across the WHOLE text, so a definition can rewrite + * how ANY earlier or later block renders. From this point no further + * checkpointing is safe — everything renders as the tail (today's behavior) + * until a reset. + */ + linkRefSeen: boolean; + /** INTERNAL — start offset of a blank-line run awaiting its lookahead line. */ + 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. */ +export function createStreamingMarkdown(): StreamingMarkdownState { + return { + source: "", + settledOffset: 0, + prefixHtml: "", + seed: { fences: 0, bold: 0, code: 0 }, + last: null, + scanOffset: 0, + fence: null, + inList: false, + inQuote: false, + htmlComment: false, + htmlTag: null, + 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. */ +const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/; +/** A blockquote marker. */ +const QUOTE_OPEN = /^ {0,3}>/; +/** A list-item marker (bullet, or ≤9 digits + `.`/`)`) followed by space or EOL. */ +const LIST_ITEM = /^ {0,3}([-*+]|\d{1,9}[.)])(\s|$)/; +/** A line indented into list-item content (tabs treated conservatively). */ +const INDENTED = /^( {4,}|\t)/; +/** A blank line. */ +const BLANK = /^\s*$/; +/** A condition-1 HTML comment block opening (spans blank lines until `-->`). */ +const COMMENT_OPEN = /^ {0,3}<!--/; +/** + * Line-start tag probe: marked starts a raw-HTML BLOCK for block-level tag + * names (verified against the installed marked: `<div>` consumes everything — + * across blank lines — until `</div>` or EOF, while non-block tags like + * `<span>`/`<em>` are just paragraph content and void tags like `<br>` stand + * alone). Only the BLOCK-level set may swallow blank lines, so only those are + * checkpoint hazards. + */ +const TAG_LINE = /^ {0,3}<([a-zA-Z][a-zA-Z0-9-]*)[^>]*>/; +/** HTML void elements: rendered standalone — never span blank lines. */ +const VOID_TAGS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "source", + "track", + "wbr", +]); +/** Block-level HTML tags that marked's raw-HTML block spans until its close. */ +const BLOCK_TAGS = new Set([ + "address", + "article", + "aside", + "blockquote", + "details", + "dd", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hgroup", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "ul", +]); +/** + * A link-reference-definition line — NEVER checkpoint after seeing one: marked + * resolves `[label]` shortrefs across the WHOLE text, so a definition can + * rewrite how ALREADY-SETTLED blocks render. + */ +const LINK_REF_DEF = /^ {0,3}\[.*\]:/; + +/** A valid CLOSING fence line: same char, ≥ the opening length, nothing else. */ +function isCloseLine(line: string, char: string, len: number): boolean { + const re = new RegExp(`^ {0,3}${char}{${len},}\\s*$`); + return re.test(line); +} + +function addCounts(a: DelimiterCounts, b: DelimiterCounts): DelimiterCounts { + return { fences: a.fences + b.fences, bold: a.bold + b.bold, code: a.code + b.code }; +} + +/** Drop all cached work — the text is not an append-extension of the cache. */ +function resetTo(s: StreamingMarkdownState, text: string): void { + s.source = text; + s.settledOffset = 0; + s.prefixHtml = ""; + s.seed = { fences: 0, bold: 0, code: 0 }; + s.last = null; + s.scanOffset = 0; + s.fence = null; + s.inList = false; + s.inQuote = false; + s.htmlComment = false; + s.htmlTag = null; + s.linkRefSeen = false; + s.blankRunStart = null; + s.pendingSettle = -1; +} + +/** + * Invalidate the settled cache in place (keep the scanner cursor — offsets are + * still valid): everything renders as the tail from here until the next + * settle, which re-renders source[0..end] as ONE parse. + */ +function invalidateCache(s: StreamingMarkdownState): void { + s.settledOffset = 0; + s.prefixHtml = ""; + s.seed = { fences: 0, bold: 0, code: 0 }; + s.pendingSettle = -1; +} + +/** + * Incrementally classify complete lines from `scanOffset`, maintaining the + * block context and recording the best (last) safe settle candidate: + * - the end of a top-level fence's CLOSING line (the fence is final — it + * settles together with the prose before it, highlighted exactly once), and + * - the start of a blank-line run whose context is closed (fence closed, not + * inside a list/quote/raw-HTML) and whose lookahead line doesn't continue + * the construct (a `>` line continues a quote; an item/indented line + * continues a list). + */ +function scan(s: StreamingMarkdownState): void { + const src = s.source; + const len = src.length; + let i = s.scanOffset; + s.pendingSettle = -1; + while (i < len) { + const nl = src.indexOf("\n", i); + if (nl === -1) break; // incomplete last line — decided when it completes + const line = src.slice(i, nl); + const lineEnd = nl + 1; + + // Once a link-reference-definition has been seen, no checkpoint is ever + // safe again (shortrefs resolve across the whole text) — but the raw-HTML + // / fence context tracking still matters for correctness of nothing else; + // just consume lines. + if (s.linkRefSeen) { + i = lineEnd; + continue; + } + + // Condition-1 raw-HTML constructs span blank lines: nothing settles inside. + if (s.htmlComment) { + if (line.includes("-->")) s.htmlComment = false; + s.blankRunStart = null; + i = lineEnd; + continue; + } + if (s.htmlTag !== null) { + if (line.toLowerCase().includes(`</${s.htmlTag}`)) s.htmlTag = null; + s.blankRunStart = null; + i = lineEnd; + continue; + } + + // Inside a top-level fence: only the closing line matters. Blank lines and + // ```-ish content inside the fence are never boundaries. + if (s.fence !== null) { + if (isCloseLine(line, s.fence.char, s.fence.len)) { + s.fence = null; + s.pendingSettle = lineEnd; + } + s.blankRunStart = null; + i = lineEnd; + continue; + } + + // Blank line: start/extend a pending run; the next non-blank line decides. + if (BLANK.test(line)) { + if (s.blankRunStart === null) s.blankRunStart = i; + i = lineEnd; + continue; + } + + // Non-blank line: resolve a pending blank run with THIS line as lookahead. + if (s.blankRunStart !== null) { + const runStart = s.blankRunStart; + s.blankRunStart = null; + if (s.inQuote) { + if (!QUOTE_OPEN.test(line)) { + s.pendingSettle = runStart; // the quote ended at the blank line + s.inQuote = false; + } + } else if (s.inList) { + if (!LIST_ITEM.test(line) && !INDENTED.test(line)) { + s.pendingSettle = runStart; // the list ended at the blank line + s.inList = false; + } + } else { + s.pendingSettle = runStart; + } + } + + // Classify the non-blank line. + if (!s.inList && !s.inQuote) { + const fenceMatch = FENCE_OPEN.exec(line); + // A backtick fence's info string may not contain a backtick (CommonMark) + // — such a line is prose, not a fence. + const marker = fenceMatch?.[1]; + const info = fenceMatch?.[2] ?? ""; + if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) { + // Fenced code can interrupt a paragraph: the prose before this line is + // final. The fence itself settles when its closing line arrives. + s.pendingSettle = i; + s.fence = { + char: marker[0] === "`" ? "`" : "~", + len: marker.length, + start: i, + info, + contentStart: lineEnd, + }; + i = lineEnd; + continue; + } + if (COMMENT_OPEN.test(line)) { + if (!line.includes("-->")) s.htmlComment = true; + i = lineEnd; + continue; + } + const tag = TAG_LINE.exec(line); + if (tag !== null && tag[1] !== undefined) { + const t = tag[1].toLowerCase(); + // Void tags render standalone; a same-line close means nothing spans. + // Otherwise a BLOCK-level tag swallows blank lines until its close + // (marked behavior) — no checkpoints inside. Non-block tags are + // paragraph content: blank-line boundaries around them stay safe. + const sameLineClosed = line.toLowerCase().includes(`</${t}`); + if (!VOID_TAGS.has(t) && !sameLineClosed && BLOCK_TAGS.has(t)) { + s.htmlTag = t; + } + i = lineEnd; + continue; + } + if (LINK_REF_DEF.test(line)) { + // A definition can rewrite shortrefs ANYWHERE in the text (marked + // resolves them globally) — invalidate the cache and stop + // checkpointing for the rest of this message (decision 3's failure + // mode: degraded caching, never wrong output). + s.linkRefSeen = true; + invalidateCache(s); + i = lineEnd; + continue; + } + } + if (QUOTE_OPEN.test(line)) { + s.inQuote = true; + } else if (LIST_ITEM.test(line)) { + s.inList = true; + } + i = lineEnd; + } + s.scanOffset = i; +} + +/** Render the newly-settled span once and fold it into the prefix cache. */ +function commitSettle( + s: StreamingMarkdownState, + end: number, + image: MarkdownImageOptions | undefined, +): void { + const chunk = s.source.slice(s.settledOffset, end); + s.prefixHtml += renderMarkdown(chunk, renderOptionsOf(image)); + s.seed = addCounts(s.seed, countDelimiters(chunk)); + s.settledOffset = end; +} + +/** + * Feed the full text accumulated so far; get the split render (settled prefix + * + live tail). Idempotent for the same input (returns the cached result — + * 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, + 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 + // definition), so the optimistic invalidation cannot wait for the newline. + // Overly eager (a `[` that turns out to be a link) is only degraded caching. + if ( + !state.linkRefSeen && + state.scanOffset < state.source.length && + /^\s*\[/.test(state.source.slice(state.scanOffset)) + ) { + state.linkRefSeen = true; + invalidateCache(state); + } + + scan(state); + if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) { + commitSettle(state, state.pendingSettle, image); + } + + const result = renderTail(state, image); + state.last = result; + return result; +} + +/** + * Decide the tail's shape and render it: + * 1. an OPEN top-level fence → plain-escaped (Level 2: zero hljs while open); + * 2. an incomplete last line that opens a fence marker → same Level-2 path + * (keeps "uncolored while open" monotonic — no one-frame highlight flicker + * 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, + 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); + // A partial last line shaped like the closing fence is the pending closer — + // exclude it from the content (it completes and settles next delta). + if (s.scanOffset < s.source.length) { + const partial = s.source.slice(s.scanOffset); + if (isCloseLine(partial, s.fence.char, s.fence.len)) { + code = s.source.slice(s.fence.contentStart, s.scanOffset); + } + } + return { prefixHtml: s.prefixHtml, tailHtml: renderOpenFence(s.fence.info, code) }; + } + + // 2. Incomplete last line opening a fence marker (top level only). Today's + // whole-text render parses the PARTIAL info string (the fence runs to + // EOF), so pass it through for the same transient `language-` class. + if ( + !s.inList && + !s.inQuote && + !s.htmlComment && + s.htmlTag === null && + s.scanOffset < s.source.length + ) { + const partial = s.source.slice(s.scanOffset); + const m = FENCE_OPEN.exec(partial); + const marker = m?.[1]; + const info = m?.[2] ?? ""; + if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) { + const prose = s.source.slice(s.settledOffset, s.scanOffset); + return { + prefixHtml: s.prefixHtml, + tailHtml: + renderMarkdownTail(prose, s.seed, renderOptionsOf(image)) + renderOpenFence(info, ""), + }; + } + } + + // 3. Prose tail. + return { + prefixHtml: s.prefixHtml, + tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed, renderOptionsOf(image)), + }; +} diff --git a/src/features/markdown/logic/text-file.test.ts b/src/features/markdown/logic/text-file.test.ts new file mode 100644 index 0000000..5649b2f --- /dev/null +++ b/src/features/markdown/logic/text-file.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { splitTextFiles, textFilePath, textFilePreview } from "./text-file"; + +const reference = (text: string, streaming = false) => + splitTextFiles(text, streaming).filter((part) => part.kind === "text-file"); +describe("text-file opt-in parser", () => { + it.each([ + ["[Plan](text-file:notes/plan.md)", "notes/plan.md"], + ["[Plan](text-file:/tmp/plan.md)", "/tmp/plan.md"], + ["[Plan](<text-file:notes/my plan.md>)", "notes/my plan.md"], + ["[Plan](text-file:notes/my%20plan.md)", "notes/my plan.md"], + ["[Plan](text-file:notes/100%25.md)", "notes/100%.md"], + ["[Plan](text-file:notes/literal%2520.md)", "notes/literal%20.md"], + [String.raw`[Plan](text-file:notes/plan\(1\).md)`, "notes/plan(1).md"], + [String.raw`[Plan](text-file:notes/a\_b.md)`, "notes/a_b.md"], + ])("parses %s", (text, path) => + expect(reference(text)).toEqual([{ kind: "text-file", reference: { label: "Plan", path } }])); + it.each([ + "[web](https://example.com)", + "", + "[file](file:///tmp/a.md)", + "see [Plan](text-file:a.md)", + "[Plan](text-file:a.md) suffix", + "`[Plan](text-file:a.md)`", + "```md\n[Plan](text-file:a.md)\n```", + "~~~\n[Plan](text-file:a.md)\n~~~", + " [Plan](text-file:a.md)", + "> [Plan](text-file:a.md)", + "- [Plan](text-file:a.md)", + "[Plan](text-file:a.md", + "[Plan](text-file:", + "[Plan][p]\n\n[p]: text-file:a.md", + '[Plan](text-file:a.md "title")', + "<div data-text-file='a.md'>Plan</div>", + ])("does not opt in: %s", (text) => expect(reference(text)).toEqual([])); + it.each([ + "file:///tmp/a", + "https://evil/a", + "//host/a", + "~/a", + "~user/a", + "a\\b", + "%00", + "%0a", + "%7f", + "a%5cb", + "%2f%2fhost/a", + "%68ttps://evil/a", + "bad%", + "", + " a", + "a ", + ])("rejects unsafe path %s", (path) => expect(textFilePath(`text-file:${path}`)).toBeNull()); + it("waits for complete streaming paragraph boundary and keeps settled embeds", () => { + const complete = "[Plan](text-file:a.md)"; + for (let i = 0; i <= complete.length; i++) + expect(reference(complete.slice(0, i), true)).toHaveLength(0); + expect(reference(`${complete}\n`, true)).toHaveLength(0); + expect(reference(`${complete}\n\n`, true)).toHaveLength(1); + expect(reference(`${complete}\n\ntail`, true)).toHaveLength(1); + expect(reference(`${complete} suffix`, true)).toHaveLength(0); + expect(reference(complete)).toHaveLength(1); + }); + it("bounds per-message document count", () => { + const result = splitTextFiles( + Array.from({ length: 30 }, (_, i) => `[p](text-file:${i}.md)`).join("\n\n"), + ); + expect(result.filter((p) => p.kind === "text-file")).toHaveLength(8); + expect(JSON.stringify(result)).toContain("embed limit reached"); + }); +}); +describe("source-line preview", () => { + it.each([1, 6, 12])("does not overlap %i source lines", (n) => { + const text = Array.from({ length: n }, (_, i) => `${i}`).join("\n"); + expect(textFilePreview(text, true)).toEqual({ head: text, tail: "", omitted: 0 }); + }); + it("takes first six and last six source lines, ignoring final newline", () => { + const result = textFilePreview( + `${Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")}\n`, + true, + ); + expect(result).toEqual({ + head: "line1\nline2\nline3\nline4\nline5\nline6", + tail: "line15\nline16\nline17\nline18\nline19\nline20", + omitted: 8, + }); + }); + it("restores top-level fence context at fragment edges", () => { + const text = `\`\`\`js\n${Array.from({ length: 20 }, () => "const x = 1;").join("\n")}\n\`\`\``; + const result = textFilePreview(text, true); + expect(result.head).toMatch(/\n```$/); + expect(result.tail).toMatch(/^```js\n/); + expect(result.tail).toMatch(/\n```$/); + expect(textFilePreview(text, false).tail).not.toMatch(/^```js/); + }); +}); + +describe("text-file parser boundary compatibility", () => { + it("preserves document-wide ordinary reference definitions across embed splits", () => { + const parts = splitTextFiles( + "[before][web]\n\n[Plan](text-file:a.md)\n\n[after][web]\n\n[web]: https://example.com", + ); + expect(parts.filter((part) => part.kind === "text-file")).toHaveLength(1); + for (const part of parts) { + if (part.kind === "markdown") expect(part.text).toContain("[web]: https://example.com"); + } + }); + it.each([ + "%C2%85", + "a".repeat(4097), + ])("rejects backend-unsupported controls or overlong paths", (path) => { + expect(textFilePath(`text-file:${path}`)).toBeNull(); + }); + it("accepts encoded filename delimiters without treating them as a URL query/fragment", () => { + expect(textFilePath("text-file:notes/a%23b%3Fc.md")).toBe("notes/a#b?c.md"); + }); +}); diff --git a/src/features/markdown/logic/text-file.ts b/src/features/markdown/logic/text-file.ts new file mode 100644 index 0000000..4321c6f --- /dev/null +++ b/src/features/markdown/logic/text-file.ts @@ -0,0 +1,116 @@ +import { Marked } from "marked"; + +export interface TextFileReference { + readonly label: string; + readonly path: string; +} +export type MarkdownPart = + | { readonly kind: "markdown"; readonly text: string } + | { readonly kind: "text-file"; readonly reference: TextFileReference }; + +/** Input is marked's already Markdown-unescaped href. Decode percent escapes ONCE. */ +export function textFilePath(href: string): string | null { + if (!href.startsWith("text-file:")) return null; + let path: string; + try { + path = decodeURIComponent(href.slice(10)); + } catch { + return null; + } + if ( + !path || + path !== path.trim() || + path.length > 4096 || + path.includes("\\") || + Array.from(path).some((character) => { + const code = character.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }) || + path.startsWith("~") || + path.startsWith("//") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path) + ) + return null; + return path; +} + +/** Only a complete, top-level, standalone inline-link paragraph opts in. No I/O. */ +export function splitTextFiles(text: string, streaming = false): readonly MarkdownPart[] { + if (!text.includes("text-file:")) return [{ kind: "markdown", text }]; + const parts: MarkdownPart[] = []; + let prose = ""; + let offset = 0; + let embeds = 0; + const tokens = new Marked({ gfm: true }).lexer(text); + for (const token of tokens) { + offset += token.raw.length; + const children = token.type === "paragraph" ? token.tokens : undefined; + const link = children?.length === 1 ? children[0] : undefined; + const path = + link?.type === "link" && + link.title === null && + // Reference-style links and multiline paragraphs are deliberately not embeds. + /^\s*\[[\s\S]*\]\(/.test(token.raw) && + !token.raw.trim().includes("\n") + ? textFilePath(link.href) + : null; + // During streaming wait for a paragraph boundary, not merely a closing ')' + // that could still grow into adjacent prose on the next delta. + const settled = !streaming || /^\s*\n/.test(text.slice(offset)); + if (path !== null && link?.type === "link" && settled && embeds < 8) { + embeds++; + if (prose) parts.push({ kind: "markdown", text: prose }); + prose = ""; + parts.push({ kind: "text-file", reference: { label: link.text, path } }); + } else { + prose += token.raw; + if (path !== null && settled && embeds >= 8) + prose += "\n\n_Text-file embed limit reached (8 per message)._\n\n"; + } + } + if (prose) parts.push({ kind: "markdown", text: prose }); + // Reference definitions apply document-wide, even across embed boundaries. + const definitions = tokens + .filter((token) => token.type === "def") + .map((token) => token.raw) + .join("\n"); + return embeds && definitions + ? parts.map((part) => + part.kind === "markdown" + ? { + ...part, + text: `${part.text}\n\n${definitions}`, + } + : part, + ) + : parts; +} + +/** Restore top-level fence context when the tail begins inside a code fence. */ +function fenceAt(lines: readonly string[], end: number): string | null { + let fence: { marker: string; info: string } | null = null; + for (const line of lines.slice(0, end)) { + if (fence) { + const close = /^ {0,3}(`+|~+)\s*$/.exec(line)?.[1]; + if (close && close[0] === fence.marker[0] && close.length >= fence.marker.length) + fence = null; + } else { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (match?.[1]) fence = { marker: match[1], info: match[2] ?? "" }; + } + } + return fence ? `${fence.marker}${fence.info}` : null; +} +export function textFilePreview(text: string, markdown: boolean) { + const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); + if (lines.length <= 12) return { head: text, tail: "", omitted: 0 }; + const headLines = lines.slice(0, 6); + const tailLines = lines.slice(-6); + const headFence = markdown ? fenceAt(lines, 6) : null; + const tailFence = markdown ? fenceAt(lines, lines.length - 6) : null; + return { + head: headLines.join("\n") + (headFence ? `\n${headFence.match(/^[`~]+/)?.[0]}` : ""), + tail: (tailFence ? `${tailFence}\n` : "") + tailLines.join("\n"), + omitted: lines.length - 12, + }; +} diff --git a/src/features/markdown/types.ts b/src/features/markdown/types.ts new file mode 100644 index 0000000..cb3fe8d --- /dev/null +++ b/src/features/markdown/types.ts @@ -0,0 +1,13 @@ +import type { TextFileContextResponse, TextFileResponse } from "@dispatch/transport-contract"; + +export type TextFileState = + | { readonly kind: "loading" } + | { readonly kind: "error"; readonly message: string } + | { readonly kind: "ready"; readonly file: TextFileResponse }; +export interface TextFileClient { + subscribe(path: string, listener: (state: TextFileState) => void): () => void; + /** Refresh context as well as the live on-disk file. */ + refresh(path: string): void; + dispose(): void; +} +export type TextFileContext = TextFileContextResponse; diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index 72b892b..c33f9f6 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,58 +1,33 @@ <script lang="ts"> - import { renderMarkdown } from "../logic/markdown"; + import type { MarkdownImageOptions } from "../logic/markdown"; + import { splitTextFiles } from "../logic/text-file"; + import type { TextFileClient } from "../types"; + import MarkdownContent from "./MarkdownContent.svelte"; + import TextFileEmbed from "./TextFileEmbed.svelte"; let { text, streaming = false, + image = undefined, + textFiles = undefined, }: { text: string; - /** Balance dangling delimiters while the message is still generating. */ streaming?: boolean; + image?: MarkdownImageOptions | undefined; + /** Explicit opt-in; embedded documents never receive this port. */ + textFiles?: TextFileClient | undefined; } = $props(); - - // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown. - const html = $derived(renderMarkdown(text, { streaming })); - - let container: HTMLElement; - - // One delegated listener on the stable container handles every code block's - // copy button — including blocks re-created when `html` changes (streaming), - // since the listener lives on the container, not the buttons. Clipboard is the - // edge effect; absent (insecure context) → no-op. - $effect(() => { - const el = container; - if (el === undefined) return; - - const onClick = (event: Event): void => { - const target = event.target; - if (!(target instanceof Element)) return; - const button = target.closest<HTMLButtonElement>("[data-copy]"); - if (button === null) return; - - const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; - const clipboard = navigator.clipboard; - if (clipboard === undefined) return; - - void clipboard - .writeText(code) - .then(() => { - const prev = button.textContent; - button.textContent = "Copied"; - setTimeout(() => { - button.textContent = prev; - }, 1200); - }) - .catch(() => { - // Clipboard denied — leave the button as-is. - }); - }; - - el.addEventListener("click", onClick); - return () => el.removeEventListener("click", onClick); - }); + const parts = $derived(textFiles ? splitTextFiles(text, streaming) : null); </script> -<div class="markdown-body" bind:this={container}> - <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> - {@html html} -</div> +{#if parts && textFiles} + {#each parts as part, i (i)} + {#if part.kind === "text-file"} + <TextFileEmbed reference={part.reference} client={textFiles} {image} /> + {:else} + <MarkdownContent text={part.text} {streaming} {image} /> + {/if} + {/each} +{:else} + <MarkdownContent {text} {streaming} {image} /> +{/if} diff --git a/src/features/markdown/ui/MarkdownContent.svelte b/src/features/markdown/ui/MarkdownContent.svelte new file mode 100644 index 0000000..6149d6b --- /dev/null +++ b/src/features/markdown/ui/MarkdownContent.svelte @@ -0,0 +1,116 @@ +<script lang="ts"> + 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 | undefined; + } = $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). + const streamingState = createStreamingMarkdown(); + + // While streaming: the checkpoint split (settled prefix + live tail), which + // equals today's full render at every step modulo inter-block newlines and + // highlight-on-close. When the message completes (or for every non-streaming + // 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, renderOpts) }; + return advance(streamingState, text, renderOpts); + }); + + let container: HTMLElement; + + // 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(() => { + const el = container; + if (el === undefined) return; + + 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; + + const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + + void clipboard + .writeText(code) + .then(() => { + const prev = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = prev; + }, 1200); + }) + .catch(() => { + // Clipboard denied — leave the button as-is. + }); + }; + + el.addEventListener("click", onClick); + return () => el.removeEventListener("click", onClick); + }); +</script> + +<div class="markdown-body" bind:this={container}> + {#if streaming} + <!-- Two sibling wrappers (no padding/border/min-height, so block margins + collapse across them exactly as if the blocks were direct children). + Svelte 5 skips the {@html} DOM write when the string is unchanged, so + the settled prefix's subtree is never re-parsed/re-created/re-laid-out; + only the tail's subtree is replaced per delta. Both strings are + DOMPurify-sanitized in the logic modules. --> + <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div> + <div class="markdown-stream-tail">{@html rendered.tailHtml}</div> + {:else} + <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> + {@html rendered.tailHtml} + {/if} +</div> diff --git a/src/features/markdown/ui/TextFileEmbed.svelte b/src/features/markdown/ui/TextFileEmbed.svelte new file mode 100644 index 0000000..2e1a7a3 --- /dev/null +++ b/src/features/markdown/ui/TextFileEmbed.svelte @@ -0,0 +1,113 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { MarkdownImageOptions } from "../logic/markdown"; + import { textFilePreview, type TextFileReference } from "../logic/text-file"; + import type { TextFileClient, TextFileState } from "../types"; + import MarkdownContent from "./MarkdownContent.svelte"; + + let { + reference, + client, + image = undefined, + }: { + reference: TextFileReference; + client: TextFileClient; + image?: MarkdownImageOptions | undefined; + } = $props(); + let loadState = $state<TextFileState>({ kind: "loading" }); + let expanded = $state(false); + const preview = $derived( + loadState.kind === "ready" + ? textFilePreview(loadState.file.text, loadState.file.format === "markdown") + : null, + ); + const path = $derived(reference.path); + const currentClient = $derived(client); + $effect(() => { + const source = currentClient; + const destination = path; + expanded = false; + // The callback can publish synchronously. Only routing changes, not reads + // inside a subscriber or unrelated parent prop updates, own this effect. + return untrack(() => + source.subscribe(destination, (next) => { + loadState = next; + }), + ); + }); + // Use the resolved file's directory for local images, not the chat's cwd. + const documentImage = $derived( + loadState.kind === "ready" && image + ? { + apiBase: image.apiBase, + cwd: loadState.file.path.slice(0, loadState.file.path.lastIndexOf("/")) || "/", + } + : image, + ); +</script> + +<section + class="inset-well my-2 w-full min-w-0 rounded-box text-sm" + aria-label={`Text file: ${reference.label}`} +> + <header class="flex min-w-0 flex-wrap items-center gap-2 px-4 py-2 font-medium"> + <span aria-hidden="true">▤</span> + <span>{reference.label}</span> + <span class="min-w-0 flex-1 break-all font-mono text-xs font-normal text-base-content/60" + >{reference.path}</span + > + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => client.refresh(reference.path)} + disabled={loadState.kind === "loading"} + > + {loadState.kind === "error" ? "Retry" : "Refresh"} + </button> + </header> + <div class="min-w-0 px-4 pb-4"> + {#if loadState.kind === "loading"} + <p role="status"> + <span class="loading loading-dots loading-sm" aria-hidden="true"></span> Loading text file… + </p> + {:else if loadState.kind === "error"} + <p role="alert" class="text-error">{loadState.message}</p> + {:else if loadState.file.text === ""} + <p class="italic text-base-content/60">Empty file</p> + {:else if preview} + {#if expanded} + {@render content(loadState.file.text, loadState.file.format)} + {:else} + {@render content(preview.head, loadState.file.format)} + {#if preview.omitted > 0} + <p class="my-2 border-y border-base-content/20 py-2 text-xs text-base-content/60"> + ⋯ {preview.omitted} source lines omitted ⋯ + </p> + {@render content(preview.tail, loadState.file.format)} + {/if} + {/if} + <button + type="button" + class="btn btn-soft btn-xs mt-2" + aria-expanded={expanded} + onclick={() => { + expanded = !expanded; + }} + > + {expanded ? "Collapse document" : "Expand document"} + </button> + <p class="mt-2 text-xs text-base-content/50"> + Live file · Refresh rereads disk · maximum 256 KiB + </p> + {/if} + </div> +</section> + +{#snippet content(text: string, format: "markdown" | "text")} + {#if format === "markdown"} + <!-- No textFiles port: nested references are inert, even in expanded content. --> + <MarkdownContent {text} image={documentImage} /> + {:else} + <pre class="overflow-x-auto whitespace-pre font-mono text-xs"><code>{text}</code></pre> + {/if} +{/snippet} diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts index d65b3d1..b86cc0a 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/svelte"; +import hljs from "highlight.js/lib/core"; import { describe, expect, it, vi } from "vitest"; import Markdown from "./Markdown.svelte"; @@ -37,4 +38,154 @@ describe("Markdown", () => { expect(writeText).toHaveBeenCalledTimes(1); 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: '\n\n', + 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: "[](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(); + expect(container.querySelector(".markdown-stream-tail")).toBeNull(); + // The rendered blocks are DIRECT children of .markdown-body (no wrappers). + const body = container.querySelector(".markdown-body"); + expect(body?.querySelector("h1")).not.toBeNull(); + expect(body?.children[0]?.tagName).toBe("H1"); + }); + + it("streaming renders the prefix/tail split with content matching the full render", () => { + const { container } = render(Markdown, { + props: { text: "# Hello\n\nsettled paragraph\n\ngrowing tail", streaming: true }, + }); + + const prefix = container.querySelector<HTMLElement>(".markdown-stream-prefix"); + const tail = container.querySelector<HTMLElement>(".markdown-stream-tail"); + expect(prefix).not.toBeNull(); + expect(tail).not.toBeNull(); + // Same visible content as today's render, split across the two containers. + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.textContent).toContain("growing tail"); + expect(prefix?.querySelector("h1")).not.toBeNull(); + }); + + it("streaming: the settled prefix's html string identity is stable across text appends (no DOM churn)", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x = 1;\n```\n\nafter", streaming: true }, + }); + + const prefixBefore = container.querySelector(".markdown-stream-prefix")?.innerHTML; + expect(prefixBefore).toContain("hljs-keyword"); // the closed fence settled, highlighted once + expect(spy).toHaveBeenCalledTimes(1); + + // Appends that settle nothing leave the prefix string (and its DOM) alone. + await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and", streaming: true }); + await rerender({ + text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and more", + streaming: true, + }); + expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toBe(prefixBefore); + expect(spy).toHaveBeenCalledTimes(1); // still exactly one highlight + }); + + it("streaming: an open fence renders uncolored, then highlights exactly once when it closes", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x", streaming: true }, + }); + + const tail = container.querySelector<HTMLElement>(".markdown-stream-tail"); + expect(tail?.innerHTML).toContain("<pre><code"); + expect(tail?.innerHTML).toContain("data-copy"); // same copy button + expect(tail?.innerHTML).not.toContain("hljs-keyword"); // uncolored while open + expect(spy).not.toHaveBeenCalled(); + + // Close the fence → highlighted EXACTLY once, settled into the prefix. + await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\ndone", streaming: true }); + expect(spy).toHaveBeenCalledTimes(1); + expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toContain("hljs-keyword"); + }); + + it("streaming → done flips to the single-container full render (completion backstop)", async () => { + const { container, rerender } = render(Markdown, { + props: { text: "# Hello\n\nbody", streaming: true }, + }); + expect(container.querySelector(".markdown-stream-prefix")).not.toBeNull(); + + await rerender({ text: "# Hello\n\nbody", streaming: false }); + expect(container.querySelector(".markdown-stream-prefix")).toBeNull(); + expect(container.querySelector(".markdown-stream-tail")).toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + }); + + it("streaming: the delegated copy-button listener still works for tail code blocks", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + + const { container } = render(Markdown, { + props: { text: "para\n\n```js\nconst y = 2;", streaming: true }, + }); + + const button = container.querySelector<HTMLElement>("[data-copy]"); + expect(button).not.toBeNull(); + if (button === null) throw new Error("expected a copy button"); + await fireEvent.click(button); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const y = 2;"); + }); }); diff --git a/src/features/markdown/ui/text-file.test.ts b/src/features/markdown/ui/text-file.test.ts new file mode 100644 index 0000000..cbbf11e --- /dev/null +++ b/src/features/markdown/ui/text-file.test.ts @@ -0,0 +1,190 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import { createTextFileClient } from "../adapter/text-file"; +import Markdown from "./Markdown.svelte"; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +function fixture(text: string, format: "markdown" | "text" = "markdown") { + const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => + json( + String(url).endsWith("-context") + ? context + : { + path: "/project/notes/plan.md", + text, + format, + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, + }, + ), + ); + return { fetcher, client: createTextFileClient("/api", "c", fetcher) }; +} +describe("rendered live text-file embeds", () => { + it("renders Markdown head/tail source lines and expands/collapses the full sanitized document", async () => { + const text = Array.from({ length: 20 }, (_, i) => `## Line ${i + 1}`).join("\n"); + const { client, fetcher } = fixture(text); + const { unmount } = render(Markdown, { + text: "Summary\n\n[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + expect(screen.getByRole("status")).toHaveTextContent("Loading text file"); + await screen.findByRole("heading", { name: "Line 1" }); + expect(screen.getByRole("heading", { name: "Line 6" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 15" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 20" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(screen.getByText(/8 source lines omitted/)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect(screen.getByRole("heading", { name: "Line 10" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Collapse document" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await fireEvent.click(screen.getByRole("button", { name: "Collapse document" })); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("short documents don't duplicate; nested embeds are inert; HTML stays sanitized and remote images require consent", async () => { + const text = + '# Short\n\n[nested](text-file:secret.md)\n\n<script>alert(1)</script><img src="https://evil.example/a.png" onerror="alert(1)">\n\n[bad](javascript:alert(1))'; + const { client, fetcher } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Short" }); + expect(screen.getAllByRole("heading", { name: "Short" })).toHaveLength(1); + expect( + container.querySelector("script,[onerror],a[href^='javascript:'],a[href^='text-file:'],img"), + ).toBeNull(); + expect(container.textContent).toContain("nested"); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" })); + expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png"); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("does not render omitted images before expansion; restores tail fence context", async () => { + const text = + "# Head\n\n1\n2\n3\n4\n\n```js\n" + + Array.from({ length: 20 }, () => 'const x = "safe";').join("\n") + + "\n```"; + const { client } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Head" }); + expect( + screen.queryByRole("button", { name: "Load image from hidden.example" }), + ).not.toBeInTheDocument(); + expect(container.querySelector("code.language-js")).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect( + screen.getByRole("button", { name: "Load image from hidden.example" }), + ).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); + unmount(); + client.dispose(); + }); + it("renders plain text with safe whitespace and no Markdown/HTML interpretation", async () => { + const { client } = fixture("# heading\n <script>bad</script>\n\tlast", "text"); + const { container, unmount } = render(Markdown, { + text: "[Text](text-file:a.txt)", + textFiles: client, + }); + await waitFor(() => + expect(container.querySelector("pre code")?.textContent).toBe( + "# heading\n <script>bad</script>\n\tlast", + ), + ); + expect(container.querySelector("script,h1")).toBeNull(); + unmount(); + client.dispose(); + }); + it("empty, error, Retry and Refresh states reread rather than insert file text into source", async () => { + const { client, fetcher } = fixture(""); + fetcher + .mockResolvedValueOnce(json(context)) + .mockResolvedValueOnce(json({ error: "Missing file" }, 404)); + const { unmount } = render(Markdown, { text: "[Plan](text-file:a.md)", textFiles: client }); + expect(await screen.findByRole("alert")).toHaveTextContent("Missing file"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("Empty file"); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByText("Empty file"); + expect(fetcher).toHaveBeenCalledTimes(6); + unmount(); + client.dispose(); + }); + it("never fetches without opt-in, for incomplete streaming, code or forged raw HTML", async () => { + const { client, fetcher } = fixture("body"); + const { rerender, unmount } = render(Markdown, { text: "[Plan](text-file:a.md)" }); + await rerender({ + text: '<div data-text-file="secret.md"><button data-path="secret.md">Fake</button></div>\n\n`[Plan](text-file:a.md)`\n\n```\n[Plan](text-file:a.md)\n```', + textFiles: client, + }); + await rerender({ text: "[Plan](text-file:a.md)", streaming: true, textFiles: client }); + expect(fetcher).not.toHaveBeenCalled(); + await rerender({ text: "[Plan](text-file:a.md)\n\ntail", streaming: true, textFiles: client }); + await screen.findByText("body"); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: true, + textFiles: client, + }); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: false, + textFiles: client, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("ignores old conversation results after the context-bound client changes", async () => { + let finish: (r: Response) => void = () => {}; + const old = fixture("old"); + old.fetcher.mockResolvedValueOnce(json(context)).mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + const next = fixture("new"); + const { rerender, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: old.client, + }); + await waitFor(() => expect(old.fetcher).toHaveBeenCalledTimes(2)); + await rerender({ text: "[Plan](text-file:a.md)", textFiles: next.client }); + await screen.findByText("new"); + finish( + json({ path: "/old/a.md", text: "old", format: "markdown", bytes: 3, maxBytes: 262144 }), + ); + await waitFor(() => expect(screen.queryByText("old")).not.toBeInTheDocument()); + expect(old.fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + unmount(); + old.client.dispose(); + next.client.dispose(); + }); + it("deduplicates repeated blocks without replacing them on per-token appends", async () => { + const { client, fetcher } = fixture("# shared"); + const text = "[one](text-file:a.md)\n\n[two](text-file:a.md)\n\n"; + const { rerender, unmount } = render(Markdown, { text, streaming: true, textFiles: client }); + await waitFor(() => expect(screen.getAllByRole("heading", { name: "shared" })).toHaveLength(2)); + await fireEvent.click( + screen.getAllByRole("button", { name: "Expand document" })[0] as HTMLElement, + ); + await rerender({ text: `${text}growing`, streaming: true, textFiles: client }); + expect(screen.getByRole("button", { name: "Collapse document" })).toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); +}); diff --git a/src/features/named-agents/index.ts b/src/features/named-agents/index.ts new file mode 100644 index 0000000..e66ef8e --- /dev/null +++ b/src/features/named-agents/index.ts @@ -0,0 +1,64 @@ +export { + type AgentDraft, + type AgentSaved, + type DraftValidity, + draftModel, + draftValidity, + isClean, + isDirty, + type SaveStatus, + saveStatus, + saveStatusLabel, +} from "./logic/auto-save"; +export { + normalizeNamedAgent, + normalizeNamedAgentRecord, + normalizeNamedAgents, + toNamedAgentInput, +} from "./logic/normalize"; +export { + fullModelFromSelection, + joinModel, + modelFromFull, + modelsForProvider, + openCodeNumber, + providerFromModel, + providerLabel, + providerOptions, +} from "./logic/options"; +export { findNamedAgent, snapshotFor } from "./logic/snapshot"; +export type { + CreateNamedAgent, + DeleteNamedAgent, + LoadNamedAgents, + NamedAgent, + NamedAgentDeleteResult, + NamedAgentInput, + NamedAgentResponse, + NamedAgentSaveResult, + NamedAgentSelectionSnapshot, + NamedAgentsResponse, + NamedAgentsResult, + UpdateNamedAgent, +} from "./logic/types"; +export { + isUsableModel, + isUsableName, + nameError, + normalizeName, +} from "./logic/validate"; +export { + type CardController, + type CardControllerOptions, + type CardSaveState, + createCardController, + type NamedAgentPorts, +} from "./ui/controller.svelte"; +export { default as NamedAgentCard } from "./ui/NamedAgentCard.svelte"; +export { default as NamedAgentsView } from "./ui/NamedAgentsView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "named-agents", + description: "Curated Named Agent presets (name + model) in the sidebar", +} as const; diff --git a/src/features/named-agents/logic/auto-save.test.ts b/src/features/named-agents/logic/auto-save.test.ts new file mode 100644 index 0000000..24c9571 --- /dev/null +++ b/src/features/named-agents/logic/auto-save.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + type AgentDraft, + draftModel, + draftValidity, + isDirty, + saveStatus, + saveStatusLabel, +} from "./auto-save"; + +const draft = (p: Partial<AgentDraft>): AgentDraft => ({ + id: null, + name: "", + provider: "", + model: "", + ...p, +}); + +describe("draftModel", () => { + it("joins provider + model suffix", () => { + expect(draftModel(draft({ provider: "opencode-1", model: "claude" }))).toBe( + "opencode-1/claude", + ); + }); + + it("falls back to provider-only when no model", () => { + expect(draftModel(draft({ provider: "opencode-1" }))).toBe("opencode-1"); + }); +}); + +describe("draftValidity", () => { + it("is usable when name, provider, and model are all valid", () => { + const v = draftValidity(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), []); + expect(v.usable).toBe(true); + expect(v.message).toBeNull(); + }); + + it("is unusable with an empty name / duplicate / missing model", () => { + expect(draftValidity(draft({ name: "", provider: "p", model: "m" }), []).usable).toBe(false); + expect( + draftValidity(draft({ name: "coding", provider: "p", model: "m" }), ["Coding"]).usable, + ).toBe(false); + expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).usable).toBe( + false, + ); + }); + + it("reports the first invalid field's message", () => { + expect(draftValidity(draft({ name: "", provider: "", model: "" }), []).message).toBe( + "Name is required.", + ); + expect( + draftValidity(draft({ name: "coding", provider: "", model: "" }), ["Coding"]).message, + ).toBe("A named agent with this name already exists."); + expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).message).toBe( + "Select a provider.", + ); + }); +}); + +describe("isDirty", () => { + it("is false when the draft matches the saved definition", () => { + expect( + isDirty(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(false); + }); + + it("is true when name or model differ", () => { + expect( + isDirty(draft({ name: "Coding X", provider: "opencode-1", model: "claude" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(true); + expect( + isDirty(draft({ name: "Coding", provider: "umans", model: "glm" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(true); + }); +}); + +describe("saveStatus", () => { + const saved = { id: "a", name: "Coding", model: "opencode-1/claude" }; + + it("is saved when clean with an id", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + false, + null, + ), + ).toBe("saved"); + }); + + it("is dirty when input diverges from the saved definition", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding X", provider: "opencode-1", model: "claude" }), + saved, + false, + null, + ), + ).toBe("dirty"); + }); + + it("is an idle draft when it has no id", () => { + expect( + saveStatus( + draft({ name: "Coding", provider: "opencode-1", model: "claude" }), + { id: null, name: "", model: "" }, + false, + null, + ), + ).toBe("idle"); + }); + + it("is saving while a save is in flight", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + true, + null, + ), + ).toBe("saving"); + }); + + it("reports an error when the last save failed", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + false, + "boom", + ), + ).toBe("error"); + }); +}); + +describe("saveStatusLabel", () => { + it("maps every status to a short label", () => { + expect(saveStatusLabel("idle")).toBe("Not saved"); + expect(saveStatusLabel("dirty")).toBe("Not saved"); + expect(saveStatusLabel("saving")).toBe("Saving…"); + expect(saveStatusLabel("saved")).toBe("Saved"); + expect(saveStatusLabel("error")).toBe("Save failed"); + }); +}); diff --git a/src/features/named-agents/logic/auto-save.ts b/src/features/named-agents/logic/auto-save.ts new file mode 100644 index 0000000..c89d12e --- /dev/null +++ b/src/features/named-agents/logic/auto-save.ts @@ -0,0 +1,138 @@ +/** + * Pure auto-save model for a Named Agent card (plan §5.3/§5.4). + * + * A card has NO explicit Save button: provider/model changes save immediately, + * a name edit saves after a short debounce, and the card shows a status + * (`Not saved` / `Saving…` / `Saved` / `Save failed` + retry). This module is + * the PURE model of that: given the card's current DRAFT (the raw input + * controls), the last PERSISTED state, whether a save is in flight, and an + * error, it derives the card's display status and what a save would persist. + * + * The effectful coordination (debounce timers, dispatching saves, and the + * monotonic sequence that stops an older response clobbering a newer edit) + * lives in the component-wired controller (`ui/controller.svelte.ts`), which + * is itself thin over this purity. + */ + +import { isUsableModel, isUsableName, normalizeName } from "./validate"; + +/** + * The four auto-save lifecycle states a card reports. `"idle"` = a brand-new + * unsaved draft (never persisted yet) — shown as "Not saved". `"dirty"` = an + * existing card whose input differs from what was last saved — also "Not saved" + * until the (debounced/immediate) save lands. `"saving"` = a save is in flight. + * `"saved"` = current input matches the last persisted value. `"error"` = the + * last save failed (a retry is offered). A card that is not yet usable hovers + * in `"idle"` (never issued a save). + */ +export type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error"; + +/** A card's current EDITOR state (the raw form controls). */ +export interface AgentDraft { + /** The persisted id once the card has been created; null for a new draft. */ + readonly id: string | null; + /** The raw name input (may be unterminated/unedited). */ + readonly name: string; + /** Selected provider id ("" when none). */ + readonly provider: string; + /** Selected model suffix under `provider` ("" when none). */ + readonly model: string; +} + +/** The card's last PERSISTED definition (the server's canonical record). */ +export interface AgentSaved { + readonly id: string | null; + readonly name: string; + readonly model: string; +} + +/** The full canonical `<provider>/<model>` name a draft would persist. */ +export function draftModel(draft: AgentDraft): string { + return draft.model === "" ? draft.provider : `${draft.provider}/${draft.model}`; +} + +/** The full `<provider>/<model>` name a draft's model suffix maps to. */ +export interface DraftValidity { + readonly nameOk: boolean; + readonly modelOk: boolean; + /** True when the card is fully usable (name nonempty+unique, provider+model chosen). */ + readonly usable: boolean; + /** A message for the first invalid field (for inline hints), or null when usable. */ + readonly message: string | null; +} + +/** + * Validate a draft against its sibling names (the OTHER agents' names, EXCLUDING + * this card's own id — uniqueness is among distinct agents). + */ +export function draftValidity(draft: AgentDraft, otherNames: readonly string[]): DraftValidity { + const nameMessage = nameErrorMessage(draft.name, otherNames); + const nameOk = nameMessage === null; + const modelOk = draft.provider !== "" && isUsableModel(draftModel(draft)); + const usable = nameOk && modelOk; + const message = usable ? null : (nameMessage ?? modelMessage(draft)); + return { nameOk, modelOk, usable, message }; +} + +function nameErrorMessage(name: string, otherNames: readonly string[]): string | null { + if (normalizeName(name).length === 0) return "Name is required."; + return isUsableName(name, otherNames) ? null : "A named agent with this name already exists."; +} + +function modelMessage(draft: AgentDraft): string | null { + if (draft.provider === "") return "Select a provider."; + if (draft.model === "") return "Select a model."; + return "Select a valid model."; +} + +/** + * Whether the current draft differs from what was last persisted — i.e. a save + * is owed. Draft is compared by its NAME (normalized) and full MODEL string. + */ +export function isDirty(draft: AgentDraft, saved: AgentSaved): boolean { + return ( + normalizeName(draft.name) !== normalizeName(saved.name) || draftModel(draft) !== saved.model + ); +} + +/** + * The card's display status, purely from its orchestration inputs: + * - No id yet → a never-persisted draft → `"idle"` while unusable or unsaved, + * `"error"` if a create attempt failed. + * - In-flight save → `"saving"`. + * - Last save errored → `"error"`. + * - No id and clean (nothing to save) → `"idle"`. + * - Dirty (not yet (re)saved) → `"dirty"`. + * - Clean + has an id → `"saved"`. + */ +export function saveStatus( + draft: AgentDraft, + saved: AgentSaved, + saving: boolean, + error: string | null, +): SaveStatus { + if (saving) return "saving"; + if (error !== null) return "error"; + const hasId = draft.id !== null || saved.id !== null; + if (!hasId) return "idle"; + return isDirty(draft, saved) ? "dirty" : "saved"; +} + +/** Short human label for a save status (the card's status line). */ +export function saveStatusLabel(status: SaveStatus): string { + switch (status) { + case "saving": + return "Saving…"; + case "saved": + return "Saved"; + case "error": + return "Save failed"; + default: + return "Not saved"; + } +} + +/** True iff a status warrants showing the card body as "saved clean" (no warning tint). */ +export function isClean(status: SaveStatus): boolean { + return status === "saved"; +} diff --git a/src/features/named-agents/logic/normalize.test.ts b/src/features/named-agents/logic/normalize.test.ts new file mode 100644 index 0000000..5009194 --- /dev/null +++ b/src/features/named-agents/logic/normalize.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeNamedAgent, + normalizeNamedAgentRecord, + normalizeNamedAgents, + toNamedAgentInput, +} from "./normalize"; +import type { NamedAgent } from "./types"; + +describe("normalizeNamedAgent", () => { + it("coerces a well-formed record", () => { + expect(normalizeNamedAgent({ id: "x", name: "Coding", model: "opencode-1/claude" })).toEqual({ + id: "x", + name: "Coding", + model: "opencode-1/claude", + }); + }); + + it("rejects a record missing any field", () => { + expect(normalizeNamedAgent({ id: "x", name: "Coding" })).toBeNull(); + expect(normalizeNamedAgent({ name: "Coding", model: "m" })).toBeNull(); + expect(normalizeNamedAgent(null)).toBeNull(); + }); +}); + +describe("normalizeNamedAgents", () => { + it("coerces a list body", () => { + const agents: readonly NamedAgent[] = normalizeNamedAgents({ + namedAgents: [ + { id: "a", name: "Coding", model: "opencode-1/claude" }, + { id: "b", name: "Research", model: "umans/glm" }, + ], + }); + expect(agents).toHaveLength(2); + expect(agents[0]?.name).toBe("Coding"); + }); + + it("drops malformed entries and survives a missing list key", () => { + expect( + normalizeNamedAgents({ + namedAgents: [ + { id: "a", name: "x" }, + { id: "b", name: "y", model: "m" }, + ], + }).map((a) => a.id), + ).toEqual(["b"]); + expect(normalizeNamedAgents({})).toEqual([]); + expect(normalizeNamedAgents(null)).toEqual([]); + }); +}); + +describe("normalizeNamedAgentRecord", () => { + it("reads the canonical saved record", () => { + expect( + normalizeNamedAgentRecord({ + namedAgent: { id: "a", name: "Coding", model: "opencode-1/claude" }, + })?.id, + ).toBe("a"); + }); + + it("returns null for a malformed record", () => { + expect(normalizeNamedAgentRecord({ namedAgent: {} })).toBeNull(); + expect(normalizeNamedAgentRecord({})).toBeNull(); + }); +}); + +describe("toNamedAgentInput", () => { + it("trims a name and keeps the model", () => { + expect(toNamedAgentInput(" Coding ", "opencode-1/claude")).toEqual({ + name: "Coding", + model: "opencode-1/claude", + }); + }); + + it("defaults non-strings to empty", () => { + expect(toNamedAgentInput(null, 42)).toEqual({ name: "", model: "" }); + }); +}); diff --git a/src/features/named-agents/logic/normalize.ts b/src/features/named-agents/logic/normalize.ts new file mode 100644 index 0000000..c1acec2 --- /dev/null +++ b/src/features/named-agents/logic/normalize.ts @@ -0,0 +1,68 @@ +/** + * Pure network-seam normalizers for the Named Agents API — zero DOM, zero + * effects. The composition root coerces the UNTYPED JSON it receives from + * `GET/POST/PUT/DELETE /named-agents` through these so a malformed / partial + * response can never crash the renderer. Mirrors the concurrency / heartbeat + * network-seam guard pattern. + */ + +import type { NamedAgent, NamedAgentInput } from "./types"; + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object"; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** + * Coerce one untrusted record into a typed `NamedAgent`, or `null` when it is + * malformed (missing `id`, `name`, or `model` — or a non-string `model`). + */ +export function normalizeNamedAgent(value: unknown): NamedAgent | null { + if (!isRecord(value)) return null; + const id = asString(value.id); + const name = asString(value.name); + const model = asString(value.model); + if (id === null || name === null || model === null) return null; + return { id, name, model }; +} + +/** + * Coerce an untrusted `GET /named-agents` body into a typed Named Agent list. + * When the list key is absent/malformed, returns `[]` (defensive — the renderer + * shows the empty state rather than crashing). + */ +export function normalizeNamedAgents(data: unknown): readonly NamedAgent[] { + if (!isRecord(data) || !Array.isArray(data.namedAgents)) return []; + const out: NamedAgent[] = []; + for (const entry of data.namedAgents as readonly unknown[]) { + const agent = normalizeNamedAgent(entry); + if (agent !== null) out.push(agent); + } + return out; +} + +/** + * Coerce an untrusted `POST`/`PUT /named-agents` body (the canonical saved + * record) into a typed `NamedAgent`, or `null` when malformed. The composition + * root surfaces an error when this returns null on an ok response. + */ +export function normalizeNamedAgentRecord(data: unknown): NamedAgent | null { + if (!isRecord(data)) return null; + return normalizeNamedAgent(data.namedAgent); +} + +/** + * Coerce an untrusted `NamedAgentInput` at the create/update boundary so only + * clean strings reach the wire. Non-string fields drop to "" (name nonempty is + * validated separately; model nonempty too). The caller still runs name/model + * validation so a malformed body never reaches the backend. + */ +export function toNamedAgentInput(name: unknown, model: unknown): NamedAgentInput { + return { + name: typeof name === "string" ? name.trim() : "", + model: typeof model === "string" ? model : "", + }; +} diff --git a/src/features/named-agents/logic/options.test.ts b/src/features/named-agents/logic/options.test.ts new file mode 100644 index 0000000..8b774ab --- /dev/null +++ b/src/features/named-agents/logic/options.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + fullModelFromSelection, + modelFromFull, + modelsForProvider, + providerFromModel, + providerLabel, + providerOptions, +} from "./options"; + +const MODELS = [ + "opencode-2/gpt-5", + "opencode-1/claude-sonnet-4.5", + "umans/umans-glm-5.2", + "opencode-10/defiant", + "umans/umans-gpt-5", + "hyper/fast-1", +] as const; + +describe("providerFromModel / modelFromFull", () => { + it("splits a <provider>/<model> on the first slash", () => { + expect(providerFromModel("opencode-1/claude-sonnet-4.5")).toBe("opencode-1"); + expect(modelFromFull("opencode-1/claude-sonnet-4.5")).toBe("claude-sonnet-4.5"); + }); + + it("treats a slashless name as all-provider", () => { + expect(providerFromModel("umans")).toBe("umans"); + expect(modelFromFull("umans")).toBe(""); + }); +}); + +describe("providerOptions", () => { + it("sorts numbered OpenCode credentials numerically before other providers", () => { + expect(providerOptions([...MODELS])).toEqual([ + "opencode-1", + "opencode-2", + "opencode-10", + "umans", + "hyper", + ]); + }); + + it("keeps first-seen order and dedupes", () => { + expect(providerOptions(["b/x", "a/y", "b/z"])).toEqual(["b", "a"]); + }); +}); + +describe("modelsForProvider", () => { + it("returns only the model suffixes under the provider, in order", () => { + expect(modelsForProvider([...MODELS], "umans")).toEqual(["umans-glm-5.2", "umans-gpt-5"]); + expect(modelsForProvider([...MODELS], "hyper")).toEqual(["fast-1"]); + }); + + it("returns [] for an unknown provider", () => { + expect(modelsForProvider([...MODELS], "missing")).toEqual([]); + }); +}); + +describe("providerLabel", () => { + it("labels numbered OpenCode providers, leaves others unchanged", () => { + expect(providerLabel("opencode-2")).toBe("OpenCode #2"); + expect(providerLabel("umans")).toBe("umans"); + }); +}); + +describe("fullModelFromSelection", () => { + it("recombines a provider + model suffix", () => { + expect(fullModelFromSelection("opencode-1", "claude-sonnet-4.5")).toBe( + "opencode-1/claude-sonnet-4.5", + ); + }); +}); diff --git a/src/features/named-agents/logic/options.ts b/src/features/named-agents/logic/options.ts new file mode 100644 index 0000000..0946cbe --- /dev/null +++ b/src/features/named-agents/logic/options.ts @@ -0,0 +1,84 @@ +/** + * Pure provider/model dropdown helpers for the Named Agents editor — zero DOM. + * + * A Named Agent's persisted `model` is the canonical `<provider>/<model>` + * identifier, but the edit UI splits it into a provider dropdown + a model + * dropdown (plan §3/§5.2). These helpers derive those dropdowns from the raw + * `GET /models` catalog — the SAME key grouping the regular model picker uses. + * Catalog latency is acceptable here (human configuration), and it must not + * affect the agent-facing list/summon hot paths (plan §9). + */ + +/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */ +export function providerFromModel(full: string): string { + const i = full.indexOf("/"); + return i === -1 ? full : full.slice(0, i); +} + +/** The model suffix of a `<provider>/<model>` name (the part after the first `/`; empty when slashless). */ +export function modelFromFull(full: string): string { + const i = full.indexOf("/"); + return i === -1 ? "" : full.slice(i + 1); +} + +/** Recombine a provider + model suffix into a canonical `<provider>/<model>` name (provider-only if no model). */ +export function joinModel(provider: string, model: string): string { + return model === "" ? provider : `${provider}/${model}`; +} + +/** + * Distinct provider ids across the catalog, in first-seen order, with numbered + * OpenCode credentials sorted NUMERICALLY before other providers (the same + * grouping the regular model picker uses — `opencode-2` before `opencode-10`, + * then everything else). A friendly display label is produced separately so the + * RAW id stays what gets persisted. + */ +export function providerOptions(models: readonly string[]): string[] { + const seen = new Set<string>(); + const openCodeKeys: Array<{ key: string; n: number }> = []; + const otherKeys: string[] = []; + for (const full of models) { + const p = providerFromModel(full); + if (p === "" || seen.has(p)) continue; + seen.add(p); + const n = openCodeNumber(p); + if (n !== null) openCodeKeys.push({ key: p, n }); + else otherKeys.push(p); + } + openCodeKeys.sort((a, b) => a.n - b.n); + return [...openCodeKeys.map((e) => e.key), ...otherKeys]; +} + +/** The numeric identity of a numbered OpenCode provider (`opencode-1` → 1), or null. */ +export function openCodeNumber(key: string): number | null { + const m = /^opencode-([1-9][0-9]*)$/.exec(key); + return m === null ? null : Number(m[1]); +} + +/** + * Friendly display label for a provider id: `opencode-<n>` → "OpenCode #<n>"; + * any other id unchanged. Presentation only — the RAW id is what gets persisted. + */ +export function providerLabel(provider: string): string { + const n = openCodeNumber(provider); + return n === null ? provider : `OpenCode #${n}`; +} + +/** The model suffixes available under a given provider, in catalog order. */ +export function modelsForProvider(models: readonly string[], provider: string): string[] { + const out: string[] = []; + for (const full of models) { + if (providerFromModel(full) === provider) out.push(modelFromFull(full)); + } + return out; +} + +/** + * The model suffixes in the catalog that a Named Agent's model suffix may match. + * A saved Named Agent may reference a provider/model pair no longer present in a + * live catalog (e.g. a credential was removed); the editor then shows the saved + * RAW full name as an unavailable selection rather than silently rewriting it. + */ +export function fullModelFromSelection(provider: string, model: string): string { + return joinModel(provider, model); +} diff --git a/src/features/named-agents/logic/snapshot.test.ts b/src/features/named-agents/logic/snapshot.test.ts new file mode 100644 index 0000000..3f1b5cf --- /dev/null +++ b/src/features/named-agents/logic/snapshot.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { findNamedAgent, snapshotFor } from "./snapshot"; +import type { NamedAgent } from "./types"; + +const AGENTS: readonly NamedAgent[] = [ + { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "b2", name: "Research Agent", model: "umans/umans-glm-5.2" }, +]; + +describe("snapshotFor", () => { + it("builds a snapshot carrying id, name, and the raw model", () => { + const coding = AGENTS[0]; + if (coding === undefined) throw new Error("test fixture missing"); + expect(coding).toBeDefined(); + expect(snapshotFor(coding)).toEqual({ + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + }); +}); + +describe("findNamedAgent", () => { + it("finds by stable id", () => { + expect(findNamedAgent(AGENTS, "b2")?.name).toBe("Research Agent"); + }); + + it("finds by name case-insensitively", () => { + expect(findNamedAgent(AGENTS, "coding agent")?.id).toBe("a1"); + }); + + it("returns undefined for an unknown id/name", () => { + expect(findNamedAgent(AGENTS, "nope")).toBeUndefined(); + }); +}); diff --git a/src/features/named-agents/logic/snapshot.ts b/src/features/named-agents/logic/snapshot.ts new file mode 100644 index 0000000..482ceb3 --- /dev/null +++ b/src/features/named-agents/logic/snapshot.ts @@ -0,0 +1,33 @@ +/** + * Pure snapshot helpers for Named Agent selection (plan §7). + * + * Selecting a Named Agent for a conversation resolves it to its raw model AND + * records a `NamedAgentSelectionSnapshot` ({namedAgentId, namedAgentName, model}) + * so the conversation retains how it was selected. Later edits to the shared + * Named Agent (rename / model change / removal) therefore do NOT retroactively + * change an existing conversation — the snapshot is what the UI keeps showing. + */ + +import type { NamedAgent, NamedAgentSelectionSnapshot } from "./types"; + +/** Build the snapshot a conversation would retain for a Named Agent selection. */ +export function snapshotFor(agent: NamedAgent): NamedAgentSelectionSnapshot { + return { + namedAgentId: agent.id, + namedAgentName: agent.name, + model: agent.model, + }; +} + +/** + * Find a Named Agent by stable id or (case-insensitively) by name. Used to + * resolve a name/id picked in the model selector back to its current definition. + * Returns undefined when unknown (a stale name/id after deletion). + */ +export function findNamedAgent( + agents: readonly NamedAgent[], + idOrName: string, +): NamedAgent | undefined { + const lower = idOrName.trim().toLowerCase(); + return agents.find((a) => a.id === idOrName || a.name.trim().toLowerCase() === lower); +} diff --git a/src/features/named-agents/logic/types.ts b/src/features/named-agents/logic/types.ts new file mode 100644 index 0000000..2e4d4d3 --- /dev/null +++ b/src/features/named-agents/logic/types.ts @@ -0,0 +1,57 @@ +/** + * Pure shared types for the Named Agents feature — zero DOM, zero effects, + * zero Svelte. + * + * The shared shapes — `NamedAgent`, `NamedAgentSelectionSnapshot`, and the + * `/named-agents` HTTP request/response types — are RE-EXPORTED from the + * backend-contract packages (`@dispatch/wire`, `@dispatch/transport-contract`), + * which now carry them (the backend shipped the named-agents contract). The FE + * consumes those packages as the cross-repo seam; only the FE's port/result + * types are defined locally. + */ + +import type { + CreateNamedAgentRequest, + DeleteNamedAgentResponse, + NamedAgentResponse, + NamedAgentsResponse, + UpdateNamedAgentRequest, +} from "@dispatch/transport-contract"; +import type { NamedAgent, NamedAgentSelectionSnapshot } from "@dispatch/wire"; + +export type { + DeleteNamedAgentResponse, + NamedAgent, + NamedAgentResponse, + NamedAgentSelectionSnapshot, + NamedAgentsResponse, + UpdateNamedAgentRequest, +}; + +/** The subset of a Named Agent a client creates/updates (plan §9 — id is server-minted). */ +export type NamedAgentInput = CreateNamedAgentRequest; + +// ── Ports (injected effects — the composition root supplies real responders) ── + +/** Outcome of listing Named Agents. */ +export type NamedAgentsResult = + | { readonly ok: true; readonly agents: readonly NamedAgent[] } + | { readonly ok: false; readonly error: string }; + +/** Outcome of creating / updating a Named Agent (the canonical saved record). */ +export type NamedAgentSaveResult = + | { readonly ok: true; readonly agent: NamedAgent } + | { readonly ok: false; readonly error: string }; + +/** Outcome of removing a Named Agent. */ +export type NamedAgentDeleteResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: string }; + +export type LoadNamedAgents = () => Promise<NamedAgentsResult>; +export type CreateNamedAgent = (input: NamedAgentInput) => Promise<NamedAgentSaveResult>; +export type UpdateNamedAgent = ( + id: string, + input: NamedAgentInput, +) => Promise<NamedAgentSaveResult>; +export type DeleteNamedAgent = (id: string) => Promise<NamedAgentDeleteResult>; diff --git a/src/features/named-agents/logic/validate.test.ts b/src/features/named-agents/logic/validate.test.ts new file mode 100644 index 0000000..d647354 --- /dev/null +++ b/src/features/named-agents/logic/validate.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { isUsableModel, isUsableName, nameError, normalizeName } from "./validate"; + +describe("normalizeName", () => { + it("trims surrounding whitespace", () => { + expect(normalizeName(" Coding ")).toBe("Coding"); + }); + + it("collapses internal runs of whitespace to one space", () => { + expect(normalizeName(" Research Agent ")).toBe("Research Agent"); + }); + + it("collapses an all-whitespace name to empty", () => { + expect(normalizeName(" ")).toBe(""); + }); +}); + +describe("nameError", () => { + it("returns null for a nonempty unique name", () => { + expect(nameError("Coding", ["Research", "Fast"])).toBeNull(); + }); + + it("flags an empty/normalized-empty name", () => { + expect(nameError("", ["Research"])).toBe("Name is required."); + expect(nameError(" ", ["Research"])).toBe("Name is required."); + }); + + it("flags a name duplicate case-insensitively", () => { + expect(nameError("coding", ["Coding"])).toBe("A named agent with this name already exists."); + expect(nameError("FAST", ["Fast"])).toBe("A named agent with this name already exists."); + }); + + it("ignores whitespace when comparing uniqueness", () => { + expect(nameError(" coding ", ["Coding "])).toBe( + "A named agent with this name already exists.", + ); + }); +}); + +describe("isUsableName", () => { + it("is true only for a unique nonempty name", () => { + expect(isUsableName("Coding", ["Research"])).toBe(true); + expect(isUsableName("coding", ["Coding"])).toBe(false); + expect(isUsableName("", [])).toBe(false); + }); +}); + +describe("isUsableModel", () => { + it("accepts a canonical provider/model pair", () => { + expect(isUsableModel("opencode-1/claude-sonnet-4.5")).toBe(true); + }); + + it("rejects a slashless / provider-only value", () => { + expect(isUsableModel("opencode-1")).toBe(false); + expect(isUsableModel("/claude")).toBe(false); + expect(isUsableModel("")).toBe(false); + }); +}); diff --git a/src/features/named-agents/logic/validate.ts b/src/features/named-agents/logic/validate.ts new file mode 100644 index 0000000..c25524a --- /dev/null +++ b/src/features/named-agents/logic/validate.ts @@ -0,0 +1,47 @@ +/** + * Pure validation for Named Agent form fields — zero DOM, zero effects. + * + * A Named Agent is usable only when (plan §5.4): the name is nonempty + unique + * case-insensitively, and a provider + model are selected. These pure helpers + * drive the card UI's validity gating + inline messages; the backend remains + * the authority (it 400s/conflicts on a duplicate or empty name / malformed + * model identifier). + */ + +/** Trim + collapse runs of whitespace inside a name (survey-style display). */ +export function normalizeName(raw: string): string { + return raw.trim().replace(/\s+/g, " "); +} + +/** + * A name-validation error message, or `null` when the name is valid. `otherNames` + * are the OTHER agents' names for the case-insensitive uniqueness check (the + * caller EXCLUDES the card's own last-persisted name / the card being edited). + */ +export function nameError(name: string, otherNames: readonly string[]): string | null { + const normalized = normalizeName(name); + if (normalized.length === 0) return "Name is required."; + const lower = normalized.toLowerCase(); + for (const other of otherNames) { + if (other.trim().toLowerCase() === lower) return "A named agent with this name already exists."; + } + return null; +} + +/** True iff `name` is a unique, nonempty agent name among `otherNames`. */ +export function isUsableName(name: string, otherNames: readonly string[]): boolean { + return nameError(name, otherNames) === null; +} + +/** + * Whether a full model identifier is syntactically valid — a canonical + * `<provider>/<model>` with a nonempty provider (the part before the first "/") + * AND a nonempty model (the part after). A slashless or provider-only value is + * invalid for a Named Agent (it must resolve to a concrete model). + */ +export function isUsableModel(full: string): boolean { + const i = full.indexOf("/"); + if (i <= 0) return false; // no slash, or empty provider + const model = full.slice(i + 1); + return model.length > 0; +} diff --git a/src/features/named-agents/ui/NamedAgentCard.svelte b/src/features/named-agents/ui/NamedAgentCard.svelte new file mode 100644 index 0000000..b81c59c --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentCard.svelte @@ -0,0 +1,120 @@ +<script lang="ts"> + import type { NamedAgent } from "../logic/types"; + import type { CardController } from "./controller.svelte"; + import { + modelsForProvider, + providerLabel, + providerOptions, + fullModelFromSelection, + } from "../logic/options"; + import { draftValidity } from "../logic/auto-save"; + import { saveStatusLabel } from "../logic/auto-save"; + + let { + models, + agent, + controller, + otherNames, + }: { + /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */ + models: readonly string[]; + /** The persisted agent this card edits, or null for a new draft. */ + agent: NamedAgent | null; + /** The card's auto-save controller (draft + status + save lifecycle). */ + controller: CardController; + /** The OTHER agents' names (excludes this card) for the uniqueness hint. */ + otherNames: readonly string[]; + } = $props(); + + const providers = $derived(providerOptions(models)); + const providerModels = $derived(modelsForProvider(models, controller.draft.provider)); + // The full model string a provider+model selection maps to (for the "unavailable" + // fallback when the saved model leaves the catalog). + const fullModel = $derived( + fullModelFromSelection(controller.draft.provider, controller.draft.model), + ); + const validity = $derived(draftValidity(controller.draft, otherNames)); + const statusLabel = $derived(saveStatusLabel(controller.save.status)); + // Whether the current provider+model actually resolves inside the catalog. + const selectedResolves = $derived( + controller.draft.provider !== "" && + modelsForProvider(models, controller.draft.provider).includes(controller.draft.model), + ); +</script> + +<div class="flex flex-col gap-1 rounded-box bg-base-200 p-3"> + <span class="text-[10px] uppercase opacity-60">Name</span> + <input + type="text" + class="input input-bordered input-sm w-full" + placeholder="Enter a name…" + aria-label="Named agent name" + value={controller.draft.name} + oninput={(e) => controller.setName(e.currentTarget.value)} + /> + + <span class="mt-1 text-[10px] uppercase opacity-60">Provider</span> + <select + class="select select-bordered select-sm w-full" + aria-label="Named agent provider" + value={controller.draft.provider} + onchange={(e) => controller.setProvider(e.currentTarget.value)} + > + {#if controller.draft.provider === ""} + <option value="" disabled>Select provider…</option> + {/if} + {#each providers as provider (provider)} + <option value={provider}>{providerLabel(provider)}</option> + {/each} + </select> + + <span class="mt-1 text-[10px] uppercase opacity-60">Model</span> + <select + class="select select-bordered select-sm w-full" + aria-label="Named agent model" + value={controller.draft.model} + disabled={controller.draft.provider === ""} + onchange={(e) => controller.setModel(e.currentTarget.value)} + > + {#if controller.draft.provider === ""} + <option value="" disabled>Select provider first</option> + {:else if controller.draft.model === ""} + <option value="" disabled>Select a model…</option> + {/if} + {#each providerModels as model (model)} + <option value={model}>{model}</option> + {/each} + {#if !selectedResolves && controller.draft.model !== ""} + <!-- The saved selection is no longer in the live catalog (its provider/ + credential was removed) — keep showing it as unavailable rather than + silently rewriting it. --> + <option value={controller.draft.model} disabled> + {fullModel} (unavailable) + </option> + {/if} + </select> + + {#if !validity.usable && validity.message !== null} + <p class="mt-1 text-xs text-warning" role="status">{validity.message}</p> + {/if} + + <div class="mt-2 flex items-center justify-between gap-2"> + <span + class="text-xs" + class:text-error={controller.save.status === "error" && controller.save.error !== null} + class:opacity-60={controller.save.status !== "saving" && controller.save.status !== "error"} + role="status" + > + {statusLabel}{#if controller.save.error !== null}: {controller.save.error}{/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs text-error" + aria-label="Remove named agent" + disabled={controller.saving} + onclick={() => controller.remove()} + > + Remove + </button> + </div> +</div>
\ No newline at end of file diff --git a/src/features/named-agents/ui/NamedAgentsView.svelte b/src/features/named-agents/ui/NamedAgentsView.svelte new file mode 100644 index 0000000..743ac20 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.svelte @@ -0,0 +1,126 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { NamedAgent } from "../logic/types"; + import type { NamedAgentPorts } from "./controller.svelte"; + import { createCardController, type CardController } from "./controller.svelte"; + import NamedAgentCard from "./NamedAgentCard.svelte"; + + interface Row { + key: number; + agent: NamedAgent | null; + controller: CardController; + } + + let { + agents, + models, + ports, + }: { + /** The persisted Named Agents (seed for the cards; the store stays authoritative for consumers). */ + agents: readonly NamedAgent[]; + /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */ + models: readonly string[]; + ports: NamedAgentPorts; + } = $props(); + + // The view OWNS the editable card rows (each a controller + its last-saved + // canonical agent). Seeded once from the `agents` snapshot on mount (untracked + // so a later store update — which this view drove — never clobbers an + // in-flight draft). The store remains authoritative for other consumers. + let rows = $state<Row[]>([]); + let nextKey = $state(0); + let seeded = $state(false); + + $effect(() => { + untrack(() => { + if (seeded) return; + seeded = true; + for (const a of agents) addRow(a); + }); + }); + + /** + * The other rows' names (excluding the row with `rowKey`) for the uniqueness + * hint. Identity is the ROW's stable `key` — a draft (`agent === null`) must + * still exclude ITSELF (otherwise a fresh draft collides with its own name), + * while any OTHER draft is a genuine sibling. + */ + function otherNamesOf(rowKey: number): readonly string[] { + const out: string[] = []; + for (const r of rows) { + if (r.key === rowKey) continue; + out.push(r.agent !== null ? r.agent.name : r.controller.draft.name); + } + return out; + } + + /** + * Live uniqueness predicate for a card: true when `name` collides with a + * SIBLING row (excludes the card's own row by its stable `rowKey` — not by a + * nullable persisted id, so a draft excludes itself exactly once). + */ + function isNameTakenFor(rowKey: number): (name: string) => boolean { + return (name: string): boolean => { + const lower = name.trim().toLowerCase(); + for (const r of rows) { + if (r.key === rowKey) continue; + const candidate = r.agent !== null ? r.agent.name : r.controller.draft.name; + if (candidate.trim().toLowerCase() === lower) return true; + } + return false; + }; + } + + function addRow(agent: NamedAgent | null): void { + // Capture the row's stable key FIRST so the controller's callbacks reconcile + // THIS row only — never a sibling draft (`agent === null` matches all). + const key = nextKey++; + const controller = createCardController({ + agent, + savedModel: agent?.model ?? "", + isNameTaken: isNameTakenFor(key), + ports, + onChanged: (saved) => replaceAgent(key, saved), + onRemoved: () => removeRow(key), + }); + rows = [...rows, { key, agent, controller }]; + } + + function replaceAgent(key: number, saved: NamedAgent): void { + rows = rows.map((r) => (r.key === key ? { ...r, agent: saved } : r)); + } + + function removeRow(key: number): void { + const row = rows.find((r) => r.key === key); + if (row === undefined) return; + row.controller.dispose(); + rows = rows.filter((r) => r.key !== key); + } + + function addDraft(): void { + addRow(null); + } +</script> + +<div class="flex flex-col gap-2"> + <ul class="flex flex-col gap-2"> + {#each rows as row (row.key)} + <li> + <NamedAgentCard + models={models} + agent={row.agent} + controller={row.controller} + otherNames={otherNamesOf(row.key)} + /> + </li> + {/each} + </ul> + + {#if rows.length === 0} + <p class="text-xs opacity-60">No named agents yet.</p> + {/if} + + <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={addDraft}> + + Add Named Agent + </button> +</div>
\ No newline at end of file diff --git a/src/features/named-agents/ui/NamedAgentsView.test.ts b/src/features/named-agents/ui/NamedAgentsView.test.ts new file mode 100644 index 0000000..bac4e25 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.test.ts @@ -0,0 +1,181 @@ +import { render, screen } from "@testing-library/svelte"; +import type { UserEvent } from "@testing-library/user-event"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + CreateNamedAgent, + DeleteNamedAgent, + NamedAgentInput, + UpdateNamedAgent, +} from "../logic/types"; +import type { NamedAgentPorts } from "./controller.svelte"; +import NamedAgentsView from "./NamedAgentsView.svelte"; + +// ── Fakes for the injected ports (ONLY the outermost edge — no sibling module +// is mocked). A modest `debounceMs` keeps name edits coalesced into one save +// (with 0, each keystroke fires its own update) while provider/model saves stay +// immediate (the model setter dispatches synchronously). +function makePorts(): { + ports: NamedAgentPorts; + created: NamedAgentInput[]; + updated: { id: string; input: NamedAgentInput }[]; + removed: string[]; +} { + const created: NamedAgentInput[] = []; + const updated: { id: string; input: NamedAgentInput }[] = []; + const removed: string[] = []; + const ports: NamedAgentPorts = { + create: (async (input) => { + created.push(input); + return { ok: true, agent: { id: `new-${created.length}`, ...input } }; + }) as CreateNamedAgent, + update: (async (id, input) => { + updated.push({ id, input }); + return { ok: true, agent: { id, ...input } }; + }) as UpdateNamedAgent, + remove: (async (id) => { + removed.push(id); + return { ok: true } as const; + }) as DeleteNamedAgent, + debounceMs: 150, + }; + return { ports, created, updated, removed }; +} + +const MODELS = ["opencode-1/claude-sonnet-4.5", "anthropic/sonnet"] as unknown as readonly string[]; + +const DUP_MESSAGE = "A named agent with this name already exists."; + +function renderView() { + const fakes = makePorts(); + render(NamedAgentsView, { props: { agents: [], models: MODELS, ports: fakes.ports } }); + return fakes; +} + +async function addDraft(user: UserEvent): Promise<void> { + await user.click(screen.getByRole("button", { name: "+ Add Named Agent" })); +} + +async function typeName(index: number, name: string): Promise<void> { + const inputs = screen.getAllByLabelText("Named agent name"); + const input = inputs[index]; + if (input === undefined) throw new Error(`No named-agent name input at index ${index}`); + await userEvent.setup().clear(input); + await userEvent.setup().type(input, name); +} + +async function completeDraft(index: number): Promise<void> { + const user = userEvent.setup(); + // Provider first (the model select is disabled until a provider is chosen). + const providerSelect = screen.getAllByLabelText("Named agent provider")[index]; + if (providerSelect === undefined) throw new Error(`No provider select at index ${index}`); + await user.selectOptions(providerSelect, "opencode-1"); + const modelSelect = screen.getAllByLabelText("Named agent model")[index]; + if (modelSelect === undefined) throw new Error(`No model select at index ${index}`); + await user.selectOptions(modelSelect, "claude-sonnet-4.5"); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("NamedAgentsView", () => { + it("a fresh single draft does not self-collide with its own name", async () => { + renderView(); + await addDraft(userEvent.setup()); + await typeName(0, "Coding Agent"); + + // The card is not yet usable (no model) so it shows a model hint, but it + // must NOT report its own draft name as an existing duplicate. + await screen.findByText("Select a provider."); + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + }); + + it("two drafts with DISTINCT names neither self-collide", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Each must see the OTHER's name as an other-name, never its OWN. + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + }); + + it("two drafts with the SAME name are a genuine sibling collision (each flags it)", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Coding Agent"); + + // Duplicate names among DISTINCT rows are invalid — both rows must report it + // (the self is excluded, but the sibling's identical name is a real collision). + expect((await screen.findAllByText(DUP_MESSAGE)).length).toBe(2); + }); + + it("saving one of multiple drafts persists only that row (distinct records)", async () => { + const user = userEvent.setup(); + const fakes = renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Both drafts complete to usable cards → each dispatches its OWN create. + await completeDraft(0); + await completeDraft(1); + + await vi.waitFor(() => expect(fakes.created).toHaveLength(2)); + expect(fakes.created[0]?.name).toBe("Coding Agent"); + expect(fakes.created[1]?.name).toBe("Writer"); + // Each persisted record got its own server-minted id (a single draft must + // not clobber its sibling's reconciling to the same record). + expect(await screen.findAllByText("Saved")).toHaveLength(2); + }); + + it("after a draft persists, a sibling draft does not self-collide and the saved row re-saves on edit", async () => { + const user = userEvent.setup(); + const fakes = renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await completeDraft(0); + await vi.waitFor(() => expect(fakes.created).toHaveLength(1)); + + // Add a second draft with a DIFFERENT name. After row 0 is persisted, this + // still-draft row must not treat its own name as a duplicate. + await addDraft(user); + await typeName(1, "Writer"); + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + + // The saved card's controller now owns a real id → a later edit targets an + // UPDATE of the SAME row (id new-1), never a fresh create. + await typeName(0, "Coding Agent X"); + await vi.waitFor(() => expect(fakes.updated.length).toBeGreaterThanOrEqual(1)); + expect(fakes.updated[0]?.id).toBe("new-1"); + expect(fakes.updated[0]?.input.name).toBe("Coding Agent X"); + // Still exactly one create — the edit reconciled, not re-created. + expect(fakes.created).toHaveLength(1); + }); + + it("removing one of multiple drafts removes only that row", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Remove the SECOND card — the first must survive. + const removeButtons = screen.getAllByRole("button", { name: "Remove named agent" }); + const second = removeButtons[1]; + if (second === undefined) throw new Error("Expected a second Remove button"); + await user.click(second); + + const inputs = await screen.findAllByLabelText("Named agent name"); + expect(inputs).toHaveLength(1); + expect((inputs[0] as HTMLInputElement).value).toBe("Coding Agent"); + }); +}); diff --git a/src/features/named-agents/ui/controller.svelte.ts b/src/features/named-agents/ui/controller.svelte.ts new file mode 100644 index 0000000..51c85b0 --- /dev/null +++ b/src/features/named-agents/ui/controller.svelte.ts @@ -0,0 +1,257 @@ +// Injected shell for Named Agent auto-save: owns the reactive per-card editing +// + save lifecycle (the thin rune wrapper over `logic/auto-save`), the debounce +// timer for name edits, and the monotonic sequence guard that stops an older +// save response clobbering a newer edit. No ambient state: ONE controller per +// card, created by the view and disposed on unmount. The network calls +// (create/update/delete) are INJECTED ports — this module never touches +// fetch/DOM. +// +// The pure model (`logic/auto-save`) does the status/validity reasoning; this +// controller only owns the effectful plumbing + the sequence guard. A live +// `isNameTaken` callback (supplied by the owning view against the CURRENT +// sibling list) keeps the controller from persisting a duplicate name. + +import { + type AgentDraft, + type AgentSaved, + draftModel, + type SaveStatus, + saveStatus, +} from "../logic/auto-save"; +import type { + CreateNamedAgent, + DeleteNamedAgent, + NamedAgent, + NamedAgentInput, + UpdateNamedAgent, +} from "../logic/types"; +import { isUsableModel, normalizeName } from "../logic/validate"; + +/** Named-agent edit port bundle (injected by the composition root). */ +export interface NamedAgentPorts { + create: CreateNamedAgent; + update: UpdateNamedAgent; + remove: DeleteNamedAgent; + /** Debounce delay (ms) for NAME edits only (provider/model saves are immediate). */ + debounceMs?: number; +} + +export interface CardSaveState { + /** The current reactive save status (drives the status line). */ + readonly status: SaveStatus; + /** A save-failure error message, or null. */ + readonly error: string | null; +} + +/** A fully-wired Named Agent card controller (one per card row). */ +export interface CardController { + /** The current reactive status + error. */ + readonly save: CardSaveState; + /** Read the current draft (const snapshots already applied via the setter methods). */ + readonly draft: AgentDraft; + /** Whether the card is currently awaiting a save response. */ + readonly saving: boolean; + /** Bind a provider id into the draft (immediate save once a model is chosen). */ + setProvider(provider: string): void; + /** Bind a model suffix under the current provider (immediate save). */ + setModel(model: string): void; + /** Bind a raw name edit (debounced save when it becomes usable). */ + setName(name: string): void; + /** Remove the Named Agent (for an existing card) — notifies via `onRemoved`. */ + remove(): void; + /** Dispose timers; call on unmount. */ + dispose(): void; +} + +export interface CardControllerOptions { + /** The existing Named Agent this card edits, or null for a new draft. */ + agent: NamedAgent | null; + /** The saved full `<provider>/<model>` ('' for a new draft). */ + savedModel: string; + /** + * Live uniqueness predicate: true when `name` is already taken by a SIBLING + * card (the view EXCLUDES this card's own id). Drives the controller's + * usable-name gate so a duplicate is never persisted. + */ + isNameTaken: (name: string) => boolean; + ports: NamedAgentPorts; + /** Fired with the canonical saved record when a create/update settles. */ + onChanged: (agent: NamedAgent) => void; + /** Fired when the card should leave the list (after a successful remove, or an unsaved draft). */ + onRemoved: () => void; +} + +/** + * A card controller bound to one Named Agent (existing) or a new draft. + * + * The controller owns ONLY this card's draft + save lifecycle; the sibling list + * (for uniqueness + reconciling the authoritative list after a save) is the + * parent view's. `onChanged` lets the parent replace the card's canonical record + * on success; `onRemoved` fires when the card is to be dropped. + */ +export function createCardController(opts: CardControllerOptions): CardController { + const debounceMs = opts.ports.debounceMs ?? 400; + + // ── The draft: raw form controls (name/provider/model) owned reactively. ── + const { agent, savedModel } = opts; + let id = $state<string | null>(agent?.id ?? null); + let name = $state<string>(agent?.name ?? ""); + let provider = $state<string>(savedModel === "" ? "" : (savedModel.split("/")[0] ?? "")); + let model = $state<string>( + savedModel === "" ? "" : savedModel.slice(savedModel.indexOf("/") + 1), + ); + + // ── Saved-state + in-flight/sequence tracking. ── + // `savedName`/`savedModelValue` = the last PERSISTED definition; `error` = + // last save failure. `editSeq` increments on every input change; a save + // captures the seq at dispatch time and applies its result ONLY if it is still + // the latest — an older response can never overwrite a newer edit (§5.4). + let savedName = $state<string>(agent?.name ?? ""); + let savedModelValue = $state<string>(savedModel); + let saving = $state(false); + let error = $state<string | null>(null); + + let editSeq = 0; + let nameTimer: ReturnType<typeof setTimeout> | null = null; + + function currentDraft(): AgentDraft { + return { id, name, provider, model }; + } + + function bump(): number { + return ++editSeq; + } + + function nameUsable(): boolean { + // A usable name is nonempty + not already taken by a sibling card. + const normalized = normalizeName(name); + return normalized !== "" && !opts.isNameTaken(normalized); + } + + function usable(full: string): boolean { + return nameUsable() && isUsableModel(full); + } + + // A save is owed + legal to dispatch right now when the card is usable and + // differs from the persisted definition (or is a fresh, usable draft). + function shouldSaveNow(): boolean { + const d = currentDraft(); + const full = draftModel(d); + if (!usable(full)) return false; + if (id === null && savedName === "" && savedModelValue === "") return true; // fresh draft + return normalizeName(d.name) !== savedName || full !== savedModelValue; + } + + function doSave(seq: number): void { + const d = currentDraft(); + const full = draftModel(d); + if (!usable(full)) return; + saving = true; + error = null; + + function settle(ok: boolean, saved?: NamedAgent, err?: string): void { + // Stale-response guard: only apply if this is still the latest edit. + if (seq !== editSeq) return; + saving = false; + if (ok && saved) { + savedName = saved.name; + savedModelValue = saved.model; + id = saved.id; + opts.onChanged(saved); + } else { + error = err ?? "Save failed"; + } + } + + const input: NamedAgentInput = { name: normalizeName(d.name), model: full }; + if (id === null) { + void opts.ports.create(input).then( + (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)), + (e) => settle(false, undefined, String(e)), + ); + } else { + void opts.ports.update(id, input).then( + (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)), + (e) => settle(false, undefined, String(e)), + ); + } + } + + function scheduleNameSave(): void { + if (nameTimer !== null) clearTimeout(nameTimer); + const seq = editSeq; + nameTimer = setTimeout(() => { + nameTimer = null; + if (shouldSaveNow()) doSave(seq); + }, debounceMs); + } + + function saveImmediately(): void { + if (nameTimer !== null) { + clearTimeout(nameTimer); + nameTimer = null; + } + const seq = editSeq; + if (shouldSaveNow()) doSave(seq); + } + + // Reactive derived status: from the current draft + saved + in-flight + error, + // via the PURE `saveStatus` (this shell supplies only the orchestration inputs). + const save = $derived.by<CardSaveState>(() => { + const d = currentDraft(); + const saved: AgentSaved = { id, name: savedName, model: savedModelValue }; + return { status: saveStatus(d, saved, saving, error), error }; + }); + + return { + get save(): CardSaveState { + return save; + }, + get draft(): AgentDraft { + return currentDraft(); + }, + get saving(): boolean { + return saving; + }, + setProvider(next: string): void { + bump(); + provider = next; + // Changing provider clears the prior model if it may not belong to the new + // one; the model dropdown re-populates from the new provider. Persist only + // once a valid model is selected (§5.5) → the model change saves immediately. + model = ""; + }, + setModel(next: string): void { + bump(); + model = next; + saveImmediately(); + }, + setName(next: string): void { + bump(); + name = next; + scheduleNameSave(); + }, + remove(): void { + if (id === null) { + // An unsaved draft — nothing server-side to delete. + opts.onRemoved(); + return; + } + saving = true; + void opts.ports.remove(id).then( + () => { + saving = false; + opts.onRemoved(); + }, + (e) => { + saving = false; + error = String(e); + }, + ); + }, + dispose(): void { + if (nameTimer !== null) clearTimeout(nameTimer); + nameTimer = null; + }, + }; +} diff --git a/src/features/named-agents/ui/controller.test.ts b/src/features/named-agents/ui/controller.test.ts new file mode 100644 index 0000000..1655a31 --- /dev/null +++ b/src/features/named-agents/ui/controller.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { NamedAgent, NamedAgentInput, NamedAgentSaveResult } from "../logic/types"; +import { createCardController, type NamedAgentPorts } from "./controller.svelte"; + +function fakePorts(overrides?: { + create?: NamedAgentPorts["create"]; + update?: NamedAgentPorts["update"]; + remove?: NamedAgentPorts["remove"]; + debounceMs?: number; +}): { + ports: NamedAgentPorts; + created: NamedAgentInput[]; + updated: Array<{ id: string; input: NamedAgentInput }>; + removed: string[]; +} { + const created: NamedAgentInput[] = []; + const updated: Array<{ id: string; input: NamedAgentInput }> = []; + const removed: string[] = []; + const settle = (r: NamedAgentSaveResult) => Promise.resolve(r); + return { + ports: { + create: + overrides?.create ?? + ((input) => { + created.push(input); + return settle({ ok: true, agent: { id: `new-${created.length}`, ...input } }); + }), + update: + overrides?.update ?? + ((id, input) => { + updated.push({ id, input }); + return settle({ ok: true, agent: { id, ...input } }); + }), + remove: + overrides?.remove ?? + ((id) => { + removed.push(id); + return Promise.resolve({ ok: true } as const); + }), + debounceMs: overrides?.debounceMs ?? 0, + } as NamedAgentPorts, + created, + updated, + removed, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("createCardController", () => { + it("creates a new draft when it becomes fully usable and the model is set", async () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const changed: NamedAgent[] = []; + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: (a) => changed.push(a), + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + c.setProvider("opencode-1"); + c.setModel("claude-sonnet-4.5"); + // The model setter saves immediately (the create is dispatched synchronously). + expect(created).toHaveLength(1); + expect(created[0]).toEqual({ name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }); + // The canonical record + onChanged land after the create promise resolves. + await vi.runAllTimersAsync(); + expect(changed).toHaveLength(1); + expect(changed[0]?.id).toBe("new-1"); + c.dispose(); + }); + + it("does not create with only a name (needs a provider + model)", () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + vi.advanceTimersByTime(500); + expect(created).toHaveLength(0); + c.dispose(); + }); + + it("does not persist a name that is already taken", () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: (name) => name.toLowerCase() === "coding agent", + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + c.setProvider("opencode-1"); + c.setModel("claude-sonnet-4.5"); + expect(created).toHaveLength(0); + c.dispose(); + }); + + it("updates an existing card and reports Saved once clean", async () => { + vi.useFakeTimers(); + const existing: NamedAgent = { + id: "a1", + name: "Coding", + model: "opencode-1/claude-sonnet-4.5", + }; + const { ports, updated } = fakePorts({ debounceMs: 100 }); + const c = createCardController({ + agent: existing, + savedModel: existing.model, + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + expect(c.save.status).toBe("saved"); + c.setName("Coding X"); + // Within the debounce window the edit is unsaved → "dirty". + await vi.advanceTimersByTimeAsync(20); + expect(c.save.status).toBe("dirty"); + // Past the debounce the name save fires → the update is dispatched. + await vi.advanceTimersByTimeAsync(200); + expect(updated).toHaveLength(1); + expect(updated[0]?.id).toBe("a1"); + expect(updated[0]?.input.name).toBe("Coding X"); + // After the update promise resolves the card reads clean → "saved". + await vi.runAllTimersAsync(); + expect(c.save.status).toBe("saved"); + c.dispose(); + }); + + it("marks the status as error and keeps the draft on save failure", async () => { + vi.useFakeTimers(); + const { ports } = fakePorts({ + create: () => Promise.resolve({ ok: false, error: "duplicate name" as const }), + }); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding"); + c.setProvider("opencode-1"); + c.setModel("claude"); + await vi.runAllTimersAsync(); + expect(c.save.status).toBe("error"); + expect(c.save.error).toBe("duplicate name"); + expect(c.draft.name).toBe("Coding"); + c.dispose(); + }); + + it("guards stale responses: a newer edit is not overwritten by an older save result", async () => { + vi.useFakeTimers(); + const pending: { resolve: ((r: NamedAgentSaveResult) => void) | null } = { resolve: null }; + const { ports } = fakePorts({ + create: () => new Promise<NamedAgentSaveResult>((res) => (pending.resolve = res)), + }); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + // Kick a save in flight, then edit again before it resolves. + c.setName("A"); + c.setProvider("opencode-1"); + c.setModel("claude"); + c.setName("B"); // sequence bumps past the in-flight save + expect(pending.resolve).not.toBeNull(); + pending.resolve?.({ ok: true, agent: { id: "x", name: "A", model: "opencode-1/claude" } }); + await vi.runAllTimersAsync(); + // The stale "A" create must NOT have applied the old result (saving cleared, + // but the draft still reads B and no onChanged with "A"). + expect(c.draft.name).toBe("B"); + c.dispose(); + }); + + it("remove() on an existing card calls remove and drops via onRemoved", async () => { + vi.useFakeTimers(); + const existing: NamedAgent = { + id: "a1", + name: "Coding", + model: "opencode-1/claude", + }; + const { ports, removed } = fakePorts(); + let removedCard = false; + const c = createCardController({ + agent: existing, + savedModel: existing.model, + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => (removedCard = true), + }); + c.remove(); + await vi.runAllTimersAsync(); + expect(removed).toEqual(["a1"]); + expect(removedCard).toBe(true); + c.dispose(); + }); + + it("remove() on an unsaved draft drops it without a server call", () => { + vi.useFakeTimers(); + const { ports, removed } = fakePorts(); + let removedCard = false; + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => (removedCard = true), + }); + c.remove(); + expect(removed).toHaveLength(0); + expect(removedCard).toBe(true); + c.dispose(); + }); +}); diff --git a/src/features/plugins/adapter/http.test.ts b/src/features/plugins/adapter/http.test.ts new file mode 100644 index 0000000..b1fe477 --- /dev/null +++ b/src/features/plugins/adapter/http.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginsHttp } from "./http"; + +/** A stub fetch that records requests and replies from a handler. */ +function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) { + const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return await handler(url, init); + }); + return impl as unknown as typeof fetch; +} + +const okJson = (body: unknown): Response => + ({ ok: true, status: 200, json: async () => body }) as unknown as Response; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createPluginsHttp — manager routes (plan 05)", () => { + it("GET /plugins returns the parsed list (null on failure)", async () => { + const fetch1 = stubFetch(() => + okJson({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }), + ); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.list()).resolves.toEqual({ + loaded: [], + disabledWithReason: [], + discovered: [], + missing: [], + }); + + const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response); + await expect(createPluginsHttp("http://x", fetch2).list()).resolves.toBeNull(); + }); + + it("POST /plugins/scan posts to the scan route", async () => { + let seenUrl = ""; + let seenInit: RequestInit | undefined; + const fetch1 = stubFetch((url, init) => { + seenUrl = url; + seenInit = init; + return okJson({ root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }); + }); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.scan(); + expect(result.ok).toBe(true); + expect(seenUrl).toBe("http://x/plugins/scan"); + expect(seenInit?.method).toBe("POST"); + }); + + it("POST /plugins/:id/unload sends the force flag in the body", async () => { + let seenBody: string | undefined; + const fetch1 = stubFetch((_url, init) => { + seenBody = init?.body as string; + return okJson({ ok: true, action: "unload", id: "mcp" }); + }); + const http = createPluginsHttp("http://x", fetch1); + await http.unload("mcp", true); + expect(JSON.parse(seenBody ?? "{}")).toEqual({ force: true }); + }); + + it("maps a blocked mutation (409) to ok:false with reason + dependents", async () => { + const fetch1 = stubFetch( + () => + ({ + ok: false, + status: 409, + json: async () => ({ + ok: false, + action: "unload", + id: "heartbeat", + reason: "cascade-needs-force", + message: "blocked", + dependents: ["transport-http"], + }), + }) as unknown as Response, + ); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.unload("heartbeat", false); + expect(result).toEqual({ + ok: false, + error: "blocked", + reason: "cascade-needs-force", + dependents: ["transport-http"], + }); + }); + + it("load and reload hit their routes with the encoded id", async () => { + const urls: string[] = []; + const fetch1 = stubFetch((url) => { + urls.push(url); + return okJson({ ok: true, action: "load", id: "a/b" }); + }); + const http = createPluginsHttp("http://x", fetch1); + await http.load("a/b"); + await http.reload("a/b"); + expect(urls).toEqual(["http://x/plugins/a%2Fb/load", "http://x/plugins/a%2Fb/reload"]); + }); +}); + +describe("createPluginsHttp — workspace routes (plan 06 §6.6)", () => { + it("GET /workspaces/:id/plugins returns the parsed response (null on failure)", async () => { + const fetch1 = stubFetch(() => + okJson({ workspaceId: "ws", plugins: [], enabledProviderCount: 2 }), + ); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.workspacePlugins("ws")).resolves.toEqual({ + workspaceId: "ws", + plugins: [], + enabledProviderCount: 2, + }); + + const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response); + await expect(createPluginsHttp("http://x", fetch2).workspacePlugins("ws")).resolves.toBeNull(); + }); + + it("PUT /workspaces/:id/plugins/:pluginId sends the state body", async () => { + let seenUrl = ""; + let seenBody: string | undefined; + const fetch1 = stubFetch((url, init) => { + seenUrl = url; + seenBody = init?.body as string; + return okJson({ workspaceId: "ws", pluginId: "mcp", state: "enabled" }); + }); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.setWorkspaceState("ws", "mcp", "enabled"); + expect(result.ok).toBe(true); + expect(seenUrl).toBe("http://x/workspaces/ws/plugins/mcp"); + expect(JSON.parse(seenBody ?? "{}")).toEqual({ state: "enabled" }); + }); + + it("maps a 409 refusal (core-locked) to ok:false with the reason", async () => { + const fetch1 = stubFetch( + () => + ({ + ok: false, + status: 409, + json: async () => ({ + workspaceId: "ws", + pluginId: "kernel", + reason: "core-locked", + message: "core is locked", + }), + }) as unknown as Response, + ); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.setWorkspaceState("ws", "kernel", "disabled"); + expect(result).toEqual({ ok: false, error: "core is locked", reason: "core-locked" }); + }); + + it("GET providers-status returns the parsed response", async () => { + let seenUrl = ""; + const fetch1 = stubFetch((url) => { + seenUrl = url; + return okJson({ workspaceId: "ws", enabledProviderCount: 0 }); + }); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.providersStatus("ws")).resolves.toEqual({ + workspaceId: "ws", + enabledProviderCount: 0, + }); + expect(seenUrl).toBe("http://x/workspaces/ws/plugins/providers-status"); + }); + + it("network throws degrade reads to null and mutations to ok:false", async () => { + const boom = vi.fn(async () => { + throw new Error("down"); + }) as unknown as typeof fetch; + const http = createPluginsHttp("http://x", boom); + await expect(http.list()).resolves.toBeNull(); + await expect(http.providersStatus("ws")).resolves.toBeNull(); + await expect(http.setWorkspaceState("ws", "mcp", "enabled")).resolves.toMatchObject({ + ok: false, + }); + }); +}); diff --git a/src/features/plugins/adapter/http.ts b/src/features/plugins/adapter/http.ts new file mode 100644 index 0000000..7814f4f --- /dev/null +++ b/src/features/plugins/adapter/http.ts @@ -0,0 +1,230 @@ +import type { + PluginListResponse, + PluginOpResponse, + PluginScanResponse, + SetWorkspacePluginStateBlocked, + SetWorkspacePluginStateRequest, + SetWorkspacePluginStateResponse, + WorkspacePluginProvidersStatusResponse, + WorkspacePluginsResponse, +} from "@dispatch/transport-contract"; + +/** + * Plugin HTTP effects — the injected edge that talks to the backend's plugin + * manager routes (plan 05) + per-workspace enablement routes (plan 06 §6.6). + * Mirrors the workspaces adapter's pattern: `httpBase` + an injected + * `fetchImpl` (testable without the network); reads return data or a safe + * `null` on failure (non-fatal), mutations return a typed result so a caller + * can surface the backend's `{ reason, message }` block. + * + * Endpoints: + * - `GET /plugins` → {@link PluginListResponse} (the two manager tables + missing strip) + * - `POST /plugins/scan` → {@link PluginScanResponse} (manual scan; never auto-loads) + * - `POST /plugins/:id/load` → {@link PluginOpResponse} + * - `POST /plugins/:id/unload` body `{ force }` → {@link PluginOpResponse} + * - `POST /plugins/:id/reload` → {@link PluginOpResponse} + * - `GET /workspaces/:id/plugins` → {@link WorkspacePluginsResponse} (READ triggers + * read-through materialization) + * - `PUT /workspaces/:id/plugins/:pluginId` body `{ state }` → + * {@link SetWorkspacePluginStateResponse} (409 `core-locked` / `unloaded`) + * - `GET /workspaces/:id/plugins/providers-status` → + * {@link WorkspacePluginProvidersStatusResponse} (zero-enabled-providers guardrail) + */ + +/** Outcome of a plugin-manager mutation (load / unload / reload). */ +export type PluginOpResult = + | { readonly ok: true; readonly response: Extract<PluginOpResponse, { ok: true }> } + | { + readonly ok: false; + readonly error: string; + readonly reason?: string; + /** For `cascade-needs-force`: the loaded dependents a force would remove. */ + readonly dependents?: readonly string[]; + }; + +/** Outcome of a per-workspace state write (`PUT .../plugins/:pluginId`). */ +export type WorkspaceStateResult = + | { readonly ok: true; readonly response: SetWorkspacePluginStateResponse } + | { readonly ok: false; readonly error: string; readonly reason?: string }; + +/** Outcome of the manual scan (`POST /plugins/scan`). */ +export type PluginScanResult = + | { readonly ok: true; readonly response: PluginScanResponse } + | { readonly ok: false; readonly error: string }; + +export interface PluginsHttp { + list(): Promise<PluginListResponse | null>; + scan(): Promise<PluginScanResult>; + load(id: string): Promise<PluginOpResult>; + unload(id: string, force: boolean): Promise<PluginOpResult>; + reload(id: string): Promise<PluginOpResult>; + /** Read one workspace's effective plugin states (triggers materialization). */ + workspacePlugins(workspaceId: string): Promise<WorkspacePluginsResponse | null>; + setWorkspaceState( + workspaceId: string, + pluginId: string, + state: "enabled" | "disabled" | "unset", + ): Promise<WorkspaceStateResult>; + /** The zero-enabled-providers signal for the active workspace (§6.5). */ + providersStatus(workspaceId: string): Promise<WorkspacePluginProvidersStatusResponse | null>; +} + +async function errorBody(res: Response): Promise<{ + error?: string; + reason?: string; + dependents?: readonly string[]; + message?: string; +}> { + try { + return (await res.json()) as { + error?: string; + reason?: string; + dependents?: readonly string[]; + message?: string; + }; + } catch { + return {}; + } +} + +/** + * Map a plugin-manager block/error response to a `PluginOpResult`. The + * backend's blocked shape is `{ ok: false, reason, message, dependents? }`; + * other failures degrade to `{ error }` only. + */ +async function opResult(res: Response): Promise<PluginOpResult> { + const body = await errorBody(res); + const message = body.message ?? body.error ?? `HTTP ${res.status}`; + return { + ok: false, + error: message, + ...(body.reason !== undefined ? { reason: body.reason } : {}), + ...(body.dependents !== undefined ? { dependents: body.dependents } : {}), + }; +} + +export function createPluginsHttp(httpBase: string, fetchImpl: typeof fetch): PluginsHttp { + const post = (path: string, body?: unknown): Promise<Response> => + fetchImpl(`${httpBase}${path}`, { + method: "POST", + ...(body !== undefined + ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } + : {}), + }); + + return { + async list(): Promise<PluginListResponse | null> { + try { + const res = await fetchImpl(`${httpBase}/plugins`); + if (!res.ok) return null; + return (await res.json()) as PluginListResponse; + } catch { + return null; + } + }, + + async scan(): Promise<PluginScanResult> { + try { + const res = await post("/plugins/scan"); + if (!res.ok) + return { ok: false, error: (await errorBody(res)).error ?? `HTTP ${res.status}` }; + return { ok: true, response: (await res.json()) as PluginScanResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Scan request failed" }; + } + }, + + async load(id): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/load`); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Load request failed" }; + } + }, + + async unload(id, force): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/unload`, { force }); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unload request failed" }; + } + }, + + async reload(id): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/reload`); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Reload request failed" }; + } + }, + + async workspacePlugins(workspaceId): Promise<WorkspacePluginsResponse | null> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins`, + ); + if (!res.ok) return null; + return (await res.json()) as WorkspacePluginsResponse; + } catch { + return null; + } + }, + + async setWorkspaceState(workspaceId, pluginId, state): Promise<WorkspaceStateResult> { + const body: SetWorkspacePluginStateRequest = { state }; + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/${encodeURIComponent(pluginId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const blocked = (await errorBody(res)) as Partial<SetWorkspacePluginStateBlocked> & { + error?: string; + }; + return { + ok: false, + error: blocked.message ?? blocked.error ?? `HTTP ${res.status}`, + ...(blocked.reason !== undefined ? { reason: blocked.reason } : {}), + }; + } + return { ok: true, response: (await res.json()) as SetWorkspacePluginStateResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set plugin state request failed", + }; + } + }, + + async providersStatus(workspaceId): Promise<WorkspacePluginProvidersStatusResponse | null> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`, + ); + if (!res.ok) return null; + return (await res.json()) as WorkspacePluginProvidersStatusResponse; + } catch { + return null; + } + }, + }; +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts new file mode 100644 index 0000000..f79ed98 --- /dev/null +++ b/src/features/plugins/index.ts @@ -0,0 +1,24 @@ +export type { + PluginOpResult, + PluginScanResult, + PluginsHttp, + WorkspaceStateResult, +} from "./adapter/http"; +export { createPluginsHttp } from "./adapter/http"; +export { computeCascade } from "./logic/cascade"; +export type { HighlightSets } from "./logic/highlight"; +export { dependencyHighlightSets } from "./logic/highlight"; +export { isLastProvider, unloadBlockReason } from "./logic/last-provider"; +export { autoCheckBackendPrereqs } from "./logic/prereqs"; +export type { + PluginNode, + PluginSection, + WorkspaceLayoutPort, +} from "./logic/types"; +export { TIER_ORDER, tierLabel } from "./logic/types"; +export { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./logic/view-model"; +export { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./logic/workspace-row"; +export type { PluginsStore, WorkspacePluginsView } from "./store.svelte"; +export { createPluginsStore } from "./store.svelte"; +export { default as PluginManager } from "./ui/PluginManager.svelte"; +export { default as WorkspacePluginModal } from "./ui/WorkspacePluginModal.svelte"; diff --git a/src/features/plugins/logic/cascade.test.ts b/src/features/plugins/logic/cascade.test.ts new file mode 100644 index 0000000..109c08b --- /dev/null +++ b/src/features/plugins/logic/cascade.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { computeCascade } from "./cascade"; +import { node } from "./test-fixtures"; + +describe("computeCascade", () => { + it("returns [] when the target has no dependents", () => { + const nodes = [node({ id: "a" }), node({ id: "b" })]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("returns the direct loaded dependents", () => { + const nodes = [ + node({ id: "heartbeat" }), + node({ id: "transport-http", dependsOn: ["heartbeat"] }), + ]; + expect(computeCascade("heartbeat", nodes)).toEqual(["transport-http"]); + }); + + it("is transitive (dependent of dependent)", () => { + const nodes = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual(["b", "c"]); + }); + + it("does NOT traverse through an UNLOADED intermediate (loaded-subgraph closure — server mirror)", () => { + // c depends on b, b is unloaded: unloading a cascades to b only (b is not + // collected — unloaded) and does NOT reach c (its dep chain is already unmet). + const nodes = [ + node({ id: "a" }), + node({ id: "b", loaded: false, section: "discovered", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("ignores plugins that merely share a dependency (direction matters)", () => { + const nodes = [node({ id: "dep" }), node({ id: "x", dependsOn: ["dep"] })]; + expect(computeCascade("x", nodes)).toEqual([]); + }); + + it("handles diamond dependencies without duplicates", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "left", dependsOn: ["root"] }), + node({ id: "right", dependsOn: ["root"] }), + node({ id: "bottom", dependsOn: ["left", "right"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["bottom", "left", "right"]); + }); + + it("returns [] for a target that is not in the list", () => { + expect(computeCascade("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("is deterministic (alphabetical order)", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "zeta", dependsOn: ["root"] }), + node({ id: "alpha", dependsOn: ["root"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/src/features/plugins/logic/cascade.ts b/src/features/plugins/logic/cascade.ts new file mode 100644 index 0000000..0cc6834 --- /dev/null +++ b/src/features/plugins/logic/cascade.ts @@ -0,0 +1,42 @@ +import type { PluginNode } from "./types"; + +/** + * The transitive set of LOADED dependents that would also be unloaded if + * `targetId` were unloaded — the §8 cascade-uncheck warning's list ("unloading + * X also unchecks Y, Z"). Client mirror of the backend's + * `computeUnloadCascade` (plan 05): the reverse-dependency closure computed + * over the LOADED subgraph only (an unloaded intermediate breaks the chain — + * a loaded dependent behind it is already unmet and is not part of THIS + * cascade). The server still enforces the real cascade; the FE computes the + * same set to warn before the request goes out. + * + * Pure input → output; deterministic order (alphabetical by id) so tests and + * renderings are stable. + */ +export function computeCascade(targetId: string, nodes: readonly PluginNode[]): readonly string[] { + // Reverse edges within the loaded subgraph: dep id → loaded dependent ids. + const dependentsOf = new Map<string, string[]>(); + for (const node of nodes) { + if (!node.loaded) continue; + for (const dep of node.dependsOn) { + const list = dependentsOf.get(dep); + if (list) list.push(node.id); + else dependentsOf.set(dep, [node.id]); + } + } + + // BFS over reverse edges, collecting every reached loaded dependent. + const out: string[] = []; + const seen = new Set<string>([targetId]); + const queue = [targetId]; + while (queue.length > 0) { + const current = queue.shift() as string; + for (const dependent of dependentsOf.get(current) ?? []) { + if (seen.has(dependent)) continue; + seen.add(dependent); + out.push(dependent); + queue.push(dependent); + } + } + return out.sort((a, b) => a.localeCompare(b)); +} diff --git a/src/features/plugins/logic/highlight.test.ts b/src/features/plugins/logic/highlight.test.ts new file mode 100644 index 0000000..616f7ad --- /dev/null +++ b/src/features/plugins/logic/highlight.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { dependencyHighlightSets } from "./highlight"; +import { node } from "./test-fixtures"; + +describe("dependencyHighlightSets", () => { + const nodes = [ + node({ id: "lib", loaded: false, section: "discovered" }), + node({ id: "mid", dependsOn: ["lib"] }), + node({ id: "app", dependsOn: ["mid", "lib"] }), + ]; + + it("returns empty sets for no hover", () => { + const sets = dependencyHighlightSets(null, nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering an item highlights its DIRECT dependencies (one hop)", () => { + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps).toEqual(new Set(["mid", "lib"])); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering a dependency highlights its dependents (both directions work)", () => { + const sets = dependencyHighlightSets("lib", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents).toEqual(new Set(["mid", "app"])); + }); + + it("hovering a middle node highlights BOTH directions", () => { + const sets = dependencyHighlightSets("mid", nodes); + expect(sets.deps).toEqual(new Set(["lib"])); + expect(sets.dependents).toEqual(new Set(["app"])); + }); + + it("is one hop — not the transitive closure", () => { + // app → mid → lib: hovering app highlights mid + lib (both direct deps), + // but hovering lib highlights only DIRECT dependents (mid, app) — already + // one hop. The one-hop rule shows in: a plugin two hops away via an + // unrelated chain is not highlighted. + const chain = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(dependencyHighlightSets("c", chain).deps).toEqual(new Set(["b"])); + }); + + it("returns empty sets for an unknown hovered id", () => { + const sets = dependencyHighlightSets("ghost", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("works across sections (a loaded row highlights an unloaded dependency)", () => { + // `app` loaded, `lib` discovered: hovering app still highlights lib. + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps.has("lib")).toBe(true); + }); +}); diff --git a/src/features/plugins/logic/highlight.ts b/src/features/plugins/logic/highlight.ts new file mode 100644 index 0000000..1bcfaad --- /dev/null +++ b/src/features/plugins/logic/highlight.ts @@ -0,0 +1,37 @@ +import type { PluginNode } from "./types"; + +/** The two highlight sets for the §8 bidirectional dependency hover. */ +export interface HighlightSets { + /** The hovered row's DIRECT dependencies (highlighted when hovering an item). */ + readonly deps: ReadonlySet<string>; + /** The loaded/unloaded rows that DIRECTLY depend on the hovered row + * (highlighted when hovering a dependency). */ + readonly dependents: ReadonlySet<string>; +} + +const EMPTY: HighlightSets = { deps: new Set(), dependents: new Set() }; + +/** + * Bidirectional hover highlighting (§8): hover any item → its DIRECT + * dependencies highlight; hover a dependency → its dependents highlight. + * One hop in each direction (the plan's grammar is "its dependencies" / + * "its dependents" — the visual neighborhood, not the transitive closure; + * the transitive closures live in `computeCascade` / `autoCheckBackendPrereqs`). + * + * Works across BOTH manager tables (a loaded row can highlight an unloaded + * dependency row and vice versa) — the node list spans the whole catalog. + * Pure input → output; `hoveredId` null (no hover) → both sets empty. + */ +export function dependencyHighlightSets( + hoveredId: string | null, + nodes: readonly PluginNode[], +): HighlightSets { + if (hoveredId === null) return EMPTY; + const hovered = nodes.find((n) => n.id === hoveredId); + if (hovered === undefined) return EMPTY; + const deps = new Set(hovered.dependsOn); + const dependents = new Set( + nodes.filter((n) => n.id !== hoveredId && n.dependsOn.includes(hoveredId)).map((n) => n.id), + ); + return { deps, dependents }; +} diff --git a/src/features/plugins/logic/last-provider.test.ts b/src/features/plugins/logic/last-provider.test.ts new file mode 100644 index 0000000..f85ba89 --- /dev/null +++ b/src/features/plugins/logic/last-provider.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { isLastProvider, unloadBlockReason } from "./last-provider"; +import { node } from "./test-fixtures"; + +describe("isLastProvider", () => { + it("is true for a loaded provider when it is the only one", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + expect(isLastProvider("opencode", nodes)).toBe(true); + }); + + it("is false for a loaded provider when another provider is loaded", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + ]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for a non-provider target (even when alone)", () => { + const nodes = [node({ id: "solo", tier: "standard" })]; + expect(isLastProvider("solo", nodes)).toBe(false); + }); + + it("is false for an UNLOADED provider", () => { + const nodes = [node({ id: "opencode", tier: "providers", loaded: false })]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for an unknown id", () => { + expect(isLastProvider("ghost", [])).toBe(false); + }); + + it("is false for a core-only list (no providers at all)", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(isLastProvider("kernel", nodes)).toBe(false); + }); +}); + +describe("unloadBlockReason", () => { + it("explains the core lock", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + const reason = unloadBlockReason("kernel", nodes); + expect(reason).toContain("Core plugins cannot be unloaded"); + }); + + it("explains the last-provider lock", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + const reason = unloadBlockReason("opencode", nodes); + expect(reason).toContain("last provider"); + }); + + it("is null for an interactive row", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + node({ id: "app" }), + ]; + expect(unloadBlockReason("app", nodes)).toBeNull(); + expect(unloadBlockReason("opencode", nodes)).toBeNull(); + }); + + it("prefers the core lock when both would apply", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(unloadBlockReason("kernel", nodes)).toContain("Core"); + }); + + it("is null for an unknown id", () => { + expect(unloadBlockReason("ghost", [])).toBeNull(); + }); +}); diff --git a/src/features/plugins/logic/last-provider.ts b/src/features/plugins/logic/last-provider.ts new file mode 100644 index 0000000..279df4d --- /dev/null +++ b/src/features/plugins/logic/last-provider.ts @@ -0,0 +1,31 @@ +import type { PluginNode } from "./types"; + +/** + * Whether unloading `targetId` would remove the LAST loaded provider — the + * §8 hard lock (the one unload the server refuses outright, never force-able). + * The UI disables the control with an explanatory tooltip; the server still + * enforces the rule. Pure input → output. + */ +export function isLastProvider(targetId: string, nodes: readonly PluginNode[]): boolean { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined || !target.loaded || target.tier !== "providers") return false; + const loadedProviders = nodes.filter((n) => n.loaded && n.tier === "providers"); + return loadedProviders.length <= 1; +} + +/** + * Why the row's load/unload control is disabled, or null when it is + * interactive. Composes the two client-mirrored locks: + * - tier "core" (`locked`) → the server refuses to unload it (§3/§5); + * - the last loaded provider → the §8 hard lock. + * Missing rows have no control at all (handled separately by the UI). + */ +export function unloadBlockReason(targetId: string, nodes: readonly PluginNode[]): string | null { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined) return null; + if (target.locked) return "Core plugins cannot be unloaded — they are part of the runtime."; + if (isLastProvider(targetId, nodes)) { + return "Cannot unload the last provider — Dispatch needs at least one to pick models."; + } + return null; +} diff --git a/src/features/plugins/logic/prereqs.test.ts b/src/features/plugins/logic/prereqs.test.ts new file mode 100644 index 0000000..704399c --- /dev/null +++ b/src/features/plugins/logic/prereqs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { autoCheckBackendPrereqs } from "./prereqs"; +import { node } from "./test-fixtures"; + +describe("autoCheckBackendPrereqs", () => { + it("returns [] when all dependencies are already loaded", () => { + const nodes = [node({ id: "dep" }), node({ id: "app", dependsOn: ["dep"] })]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual([]); + }); + + it("returns the unloaded direct dependencies (to auto-check)", () => { + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "other", loaded: false, section: "discovered" }), + node({ id: "loaded-dep" }), + node({ id: "app", dependsOn: ["dep", "other", "loaded-dep"] }), + ]; + // Only the UNLOADED deps are returned (loaded-dep needs no auto-check). + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["dep", "other"]); + }); + + it("is transitive (dep of a dep)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "mid", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["mid"] }), + ]; + // Load order: dependencies BEFORE their dependents. + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["mid", "base"]); + }); + + it("is one-directional: it never pulls DEPENDENTS in (no reverse traversal)", () => { + // Checking `dep` must not auto-check `app` (app depends on dep, not vice versa). + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "app", dependsOn: ["dep"] }), + ]; + expect(autoCheckBackendPrereqs("dep", nodes)).toEqual([]); + }); + + it("excludes the target itself and skips already-seen nodes (diamond)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "left", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "right", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["left", "right"] }), + ]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["left", "right", "base"]); + }); + + it("returns [] for an unknown target", () => { + expect(autoCheckBackendPrereqs("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("returns [] for a target with no dependencies", () => { + expect(autoCheckBackendPrereqs("app", [node({ id: "app" })])).toEqual([]); + }); +}); diff --git a/src/features/plugins/logic/prereqs.ts b/src/features/plugins/logic/prereqs.ts new file mode 100644 index 0000000..9066413 --- /dev/null +++ b/src/features/plugins/logic/prereqs.ts @@ -0,0 +1,45 @@ +import type { PluginNode } from "./types"; + +/** + * When CHECKING (loading) `targetId`, the transitive set of its dependencies + * that are NOT currently loaded — the plugins that must be auto-checked + * (loaded) first (§8: "Checking a frontend plugin auto-checks its required + * backend plugins (transitively)"). + * + * One-directional by construction: only `dependsOn` edges FROM the target are + * followed (frontend→backend / backend→backend). The reverse direction is + * NEVER traversed here — backend plugins never depend on frontend ones (§8), + * so a dependency auto-check never pulls the other way. + * + * Returns the ids in deterministic load order (dependencies before their + * dependents — alphabetical within a level) so the caller can issue the load + * requests in a safe sequence. Pure input → output. + */ +export function autoCheckBackendPrereqs( + targetId: string, + nodes: readonly PluginNode[], +): readonly string[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + + // BFS over dependency edges (target → deps), collecting UNLOADED deps. + const out: string[] = []; + const seen = new Set<string>([targetId]); + // Queue of [id, depth] — sorted per level so ties resolve alphabetically. + let frontier: string[] = [targetId]; + while (frontier.length > 0) { + const next: string[] = []; + for (const id of frontier) { + const node = byId.get(id); + for (const dep of node?.dependsOn ?? []) { + if (seen.has(dep)) continue; + seen.add(dep); + next.push(dep); + if (!byId.get(dep)?.loaded) out.push(dep); + } + } + frontier = next.sort((a, b) => a.localeCompare(b)); + } + // Load order: dependencies before dependents (BFS level order already is), + // alphabetical within a level. + return out; +} diff --git a/src/features/plugins/logic/test-fixtures.ts b/src/features/plugins/logic/test-fixtures.ts new file mode 100644 index 0000000..e7b3f5d --- /dev/null +++ b/src/features/plugins/logic/test-fixtures.ts @@ -0,0 +1,30 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { PluginNode } from "./types"; +import { pluginNodes } from "./view-model"; + +/** + * Build a PluginNode directly (the graph-logic tests' fixture). Defaults: + * loaded, standard tier, no deps. + */ +export function node(overrides: Partial<PluginNode> = {}): PluginNode { + return { + id: "p", + name: "p", + version: "1.0.0", + tier: "standard", + trust: "bundled", + loaded: true, + locked: false, + dependsOn: [], + reason: null, + section: "loaded", + ...overrides, + }; +} + +/** An empty `GET /plugins` response. */ +export function emptyListResponse(): PluginListResponse { + return { loaded: [], disabledWithReason: [], discovered: [], missing: [] }; +} + +export { pluginNodes }; diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts new file mode 100644 index 0000000..e64b553 --- /dev/null +++ b/src/features/plugins/logic/types.ts @@ -0,0 +1,99 @@ +import type { PluginTier, PluginTrustLevel } from "@dispatch/transport-contract"; + +/** Which manager table a plugin row belongs to (the response's row kinds). */ +export type PluginSection = "loaded" | "discovered" | "missing"; + +/** + * The manager's normalized view of one plugin — a single shape across the + * `GET /plugins` response's row kinds (loaded / disabledWithReason / + * discovered / missing) so the pure graph logic (cascade / prereqs / + * highlight / last-provider) and the manager tables work over ONE node type. + * Built by `pluginNodes` (`logic/view-model`) from a `PluginListResponse`. + */ +export interface PluginNode { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + /** Trust level, when the row carries one (missing rows don't). */ + readonly trust: PluginTrustLevel | null; + /** Loaded and live — the manager's checkbox reflects this. */ + readonly loaded: boolean; + /** True for tier "core" — the server refuses to unload it (locked control). */ + readonly locked: boolean; + /** Declared dependencies (plugin ids), possibly empty. */ + readonly dependsOn: readonly string[]; + /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */ + readonly reason: string | null; + /** + * The frontend-plugin (plan 03) issue line for the manager row, when the + * plugin's FE half is incompatible ("incompatible frontend plugin") or + * failed (`frontend failed: <error>`). Absent = the FE half is fine (or + * the plugin declares no frontend section). + */ + readonly frontendIssue?: string; + readonly section: PluginSection; +} + +/** Display labels for the classification tiers (backend-canonical values). */ +export function tierLabel(tier: PluginTier): string { + switch (tier) { + case "core": + return "Core"; + case "providers": + return "Provider"; + case "standard": + return "Standard"; + } +} + +/** Display order of the tiers within a manager table (Core first). */ +export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"]; + +/** + * The layout port (plan 04 §4.4) the workspace plugins modal needs for the + * "Reset layout to default" affordance — the modal is the natural home (it + * is where users manage the layout plugins themselves). Wired by the + * composition root over the layout store; absent → the section doesn't + * render (tests). + */ +export interface WorkspaceLayoutPort { + /** + * Whether a layout provider is active (the reset button's gate). False → + * the button is DISABLED with the reason shown ("no layout plugin + * enabled") — there is nothing to reset and no persistence to re-seed. + */ + readonly providerActive: boolean; + /** + * Reset the workspace's layout to the default seed (`DELETE + * /workspaces/:id/layout` → re-seed → broadcast). Resolves to `null` on + * success or to the failure reason to display. + */ + readonly reset: (workspaceId: string) => Promise<string | null>; + /** + * The workspace's PER-WORKSPACE PLUGIN STATE changed (the modal just + * toggled a plugin's enablement): the layout plugin that serves THIS + * workspace may have changed (the per-workspace activation rule) — the + * composition root re-probes the layout so the newly-enabled provider's + * tree (or the no-provider degrade) lands without a reload. Only the + * ACTIVE workspace's toggle re-renders; others are ignored by the store. + */ + readonly refresh: (workspaceId: string) => void; +} + +/** + * Runtime-normalize a tier value to the canonical THREE: anything that is not + * exactly `core` | `providers` | `standard` — a missing tier, or a value + * outside the contract ("kernel" is NOT a tier; the kernel hosts plugins, it + * is not one) — folds into `"standard"`, the safe catch-all classification. + * The wire contract pins the three values, so this only ever fires on a + * contract violation (the backend rejects tierless externals at boot and + * directory-scanned plugins fail manifest validation); folding rather than + * surfacing the raw value keeps every tier-keyed consumer total — e.g. + * `tierLabel`'s exhaustive switch (an out-of-contract value would render an + * empty badge) and the manager table's keyed grouping (a non-canonical key + * would create a second, colliding group). + */ +export function normalizeTier(tier: unknown): PluginTier { + return tier === "core" || tier === "providers" || tier === "standard" ? tier : "standard"; +} diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts new file mode 100644 index 0000000..661cade --- /dev/null +++ b/src/features/plugins/logic/view-model.test.ts @@ -0,0 +1,296 @@ +import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { emptyListResponse, node } from "./test-fixtures"; +import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model"; + +function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse { + return { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: ["kernel"], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone", disabledAt: "2026-01-01" }], + ...overrides, + }; +} + +describe("pluginNodes", () => { + it("normalizes every row kind into one node shape", () => { + const nodes = pluginNodes(listResponse()); + expect(nodes.map((n) => n.id)).toEqual(["kernel", "opencode", "broken", "fresh", "gone"]); + }); + + it("maps loaded rows (loaded: true, locked from the row, deps kept)", () => { + const nodes = pluginNodes(listResponse()); + const kernel = nodes.find((n) => n.id === "kernel"); + expect(kernel).toMatchObject({ + loaded: true, + locked: true, + tier: "core", + section: "loaded", + dependsOn: [], + reason: null, + }); + const opencode = nodes.find((n) => n.id === "opencode"); + expect(opencode).toMatchObject({ loaded: true, locked: false, dependsOn: ["kernel"] }); + }); + + it("maps disabledWithReason rows (unloaded, reason kept, discovered section)", () => { + const nodes = pluginNodes(listResponse()); + const broken = nodes.find((n) => n.id === "broken"); + expect(broken).toMatchObject({ + loaded: false, + locked: false, + reason: "activation threw", + section: "discovered", + }); + }); + + it("maps discovered rows (unloaded, no reason)", () => { + const nodes = pluginNodes(listResponse()); + const fresh = nodes.find((n) => n.id === "fresh"); + expect(fresh).toMatchObject({ loaded: false, reason: null, section: "discovered" }); + }); + + it("maps missing rows (greyed strip; no trust/version)", () => { + const nodes = pluginNodes(listResponse()); + const gone = nodes.find((n) => n.id === "gone"); + expect(gone).toMatchObject({ + loaded: false, + section: "missing", + reason: "directory is gone", + trust: null, + version: "", + }); + }); + + it("handles the empty response", () => { + expect(pluginNodes(emptyListResponse())).toEqual([]); + }); + + it("normalizes a missing/unknown tier at the boundary so node tiers are canonical", () => { + // Contract-violation casts: the wire type pins three tier values, but the + // view-model must survive a backend that emits a tierless or out-of- + // contract row (the original duplicate-key crash trigger). + const response = listResponse({ + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "tierless", + name: "Tierless", + version: "1.0.0", + tier: undefined as unknown as PluginTier, + trust: "external", + locked: false, + dependsOn: [], + }, + { + id: "kernelish", + name: "Kernelish", + version: "1.0.0", + tier: "kernel" as unknown as PluginTier, + trust: "external", + locked: false, + dependsOn: [], + }, + ], + }); + const nodes = pluginNodes(response); + expect(nodes.find((n) => n.id === "tierless")?.tier).toBe("standard"); + expect(nodes.find((n) => n.id === "kernelish")?.tier).toBe("standard"); + + // The Loaded table's grouping: exactly two groups (core + standard), no + // duplicate keys, every row present. + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + const keys = groups.map((g) => g.tier); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(["core", "standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + expect(groups[1]?.rows.map((r) => r.id)).toEqual(["kernelish", "tierless"]); + }); +}); + +describe("groupByTier", () => { + it("clusters rows in tier display order (Core → Providers → Standard)", () => { + const nodes = pluginNodes(listResponse()); + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + }); + + it("omits empty tiers", () => { + const groups = groupByTier([pluginNodes(listResponse())[2] ?? []].flat()); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + }); + + it("emits one group per canonical tier, all three present", () => { + const groups = groupByTier([ + node({ id: "s", tier: "standard" }), + node({ id: "c", tier: "core" }), + node({ id: "p", tier: "providers" }), + ]); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers", "standard"]); + expect(groups.flatMap((g) => g.rows.map((r) => r.id))).toEqual(["c", "p", "s"]); + }); + + it("never emits duplicate group keys — an unknown tier folds into standard", () => { + // Contract violation cast: "kernel" is NOT a tier (the kernel hosts + // plugins, it is not one). groupByTier must stay total anyway. + const groups = groupByTier([ + node({ id: "s-row", tier: "standard" }), + node({ id: "k-row", tier: "kernel" as PluginTier }), + ]); + const keys = groups.map((g) => g.tier); + // No duplicates (the keyed each `(group.tier)` crashes on them)… + expect(new Set(keys).size).toBe(keys.length); + // …and the unknown-tier row is NOT dropped — it merges into standard. + expect(keys).toEqual(["standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["k-row", "s-row"]); + }); + + it("never emits duplicate group keys — a missing tier folds into standard", () => { + const groups = groupByTier([ + node({ id: "s-row", tier: "standard" }), + node({ id: "m-row", tier: undefined as unknown as PluginTier }), + ]); + const keys = groups.map((g) => g.tier); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(["standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["m-row", "s-row"]); + }); + + it("keeps unknown/missing tiers out of the canonical groups' identity (no second standard group)", () => { + // The old implementation pushed a trailing "rest" group keyed + // `rest[0]?.tier ?? "standard"` — a DUPLICATE standard key when canonical + // standard rows existed (the headless-Chromium each_key_duplicate crash). + const groups = groupByTier([ + node({ id: "a", tier: "standard" }), + node({ id: "b", tier: "bogus" as PluginTier }), + node({ id: "c", tier: undefined as unknown as PluginTier }), + ]); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + expect(groups[0]?.rows).toHaveLength(3); + }); +}); + +describe("splitSections", () => { + it("splits the node list into the two tables + the missing strip", () => { + const sections = splitSections(pluginNodes(listResponse())); + expect(sections.loaded.map((n) => n.id)).toEqual(["kernel", "opencode"]); + expect(sections.discovered.map((n) => n.id)).toEqual(["broken", "fresh"]); + expect(sections.missing.map((n) => n.id)).toEqual(["gone"]); + }); +}); + +// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ───────── + +describe("withFrontendStates", () => { + function listWith(...ids: readonly string[]): PluginListResponse { + return { + loaded: ids.map((id) => ({ + id, + name: id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; + } + + it("renders the plan-mandated grammar for an incompatible FE half", () => { + const nodes = pluginNodes(listWith("bad")); + const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin"); + }); + + it("renders the activation error for a failed FE half", () => { + const nodes = pluginNodes(listWith("broken")); + const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("frontend failed: boom"); + }); + + it("a loaded FE half adds no issue line; absent states add none", () => { + const nodes = pluginNodes(listWith("ok", "no-fe")); + const states = new Map([["ok", { kind: "loaded" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined(); + expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined(); + }); + + it("never annotates non-loaded sections (discovered / missing rows)", () => { + const response: PluginListResponse = { + loaded: [], + disabledWithReason: [], + discovered: [ + { + id: "dis", + name: "dis", + version: "1.0.0", + tier: "standard", + trust: "local", + dir: "/plugins/dis", + }, + ], + missing: [], + }; + const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(pluginNodes(response), states); + expect(out[0]?.frontendIssue).toBeUndefined(); + }); + + it("an empty state map is the identity (no allocation on the hot path)", () => { + const nodes = pluginNodes(listWith("a")); + expect(withFrontendStates(nodes, new Map())).toBe(nodes); + }); +}); diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts new file mode 100644 index 0000000..5a66ffa --- /dev/null +++ b/src/features/plugins/logic/view-model.ts @@ -0,0 +1,160 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../../core/plugin-abi"; +import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types"; + +/** + * Normalize a `GET /plugins` response into the flat `PluginNode` list the + * manager tables + graph logic consume. Each response row kind maps to a + * node: + * - `loaded` → section "loaded" (`loaded: true`, `locked` from the row — + * tier "core" is always locked); + * - `disabledWithReason` → section "discovered" with the refusal `reason` + * (present on disk, refused/failed — greyed rows with a visible why); + * - `discovered` → section "discovered" (`loaded: false`, unchecked); + * - `missing` → section "missing" (override record whose directory is gone). + * + * Pure input → output; zero DOM/fetch. + */ +export function pluginNodes(response: PluginListResponse): readonly PluginNode[] { + const nodes: PluginNode[] = []; + for (const row of response.loaded) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + // Normalize at the boundary: a missing/out-of-contract tier (backend + // bug, stale contract) folds into "standard" so every node's tier is + // canonical and the tier-keyed consumers below stay total. + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: true, + locked: row.locked, + dependsOn: row.dependsOn, + reason: null, + section: "loaded", + }); + } + for (const row of response.disabledWithReason) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason, + section: "discovered", + }); + } + for (const row of response.discovered) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: null, + section: "discovered", + }); + } + for (const row of response.missing) { + nodes.push({ + id: row.id, + name: row.id, + version: "", + tier: "standard", + trust: null, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason ?? "directory is gone", + section: "missing", + }); + } + return nodes; +} + +/** + * Merge the frontend-plugin (plan 03) per-plugin FE states into the node + * list — the manager rows' `frontendIssue` line. Pure input → output: + * - `incompatible` → the plan-mandated "incompatible frontend plugin" + * grammar (the backend apiVersion-mismatch wording, FE half); + * - `failed` → the activation error (the FE half failed; the backend half + * stays loaded). + * Loaded rows only — the FE states never apply to discovered/missing rows. + */ +export function withFrontendStates( + nodes: readonly PluginNode[], + states: ReadonlyMap<string, FrontendPluginState>, +): readonly PluginNode[] { + if (states.size === 0) return nodes; + return nodes.map((node) => { + if (node.section !== "loaded") return node; + const state = states.get(node.id); + if (state === undefined) return node; + const issue = + state.kind === "incompatible" + ? "incompatible frontend plugin" + : state.kind === "failed" + ? `frontend failed: ${state.error}` + : null; + return issue === null ? node : { ...node, frontendIssue: issue }; + }); +} + +/** + * Cluster nodes for one manager table: tier groups in display order (Core → + * Providers → Standard), each tier's rows sorted by id. Tiers with no rows + * are omitted. Used by the loaded table's tier grouping (plan 07 §7.2). + * + * Duplicate-key-proof BY CONSTRUCTION: nodes are bucketed through a Map keyed + * by the NORMALIZED tier, so each of the three canonical tiers can yield at + * most one group — `PluginTable.svelte` renders a keyed each + * (`{#each groups as group (group.tier)}`), and a duplicate key throws + * `each_key_duplicate` in Svelte 5, aborting the whole manager render. The + * normalization also means an unknown/missing tier (contract violation — the + * backend rejects tierless externals at boot and directory scans fail + * validation) folds into the `standard` bucket instead of forming a separate + * trailing group; the old fallback (`rest[0]?.tier ?? "standard"`) produced a + * DUPLICATE `standard` key when canonical standard rows were present. + * groupByTier normalizes on the way in even though `pluginNodes` already + * does at the boundary — it is exported and must be safe for any caller. + */ +export function groupByTier( + nodes: readonly PluginNode[], +): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> { + const byTier = new Map<PluginNode["tier"], PluginNode[]>(); + for (const n of nodes) { + const tier = normalizeTier(n.tier); + const rows = byTier.get(tier); + if (rows) rows.push(n); + else byTier.set(tier, [n]); + } + const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = []; + for (const tier of TIER_ORDER) { + const rows = byTier.get(tier); + if (!rows || rows.length === 0) continue; + out.push({ tier, rows: rows.sort((a, b) => a.id.localeCompare(b.id)) }); + } + return out; +} + +/** Split nodes into the manager's two tables + the missing strip. */ +export function splitSections(nodes: readonly PluginNode[]): { + readonly loaded: readonly PluginNode[]; + readonly discovered: readonly PluginNode[]; + readonly missing: readonly PluginNode[]; +} { + const bySection = (section: PluginSection): PluginNode[] => + nodes.filter((n) => n.section === section); + return { + loaded: bySection("loaded"), + discovered: bySection("discovered"), + missing: bySection("missing"), + }; +} diff --git a/src/features/plugins/logic/workspace-row.test.ts b/src/features/plugins/logic/workspace-row.test.ts new file mode 100644 index 0000000..4c7d29e --- /dev/null +++ b/src/features/plugins/logic/workspace-row.test.ts @@ -0,0 +1,67 @@ +import type { WorkspacePluginRow } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./workspace-row"; + +function row(overrides: Partial<WorkspacePluginRow> = {}): WorkspacePluginRow { + return { + pluginId: "mcp", + tier: "standard", + state: "undefined", + loaded: true, + locked: false, + ...overrides, + }; +} + +describe("rowChecked", () => { + it("is true only for an explicit enabled record", () => { + expect(rowChecked(row({ state: "enabled" }))).toBe(true); + expect(rowChecked(row({ state: "disabled" }))).toBe(false); + expect(rowChecked(row({ state: "undefined" }))).toBe(false); + }); +}); + +describe("rowSelectable", () => { + it("is true for a loaded, unlocked row (all three states)", () => { + for (const state of ["enabled", "disabled", "undefined"] as const) { + expect(rowSelectable(row({ state }))).toBe(true); + } + }); + + it("is false for an UNLOADED plugin (§7 strict layering — greyed out)", () => { + expect(rowSelectable(row({ loaded: false, state: "disabled" }))).toBe(false); + }); + + it("is false for a LOCKED (core) row — the server refuses the PUT (409)", () => { + expect(rowSelectable(row({ tier: "core", locked: true, state: "enabled" }))).toBe(false); + }); +}); + +describe("rowBlockReason", () => { + it("points at the global manager for unloaded plugins", () => { + const reason = rowBlockReason(row({ loaded: false })); + expect(reason).toContain("plugin manager"); + }); + + it("explains the core lock", () => { + const reason = rowBlockReason(row({ tier: "core", locked: true })); + expect(reason).toContain("Core plugins are enabled by default"); + }); + + it("prefers the unloaded reason when both apply", () => { + const reason = rowBlockReason(row({ loaded: false, tier: "core", locked: true })); + expect(reason).toContain("plugin manager"); + }); + + it("is null for an interactive row", () => { + expect(rowBlockReason(row())).toBeNull(); + }); +}); + +describe("stateLabel", () => { + it("renders the three wire states in a uniform grammar", () => { + expect(stateLabel("enabled")).toBe("enabled"); + expect(stateLabel("disabled")).toBe("disabled"); + expect(stateLabel("undefined")).toBe("not set"); + }); +}); diff --git a/src/features/plugins/logic/workspace-row.ts b/src/features/plugins/logic/workspace-row.ts new file mode 100644 index 0000000..c85b759 --- /dev/null +++ b/src/features/plugins/logic/workspace-row.ts @@ -0,0 +1,54 @@ +import type { WorkspacePluginRow, WorkspacePluginStateValue } from "@dispatch/transport-contract"; + +/** + * Pure helpers for the per-workspace plugin modal (plan 06 §6.6 wire rows → + * checkbox semantics). Zero DOM. + * + * State model (backend-canonical): `"enabled"` / `"disabled"` — an explicit + * record — or `"undefined"` for NO record (the blank slate; behaves disabled + * for Providers/Standard). Core rows arrive materialized `enabled` (Q-A). + */ + +/** Whether the modal checkbox is checked for a row (explicit `enabled` only). */ +export function rowChecked(row: WorkspacePluginRow): boolean { + return row.state === "enabled"; +} + +/** + * Whether the row's checkbox is selectable. Two unselectable cases (§7 + * strict layering + plan 06 §6.6): + * - the plugin is NOT loaded globally → greyed out and unselectable (only the + * global manager loads code); + * - the server reports the row locked (tier "core") → the server refuses the + * PUT (`core-locked` 409), so the control is disabled with a tooltip. + */ +export function rowSelectable(row: WorkspacePluginRow): boolean { + return row.loaded && !row.locked; +} + +/** + * Why the row is unselectable (tooltip text), or null when interactive. + * Unloaded beats locked in the copy (the user's actionable next step is the + * global manager). + */ +export function rowBlockReason(row: WorkspacePluginRow): string | null { + if (!row.loaded) { + return "Not loaded globally — load it in the plugin manager first (only the manager loads code)."; + } + if (row.locked) { + return "Core plugins are enabled by default in every workspace and managed globally."; + } + return null; +} + +/** The effective-state label shown next to a row (uniform three-state grammar). */ +export function stateLabel(state: WorkspacePluginStateValue): string { + switch (state) { + case "enabled": + return "enabled"; + case "disabled": + return "disabled"; + case "undefined": + return "not set"; + } +} diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts new file mode 100644 index 0000000..fe48940 --- /dev/null +++ b/src/features/plugins/store.svelte.ts @@ -0,0 +1,218 @@ +import type { + PluginListResponse, + SetWorkspacePluginStateRequest, + WorkspacePluginRow, +} from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../core/plugin-abi"; +import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http"; +import type { PluginNode } from "./logic/types"; +import { pluginNodes } from "./logic/view-model"; + +/** + * Plugins store — a thin reactive wrapper over the injected `PluginsHttp` + * edge (the workspaces-store pattern). Owns: + * - the manager's plugin list (`GET /plugins`) normalized to `PluginNode`s; + * - ONE open workspace modal's view (`GET /workspaces/:id/plugins` — read-through + * materialization makes this first read the workspace's "open"); + * - pending/error state for the mutations. + * + * Multi-client coherence (§11): the composition root calls + * `onExtensionsChanged()` when the server broadcasts `extensions-changed` — + * the store refetches the list (and the open workspace view), so every + * connected client converges. No ambient state: one instance per composition + * root, passed explicitly; no module-level mutables. + */ +export interface PluginsStore { + /** The last `GET /plugins` response (null until the first fetch lands). */ + readonly list: PluginListResponse | null; + /** The normalized nodes across ALL sections (loaded + discovered + missing). */ + readonly nodes: readonly PluginNode[]; + readonly loading: boolean; + /** The last mutation/scan/load error message, or null. */ + readonly error: string | null; + /** The manual scan's result (null until a scan ran), for the summary line. */ + readonly lastScan: PluginScanResult | null; + + /** Refetch `GET /plugins`. */ + refresh(): Promise<void>; + /** `POST /plugins/scan` (manual; the server never auto-loads new plugins). */ + scan(): Promise<PluginScanResult>; + /** `POST /plugins/:id/load`. */ + load(id: string): Promise<PluginOpResult>; + /** `POST /plugins/:id/unload` with `{ force }` (the §8 force semantics). */ + unload(id: string, force: boolean): Promise<PluginOpResult>; + /** `POST /plugins/:id/reload`. */ + reload(id: string): Promise<PluginOpResult>; + + /** + * The open workspace modal's rows (null when nothing loaded yet). Cleared + * IMMEDIATELY on open so a slow/failing fetch never shows the previous + * workspace's rows (the refreshCwd/refreshComputer pattern). + */ + readonly workspaceView: WorkspacePluginsView | null; + /** Load/reload the workspace modal's view (`GET /workspaces/:id/plugins`). */ + openWorkspace(workspaceId: string): Promise<void>; + /** + * Write one plugin's state in one workspace (`PUT .../plugins/:pluginId`). + * On success the open view's row is updated in place from the response; on + * failure the error is surfaced AND the view refetched (server-authoritative + * reconciliation — the checkbox never lies). + */ + setWorkspaceState( + workspaceId: string, + pluginId: string, + state: SetWorkspacePluginStateRequest["state"], + ): Promise<void>; + + /** + * Multi-client coherence hook (§11): the composition root calls + * `onExtensionsChanged()` when the server broadcasts `extensions-changed` — + * the store refetches the list (and the open workspace view), so every + * connected client converges. Fire-and-forget. + */ + onExtensionsChanged(): void; + + /** + * The frontend-plugin (plan 03) per-plugin FE states — reported by the + * plugin host after every driver sync. Rendered by the manager rows: + * `incompatible` → "incompatible frontend plugin" (the backend-mismatch + * grammar), `failed` → the activation error. Absent = no FE section. + */ + readonly frontendStates: ReadonlyMap<string, FrontendPluginState>; + /** The plugin host's post-sync report (the only writer). */ + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; +} + +/** One open workspace modal's view (rows + materialized provider count). */ +export interface WorkspacePluginsView { + readonly workspaceId: string; + readonly rows: readonly WorkspacePluginRow[]; + /** Loaded provider-tier plugins effectively enabled here (§6.5). */ + readonly enabledProviderCount: number; +} + +export function createPluginsStore(http: PluginsHttp): PluginsStore { + let list = $state<PluginListResponse | null>(null); + let loading = $state(false); + let error = $state<string | null>(null); + let lastScan = $state<PluginScanResult | null>(null); + let workspaceView = $state<WorkspacePluginsView | null>(null); + let frontendStates = $state<ReadonlyMap<string, FrontendPluginState>>(new Map()); + + // Normalized nodes — derived so the graph logic re-runs only when the list + // changes, not on every read. + const nodes = $derived<readonly PluginNode[]>(list === null ? [] : pluginNodes(list)); + + return { + get list(): PluginListResponse | null { + return list; + }, + get nodes(): readonly PluginNode[] { + return nodes; + }, + get loading(): boolean { + return loading; + }, + get error(): string | null { + return error; + }, + get lastScan(): PluginScanResult | null { + return lastScan; + }, + get workspaceView(): WorkspacePluginsView | null { + return workspaceView; + }, + + async refresh(): Promise<void> { + loading = true; + const data = await http.list(); + if (data !== null) list = data; + loading = false; + }, + + async scan(): Promise<PluginScanResult> { + error = null; + const result = await http.scan(); + lastScan = result; + if (!result.ok) error = result.error; + // A scan broadcasts `extensions-changed` server-side; refresh anyway so + // the manager is coherent even without a WS connection. + await this.refresh(); + return result; + }, + + async load(id): Promise<PluginOpResult> { + error = null; + const result = await http.load(id); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async unload(id, force): Promise<PluginOpResult> { + error = null; + const result = await http.unload(id, force); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async reload(id): Promise<PluginOpResult> { + error = null; + const result = await http.reload(id); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async openWorkspace(workspaceId): Promise<void> { + // Clear immediately so a slow/failing fetch never shows the PREVIOUS + // workspace's rows in the new modal. + workspaceView = null; + const data = await http.workspacePlugins(workspaceId); + if (data === null) { + error = `Failed to load plugins for workspace "${workspaceId}"`; + return; + } + workspaceView = { + workspaceId: data.workspaceId, + rows: data.plugins, + enabledProviderCount: data.enabledProviderCount, + }; + }, + + async setWorkspaceState(workspaceId, pluginId, state): Promise<void> { + const result = await http.setWorkspaceState(workspaceId, pluginId, state); + if (result.ok) { + // Server-authoritative in-place row update (the response carries the + // resulting effective state — "undefined" after "unset"). + if (workspaceView?.workspaceId === workspaceId) { + const rows = workspaceView.rows.map( + (row): WorkspacePluginRow => + row.pluginId === pluginId ? { ...row, state: result.response.state } : row, + ); + workspaceView = { ...workspaceView, rows }; + } + return; + } + error = result.error; + // Refusal (409 core-locked / unloaded) or failure — reconcile from the + // server so the checkbox never lies. + await this.openWorkspace(workspaceId); + }, + + onExtensionsChanged(): void { + void this.refresh(); + const open = workspaceView?.workspaceId; + if (open !== undefined) void this.openWorkspace(open); + }, + + get frontendStates(): ReadonlyMap<string, FrontendPluginState> { + return frontendStates; + }, + + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void { + frontendStates = states; + }, + }; +} diff --git a/src/features/plugins/store.test.ts b/src/features/plugins/store.test.ts new file mode 100644 index 0000000..be301ee --- /dev/null +++ b/src/features/plugins/store.test.ts @@ -0,0 +1,183 @@ +import type { PluginListResponse, WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { + PluginOpResult, + PluginScanResult, + PluginsHttp, + WorkspaceStateResult, +} from "./adapter/http"; +import { createPluginsStore } from "./store.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "1.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [], +}; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + ], + enabledProviderCount: 1, +}; + +/** A fake PluginsHttp with a controllable list + workspace response. */ +function fakeHttp(opts: { + list?: PluginListResponse | null; + workspacePlugins?: WorkspacePluginsResponse | null; +}) { + // `=== undefined` (not `??`) so an explicit `null` override defeats the default. + const list = () => (opts.list === undefined ? listResponse : opts.list); + const ws = () => (opts.workspacePlugins === undefined ? wsResponse : opts.workspacePlugins); + const opOk = (action: "load" | "unload" | "reload") => ({ ok: true, action, id: "x" }) as const; + return { + list: vi.fn(async (): Promise<PluginListResponse | null> => list()), + scan: vi.fn( + async (): Promise<PluginScanResult> => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + }), + ), + load: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("load") })), + unload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("unload") })), + reload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("reload") })), + workspacePlugins: vi.fn(async (): Promise<WorkspacePluginsResponse | null> => ws()), + setWorkspaceState: vi.fn( + async (): Promise<WorkspaceStateResult> => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + }), + ), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + } satisfies PluginsHttp; +} + +describe("createPluginsStore — manager list", () => { + it("fetches + normalizes the list into nodes", async () => { + const store = createPluginsStore(fakeHttp({})); + expect(store.list).toBeNull(); + expect(store.nodes).toEqual([]); + await store.refresh(); + expect(store.list).toEqual(listResponse); + expect(store.nodes.map((n) => n.id)).toEqual(["kernel", "fresh"]); + }); + + it("mutation results are surfaced and the list is refetched", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.refresh(); + const result = await store.unload("kernel", false); + expect(result.ok).toBe(true); + expect(http.unload).toHaveBeenCalledWith("kernel", false); + expect(http.list).toHaveBeenCalledTimes(2); + }); + + it("scan records the result + refreshes", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.scan(); + expect(store.lastScan?.ok).toBe(true); + expect(http.scan).toHaveBeenCalledOnce(); + expect(http.list).toHaveBeenCalledTimes(1); // refresh after scan (initial list fetch absent) + }); + + it("a mutation error lands in store.error", async () => { + const http = fakeHttp({}); + http.unload = vi.fn(async (): Promise<PluginOpResult> => ({ ok: false, error: "blocked" })); + const store = createPluginsStore(http); + await store.unload("kernel", false); + expect(store.error).toBe("blocked"); + }); +}); + +describe("createPluginsStore — workspace view", () => { + it("openWorkspace loads the rows + enabled provider count", async () => { + const store = createPluginsStore(fakeHttp({})); + await store.openWorkspace("ws"); + expect(store.workspaceView).toEqual({ + workspaceId: "ws", + rows: wsResponse.plugins, + enabledProviderCount: 1, + }); + }); + + it("setWorkspaceState updates the row in place from the response", async () => { + const store = createPluginsStore(fakeHttp({})); + await store.openWorkspace("ws"); + await store.setWorkspaceState("ws", "mcp", "enabled"); + const row = store.workspaceView?.rows.find((r) => r.pluginId === "mcp"); + expect(row?.state).toBe("enabled"); + }); + + it("a refused setWorkspaceState refetches (server-authoritative reconcile)", async () => { + const http = fakeHttp({}); + http.setWorkspaceState = vi.fn( + async (): Promise<WorkspaceStateResult> => ({ + ok: false, + error: "core is locked", + reason: "core-locked", + }), + ); + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + await store.setWorkspaceState("ws", "kernel", "disabled"); + expect(store.error).toBe("core is locked"); + expect(http.workspacePlugins).toHaveBeenCalledTimes(2); + }); + + it("a failed openWorkspace clears the view (no stale rows from another workspace)", async () => { + const http = fakeHttp({ workspacePlugins: null }); + const store = createPluginsStore(http); + await store.openWorkspace("other"); + expect(store.workspaceView).toBeNull(); + expect(store.error).toContain("other"); + }); +}); + +describe("createPluginsStore — multi-client coherence (§11)", () => { + it("onExtensionsChanged refetches the list + the open workspace view", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + expect(http.list).toHaveBeenCalledTimes(0); + expect(http.workspacePlugins).toHaveBeenCalledTimes(1); + + store.onExtensionsChanged(); + await vi.waitFor(() => { + expect(http.list).toHaveBeenCalledTimes(1); + expect(http.workspacePlugins).toHaveBeenCalledTimes(2); + }); + }); + + it("onExtensionsChanged does not refetch a workspace view when none is open", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + store.onExtensionsChanged(); + await vi.waitFor(() => { + expect(http.list).toHaveBeenCalledTimes(1); + expect(http.workspacePlugins).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte new file mode 100644 index 0000000..fd2a8a7 --- /dev/null +++ b/src/features/plugins/ui/PluginManager.svelte @@ -0,0 +1,221 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PluginsStore } from "../store.svelte"; + import type { PluginNode } from "../logic/types"; + import { computeCascade } from "../logic/cascade"; + import { autoCheckBackendPrereqs } from "../logic/prereqs"; + import { dependencyHighlightSets } from "../logic/highlight"; + import { unloadBlockReason } from "../logic/last-provider"; + import { splitSections, withFrontendStates } from "../logic/view-model"; + import PluginTable from "./PluginTable.svelte"; + import UnloadConfirmModal from "./UnloadConfirmModal.svelte"; + + let { plugins, onClose }: { plugins: PluginsStore; onClose: () => void } = $props(); + + onMount(() => { + void plugins.refresh(); + }); + + // ── Derived sections + hover highlighting (§8) ──────────────────────────── + // The FE-half states (plan 03) merge into the nodes here — one reactive + // derivation over both inputs (the normalized nodes + the plugin host's + // post-sync state map). + const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates)); + const sections = $derived(splitSections(nodes)); + let hoveredId = $state<string | null>(null); + const highlight = $derived(dependencyHighlightSets(hoveredId, nodes)); + + function highlightFor(id: string): "dep" | "dependent" | null { + if (highlight.deps.has(id)) return "dep"; + if (highlight.dependents.has(id)) return "dependent"; + return null; + } + + // ── Mutations ───────────────────────────────────────────────────────────── + let busyIds = $state<ReadonlySet<string>>(new Set()); + let scanning = $state(false); + + async function withBusy(id: string, op: () => Promise<unknown>): Promise<void> { + busyIds = new Set(busyIds).add(id); + try { + await op(); + } finally { + const next = new Set(busyIds); + next.delete(id); + busyIds = next; + } + } + + // ── Unload confirm state (cascade warn §8 + in-use force §8) ───────────── + // The pending unload the confirm modal describes. `cascade` lists the loaded + // dependents that will also be unchecked; `notice` carries a refused-attempt + // error (in-use / engine refusal) that motivated the force offer. + let pendingUnload = $state<{ + node: PluginNode; + cascade: readonly string[]; + notice: string | null; + } | null>(null); + + async function toggle(node: PluginNode, checked: boolean): Promise<void> { + if (busyIds.has(node.id)) return; + if (checked) { + // §8: checking auto-checks the required backend plugins (transitive, + // one-directional). Load the missing prereqs first, then the target. + const prereqs = autoCheckBackendPrereqs(node.id, plugins.nodes); + for (const id of prereqs) { + await withBusy(id, () => plugins.load(id)); + } + await withBusy(node.id, () => plugins.load(node.id)); + return; + } + // Unchecking: warn when loaded dependents would cascade-uncheck (§8). + const cascade = computeCascade(node.id, plugins.nodes); + if (cascade.length > 0) { + pendingUnload = { node, cascade, notice: null }; + return; + } + // No local cascade — attempt the unload; a server refusal (in-use / + // engine) offers force with the global-stop warning (§8). + const result = await withBusyRes(node.id, () => plugins.unload(node.id, false)); + if (result !== null && !result.ok) { + pendingUnload = { node, cascade: result.dependents ?? [], notice: result.error }; + } + } + + /** Like `withBusy` but returns the op's result (for the unload flow). */ + async function withBusyRes<T>(id: string, op: () => Promise<T>): Promise<T | null> { + busyIds = new Set(busyIds).add(id); + try { + return await op(); + } finally { + const next = new Set(busyIds); + next.delete(id); + busyIds = next; + } + } + + async function confirmUnload(): Promise<void> { + const pending = pendingUnload; + pendingUnload = null; + if (pending === null) return; + // Force cascades the dependents server-side (plan 05: reverse-topological + // unload) and stops ALL agents globally first — the modal's copy says so. + await withBusy(pending.node.id, () => plugins.unload(pending.node.id, true)); + } + + function cancelUnload(): void { + pendingUnload = null; + } + + async function reload(node: PluginNode): Promise<void> { + await withBusy(node.id, () => plugins.reload(node.id)); + } + + async function scan(): Promise<void> { + scanning = true; + await plugins.scan(); + scanning = false; + } + + const scanSummary = $derived.by(() => { + const last = plugins.lastScan; + if (last === null || !last.ok) return null; + const r = last.response; + const parts: string[] = [`${r.newPlugins.length} new`]; + if (r.invalid.length > 0) parts.push(`${r.invalid.length} invalid`); + if (r.incompatible.length > 0) parts.push(`${r.incompatible.length} incompatible`); + return `Scan of ${r.root}: ${parts.join(", ")}`; + }); +</script> + +<div class="modal modal-open" role="dialog" aria-label="Plugin manager"> + <div class="modal-box max-w-3xl"> + <div class="flex items-center justify-between"> + <h3 class="text-lg font-bold">Plugin manager</h3> + <button + type="button" + class="btn btn-sm" + disabled={scanning} + onclick={scan} + title="Rescan the plugin directory (new plugins stay unloaded — the consent gate)" + > + {#if scanning} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Scan + {/if} + </button> + </div> + + {#if plugins.error} + <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p> + {/if} + {#if scanSummary} + <p class="mt-2 text-xs opacity-70" role="status">{scanSummary}</p> + {/if} + + <div class="mt-3 flex flex-col gap-4"> + <PluginTable + title="Loaded" + rows={sections.loaded} + blockReasonFor={(id) => unloadBlockReason(id, plugins.nodes)} + {highlightFor} + {busyIds} + onToggle={toggle} + onReload={reload} + onHover={(id) => (hoveredId = id)} + onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)} + /> + + <PluginTable + title="Discovered (not loaded)" + rows={sections.discovered} + blockReasonFor={() => null} + {highlightFor} + {busyIds} + onToggle={toggle} + onHover={(id) => (hoveredId = id)} + onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)} + /> + + {#if sections.missing.length > 0} + <section class="flex flex-col gap-1"> + <h4 class="text-xs font-semibold uppercase opacity-60">Missing (directory gone)</h4> + <ul class="flex flex-col gap-1"> + {#each sections.missing as node (node.id)} + <li class="rounded bg-base-200/40 px-2 py-1 text-xs opacity-60"> + <span class="font-mono">{node.id}</span> — {node.reason} + </li> + {/each} + </ul> + </section> + {/if} + </div> + + <div class="modal-action"> + <p class="mr-auto self-center text-xs opacity-50"> + Hover a row to highlight its dependencies and dependents. + </p> + <button type="button" class="btn btn-sm" onclick={onClose}>Close</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onClose}></div> +</div> + +{#if pendingUnload !== null} + <UnloadConfirmModal + headline={pendingUnload.node.loaded ? `Unload ${pendingUnload.node.id}?` : `Force-unload ${pendingUnload.node.id}?`} + lines={[ + ...(pendingUnload.cascade.length > 0 + ? [ + `Unloading ${pendingUnload.node.id} also unchecks: ${pendingUnload.cascade.join(", ")}.`, + ] + : []), + ...(pendingUnload.notice !== null ? [pendingUnload.notice] : []), + ]} + confirmLabel={pendingUnload.cascade.length > 0 ? "Unload with force" : "Force unload"} + onCancel={cancelUnload} + onConfirm={confirmUnload} + /> +{/if} diff --git a/src/features/plugins/ui/PluginManager.test.ts b/src/features/plugins/ui/PluginManager.test.ts new file mode 100644 index 0000000..c81938c --- /dev/null +++ b/src/features/plugins/ui/PluginManager.test.ts @@ -0,0 +1,284 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginOpResult, PluginsHttp } from "../adapter/http"; +import { createPluginsStore } from "../store.svelte"; +import PluginManager from "./PluginManager.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "transport-http", + name: "Transport HTTP", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: ["heartbeat"], + }, + { + id: "heartbeat", + name: "Heartbeat", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: [], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone" }], +}; + +/** A fake PluginsHttp (the OUTERMOST edge — the store itself is real). */ +function fakeHttp(overrides: Partial<PluginsHttp> = {}): PluginsHttp { + return { + list: vi.fn(async () => listResponse), + scan: vi.fn(async () => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + })), + load: vi.fn(async () => ({ ok: true, response: { ok: true, action: "load", id: "x" } })), + unload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "unload", id: "x" } })), + reload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "reload", id: "x" } })), + workspacePlugins: vi.fn(async () => null), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "x", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderManager(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + return { store, http }; +} + +describe("PluginManager — tables + locked core", () => { + it("renders the loaded table with tier groups and the discovered table", async () => { + await renderManager(); + expect(screen.getByText("Loaded")).toBeInTheDocument(); + expect(screen.getByText("Discovered (not loaded)")).toBeInTheDocument(); + // Tier subheadings (Core / Provider / Standard) — each also appears as a + // row badge, so assert at least one occurrence of each. + expect(screen.getAllByText("Core").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Provider").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Standard").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Missing (directory gone)")).toBeInTheDocument(); + expect(screen.getByText("gone")).toBeInTheDocument(); + }); + + it("core rows show a lock instead of a checkbox", async () => { + await renderManager(); + // The kernel row has no checkbox (the lock glyph instead). + expect(screen.queryByRole("checkbox", { name: /Unload kernel/i })).not.toBeInTheDocument(); + // Provider + standard rows have checkboxes. + expect(screen.getByRole("checkbox", { name: /Unload opencode/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Unload heartbeat/i })).toBeChecked(); + }); + + it("disabled-with-reason rows show their reason", async () => { + await renderManager(); + expect(screen.getByText("activation threw")).toBeInTheDocument(); + }); +}); + +describe("PluginManager — cascade-uncheck warning (§8)", () => { + it("unchecking a plugin with loaded dependents warns first (no request yet)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http depends on heartbeat → unchecking heartbeat cascades. + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + expect(http.unload).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: /Unload heartbeat/i })).toBeInTheDocument(); + expect(screen.getByText(/also unchecks: transport-http/)).toBeInTheDocument(); + }); + + it("accepting the warning issues the forced unload (the cascade is server-side)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Unload with force" })); + expect(http.unload).toHaveBeenCalledWith("heartbeat", true); + expect(screen.queryByRole("dialog", { name: /Unload heartbeat/i })).not.toBeInTheDocument(); + }); + + it("cancelling the warning issues nothing", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(http.unload).not.toHaveBeenCalled(); + }); + + it("unchecking WITHOUT dependents unloads directly (no warning)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http has no loaded dependents (it DEPENDS on heartbeat, not + // the reverse) — and it is not a provider, so the last-provider lock + // (which correctly disables `opencode`, the fixture's only provider) + // does not apply. + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + expect(http.unload).toHaveBeenCalledWith("transport-http", false); + expect(screen.queryByRole("dialog", { name: /Unload/i })).not.toBeInTheDocument(); + }); + + it("a refused unload offers force with the global-stop copy (§8 in-use force)", async () => { + const user = userEvent.setup(); + const http = fakeHttp({ + unload: vi.fn( + async (): Promise<PluginOpResult> => ({ + ok: false, + error: "the engine refused — the plugin is in use", + reason: "engine-refused", + }), + ), + }); + await renderManager(http); + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + // The refusal is surfaced both in the manager's error line and the + // confirm dialog's notice (the dialog appears after the async refusal). + expect( + await screen.findAllByText(/the engine refused/).then((els) => els.length), + ).toBeGreaterThanOrEqual(1); + expect(await screen.findByText(/stops ALL agents globally/i)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Force unload" })); + expect(http.unload).toHaveBeenLastCalledWith("transport-http", true); + }); +}); + +describe("PluginManager — last-provider lock (§8)", () => { + it("the last loaded provider's checkbox is disabled with a tooltip", async () => { + const single: PluginListResponse = { + loaded: [ + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [], + missing: [], + }; + const store = createPluginsStore(fakeHttp({ list: async () => single })); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + const checkbox = screen.getByRole("checkbox", { name: /Unload opencode/i }); + expect(checkbox).toBeDisabled(); + // The explanatory copy is wired via the title attribute (the tooltip). + expect(checkbox.getAttribute("title")).toMatch(/last provider/i); + }); +}); + +describe("PluginManager — load with auto-checked prereqs (§8)", () => { + it("checking a discovered plugin loads it and reflects the stateful refetch", async () => { + const user = userEvent.setup(); + // Stateful fake: after `load("fresh")`, the list reports fresh as loaded + // (the store refetches from the server after the op — the checkbox must + // reflect the NEW list, not a local guess). + let freshLoaded = false; + const stateful: PluginsHttp = { + ...fakeHttp(), + list: vi.fn(async () => + freshLoaded + ? { + ...listResponse, + discovered: [], + loaded: [ + ...listResponse.loaded, + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + locked: false, + dependsOn: [], + }, + ], + } + : listResponse, + ), + load: vi.fn(async (id: string) => { + if (id === "fresh") freshLoaded = true; + return { ok: true, response: { ok: true, action: "load", id } }; + }), + } as unknown as PluginsHttp; + const store = createPluginsStore(stateful); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + await user.click(screen.getByRole("checkbox", { name: /Load fresh/i })); + expect(stateful.load).toHaveBeenCalledWith("fresh"); + expect(screen.getByRole("checkbox", { name: /Unload fresh/i })).toBeChecked(); + }); + + it("the Scan button triggers a manual scan", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Scan/ })); + expect(http.scan).toHaveBeenCalledOnce(); + }); + + it("the Reload affordance calls reload for a loaded row", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Reload transport-http/i })); + expect(http.reload).toHaveBeenCalledWith("transport-http"); + }); +}); diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte new file mode 100644 index 0000000..73e0b18 --- /dev/null +++ b/src/features/plugins/ui/PluginRow.svelte @@ -0,0 +1,120 @@ +<script lang="ts"> + import type { PluginNode } from "../logic/types"; + import { tierLabel } from "../logic/types"; + + let { + node, + highlight, + blockReason = null, + busy = false, + onToggle, + onReload, + onHover, + onUnhover, + }: { + node: PluginNode; + /** Hover-highlight state for this row: a direct dep, a dependent, or none. */ + highlight: "dep" | "dependent" | null; + /** + * Why the unload control is disabled (core-locked / last-provider), or + * null when interactive. Missing rows are always disabled (no plugin). + */ + blockReason?: string | null; + /** Whether a mutation for this row is in flight (spinner in the controls). */ + busy?: boolean; + onToggle: (node: PluginNode, checked: boolean) => void; + onReload?: (node: PluginNode) => void; + onHover: (id: string) => void; + onUnhover: (id: string) => void; + } = $props(); + + // Missing rows have no checkbox at all — there is no plugin to load. + const hasCheckbox = $derived(node.section !== "missing"); + // Core rows are LOCKED (no load/unload control) per §3/§5. + const interactive = $derived(blockReason === null && !busy); + const rowClass = $derived( + [ + node.section === "missing" || node.locked || (!node.loaded && node.section === "discovered") + ? "opacity-50" + : "", + highlight === "dep" + ? "outline outline-1 outline-info bg-info/10" + : highlight === "dependent" + ? "outline outline-1 outline-warning bg-warning/10" + : "", + ] + .filter(Boolean) + .join(" "), + ); +</script> + +<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> +<tr + class={rowClass} + onmouseover={() => onHover(node.id)} + onmouseout={() => onUnhover(node.id)} + onfocus={() => onHover(node.id)} + onblur={() => onUnhover(node.id)} +> + <td class="w-10"> + {#if hasCheckbox} + {#if node.locked} + <span + class="inline-block w-4 text-center opacity-40" + title={blockReason ?? "Core plugins cannot be unloaded"} + aria-label="Locked (core)">🔒</span + > + {:else} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={node.loaded} + disabled={!interactive} + title={blockReason ?? undefined} + aria-label={`${node.loaded ? "Unload" : "Load"} ${node.id}`} + onchange={(e) => onToggle(node, e.currentTarget.checked)} + /> + {/if} + {/if} + </td> + <td> + <div class="flex flex-col"> + <span class="font-medium">{node.name}</span> + {#if node.reason !== null} + <span class="text-xs text-error">{node.reason}</span> + {/if} + {#if node.frontendIssue !== undefined} + <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error --> + <span class="text-xs text-error">{node.frontendIssue}</span> + {/if} + {#if node.dependsOn.length > 0} + <span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span> + {/if} + </div> + </td> + <td class="text-xs"> + <span class="badge badge-ghost badge-sm">{tierLabel(node.tier)}</span> + {#if node.trust !== null} + <span class="badge badge-ghost badge-sm">{node.trust}</span> + {/if} + </td> + <td class="text-xs opacity-60">{node.version}</td> + <td class="text-right"> + {#if node.loaded && onReload !== undefined} + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={!interactive} + title="Reload this plugin's code (picks up updates)" + aria-label={`Reload ${node.id}`} + onclick={() => onReload?.(node)} + > + {#if busy} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ↻ + {/if} + </button> + {/if} + </td> +</tr> diff --git a/src/features/plugins/ui/PluginTable.svelte b/src/features/plugins/ui/PluginTable.svelte new file mode 100644 index 0000000..717f839 --- /dev/null +++ b/src/features/plugins/ui/PluginTable.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import type { PluginNode } from "../logic/types"; + import { tierLabel } from "../logic/types"; + import { groupByTier } from "../logic/view-model"; + import PluginRow from "./PluginRow.svelte"; + + let { + title, + rows, + blockReasonFor, + highlightFor, + busyIds, + onToggle, + onReload, + onHover, + onUnhover, + }: { + title: string; + rows: readonly PluginNode[]; + /** + * Why a row's control is disabled (core-locked / last-provider), or null + * when interactive. Injected by the manager (it owns the graph context). + */ + blockReasonFor: (id: string) => string | null; + /** + * The §8 hover-highlight state for one row: a highlighted dependency, a + * highlighted dependent, or none. Injected by the manager (it owns the + * hovered id + the graph). + */ + highlightFor: (id: string) => "dep" | "dependent" | null; + /** Ids with an in-flight mutation (spinner in their controls). */ + busyIds: ReadonlySet<string>; + onToggle: (node: PluginNode, checked: boolean) => void; + onReload?: (node: PluginNode) => void; + onHover: (id: string) => void; + onUnhover: (id: string) => void; + } = $props(); + + // Tier-grouped rows (Core → Providers → Standard) per the plan-07 decision. + const groups = $derived(groupByTier(rows)); +</script> + +<section class="flex flex-col gap-1"> + <h4 class="text-xs font-semibold uppercase opacity-60">{title}</h4> + {#if rows.length === 0} + <p class="text-xs opacity-50">Nothing here.</p> + {:else} + <table class="table table-sm"> + <thead> + <tr> + <th class="w-10"> + <span class="sr-only">Load/unload</span> + </th> + <th>Plugin</th> + <th>Class</th> + <th>Version</th> + <th class="text-right"> + <span class="sr-only">Actions</span> + </th> + </tr> + </thead> + {#each groups as group (group.tier)} + <tbody> + <tr> + <td colspan="5" class="bg-base-200/40 text-xs font-semibold uppercase opacity-60"> + {tierLabel(group.tier)} + </td> + </tr> + {#each group.rows as node (node.id)} + <PluginRow + {node} + highlight={highlightFor(node.id)} + blockReason={blockReasonFor(node.id)} + busy={busyIds.has(node.id)} + {onToggle} + {...(onReload ? { onReload } : {})} + {onHover} + {onUnhover} + /> + {/each} + </tbody> + {/each} + </table> + {/if} +</section> diff --git a/src/features/plugins/ui/ResetLayoutConfirm.svelte b/src/features/plugins/ui/ResetLayoutConfirm.svelte new file mode 100644 index 0000000..1f26601 --- /dev/null +++ b/src/features/plugins/ui/ResetLayoutConfirm.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + /** + * The "Reset layout to default?" confirm (plan 04 §4.4) — the dialog copy + * names the consequence exactly: this workspace's layout returns to the + * default; plugin and dropdown customizations are lost (there is NO + * undo — the no-revert semantics make the reset the recovery path). + */ + let { + onConfirm, + onCancel, + }: { + onConfirm: () => void; + onCancel: () => void; + } = $props(); +</script> + +<div class="modal modal-open" role="dialog" aria-label="Reset layout to default?"> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold text-warning">Reset layout to default?</h3> + <p class="py-3 text-sm"> + This workspace's layout returns to the default; plugin and dropdown customizations are lost. + </p> + <div class="modal-action"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>Reset</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onCancel}></div> +</div> diff --git a/src/features/plugins/ui/UnloadConfirmModal.svelte b/src/features/plugins/ui/UnloadConfirmModal.svelte new file mode 100644 index 0000000..6ae95cc --- /dev/null +++ b/src/features/plugins/ui/UnloadConfirmModal.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + let { + headline, + lines, + confirmLabel, + onCancel, + onConfirm, + }: { + /** The warning headline (e.g. "Unload heartbeat?"). */ + headline: string; + /** The warning body lines: what else unchecks + the force blast radius. */ + lines: readonly string[]; + confirmLabel: string; + onCancel: () => void; + onConfirm: () => void; + } = $props(); +</script> + +<div class="modal modal-open" role="dialog" aria-label={headline}> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold text-warning">{headline}</h3> + <div class="flex flex-col gap-1 py-3"> + {#each lines as line (line)} + <p class="text-sm">{line}</p> + {/each} + <p class="mt-1 text-xs text-error"> + Force stops ALL agents globally — every active turn in every workspace — not just agents + using this plugin. + </p> + </div> + <div class="modal-action"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}> + {confirmLabel} + </button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onCancel}></div> +</div> diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte new file mode 100644 index 0000000..0ba7cd6 --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.svelte @@ -0,0 +1,171 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PluginsStore } from "../store.svelte"; + import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row"; + import type { WorkspaceLayoutPort } from "../logic/types"; + import ResetLayoutConfirm from "./ResetLayoutConfirm.svelte"; + + let { + workspaceId, + workspaceTitle, + plugins, + layout, + onClose, + }: { + workspaceId: string; + /** The workspace's display title (modal heading). */ + workspaceTitle: string; + plugins: PluginsStore; + /** + * The layout port (plan 04 §4.4) — gates + powers the "Reset layout to + * default" affordance. Absent → the section doesn't render (tests). + */ + layout?: WorkspaceLayoutPort; + onClose: () => void; + } = $props(); + + onMount(() => { + // Load only when the view isn't already showing THIS workspace (reopening + // the same modal never flashes; a different id reloads it). + if (plugins.workspaceView?.workspaceId !== workspaceId) { + void plugins.openWorkspace(workspaceId); + } + }); + + // The default workspace is the template (§7): its states are what other + // workspaces inherit when they first open. + const isTemplate = $derived(workspaceId === "default"); + + async function toggle(pluginId: string, checked: boolean): Promise<void> { + await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled"); + // The per-workspace activation rule (layout): toggling a plugin may have + // changed which layout plugin serves THIS workspace — re-probe so the + // newly-enabled provider's tree (or the no-provider degrade) renders + // without a reload. The store ignores non-active workspaces. + layout?.refresh(workspaceId); + } + + // ── The reset affordance (plan 04 §4.4) ───────────────────────────────── + let resetOpen = $state(false); + let resetting = $state(false); + let resetError = $state<string | null>(null); + + async function confirmReset(): Promise<void> { + if (layout === undefined || resetting) return; + resetting = true; + resetError = null; + const failure = await layout.reset(workspaceId); + resetting = false; + resetOpen = false; + if (failure !== null) resetError = failure; + } +</script> + +<div class="modal modal-open" role="dialog" aria-label={`Plugins for ${workspaceTitle}`}> + <div class="modal-box max-w-2xl"> + <h3 class="text-lg font-bold">Plugins — {workspaceTitle}</h3> + <p class="mt-1 text-xs opacity-60"> + What THIS workspace uses. Unloaded plugins are greyed out — only the global plugin manager + loads code. + </p> + {#if isTemplate} + <p class="mt-1 text-xs text-info"> + This is the default workspace — other workspaces inherit these states when they first open. + </p> + {/if} + + {#if plugins.error} + <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p> + {/if} + + {#if plugins.workspaceView === null} + <div class="flex h-24 items-center justify-center"> + <span class="loading loading-spinner loading-sm opacity-60"></span> + </div> + {:else} + <table class="table table-sm mt-2"> + <thead> + <tr> + <th class="w-10"> + <span class="sr-only">Enabled</span> + </th> + <th>Plugin</th> + <th>State</th> + </tr> + </thead> + <tbody> + {#each plugins.workspaceView.rows as row (row.pluginId)} + <tr class={row.loaded ? "" : "opacity-50"}> + <td> + {#if rowSelectable(row)} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={rowChecked(row)} + aria-label={`${rowChecked(row) ? "Disable" : "Enable"} ${row.pluginId} in this workspace`} + onchange={(e) => toggle(row.pluginId, e.currentTarget.checked)} + /> + {:else} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={rowChecked(row)} + disabled + aria-label={`${row.pluginId} (unavailable in this workspace)`} + title={rowBlockReason(row) ?? undefined} + /> + {/if} + </td> + <td> + <span class="font-medium">{row.pluginId}</span> + <span class="badge badge-ghost badge-sm ml-2 text-xs">{row.tier}</span> + </td> + <td class="text-xs opacity-70"> + {stateLabel(row.state)} + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + + {#if layout} + <div class="mt-3 border-t border-base-300 pt-3"> + <div class="flex items-center justify-between gap-3"> + <div> + <p class="text-sm font-medium">Layout</p> + {#if layout.providerActive} + <p class="text-xs opacity-60">Managed by the active layout plugin.</p> + {:else} + <p class="text-xs opacity-60">No layout plugin enabled — no layout to reset.</p> + {/if} + </div> + <button + type="button" + class="btn btn-warning btn-sm" + disabled={!layout.providerActive || resetting} + title={layout.providerActive + ? "Restore this workspace's default layout" + : "No layout plugin enabled"} + onclick={() => (resetOpen = true)} + > + Reset layout to default + </button> + </div> + {#if resetError} + <p class="mt-1 text-xs text-error" role="alert">Resetting the layout failed: {resetError}</p> + {/if} + </div> + {/if} + + <div class="modal-action"> + <button type="button" class="btn btn-sm" onclick={onClose}>Close</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onClose}></div> +</div> + +{#if resetOpen && layout} + <ResetLayoutConfirm onConfirm={() => void confirmReset()} onCancel={() => (resetOpen = false)} /> +{/if} diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts new file mode 100644 index 0000000..4c75ab3 --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts @@ -0,0 +1,258 @@ +import type { WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginsHttp } from "../adapter/http"; +import type { WorkspaceLayoutPort } from "../logic/types"; +import { createPluginsStore } from "../store.svelte"; +import WorkspacePluginModal from "./WorkspacePluginModal.svelte"; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + // Core: enabled + locked (the server materializes core to enabled and + // refuses its PUT with a 409 core-locked — plan 06 §6.6). + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + // Loaded standard: interactive. + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + // UNLOADED: greyed out + unselectable (§7 strict layering). + { pluginId: "notloaded", tier: "standard", state: "disabled", loaded: false, locked: false }, + ], + enabledProviderCount: 1, +}; + +function fakeHttp(overrides: Partial<PluginsHttp> = {}): PluginsHttp { + return { + list: vi.fn(async () => null), + scan: vi.fn(), + load: vi.fn(), + unload: vi.fn(), + reload: vi.fn(), + workspacePlugins: vi.fn(async () => wsResponse), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderModal(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { workspaceId: "ws", workspaceTitle: "My Workspace", plugins: store, onClose: vi.fn() }, + }); + return { store, http }; +} + +/** A layout port (plan 04 §4.4) scripted per test. */ +function fakeLayoutPort( + providerActive = true, + reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null), + refresh: WorkspaceLayoutPort["refresh"] = vi.fn(), +): WorkspaceLayoutPort { + return { providerActive, reset, refresh }; +} + +describe("WorkspacePluginModal", () => { + it("renders the workspace's plugin rows", async () => { + await renderModal(); + expect(screen.getByText("Plugins — My Workspace")).toBeInTheDocument(); + expect(screen.getByText("kernel")).toBeInTheDocument(); + expect(screen.getByText("mcp")).toBeInTheDocument(); + }); + + it("checkboxes reflect the workspace state (enabled=checked, undefined=unchecked)", async () => { + await renderModal(); + expect(screen.getByRole("checkbox", { name: /kernel/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Enable mcp/i })).not.toBeChecked(); + }); + + it("unloaded plugins are greyed out AND unselectable (§7)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /notloaded/i }); + expect(checkbox).toBeDisabled(); + // The row is visually greyed (the opacity marker class). + expect(checkbox.closest("tr")?.className).toContain("opacity-50"); + // The tooltip points at the global manager. + expect(checkbox.getAttribute("title")).toMatch(/plugin manager/i); + }); + + it("core rows render locked (checked but unselectable — the server refuses the PUT)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /kernel/i }); + expect(checkbox).toBeDisabled(); + expect(checkbox.getAttribute("title")).toMatch(/Core plugins are enabled by default/i); + }); + + it("checking a selectable row writes enabled via the store", async () => { + const user = userEvent.setup(); + const { http } = await renderModal(); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(http.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "enabled"); + }); + + it("unchecking a checked row writes disabled", async () => { + const user = userEvent.setup(); + const enabled: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "mcp", tier: "standard", state: "enabled", loaded: true, locked: false }, + ], + enabledProviderCount: 1, + }; + const fake = fakeHttp({ workspacePlugins: async () => enabled }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Disable mcp/i })); + expect(fake.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "disabled"); + }); + + it("a successful toggle calls the layout port's refresh (the per-workspace activation may change the served tree)", async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + layout: fakeLayoutPort( + true, + vi.fn(async () => null), + refresh, + ), + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(refresh).toHaveBeenCalledWith("ws"); + }); + + it("a REFUSED toggle (state write fails) still calls refresh — the probe is the idempotent catch-up", async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + const fake = fakeHttp({ + setWorkspaceState: async () => ({ + ok: false, + error: "Plugin is not loaded — load it globally first", + }), + }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + layout: fakeLayoutPort( + true, + vi.fn(async () => null), + refresh, + ), + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + // The attempted state change re-probes either way (a refused write changed + // nothing, so the probe re-syncs the same tree — harmless and consistent). + expect(refresh).toHaveBeenCalledWith("ws"); + }); + + it("shows the template hint for the default workspace", async () => { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("default"); + render(WorkspacePluginModal, { + props: { + workspaceId: "default", + workspaceTitle: "Default", + plugins: store, + onClose: vi.fn(), + }, + }); + expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument(); + }); +}); + +describe("WorkspacePluginModal — the reset-layout affordance (plan 04 §4.4)", () => { + async function renderWithLayout(layout?: ReturnType<typeof fakeLayoutPort>) { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + ...(layout ? { layout } : {}), + onClose: vi.fn(), + }, + }); + return layout; + } + + it("renders an ENABLED reset button when a layout provider is active", async () => { + await renderWithLayout(fakeLayoutPort(true)); + const button = screen.getByRole("button", { name: /reset layout to default/i }); + expect(button).toBeEnabled(); + }); + + it("the button is DISABLED with the 'no layout plugin enabled' reason when no provider is active", async () => { + await renderWithLayout(fakeLayoutPort(false)); + const button = screen.getByRole("button", { name: /reset layout to default/i }); + expect(button).toBeDisabled(); + expect(button.getAttribute("title")).toMatch(/no layout plugin enabled/i); + expect(screen.getByText(/no layout plugin enabled/i)).toBeInTheDocument(); + }); + + it("no layout port → the layout section doesn't render at all (tests / no wiring)", async () => { + await renderWithLayout(undefined); + expect(screen.queryByRole("button", { name: /reset layout to default/i })).toBeNull(); + }); + + it("confirming the dialog resets the layout (and the dialog copy names the consequence)", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => null); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + // The confirm dialog names the consequence exactly (§4.4 copy). + expect( + screen.getByText( + /layout returns to the default; plugin and dropdown customizations are lost/i, + ), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /^reset$/i })); + expect(reset).toHaveBeenCalledWith("ws"); + }); + + it("cancelling the dialog never resets", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => null); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(reset).not.toHaveBeenCalled(); + }); + + it("a failed reset surfaces the reason as an alert", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => "no layout plugin enabled"); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + await user.click(screen.getByRole("button", { name: /^reset$/i })); + expect(await screen.findByRole("alert")).toHaveTextContent(/resetting the layout failed/i); + }); +}); diff --git a/src/features/search-settings/index.ts b/src/features/search-settings/index.ts new file mode 100644 index 0000000..a6ea160 --- /dev/null +++ b/src/features/search-settings/index.ts @@ -0,0 +1,22 @@ +export type { + LoadSearchBackendConfig, + SaveSearchBackend, + SearchBackendAvailability, + SearchBackendConfig, + SearchBackendConfigResult, +} from "./logic/view-model"; +export { + buildSearchBackendOptions, + NONE_BACKEND_ID, + NONE_BACKEND_LABEL, + normalizeSearchBackendConfig, + searchBackendChanged, + searchBackendLabel, +} from "./logic/view-model"; +export { default as SearchBackendField } from "./ui/SearchBackendField.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "Search Settings", + description: "Global search backend selection for the web_search tool (GET/PUT /search/config)", +} as const; diff --git a/src/features/search-settings/logic/view-model.test.ts b/src/features/search-settings/logic/view-model.test.ts new file mode 100644 index 0000000..7f3ceb5 --- /dev/null +++ b/src/features/search-settings/logic/view-model.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + buildSearchBackendOptions, + NONE_BACKEND_ID, + NONE_BACKEND_LABEL, + normalizeSearchBackendConfig, + type SearchBackendConfig, + searchBackendChanged, + searchBackendLabel, +} from "./view-model"; + +const CONFIG: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: true }, + ], +}; + +describe("buildSearchBackendOptions", () => { + it("lists the available backends in wire order plus the None off state last", () => { + expect(buildSearchBackendOptions(CONFIG)).toEqual([ + { id: "firecrawl", label: "Firecrawl (self-hosted)", disabled: false }, + { id: "umans", label: "Umans (Exa)", disabled: false }, + { id: "none", label: NONE_BACKEND_LABEL, disabled: false }, + ]); + }); + + it("disables unconfigured backends with a suffix (listed, never an error)", () => { + const config: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: false }, + ], + }; + const options = buildSearchBackendOptions(config); + expect(options[1]).toEqual({ + id: "umans", + label: "Umans (Exa) (not configured)", + disabled: true, + }); + expect(options[2]?.id).toBe(NONE_BACKEND_ID); + }); + + it("prepends an unknown persisted selection so the select can render it", () => { + const config: SearchBackendConfig = { backend: "gone", available: [] }; + const options = buildSearchBackendOptions(config); + expect(options[0]).toEqual({ id: "gone", label: "gone (unknown)", disabled: false }); + expect(options.at(-1)?.id).toBe(NONE_BACKEND_ID); + }); + + it("does not duplicate the none option when 'none' is selected", () => { + const config: SearchBackendConfig = { backend: NONE_BACKEND_ID, available: [] }; + const options = buildSearchBackendOptions(config); + expect(options.filter((o) => o.id === NONE_BACKEND_ID)).toHaveLength(1); + }); +}); + +describe("searchBackendLabel", () => { + it("maps a backend id to its advertised label", () => { + expect(searchBackendLabel(CONFIG, "umans")).toBe("Umans (Exa)"); + }); + + it("maps 'none' to the off-state label", () => { + expect(searchBackendLabel(CONFIG, NONE_BACKEND_ID)).toBe(NONE_BACKEND_LABEL); + }); + + it("falls back to the raw id for an unknown backend", () => { + expect(searchBackendLabel(CONFIG, "gone")).toBe("gone"); + }); +}); + +describe("searchBackendChanged", () => { + it("detects a real change and a no-op reselect", () => { + expect(searchBackendChanged("umans", "firecrawl")).toBe(true); + expect(searchBackendChanged("firecrawl", "firecrawl")).toBe(false); + expect(searchBackendChanged(NONE_BACKEND_ID, NONE_BACKEND_ID)).toBe(false); + }); +}); + +describe("normalizeSearchBackendConfig", () => { + it("normalizes a well-formed response", () => { + expect( + normalizeSearchBackendConfig({ + backend: "umans", + available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }], + }), + ).toEqual({ + backend: "umans", + available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }], + }); + }); + + it("degrades a malformed/partial response instead of crashing the renderer", () => { + expect(normalizeSearchBackendConfig(null)).toEqual({ backend: NONE_BACKEND_ID, available: [] }); + expect(normalizeSearchBackendConfig({})).toEqual({ backend: NONE_BACKEND_ID, available: [] }); + expect( + normalizeSearchBackendConfig({ + backend: "firecrawl", + available: [null, 42, { id: "" }, { label: "no id" }, { id: "umans" }], + }), + ).toEqual({ + backend: "firecrawl", + available: [{ id: "umans", label: "umans", configured: false }], + }); + }); +}); diff --git a/src/features/search-settings/logic/view-model.ts b/src/features/search-settings/logic/view-model.ts new file mode 100644 index 0000000..5d172c9 --- /dev/null +++ b/src/features/search-settings/logic/view-model.ts @@ -0,0 +1,124 @@ +/** + * Pure core for the search-settings feature — zero DOM, zero effects, zero + * Svelte. + * + * The "Search backend" dropdown (Settings pane) picks the engine backing the + * `web_search` tool's SEARCH mode. The selection is GLOBAL and persisted on + * the backend (`GET/PUT /search/config`); decided entries are exactly + * "Firecrawl (self-hosted)" + "Umans (Exa)" plus "None (search disabled)" as + * the off state. Scrape/crawl always route to the self-hosted Firecrawl + * backend regardless of the selection; the tool's `map` mode is removed + * entirely (backend plan). + */ + +/** + * One selectable backend as the backend's wire advertises it (mirrors + * `@dispatch/wire`'s `SearchBackendAvailability`). + */ +export interface SearchBackendAvailability { + readonly id: string; + readonly label: string; + /** The availability gate (umans: `UMANS_API_KEY` resolves; firecrawl: always). */ + readonly configured: boolean; +} + +/** The global search-backend config (`GET /search/config` response). */ +export interface SearchBackendConfig { + /** The selected backend id, or `"none"` (search off). */ + readonly backend: string; + readonly available: readonly SearchBackendAvailability[]; +} + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +/** Outcome of `GET/PUT /search/config`. `unavailable` marks the 503 degrade + * (the backend's search-config service not loaded) so the UI can disable the + * field with a "search not available" hint instead of a generic error. */ +export type SearchBackendConfigResult = + | { readonly ok: true; readonly config: SearchBackendConfig } + | { readonly ok: false; readonly error: string; readonly unavailable?: boolean }; + +export type LoadSearchBackendConfig = () => Promise<SearchBackendConfigResult>; + +export type SaveSearchBackend = (backend: string) => Promise<SearchBackendConfigResult>; + +/** The explicit off-state option label. */ +export const NONE_BACKEND_ID = "none"; +export const NONE_BACKEND_LABEL = "None (search disabled)"; + +/** One rendered `<option>` of the dropdown. */ +export interface SearchBackendOption { + readonly id: string; + readonly label: string; + /** Unconfigured backends stay LISTED but disabled — never an error. */ + readonly disabled: boolean; +} + +/** + * Build the dropdown options from the config: every advertised backend in + * wire order (unconfigured ones disabled with a "not configured" suffix), + * then the "None (search disabled)" off state appended last. If the persisted + * selection is not among them (an unknown id from a newer/older backend), it + * is prepended so the select can render it truthfully. + */ +export function buildSearchBackendOptions( + config: SearchBackendConfig, +): readonly SearchBackendOption[] { + const options: SearchBackendOption[] = config.available.map((b) => ({ + id: b.id, + label: b.configured ? b.label : `${b.label} (not configured)`, + disabled: !b.configured, + })); + if ( + config.backend !== NONE_BACKEND_ID && + !config.available.some((b) => b.id === config.backend) + ) { + options.unshift({ id: config.backend, label: `${config.backend} (unknown)`, disabled: false }); + } + options.push({ id: NONE_BACKEND_ID, label: NONE_BACKEND_LABEL, disabled: false }); + return options; +} + +/** The human-facing label for a backend id (the saved / error messages). */ +export function searchBackendLabel(config: SearchBackendConfig, id: string): string { + if (id === NONE_BACKEND_ID) { + return NONE_BACKEND_LABEL; + } + return config.available.find((b) => b.id === id)?.label ?? id; +} + +/** Whether selecting `selected` would change the persisted backend. */ +export function searchBackendChanged(selected: string, persisted: string): boolean { + return selected !== persisted; +} + +/** + * Normalize the untyped JSON at the network seam so a malformed/partial + * response can never crash the renderer (mirrors `normalizeHeartbeatConfig`). + * Unknown entries degrade to unconfigured; a missing backend falls back to + * `"none"` rendering (the safest default for a UI that must not lie). + */ +export function normalizeSearchBackendConfig(raw: unknown): SearchBackendConfig { + const obj = (raw ?? {}) as { + backend?: unknown; + available?: unknown; + }; + const available = Array.isArray(obj.available) ? obj.available : []; + return { + backend: + typeof obj.backend === "string" && obj.backend.length > 0 ? obj.backend : NONE_BACKEND_ID, + available: available.flatMap((entry) => { + if (entry === null || typeof entry !== "object") return []; + const e = entry as { id?: unknown; label?: unknown; configured?: unknown }; + if (typeof e.id !== "string" || e.id.length === 0) return []; + return [ + { + id: e.id, + label: typeof e.label === "string" ? e.label : e.id, + configured: e.configured === true, + }, + ]; + }), + }; +} diff --git a/src/features/search-settings/ui/SearchBackendField.svelte b/src/features/search-settings/ui/SearchBackendField.svelte new file mode 100644 index 0000000..c1ae450 --- /dev/null +++ b/src/features/search-settings/ui/SearchBackendField.svelte @@ -0,0 +1,142 @@ +<script lang="ts"> + /** + * The "Search backend" dropdown (Settings pane) — picks the engine backing + * the `web_search` tool's SEARCH mode. GLOBAL setting persisted on the + * backend (`GET/PUT /search/config`); SAVE ON CHANGE (no Set button). + * Unconfigured backends stay listed but disabled; "None (search disabled)" + * is the off state. The config affects search mode only — scrape/crawl + * always route to the self-hosted Firecrawl backend. + * + * Thin shell: seeds/reverts the select from the last PERSISTED value, shows + * saving/saved/error feedback, and degrades to a disabled field with a + * "search not available" hint when the backend's search-config service is + * not loaded (503) — matching heartbeat's degrade philosophy. + */ + import { untrack } from "svelte"; + import { + buildSearchBackendOptions, + type LoadSearchBackendConfig, + NONE_BACKEND_ID, + type SaveSearchBackend, + searchBackendChanged, + searchBackendLabel, + type SearchBackendConfig, + } from "../logic/view-model"; + + let { + load, + save, + }: { + /** Fetch the persisted global config (called once on mount). */ + load: LoadSearchBackendConfig; + /** Persist a new backend selection (save-on-change). */ + save: SaveSearchBackend; + } = $props(); + + let config = $state<SearchBackendConfig | null>(null); + let loading = $state(true); + let loadUnavailable = $state(false); + let loadError = $state<string | null>(null); + let selected = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state<string | null>(null); + + $effect(() => { + // Load ONCE on first mount (the setting is GLOBAL — no workspace + // re-seeding); guard against strict-mode double effects via the flag. + untrack(async () => { + if (config !== null || loadUnavailable) return; + loading = true; + loadError = null; + const result = await load(); + loading = false; + if (result.ok) { + config = result.config; + selected = result.config.backend; + lastSeed = result.config.backend; + } else { + loadUnavailable = result.unavailable === true; + loadError = result.error; + } + }); + }); + + const options = $derived(config === null ? [] : buildSearchBackendOptions(config)); + const persisted = $derived(lastSeed); + + async function handleChange(): Promise<void> { + if (saving || config === null) return; + const next = selected; + if (!searchBackendChanged(next, persisted)) return; + saving = true; + error = null; + justSaved = null; + const result = await save(next); + saving = false; + if (result.ok) { + config = result.config; + // Reflect the persisted value back (the backend is authoritative). + selected = result.config.backend; + lastSeed = result.config.backend; + justSaved = searchBackendLabel(result.config, result.config.backend); + } else { + error = result.error; + // Revert the select to the last persisted value on failure. + selected = persisted; + } + } + + function onSelectChange(): void { + error = null; + justSaved = null; + void handleChange(); + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Search backend</span> + {#if loading} + <div class="skeleton h-7 w-full" aria-label="Search backend loading"></div> + {:else if config === null} + <select + class="select select-bordered select-sm w-full text-xs" + disabled + aria-label="Search backend" + > + <option>{loadUnavailable ? "Search not available" : (loadError ?? "Unavailable")}</option> + </select> + {#if loadError && !loadUnavailable} + <p class="text-xs text-error">{loadError}</p> + {/if} + {:else} + <select + class="select select-bordered select-sm w-full text-xs" + bind:value={selected} + disabled={saving} + onchange={onSelectChange} + aria-label="Search backend" + > + {#each options as option (option.id)} + <option value={option.id} disabled={option.disabled}>{option.label}</option> + {/each} + </select> + {#if saving} + <p class="text-xs opacity-60 flex items-center gap-1"> + <span class="loading loading-spinner loading-xs"></span> + Saving… + </p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved !== null} + <p class="text-xs text-success">Saved: {justSaved}.</p> + {/if} + {/if} + {#if !loadUnavailable} + <p class="text-xs opacity-50"> + Affects the web_search tool's <em>search</em> mode only — scrape and crawl always use the + self-hosted Firecrawl backend, and the tool's map mode has been removed. + </p> + {/if} +</div> diff --git a/src/features/search-settings/ui/SearchBackendField.test.ts b/src/features/search-settings/ui/SearchBackendField.test.ts new file mode 100644 index 0000000..d8597ef --- /dev/null +++ b/src/features/search-settings/ui/SearchBackendField.test.ts @@ -0,0 +1,119 @@ +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { SearchBackendConfig, SearchBackendConfigResult } from "../logic/view-model"; +import SearchBackendField from "./SearchBackendField.svelte"; + +const CONFIG: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: false }, + ], +}; + +function okLoad(backend = "firecrawl"): () => Promise<SearchBackendConfigResult> { + return async () => ({ ok: true, config: { ...CONFIG, backend } }); +} + +function okSave(backend: string): () => Promise<SearchBackendConfigResult> { + const saves: string[] = []; + const impl = async (value: string): Promise<SearchBackendConfigResult> => { + saves.push(value); + return { ok: true, config: { ...CONFIG, backend: value } }; + }; + // attach the recorder for assertions + (impl as unknown as { saves: string[] }).saves = saves; + void backend; + return impl as () => Promise<SearchBackendConfigResult>; +} + +describe("SearchBackendField", () => { + it("renders the options from the loaded config (unconfigured disabled, None last)", async () => { + render(SearchBackendField, { props: { load: okLoad(), save: okSave("none") } }); + const select = await screen.findByLabelText("Search backend"); + await waitFor(() => expect(select).toBeEnabled()); + const options = select.querySelectorAll("option"); + const labels = [...options].map((o) => o.textContent); + expect(labels).toEqual([ + "Firecrawl (self-hosted)", + "Umans (Exa) (not configured)", + "None (search disabled)", + ]); + expect(options[1]?.disabled).toBe(true); + expect((select as HTMLSelectElement).value).toBe("firecrawl"); + }); + + it("saves on change and shows the saved confirmation with the new label", async () => { + const user = userEvent.setup(); + const save = okSave("umans"); + render(SearchBackendField, { props: { load: okLoad(), save } }); + + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + + // Select the umans entry (disabled here would block it, so flip configured + // via a custom config below); instead select "none" which is always enabled. + await user.selectOptions(select, "none"); + await waitFor(() => expect(screen.getByText(/Saved/)).toBeInTheDocument()); + expect(screen.getByText(/Saved: None \(search disabled\)\./)).toBeInTheDocument(); + const saves = (save as unknown as { saves: string[] }).saves; + expect(saves).toEqual(["none"]); + expect(select.value).toBe("none"); + }); + + it("reverts the selection to the persisted value when the save fails", async () => { + const user = userEvent.setup(); + const save = async (): Promise<SearchBackendConfigResult> => ({ + ok: false, + error: "Set search backend failed (HTTP 500)", + }); + render(SearchBackendField, { props: { load: okLoad(), save } }); + + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + await user.selectOptions(select, "none"); + + expect(await screen.findByText(/HTTP 500/)).toBeInTheDocument(); + expect(select.value).toBe("firecrawl"); + }); + + it("renders disabled with a 'search not available' hint on the 503 degrade", async () => { + const load = async (): Promise<SearchBackendConfigResult> => ({ + ok: false, + error: "Search config failed (HTTP 503)", + unavailable: true, + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + const select = await screen.findByLabelText("Search backend"); + await waitFor(() => expect(select).toBeDisabled()); + expect(screen.getByText(/Search not available/)).toBeInTheDocument(); + // No help text in the unavailable state (nothing to explain — search is off). + expect(screen.queryByText(/search.*mode only/i)).not.toBeInTheDocument(); + }); + + it("renders a loading skeleton while the initial GET is in flight", async () => { + let resolveLoad!: (r: SearchBackendConfigResult) => void; + const load = () => + new Promise<SearchBackendConfigResult>((resolve) => { + resolveLoad = resolve; + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + expect(screen.getByLabelText("Search backend loading")).toBeInTheDocument(); + resolveLoad({ ok: true, config: CONFIG }); + await screen.findByLabelText("Search backend"); + }); + + it("prepends an unknown persisted selection as a selectable option", async () => { + const load = async (): Promise<SearchBackendConfigResult> => ({ + ok: true, + config: { backend: "gone", available: [] }, + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + expect(select.value).toBe("gone"); + const labels = [...select.querySelectorAll("option")].map((o) => o.textContent); + expect(labels).toEqual(["gone (unknown)", "None (search disabled)"]); + }); +}); 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 8d55eb7..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,12 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; import type { QueuedMessage } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { parseMessageQueuePayload } from "./message-queue"; +import { + messageQueueCount, + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, +} from "./message-queue"; const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({ id, @@ -46,3 +52,89 @@ describe("parseMessageQueuePayload", () => { expect(parseMessageQueuePayload(payload)).toBeNull(); }); }); + +describe("selectVisibleMessages", () => { + it("returns the snapshot unchanged when nothing is cancelled", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + expect(selectVisibleMessages(messages, new Set())).toBe(messages); + }); + + it("hides the optimistically-cancelled row", () => { + const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")]; + expect(selectVisibleMessages(messages, new Set(["m2"])).map((m) => m.id)).toEqual(["m1", "m3"]); + }); + + it("hides multiple cancelled rows", () => { + const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")]; + expect(selectVisibleMessages(messages, new Set(["m1", "m3"])).map((m) => m.id)).toEqual(["m2"]); + }); + + it("tolerates a cancelled id not present in the snapshot (no-op)", () => { + const messages = [msg("m1", "a")]; + expect(selectVisibleMessages(messages, new Set(["ghost"])).map((m) => m.id)).toEqual(["m1"]); + }); +}); + +describe("reconcileCancelledIds", () => { + it("returns an empty set when nothing was cancelled", () => { + const result = reconcileCancelledIds([msg("m1", "a")], new Set()); + expect(result.size).toBe(0); + }); + + it("drops ids the surface confirmed gone (no longer queued)", () => { + // m1 still queued (cancel pending), m2 confirmed gone (left the snapshot). + const messages = [msg("m1", "a")]; + const result = reconcileCancelledIds(messages, new Set(["m1", "m2"])); + expect([...result]).toEqual(["m1"]); + }); + + it("returns the SAME set identity when nothing changed (no spurious cycle)", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + const cancelled = new Set(["m1", "m2"]); + expect(reconcileCancelledIds(messages, cancelled)).toBe(cancelled); + }); + + it("returns an empty set when all cancels were confirmed", () => { + const messages = [msg("m1", "a")]; + expect(reconcileCancelledIds(messages, new Set(["m2", "m3"])).size).toBe(0); + }); + + it("keeps a still-queued cancelled id (cancel still pending)", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + const result = reconcileCancelledIds(messages, new Set(["m2"])); + 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 79707a5..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,5 +42,75 @@ 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"; + +/** + * Optimistic-removal view-model for the queue list. + * + * The `chat.queue.cancel` op is fire-and-forget + idempotent: success is + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot), not by a reply. To avoid a flash of the row lingering + * for a round-trip, the renderer hides a row the instant the user clicks cancel + * (tracking the cancelled id locally), then reconciles when the surface pushes + * the post-cancel snapshot. These two pure helpers drive that — the component + * holds the cancelled-id set as a thin `$state` wrapper and delegates all + * decisions here. + */ + +/** + * The messages the renderer should show: the surface snapshot MINUS any + * optimistically-cancelled ids (a cancel whose surface confirmation hasn't + * arrived yet). Pure — no mutation of inputs. + */ +export function selectVisibleMessages( + messages: readonly QueuedMessage[], + cancelledIds: ReadonlySet<string>, +): readonly QueuedMessage[] { + if (cancelledIds.size === 0) return messages; + return messages.filter((m) => !cancelledIds.has(m.id)); +} + +/** + * Reconcile the cancelled-id set against a NEW surface snapshot: keep only the + * ids that are STILL queued (the cancel is pending — its surface confirmation + * hasn't landed). Drop ids that have left the snapshot: the server confirmed + * the removal (or the message drained as steering / the queue cleared), so the + * optimistic hide is no longer needed. This keeps the set bounded — it never + * outlives the rows it tracks. Pure — returns a NEW set (callers assign it to + * the reactive `$state`). + */ +export function reconcileCancelledIds( + messages: readonly QueuedMessage[], + cancelledIds: ReadonlySet<string>, +): ReadonlySet<string> { + if (cancelledIds.size === 0) return EMPTY_STRING_SET; + const stillQueued = new Set<string>(); + for (const m of messages) { + if (cancelledIds.has(m.id)) stillQueued.add(m.id); + } + // Same set back → return the input identity so the component's `$state` setter + // sees no change (avoids a spurious reactive cycle). + if (stillQueued.size === cancelledIds.size) { + let same = true; + for (const id of cancelledIds) { + if (!stillQueued.has(id)) { + same = false; + break; + } + } + if (same) return cancelledIds; + } + return stillQueued; +} + +const EMPTY_STRING_SET: ReadonlySet<string> = new Set<string>(); diff --git a/src/features/surface-host/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte index 554fa02..b260de2 100644 --- a/src/features/surface-host/ui/MessageQueueList.svelte +++ b/src/features/surface-host/ui/MessageQueueList.svelte @@ -1,21 +1,92 @@ <script lang="ts"> - import { parseMessageQueuePayload } from "../logic/message-queue"; + import { + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, + } from "../logic/message-queue"; - let { payload }: { readonly payload: unknown } = $props(); + let { + payload, + onCancel, + }: { + readonly payload: unknown; + /** + * Cancel (remove) a single queued message by id (`chat.queue.cancel`). + * Required-but-nullable (not optional) so a parent can thread a + * `| undefined` callback through under `exactOptionalPropertyTypes`: + * `undefined` → a read-only list (no × affordance), e.g. a generic surface + * context with no conversation scope. The list still reconciles from the + * surface either way. + */ + readonly onCancel: ((messageId: string) => void) | undefined; + } = $props(); // Parse defensively; an unparseable payload yields null → render nothing // (graceful skip, per the custom-field contract). const data = $derived(parseMessageQueuePayload(payload)); + + // Optimistic-removal: a cancelled id is hidden the instant the user clicks, + // ahead of the surface's post-cancel snapshot. Reconcile on every payload + // change so a confirmed-removed id (no longer in the snapshot) is dropped + // from the set — keeping it bounded (pure helpers in logic/message-queue). + let cancelledIds = $state<ReadonlySet<string>>(new Set()); + + $effect(() => { + const parsed = data; + if (parsed === null) return; + const next = reconcileCancelledIds(parsed.messages, cancelledIds); + if (next !== cancelledIds) cancelledIds = next; + }); + + const visible = $derived( + data === null ? [] : selectVisibleMessages(data.messages, cancelledIds), + ); + + function handleCancel(messageId: string): void { + // Optimistically hide the row + fire the cancel (fire-and-forget; the + // surface update reconciles). Idempotent server-side, so a double-click or + // a cancel of an already-drained message is a silent no-op — no rollback. + if (cancelledIds.has(messageId)) return; + const next = new Set(cancelledIds); + next.add(messageId); + cancelledIds = next; + onCancel?.(messageId); + } </script> {#if data !== null && data.messages.length > 0} <ul class="flex flex-col gap-1 text-sm"> - {#each data.messages as msg (msg.id)} - <li class="rounded-box bg-base-200 px-3 py-2"> - <p class="whitespace-pre-wrap">{msg.text}</p> - <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> - {new Date(msg.queuedAt).toLocaleTimeString()} - </time> + {#each visible as msg (msg.id)} + <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2"> + <div class="min-w-0 flex-1"> + <p class="whitespace-pre-wrap break-words">{msg.text}</p> + <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> + {new Date(msg.queuedAt).toLocaleTimeString()} + </time> + </div> + {#if onCancel !== undefined} + <button + type="button" + class="btn btn-ghost btn-xs btn-square shrink-0 opacity-60 hover:opacity-100" + title="Cancel this queued message" + aria-label="Cancel this queued message" + onclick={() => handleCancel(msg.id)} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3.5 w-3.5" + > + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + {/if} </li> {/each} </ul> diff --git a/src/features/surface-host/ui/MessageQueueList.test.ts b/src/features/surface-host/ui/MessageQueueList.test.ts new file mode 100644 index 0000000..53044b3 --- /dev/null +++ b/src/features/surface-host/ui/MessageQueueList.test.ts @@ -0,0 +1,135 @@ +import type { QueuedMessage } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import MessageQueueList from "./MessageQueueList.svelte"; + +function msg(id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage { + return { id, text, queuedAt }; +} + +/** Build the message-queue surface field payload. */ +function payload(messages: readonly QueuedMessage[]): { messages: readonly QueuedMessage[] } { + return { messages }; +} + +describe("MessageQueueList", () => { + it("renders each queued message's text", () => { + render(MessageQueueList, { + props: { + payload: payload([msg("m1", "steer left"), msg("m2", "go right")]), + onCancel: undefined, + }, + }); + expect(screen.getByText("steer left")).toBeInTheDocument(); + expect(screen.getByText("go right")).toBeInTheDocument(); + }); + + it("renders nothing when the queue is empty", () => { + const { container } = render(MessageQueueList, { + props: { payload: payload([]), onCancel: undefined }, + }); + expect(container.querySelector("ul")).toBeNull(); + }); + + it("renders nothing for a malformed payload (graceful skip)", () => { + const { container } = render(MessageQueueList, { + props: { payload: { nope: true }, onCancel: undefined }, + }); + expect(container.querySelector("ul")).toBeNull(); + }); + + it("omits the cancel button when no onCancel is wired (read-only)", () => { + render(MessageQueueList, { + props: { payload: payload([msg("m1", "steer")]), onCancel: undefined }, + }); + expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull(); + }); + + it("renders a cancel button per row when onCancel is wired", () => { + render(MessageQueueList, { + props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel: vi.fn() }, + }); + expect(screen.getAllByRole("button", { name: /cancel/i })).toHaveLength(2); + }); + + it("clicking cancel fires onCancel with the row's message id and optimistically hides the row", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render(MessageQueueList, { + props: { payload: payload([msg("m1", "keep me"), msg("m2", "cancel me")]), onCancel }, + }); + + // Both rows visible before click. + expect(screen.getByText("keep me")).toBeInTheDocument(); + expect(screen.getByText("cancel me")).toBeInTheDocument(); + + const buttons = screen.getAllByRole("button", { name: /cancel/i }); + // Cancel the SECOND row (m2). Buttons mirror row order. + const cancelM2 = buttons[1]; + if (cancelM2 === undefined) throw new Error("expected two cancel buttons"); + await user.click(cancelM2); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCancel).toHaveBeenCalledWith("m2"); + // m2 is optimistically hidden immediately; m1 remains. + expect(screen.getByText("keep me")).toBeInTheDocument(); + expect(screen.queryByText("cancel me")).toBeNull(); + }); + + it("does not fire onCancel twice for a double-click on the same row (idempotent client-side)", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render(MessageQueueList, { + props: { payload: payload([msg("m1", "x")]), onCancel }, + }); + + const button = screen.getByRole("button", { name: /cancel/i }); + await user.click(button); + // The row is gone after the first click; the button left the DOM, so a + // second click on the stale element is a no-op — onCancel fires once. + await user.click(button).catch(() => {}); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("reconciles from the surface: a cancelled id gone from the snapshot clears the optimistic hide", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const { rerender } = render(MessageQueueList, { + props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel }, + }); + + // Cancel m1 — it hides optimistically. + const cancelButtons = screen.getAllByRole("button", { name: /cancel/i }); + const cancelM1 = cancelButtons[0]; + if (cancelM1 === undefined) throw new Error("expected a cancel button"); + await user.click(cancelM1); + expect(screen.queryByText("a")).toBeNull(); + expect(screen.getByText("b")).toBeInTheDocument(); + + // The surface pushes the post-cancel snapshot: m1 is gone (server confirmed). + // A NEW message m3 arrives in the same snapshot. The list renders m2 + m3. + rerender({ payload: payload([msg("m2", "b"), msg("m3", "c")]), onCancel }); + expect(screen.queryByText("a")).toBeNull(); + expect(screen.getByText("b")).toBeInTheDocument(); + expect(screen.getByText("c")).toBeInTheDocument(); + }); + + it("re-shows a row if the surface snapshot still contains a cancelled id (cancel not yet confirmed)", async () => { + // Edge case: the cancel is in flight and the surface hasn't updated yet, but + // a re-render with the SAME snapshot must keep the row hidden (optimistic). + const user = userEvent.setup(); + const onCancel = vi.fn(); + const samePayload = payload([msg("m1", "a")]); + const { rerender } = render(MessageQueueList, { + props: { payload: samePayload, onCancel }, + }); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(screen.queryByText("a")).toBeNull(); + + // Re-render with the same (stale) snapshot — the row stays hidden. + rerender({ payload: payload([msg("m1", "a")]), onCancel }); + expect(screen.queryByText("a")).toBeNull(); + }); +}); diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index aed8d03..8ce8ade 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -11,7 +11,22 @@ import TodoList from "./TodoList.svelte"; import Toggle from "./Toggle.svelte"; - let { spec, onInvoke }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props(); + let { + spec, + onInvoke, + onCancelQueuedMessage, + }: { + spec: SurfaceSpec; + onInvoke: (msg: InvokeMessage) => void; + /** + * Cancel a queued message by id — threaded ONLY to the `message-queue` + * renderer. Optional + scoped: generic surfaces (which pass nothing) keep + * rendering a read-only queue list. Kept as a typed callback (never a + * stringly-typed bus); the renderer dispatch is on `rendererId` (a renderer + * KIND), never the surface id. + */ + onCancelQueuedMessage?: (messageId: string) => void; + } = $props(); const plan = $derived(planSurface(spec)); // Consecutive stats render together as one aligned table; everything else is @@ -40,7 +55,7 @@ {#if group.field.rendererId === "table"} <SurfaceTable payload={group.field.payload} /> {:else if group.field.rendererId === "message-queue"} - <MessageQueueList payload={group.field.payload} /> + <MessageQueueList payload={group.field.payload} onCancel={onCancelQueuedMessage} /> {:else if group.field.rendererId === "todo"} <TodoList payload={group.field.payload} /> {/if} diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 7520215..145a3d9 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -1,3 +1,17 @@ +export type { GroupTone, TabGroupForest, TabGroupNode } from "./tab-groups"; +export { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + GROUP_TONES, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + groupTone, + lastRowIsGroup, + visibleGroupParents, +} from "./tab-groups"; export type { Tab, TabsState } from "./tabs"; export { activeTab, @@ -9,6 +23,7 @@ export { newDraft, selectTab, setModel, + setTabGroupParent, setTitle, shortHandle, } from "./tabs"; 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 new file mode 100644 index 0000000..46b04e7 --- /dev/null +++ b/src/features/tabs/tab-groups.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + groupTone, + lastRowIsGroup, + visibleGroupParents, +} from "./tab-groups"; +import type { Tab } from "./tabs"; + +function tab(id: string, parent?: string): Tab { + return { + conversationId: id, + model: "m", + title: id.toUpperCase(), + workspaceId: "w", + ...(parent !== undefined ? { tabGroupParentId: parent } : {}), + }; +} + +const ids = (tabs: readonly Tab[]): string[] => tabs.map((t) => t.conversationId); + +describe("visibleGroupParents", () => { + it("keeps a parent that is another open tab", () => { + const parents = visibleGroupParents([tab("a"), tab("b", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBe("a"); + }); + + it("drops a parent that is not open (closed / other workspace) or is the tab itself", () => { + const parents = visibleGroupParents([tab("a", "gone"), tab("b", "b")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + }); + + it("treats every member of a cycle as a root, but keeps hangers-on below them", () => { + const parents = visibleGroupParents([tab("a", "b"), tab("b", "a"), tab("c", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + expect(parents.get("c")).toBe("a"); + }); +}); + +describe("buildTabGroups", () => { + it("returns flat roots and depth 0 when nothing is grouped", () => { + const forest = buildTabGroups([tab("a"), tab("b"), tab("c")]); + expect(forest.maxDepth).toBe(0); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "b", "c"]); + expect(forest.roots.every((n) => n.children.length === 0)).toBe(true); + }); + + it("nests children under their parent in tab order, preserving root order", () => { + // Child listed BEFORE its parent still nests under it. + const forest = buildTabGroups([tab("x", "p"), tab("a"), tab("p"), tab("y", "p")]); + expect(forest.maxDepth).toBe(1); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "p"]); + const p = forest.roots[1]; + expect(p?.children.map((n) => n.tab.conversationId)).toEqual(["x", "y"]); + }); + + it("supports nested groups (subagents with subagents) and reports the depth", () => { + const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("gg", "g")]); + expect(forest.maxDepth).toBe(3); + const c = forest.roots[0]?.children[0]; + expect(c?.tab.conversationId).toBe("c"); + expect(c?.children[0]?.children[0]?.tab.conversationId).toBe("gg"); + }); + + it("renders every tab exactly once", () => { + const tabs = [tab("a", "b"), tab("b", "a"), tab("c", "a"), tab("d", "nope"), tab("e", "c")]; + const seen: string[] = []; + const walk = (nodes: ReturnType<typeof buildTabGroups>["roots"]): void => { + for (const n of nodes) { + seen.push(n.tab.conversationId); + walk(n.children); + } + }; + walk(buildTabGroups(tabs).roots); + expect(seen.sort()).toEqual(["a", "b", "c", "d", "e"]); + }); +}); + +describe("groupDescendants / directGroupChildren / groupParentCandidates", () => { + const tabs = [tab("p"), tab("c1", "p"), tab("c2", "p"), tab("g", "c1"), tab("solo")]; + + it("collects transitive descendants", () => { + expect([...groupDescendants(tabs, "p")].sort()).toEqual(["c1", "c2", "g"]); + expect([...groupDescendants(tabs, "c1")]).toEqual(["g"]); + expect(groupDescendants(tabs, "solo").size).toBe(0); + }); + + it("lists direct children only", () => { + expect(directGroupChildren(tabs, "p")).toEqual(["c1", "c2"]); + }); + + it("offers every tab except self, descendants (cycle) and the current parent", () => { + expect(ids(groupParentCandidates(tabs, "c1"))).toEqual(["c2", "solo"]); + expect(ids(groupParentCandidates(tabs, "p"))).toEqual(["solo"]); + expect(ids(groupParentCandidates(tabs, "solo"))).toEqual(["p", "c1", "c2", "g"]); + }); +}); + +describe("cascadeCloseIds (the ids a cascading tab close removes)", () => { + const tabs = [ + tab("p"), + tab("c1", "p"), + tab("c2", "p"), + tab("g", "c1"), // sub-sub-agent: grouped under c1, still cascades from p + tab("solo"), + ]; + + it("returns the tab first, then its transitive descendants in tab order", () => { + expect(cascadeCloseIds(tabs, "p")).toEqual(["p", "c1", "c2", "g"]); + }); + + it("starts the cascade at a mid-level tab, taking only its own subtree", () => { + expect(cascadeCloseIds(tabs, "c1")).toEqual(["c1", "g"]); + }); + + it("returns just the tab itself when it has no subagents (closes without a prompt)", () => { + expect(cascadeCloseIds(tabs, "solo")).toEqual(["solo"]); + expect(cascadeCloseIds(tabs, "c2")).toEqual(["c2"]); + }); + + it("matches the rendered forest for cycles and manual regrouping", () => { + const cycle = [tab("p", "c1"), tab("c1", "p"), tab("g", "c1")]; + expect(cascadeCloseIds(cycle, "p")).toEqual(["p"]); + expect(cascadeCloseIds(cycle, "c1")).toEqual(["c1", "g"]); + const regrouped = [tab("p"), tab("c1", "solo"), tab("g", "c1"), tab("solo")]; + expect(cascadeCloseIds(regrouped, "p")).toEqual(["p"]); + expect(cascadeCloseIds(regrouped, "solo")).toEqual(["solo", "c1", "g"]); + }); + + it("ignores tabs whose group parent is no longer open", () => { + const stale = [tab("p"), tab("orphan", "gone")]; + expect(cascadeCloseIds(stale, "p")).toEqual(["p"]); + }); +}); + +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); + expect(groupBleedPx(1, 0)).toBe(0); + }); + + it("bleeds outer boxes further than inner ones, the outermost filling the gutter", () => { + const max = 3; + const bleeds = [1, 2, 3].map((l) => groupBleedPx(l, max)); + expect(bleeds[0]).toBe(groupGutterPx(max)); + expect(bleeds[0]).toBeGreaterThan(bleeds[1] ?? 0); + expect(bleeds[1]).toBeGreaterThan(bleeds[2] ?? 0); + expect(bleeds[2]).toBeGreaterThan(0); + }); + + it("keeps the gutter bounded for deep nesting", () => { + expect(groupGutterPx(12)).toBeLessThanOrEqual(24); + expect(groupBleedPx(12, 12)).toBeGreaterThan(0); + }); +}); + +describe("lastRowIsGroup (mirrors grid row-dense placement)", () => { + const leaf = (id: string) => ({ tab: tab(id), children: [] }); + const group = (id: string) => ({ tab: tab(id), children: [leaf(`${id}-kid`)] }); + + it("is false with only cards", () => { + expect(lastRowIsGroup([])).toBe(false); + expect(lastRowIsGroup([leaf("a"), leaf("b"), leaf("c")])).toBe(false); + }); + + it("is true when a nested group comes last", () => { + expect(lastRowIsGroup([leaf("a"), group("g")])).toBe(true); + expect(lastRowIsGroup([group("g")])).toBe(true); + }); + + it("stays true when a later card back-fills an earlier half row", () => { + // [a, _] then [g, g]; b fills a's half row → g is still the last row. + expect(lastRowIsGroup([leaf("a"), group("g"), leaf("b")])).toBe(true); + }); + + it("is false when a later card has to open a new row below the group", () => { + expect(lastRowIsGroup([group("g"), leaf("a")])).toBe(false); + expect(lastRowIsGroup([leaf("a"), leaf("b"), group("g"), leaf("c")])).toBe(false); + }); +}); diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts new file mode 100644 index 0000000..ff3ddd4 --- /dev/null +++ b/src/features/tabs/tab-groups.ts @@ -0,0 +1,211 @@ +import type { Tab } from "./tabs"; + +/** + * Subagent tab groups — the PURE model behind the tab grid. + * + * Each tab may name a tab group parent (`Tab.tabGroupParentId`, the effective + * value from the backend). The tab grid renders the tabs as a forest: a tab + * with ≥1 visible child is a GROUP (double-width card + a darker box wrapping + * it and its children); everything else is a single half-width card. Groups + * nest (subagents can summon subagents). Purely presentational. + */ + +export interface TabGroupNode { + readonly tab: Tab; + /** Visible children, in tab order. Non-empty ⇒ this node renders as a group. */ + readonly children: readonly TabGroupNode[]; +} + +export interface TabGroupForest { + /** Top-level nodes, in tab order. */ + readonly roots: readonly TabGroupNode[]; + /** Deepest group-box nesting level (0 = no groups, 1 = only top-level groups, …). */ + readonly maxDepth: number; +} + +/** + * The VISIBLE tab group parent of every tab in `tabs`: its `tabGroupParentId` + * when that names ANOTHER tab in the same set, else null. A tab whose + * ancestor chain loops back to itself (a cycle — never produced by the + * backend, but tolerated) is treated as a root, so rendering never loops. + */ +export function visibleGroupParents(tabs: readonly Tab[]): ReadonlyMap<string, string | null> { + const byId = new Map(tabs.map((t) => [t.conversationId, t])); + const direct = (id: string): string | null => { + const p = byId.get(id)?.tabGroupParentId; + return p !== undefined && p !== id && byId.has(p) ? p : null; + }; + const out = new Map<string, string | null>(); + for (const tab of tabs) { + const id = tab.conversationId; + const parent = direct(id); + let cyclic = false; + const seen = new Set<string>([id]); + let cursor = parent; + while (cursor !== null) { + if (cursor === id) { + cyclic = true; + break; + } + if (seen.has(cursor)) break; // a loop above us that doesn't include us + seen.add(cursor); + cursor = direct(cursor); + } + out.set(id, cyclic ? null : parent); + } + return out; +} + +/** Build the group forest for one workspace's tab set (input order preserved). */ +export function buildTabGroups(tabs: readonly Tab[]): TabGroupForest { + const parents = visibleGroupParents(tabs); + const childIds = new Map<string, string[]>(); + for (const tab of tabs) { + const p = parents.get(tab.conversationId) ?? null; + if (p === null) continue; + const list = childIds.get(p); + if (list === undefined) childIds.set(p, [tab.conversationId]); + else list.push(tab.conversationId); + } + const byId = new Map(tabs.map((t) => [t.conversationId, t])); + let maxDepth = 0; + const build = (tab: Tab, depth: number): TabGroupNode => { + const ids = childIds.get(tab.conversationId) ?? []; + if (ids.length > 0) maxDepth = Math.max(maxDepth, depth + 1); + const children: TabGroupNode[] = []; + for (const cid of ids) { + const child = byId.get(cid); + if (child !== undefined) children.push(build(child, depth + 1)); + } + return { tab, children }; + }; + const roots = tabs + .filter((t) => (parents.get(t.conversationId) ?? null) === null) + .map((t) => build(t, 0)); + return { roots, maxDepth }; +} + +/** Every (transitive) descendant id of `conversationId` in the VISIBLE grouping. */ +export function groupDescendants(tabs: readonly Tab[], conversationId: string): Set<string> { + const parents = visibleGroupParents(tabs); + const out = new Set<string>(); + let grew = true; + while (grew) { + grew = false; + for (const [id, p] of parents) { + if (out.has(id) || id === conversationId || p === null) continue; + if (p === conversationId || out.has(p)) { + out.add(id); + grew = true; + } + } + } + return out; +} + +/** + * The tabs removed by a CASCADING close of `conversationId` (the user chose + * "also close its subagents"): the tab itself first, then its transitive + * visible-group descendants in tab order. ONE decision covers the whole + * subtree — closing never re-prompts per level. A single-element result (the + * tab alone) means the tab has no subagents and closes without a prompt. + */ +export function cascadeCloseIds(tabs: readonly Tab[], conversationId: string): string[] { + const descendants = groupDescendants(tabs, conversationId); + const ids = [conversationId]; + for (const tab of tabs) { + if (descendants.has(tab.conversationId)) ids.push(tab.conversationId); + } + return ids; +} + +/** + * The tabs `conversationId` may be moved under (the "Add to group" menu): + * every other tab EXCEPT itself, its descendants (that would form a cycle), + * and its current visible group parent (already there). Tab order preserved. + */ +export function groupParentCandidates(tabs: readonly Tab[], conversationId: string): Tab[] { + const parents = visibleGroupParents(tabs); + const current = parents.get(conversationId) ?? null; + const descendants = groupDescendants(tabs, conversationId); + return tabs.filter( + (t) => + t.conversationId !== conversationId && + t.conversationId !== current && + !descendants.has(t.conversationId), + ); +} + +/** Ids of the tabs DIRECTLY grouped under `conversationId` (visible grouping). */ +export function directGroupChildren(tabs: readonly Tab[], conversationId: string): string[] { + const parents = visibleGroupParents(tabs); + return tabs + .filter((t) => parents.get(t.conversationId) === conversationId) + .map((t) => t.conversationId); +} + +/** + * Whether a group box's LAST grid row is a nested group box (rather than a + * card). Mirrors the grid's `row dense` auto-placement: the group parent's + * double-width card fills row 0; a card takes the first free half-row (or + * opens a new row); a nested group (spans both columns) always opens a new + * row. Used to trim the outer box's bottom padding so the gap below a + * nested box matches the gap at its sides (an even-looking box). + */ +export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean { + let openHalfRows = 0; // rows with exactly one free cell + let lastIsGroup = false; // the row-0 parent card is not a group row + for (const child of children) { + if (child.children.length > 0) { + lastIsGroup = true; // opens a new (last) row + } else if (openHalfRows > 0) { + openHalfRows -= 1; // back-fills an earlier half row; last row unchanged + } else { + openHalfRows += 1; // opens a new (last) row, half-filled + lastIsGroup = false; + } + } + return lastIsGroup; +} + +/** + * 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 + * exactly the same two grid columns as top-level cards — all cards keep + * IDENTICAL dimensions at every depth. Outer boxes bleed more than inner + * ones so nested edges stay visible. The step shrinks for deep nesting so the + * total gutter (`groupGutterPx`) stays bounded. + */ +export function groupBleedPx(level: number, maxDepth: number): number { + if (maxDepth <= 0 || level < 1 || level > maxDepth) return 0; + return (maxDepth - level + 1) * groupStepPx(maxDepth); +} + +/** The horizontal gutter the grid must reserve so the outermost box fits. */ +export function groupGutterPx(maxDepth: number): number { + return maxDepth <= 0 ? 0 : maxDepth * groupStepPx(maxDepth); +} + +const MAX_STEP_PX = 6; +const MAX_GUTTER_PX = 18; + +function groupStepPx(maxDepth: number): number { + return Math.max(2, Math.min(MAX_STEP_PX, Math.floor(MAX_GUTTER_PX / maxDepth))); +} diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts index d044b1f..8e63171 100644 --- a/src/features/tabs/tabs-store.svelte.ts +++ b/src/features/tabs/tabs-store.svelte.ts @@ -1,3 +1,4 @@ +import type { NamedAgentSelectionSnapshot } from "../named-agents"; import type { Tab, TabsState } from "./tabs"; import { initialState, @@ -7,7 +8,10 @@ import { openTab as reduceOpenTab, selectTab as reduceSelectTab, setModel as reduceSetModel, + setSnapshot as reduceSetSnapshot, + setTabGroupParent as reduceSetTabGroupParent, setTitle as reduceSetTitle, + setWorkspaceId as reduceSetWorkspaceId, activeTab as selectActiveTab, } from "./tabs"; @@ -27,7 +31,15 @@ export interface TabsStore { selectTab(conversationId: string): void; closeTab(conversationId: string): void; setModel(conversationId: string, model: string): void; + /** + * Set (or clear, with null) a tab's Named Agent snapshot (plan §7). The caller + * keeps the snapshot's captured `model` in sync with the tab's `model`. + */ + setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void; setTitle(conversationId: string, title: string): void; + setWorkspaceId(conversationId: string, workspaceId: string): void; + /** Set (string) or clear (null) a tab's tab group parent (visual subagent group). */ + setTabGroupParent(conversationId: string, parentId: string | null): void; } export function createTabsStore(storage: TabsStorage): TabsStore { @@ -66,8 +78,17 @@ export function createTabsStore(storage: TabsStorage): TabsStore { setModel(conversationId: string, model: string): void { apply(reduceSetModel(state, conversationId, model)); }, + setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void { + apply(reduceSetSnapshot(state, conversationId, snapshot)); + }, setTitle(conversationId: string, title: string): void { apply(reduceSetTitle(state, conversationId, title)); }, + setWorkspaceId(conversationId: string, workspaceId: string): void { + apply(reduceSetWorkspaceId(state, conversationId, workspaceId)); + }, + setTabGroupParent(conversationId: string, parentId: string | null): void { + apply(reduceSetTabGroupParent(state, conversationId, parentId)); + }, }; } diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index ec93076..696e994 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -10,6 +10,7 @@ import { newDraft, selectTab, setModel, + setTabGroupParent, setTitle, shortHandle, } from "./tabs"; @@ -223,3 +224,31 @@ describe("shortHandle", () => { expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); }); }); + +describe("setTabGroupParent", () => { + const base: TabsState = { + tabs: [ + { conversationId: "a", model: "m", title: "A", workspaceId: "w" }, + { conversationId: "b", model: "m", title: "B", workspaceId: "w" }, + ], + activeConversationId: "a", + }; + + it("sets the tab group parent on the named tab only", () => { + const next = setTabGroupParent(base, "b", "a"); + expect(next.tabs[1]?.tabGroupParentId).toBe("a"); + expect(next.tabs[0]).toBe(base.tabs[0]); + expect(next.activeConversationId).toBe("a"); + }); + + it("clears the key entirely with null", () => { + const grouped = setTabGroupParent(base, "b", "a"); + const cleared = setTabGroupParent(grouped, "b", null); + expect(cleared.tabs[1]).toEqual(base.tabs[1]); + expect("tabGroupParentId" in (cleared.tabs[1] ?? {})).toBe(false); + }); + + it("is a no-op for an unknown tab", () => { + expect(setTabGroupParent(base, "zz", "a").tabs).toEqual(base.tabs); + }); +}); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index 63cda35..b2dad7d 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -1,9 +1,25 @@ +import type { NamedAgentSelectionSnapshot } from "../named-agents"; + export interface Tab { readonly conversationId: string; readonly model: string; readonly title: string; /** The workspace this tab belongs to (the workspace's URL slug). */ readonly workspaceId: string; + /** + * The Named Agent selection snapshot, when this conversation was selected via + * a Named Agent (plan §7). Retained so the conversation keeps showing the + * friendly label (and its captured raw model) even after the Named Agent is + * renamed/removed. Absent for a raw model selection. + */ + readonly snapshot?: NamedAgentSelectionSnapshot; + /** + * The EFFECTIVE tab group parent (backend `tabGroupParentId`): the + * conversation whose subagent group this tab is visually nested under in + * the tab grid. Purely presentational — distinct from the lineage + * `parentConversationId`. Absent = ungrouped (a top-level tab). + */ + readonly tabGroupParentId?: string; } export interface TabsState { @@ -73,7 +89,36 @@ export function closeTab(state: TabsState, conversationId: string): TabsState { } export function setModel(state: TabsState, conversationId: string, model: string): TabsState { - const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t)); + // A raw-model selection clears any Named Agent snapshot (a raw selection is + // not "via a Named Agent"). Omit the key entirely (`exactOptionalPropertyTypes`). + const tabs = state.tabs.map((t) => + t.conversationId !== conversationId ? t : omitSnapshot({ ...t, model }), + ); + return { tabs, activeConversationId: state.activeConversationId }; +} + +/** Drop the optional `snapshot` key (used to clear a Named Agent selection). */ +function omitSnapshot(tab: Tab): Tab { + if (!("snapshot" in tab)) return tab; + const { snapshot: _omit, ...rest } = tab; + void _omit; + return rest; +} + +/** + * Set (or clear) a tab's Named Agent selection snapshot (plan §7). The snapshot's + * captured `model` should equal the tab's `model` (the resolved raw model); this + * reducer does not rewrite `model` — the caller keeps them in sync. Pass `null` + * to clear (a raw model selection). + */ +export function setSnapshot( + state: TabsState, + conversationId: string, + snapshot: NamedAgentSelectionSnapshot | null, +): TabsState { + const tabs = state.tabs.map((t) => + t.conversationId === conversationId ? { ...t, ...(snapshot === null ? {} : { snapshot }) } : t, + ); return { tabs, activeConversationId: state.activeConversationId }; } @@ -82,6 +127,37 @@ export function setTitle(state: TabsState, conversationId: string, title: string return { tabs, activeConversationId: state.activeConversationId }; } +/** + * Set (string) or clear (null) a tab's tab group parent. Omits the key when + * cleared (`exactOptionalPropertyTypes`). No-op for an unknown tab. + */ +export function setTabGroupParent( + state: TabsState, + conversationId: string, + parentId: string | null, +): TabsState { + const tabs = state.tabs.map((t) => { + if (t.conversationId !== conversationId) return t; + if (parentId !== null) return { ...t, tabGroupParentId: parentId }; + if (!("tabGroupParentId" in t)) return t; + const { tabGroupParentId: _omit, ...rest } = t; + void _omit; + return rest; + }); + return { tabs, activeConversationId: state.activeConversationId }; +} + +export function setWorkspaceId( + state: TabsState, + conversationId: string, + workspaceId: string, +): TabsState { + const tabs = state.tabs.map((t) => + t.conversationId === conversationId ? { ...t, workspaceId } : t, + ); + return { tabs, activeConversationId: state.activeConversationId }; +} + export function activeTab(state: TabsState): Tab | null { if (state.activeConversationId === null) return null; return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index ae2a021..8f775a1 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,6 +1,7 @@ -import { fireEvent, render, screen } from "@testing-library/svelte"; +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"; @@ -165,7 +166,7 @@ describe("TabList", () => { expect(screen.getByText("7c2d")).toBeInTheDocument(); }); - it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => { + it("renders the tabs as a 2-column grid of same-size cards", () => { render(TabList, { props: { tabs: sampleTabs, @@ -176,14 +177,17 @@ describe("TabList", () => { }, }); - // The scroll region containing the tab rows is a vertical flex column. const tabs = screen.getAllByRole("tab"); - expect(tabs.length).toBeGreaterThan(0); - const region = tabs[0]?.parentElement; - expect(region).toHaveClass("flex-col"); + const grid = tabs[0]?.parentElement; + expect(grid).toHaveClass("grid", "grid-cols-2"); + // Every card has the same fixed height; none is double-width without a group. + for (const t of tabs) { + expect(t).toHaveClass("h-[4.5rem]"); + expect(t).not.toHaveClass("col-span-2"); + } }); - it("fixes the tab list 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, @@ -195,8 +199,8 @@ describe("TabList", () => { }); const tabs = screen.getAllByRole("tab"); - const region = tabs[0]?.parentElement; - expect(region).toHaveClass("h-[40vh]"); + const region = tabs[0]?.parentElement?.parentElement; + expect(region).toHaveClass("h-[70vh]"); expect(region).toHaveClass("overflow-y-auto"); }); @@ -295,4 +299,527 @@ describe("TabList", () => { // c3 has no status → no spinner at all. expect(idleTab.querySelector(".loading")).toBeNull(); }); + + describe("subagent groups", () => { + const grouped: readonly Tab[] = [ + { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" }, + { + conversationId: "c1", + model: "m", + title: "Child 1", + workspaceId: "w", + tabGroupParentId: "p", + }, + { + conversationId: "c2", + model: "m", + title: "Child 2", + workspaceId: "w", + tabGroupParentId: "p", + }, + { + conversationId: "g", + model: "m", + title: "Grandchild", + workspaceId: "w", + tabGroupParentId: "c1", + }, + { conversationId: "solo", model: "m", title: "Solo", workspaceId: "w" }, + ]; + + function renderGrouped(onSetTabGroup = vi.fn()) { + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup, + }, + }); + return onSetTabGroup; + } + + const card = (id: string): HTMLElement => { + const el = document.querySelector<HTMLElement>(`[role="tab"][data-conversation-id="${id}"]`); + if (el === null) throw new Error(`card ${id} not found`); + return el; + }; + + it("renders a group parent double-width inside a darker group box with its subagents", () => { + renderGrouped(); + const box = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + expect(box).not.toBeNull(); + expect(box).toHaveClass("col-span-2"); + expect(card("p")).toHaveClass("col-span-2"); + expect(card("p")).toHaveTextContent("2 subagents"); + expect(box?.contains(card("c1"))).toBe(true); + expect(box?.contains(card("c2"))).toBe(true); + expect(box?.contains(card("solo"))).toBe(false); + // Subagents are regular half-width cards. + expect(card("c2")).not.toHaveClass("col-span-2"); + }); + + it("nests a subagent that has its own subagents in an inner box", () => { + renderGrouped(); + const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]'); + expect(inner).not.toBeNull(); + expect(outer?.contains(inner)).toBe(true); + expect(inner?.dataset.level).toBe("2"); + expect(card("c1")).toHaveClass("col-span-2"); + expect(inner?.contains(card("g"))).toBe(true); + // Inner boxes bleed less than outer ones, so nested edges stay visible + // while the cards keep identical widths. + const bleed = (el: HTMLElement | null) => Number.parseFloat(el?.style.paddingLeft ?? ""); + expect(bleed(inner)).toBeGreaterThan(0); + expect(bleed(outer)).toBeGreaterThan(bleed(inner)); + // Equal internal padding on all four sides (an even-looking box). + for (const box of [outer, inner]) { + const s = box?.style; + expect([s?.paddingTop, s?.paddingRight, s?.paddingBottom]).toEqual([ + s?.paddingLeft, + s?.paddingLeft, + s?.paddingLeft, + ]); + } + // 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}`); + }); + + it("returns the group parent to a half-width card once all its subagent tabs close", async () => { + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + expect(card("p")).toHaveClass("col-span-2"); + + // Close one subagent: still a group (1 left). + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + expect(card("p")).toHaveClass("col-span-2"); + expect(card("p")).toHaveTextContent("1 subagent"); + + // Close the rest (c1 + its own subagent g): back to a plain half-width card. + await rerender({ + ...props, + tabs: grouped.filter((t) => ["p", "solo"].includes(t.conversationId)), + }); + expect(card("p")).not.toHaveClass("col-span-2"); + expect(card("p")).not.toHaveTextContent("subagent"); + 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, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + // Close the grandchild only: c1 goes back to half width, p stays a group. + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "g") }); + expect(card("c1")).not.toHaveClass("col-span-2"); + expect(document.querySelector('[data-tab-group="c1"]')).toBeNull(); + expect(card("p")).toHaveClass("col-span-2"); + }); + + it("trims the bottom padding under a nested box on the last row to match its side gaps", () => { + // p's children: c2 (card) then c1 (nested group) → c1's box is the last row. + const lastIsGroup: readonly Tab[] = [ + { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" }, + { conversationId: "c2", model: "m", title: "C2", workspaceId: "w", tabGroupParentId: "p" }, + { conversationId: "c1", model: "m", title: "C1", workspaceId: "w", tabGroupParentId: "p" }, + { conversationId: "g", model: "m", title: "G", workspaceId: "w", tabGroupParentId: "c1" }, + ]; + render(TabList, { + props: { + tabs: lastIsGroup, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]'); + const px = (v: string | undefined) => Number.parseFloat(v ?? ""); + const outerSide = px(outer?.style.paddingLeft); + const innerBleed = px(inner?.style.paddingLeft); + // Side gap between the two box edges = outer bleed − inner bleed; the + // bottom gap must equal it. + expect(px(outer?.style.paddingBottom)).toBe(outerSide - innerBleed); + expect(px(outer?.style.paddingTop)).toBe(outerSide); + // The inner box ends in a card → untouched, even on all sides. + expect(px(inner?.style.paddingBottom)).toBe(innerBleed); + }); + + it("right-click opens the menu; 'Remove from group' ungroups the tab", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("c2"), { clientX: 10, clientY: 10 }); + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("menuitem", { name: /Remove from group/ })); + expect(onSetTabGroup).toHaveBeenCalledWith("c2", null); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("'Add to group' lists valid parents (no self/descendants/current) and moves the tab", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("c1")); + const candidates = [...document.querySelectorAll<HTMLElement>("[data-group-candidate]")].map( + (el) => el.dataset.groupCandidate, + ); + expect(candidates).toEqual(["c2", "solo"]); + const solo = document.querySelector<HTMLElement>('[data-group-candidate="solo"]'); + if (solo === null) throw new Error("solo candidate missing"); + await fireEvent.click(solo); + expect(onSetTabGroup).toHaveBeenCalledWith("c1", "solo"); + }); + + it("a group parent's menu can ungroup all of its direct subagents", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("p")); + expect(screen.queryByRole("menuitem", { name: /Remove from group/ })).toBeNull(); + await fireEvent.click(screen.getByRole("menuitem", { name: /Ungroup 2 subagents/ })); + expect(onSetTabGroup).toHaveBeenCalledTimes(2); + expect(onSetTabGroup).toHaveBeenCalledWith("c1", null); + expect(onSetTabGroup).toHaveBeenCalledWith("c2", null); + }); + + it("Escape closes the menu without changing anything", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("solo")); + expect(screen.getByRole("menu")).toBeInTheDocument(); + await fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(onSetTabGroup).not.toHaveBeenCalled(); + }); + + it("touch-and-hold opens the menu and swallows the follow-up click", async () => { + vi.useFakeTimers(); + try { + const onSelect = vi.fn(); + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect, + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }, + }); + await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 }); + await vi.advanceTimersByTimeAsync(600); + expect(screen.getByRole("menu")).toBeInTheDocument(); + await fireEvent.click(card("solo")); + expect(onSelect).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("a touch that moves away cancels the long-press", async () => { + vi.useFakeTimers(); + try { + renderGrouped(); + await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 }); + await fireEvent.pointerMove(card("solo"), { + pointerType: "touch", + clientX: 5, + clientY: 60, + }); + await vi.advanceTimersByTimeAsync(600); + expect(screen.queryByRole("menu")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + describe("closing a tab with subagents (confirm + cascade)", () => { + const dialog = (): HTMLElement => + screen.getByRole("dialog", { name: "Close subagents too?" }); + + async function clickCloseOn(id: string): Promise<void> { + await fireEvent.click(within(card(id)).getByRole("button", { name: "Close tab" })); + } + + function renderClosable(onClose = vi.fn()) { + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }, + }); + return onClose; + } + + it("closes a tab WITHOUT subagents directly — no confirm dialog", async () => { + const onClose = renderClosable(); + await clickCloseOn("solo"); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("solo"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("shows ONE confirm dialog for a group parent (never one per descendant)", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + // 3 subagent tabs: c1, c2 and the nested grandchild g. + expect(dialog()).toHaveTextContent("3 subagent tabs"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("'No, keep them open' closes only the tab itself", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.click(screen.getByRole("button", { name: /No, keep them open/ })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("p"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("'Yes, close all' cascades to every descendant — no further prompts", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + // p first, then its transitive descendants in tab order (c1, c2, g). + expect(onClose.mock.calls.map((c) => c[0])).toEqual(["p", "c1", "c2", "g"]); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("cascades from a mid-level subagent, taking only its own subtree", async () => { + const onClose = renderClosable(); + await clickCloseOn("c1"); + expect(dialog()).toHaveTextContent("1 subagent tab"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((c) => c[0])).toEqual(["c1", "g"]); + }); + + it("restores keyboard focus to the opener after cancellation", async () => { + renderClosable(); + const user = userEvent.setup(); + const opener = within(card("p")).getByRole("button", { name: "Close tab" }); + await user.click(opener); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(opener).toHaveFocus(); + }); + + it("Cancel and backdrop dismiss without closing any tab", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + await clickCloseOn("p"); + await fireEvent.submit(dialog().querySelector("form") as HTMLFormElement); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("keeps the confirmed subtree snapshot when new tabs arrive", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ + ...props, + tabs: [ + ...grouped, + { + conversationId: "late", + model: "m", + title: "Late", + workspaceId: "w", + tabGroupParentId: "p", + }, + ], + }); + expect(dialog()).toHaveTextContent("3 subagent tabs"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((call) => call[0])).toEqual(["p", "c1", "c2", "g"]); + }); + + it("dismisses when the target disappears (remote close or workspace switch)", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ ...props, tabs: grouped.filter((tab) => tab.conversationId !== "p") }); + expect(screen.queryByRole("dialog")).toBeNull(); + await rerender(props); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("Escape dismisses the dialog without closing anything", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent(dialog(), new Event("cancel", { cancelable: true })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte new file mode 100644 index 0000000..7ffda6c --- /dev/null +++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + + /** + * The close-subagents confirm dialog: shown when the user closes a tab that + * still has subagent tabs in its (visible) subagent group. ONE dialog for + * the whole subtree — "Yes" cascades the close to every transitive + * descendant (sub-sub-agents included, no further prompts); "No" closes + * only the tab itself. Escape / backdrop dismisses without closing anything. + * Native modal dialog with daisyUI chrome: top-layer rendering, focus + * containment, and inert background even inside the animated sidebar. + */ + let { + tab, + subagentCount, + onKeep, + onCloseAll, + onDismiss, + }: { + /** The tab being closed (drives the title shown in the dialog). */ + tab: Tab; + /** Number of tabs in the tab's subagent group (transitive). */ + subagentCount: number; + /** "No" — close only this tab, keep the subagents open. */ + onKeep: () => void; + /** "Yes" — close this tab AND cascade to all its subagents. */ + onCloseAll: () => void; + /** Escape / backdrop — close nothing. */ + onDismiss: () => void; + } = $props(); + function openModal(node: HTMLDialogElement) { + const opener = document.activeElement; + node.showModal(); + return { + destroy() { + node.close(); + if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); + }, + }; + } +</script> + +<dialog + use:openModal + class="modal" + aria-label="Close subagents too?" + aria-describedby="close-subagents-description" + oncancel={(event) => { + event.preventDefault(); + onDismiss(); + }} +> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold">Close subagents too?</h3> + <div id="close-subagents-description" class="flex flex-col gap-1 py-3"> + <p class="text-sm"> + "{tab.title}" has {subagentCount} + {subagentCount === 1 ? "subagent tab" : "subagent tabs"} (including nested ones). + </p> + <p class="text-sm"> + Close them as well? Their own subagents are closed too, and any in-flight turns are + stopped. + </p> + </div> + <div class="modal-action flex-wrap"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onDismiss}>Cancel</button> + <button type="button" class="btn btn-sm" onclick={onKeep}>No, keep them open</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onCloseAll}> + Yes, close all {subagentCount + 1} + </button> + </div> + </div> + <form + method="dialog" + class="modal-backdrop" + onsubmit={(event) => { + event.preventDefault(); + onDismiss(); + }} + > + <button type="submit" aria-label="Dismiss close confirmation">close</button> + </form> +</dialog> diff --git a/src/features/tabs/ui/TabCard.svelte b/src/features/tabs/ui/TabCard.svelte new file mode 100644 index 0000000..ae891c0 --- /dev/null +++ b/src/features/tabs/ui/TabCard.svelte @@ -0,0 +1,234 @@ +<script lang="ts"> + import { cardFlipId } from "../tab-flip"; + import type { Tab } from "../tabs"; + + /** + * One tab in the tab grid — a fixed-height card. Every card has IDENTICAL + * dimensions; a group parent (`wide`) simply spans both grid columns. + * Right-click (or touch-and-hold) asks the owner to open the tab menu. + */ + let { + tab, + handle, + active, + status, + wide = false, + subagentCount = 0, + onSelect, + onClose, + onRename, + onMenu, + }: { + tab: Tab; + /** Short-handle (git-style unique prefix) shown in the id badge. */ + handle: string; + active: boolean; + /** Lifecycle status (drives the spinner), when known. */ + status: string | undefined; + /** Group parent: spans both grid columns. */ + wide?: boolean; + /** Number of direct subagents (shown on a group parent). */ + subagentCount?: number; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onRename?: ((conversationId: string, title: string) => void) | undefined; + /** Open the tab menu at viewport coordinates. */ + onMenu: (conversationId: string, x: number, y: number) => void; + } = $props(); + + // Inline rename: double-click the title to edit, Enter/blur to save. + let editing = $state(false); + let editValue = $state(""); + let editEl = $state<HTMLInputElement>(); + + function startRename(): void { + if (onRename === undefined) return; + editing = true; + editValue = tab.title; + queueMicrotask(() => editEl?.focus()); + } + + function commitRename(): void { + if (!editing) return; + const trimmed = editValue.trim(); + if (trimmed.length > 0 && onRename !== undefined) onRename(tab.conversationId, trimmed); + editing = false; + } + + // Click-to-copy the FULL conversation id; the badge text is selected as the + // feedback (and as a manual-copy fallback when the clipboard is unavailable). + async function copyId(el: HTMLElement): Promise<void> { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + try { + await clipboard.writeText(tab.conversationId); + } catch { + // Selection already lets the user copy manually. + } + } + + // Touch-and-hold → menu (iOS Safari never fires `contextmenu`; Android does, + // and opening twice at the same spot is idempotent). + const LONG_PRESS_MS = 500; + const MOVE_TOLERANCE_PX = 10; + let pressTimer: ReturnType<typeof setTimeout> | null = null; + let pressX = 0; + let pressY = 0; + let suppressClick = false; + + function cancelPress(): void { + if (pressTimer !== null) clearTimeout(pressTimer); + pressTimer = null; + } + + function onPointerDown(e: PointerEvent): void { + if (e.pointerType !== "touch") return; + cancelPress(); + pressX = e.clientX; + pressY = e.clientY; + pressTimer = setTimeout(() => { + pressTimer = null; + suppressClick = true; + onMenu(tab.conversationId, pressX, pressY); + }, LONG_PRESS_MS); + } + + function onPointerMove(e: PointerEvent): void { + if (pressTimer === null) return; + if (Math.hypot(e.clientX - pressX, e.clientY - pressY) > MOVE_TOLERANCE_PX) cancelPress(); + } + + $effect(() => cancelPress); +</script> + +<div + class="tab-card group/card relative flex h-[4.5rem] min-w-0 cursor-pointer flex-col justify-between rounded-md border px-2 py-1.5 text-sm transition-colors {active + ? 'border-primary bg-primary/15' + : 'border-base-content/10 bg-base-300 hover:border-base-content/25'}" + class:col-span-2={wide} + role="tab" + tabindex="0" + 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) { + suppressClick = false; + return; + } + onSelect(tab.conversationId); + }} + onkeydown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter") onSelect(tab.conversationId); + else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { + e.preventDefault(); + const r = e.currentTarget.getBoundingClientRect(); + onMenu(tab.conversationId, r.left + 8, r.bottom); + } + }} + oncontextmenu={(e) => { + e.preventDefault(); + cancelPress(); + onMenu(tab.conversationId, e.clientX, e.clientY); + }} + onpointerdown={onPointerDown} + onpointermove={onPointerMove} + onpointerup={cancelPress} + onpointercancel={cancelPress} + onpointerleave={cancelPress} +> + <div class="flex min-w-0 items-center gap-1"> + <button + type="button" + class="shrink-0 select-text rounded bg-base-100 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content" + data-copy-id={tab.conversationId} + title="Click to copy conversation id" + aria-label={`Copy conversation id ${tab.conversationId}`} + onclick={(e) => { + e.stopPropagation(); + void copyId(e.currentTarget); + }} + > + {handle} + </button> + {#if wide && subagentCount > 0} + <span + class="shrink-0 truncate rounded bg-base-100 px-1 py-0.5 text-[10px] leading-none text-base-content/60" + data-subagent-count={subagentCount} + > + {subagentCount} + {subagentCount === 1 ? "subagent" : "subagents"} + </span> + {/if} + <span class="flex-1"></span> + {#if status === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> + <span + class="loading loading-ring loading-xs shrink-0 text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if status === "active"} + <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> + {/if} + <button + type="button" + class="btn btn-ghost btn-xs -mr-1 h-5 min-h-5 w-5 shrink-0 p-0 text-base-content/60" + aria-label="Close tab" + onclick={(e) => { + e.stopPropagation(); + onClose(tab.conversationId); + }} + > + × + </button> + </div> + {#if editing} + <input + bind:this={editEl} + bind:value={editValue} + class="w-full min-w-0 rounded bg-base-100 px-1 py-0.5 text-left text-xs outline outline-1 outline-primary" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + editing = false; + } + }} + onblur={commitRename} + /> + {:else} + <span + class="line-clamp-2 min-w-0 break-words text-left text-xs leading-tight" + role="button" + tabindex="-1" + title={tab.title} + ondblclick={(e) => { + e.stopPropagation(); + startRename(); + }} + > + {tab.title} + </span> + {/if} +</div> + +<style> + /* Touch-and-hold opens the tab menu — suppress the native callout/selection. */ + .tab-card { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } +</style> diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte new file mode 100644 index 0000000..5b644e0 --- /dev/null +++ b/src/features/tabs/ui/TabGroupBox.svelte @@ -0,0 +1,90 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + import type { TabGroupNode } 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"; + + /** + * A subagent group: a tinted box spanning both grid columns that wraps the + * group parent's (double-width) card + its subagents' cards. Recursive — + * a subagent with its own subagents renders a nested box. The box BLEEDS + * outward (negative margin + equal padding, `groupBleedPx`) so the cards + * 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. 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, + level, + maxDepth, + card, + }: { + node: TabGroupNode; + /** Nesting level of THIS box (1 = outermost). */ + level: number; + maxDepth: number; + /** Renders one card: (tab, wide, subagentCount). */ + card: Snippet<[Tab, boolean, number]>; + } = $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. + const padBottom = $derived( + lastRowIsGroup(node.children) ? bleed - groupBleedPx(level + 1, maxDepth) : bleed, + ); +</script> + +<div + 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="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} + <TabGroupBox node={child} level={level + 1} {maxDepth} {card} /> + {:else} + {@render card(child.tab, false, 0)} + {/if} + {/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 6d85065..8b388f0 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -1,183 +1,220 @@ <script lang="ts"> - import type { Tab } from "../tabs"; - import { shortHandle } from "../tabs"; + import { untrack } from "svelte"; + import { forestFlipIds } from "../tab-flip"; + import { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + groupGutterPx, + groupParentCandidates, + visibleGroupParents, + } from "../tab-groups"; + import type { Tab } from "../tabs"; + import { shortHandle } from "../tabs"; + import CloseSubagentsConfirm from "./CloseSubagentsConfirm.svelte"; + import TabCard from "./TabCard.svelte"; + import TabGroupBox from "./TabGroupBox.svelte"; + import TabMenu from "./TabMenu.svelte"; + import { createTabFlip } from "./tab-flip"; - let { - tabs, - activeConversationId, - statusFor, - onSelect, - onClose, - onNewDraft, - onRename, - }: { - tabs: readonly Tab[]; - activeConversationId: string | null; - /** Returns the conversation's lifecycle status, or undefined when unknown. */ - statusFor?: (conversationId: string) => string | undefined; - onSelect: (conversationId: string) => void; - onClose: (conversationId: string) => void; - onNewDraft: () => void; - onRename?: (conversationId: string, title: string) => void; - } = $props(); + let { + tabs, + activeConversationId, + statusFor, + onSelect, + onClose, + onNewDraft, + onRename, + onSetTabGroup, + }: { + tabs: readonly Tab[]; + activeConversationId: string | null; + /** Returns the conversation's lifecycle status, or undefined when unknown. */ + statusFor?: (conversationId: string) => string | undefined; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onNewDraft: () => void; + onRename?: (conversationId: string, title: string) => void; + /** + * Move a tab into a subagent group (parent id) or out of it (null). Purely + * presentational grouping. When absent, the tab menu is disabled. + */ + onSetTabGroup?: (conversationId: string, parentId: string | null) => void; + } = $props(); - // Git-style short handle (shortest unique prefix) per open tab — the visible - // "tab ID". Derived from the set of open conversation ids; pure helper. - const handles = $derived.by(() => { - const ids = tabs.map((t) => t.conversationId); - const map = new Map<string, string>(); - for (const id of ids) map.set(id, shortHandle(id, ids)); - return map; - }); + // Git-style short handle (shortest unique prefix) per open tab — the visible + // "tab ID". Derived from the set of open conversation ids; pure helper. + const handles = $derived.by(() => { + const ids = tabs.map((t) => t.conversationId); + const map = new Map<string, string>(); + for (const id of ids) map.set(id, shortHandle(id, ids)); + return map; + }); + const handleFor = (id: string): string => handles.get(id) ?? id; - // Inline rename: double-click a tab's title to edit, Enter/blur to save. - let editingId = $state<string | null>(null); - let editValue = $state(""); - let editEl = $state<HTMLInputElement>(); + // The subagent-group forest (pure) + the gutter the outermost box bleeds into. + const forest = $derived(buildTabGroups(tabs)); + const gutter = $derived(groupGutterPx(forest.maxDepth)); - function startRename(tab: Tab): void { - if (onRename === undefined) return; - editingId = tab.conversationId; - editValue = tab.title; - // Focus the input after it renders. - queueMicrotask(() => editEl?.focus()); - } + // 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()); + }); - function commitRename(): void { - const id = editingId; - if (id !== null && onRename !== undefined) { - const trimmed = editValue.trim(); - if (trimmed.length > 0) onRename(id, trimmed); - } - editingId = null; - } + // 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( + menu === null ? null : (tabs.find((t) => t.conversationId === menu?.conversationId) ?? null), + ); + const menuGroupParent = $derived.by(() => { + if (menuTab === null) return null; + const pid = visibleGroupParents(tabs).get(menuTab.conversationId) ?? null; + return pid === null ? null : (tabs.find((t) => t.conversationId === pid) ?? null); + }); + const menuChildren = $derived( + menuTab === null ? [] : directGroupChildren(tabs, menuTab.conversationId), + ); + const menuCandidates = $derived( + menuTab === null ? [] : groupParentCandidates(tabs, menuTab.conversationId), + ); - function cancelRename(): void { - editingId = null; - } + function openMenu(conversationId: string, x: number, y: number): void { + if (onSetTabGroup === undefined) return; + menu = { conversationId, x, y }; + } - // Click-to-copy the agent (conversation) id: clicking the ID badge copies the - // FULL conversationId to the clipboard (the stable, useful id — the badge - // only shows a short prefix) and highlights the badge text as feedback. The - // highlight (text selection) is the indicator — no text is swapped, so the - // badge keeps a stable width. If the clipboard write fails, the selection is - // already in place so the user can Ctrl+C the text manually. - async function copyId(conversationId: string, el: HTMLElement): Promise<void> { - selectText(el); - const clipboard = navigator.clipboard; - if (clipboard === undefined) return; - try { - await clipboard.writeText(conversationId); - } catch { - // Selection already lets the user copy manually. - } - } + // Close-with-confirm: a tab whose (visible) subagent group is non-empty + // gets ONE confirm dialog covering its whole subtree (never one per level). + // The dialog's ids are snapshotted at open time; a tab the backend closed + // in the meantime is a no-op for `onClose`. A plain tab closes directly. + let closeConfirm = $state<{ tab: Tab; cascadeIds: readonly string[] } | null>(null); - function selectText(el: HTMLElement): void { - const range = document.createRange(); - range.selectNodeContents(el); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - } + // A remote close or workspace switch must not leave an actionable dialog + // for a tab that is no longer in this list. + $effect(() => { + if ( + closeConfirm !== null && + !tabs.some((tab) => tab.conversationId === closeConfirm?.tab.conversationId) + ) { + closeConfirm = null; + } + }); + + function requestClose(conversationId: string): void { + menu = null; + const cascadeIds = cascadeCloseIds(tabs, conversationId); + if (cascadeIds.length <= 1) { + onClose(conversationId); + return; + } + const tab = tabs.find((t) => t.conversationId === conversationId); + if (tab === undefined) { + onClose(conversationId); + return; + } + closeConfirm = { tab, cascadeIds }; + } </script> +{#snippet card(tab: Tab, wide: boolean, subagentCount: number)} + <TabCard + {tab} + handle={handleFor(tab.conversationId)} + active={tab.conversationId === activeConversationId} + status={statusFor?.(tab.conversationId)} + {wide} + {subagentCount} + {onSelect} + onClose={requestClose} + {onRename} + onMenu={openMenu} + /> +{/snippet} + <div class="flex flex-col gap-2"> - <!-- Single-column vertical tab list. Fixed at 40% of the viewport height so a - long tab set scrolls inside this region instead of growing the whole sidebar. --> - <div class="flex h-[40vh] flex-col gap-1 overflow-y-auto pr-1"> - {#each tabs as tab (tab.conversationId)} - <div - class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-300" - class:bg-base-300={tab.conversationId === activeConversationId} - role="tab" - tabindex="0" - aria-selected={tab.conversationId === activeConversationId} - title={tab.title} - onclick={() => onSelect(tab.conversationId)} - onkeydown={(e) => { - if (e.key === "Enter") onSelect(tab.conversationId); - }} - > - <button - type="button" - class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content" - data-copy-id={tab.conversationId} - title="Click to copy conversation id" - aria-label={`Copy conversation id ${tab.conversationId}`} - onclick={(e) => { - e.stopPropagation(); - void copyId(tab.conversationId, e.currentTarget); - }} - > - {handles.get(tab.conversationId) ?? tab.conversationId} - </button> - {#if editingId === tab.conversationId} - <input - bind:this={editEl} - bind:value={editValue} - class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary" - onclick={(e) => e.stopPropagation()} - onkeydown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - commitRename(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelRename(); - } - }} - onblur={commitRename} - /> - {:else} - <span - class="min-w-0 flex-1 cursor-pointer truncate text-left" - role="button" - tabindex="-1" - title={tab.title} - ondblclick={(e) => { - e.stopPropagation(); - startRename(tab); - }} - > - {tab.title} - </span> - {/if} - {#if statusFor?.(tab.conversationId) === "queued"} - <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> - <span - class="loading loading-ring loading-xs shrink-0 text-primary" - aria-label="Queued" - title="Waiting for a concurrency slot" - ></span> - {:else if statusFor?.(tab.conversationId) === "active"} - <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> - {/if} - <button - class="btn btn-ghost btn-xs shrink-0" - aria-label="Close tab" - onclick={(e) => { - e.stopPropagation(); - onClose(tab.conversationId); - }} - > - × - </button> - </div> - {/each} - </div> + <!-- 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 + 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="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} /> + {:else} + {@render card(node.tab, false, 0)} + {/if} + {/each} + </div> + </div> - <button - type="button" - class="btn btn-ghost btn-sm w-full border border-base-300" - class:btn-primary={activeConversationId === null} - aria-label="New chat" - onclick={() => onNewDraft()} - > - {#if activeConversationId === null} - New Chat - {:else} - + New chat - {/if} - </button> + <button + type="button" + class="btn btn-ghost btn-sm w-full border border-base-300" + class:btn-primary={activeConversationId === null} + aria-label="New chat" + onclick={() => onNewDraft()} + > + {#if activeConversationId === null} + New Chat + {:else} + + New chat + {/if} + </button> </div> + +{#if menu !== null && menuTab !== null && onSetTabGroup !== undefined} + {@const setGroup = onSetTabGroup} + {@const target = menuTab.conversationId} + {@const children = [...menuChildren]} + <TabMenu + tab={menuTab} + handle={handleFor(target)} + x={menu.x} + y={menu.y} + groupParent={menuGroupParent} + childCount={children.length} + candidates={menuCandidates} + {handleFor} + onAddToGroup={(parentId) => setGroup(target, parentId)} + onRemoveFromGroup={() => setGroup(target, null)} + onUngroupChildren={() => { + for (const id of children) setGroup(id, null); + }} + onDismiss={() => (menu = null)} + /> +{/if} + +{#if closeConfirm !== null} + {@const target = closeConfirm.tab} + {@const cascadeIds = [...closeConfirm.cascadeIds]} + {@const closeOwner = onClose} + <CloseSubagentsConfirm + tab={target} + subagentCount={cascadeIds.length - 1} + onKeep={() => { + closeOwner(target.conversationId); + closeConfirm = null; + }} + onCloseAll={() => { + for (const id of cascadeIds) closeOwner(id); + closeConfirm = null; + }} + onDismiss={() => (closeConfirm = null)} + /> +{/if} diff --git a/src/features/tabs/ui/TabMenu.svelte b/src/features/tabs/ui/TabMenu.svelte new file mode 100644 index 0000000..513463f --- /dev/null +++ b/src/features/tabs/ui/TabMenu.svelte @@ -0,0 +1,145 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + + /** + * The tab context menu (right-click / touch-and-hold on a tab card): add the + * tab to a subagent group, remove it from its group, or release its own + * subagents. Purely presentational grouping. Fixed-positioned at the + * pointer, clamped into the viewport; closes on Escape / outside press / + * scroll / resize. + */ + let { + tab, + handle, + x, + y, + groupParent, + childCount, + candidates, + handleFor, + onAddToGroup, + onRemoveFromGroup, + onUngroupChildren, + onDismiss, + }: { + tab: Tab; + handle: string; + /** Requested viewport position (pointer). */ + x: number; + y: number; + /** The tab's current visible group parent, or null when top-level. */ + groupParent: Tab | null; + /** Number of tabs directly grouped under this tab. */ + childCount: number; + /** Tabs this tab may be moved under. */ + candidates: readonly Tab[]; + handleFor: (conversationId: string) => string; + onAddToGroup: (parentId: string) => void; + onRemoveFromGroup: () => void; + onUngroupChildren: () => void; + onDismiss: () => void; + } = $props(); + + let menuEl = $state<HTMLDivElement>(); + let width = $state(0); + let height = $state(0); + const MARGIN = 8; + + const left = $derived( + Math.max(MARGIN, Math.min(x, (globalThis.innerWidth ?? 0) - width - MARGIN)), + ); + const top = $derived( + Math.max(MARGIN, Math.min(y, (globalThis.innerHeight ?? 0) - height - MARGIN)), + ); + + $effect(() => { + menuEl?.focus(); + }); + + function act(fn: () => void): void { + fn(); + onDismiss(); + } +</script> + +<svelte:window + onkeydown={(e) => { + if (e.key === "Escape") onDismiss(); + }} + onpointerdown={(e) => { + if (menuEl !== undefined && !menuEl.contains(e.target as Node)) onDismiss(); + }} + onresize={onDismiss} +/> + +<div + bind:this={menuEl} + bind:clientWidth={width} + bind:clientHeight={height} + class="fixed z-50 flex w-64 max-w-[calc(100vw-1rem)] flex-col rounded-lg border border-base-content/15 bg-base-100 p-1 text-sm shadow-xl outline-none" + style:left="{left}px" + style:top="{top}px" + role="menu" + tabindex="-1" + aria-label={`Tab menu for ${tab.title}`} + oncontextmenu={(e) => e.preventDefault()} +> + <div class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-xs text-base-content/60"> + <span class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none"> + {handle} + </span> + <span class="truncate">{tab.title}</span> + </div> + + {#if groupParent !== null} + <button + type="button" + role="menuitem" + class="rounded px-2 py-1.5 text-left hover:bg-base-300" + onclick={() => act(onRemoveFromGroup)} + > + Remove from group + <span class="block truncate text-xs text-base-content/50"> + Currently under {handleFor(groupParent.conversationId)} · {groupParent.title} + </span> + </button> + {/if} + + {#if childCount > 0} + <button + type="button" + role="menuitem" + class="rounded px-2 py-1.5 text-left hover:bg-base-300" + onclick={() => act(onUngroupChildren)} + > + Ungroup {childCount} + {childCount === 1 ? "subagent" : "subagents"} + </button> + {/if} + + <div class="mt-1 border-t border-base-content/10 px-2 pt-1.5 pb-1 text-xs text-base-content/60"> + Add to group under… + </div> + {#if candidates.length === 0} + <div class="px-2 py-1.5 text-xs text-base-content/40">No other tabs</div> + {:else} + <div class="flex max-h-56 flex-col overflow-y-auto"> + {#each candidates as candidate (candidate.conversationId)} + <button + type="button" + role="menuitem" + class="flex min-w-0 items-center gap-1.5 rounded px-2 py-1.5 text-left hover:bg-base-300" + data-group-candidate={candidate.conversationId} + onclick={() => act(() => onAddToGroup(candidate.conversationId))} + > + <span + class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60" + > + {handleFor(candidate.conversationId)} + </span> + <span class="truncate">{candidate.title}</span> + </button> + {/each} + </div> + {/if} +</div> 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 18d8939..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 () => { @@ -186,4 +208,67 @@ describe("createWorkspaceHttp", () => { const result = await http.unstar("a"); expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" }); }); + + it("list passes the archived filter as a query param", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list("true"); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces?archived=true`); + }); + + it("list omits the query param when no filter is given", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list(); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); + }); + + it("archive PUTs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: true, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.archive("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/archive`); + expect(call?.[1]).toEqual({ method: "PUT" }); + }); + + it("unarchive DELETEs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.unarchive("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/archive`); + expect(call?.[1]).toEqual({ method: "DELETE" }); + }); + + it("archive surfaces the backend error on a 409 (default workspace)", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 409, body: { error: 'The "default" workspace cannot be archived' } }]), + ); + const result = await http.archive("default"); + expect(result).toEqual({ ok: false, error: 'The "default" workspace cannot be archived' }); + }); }); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 5673881..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 @@ -19,24 +20,34 @@ import type { * safe empty/null on failure (non-fatal — the UI falls back gracefully). * * Endpoints ([email protected]): - * - `GET /workspaces` → list + * - `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) * - `PUT /workspaces/:id/star` (create-on-miss) → star (concurrency priority) * - `DELETE /workspaces/:id/star` (create-on-miss) → unstar + * - `PUT /workspaces/:id/archive` (create-on-miss; 409 for "default") → archive (hide from home grid) + * - `DELETE /workspaces/:id/archive` (create-on-miss; 409 for "default") → unarchive * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult<T> = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }; +/** Which workspaces `GET /workspaces` returns: active-only (default), archived-only, or all. */ +export type WorkspaceArchivedFilter = "true" | "false" | "all"; + export interface WorkspaceHttp { - list(): Promise<readonly WorkspaceEntry[]>; + 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>>; @@ -44,6 +55,10 @@ export interface WorkspaceHttp { star(id: string): Promise<WorkspaceResult<Workspace>>; /** Unstar a workspace. Create-on-miss; no body. */ unstar(id: string): Promise<WorkspaceResult<Workspace>>; + /** Archive a workspace (hide from the home grid; data untouched). Create-on-miss; no body. */ + archive(id: string): Promise<WorkspaceResult<Workspace>>; + /** Unarchive a workspace (restore to the home grid). Create-on-miss; no body. */ + unarchive(id: string): Promise<WorkspaceResult<Workspace>>; delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } @@ -58,9 +73,10 @@ async function errText(res: Response): Promise<string> { export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { return { - async list(): Promise<readonly WorkspaceEntry[]> { + async list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]> { try { - const res = await fetchImpl(`${httpBase}/workspaces`); + const qs = archived !== undefined ? `?archived=${archived}` : ""; + const res = await fetchImpl(`${httpBase}/workspaces${qs}`); if (!res.ok) return []; const data = (await res.json()) as WorkspaceListResponse; return data.workspaces; @@ -86,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", + }; } }, @@ -171,6 +191,30 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + async archive(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + method: "PUT", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Archive failed" }; + } + }, + + async unarchive(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + method: "DELETE", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unarchive failed" }; + } + }, + async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { try { const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { diff --git a/src/features/workspaces/adapter/tile-layout-http.ts b/src/features/workspaces/adapter/tile-layout-http.ts new file mode 100644 index 0000000..58af9d8 --- /dev/null +++ b/src/features/workspaces/adapter/tile-layout-http.ts @@ -0,0 +1,117 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; + +/** + * Home tile-layout HTTP effects — the injected edge that talks to the + * `home-tile-layout` plugin's well-known routes: + * - `GET /home/tile-layout/:mode` → `{ layout }` (a fresh orientation reads + * as the seeded empty document) + * - `PUT /home/tile-layout/:mode` body `{ layout }` → 200 `{ layout }` / + * 400 `{ error }` + * - `DELETE /home/tile-layout/:mode` → the fresh empty document (reset) + * + * Mirrors the layout adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). A 404 is NOT a "no document" signal (the + * backend never 404s a fresh orientation — it seeds): any non-2xx / network + * failure means "the routes aren't served" (an old backend) or "backend + * unreachable" — both degrade the store to its local state with ephemeral + * edits, never an error UI. + */ + +/** Outcome of a tile-layout read/write. */ +export type TileLayoutHttpResult = + | { readonly ok: true; readonly layout: TileLayoutData } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface TileLayoutHttp { + getLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; + putLayout(mode: TileGeometryMode, layout: TileLayoutData): Promise<TileLayoutHttpResult>; + deleteLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a failure — never poisons the store. */ +function malformed(): TileLayoutHttpResult { + return { ok: false, notFound: false, error: "Malformed tile-layout response" }; +} + +function route(httpBase: string, mode: TileGeometryMode): string { + return `${httpBase}/home/tile-layout/${encodeURIComponent(mode)}`; +} + +export function createTileLayoutHttp(httpBase: string, fetchImpl: typeof fetch): TileLayoutHttp { + return { + async getLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode)); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async putLayout(mode, layout): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ layout }), + }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async deleteLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { method: "DELETE" }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + }; +} diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index dab1dec..c7f57d5 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -1,5 +1,18 @@ 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, + DragPhase, +} from "./logic/drag-controller"; +export { nextDragPhase } from "./logic/drag-controller"; export type { Route } from "./logic/route"; export { DEFAULT_WORKSPACE_ID, @@ -8,10 +21,36 @@ export { WORKSPACE_SLUG_RE, workspacePath, } from "./logic/route"; +export type { + GridGeometry, + Placement, + TileGeometryMode, + TileLayoutData, + TileLayoutEntry, +} from "./logic/tile-layout"; +export { + clampSpan, + displacePlace, + dragAppendLimit, + fillOrderKey, + firstEmptySlot, + geometryFor, + mergeLayout, + pack, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + sortTiles, + toRenderCell, + toUniversalCell, +} from "./logic/tile-layout"; export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model"; export type { WorkspaceStore } from "./store.svelte"; export { createWorkspaceStore } from "./store.svelte"; -export { default as WorkspaceCard } from "./ui/WorkspaceCard.svelte"; +export type { TileLayoutProviderState, TileLayoutStore } from "./tile-layout-store.svelte"; +export { createTileLayoutStore } from "./tile-layout-store.svelte"; export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ 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/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts new file mode 100644 index 0000000..459063a --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { type DragPhase, type DragTransition, nextDragPhase } from "./drag-controller"; + +const OPTS = { dragThresholdPx: 6 }; + +function down(over: Partial<Parameters<typeof nextDragPhase>[1] & { type: "pointer-down" }> = {}) { + return { + type: "pointer-down" as const, + id: "t1", + x: 10, + y: 10, + touch: false, + ...over, + }; +} + +function press(touch = false): DragPhase { + return nextDragPhase({ kind: "idle" }, down({ touch }), OPTS).phase; +} + +describe("nextDragPhase — mouse", () => { + it("a pointer-down enters the undecided pressing state", () => { + expect(press()).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("movement past the threshold starts a drag", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 20, y: 12, targetCell: { col: 4, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 20, + y: 12, + targetCell: { col: 4, row: 1 }, + touch: false, + }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold stays pressing", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 12, y: 14, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("release without moving emits a tap (open the workspace)", () => { + const transition = nextDragPhase(press(), { type: "pointer-up" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toEqual({ kind: "tap", id: "t1" }); + }); + + it("release while dragging emits a drop with the last tracked index", () => { + let transition: DragTransition = nextDragPhase( + press(), + { type: "pointer-move", x: 30, y: 30, targetCell: { col: 3, row: 1 } }, + OPTS, + ); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 31, y: 31, targetCell: { col: 6, row: 1 } }, + OPTS, + ); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: { col: 6, row: 1 } }); + expect(transition.phase).toEqual({ kind: "idle" }); + }); +}); + +describe("nextDragPhase — touch", () => { + it("a long-press lifts the tile and emits lift (arm the drag + select)", () => { + const transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + expect(transition.emit).toEqual({ kind: "lift", id: "t1" }); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 10, + targetCell: null, + touch: true, + }); + }); + + it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 10, y: 80, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold before the lift keeps pressing", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 13, y: 12, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("after the lift, movement drags (never cancels)", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 10, y: 90, targetCell: { col: 5, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 90, + targetCell: { col: 5, row: 1 }, + touch: true, + }); + }); + + it("a long-press never lifts a mouse press (desktop selects via right-click)", () => { + const transition = nextDragPhase(press(false), { type: "long-press" }, OPTS); + expect(transition.emit).toBeUndefined(); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("release while lifted (no movement) drops at the lifted tile's own slot", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: null }); + }); +}); + +describe("nextDragPhase — cancellations", () => { + it("cancel returns to idle from any phase without emitting", () => { + for (const phase of [ + { kind: "idle" as const }, + press(), + nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase, + ]) { + const transition = nextDragPhase(phase, { type: "cancel" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + } + }); + + it("events in idle are inert", () => { + expect(nextDragPhase({ kind: "idle" }, { type: "pointer-up" }, OPTS).phase).toEqual({ + kind: "idle", + }); + expect( + nextDragPhase( + { kind: "idle" }, + { type: "pointer-move", x: 1, y: 1, targetCell: { col: 1, row: 1 } }, + OPTS, + ).phase, + ).toEqual({ kind: "idle" }); + expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({ + kind: "idle", + }); + }); + + it("a second pointer-down restarts the gesture with the new tile", () => { + const dragging = nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase; + const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS); + expect(transition.phase).toEqual({ kind: "pressing", id: "t2", x: 10, y: 10, touch: false }); + }); +}); diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts new file mode 100644 index 0000000..a1877aa --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.ts @@ -0,0 +1,162 @@ +/** + * Pure drag/press state machine for the home tile grid — zero DOM, zero + * timers (the component schedules the long-press timer and dispatches its + * firing as an event), zero Svelte. + * + * Pointer semantics: + * - **Mouse**: press → move past the threshold = DRAG; release without + * moving = a TAP (open the workspace). Selection is right-click. + * - **Touch**: press → (no movement past the threshold) long-press = LIFT + * (arm the drag + select the tile) → further moves drag. Moving past the + * threshold BEFORE the lift cancels the press — vertical scrolling wins, + * so the two gestures never fight. Release while lifted = DROP. + * + * Pure: `(phase, event, options) → { phase, emit }`. The component owns the + * reactive `$state` and performs the emits (navigate / select / place). + */ + +import type { Cell } from "./tile-layout"; + +/** Where the interaction currently stands. */ +export type DragPhase = + | { readonly kind: "idle" } + /** Pointer is down; not yet decided between tap / drag / long-press. */ + | { + readonly kind: "pressing"; + readonly id: string; + readonly x: number; + readonly y: number; + readonly touch: boolean; + } + /** The tile is lifted and following the pointer (touch: after the long-press). */ + | { + readonly kind: "dragging"; + readonly id: string; + readonly x: number; + readonly y: number; + /** The grid CELL under the pointer (null = not over the canvas yet). */ + readonly targetCell: Cell | null; + readonly touch: boolean; + }; + +/** An action the component should perform as a result of a transition. */ +export type DragEmit = + | { readonly kind: "tap"; readonly id: string } + | { readonly kind: "lift"; readonly id: string } + | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null }; + +/** Events the component dispatches (DOM events + the long-press timer firing). */ +export type DragEvent = + | { + readonly type: "pointer-down"; + readonly id: string; + readonly x: number; + readonly y: number; + readonly touch: boolean; + } + | { + readonly type: "pointer-move"; + readonly x: number; + readonly y: number; + readonly targetCell: Cell | null; + } + | { readonly type: "pointer-up" } + | { readonly type: "long-press" } + /** pointercancel / blur / the tile unmounting / Escape. */ + | { readonly type: "cancel" }; + +export interface DragOptions { + /** Pointer travel (px) that turns a press into a drag (or cancels a touch press). */ + readonly dragThresholdPx: number; +} + +export interface DragTransition { + readonly phase: DragPhase; + readonly emit?: DragEmit; +} + +const IDLE: DragPhase = { kind: "idle" }; + +/** Advance the state machine. Pure: phase + event in, NEXT phase + emit out. */ +export function nextDragPhase( + phase: DragPhase, + event: DragEvent, + options: DragOptions, +): DragTransition { + switch (event.type) { + case "pointer-down": + // A press always restarts (a pointerdown while dragging is a new gesture). + return { + phase: { + kind: "pressing", + id: event.id, + x: event.x, + y: event.y, + touch: event.touch, + }, + }; + case "pointer-move": { + if (phase.kind === "pressing") { + const travelled = Math.hypot(event.x - phase.x, event.y - phase.y); + if (travelled < options.dragThresholdPx) return { phase }; + // Touch: a big movement before the long-press lift is a SCROLL, not a + // drag — abandon the press so the canvas scrolls natively. + if (phase.touch) return { phase: IDLE }; + return { + phase: { + kind: "dragging", + id: phase.id, + x: event.x, + y: event.y, + targetCell: event.targetCell, + touch: phase.touch, + }, + }; + } + if (phase.kind === "dragging") { + return { + phase: { + ...phase, + x: event.x, + y: event.y, + targetCell: event.targetCell, + }, + }; + } + return { phase }; + } + case "pointer-up": { + if (phase.kind === "pressing") { + // No drag, no lift → a tap. + return { phase: IDLE, emit: { kind: "tap", id: phase.id } }; + } + if (phase.kind === "dragging") { + return { + phase: IDLE, + emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell }, + }; + } + return { phase }; + } + case "long-press": { + if (phase.kind === "pressing" && phase.touch) { + // Touch lift: arm the drag at the press point (the pointer will move + // from here) + tell the component to select the tile. + return { + phase: { + kind: "dragging", + id: phase.id, + x: phase.x, + y: phase.y, + targetCell: null, + touch: true, + }, + emit: { kind: "lift", id: phase.id }, + }; + } + return { phase }; + } + case "cancel": + return { phase: IDLE }; + } +} diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts new file mode 100644 index 0000000..b538373 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.test.ts @@ -0,0 +1,857 @@ +import { describe, expect, it } from "vitest"; +import { + clampSpan, + displacePlace, + dragAppendLimit, + fillOrderKey, + firstEmptySlot, + type GridGeometry, + geometryFor, + mergeLayout, + type Placement, + pack, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutEntry, + toRenderCell, + toUniversalCell, + UNIVERSAL_ROWS, +} from "./tile-layout"; + +const WIDE = geometryFor(1920, 1080); +const TALL = geometryFor(390, 844); + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +describe("geometryFor", () => { + it("resolves landscape/desktop viewports to the WIDE geometry", () => { + const geometry = geometryFor(1920, 1080); + expect(geometry.mode).toBe("wide"); + expect(geometry.flowRows).toBe(4); + expect(geometry.flowCols).toBe(0); + expect(geometry.maxColSpan).toBe(4); + expect(geometry.maxRowSpan).toBe(2); + expect(geometry.unit.height).toBeGreaterThan(0); + expect(geometry.unit.width).toBeGreaterThan(0); + }); + + it("resolves portrait/mobile viewports to the TALL geometry", () => { + const geometry = geometryFor(390, 844); + expect(geometry.mode).toBe("tall"); + expect(geometry.flowCols).toBe(2); + expect(geometry.flowRows).toBe(0); + expect(geometry.maxColSpan).toBe(2); + expect(geometry.maxRowSpan).toBe(2); + }); + + it("picks the picker dimensions from the span caps (4×2 wide, 2×2 tall)", () => { + // The resize picker IS the span cap: cols × rows of small squares. + expect([WIDE.maxColSpan, WIDE.maxRowSpan]).toEqual([4, 2]); + expect([TALL.maxColSpan, TALL.maxRowSpan]).toEqual([2, 2]); + }); + + it("resolves degenerate (unmeasured) sizes to wide", () => { + expect(geometryFor(0, 0).mode).toBe("wide"); + }); + + it("derives the wide unit from the canvas height so the 4 rows fill it", () => { + // 4·unit + 3·gap + 2·margin ≈ height: an 800px canvas gets ~160px tiles, + // NOT the old fixed 120px (the "squares are so small" complaint). + const geometry = geometryFor(1440, 800); + const filled = 4 * geometry.unit.height + 3 * geometry.unit.gap + 2 * geometry.margin; + expect(geometry.unit.height).toBeGreaterThanOrEqual(140); + expect(Math.abs(filled - 800)).toBeLessThanOrEqual(8); + expect(geometry.unit.width).toBe(geometry.unit.height); + }); + + it("clamps the wide unit to its band on very short and very tall canvases", () => { + const short = geometryFor(1200, 300); + const huge = geometryFor(5000, 4000); // wide (width ≥ height) with a huge height + expect(short.unit.height).toBe(112); + expect(huge.unit.height).toBe(184); + // The margin never collapses (the "no margins" complaint) nor dominates. + expect(short.margin).toBeGreaterThanOrEqual(24); + expect(huge.margin).toBeLessThanOrEqual(64); + }); + + it("always carries a positive margin (the tile field never hugs the edge)", () => { + const sizes: [number, number][] = [ + [1920, 1080], + [390, 844], + [0, 0], + [1200, 300], + ]; + for (const [w, h] of sizes) { + expect(geometryFor(w, h).margin).toBeGreaterThan(0); + } + }); + + it("keeps the tall unit fixed with a gap (its columns are fluid)", () => { + expect(TALL.unit.width).toBe(0); + expect(TALL.unit.height).toBeGreaterThanOrEqual(120); + expect(TALL.unit.gap).toBeGreaterThan(8); + }); +}); + +describe("clampSpan", () => { + it("clamps spans to the geometry caps", () => { + expect(clampSpan({ colSpan: 4, rowSpan: 2 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 }); + expect(clampSpan({ colSpan: 9, rowSpan: 9 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 }); + expect(clampSpan({ colSpan: 3, rowSpan: 1 }, TALL)).toEqual({ colSpan: 2, rowSpan: 1 }); + }); + + it("coerces garbage spans to 1×1", () => { + expect(clampSpan({ colSpan: 0, rowSpan: -2 }, WIDE)).toEqual({ colSpan: 1, rowSpan: 1 }); + expect(clampSpan({ colSpan: Number.NaN, rowSpan: 1.9 }, WIDE)).toEqual({ + colSpan: 1, + rowSpan: 1, + }); + }); +}); + +describe("pack", () => { + it("places tiles in order at the first fitting slot (wide, column-wise flow)", () => { + const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], WIDE); + expect(placements).toEqual([ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]); + }); + + it("flows to the next column when the current one is full (wide)", () => { + const placements = pack( + [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3), tile("e", 4)], + WIDE, + ); + expect(placements.map((p) => [p.col, p.row])).toEqual([ + [1, 1], + [1, 2], + [1, 3], + [1, 4], + [2, 1], + ]); + }); + + it("respects the persisted order over ids", () => { + const placements = pack([tile("z", 0), tile("a", 1)], WIDE); + expect(placements.map((p) => p.id)).toEqual(["z", "a"]); + }); + + it("places a double-height tile at the first slot with room (wide)", () => { + const placements = pack([tile("a", 0, 1, 2), tile("b", 1), tile("c", 2)], WIDE); + expect(placements.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["a", 1, 1, 2], + ["b", 1, 3, 1], + ["c", 1, 4, 1], + ]); + }); + + it("a tile too tall for the remaining rows moves to the next column (wide)", () => { + // a,b,c fill rows 1-3; a 2-row tile does not fit the single remaining row → col 2. + const placements = pack( + [tile("a", 0), tile("b", 1), tile("c", 2), tile("tall", 3, 1, 2)], + WIDE, + ); + expect(placements[3]).toEqual({ id: "tall", col: 2, row: 1, colSpan: 1, rowSpan: 2 }); + }); + + it("flows row-wise across two fixed columns in tall mode", () => { + const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2)], TALL); + expect(placements.map((p) => [p.col, p.row])).toEqual([ + [1, 1], + [2, 1], + [1, 2], + ]); + }); + + it("clamps spans per geometry when packing (persisted spans stay raw)", () => { + const tiles = [tile("wide", 0, 3, 1), tile("b", 1, 4, 2)]; + const placements = pack(tiles, TALL); + expect(placements).toEqual([ + { id: "wide", col: 1, row: 1, colSpan: 2, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 2, rowSpan: 2 }, + ]); + // The input is untouched. + expect(tiles[0]).toEqual({ id: "wide", order: 0, colSpan: 3, rowSpan: 1 }); + }); + + it("never overlaps tiles", () => { + const tiles = [ + tile("a", 0, 2, 2), + tile("b", 1, 2, 2), + tile("c", 2, 4, 1), + tile("d", 3), + tile("e", 4, 1, 2), + tile("f", 5), + ]; + const placements = pack(tiles, WIDE); + const seen = new Set<string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + const key = `${r}:${c}`; + expect(seen.has(key)).toBe(false); + seen.add(key); + } + } + } + }); +}); + +describe("fillOrderKey", () => { + it("ranks wide slots column-wise (top-to-bottom, then next column)", () => { + expect(fillOrderKey(1, 1, WIDE)).toBeLessThan(fillOrderKey(1, 2, WIDE)); + expect(fillOrderKey(1, 4, WIDE)).toBeLessThan(fillOrderKey(2, 1, WIDE)); + }); + + it("ranks tall slots row-wise (left-to-right, then next row)", () => { + expect(fillOrderKey(1, 1, TALL)).toBeLessThan(fillOrderKey(2, 1, TALL)); + expect(fillOrderKey(2, 1, TALL)).toBeLessThan(fillOrderKey(1, 2, TALL)); + }); +}); + +describe("firstEmptySlot", () => { + it("finds the first fitting empty slot in fill order", () => { + // Wide, rows 1-2 of col 1 occupied by a double-height tile → first empty is (col1,row3). + const tiles = [tile("a", 0, 1, 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 3 }); + }); + + it("skips slots too small for the requested footprint", () => { + // (col1,row4) is the first empty 1×1 slot after three singles, but a + // 2-row tile needs rows 4-5 of a 4-row grid — impossible → next column. + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 4 }); + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 2 }, WIDE)).toEqual({ col: 2, row: 1 }); + }); + + it("returns the origin for an empty layout", () => { + expect(firstEmptySlot([], { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 1 }); + }); + + it("lands in a brand-new column/row beyond the occupied extent", () => { + // Two 4×2 tiles fill columns 1-4 across all 4 rows → the next slot is col 5. + const tiles = [tile("a", 0, 4, 2), tile("b", 1, 4, 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 5, row: 1 }); + }); +}); + +describe("pinAtFirstEmpty", () => { + it("pins a new tile at the first empty slot (holes count)", () => { + // A double-height tile fills rows 1-2, a single fills row 3 → the first + // empty slot is (col1,row4); the new tile PINS exactly there. + const tiles = [tile("big", 0, 1, 2), tile("c", 1)]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + const placements = pack(next, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["big", 1, 1], + ["c", 1, 3], + ["new", 1, 4], + ]); + }); + + it("pins into a GAP between pinned tiles", () => { + // big is pinned out of the way; the flow left row 3 free for a 1×1. + const tiles = [ + { ...tile("big", 0, 1, 2), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 3 }); + }); + + it("re-pins an already-present tile instead of duplicating (refresh-race idempotency)", () => { + const tiles = [tile("a", 0), tile("x", 1), tile("b", 2, 1, 2)]; + const first = pinAtFirstEmpty(tiles, "x", WIDE); + const second = pinAtFirstEmpty(first, "x", WIDE); + expect(second).toEqual(first); + expect(second.filter((t) => t.id === "x")).toHaveLength(1); + }); + + it("appends (pins beyond the current extent) when the grid is full", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)]; + const next = pinAtFirstEmpty(tiles, "e", WIDE); + expect(next.find((t) => t.id === "e")).toMatchObject({ col: 2, row: 1 }); + }); + + it("preserves the spans of a re-pinned existing tile", () => { + const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)]; + const next = pinAtFirstEmpty(tiles, "x", WIDE); + expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 2 }); + }); + + it("saves the pin in the universal frame (tall)", () => { + const tiles = [{ ...tile("a", 0), col: 1, row: 1 }]; + const next = pinAtFirstEmpty(tiles, "new", TALL); + // a renders at tall (1,1); the first empty slot is tall (2,1) → universal + // (col 1, row 2). + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 2 }); + }); +}); + +describe("resizeTile", () => { + it("sets the persisted spans of one tile", () => { + const next = resizeTile([tile("a", 0), tile("b", 1)], "b", 4, 2); + expect(next.find((t) => t.id === "b")).toMatchObject({ colSpan: 4, rowSpan: 2 }); + expect(next.find((t) => t.id === "a")).toMatchObject({ colSpan: 1, rowSpan: 1 }); + }); + + it("sanitizes garbage spans to 1 and preserves order", () => { + const next = resizeTile([tile("a", 5, 2, 2)], "a", Number.NaN, -3); + expect(next).toEqual([{ id: "a", order: 5, colSpan: 1, rowSpan: 1 }]); + }); + + it("ignores unknown ids", () => { + const tiles = [tile("a", 0)]; + expect(resizeTile(tiles, "nope", 2, 2)).toEqual(tiles); + }); + + it("does NOT clamp to the current geometry (render-time concern)", () => { + // A 4-wide desktop tile resized while wide stays 4-wide in the document; + // tall rendering clamps its VIEW. + const next = resizeTile([tile("a", 0)], "a", 4, 2); + expect(next[0]).toMatchObject({ colSpan: 4, rowSpan: 2 }); + }); +}); + +describe("displacePlace", () => { + /** Render placements as the column-1 stream (cells 1..n, `_` = empty, + * one character per tile — the id's first char). */ + const stream = (placements: Placement[], geometry: GridGeometry): string => { + const cells = new Map<number, string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + cells.set(fillOrderKey(c, r, geometry), p.id.charAt(0)); + } + } + } + const maxKey = Math.max(0, ...cells.keys()); + const out: string[] = []; + for (let key = 0; key <= maxKey; key++) { + out.push(cells.get(key) ?? "_"); + } + return out.join(""); + }; + + it("USER EXAMPLE 1: X_X + Y placed in slot 1 → YXX (the gap absorbs the shift)", () => { + // X at cells 1,3 (wide: (1,1) and (1,3)); Y sits at cell 5 (dragged in + // from outside the shown range — its origin is beyond the gap). + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays. The array + // keeps the INPUT render order (only coordinates change). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["X", 1, 2], + ["X2", 1, 3], + ["Y", 1, 1], + ]); + }); + + it("USER EXAMPLE 2: _XZ_ + Y placed in slot 2 → _YXZ (leading gap preserved)", () => { + // X@(1,2), Z@(1,3); Y dragged in from cell 5. + const placements: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 2 }, WIDE); + // Same coordinates, input render order: X, Z, then the dragged Y. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["X", 1, 3], + ["Z", 1, 4], + ["Y", 1, 2], + ]); + }); + + it("dragging a tile OUT opens a gap at its old anchor (nothing slides back)", () => { + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + ]; + // Drag a (cell 1) to cell 3 — b stays at 2; a lands at 3. + const next = displacePlace(placements, "a", { col: 1, row: 3 }, WIDE); + // The array keeps the input render order (a first) — only a's coords move. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 2], + ]); + }); + + it("internal move rightward over a full run pushes the tail forward", () => { + // a,b,c in cells 1-3; drag a to cell 2: b→3 pushes c→4. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 2], + ["b", 1, 3], + ["c", 1, 4], + ]); + }); + + it("an unknown dragged id changes nothing (a copy of the input)", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + const next = displacePlace(placements, "nope", { col: 1, row: 2 }, WIDE); + expect(next).toEqual(placements); + }); + + it("a spanned displaced tile keeps its spans and re-anchors forward as a rect", () => { + // big (2-tall) at rows 1-2; tail (2-tall) at rows 3-4. Drag big to (1,2): + // its own old body is free-ish but (1,2)+(1,3) collides with tail's + // (1,3)+(1,4) → tail re-anchors FORWARD: no 2-tall slot fits in column 1 + // anymore → column 2. + const placements: Placement[] = [ + { id: "big", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "tail", col: 1, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + const next = displacePlace(placements, "big", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["big", 1, 2, 2], + ["tail", 2, 1, 2], + ]); + }); + + it("displacing into the NEXT COLUMN when the column is full", () => { + // 4 tiles fill column 1; drag the first to the LAST cell of column 1. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 4 }, WIDE); + // d is pushed past the column's end → column 2 row 1. The array keeps the + // INPUT render order (a,b,c,d) — only coordinates change. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 4], + ["b", 1, 2], + ["c", 1, 3], + ["d", 2, 1], + ]); + }); + + it("works in TALL mode along the row-major stream", () => { + // Tall: 2 columns, row-major. a@(1,1), b@(2,1); drag a to cell (2,1). + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 2, row: 1 }, TALL); + // b pushed forward past the row's end → next row. Stream order: a(1) b(2). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 2, 1], + ["b", 1, 2], + ]); + }); + + it("the stream helper renders the documented examples (sanity)", () => { + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + expect(stream(next, WIDE)).toBe("YXX"); + const placements2: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next2 = displacePlace(placements2, "Y", { col: 1, row: 2 }, WIDE); + expect(stream(next2, WIDE)).toBe("_YXZ"); + }); +}); + +describe("placeAtCell", () => { + it("pins every tile at its displaced anchor in the universal frame", () => { + // Wide: a@(1,1), b@(1,2); drag a to (2,1) — a pins there, b KEEPS its + // cell (the gap at (1,1) opens and stays). + const tiles = [tile("a", 0), tile("b", 1)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, WIDE); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(next.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + // And the pinned layout packs exactly as placed (b first — stream order). + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 2], + ["a", 2, 1], + ]); + }); + + it("keeps persisted spans raw (never clamped) through the commit", () => { + // A 4-wide desktop tile dropped in TALL commits the tall cell but keeps + // its raw 4-wide span. + const tiles = [{ ...tile("big", 0, 4, 1) }, tile("b", 1)]; + const next = placeAtCell(tiles, "big", { col: 1, row: 3 }, TALL); + expect(next.find((t) => t.id === "big")).toMatchObject({ + colSpan: 4, + rowSpan: 1, + col: 1, + row: 5, + }); + }); + + it("renumbers orders densely in stream order", () => { + const tiles = [tile("a", 0), tile("b", 5), tile("c", 9)]; + const next = placeAtCell(tiles, "c", { col: 1, row: 1 }, WIDE); + expect(next.every((t, i) => t.order === i)).toBe(true); + }); + + it("maps tall commits to the universal frame", () => { + // Tall (col 2, row 1) → universal (col 1, row 2). + const tiles = [tile("a", 0)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, TALL); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 1, row: 2 }); + }); + + it("an unknown id changes nothing (no-op)", () => { + const tiles = [tile("a", 0)]; + expect(placeAtCell(tiles, "nope", { col: 1, row: 1 }, WIDE)).toEqual(tiles); + }); +}); + +describe("dragAppendLimit", () => { + it("caps a wide drag at 3 columns past the right-most content edge (span-aware)", () => { + // b spans cols 3-4 → the right-most occupied column is 4; +3 = 7. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 3, row: 1, colSpan: 2, rowSpan: 1 }, + ]; + expect(dragAppendLimit(placements, WIDE)).toBe(7); + }); + + it("caps a tall drag at 3 rows past the bottom-most content edge (span-aware)", () => { + // b spans rows 3-4 → the bottom-most occupied row is 4; +3 = 7. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "b", col: 2, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + expect(dragAppendLimit(placements, TALL)).toBe(7); + }); + + it("an empty grid still allows the first tiles within the first 3 columns", () => { + expect(dragAppendLimit([], WIDE)).toBe(3); + expect(dragAppendLimit([], TALL)).toBe(3); + }); + + it("honors a custom extra", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + expect(dragAppendLimit(placements, WIDE, 5)).toBe(6); + }); +}); + +describe("sortByRecentActivity", () => { + it("re-applies the recent-activity order (descending)", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ["c", 200], + ]), + ); + expect(next.map((t) => t.id)).toEqual(["b", "c", "a"]); + }); + + it("treats unknown ids as least-recent and renumbers", () => { + const tiles = [tile("a", 0), tile("b", 1)]; + // `a` has recent activity (50); `b` is unknown → least-recent → last. + const next = sortByRecentActivity(tiles, new Map([["a", 50]])); + expect(next.map((t) => [t.id, t.order])).toEqual([ + ["a", 0], + ["b", 1], + ]); + }); + + it("CLEARs every pin (an explicit arrangement reset — gaps gone)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 2, row: 3 }, + ]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ]), + ); + expect(next.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + // And the now-unpinned layout packs compactly. + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 1], + ["a", 1, 2], + ]); + }); +}); + +describe("mergeLayout", () => { + it("starts workspaces unknown to the layout (appends after the max order)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 0)] }, ["a", "b", "c"]); + expect(merged.map((t) => [t.id, t.order])).toEqual([ + ["a", 0], + ["b", 1], + ["c", 2], + ]); + }); + + it("appends unknowns in the live list's order", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("z", 0)] }, ["z", "y", "x"]); + expect(merged.map((t) => t.id)).toEqual(["z", "y", "x"]); + }); + + it("drops entries for deleted workspaces", () => { + const merged = mergeLayout( + { version: 1, tiles: [tile("a", 0), tile("gone", 1), tile("b", 2)] }, + ["a", "b"], + ); + expect(merged.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("keeps manual order (not the list order) for known workspaces", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("c", 0), tile("a", 1)] }, ["a", "c"]); + expect(merged.map((t) => t.id)).toEqual(["c", "a"]); + }); + + it("clamps spans in the view when a geometry is given (persisted data unchanged)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"], TALL); + expect(merged[0]).toMatchObject({ colSpan: 2, rowSpan: 1 }); + expect(mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"])).toEqual([ + { id: "a", order: 0, colSpan: 3, rowSpan: 1 }, + ]); + }); + + it("deduplicates corrupted duplicate ids (first by order wins)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 1, 2, 2), tile("a", 0)] }, [ + "a", + "b", + ]); + expect(merged).toEqual([ + { id: "a", order: 0, colSpan: 1, rowSpan: 1 }, + { id: "b", order: 1, colSpan: 1, rowSpan: 1 }, + ]); + }); + + it("handles a null/undefined persisted document", () => { + expect(mergeLayout(null, ["a"])).toEqual([{ id: "a", colSpan: 1, rowSpan: 1, order: 1 }]); + expect(mergeLayout(undefined, [])).toEqual([]); + }); +}); + +describe("sanitizeLayout", () => { + it("accepts a valid v2 document (anchors kept)", () => { + expect( + sanitizeLayout({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }); + }); + + it("MIGRATES a v1 document to v2 in memory (all tiles unpinned)", () => { + expect( + sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }], + }); + }); + + it("rejects foreign versions and malformed documents", () => { + expect(sanitizeLayout(null)).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout("nope")).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 3, tiles: [] })).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 2, tiles: [] }); + }); + + it("coerces malformed entries and drops anonymous/duplicate ones", () => { + const data = sanitizeLayout({ + version: 1, + tiles: [ + { id: "a", colSpan: "x", rowSpan: 0, order: "y" }, + null, + { colSpan: 2 }, + { id: "a", order: 9 }, + { id: "b", colSpan: 3.9, order: 2.5 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "a", colSpan: 1, rowSpan: 1, order: 0 }, + { id: "b", colSpan: 3, rowSpan: 1, order: 2 }, + ]); + }); + + it("drops garbage anchors (non-positive / fractional / half a pair)", () => { + const data = sanitizeLayout({ + version: 2, + tiles: [ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2.5, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2, col: 0, row: 1 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3, col: 2 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4, col: -1, row: -1 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4 }, + ]); + }); +}); + +describe("geometry-agnostic persistence", () => { + it("the SAME persisted data renders in either geometry (unpinned flow re-derives)", () => { + const tiles: TileLayoutEntry[] = [ + tile("a", 0, 4, 2), + tile("b", 1), + tile("c", 2), + tile("d", 3, 2, 2), + tile("e", 4), + ]; + const widePlacements = pack(tiles, WIDE); + const tallPlacements = pack(tiles, TALL); + // Wide flows column-wise across 4 rows; tall flows row-wise across 2 columns. + expect(widePlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 4, rowSpan: 2 }); + expect(widePlacements.at(-1)).toMatchObject({ id: "e" }); + expect(tallPlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 2, rowSpan: 2 }); + // An unpinned entry carries NO anchor — only id/spans/order. + expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]); + }); +}); + +describe("pinned pack (v2)", () => { + it("pinned tiles land at their anchors; unpinned flow into the remaining free cells", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 3 }, + { ...tile("b", 1), col: 1, row: 1 }, + tile("c", 2), // unpinned → first free = (1,2) + tile("d", 3), // unpinned → next free = (1,4) + ]; + // pack returns ENTRY order (a, b, c, d); positions are what matters. + const placements = pack(tiles, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 1], + ["c", 1, 2], + ["d", 1, 4], + ]); + }); + + it("keeps the GAP between two pins (flow does not compact it away)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.row])).toEqual([ + ["a", 1], + ["z", 4], + ]); + }); + + it("a pin whose span no longer fits its anchor reads as UNPINNED (flows)", () => { + // A 2-tall tile pinned at row 4 cannot fit the 4-row cross-axis at that + // anchor → it flows to the first fitting slot instead: (1,2). + const tiles: TileLayoutEntry[] = [ + { ...tile("big", 0, 1, 2), col: 1, row: 4 }, + { ...tile("a", 1), col: 1, row: 1 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "big")).toMatchObject({ col: 1, row: 2 }); + }); + + it("overlapping pins resolve first-wins by entry order (the loser flows)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 2 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "a")).toMatchObject({ col: 1, row: 2 }); + expect(placements.find((p) => p.id === "b")).toMatchObject({ col: 1, row: 1 }); + }); + + it("a wide pin at col > 1 reads as UNPINNED in tall (documented lossy case)", () => { + const tiles: TileLayoutEntry[] = [{ ...tile("a", 0), col: 3, row: 1 }]; + expect(pack(tiles, TALL).find((p) => p.id === "a")).toMatchObject({ col: 1, row: 1 }); + }); + + it("tall pins map through the universal frame in both directions", () => { + // Universal column 1 folds onto tall's 2 columns in reading order: + // u1→(1,1) u2→(2,1) u3→(1,2) u4→(2,2). + expect(toRenderCell({ col: 1, row: 1 }, TALL)).toEqual({ col: 1, row: 1 }); + expect(toRenderCell({ col: 1, row: 2 }, TALL)).toEqual({ col: 2, row: 1 }); + expect(toRenderCell({ col: 1, row: 3 }, TALL)).toEqual({ col: 1, row: 2 }); + expect(toRenderCell({ col: 1, row: 4 }, TALL)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, TALL)).toEqual({ col: 1, row: 4 }); + // Wide is the identity. + expect(toRenderCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(UNIVERSAL_ROWS).toBe(4); + }); + + it("a layout dragged in TALL renders its pins when back in WIDE", () => { + // Committed in tall: a at tall (1,1) → universal (1,1); b at tall (2,1) → + // universal (1,2). Back in wide they render exactly there (col 1). + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 2], + ]); + }); + + it("deleting a workspace leaves its cells EMPTY (mergeLayout keeps other pins)", () => { + const persisted = { + version: 2 as const, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("gone", 1), col: 1, row: 2 }, + { ...tile("b", 2), col: 1, row: 3 }, + ] as TileLayoutEntry[], + }; + const merged = mergeLayout(persisted, ["a", "b"]); + expect(merged.map((t) => [t.id, t.col, t.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 3], + ]); + // The gap at (1,2) stays: a new workspace flows/pins INTO it. + expect(firstEmptySlot(merged, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 2 }); + }); +}); + +describe("type surface", () => { + it("exposes the geometry fields the picker and grid need", () => { + // A 500px-tall canvas: the raw unit (100) clamps UP to the 112px minimum, + // so the gap is 12px and the margin 37px (never the old fixed 8px gap). + const geometry: GridGeometry = geometryFor(1000, 500); + expect([ + geometry.mode, + geometry.flowRows, + geometry.flowCols, + geometry.unit.gap, + geometry.margin, + ]).toEqual(["wide", 4, 0, 12, 37]); + }); +}); diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts new file mode 100644 index 0000000..6f7c281 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.ts @@ -0,0 +1,709 @@ +/** + * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero + * DOM, zero Svelte, zero effects. + * + * The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order, + * col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned + * anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds + * its 2 columns onto frame column 1), so the same document renders in either + * geometry with no orientation conversion step: an anchor that doesn't map + * into the current geometry (or whose clamped span can't fit there) reads as + * UNPINNED, and unpinned tiles flow into the first free cells — holes between + * pins included. Empty cells are real: nothing ever slides backward, so gaps + * only close when a drop's displacement pushes a run of filled cells through + * them (see {@link displacePlace}). + * + * Terminology (distinct from the layout feature's nested split-tree "tile"): + * a HOME TILE is one workspace's cell in the flat start-screen grid. + */ + +/** The grid geometry mode: landscape/desktop ("wide") or portrait/mobile ("tall"). */ +export type TileGeometryMode = "wide" | "tall"; + +/** The px size of one tile unit (one grid cell) + the grid gap. */ +export interface TileUnitSize { + /** + * The unit's px width. `0` means "fluid" (the tall grid sizes its fixed + * columns in `1fr` so two tiles fill a phone's width). + */ + readonly width: number; + readonly height: number; + readonly gap: number; +} + +/** + * The full grid geometry a viewport resolves to. `flowRows`/`flowCols` name + * the FIXED track counts across the fill direction's cross axis: + * wide mode fixes ROWS (tiles flow column-wise, columns grow unbounded); + * tall mode fixes COLS (tiles flow row-wise, rows grow unbounded). `0` = + * unbounded. The picker (resize) dimensions equal the span caps. + */ +export interface GridGeometry { + readonly mode: TileGeometryMode; + /** Fixed row tracks (wide: 4; tall: 0 = rows grow unbounded). */ + readonly flowRows: number; + /** Fixed column tracks (tall: 2; wide: 0 = columns grow unbounded). */ + readonly flowCols: number; + /** Maximum horizontal span a tile may occupy in this geometry. */ + readonly maxColSpan: number; + /** Maximum vertical span a tile may occupy in this geometry. */ + readonly maxRowSpan: number; + readonly unit: TileUnitSize; + /** + * The breathing room around the whole tile field — the grid never renders + * flush against the canvas edge. In wide mode the scroller pads all four + * sides by this amount (the pan arrow/scroll indicator offset inward by it + * too); in tall mode it insets the two fluid columns horizontally. + */ + readonly margin: number; +} + +/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */ +export interface TileLayoutEntry { + /** The workspace id (the slug). */ + readonly id: string; + readonly colSpan: number; + readonly rowSpan: number; + /** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */ + readonly order: number; + /** + * The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent = + * unpinned (the tile flows into the first free cells at render time). Tall + * mode normalizes its 2-column grid into this frame: render { c, r } ↔ + * universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's + * fixed cross-axis reads as UNPINNED (render-time clamp — like spans). + */ + readonly col?: number; + readonly row?: number; +} + +/** The persisted document (localStorage via the injected `LocalStore` adapter). */ +export interface TileLayoutData { + readonly version: 1 | 2; + readonly tiles: readonly TileLayoutEntry[]; +} + +/** A tile's resolved footprint on the grid (1-based grid coordinates). */ +export interface Placement { + readonly id: string; + readonly col: number; + readonly row: number; + readonly colSpan: number; + readonly rowSpan: number; +} + +/** A requested span (pre-clamping). */ +export interface Span { + readonly colSpan: number; + readonly rowSpan: number; +} + +/** The viewport assumed before the canvas has been measured (a landscape desktop). */ +export const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const; + +/** + * The wide unit's clamp band (px). Tiles FILL the canvas' height across the 4 + * fixed rows (the Start-screen feel), but never shrink below a legible small + * tile nor grow past a comfortable large one. + */ +const WIDE_UNIT_MIN = 112; +const WIDE_UNIT_MAX = 184; + +/** The wide canvas' outer margin band (px) — proportional to the unit size. */ +const WIDE_MARGIN_MIN = 24; +const WIDE_MARGIN_MAX = 64; + +/** The tall unit: the two fluid columns size in `1fr` (width `0` = fluid). */ +const TALL_UNIT: TileUnitSize = { width: 0, height: 148, gap: 12 }; +const TALL_MARGIN = 16; + +/** Round to whole pixels (fractional track sizes blur tile edges). */ +function px(value: number): number { + return Math.round(value); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Resolve the grid geometry from the tile canvas' size. Landscape/desktop → + * WIDE (4 fixed rows, column-wise flow, 4×2 span cap); portrait/mobile → TALL + * (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes (not yet + * measured) resolve to wide. + * + * The wide unit is DERIVED from the canvas height so the 4 rows exactly fill + * it (unit + margin + gap all proportional, clamped to their bands); the tall + * unit is fixed (its columns are fluid instead). Pure: numbers in, geometry + * out. + */ +export function geometryFor(width: number, height: number): GridGeometry { + const tall = width > 0 && height > 0 && height > width; + if (tall) { + return { + mode: "tall", + flowRows: 0, + flowCols: 2, + maxColSpan: 2, + maxRowSpan: 2, + unit: TALL_UNIT, + margin: TALL_MARGIN, + }; + } + // Solve 4·unit + 3·gap + 2·margin = height with gap = unit/9 and margin = + // unit/3: 4u + u/3 + 2u/3 = 5u → u = height/5. Unmeasured (height ≤ 0) → + // the band's middle, not a tiny tile. + const raw = height > 0 ? height / 5 : (WIDE_UNIT_MIN + WIDE_UNIT_MAX) / 2; + const unit = px(clampNumber(raw, WIDE_UNIT_MIN, WIDE_UNIT_MAX)); + const gap = Math.max(10, px(unit / 9)); + const margin = px(clampNumber(unit / 3, WIDE_MARGIN_MIN, WIDE_MARGIN_MAX)); + return { + mode: "wide", + flowRows: 4, + flowCols: 0, + maxColSpan: 4, + maxRowSpan: 2, + unit: { width: unit, height: unit, gap }, + margin, + }; +} + +/** Sort entries by their persisted order (stable — ties keep their relative order). */ +export function sortTiles<T extends TileLayoutEntry>(tiles: readonly T[]): T[] { + return [...tiles].sort((a, b) => a.order - b.order); +} + +/** Clamp a requested span to a geometry's caps, coercing garbage (non-positive/NaN) to 1. */ +export function clampSpan<T extends Span>(span: T, geometry: GridGeometry): Span { + return { + colSpan: clampUnit(span.colSpan, geometry.maxColSpan), + rowSpan: clampUnit(span.rowSpan, geometry.maxRowSpan), + }; +} + +function clampUnit(value: number, max: number): number { + if (!Number.isFinite(value)) return 1; + const int = Math.floor(value); + if (int < 1) return 1; + return Math.min(int, max); +} + +/** + * A position's rank in the geometry's FILL ORDER — the order slots are visited + * when packing: wide fills column-wise (top-to-bottom, then the next column); + * tall fills row-wise (left-to-right, then the next row). Pure; only used for + * comparisons within one geometry. + */ +export function fillOrderKey(col: number, row: number, geometry: GridGeometry): number { + if (geometry.mode === "wide") return (col - 1) * geometry.flowRows + (row - 1); + return (row - 1) * geometry.flowCols + (col - 1); +} + +/** + * The universal frame's fixed row count (the wide grid's). Tall render cells + * map onto column 1 of this frame; wide is the identity. + */ +export const UNIVERSAL_ROWS = 4; + +/** A tile's anchor cell (1-based). */ +export interface Cell { + readonly col: number; + readonly row: number; +} + +/** + * Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column + * 1 of the universal frame folds onto the 2 render columns (universal row u → + * render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be + * represented in tall → `null` (the tile reads as UNPINNED in tall). + * Pure. + */ +export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null { + if (geometry.mode === "wide") return anchor; + if (anchor.col !== 1) return null; + return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 }; +} + +/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */ +export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell { + if (geometry.mode === "wide") return cell; + return { col: 1, row: 2 * (cell.row - 1) + cell.col }; +} + +function cellsFree( + occupied: ReadonlySet<string>, + col: number, + row: number, + colSpan: number, + rowSpan: number, +): boolean { + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + if (occupied.has(`${r}:${c}`)) return false; + } + } + return true; +} + +function occupy( + occupied: Set<string>, + col: number, + row: number, + colSpan: number, + rowSpan: number, +): void { + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + occupied.add(`${r}:${c}`); + } + } +} + +function maxOccupied(occupied: ReadonlySet<string>, axis: "col" | "row"): number { + let max = 0; + for (const cell of occupied) { + const value = Number.parseInt( + axis === "row" ? (cell.split(":")[0] ?? "") : (cell.split(":")[1] ?? ""), + 10, + ); + if (Number.isFinite(value) && value > max) max = value; + } + return max; +} + +/** + * The first slot (in fill order) where a span-sized footprint fits without + * overlapping or exceeding the geometry's fixed cross-axis. Beyond the + * occupied region every cell is free, so the scan is bounded by the occupied + * extent — the result always exists (the flow is unbounded along its main + * axis). Pure: occupancy + span + geometry in, slot out. + */ +function firstFit( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, +): { col: number; row: number } { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col"); + for (let col = 1; col <= maxCol; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 1, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row"); + for (let row = 1; row <= maxRow; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 1 }; +} + +/** + * THE entries → coordinates transform, v2. Two passes, one scan each: + * + * 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly + * one-ply: a displaced pin never bumps another pin): a pinned tile lands at + * its anchor (universal frame → render cell) when the anchor maps into this + * geometry, its clamped footprint fits the fixed cross-axis there, and no + * already-placed tile overlaps. Otherwise the tile reads as UNPINNED. + * 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take + * the first fitting FREE slot in fill order — holes between pins count. + * + * Requested spans clamp to the geometry (render-time only; the persisted spans + * stay raw). Pure: entries + geometry in, a NEW placements array out (entry + * order — the render order). + */ +export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] { + const occupied = new Set<string>(); + const placements: (Placement | undefined)[] = []; + const flowing: { index: number; tile: TileLayoutEntry }[] = []; + const ordered = sortTiles(tiles); + for (let index = 0; index < ordered.length; index++) { + const tile = ordered[index]; + if (!tile) continue; + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const anchor = pinnedAnchor(tile, geometry); + if ( + anchor !== null && + fitsCrossAxis(anchor, colSpan, rowSpan, geometry) && + cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan) + ) { + occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan }; + } else { + flowing.push({ index, tile }); + } + } + for (const { index, tile } of flowing) { + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const slot = firstFit(occupied, { colSpan, rowSpan }, geometry); + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }; + } + return placements.filter((placement) => placement !== undefined); +} + +/** + * A pinned tile's render-geometry anchor, or `null` when the tile is unpinned + * (no anchor) or its anchor cannot be represented in this geometry (a wide + * column > 1 read in tall). Pure. + */ +function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null { + if (tile.col === undefined || tile.row === undefined) return null; + return toRenderCell({ col: tile.col, row: tile.row }, geometry); +} + +/** The footprint fits the geometry's FIXED cross-axis at this anchor. */ +function fitsCrossAxis( + anchor: Cell, + colSpan: number, + rowSpan: number, + geometry: GridGeometry, +): boolean { + if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows; + return anchor.col + colSpan - 1 <= geometry.flowCols; +} + +/** + * The first fitting slot at or after a minimum stream key (the displacement + * re-anchor scan — pushed tiles only move FORWARD). Pure. + */ +function firstFitFrom( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, + minKey: number, +): Cell { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col") + 1; + for (let col = 1; col <= maxCol + 1; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 2, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row") + 1; + for (let row = 1; row <= maxRow + 1; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 2 }; +} + +/** + * THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER + * placements (the squares exactly as the user sees them). Semantics (user- + * specified): the dragged tile leaves its slot (the gap OPENS — nothing slides + * backward, ever), anchors exactly at `cell`, and the contiguous run of filled + * cells pushes FORWARD in fill order until the first empty cell absorbs it: + * + * `X _ X` + Y at slot 1 → `Y X X` + * `_ X Z _` + Y at slot 2 → `_ Y X Z` + * + * Tiles keep their positions unless their footprint overlaps an already-placed + * one; the displaced re-anchor at the next fitting slot forward (spans ride + * along; a span that can't fit the fixed cross-axis anywhere forward wraps to + * the first fitting slot — it must land somewhere). Pure: placements + drop + * in, a NEW placements array out — in the INPUT array order (only coordinates + * change). A keyed `{#each}` over the result therefore never moves DOM nodes; + * moving a captured element drops its pointer capture — the "stuck tile" bug. + */ +export function displacePlace( + placements: readonly Placement[], + draggedId: string, + cell: Cell, + geometry: GridGeometry, +): Placement[] { + const dragged = placements.find((placement) => placement.id === draggedId); + if (!dragged) return [...placements]; + const occupied = new Set<string>(); + occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan); + const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }]; + const others = placements + .filter((placement) => placement.id !== draggedId) + .map((placement) => ({ + placement, + key: fillOrderKey(placement.col, placement.row, geometry), + })) + .sort((a, b) => a.key - b.key); + for (const { placement, key } of others) { + const { colSpan, rowSpan } = placement; + if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) { + occupy(occupied, placement.col, placement.row, colSpan, rowSpan); + placed.push(placement); + continue; + } + let slot = firstFitFrom(occupied, placement, geometry, key); + if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) { + slot = firstFit(occupied, placement, geometry); + } + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placed.push({ ...placement, col: slot.col, row: slot.row }); + } + // Return in the INPUT render order (coordinates change only): the live drag + // preview feeds a keyed `{#each}`, and a reordered array moves the DOM nodes + // — a moved captured element silently loses pointer capture (the stuck-tile + // bug). The commit path re-sorts to stream order itself (`placeAtCell`). + const byId = new Map(placed.map((placement) => [placement.id, placement])); + return placements.map((placement) => byId.get(placement.id) ?? placement); +} + +/** + * The commit path: pack the persisted entries, displace at `cell`, and pin + * EVERY tile back to the entries at its resulting anchor (universal frame — + * the persisted spans stay raw, only anchors + order change; order renumbers + * in stream order). This is the drag-drop write. Pure: entries in, NEW + * entries out. + */ +export function placeAtCell( + tiles: readonly TileLayoutEntry[], + id: string, + cell: Cell, + geometry: GridGeometry, +): TileLayoutEntry[] { + // An unknown dragged id changes nothing (order-sorted copy — the op's + // canonical no-op shape, like `moveTile` before it). + if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles); + // displacePlace preserves the input RENDER order for the live preview; the + // commit pins + renumbers in STREAM order, so re-sort before pinning. + const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry) + .slice() + .sort((a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry)); + const result: TileLayoutEntry[] = []; + for (const placement of displaced) { + const entry = tiles.find((tile) => tile.id === placement.id); + if (!entry) continue; + const anchor = toUniversalCell(placement, geometry); + result.push({ ...entry, col: anchor.col, row: anchor.row }); + } + return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index })); +} + +/** + * The drag target's clamp on the geometry's UNBOUNDED axis — a drop may aim + * at most `extra` columns (wide) / rows (tall) past the current content edge, + * so a drag can never create an endlessly growing canvas ("hard to place if + * it just grows"): wide bound = the right-most occupied column (span-aware) + + * `extra`; tall = the bottom-most occupied row + `extra`. Empty content → the + * first tiles may land within the first `extra` columns. The bound is measured + * on the COMMITTED placements — a drag always compares against the layout it + * started from, never the live preview, so the limit cannot ratchet mid-drag. + * Pure: placements + geometry in, the max allowed column (wide) / row (tall) + * out. + */ +export function dragAppendLimit( + placements: readonly Placement[], + geometry: GridGeometry, + extra = 3, +): number { + let extent = 0; + for (const placement of placements) { + extent = Math.max( + extent, + geometry.mode === "wide" + ? placement.col + placement.colSpan - 1 + : placement.row + placement.rowSpan - 1, + ); + } + return extent + extra; +} + +/** + * The first footprint-fitting EMPTY slot in fill order — where a NEW tile of + * the given span would go (the header's create flow). Pure: entries + span + + * geometry in, a slot placement out (always exists; may be a new column/row + * beyond the current extent). + */ +export function firstEmptySlot( + tiles: readonly TileLayoutEntry[], + span: Span, + geometry: GridGeometry, +): { col: number; row: number } { + return firstFit(packOccupancy(tiles, geometry), span, geometry); +} + +/** Build the occupancy set of the tiles' packed positions. */ +function packOccupancy(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Set<string> { + const occupied = new Set<string>(); + for (const placement of pack(tiles, geometry)) { + occupy(occupied, placement.col, placement.row, placement.colSpan, placement.rowSpan); + } + return occupied; +} + +/** Re-assign orders 0..n-1 by array position (the canonical order encoding after structural edits). */ +function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] { + return tiles.map((tile, index) => (tile.order === index ? tile : { ...tile, order: index })); +} + +/** + * Pin a tile at the first EMPTY slot in fill order — the header-create + * placement (holes between pins count). Idempotent: an id already in the + * layout is RE-PINNED there (spans preserved), not duplicated — this closes + * the race where the list-refresh sync appended the id first. Unknown ids + * append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out + * (order preserved — anchors, not order, place tiles now). + */ +export function pinAtFirstEmpty( + tiles: readonly TileLayoutEntry[], + id: string, + geometry: GridGeometry, +): TileLayoutEntry[] { + const without = sortTiles(tiles).filter((tile) => tile.id !== id); + const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry); + const anchor = toUniversalCell(slot, geometry); + const existing = tiles.find((tile) => tile.id === id); + const entry: TileLayoutEntry = existing + ? { ...existing, col: anchor.col, row: anchor.row } + : { + id, + colSpan: 1, + rowSpan: 1, + order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0), + col: anchor.col, + row: anchor.row, + }; + return [...without, entry]; +} + +/** + * Resize one tile: set its persisted spans (sanitized to positive integers — + * NOT clamped to the current geometry; clamping is a render-time concern so a + * wide-sized tile survives an orientation switch). Unknown ids are ignored. + * Pure: entries in, a NEW entries array out. + */ +export function resizeTile( + tiles: readonly TileLayoutEntry[], + id: string, + colSpan: number, + rowSpan: number, +): TileLayoutEntry[] { + const sanitized: Span = { + colSpan: clampUnit(colSpan, Number.MAX_SAFE_INTEGER), + rowSpan: clampUnit(rowSpan, Number.MAX_SAFE_INTEGER), + }; + return sortTiles(tiles).map((tile) => + tile.id === id ? { ...tile, colSpan: sanitized.colSpan, rowSpan: sanitized.rowSpan } : tile, + ); +} + +/** + * Re-apply the recent-activity order (the header overflow "Sort" action) AND + * clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their + * workspace's `lastActivityAt` descending (unknown ids last, ties stable), + * orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure. + */ +export function sortByRecentActivity( + tiles: readonly TileLayoutEntry[], + lastActivityById: ReadonlyMap<string, number>, +): TileLayoutEntry[] { + const sorted = sortTiles(tiles).sort( + (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0), + ); + return renumber( + sorted.map((tile) => { + const { col: _col, row: _row, ...unpinned } = tile; + return unpinned; + }), + ); +} + +/** + * Merge the persisted layout with the live workspace list: workspaces unknown + * to the layout (e.g. auto-created via deep-link) APPEND at the end (default + * 1×1, order after the current max); entries for deleted workspaces DROP; + * duplicate ids dedup (first wins); the result sorts by order. When a + * geometry is given, the returned VIEW clamps spans to it (the persisted + * document keeps the raw spans — clamping is transient per geometry). Pure: + * both inputs in, a NEW entries array out. + */ +export function mergeLayout( + persisted: TileLayoutData | null | undefined, + workspaceIds: readonly string[], + geometry?: GridGeometry, +): TileLayoutEntry[] { + const known = new Set(workspaceIds); + const kept: TileLayoutEntry[] = []; + const seen = new Set<string>(); + let maxOrder = 0; + for (const tile of sortTiles(persisted?.tiles ?? [])) { + if (!known.has(tile.id) || seen.has(tile.id)) continue; + seen.add(tile.id); + kept.push(tile); + if (tile.order > maxOrder) maxOrder = tile.order; + } + const appended: TileLayoutEntry[] = []; + for (const id of workspaceIds) { + if (seen.has(id)) continue; + seen.add(id); + maxOrder += 1; + appended.push({ id, colSpan: 1, rowSpan: 1, order: maxOrder }); + } + const merged = [...kept, ...appended]; + if (!geometry) return merged; + return merged.map((tile) => ({ ...tile, ...clampSpan(tile, geometry) })); +} + +/** + * Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load + * is untyped at the boundary). v1 documents (order + spans only) MIGRATE to + * v2 in memory: their tiles become unpinned, which packs identically to v1's + * first-fit — the layout only starts carrying anchors after the user's first + * drop. v2 entries keep their anchors; malformed entries coerce (missing + * spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything + * else yields the empty layout. Pure: unknown in, v2 data out. + */ +export function sanitizeLayout(raw: unknown): TileLayoutData { + if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT; + const version = (raw as { version?: unknown }).version; + if (version !== 1 && version !== 2) return EMPTY_LAYOUT; + const rawTiles = (raw as { tiles?: unknown }).tiles; + if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT; + const tiles: TileLayoutEntry[] = []; + const seen = new Set<string>(); + for (let index = 0; index < rawTiles.length; index++) { + const rawTile = rawTiles[index]; + if (typeof rawTile !== "object" || rawTile === null) continue; + const id = (rawTile as { id?: unknown }).id; + if (typeof id !== "string" || id === "" || seen.has(id)) continue; + seen.add(id); + const col = coerceAnchor((rawTile as { col?: unknown }).col); + const row = coerceAnchor((rawTile as { row?: unknown }).row); + tiles.push({ + id, + colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan), + rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan), + order: coerceOrder((rawTile as { order?: unknown }).order, index), + // An anchor is only meaningful as a PAIR — a lone coordinate drops both. + ...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}), + }); + } + return { version: 2, tiles }; +} + +const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] }; + +function coerceSpan(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1; + return Math.floor(value); +} + +function coerceOrder(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.floor(value); +} + +/** A valid anchor coordinate (1-based positive integer) or undefined. */ +function coerceAnchor(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined; + return Math.floor(value); +} diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 44d2f31..31366ba 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -1,6 +1,39 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; +import { + applyArchived, + applyStarred, + pageTitle, + relativeTime, + sortWorkspaces, + tileTone, +} from "./view-model"; + +describe("tileTone", () => { + const TONES = ["primary", "secondary", "accent", "info", "success", "warning"]; + + it("is deterministic — the same id always gets the same tone", () => { + expect(tileTone("my-workspace")).toBe(tileTone("my-workspace")); + }); + + it("only returns known tones", () => { + for (const id of ["default", "a", "my-workspace", "x-1", "project-phoenix", "zzz"]) { + expect(TONES).toContain(tileTone(id)); + } + }); + + it("spreads distinct ids across more than one tone", () => { + const tones = new Set( + ["default", "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"].map(tileTone), + ); + expect(tones.size).toBeGreaterThan(1); + }); + + it("is case-sensitive (ids are slugs, but the hash makes no normalization promise)", () => { + // Not a correctness requirement — just documents the hash reads raw chars. + expect(typeof tileTone("ABC")).toBe("string"); + }); +}); describe("relativeTime", () => { const now = 1_000_000_000_000; // 2001-09-09 @@ -23,10 +56,15 @@ describe("relativeTime", () => { expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); }); - it("is a short date beyond a week", () => { - // 7+ days ago: just check it is a MM/DD string. - const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); - expect(s).toMatch(/^\d{2}\/\d{2}$/); + it("keeps days past a week (no short date) up to a month", () => { + expect(relativeTime(now - 10 * 24 * 60 * 60_000, now)).toBe("10d"); + expect(relativeTime(now - 29 * 24 * 60 * 60_000, now)).toBe("29d"); + }); + + it("is months past a month", () => { + expect(relativeTime(now - 30 * 24 * 60 * 60_000, now)).toBe("1mo"); + expect(relativeTime(now - 75 * 24 * 60 * 60_000, now)).toBe("2mo"); + expect(relativeTime(now - 365 * 24 * 60 * 60_000, now)).toBe("12mo"); }); }); @@ -38,6 +76,7 @@ describe("pageTitle", () => { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -75,6 +114,7 @@ describe("sortWorkspaces", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt, conversationCount: 0, @@ -133,6 +173,7 @@ describe("applyStarred", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -177,3 +218,50 @@ describe("applyStarred", () => { expect(reverted[0]?.starred).toBe(false); }); }); + +describe("applyArchived", () => { + const entry = (id: string, archived: boolean): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("sets the named workspace's archived flag", () => { + const list = [entry("a", false), entry("b", false)]; + const next = applyArchived(list, "b", true); + expect(next.map((w) => [w.id, w.archived])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("a", false)]; + const next = applyArchived(list, "a", true); + expect(next).not.toBe(list); + expect(list[0]?.archived).toBe(false); + expect(next[0]?.archived).toBe(true); + }); + + it("leaves other entries referentially unchanged (only the target is replaced)", () => { + const a = entry("a", false); + const b = entry("b", false); + const next = applyArchived([a, b], "b", true); + expect(next[0]).toBe(a); + expect(next[1]).not.toBe(b); + }); + + it("can revert by re-applying the previous value (unarchive)", () => { + const list = [entry("a", false)]; + const optimistic = applyArchived(list, "a", true); + expect(optimistic[0]?.archived).toBe(true); + const reverted = applyArchived(optimistic, "a", false); + expect(reverted[0]?.archived).toBe(false); + }); +}); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index b994b7c..cdf99c8 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -6,6 +6,40 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import type { Route } from "./route"; /** + * A home tile's color tone. Windows 8's Start screen gave every tile its own + * saturated color; here the tile's workspace id DETERMINISTICALLY picks one of + * the theme's six semantic tones, so the same workspace keeps its color across + * sessions and themes (the tone name maps to a DaisyUI color, which adapts to + * the active theme). Starred stays a separate visual concern (a badge), NOT a + * tone — any tile may be starred regardless of its color. + */ +export type TileTone = "primary" | "secondary" | "accent" | "info" | "success" | "warning"; + +/** The tones a tile may take, in hash order. */ +const TILE_TONES: readonly TileTone[] = [ + "primary", + "secondary", + "accent", + "info", + "success", + "warning", +]; + +/** + * Map a workspace id to its tile tone. Deterministic (FNV-1a hash of the id) + * and uniformly distributed across the six tones — pure: id in, tone out. + */ +export function tileTone(id: string): TileTone { + let hash = 0x811c9dc5; + for (let index = 0; index < id.length; index++) { + hash ^= id.charCodeAt(index); + // FNV prime (16777619), kept in 32-bit unsigned range. + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return TILE_TONES[hash % TILE_TONES.length] ?? "primary"; +} + +/** * The browser tab / page (`document.title`) text for a route. The home route * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}", * using the workspace's display title and falling back to the URL slug (`id`) @@ -50,6 +84,20 @@ export function applyStarred<T extends WorkspaceEntry>( } /** + * Return a NEW list with the one workspace's `archived` flag set (immutably — + * the entry is replaced, the rest keep their identity). Pure: the optimistic + * archive/unarchive transformation shared by the apply + the error revert. A + * missing `id` leaves the list unchanged; a subsequent refresh reconciles. + */ +export function applyArchived<T extends WorkspaceEntry>( + workspaces: readonly T[], + id: string, + archived: boolean, +): T[] { + return workspaces.map((w) => (w.id === id ? { ...w, archived } : w)); +} + +/** * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h", * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps * (a workspace just created) read as "now". @@ -62,10 +110,8 @@ export function relativeTime(then: number, now: number): string { const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); - if (days < 7) return `${days}d`; - // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. - const d = new Date(then); - const month = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${month}/${day}`; + if (days < 30) return `${days}d`; + // Past a month: months (a month ≈ 30 days). No short date — the label + // stays relative forever. + return `${Math.floor(days / 30)}mo`; } diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 22d73b5..b9b646b 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -1,7 +1,8 @@ import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; -import { applyStarred, sortWorkspaces } from "./logic/view-model"; +import { gateCreate } from "./logic/create"; +import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model"; /** * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the @@ -16,12 +17,25 @@ export interface WorkspaceStore { * backend's `lastActivityAt`-desc order; this getter re-sorts reactively. */ readonly list: readonly WorkspaceEntry[]; + /** Archived workspaces, for the archived view. Loaded via `refreshArchived()`. */ + readonly archivedList: readonly WorkspaceEntry[]; readonly loading: boolean; + readonly archivedLoading: boolean; readonly error: string | null; /** Refresh the list from the backend. */ refresh(): Promise<void>; + /** Refresh the archived list (`GET /workspaces?archived=true`). */ + 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. */ @@ -34,25 +48,41 @@ export interface WorkspaceStore { * reverts to the prior value. No full refresh on success (avoids flicker). */ setStarred(id: string, starred: boolean): Promise<WorkspaceResult<Workspace>>; + /** + * Archive or unarchive a workspace. Optimistic: the local `archived` flag + * flips immediately (archiving drops the tile from the home grid); on error + * it reverts to the prior value. A successful archive/unarchive is followed + * by a `refresh()` so the active list reflects the backend (the archived + * workspace leaves/enters the default-filtered list). + */ + setArchived(id: string, archived: boolean): Promise<WorkspaceResult<Workspace>>; /** Delete a workspace (closes its conversations, reassigns to "default"). */ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { let list = $state<readonly WorkspaceEntry[]>([]); + let archivedList = $state<readonly WorkspaceEntry[]>([]); // Sorted view (starred first, then most-recent) — derived so it recomputes // only when the backing list changes, not on every read. let sorted = $derived(sortWorkspaces(list)); let loading = $state(false); + let archivedLoading = $state(false); let error = $state<string | null>(null); return { get list(): readonly WorkspaceEntry[] { return sorted; }, + get archivedList(): readonly WorkspaceEntry[] { + return archivedList; + }, get loading(): boolean { return loading; }, + get archivedLoading(): boolean { + return archivedLoading; + }, get error(): string | null { return error; }, @@ -69,6 +99,24 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { } }, + async refreshArchived(): Promise<void> { + archivedLoading = true; + error = null; + try { + archivedList = await http.list("true"); + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load archived workspaces"; + } finally { + archivedLoading = false; + } + }, + + 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(); @@ -105,6 +153,22 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { return result; }, + async setArchived(id, archived): Promise<WorkspaceResult<Workspace>> { + const prev = list.find((w) => w.id === id)?.archived ?? false; + // Optimistic: flip immediately so the tile leaves/enters the home grid. + list = applyArchived(list, id, archived); + const result = archived ? await http.archive(id) : await http.unarchive(id); + if (!result.ok) { + // Revert the optimistic flip on failure. + list = applyArchived(list, id, prev); + return result; + } + // Reconcile with the backend's default (active-only) filter so the + // archived tile is dropped / the unarchived tile is restored. + void this.refresh(); + return result; + }, + async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { const result = await http.delete(id); if (result.ok) void this.refresh(); diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts index 4caac9f..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 { @@ -10,6 +11,7 @@ function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 0, @@ -22,11 +24,25 @@ function fakeHttp(opts: { list?: readonly WorkspaceEntry[]; star?: (id: string) => Promise<WorkspaceResult<Workspace>>; 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 (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []), - ensure: vi.fn(), - get: vi.fn(), + list: vi.fn( + async (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + opts.list ?? [], + ), + 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(), @@ -46,6 +62,22 @@ function fakeHttp(opts: { value: entry({ id, starred: false }), }), ), + archive: + opts.archive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: true }), + }), + ), + unarchive: + opts.unarchive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: false }), + }), + ), delete: vi.fn(), }; } @@ -143,3 +175,131 @@ describe("createWorkspaceStore — setStarred", () => { expect(store.list).toHaveLength(0); }); }); + +describe("createWorkspaceStore — setArchived", () => { + it("optimistically flips archived to true before the request resolves", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + let observedDuringCall = false; + http.archive = vi.fn(async (_id: string): Promise<WorkspaceResult<Workspace>> => { + // While the request is in flight, the store already shows the new state. + observedDuringCall = store.list[0]?.archived === true; + return { ok: true, value: entry({ id: "a", archived: true }) }; + }); + + await store.setArchived("a", true); + + expect(observedDuringCall).toBe(true); + expect(http.archive).toHaveBeenCalledWith("a"); + }); + + it("calls unarchive (DELETE) when archiving false", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: true })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + await store.setArchived("a", false); + + expect(http.unarchive).toHaveBeenCalledWith("a"); + expect(http.archive).not.toHaveBeenCalled(); + }); + + it("reverts the optimistic flip on error", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.archive = vi.fn( + async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }), + ); + + const result = await store.setArchived("a", true); + + expect(result).toEqual({ ok: false, error: "boom" }); + expect(store.list[0]?.archived).toBe(false); + }); + + it("refreshes the active list after a successful archive", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + const listCallsBefore = http.list.mock.calls.length; + + await store.setArchived("a", true); + + // setArchived triggers a refresh() to reconcile the active-only list. + expect(http.list.mock.calls.length).toBeGreaterThan(listCallsBefore); + }); +}); + +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 })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + // Point the archived call at a different payload. + http.list = vi.fn( + async (archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + archived === "true" + ? [entry({ id: "done", archived: true })] + : [entry({ id: "active", archived: false })], + ); + + await store.refreshArchived(); + + expect(http.list).toHaveBeenCalledWith("true"); + expect(store.archivedList.map((w) => w.id)).toEqual(["done"]); + // The main (active) list is untouched. + expect(store.list.map((w) => w.id)).toEqual(["active"]); + }); +}); diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts new file mode 100644 index 0000000..98a5c33 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -0,0 +1,308 @@ +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, + type GridGeometry, + mergeLayout, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutData, + type TileLayoutEntry, +} from "./logic/tile-layout"; + +/** + * Tile-layout store — the synced arrangement of the home grid, ONE shared + * document PER device orientation (`wide` = landscape/desktop, `tall` = + * portrait/mobile), persisted through the INJECTED `TileLayoutHttp` edge + * (the `home-tile-layout` plugin's well-known routes). Mirrors the layout + * feature's store semantics: + * + * - **Orientation selection** — `setMode(mode)` (the home page's effect on + * the resolved grid geometry) is the boot + orientation-change trigger: + * 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; + * edits apply locally IMMEDIATELY, then PUT the whole document (decision 7 + * — no patch protocol) lightly debounced; a failed PUT on a present + * backend rolls back to the last server document with a quiet console + * warning (never an error UI). + * - **WS push** — `onServerTileLayout(mode, data)` applies a `tile-layout` + * broadcast, ignoring the OTHER orientation (the server fans out to all + * 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 — + * 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 + * arrangement. The caller's effect re-runs when the state settles. + * + * No ambient state: one instance per home page, created by the page entry + * (or injected in tests). + */ + +/** Whether the backend is serving the active orientation's document. */ +export type TileLayoutProviderState = "unknown" | "present" | "absent"; + +export interface TileLayoutStore { + /** The persisted entries of the ACTIVE orientation, order-sorted. */ + readonly entries: readonly TileLayoutEntry[]; + /** The active orientation (the home page's resolved grid geometry mode). */ + readonly mode: TileGeometryMode; + /** The backend's serving state for the ACTIVE orientation. */ + readonly providerState: TileLayoutProviderState; + /** + * 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 seeds from its cache + probes fresh. + */ + setMode(mode: TileGeometryMode): void; + /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */ + probe(): Promise<void>; + /** Server `tile-layout` broadcast. Other orientations are ignored. */ + onServerTileLayout(mode: TileGeometryMode, data: ContractTileLayoutData): void; + /** + * Merge the live workspace ids into the layout: workspaces unknown to the + * layout append (unpinned — they flow to the first free cells), entries for + * deleted workspaces drop (their cells become gaps). A no-op (no write) + * when the merge changes nothing — so an effect that tracks both the + * workspace list and this store settles instead of looping. + */ + sync(workspaceIds: readonly string[]): void; + /** Resize a tile (persisted spans; render-time clamping is separate). */ + resize(id: string, colSpan: number, rowSpan: number): void; + /** + * Drop a tile at a specific CELL (the drag commit): the contiguous filled + * run pushes forward from that cell (gaps absorb it), every resulting + * position pins. See `placeAtCell`. + */ + placeAtCell(id: string, cell: Cell, geometry: GridGeometry): void; + /** + * Pin (or re-pin) the tile at the FIRST EMPTY slot — the header-create + * flow. Idempotent (see `pinAtFirstEmpty`). + */ + placeFirstEmpty(id: string, geometry: GridGeometry): void; + /** Re-apply the recent-activity order AND clear all pins (the reset). */ + sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void; +} + +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). */ + readonly warn?: (message: string) => void; +} + +const DEFAULT_DEBOUNCE_MS = 300; + +export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutStore { + const http = opts.http; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + 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 + // 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. */ + let lastServerLayout: TileLayoutData | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the degrade log (reset when the backend returns). */ + let loggedAbsent = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `tile-layout: backend not serving the layout (${reason}) — rendering the local default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerLayout(next: TileLayoutData): void { + lastServerLayout = next; + data = next; + cache[mode].save(next); + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(): Promise<void> { + 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 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. + lastServerLayout = sanitizeLayout(result.layout); + return; + } + if (providerState !== "present") { + // The backend is gone (or never was): keep the local edit (ephemeral), + // no rollback — there is no server document to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT backend: roll back to the last server + // document, quiet console warning (never an error UI). + warn( + `tile-layout: persisting the layout failed (${result.error}) — rolled back to the last server document`, + ); + if (lastServerLayout !== null) data = lastServerLayout; + } + + async function runProbe(): Promise<void> { + // Capture the mode AT START: a setMode may happen while the fetch is in + // flight — only the CURRENT mode's result may land (the reset in + // setMode already staged the fresh default; a stale response must never + // overwrite it). + const probed = mode; + const result = await http.getLayout(mode); + if (probed !== mode) return; + if (result.ok) { + notePresent(); + applyServerLayout(sanitizeLayout(result.layout)); + } else { + // A real failure (5xx, 404 = routes not served, malformed, network) + // degrades quietly — the local default keeps rendering. + noteAbsent(result.error); + } + } + + function commit(tiles: readonly TileLayoutEntry[]): void { + // A pure op that changed nothing (e.g. a no-op place) doesn't write. + if (sameEntries(tiles, data.tiles)) return; + data = { version: 2, tiles }; + if (providerState === "unknown") return; // first probe still in flight + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut().catch(() => { + // Network-grade failure: roll back to the last server document, quietly. + warn("tile-layout: persisting the layout failed — rolled back to the last server document"); + if (lastServerLayout !== null) data = lastServerLayout; + }); + }, debounceMs); + } + + // Boot probe for the initial mode — the home page's setMode effect either + // confirms it (no-op) or switches it (fresh probe). + void runProbe(); + + return { + get entries(): readonly TileLayoutEntry[] { + return data.tiles; + }, + get mode(): TileGeometryMode { + return mode; + }, + get providerState(): TileLayoutProviderState { + return providerState; + }, + + setMode(next) { + if (next === mode) return; + mode = next; + // 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(cache[mode].load()); + providerState = "unknown"; + void runProbe(); + }, + + async probe() { + await runProbe(); + }, + + onServerTileLayout(pushMode, next) { + if (pushMode !== mode) return; // clients filter by orientation + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerLayout(sanitizeLayout(next)); + }, + + sync(workspaceIds): void { + // Never merge into an UNPROBED empty document: before the first probe + // resolves, "empty" is our placeholder, not a real arrangement — and + // merging live ids into it could PUT a wiped layout. The caller's + // effect re-runs when the state settles (it tracks providerState). + if (providerState === "unknown") return; + const merged = mergeLayout(data, workspaceIds); + if (!sameEntries(merged, data.tiles)) commit(merged); + }, + + resize(id, colSpan, rowSpan): void { + commit(resizeTile(data.tiles, id, colSpan, rowSpan)); + }, + + placeAtCell(id, cell, geometry): void { + commit(placeAtCell(data.tiles, id, cell, geometry)); + }, + + placeFirstEmpty(id, geometry): void { + commit(pinAtFirstEmpty(data.tiles, id, geometry)); + }, + + sortByRecentActivity(lastActivityById): void { + commit(sortByRecentActivity(data.tiles, lastActivityById)); + }, + }; +} + +/** Structural equality over entries (ids, spans, orders, anchors). */ +function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[]): boolean { + if (a.length !== b.length) return false; + return a.every((entry, index) => { + const other = b[index]; + return ( + other !== undefined && + entry.id === other.id && + entry.colSpan === other.colSpan && + entry.rowSpan === other.rowSpan && + entry.order === other.order && + entry.col === other.col && + entry.row === other.row + ); + }); +} diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts new file mode 100644 index 0000000..b5af8b6 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -0,0 +1,449 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "./adapter/tile-layout-http"; +import { geometryFor, type TileLayoutEntry } from "./logic/tile-layout"; +import { createTileLayoutStore } from "./tile-layout-store.svelte"; + +const WIDE = geometryFor(1920, 1080); + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +function doc(...tiles: TileLayoutEntry[]): TileLayoutData { + return { version: 2, tiles }; +} + +/** A controllable fake HTTP edge: per-mode documents + a call log (no mocks of our modules). */ +function fakeHttp( + initial: Partial<Record<TileGeometryMode, TileLayoutData>> = {}, +): TileLayoutHttp & { + docs: Map<TileGeometryMode, TileLayoutData>; + puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }>; + failNext: { get: string | null; put: string | null }; +} { + const docs = new Map<TileGeometryMode, TileLayoutData>( + Object.entries(initial) as Array<[TileGeometryMode, TileLayoutData]>, + ); + const puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }> = []; + const failNext = { get: null as string | null, put: null as string | null }; + const ok = (layout: TileLayoutData): TileLayoutHttpResult => ({ ok: true, layout }); + return { + docs, + puts, + failNext, + async getLayout(mode) { + if (failNext.get !== null) { + const error = failNext.get; + failNext.get = null; + return { ok: false, notFound: false, error }; + } + return ok(docs.get(mode) ?? doc()); + }, + async putLayout(mode, layout) { + if (failNext.put !== null) { + const error = failNext.put; + failNext.put = null; + return { ok: false, notFound: false, error }; + } + puts.push({ mode, layout }); + docs.set(mode, layout); + return ok(layout); + }, + async deleteLayout(mode) { + docs.set(mode, doc()); + return ok(docs.get(mode) as TileLayoutData); + }, + }; +} + +/** Flush the boot probe + debounced timers (microtasks first, then the clock). */ +async function settle(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); + await Promise.resolve(); +} + +async function flushOnly(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); +} + +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 () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 2, 2)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + expect(store.providerState).toBe("present"); + 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); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([]); + }); + + it("a probe failure degrades to absent (ephemeral edits, no PUT)", async () => { + const warn = vi.fn(); + const http = fakeHttp(); + http.failNext.get = "network down"; + const store = createTileLayoutStore({ http, warn }); + await flushOnly(); + expect(store.providerState).toBe("absent"); + + store.resize("a", 3, 3); // applied locally… + store.sync(["a"]); + await settle(); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); + expect(http.puts).toHaveLength(0); // …but never written + expect(warn).toHaveBeenCalledTimes(1); // logged ONCE, no error UI + }); + + it("setMode switches orientation: reset + fresh probe (documents are independent)", async () => { + const http = fakeHttp({ + wide: doc(tile("desktop", 0)), + tall: doc(tile("mobile", 0)), + }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + 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"]); + + store.setMode("tall"); // same-mode call: a no-op + store.setMode("wide"); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); + + it("an in-flight probe for a stale mode never lands", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + // A holder object defeats TS's closure narrowing (a bare `let` read after + // the callback-assigned write narrows to `null` and the call sites to + // `never`). + const gate: { release: ((value: TileLayoutHttpResult) => void) | null } = { release: null }; + const slow = http.getLayout.bind(http); + http.getLayout = (mode) => + mode === "tall" + ? new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }) + : slow(mode); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.setMode("tall"); // probe in flight… + store.setMode("wide"); // …superseded before it resolves + gate.release?.({ ok: true, layout: doc(tile("mobile", 0)) }); + await flushOnly(); + + // The stale tall response must NOT overwrite the active wide document. + expect(store.mode).toBe("wide"); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); + + it("a WS push applies for the ACTIVE mode and is IGNORED for the other", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.onServerTileLayout("tall", doc(tile("mobile-push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); // filtered + + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["push"]); // applied + }); + + it("edits apply locally IMMEDIATELY, then PUT the whole document (debounced)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); + expect(store.entries).toEqual([tile("a", 0, 4, 2)]); // local, no wait + expect(http.puts).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(300); + expect(http.puts).toEqual([{ mode: "wide", layout: doc(tile("a", 0, 4, 2)) }]); + }); + + it("edits ride the ACTIVE mode's document (the tall PUT names tall)", async () => { + const http = fakeHttp({ tall: doc(tile("m", 0)) }); + const store = createTileLayoutStore({ http }); + store.setMode("tall"); + await flushOnly(); + + store.resize("m", 2, 2); + await settle(); + expect(http.puts).toEqual([{ mode: "tall", layout: doc(tile("m", 0, 2, 2)) }]); + }); + + it("a failed PUT on a present backend rolls back to the last server document", async () => { + const warn = vi.fn(); + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, warn, debounceMs: 300 }); + await flushOnly(); + + http.failNext.put = "500 gone"; + store.resize("a", 4, 2); + await settle(); + + expect(store.entries).toEqual([tile("a", 0)]); // rolled back + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("sync merges the live workspace list (gated while the first probe is in flight)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + // NO flush — the boot probe is still in flight (providerState unknown). + + store.sync(["a", "b"]); // would merge into the empty placeholder… + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual([]); // …so it is a no-op until the probe settles + + await flushOnly(); + store.sync(["a", "b"]); + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("sync appends unknown ids, drops deleted ones, and persists once", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("gone", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a", "b"]); + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + // The max order counts KEPT entries (the dropped one doesn't reserve a slot). + expect(http.puts.at(-1)?.layout).toEqual( + doc(tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }), + ); + }); + + it("sync is a no-op (no write) when the merge changes nothing", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a"]); + store.sync(["a"]); + await settle(); + + expect(http.puts).toHaveLength(0); + }); + + it("placeAtCell commits the displacement and pins EVERY tile (persisted to the server)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell. + store.placeAtCell("a", { col: 2, row: 1 }, WIDE); + await settle(); + + const entries = store.entries; + expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + expect(http.puts.at(-1)?.layout.version).toBe(2); + }); + + it("placeAtCell with an unknown id is a no-op (no write)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.placeAtCell("nope", { col: 1, row: 1 }, WIDE); + await settle(); + + expect(http.puts).toHaveLength(0); + expect(store.entries).toEqual([tile("a", 0)]); + }); + + it("placeFirstEmpty PINS the new id at the first empty slot (idempotent on re-call)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 1, 2), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.placeFirstEmpty("new", WIDE); + await settle(); + + // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned. + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + expect(http.puts.at(-1)?.layout.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); + + store.placeFirstEmpty("new", WIDE); // the create flow's re-call must be stable + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + }); + + it("sortByRecentActivity re-applies recent-activity order and CLEARs pins", async () => { + const http = fakeHttp({ + wide: doc({ ...tile("a", 0), col: 1, row: 1 }, { ...tile("b", 1), col: 2, row: 3 }), + }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sortByRecentActivity( + new Map([ + ["a", 10], + ["b", 99], + ]), + ); + + expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); + expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + }); + + it("a WS push supersedes a queued edit (the server's copy wins)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); // edit queued… + store.onServerTileLayout("wide", doc(tile("server-wins", 0))); // …server lands first + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["server-wins"]); + expect(http.puts).toHaveLength(0); // the queued edit was dropped, not PUT + }); + + it("probe() re-reads the active orientation (the reconnect catch-up)", async () => { + const http = fakeHttp({ wide: doc() }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + http.docs.set("wide", doc(tile("fresh", 0))); + await store.probe(); + expect(store.entries.map((t) => t.id)).toEqual(["fresh"]); + }); +}); diff --git a/src/features/workspaces/ui/ArchivedWorkspaces.svelte b/src/features/workspaces/ui/ArchivedWorkspaces.svelte new file mode 100644 index 0000000..584fd68 --- /dev/null +++ b/src/features/workspaces/ui/ArchivedWorkspaces.svelte @@ -0,0 +1,119 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + + let { + store, + onOpen, + onClose, + }: { + store: WorkspaceStore; + /** Open (view) a workspace — navigates to `/<id>`. */ + onOpen: (id: string) => void; + onClose: () => void; + } = $props(); + + // Load the archived list when the modal mounts. + $effect(() => { + void store.refreshArchived(); + }); + + let busyId = $state<string | null>(null); + let actionError = $state<string | null>(null); + + async function unarchive(ws: WorkspaceEntry): Promise<void> { + if (busyId !== null) return; + busyId = ws.id; + actionError = null; + const result = await store.setArchived(ws.id, false); + busyId = null; + if (!result.ok) { + actionError = result.error; + return; + } + // Refresh the archived list so the unarchived workspace leaves it. + void store.refreshArchived(); + } + + function open(ws: WorkspaceEntry): void { + onOpen(ws.id); + onClose(); + } +</script> + +<div class="modal modal-open" role="dialog" aria-label="Archived workspaces"> + <div class="modal-box max-w-lg"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">Archived workspaces</h2> + <p class="text-xs opacity-50">Hidden from the home grid. Unarchive to restore.</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close archived workspaces" + onclick={onClose}>✕</button + > + </div> + + {#if actionError} + <p class="mt-3 text-xs text-error">{actionError}</p> + {/if} + + <div class="mt-4"> + {#if store.archivedLoading && store.archivedList.length === 0} + <div class="flex justify-center py-8"> + <span class="loading loading-spinner loading-md opacity-60"></span> + </div> + {:else if store.archivedList.length === 0} + <p class="py-8 text-center text-sm opacity-50">No archived workspaces.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each store.archivedList as ws (ws.id)} + <li + class="flex items-center justify-between gap-3 rounded-box border border-base-content/10 bg-base-200/40 px-3 py-2" + > + <div class="min-w-0 flex-1"> + <p class="truncate font-semibold">{ws.title}</p> + <p class="font-mono text-xs opacity-50"> + /{ws.id} · {ws.conversationCount} conv · {relativeTime( + ws.lastActivityAt, + Date.now(), + )} ago + </p> + </div> + <div class="flex shrink-0 items-center gap-1"> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => open(ws)} + aria-label={`Open ${ws.title}`}>View</button + > + <button + type="button" + class="btn btn-outline btn-xs" + disabled={busyId === ws.id} + onclick={() => void unarchive(ws)} + aria-label={`Unarchive ${ws.title}`} + > + {#if busyId === ws.id} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Unarchive + {/if} + </button> + </div> + </li> + {/each} + </ul> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close archived workspaces backdrop" + onclick={onClose}></button> +</div> diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte new file mode 100644 index 0000000..5329b2d --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -0,0 +1,198 @@ +<script lang="ts"> + import ArchiveIcon from "phosphor-svelte/lib/ArchiveIcon"; + import DotsThreeIcon from "phosphor-svelte/lib/DotsThreeIcon"; + import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon"; + import { isValidSlug } from "../logic/route"; + + /** The create outcome the page reports back to the popover. */ + export type CreateResult = { readonly ok: true } | { readonly ok: false; readonly error: string }; + + let { + compact = false, + withPlugins = false, + withSort = false, + onCreate, + onOpenPlugins, + onSort, + onOpenArchived, + }: { + /** Tall geometry: the controls collapse to compact icon buttons and the + * new-workspace popover becomes a bottom sheet. */ + compact?: boolean; + /** The plugins port is present → the Plugins button renders. */ + withPlugins?: boolean; + /** The Sort overflow action renders (recent-activity re-apply). */ + withSort?: boolean; + /** Create the workspace on the backend (does NOT navigate); the page + * places the new tile + enters rename mode. */ + onCreate: (slug: string) => Promise<CreateResult>; + onOpenPlugins: () => void; + onSort: () => void; + /** Open the archived-workspaces view. */ + onOpenArchived: () => void; + } = $props(); + + // ── New workspace popover (today's inline form logic, preserved) ────────── + let newSlug = $state(""); + let slugError = $state<string | null>(null); + let popoverOpen = $state(false); + let submitting = $state(false); + let slugInput = $state<HTMLInputElement | undefined>(); + + const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + + function openPopover(): void { + popoverOpen = true; + slugError = null; + queueMicrotask(() => slugInput?.focus()); + } + + function closePopover(): void { + popoverOpen = false; + newSlug = ""; + slugError = null; + } + + async function submit(): Promise<void> { + const slug = newSlug.trim(); + if (!isValidSlug(slug)) { + slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + return; + } + slugError = null; + submitting = true; + const result = await onCreate(slug); + submitting = false; + if (!result.ok) { + slugError = result.error; + return; + } + closePopover(); + } +</script> + +<!-- `.app-navbar` (app.css): the same Artix-wiki navbar look as the workspace + top bar — gradient, drop shadow, engraved primary-blue title. --> +<header class="app-navbar flex shrink-0 items-end justify-between gap-2 px-8 pt-6 pb-4 md:px-12"> + <div class="flex items-baseline gap-3"> + <h1 class="app-navbar-engraved text-4xl font-black tracking-tight text-primary">Dispatch</h1> + <span class="hidden text-sm font-medium tracking-widest uppercase text-primary/70 sm:inline"> + Workspaces + </span> + </div> + + <!-- Navbar actions (app.css): "New workspace" is the one raised button + (`.navbar-btn`, primary-tinted); the secondary actions are plain text + links (`.navbar-link`) — the light-blue colour + spacing say clickable + without button chrome. Compact: labels collapse to Phosphor icons. --> + <div class="flex items-center gap-1" class:gap-3={!compact}> + <!-- New workspace --> + <button + type="button" + class="navbar-btn navbar-btn-primary" + class:navbar-btn-icon={compact} + aria-label="New workspace" + title="New workspace" + onclick={() => (popoverOpen ? closePopover() : openPopover())} + > + <PlusIcon size={16} weight="bold" aria-hidden="true" /> + {#if !compact}<span>New workspace</span>{/if} + </button> + + {#if withPlugins} + <button + type="button" + class="navbar-link" + aria-label="Plugins" + title="Open the plugin manager (load/unload plugins globally)" + onclick={onOpenPlugins} + > + {#if compact}<PuzzlePieceIcon size={18} aria-hidden="true" />{:else}<span>Plugins</span>{/if} + </button> + {/if} + + <!-- Archived workspaces --> + <button + type="button" + class="navbar-link" + aria-label="Archived workspaces" + title="View archived workspaces" + onclick={onOpenArchived} + > + {#if compact}<ArchiveIcon size={18} aria-hidden="true" />{:else}<span>Archived</span>{/if} + </button> + + {#if withSort} + <!-- Overflow last, like a navbar's trailing menu. --> + <div class="dropdown dropdown-end"> + <button + type="button" + class="navbar-link" + aria-label="More actions" + title="More" + > + <DotsThreeIcon size={20} weight="bold" aria-hidden="true" /> + </button> + <ul class="dropdown-content menu z-50 mt-1 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl"> + <li> + <button type="button" onclick={onSort}>Reset arrangement by recent activity</button> + </li> + </ul> + </div> + {/if} + </div> +</header> + +{#if popoverOpen} + <!-- Backdrop closes the popover. --> + <button + type="button" + class="fixed inset-0 z-40 cursor-default" + aria-label="Close new workspace" + onclick={closePopover}></button> + + <!-- One popover body; the geometry only changes its positioning (bottom + sheet on tall screens, a corner card on wide ones). --> + <div + class="fixed z-50 border border-base-content/15 bg-base-100 p-4 shadow-2xl" + class:inset-x-0={compact} + class:bottom-0={compact} + class:rounded-t-box={compact} + class:top-14={!compact} + class:right-6={!compact} + class:w-80={!compact} + class:rounded-box={!compact} + > + <form + class="flex flex-col gap-2" + onsubmit={(e) => { + e.preventDefault(); + void submit(); + }} + > + <label for="new-ws" class="text-xs font-semibold uppercase opacity-60">New workspace</label> + <input + id="new-ws" + bind:this={slugInput} + class="input input-bordered w-full" + placeholder="my-workspace" + bind:value={newSlug} + autocomplete="off" + spellcheck="false" + /> + {#if slugError}<p class="text-xs text-error">{slugError}</p>{/if} + <div class="flex justify-end gap-2"> + {#if compact} + <button type="button" class="btn btn-ghost btn-sm" onclick={closePopover}>Cancel</button> + {/if} + <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid || submitting}> + {#if submitting} + <span class="loading loading-spinner loading-xs"></span> + {/if} + Create + </button> + </div> + </form> + </div> +{/if} diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts new file mode 100644 index 0000000..4db8b04 --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.test.ts @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import HomeHeader from "./HomeHeader.svelte"; + +function props() { + return { + onCreate: vi.fn(async (_slug: string): Promise<{ ok: true }> => ({ ok: true })), + onOpenPlugins: vi.fn(), + onSort: vi.fn(), + onOpenArchived: vi.fn(), + }; +} + +describe("HomeHeader", () => { + it("renders the display title and the primary New workspace button", () => { + const p = props(); + render(HomeHeader, { props: p }); + expect(screen.getByRole("heading", { name: "Dispatch" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "New workspace" })).toBeInTheDocument(); + }); + + it("the Plugins button renders only with the plugins port", () => { + const p = props(); + const without = render(HomeHeader, { props: p }); + expect(without.queryByRole("button", { name: "Plugins" })).toBeNull(); + without.unmount(); + + render(HomeHeader, { props: { ...p, withPlugins: true } }); + screen.getByRole("button", { name: "Plugins" }).click(); + expect(p.onOpenPlugins).toHaveBeenCalledTimes(1); + }); + + it("the Sort overflow renders only when asked, and dispatches", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: { ...p, withSort: true } }); + + await user.click(screen.getByRole("button", { name: "More actions" })); + await user.click(screen.getByRole("button", { name: "Reset arrangement by recent activity" })); + expect(p.onSort).toHaveBeenCalledTimes(1); + }); + + it("the Create button disables for an invalid slug (validation before submit)", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: p }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + const input = screen.getByRole("textbox", { name: "New workspace" }); + await user.type(input, "Invalid Slug!"); + expect(screen.getByRole("button", { name: "Create" })).toBeDisabled(); + expect(p.onCreate).not.toHaveBeenCalled(); + + // A valid slug enables it. + await user.clear(input); + await user.type(input, "my-workspace"); + expect(screen.getByRole("button", { name: "Create" })).toBeEnabled(); + }); + + it("submitting the form with Enter creates via the callback and closes the popover", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: p }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace{Enter}"); + + expect(p.onCreate).toHaveBeenCalledWith("my-workspace"); + }); + + it("a backend error keeps the form open with the message", async () => { + const user = userEvent.setup(); + const onCreate = vi.fn( + async (): Promise<{ ok: false; error: string }> => ({ ok: false, error: "taken" }), + ); + render(HomeHeader, { props: { ...props(), onCreate } }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(await screen.findByText("taken")).toBeInTheDocument(); + }); + + it("compact mode collapses the primary button to an icon", () => { + const p = props(); + render(HomeHeader, { props: { ...p, compact: true } }); + const button = screen.getByRole("button", { name: "New workspace" }); + expect(button).toHaveClass("navbar-btn-icon"); + expect(button).toHaveTextContent(""); // icon only — the label collapses + expect(button.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte new file mode 100644 index 0000000..40a46c5 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -0,0 +1,568 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import { gsap } from "gsap"; + import { Flip } from "gsap/Flip"; + import { tick } from "svelte"; + import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller"; + import { + displacePlace, + dragAppendLimit, + type Cell, + type GridGeometry, + type Placement, + type Span, + } from "../logic/tile-layout"; + import { tileTone, type TileTone } from "../logic/view-model"; + import type { WorkspaceStore } from "../store.svelte"; + import WorkspaceTile from "./WorkspaceTile.svelte"; + + gsap.registerPlugin(Flip); + + let { + geometry, + placements, + workspacesById, + store, + selectedId = null, + renamingId = null, + preview = null, + hasActive, + onOpen, + onSelect, + onPlace, + onRenameEnd, + }: { + geometry: GridGeometry; + /** Packed placements of the COMMITTED layout (the drag preview derives). */ + placements: readonly Placement[]; + workspacesById: ReadonlyMap<string, WorkspaceEntry>; + /** Forwarded to tiles for the inline rename (semantics preserved). */ + store: WorkspaceStore; + selectedId?: string | null; + renamingId?: string | null; + /** The resize picker's transient ghost footprint for this tile. */ + preview?: { readonly id: string; readonly colSpan: number; readonly rowSpan: number } | null; + /** Optional port: does the workspace have an active (generating) conversation. */ + hasActive?: (workspaceId: string) => boolean; + onOpen: (id: string) => void; + onSelect: (id: string) => void; + /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */ + onPlace: (id: string, cell: Cell | null) => void; + onRenameEnd: () => void; + } = $props(); + + // ── Drag/press state machine (pure logic, wired to pointer events) ──────── + const DRAG_THRESHOLD_PX = 6; + const LONG_PRESS_MS = 500; + let phase = $state<DragPhase>({ kind: "idle" }); + let longPressTimer: ReturnType<typeof setTimeout> | undefined; + /** Pointer position where the current press began (the follow transform base). */ + let pressOrigin = $state<{ x: number; y: number } | null>(null); + /** Where within the pressed tile the pointer grabbed (its follow-transform base). */ + let grabOffset = $state<{ x: number; y: number } | null>(null); + /** The tile mid-settle after a drop (excluded from FLIP until it lands). */ + let settling = $state<string | null>(null); + + function armLongPress(): void { + disarmLongPress(); + longPressTimer = setTimeout(() => { + longPressTimer = undefined; + dispatch({ type: "long-press" }); + }, LONG_PRESS_MS); + } + + function disarmLongPress(): void { + if (longPressTimer !== undefined) { + clearTimeout(longPressTimer); + longPressTimer = undefined; + } + } + + function dispatch(event: DragEvent): void { + const wasDragging = phase.kind === "dragging" ? phase.id : null; + const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX }); + phase = transition.phase; + const emit = transition.emit; + if (emit) { + if (emit.kind === "tap") onOpen(emit.id); + else if (emit.kind === "lift") onSelect(emit.id); + else if (emit.kind === "drop") drop(emit.id, emit.targetCell); + } + if (phase.kind === "idle") { + // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above; + // a CANCEL mid-drag flies the tile back to its committed slot. + if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging); + pressOrigin = null; + grabOffset = null; + } + // Keep the long-press armed exactly while a TOUCH press is undecided. + if (phase.kind === "pressing" && phase.touch) armLongPress(); + else disarmLongPress(); + } + + /** The tile being dragged (or settling after a drop) follows no FLIP. */ + const excludedFromFlip = $derived( + new Set( + [phase.kind === "dragging" ? phase.id : null, settling].filter( + (id): id is string => id !== null, + ), + ), + ); + + /** Commit the drop, then settle the tile from the pointer onto its new cell. */ + function drop(id: string, cell: Cell | null): void { + if (cell !== null) onPlace(id, cell); + flyBack(id); + } + + /** + * Animate the tile's follow transform to identity at its (possibly new) + * committed slot — the drop settle AND the cancel/Escape fly-back. The tile + * is excluded from Flip until the animation completes so the two never + * fight over the same transform. + */ + function flyBack(id: string): void { + const el = tileEls.get(id); + if (!el) return; + settling = id; + void tick().then(() => { + gsap.to(el, { + x: 0, + y: 0, + scale: 1, + duration: 0.2, + ease: "power2.out", + onComplete: () => { + gsap.set(el, { clearProps: "transform" }); + settling = null; + }, + }); + }); + } + + /** Interactive targets inside a tile (the rename input) own their gestures. */ + function insideInteractive(target: EventTarget | null): boolean { + const el = target instanceof HTMLElement ? target : null; + return el?.closest("input, select, textarea, button, a") !== null; + } + + /** + * Drag gestures live on the GRID CONTAINER, not the tiles: the container is + * never re-parented, never toggles `pointer-events`, and never unmounts, so + * pointer capture set here survives the whole gesture. (A captured element + * whose DOM node MOVES loses its capture — the "stuck tile" bug — and an + * uncaptured pointer can't stream events over empty cells — the empty-cell + * bug.) Capture also keeps move/up flowing when the pointer leaves the grid. + */ + function gridPointerDown(e: PointerEvent): void { + if (e.button !== 0) return; + const tileEl = (e.target as Element | null)?.closest("[data-tile-id]"); + if (!tileEl || insideInteractive(e.target)) return; + const id = tileEl.getAttribute("data-tile-id"); + if (id === null) return; + // Capture mouse/pen so fast drags keep streaming to this container. TOUCH + // is purposely NOT captured here: an early touch capture can suppress + // native scrolling (WebKit), and touch already gets the browser's implicit + // capture — after the long-press lift the moved events bubble up to this + // container anyway. + if (e.pointerType !== "touch") gridEl?.setPointerCapture?.(e.pointerId); + // Where within the tile the pointer grabbed — the follow transform keeps + // THIS point under the pointer (not the tile's top-left origin). + const rect = tileEl.getBoundingClientRect(); + grabOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + pressOrigin = { x: e.clientX, y: e.clientY }; + dispatch({ + type: "pointer-down", + id, + x: e.clientX, + y: e.clientY, + touch: e.pointerType === "touch", + }); + } + + function gridPointerMove(e: PointerEvent): void { + if (phase.kind === "idle") return; + dispatch({ + type: "pointer-move", + x: e.clientX, + y: e.clientY, + targetCell: cellAt(e.clientX, e.clientY), + }); + } + + function gridPointerUp(): void { + if (phase.kind === "idle") return; + dispatch({ type: "pointer-up" }); + } + + // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the + // selection — the committed layout is untouched and the tile flies home. + function windowKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape" || phase.kind === "idle") return; + e.stopPropagation(); + dispatch({ type: "cancel" }); + } + + // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ───── + let gridEl = $state<HTMLDivElement | undefined>(); + + const pitchX = $derived(geometry.unit.width + geometry.unit.gap); + const pitchY = $derived(geometry.unit.height + geometry.unit.gap); + + /** + * The drag target's clamp on the growth axis — at most 3 columns (wide) / + * rows (tall) past the current content edge. Measured on the COMMITTED + * placements (the drag preview never ratchets the limit mid-drag), so the + * canvas can't endlessly grow when dragging far right / down. + */ + const appendLimit = $derived(dragAppendLimit(placements, geometry)); + + function cellAt(clientX: number, clientY: number): Cell | null { + const el = gridEl; + if (!el) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns. + const cellW = wide ? pitchX : rect.width / geometry.flowCols; + const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1); + const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1); + return { + col: wide ? Math.min(col, appendLimit) : Math.min(col, geometry.flowCols), + row: wide ? Math.min(row, geometry.flowRows) : Math.min(row, appendLimit), + }; + } + + // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─ + /** The dragged tile's id while a drag is in flight (else null). */ + const draggedId = $derived<string | null>( + phase.kind === "dragging" ? phase.id : null, + ); + const draggedPlacement = $derived( + draggedId !== null + ? (placements.find((placement) => placement.id === draggedId) ?? null) + : null, + ); + + /** The pointer's hovered cell while dragging (else null). */ + const hoveredCell = $derived<Cell | null>( + phase.kind === "dragging" ? phase.targetCell : null, + ); + + /** The landing cell: where the pointer is, else the tile's committed slot. */ + const targetCell = $derived<Cell | null>( + hoveredCell ?? + (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null), + ); + + /** + * What to render: while dragging, the displacement of the COMMITTED layout + * at the target cell — recomputed from committed on EVERY hover change (as + * if the user moved straight from the original to here); the FLIP below + * animates from wherever the tiles currently are. Otherwise the committed + * placements. + */ + const rendered = $derived( + draggedPlacement && targetCell && draggedId !== null + ? displacePlace(placements, draggedId, targetCell, geometry) + : placements, + ); + + // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ─── + let tileEls = new Map<string, HTMLElement>(); + + function trackTile(el: HTMLElement, id: string): { destroy: () => void } { + tileEls.set(id, el); + return { + destroy() { + tileEls.delete(id); + }, + }; + } + + let flipState: ReturnType<typeof Flip.getState> | null = null; + + $effect.pre(() => { + // Track the layout inputs; the DOM read below is intentionally untracked. + void rendered + .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`) + .join("|"); + void geometry.mode; + flipState = Flip.getState( + [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el), + ); + }); + + $effect(() => { + void rendered; + void geometry.mode; + const state = flipState; + flipState = null; + if (!state) return; + Flip.from(state, { + duration: 0.25, + // Fast start, decelerating arrival — the reflow sweeps out and settles + // into the destination (an inOut curve would ease INTO the motion too). + ease: "power3.out", + nested: true, + onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }), + }); + }); + + // ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ──── + let scrollerEl = $state<HTMLDivElement | undefined>(); + let scrollProgress = $state(0); + let canPan = $state(false); + + function updateScroll(): void { + const el = scrollerEl; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + canPan = geometry.mode === "wide" && max > 1; + scrollProgress = max > 0 ? el.scrollLeft / max : 0; + } + + $effect(() => { + // Re-measure when the geometry flips, the content changes, or a drag + // starts/ends (the canvas grows with the preview and shrinks back after). + void geometry.mode; + void placements.length; + void dragging; + updateScroll(); + }); + + function handleWheel(e: WheelEvent): void { + // Vertical wheel pans the wide canvas horizontally. + if (geometry.mode !== "wide" || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + e.preventDefault(); + scrollerEl?.scrollBy({ left: e.deltaY }); + } + + function pan(): void { + const el = scrollerEl; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + const atEnd = el.scrollLeft >= max - 1; + // One viewport sideways; past the end, pan back to the start. + el.scrollBy({ left: atEnd ? -max - el.clientWidth : el.clientWidth, behavior: "smooth" }); + } + + // ── Grid + tile styles (the geometry decides; no breakpoint classes) ────── + const dragging = $derived(phase.kind === "dragging"); + + const gridStyle = $derived.by(() => { + const wide = geometry.mode === "wide"; + // Content-driven canvas: the grid hugs its content (max-content / auto), + // so the scrollbar GROWS as the preview reaches right/down and SHRINKS when + // the gesture ends — the drag sees the scrollbar move as it should. The + // drop-target clamp (`cellAt` → dragAppendLimit) bounds how far the preview + // can extend, so the canvas never grows past the committed edge + 3. + const base = + (wide + ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` + + ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` + + ` gap: ${geometry.unit.gap}px; width: max-content;` + : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` + + ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` + + ` gap: ${geometry.unit.gap}px;`); + if (!dragging) return base; + // The drop-target dots: one faint dot at each unit cell's CENTER, painted + // as a background whose tile is the cell pitch. One extra pitch of padding + // past the content end gives the append-slot a dot too. + const dotSize = wide ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`; + const dotX = wide ? geometry.unit.width / 2 : 0; + const dot = + ` background-image: radial-gradient(circle 2.5px,` + + ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` + + ` background-size: ${dotSize} ${pitchY}px;` + + ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` + + (wide ? ` padding-right: ${pitchX}px;` : ` padding-bottom: ${pitchY}px;`); + return base + dot; + }); + + function spanFor(placement: Placement): Span { + if (preview && preview.id === placement.id) { + return { colSpan: preview.colSpan, rowSpan: preview.rowSpan }; + } + return { colSpan: placement.colSpan, rowSpan: placement.rowSpan }; + } + + function wrapperStyle(placement: Placement): string { + const span = spanFor(placement); + return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`; + } + + /** + * The landing cell's top-left in CLIENT coordinates — where the CSS grid + * actually places a tile anchored at `targetCell` (the true column pitch: + * fixed in wide; the fluid column width PLUS the gap in tall). The follow + * transform cancels exactly this, so the grab point tracks the pointer. + */ + const targetCellOrigin = $derived.by<{ x: number; y: number } | null>(() => { + const el = gridEl; + if (!el || !targetCell) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + const colPitch = wide ? pitchX : (rect.width + geometry.unit.gap) / geometry.flowCols; + return { + x: rect.left + (targetCell.col - 1) * colPitch, + y: rect.top + (targetCell.row - 1) * pitchY, + }; + }); + + /** The dragged tile's follow transform (it keeps its committed slot). */ + function followStyle(placement: Placement): string { + if (draggedId !== placement.id || !pressOrigin || !grabOffset || phase.kind !== "dragging") { + return ""; + } + const origin = targetCellOrigin; + if (!origin) return ""; + // Pointer minus the tile's GRID anchor minus the grab offset — the grab + // point the user pressed tracks the pointer. A raw pointer-displacement + // translate would ride the tile's origin instead and jump by the grab + // offset (a center grab snaps the tile's top-left under the pointer). + const dx = phase.x - origin.x - grabOffset.x; + const dy = phase.y - origin.y - grabOffset.y; + return `transform: translate(${dx}px, ${dy}px) scale(1.05);`; + } + + // ── The drop ghost: a dashed footprint at the target anchor ─────────────── + const ghostStyle = $derived.by(() => { + if (!dragging || !targetCell || !draggedPlacement) return null; + const span = spanFor(draggedPlacement); + const wide = geometry.mode === "wide"; + const h = span.rowSpan * pitchY - geometry.unit.gap; + const x = wide + ? `${(targetCell.col - 1) * pitchX}px` + : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`; + const width = wide + ? `${span.colSpan * pitchX - geometry.unit.gap}px` + : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`; + return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`; + }); + + /** + * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see + * the literal `bg-*`/`text-*-content` names to emit them). A tile is its + * tone's SATURATED color with the tone's `-content` text, like Win8's + * colored tiles on the dark Start canvas. + */ + const TONE_CLASSES: Record<TileTone, string> = { + primary: "bg-primary text-primary-content border-primary", + secondary: "bg-secondary text-secondary-content border-secondary", + accent: "bg-accent text-accent-content border-accent", + info: "bg-info text-info-content border-info", + success: "bg-success text-success-content border-success", + warning: "bg-warning text-warning-content border-warning", + }; +</script> + +<svelte:window onkeydowncapture={windowKeydown} /> + +<div class="relative h-full"> + <div + bind:this={scrollerEl} + class="h-full overscroll-contain" + class:overflow-x-auto={geometry.mode === "wide"} + class:overflow-y-auto={geometry.mode === "tall"} + style={`padding: ${geometry.margin}px; scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`} + onscroll={updateScroll} + onwheel={handleWheel} + > + <!-- svelte-ignore a11y_no_static_element_interactions -- the grid owns the drag gestures (capture must live on a never-reparented element) --> + <div + bind:this={gridEl} + class="relative grid" + style={gridStyle} + onpointerdown={gridPointerDown} + onpointermove={gridPointerMove} + onpointerup={gridPointerUp} + onpointercancel={() => dispatch({ type: "cancel" })} + > + {#if ghostStyle && draggedPlacement} + <!-- The landing footprint (the dragged tile's tone, dashed). --> + <div + aria-hidden="true" + class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70" + class:border-primary={tileTone(draggedPlacement.id) === "primary"} + class:border-secondary={tileTone(draggedPlacement.id) === "secondary"} + class:border-accent={tileTone(draggedPlacement.id) === "accent"} + class:border-info={tileTone(draggedPlacement.id) === "info"} + class:border-success={tileTone(draggedPlacement.id) === "success"} + class:border-warning={tileTone(draggedPlacement.id) === "warning"} + style={ghostStyle} + ></div> + {/if} + {#each rendered as placement (placement.id)} + {@const ws = workspacesById.get(placement.id)} + {#if ws} + {@const isDragged = draggedId === placement.id} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <div + data-tile-id={placement.id} + class={`raised flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${ + TONE_CLASSES[tileTone(placement.id)] + } ${ + selectedId === placement.id + ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100" + : "border-transparent" + } ${ + isDragged + ? "pointer-events-none relative z-40 shadow-2xl" + : "hover:-translate-y-0.5 hover:shadow-xl" + }`} + style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`} + role="button" + tabindex="0" + aria-label={`Open workspace ${ws.title}`} + use:trackTile={placement.id} + oncontextmenu={(e) => { + e.preventDefault(); + onSelect(placement.id); + }} + onkeydown={(e) => { + if (insideInteractive(e.target)) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(placement.id); + } + }} + > + <WorkspaceTile + {ws} + {store} + {placement} + selected={selectedId === placement.id} + renaming={renamingId === placement.id} + active={hasActive?.(ws.id) ?? false} + {onRenameEnd} + /> + </div> + {/if} + {/each} + </div> + </div> + + {#if canPan} + <!-- Pan arrow + thin scroll-position indicator (wide geometry only). + Both sit INSIDE the canvas' outer margin. --> + <button + type="button" + class="btn btn-circle absolute z-10 border-none bg-base-100 text-base-content shadow-xl" + style={`right: ${Math.max(12, geometry.margin / 2)}px; bottom: ${Math.max(12, geometry.margin / 2)}px;`} + aria-label="Pan right" + title="Pan one screen sideways" + onclick={pan} + > + → + </button> + <div + class="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-base-content/10" + style={`margin: 0 ${geometry.margin}px;`} + > + <div + class="h-full bg-primary transition-[width] duration-100" + style={`width: ${Math.round(scrollProgress * 100)}%;`} + ></div> + </div> + {/if} +</div> diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts new file mode 100644 index 0000000..a8365d5 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -0,0 +1,200 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { geometryFor, type Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import HomeTileGrid from "./HomeTileGrid.svelte"; + +const WIDE = geometryFor(1920, 1080); + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "Workspace A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 1, + ...overrides, + }; +} + +function placement(id: string, col: number, row: number): Placement { + return { id, col, row, colSpan: 1, rowSpan: 1 }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn(), + } as unknown as WorkspaceStore; +} + +function baseProps() { + return { + geometry: WIDE, + workspacesById: new Map([ + ["a", ws()], + ["b", ws({ id: "b", title: "Workspace B" })], + ]), + store: fakeStore(), + onOpen: vi.fn(), + onSelect: vi.fn(), + onPlace: vi.fn(), + onRenameEnd: vi.fn(), + }; +} + +describe("HomeTileGrid", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders one tile per placement, positioned by the packed spans", () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { + ...props, + placements: [placement("a", 1, 1), placement("b", 2, 1)], + }, + }); + + expect(screen.getByRole("button", { name: "Open workspace Workspace A" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open workspace Workspace B" })).toBeInTheDocument(); + const wrappers = container.querySelectorAll("[data-tile-id]"); + expect(wrappers).toHaveLength(2); + expect(wrappers[0]?.getAttribute("style")).toContain("grid-column: 1 / span 1"); + expect(wrappers[1]?.getAttribute("style")).toContain("grid-column: 2 / span 1"); + }); + + it("a tap (press + release without moving) opens the workspace", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerUp(wrapper); + + expect(props.onOpen).toHaveBeenCalledWith("a"); + expect(props.onSelect).not.toHaveBeenCalled(); + }); + + it("right-click selects the tile", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.contextMenu(wrapper); + + expect(props.onSelect).toHaveBeenCalledWith("a"); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + // The cell math resolves against the grid element's rect (jsdom: 0s). + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + // The dashed landing ghost appears… + const ghost = container.querySelector("[aria-hidden='true'].border-dashed"); + expect(ghost).not.toBeNull(); + // …and the dragged tile itself follows the pointer (transform on the tile). + const tile = container.querySelector("[data-tile-id='a']"); + expect(tile?.getAttribute("style")).toContain("translate("); + + fireEvent.pointerUp(wrapper); + // The ghost + follow disappear on drop… + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + // …and the drop committed a PLACE at the hovered cell (a lands where it + // already was — the pointer hovered its own slot; a no-move drop). + expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 }); + // And no navigation either (a drag is not a tap). + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(props.onPlace).not.toHaveBeenCalled(); + // The drag visuals are gone (the tile is no longer lifted). + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + }); + + it("a touch long-press lifts the tile and selects it (never opens)", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { + button: 0, + clientX: 10, + clientY: 10, + pointerType: "touch", + }); + await vi.advanceTimersByTimeAsync(500); + + expect(props.onSelect).toHaveBeenCalledWith("a"); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("a touch drag that moves before the long-press cancels (scroll wins)", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { + button: 0, + clientX: 10, + clientY: 10, + pointerType: "touch", + }); + fireEvent.pointerMove(wrapper, { clientX: 10, clientY: 120 }); + await vi.advanceTimersByTimeAsync(500); + + expect(props.onSelect).not.toHaveBeenCalled(); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("previews a transient span for the resizing tile", () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { + ...props, + placements: [placement("a", 1, 1)], + preview: { id: "a", colSpan: 3, rowSpan: 2 }, + }, + }); + + const wrapper = container.querySelector("[data-tile-id='a']"); + expect(wrapper?.getAttribute("style")).toContain("span 3"); + expect(wrapper?.getAttribute("style")).toContain("span 2"); + }); +}); diff --git a/src/features/workspaces/ui/ResizePicker.svelte b/src/features/workspaces/ui/ResizePicker.svelte new file mode 100644 index 0000000..e066519 --- /dev/null +++ b/src/features/workspaces/ui/ResizePicker.svelte @@ -0,0 +1,107 @@ +<script lang="ts"> + import { onMount, untrack } from "svelte"; + import type { GridGeometry, Span } from "../logic/tile-layout"; + + let { + geometry, + initial, + onPreview, + onCommit, + onCancel, + }: { + geometry: GridGeometry; + /** The tile's current (clamped) spans — the picker's starting cell. */ + initial: Span; + /** Transiently ghost-preview the hovered footprint on the real grid. */ + onPreview: (span: Span | null) => void; + onCommit: (colSpan: number, rowSpan: number) => void; + onCancel: () => void; + } = $props(); + + // The picker IS the span cap: wide = 4×2 small squares, tall = 2×2. + const cols = $derived(geometry.maxColSpan); + const rows = $derived(geometry.maxRowSpan); + + // The currently hovered cell (c, r) — the highlighted rectangle spans + // 1..c × 1..r from the picker's top-left (insert-table style). The picker + // mounts fresh per resize session, so seeding from the prop is intended. + let hovered = $state({ + colSpan: untrack(() => Math.min(initial.colSpan, cols)), + rowSpan: untrack(() => Math.min(initial.rowSpan, rows)), + }); + + function hover(colSpan: number, rowSpan: number): void { + hovered = { colSpan, rowSpan }; + onPreview({ colSpan, rowSpan }); + } + + function commit(): void { + onCommit(hovered.colSpan, hovered.rowSpan); + } + + function keydown(e: KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + commit(); + return; + } + let dc = 0; + let dr = 0; + if (e.key === "ArrowRight") dc = 1; + else if (e.key === "ArrowLeft") dc = -1; + else if (e.key === "ArrowDown") dr = 1; + else if (e.key === "ArrowUp") dr = -1; + else return; + e.preventDefault(); + hover( + Math.max(1, Math.min(cols, hovered.colSpan + dc)), + Math.max(1, Math.min(rows, hovered.rowSpan + dr)), + ); + } + + let root = $state<HTMLDivElement | undefined>(); + onMount(() => root?.focus()); +</script> + +<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_tabindex --> +<div + bind:this={root} + class="fixed inset-x-0 bottom-20 z-50 mx-auto flex w-max flex-col gap-2 rounded-box border border-base-content/20 bg-base-100 p-3 shadow-2xl outline-none" + role="group" + aria-label="Resize tile" + tabindex="0" + onkeydown={keydown} + onmouseleave={() => onPreview(null)} +> + <p class="text-xs font-semibold uppercase opacity-60">Resize — click a size</p> + <div class="grid gap-1" style={`grid-template-columns: repeat(${cols}, 1.5rem);`}> + {#each Array.from({ length: rows }) as _, row (row)} + {#each Array.from({ length: cols }) as __, col (col)} + <button + type="button" + class={`size-6 border border-base-content/20 ${ + col < hovered.colSpan && row < hovered.rowSpan + ? "bg-primary/60" + : col === hovered.colSpan - 1 && row === hovered.rowSpan - 1 + ? "bg-primary/20" + : "" + }`} + aria-label={`Resize to ${col + 1} by ${row + 1}`} + onmouseenter={() => hover(col + 1, row + 1)} + onfocus={() => hover(col + 1, row + 1)} + onclick={() => { + hover(col + 1, row + 1); + commit(); + }} + ></button> + {/each} + {/each} + </div> + <p class="text-center font-mono text-xs opacity-70">{hovered.colSpan} × {hovered.rowSpan}</p> + <p class="text-center text-[10px] opacity-50">Enter commits · Escape cancels</p> +</div> diff --git a/src/features/workspaces/ui/ResizePicker.test.ts b/src/features/workspaces/ui/ResizePicker.test.ts new file mode 100644 index 0000000..796a556 --- /dev/null +++ b/src/features/workspaces/ui/ResizePicker.test.ts @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { geometryFor } from "../logic/tile-layout"; +import ResizePicker from "./ResizePicker.svelte"; + +const WIDE = geometryFor(1920, 1080); +const TALL = geometryFor(390, 844); + +function props() { + return { + onPreview: vi.fn(), + onCommit: vi.fn(), + onCancel: vi.fn(), + }; +} + +describe("ResizePicker", () => { + it("renders one small square per cap (4×2 wide, 2×2 tall)", () => { + const p = props(); + const wide = render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + expect(screen.getAllByRole("button")).toHaveLength(8); + wide.unmount(); + + render(ResizePicker, { + props: { geometry: TALL, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + expect(screen.getAllByRole("button")).toHaveLength(4); + }); + + it("hovering square (r, c) previews that rectangle", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.hover(screen.getByRole("button", { name: "Resize to 3 by 2" })); + expect(p.onPreview).toHaveBeenCalledWith({ colSpan: 3, rowSpan: 2 }); + }); + + it("clicking a square commits that size", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.click(screen.getByRole("button", { name: "Resize to 2 by 1" })); + expect(p.onCommit).toHaveBeenCalledWith(2, 1); + }); + + it("arrow keys move within the picker, Enter commits, Escape cancels", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 2, rowSpan: 1 }, ...p }, + }); + + await user.keyboard("{ArrowRight}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 1 }); + await user.keyboard("{ArrowDown}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 2 }); + await user.keyboard("{ArrowLeft}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 2, rowSpan: 2 }); + + await user.keyboard("{Enter}"); + expect(p.onCommit).toHaveBeenCalledWith(2, 2); + expect(p.onCancel).not.toHaveBeenCalled(); + + await user.keyboard("{Escape}"); + expect(p.onCancel).toHaveBeenCalledTimes(1); + }); + + it("leaving the picker clears the preview", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.hover(screen.getByRole("button", { name: "Resize to 4 by 2" })); + await user.unhover(screen.getByRole("button", { name: "Resize to 4 by 2" })); + expect(p.onPreview).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/features/workspaces/ui/TileActionBar.svelte b/src/features/workspaces/ui/TileActionBar.svelte new file mode 100644 index 0000000..ffb8f1d --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.svelte @@ -0,0 +1,73 @@ +<script lang="ts"> + import { fly } from "svelte/transition"; + import type { WorkspaceEntry } from "@dispatch/wire"; + + let { + ws, + withPlugins = false, + onOpen, + onRename, + onResize, + onToggleStar, + onDetails, + onPlugins, + onArchive, + onDelete, + onClose, + }: { + ws: WorkspaceEntry; + /** The plugins port is present → the Plugins action renders. */ + withPlugins?: boolean; + onOpen: () => void; + onRename: () => void; + onResize: () => void; + onToggleStar: () => void; + /** The details flyout (default cwd + default computer). */ + onDetails: () => void; + onPlugins: () => void; + /** Archive the workspace (hide from the home grid; reversible). */ + onArchive: () => void; + onDelete: () => void; + onClose: () => void; + } = $props(); +</script> + +<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> +<div + class="fixed inset-x-0 bottom-0 z-40 flex justify-center p-4" + transition:fly={{ y: 80, duration: 150 }} + onclick={(e) => { + // The bar owns its clicks — don't let them deselect via the backdrop. + e.stopPropagation(); + }} +> + <div + class="flex flex-wrap items-center justify-center gap-1 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-2xl" + role="toolbar" + aria-label={`Actions for ${ws.title}`} + > + <button type="button" class="btn btn-primary btn-sm" onclick={onOpen}>Open</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onRename}>Rename</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onResize}>Resize</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onDetails}>Details</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onToggleStar}> + {ws.starred ? "Unstar" : "Star"} + </button> + {#if withPlugins} + <button type="button" class="btn btn-ghost btn-sm" onclick={onPlugins}>Plugins</button> + {/if} + <button type="button" class="btn btn-ghost btn-sm" onclick={onArchive}>Archive</button> + <button + type="button" + class="btn btn-ghost btn-sm text-error" + onclick={onDelete}>Delete</button + > + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close actions" + title="Close" + onclick={onClose}>✕</button + > + </div> +</div> diff --git a/src/features/workspaces/ui/TileActionBar.test.ts b/src/features/workspaces/ui/TileActionBar.test.ts new file mode 100644 index 0000000..c00de19 --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.test.ts @@ -0,0 +1,93 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import TileActionBar from "./TileActionBar.svelte"; + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function handlers() { + return { + onOpen: vi.fn(), + onRename: vi.fn(), + onResize: vi.fn(), + onToggleStar: vi.fn(), + onDetails: vi.fn(), + onPlugins: vi.fn(), + onArchive: vi.fn(), + onDelete: vi.fn(), + onClose: vi.fn(), + }; +} + +describe("TileActionBar", () => { + it("renders the Open · Rename · Resize · Star · Details · Delete actions", () => { + const h = handlers(); + render(TileActionBar, { props: { ws: ws(), ...h } }); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Rename" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resize" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Details" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close actions" })).toBeInTheDocument(); + }); + + it("omits Plugins when no plugins port is present, renders it when one is", () => { + const h = handlers(); + const without = render(TileActionBar, { props: { ws: ws(), ...h } }); + expect(without.queryByRole("button", { name: "Plugins" })).toBeNull(); + without.unmount(); + + render(TileActionBar, { props: { ws: ws(), withPlugins: true, ...h } }); + expect(screen.getByRole("button", { name: "Plugins" })).toBeInTheDocument(); + }); + + it("labels the star action by the current state (Star ↔ Unstar)", () => { + const h = handlers(); + const unstarred = render(TileActionBar, { props: { ws: ws({ starred: false }), ...h } }); + expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); + unstarred.unmount(); + + render(TileActionBar, { props: { ws: ws({ starred: true }), ...h } }); + expect(screen.getByRole("button", { name: "Unstar" })).toBeInTheDocument(); + }); + + it("dispatches each action", async () => { + const user = userEvent.setup(); + const h = handlers(); + render(TileActionBar, { props: { ws: ws(), ...h } }); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.click(screen.getByRole("button", { name: "Rename" })); + await user.click(screen.getByRole("button", { name: "Resize" })); + await user.click(screen.getByRole("button", { name: "Star" })); + await user.click(screen.getByRole("button", { name: "Details" })); + await user.click(screen.getByRole("button", { name: "Archive" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(screen.getByRole("button", { name: "Close actions" })); + + expect(h.onOpen).toHaveBeenCalledTimes(1); + expect(h.onRename).toHaveBeenCalledTimes(1); + expect(h.onResize).toHaveBeenCalledTimes(1); + expect(h.onToggleStar).toHaveBeenCalledTimes(1); + expect(h.onDetails).toHaveBeenCalledTimes(1); + expect(h.onArchive).toHaveBeenCalledTimes(1); + expect(h.onDelete).toHaveBeenCalledTimes(1); + expect(h.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte deleted file mode 100644 index 6de4109..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ /dev/null @@ -1,279 +0,0 @@ -<script lang="ts"> - import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; - import { untrack } from "svelte"; - import type { WorkspaceStore } from "../store.svelte"; - import { relativeTime } from "../logic/view-model"; - import { workspacePath } from "../logic/route"; - import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; - - let { - ws, - store, - onNavigate, - computers, - hasActive, - }: { - ws: WorkspaceEntry; - store: WorkspaceStore; - onNavigate: (path: string) => void; - /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ - computers: readonly ComputerEntry[]; - /** - * Optional port: returns whether the workspace has at least one active - * (generating / queued) conversation — drives a loading-dots indicator on - * the card. Wired by the composition root to the app store's - * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests). - */ - hasActive?: (workspaceId: string) => boolean; - } = $props(); - - // Whether at least one conversation in this workspace is currently active - // (generating). Reactive: the composition-root port reads the app store's - // reactive tab set + lifecycle statuses, so this re-derives on change. - const active = $derived(hasActive?.(ws.id) ?? false); - - // ── Title: double-click to rename inline ────────────────────────────────── - let editingTitle = $state(false); - let titleDraft = $state(""); - let titleInput = $state<HTMLInputElement | undefined>(); - let titleError = $state<string | null>(null); - - function startEditTitle(): void { - titleDraft = ws.title; - titleError = null; - editingTitle = true; - queueMicrotask(() => titleInput?.focus()); - } - - async function saveTitle(): Promise<void> { - if (!editingTitle) return; - const title = titleDraft.trim(); - editingTitle = false; - if (title === "" || title === ws.title) return; - const result = await store.rename(ws.id, title); - if (!result.ok) titleError = result.error; - } - - function cancelTitle(): void { - editingTitle = false; - titleError = null; - } - - // ── Default cwd: inline input ───────────────────────────────────────────── - let cwdDraft = $state(untrack(() => ws.defaultCwd ?? "")); - // Reseed when the backend value changes (e.g., after a save or an external - // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered. - $effect(() => { - cwdDraft = ws.defaultCwd ?? ""; - }); - - const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? "")); - let savingCwd = $state(false); - let cwdError = $state<string | null>(null); - - async function saveCwd(): Promise<void> { - if (!cwdDirty || savingCwd) return; - savingCwd = true; - cwdError = null; - const cwd = cwdDraft.trim(); - const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd); - savingCwd = false; - if (!result.ok) cwdError = result.error; - } - - // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── - let savingComputer = $state(false); - let computerError = $state<string | null>(null); - - async function saveComputer(computerId: string | null): Promise<void> { - if (savingComputer) return; - // No-op when unchanged (the select only fires on a real change, but guard). - if (computerId === (ws.defaultComputerId ?? null)) return; - savingComputer = true; - computerError = null; - const result = await store.setDefaultComputer(ws.id, computerId); - savingComputer = false; - if (!result.ok) computerError = result.error; - } - - // ── Star (concurrency priority) ────────────────────────────────────────────── - let savingStar = $state(false); - let starError = $state<string | null>(null); - - async function toggleStar(): Promise<void> { - if (savingStar) return; - savingStar = true; - starError = null; - try { - // Optimistic: the store flips `starred` immediately and re-sorts; revert - // on error is handled there. We read `ws.starred` for the target value. - const result = await store.setStarred(ws.id, !ws.starred); - if (!result.ok) starError = result.error; - } catch (err) { - // A throw (e.g. a rejected effect) — surface it; the store already - // reverted the optimistic flip if it got far enough to apply it. - starError = err instanceof Error ? err.message : "Star toggle failed"; - } finally { - savingStar = false; - } - } - - // ── Delete ───────────────────────────────────────────────────────────────── - let deleting = $state(false); - - async function handleDelete(): Promise<void> { - if ( - !window.confirm( - `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, - ) - ) { - return; - } - deleting = true; - await store.remove(ws.id); - deleting = false; - } -</script> - -<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3"> - <div class="flex items-center gap-2"> - {#if editingTitle} - <input - bind:this={titleInput} - bind:value={titleDraft} - class="input input-bordered input-sm flex-1" - aria-label="Workspace title" - onkeydown={(e) => { - if (e.key === "Enter") saveTitle(); - else if (e.key === "Escape") cancelTitle(); - }} - onblur={saveTitle} - /> - {:else} - <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> - <span - class="flex-1 cursor-default truncate font-semibold" - title="Double-click to rename" - ondblclick={startEditTitle}>{ws.title}</span - > - {/if} - {#if active} - <span - class="loading loading-dots loading-xs shrink-0 text-primary" - aria-label="Workspace has active conversations" - title="A conversation in this workspace is generating"></span - > - {/if} - <span class="font-mono text-xs opacity-50">/{ws.id}</span> - <button - type="button" - class="btn btn-ghost btn-xs px-1" - disabled={savingStar} - aria-pressed={ws.starred} - aria-label={ws.starred ? "Unstar workspace" : "Star workspace"} - title={ws.starred - ? "Starred — its agents get concurrency priority. Click to unstar." - : "Star this workspace to give its agents concurrency priority."} - onclick={toggleStar} - > - {#if savingStar} - <span class="loading loading-spinner loading-xs"></span> - {:else if ws.starred} - <span class="text-warning" aria-hidden="true">★</span> - {:else} - <span class="opacity-40" aria-hidden="true">☆</span> - {/if} - </button> - <span class="ml-auto text-xs opacity-50"> - {ws.conversationCount} - {ws.conversationCount === 1 ? "conversation" : "conversations"} - · {relativeTime(ws.lastActivityAt, Date.now())} - </span> - <button - type="button" - class="btn btn-ghost btn-xs" - disabled={deleting} - title="Delete workspace" - aria-label="Delete workspace" - onclick={handleDelete} - > - {#if deleting} - <span class="loading loading-spinner loading-xs"></span> - {:else} - ✕ - {/if} - </button> - </div> - - {#if titleError} - <p class="text-xs text-error">{titleError}</p> - {/if} - - {#if starError} - <p class="text-xs text-error">{starError}</p> - {/if} - - <div class="flex items-center gap-2"> - <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> - <input - type="text" - class="input input-bordered input-sm flex-1 font-mono text-xs" - placeholder="inherits the server default" - bind:value={cwdDraft} - aria-label="Default working directory" - onkeydown={(e) => { - if (e.key === "Enter") saveCwd(); - }} - /> - <button - type="button" - class="btn btn-primary btn-xs" - disabled={!cwdDirty || savingCwd} - onclick={saveCwd} - > - {#if savingCwd} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Set - {/if} - </button> - </div> - - <div class="flex items-center gap-2"> - <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> - <ComputerSelect - value={ws.defaultComputerId} - {computers} - disabled={savingComputer} - onSelect={saveComputer} - /> - {#if savingComputer} - <span class="loading loading-spinner loading-xs shrink-0"></span> - {/if} - </div> - - <div class="flex justify-start"> - <a - class="btn" - href={workspacePath(ws.id)} - onclick={(e) => { - e.preventDefault(); - onNavigate(workspacePath(ws.id)); - }} - > - Open - </a> - </div> - - {#if cwdError} - <p class="text-xs text-error">{cwdError}</p> - {:else if !cwdDirty && !ws.defaultCwd} - <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> - {/if} - - {#if computerError} - <p class="text-xs text-error">{computerError}</p> - {:else if !ws.defaultComputerId} - <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> - {/if} -</li> diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts deleted file mode 100644 index 72f4e60..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import type { WorkspaceEntry } from "@dispatch/wire"; -import { render, screen } from "@testing-library/svelte"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import type { WorkspaceResult } from "../adapter/http"; -import type { WorkspaceStore } from "../store.svelte"; -import WorkspaceCard from "./WorkspaceCard.svelte"; - -function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { - return { - id: "my-ws", - title: "My Workspace", - defaultCwd: null, - defaultComputerId: null, - starred: false, - createdAt: 1, - lastActivityAt: 2, - conversationCount: 3, - ...overrides, - }; -} - -/** A fake store that records calls + resolves ok. */ -function fakeStore() { - return { - rename: vi.fn( - async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, title: _title }), - }), - ), - setDefaultCwd: vi.fn( - async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, defaultCwd }), - }), - ), - setDefaultComputer: vi.fn( - async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, defaultComputerId: computerId }), - }), - ), - setStarred: vi.fn( - async (id: string, starred: boolean): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, starred }), - }), - ), - remove: vi.fn( - async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({ - ok: true, - value: { closedCount: 0 }, - }), - ), - }; -} - -describe("WorkspaceCard", () => { - it("renders the title, slug, and an Open link", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - expect(screen.getByText("My Workspace")).toBeInTheDocument(); - expect(screen.getByText("/my-ws")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws"); - }); - - it("double-clicking the title reveals an edit input", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.dblClick(screen.getByText("My Workspace")); - expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); - }); - - it("renames via the store on Enter", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.dblClick(screen.getByText("My Workspace")); - const input = screen.getByLabelText("Workspace title"); - await user.clear(input); - await user.type(input, "Renamed{Enter}"); - - expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); - }); - - it("enables Set only when the cwd differs, then saves it", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const input = screen.getByLabelText("Default working directory"); - expect(input).toHaveValue("/old"); - expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); - - await user.clear(input); - await user.type(input, "/new/path"); - expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); - - await user.click(screen.getByRole("button", { name: "Set" })); - expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path"); - }); - - it("clears the cwd to null when saved empty (inherits the server default)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const input = screen.getByLabelText("Default working directory"); - await user.clear(input); - await user.click(screen.getByRole("button", { name: "Set" })); - - expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); - }); - - it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - const onNavigate = vi.fn(); - render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } }); - - const open = screen.getByRole("link", { name: "Open" }); - // Still a real link (progressive enhancement): href points at the workspace. - expect(open).toHaveAttribute("href", "/my-ws"); - // But it no longer opens a new browser tab. - expect(open).not.toHaveAttribute("target", "_blank"); - // Clicking navigates in-place via the SPA callback. - await user.click(open); - expect(onNavigate).toHaveBeenCalledTimes(1); - expect(onNavigate).toHaveBeenCalledWith("/my-ws"); - }); - - // ── Active indicator (loading dots) ────────────────────────────────────── - - it("shows no loading-dots when no hasActive port is given", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - expect(container.querySelector(".loading-dots")).toBeNull(); - }); - - it("shows no loading-dots when hasActive returns false", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { - ws: fakeEntry(), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: () => false, - }, - }); - expect(container.querySelector(".loading-dots")).toBeNull(); - }); - - it("shows loading-dots when hasActive returns true", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { - ws: fakeEntry(), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: () => true, - }, - }); - const dots = container.querySelector(".loading-dots"); - expect(dots).not.toBeNull(); - // Accessible label ties the indicator to the workspace-active concept. - expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations"); - }); - - it("forwards the workspace id to hasActive", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const seen: string[] = []; - render(WorkspaceCard, { - props: { - ws: fakeEntry({ id: "proj-x" }), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: (id: string) => { - seen.push(id); - return false; - }, - }, - }); - expect(seen).toEqual(["proj-x"]); - }); - - it("renders an outline star button for an unstarred workspace", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - const star = screen.getByRole("button", { name: "Star workspace" }); - expect(star).toHaveAttribute("aria-pressed", "false"); - }); - - it("renders a filled star button for a starred workspace", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] }, - }); - const star = screen.getByRole("button", { name: "Unstar workspace" }); - expect(star).toHaveAttribute("aria-pressed", "true"); - }); - - it("toggles the star via the store on click", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - expect(store.setStarred).toHaveBeenCalledWith("my-ws", true); - }); - - it("clicking a starred workspace's star calls setStarred(id, false)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Unstar workspace" })); - expect(store.setStarred).toHaveBeenCalledWith("my-ws", false); - }); - - it("renders no star error on a successful toggle", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - expect(screen.queryByText(/Star toggle failed/i)).not.toBeInTheDocument(); - }); - - it("shows an inline error when setStarred fails (result.ok false)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - // The store reverts the optimistic flip on failure, so the entry's - // `starred` stays false — fake the revert by returning ok:false unchanged. - store.setStarred = vi.fn( - async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }), - ); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - - expect(screen.getByText("boom")).toBeInTheDocument(); - }); - - it("re-enables the star button after a failure (savingStar resets)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - store.setStarred = vi.fn( - async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }), - ); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const star = screen.getByRole("button", { name: "Star workspace" }); - await user.click(star); - // After the failed toggle, the button is NOT disabled (savingStar reset). - expect(star).not.toBeDisabled(); - }); - - it("re-enables the star button even when setStarred throws", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - store.setStarred = vi.fn(async (): Promise<WorkspaceResult<WorkspaceEntry>> => { - throw new Error("network"); - }); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const star = screen.getByRole("button", { name: "Star workspace" }); - await user.click(star); - // savingStar must reset via try/finally even on a throw. - expect(star).not.toBeDisabled(); - expect(screen.getByText("network")).toBeInTheDocument(); - }); -}); diff --git a/src/features/workspaces/ui/WorkspaceDetails.svelte b/src/features/workspaces/ui/WorkspaceDetails.svelte new file mode 100644 index 0000000..6595725 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.svelte @@ -0,0 +1,132 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; + + let { + ws, + store, + computers, + onClose, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ + computers: readonly ComputerEntry[]; + onClose: () => void; + } = $props(); + + // ── Default cwd: input + Set (semantics preserved from the WorkspaceCard) ── + let cwdDraft = $state(untrack(() => ws.defaultCwd ?? "")); + // Reseed when the backend value changes (e.g., after a save or an external + // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered. + $effect(() => { + cwdDraft = ws.defaultCwd ?? ""; + }); + + const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? "")); + let savingCwd = $state(false); + let cwdError = $state<string | null>(null); + + async function saveCwd(): Promise<void> { + if (!cwdDirty || savingCwd) return; + savingCwd = true; + cwdError = null; + const cwd = cwdDraft.trim(); + const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd); + savingCwd = false; + if (!result.ok) cwdError = result.error; + } + + // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── + let savingComputer = $state(false); + let computerError = $state<string | null>(null); + + async function saveComputer(computerId: string | null): Promise<void> { + if (savingComputer) return; + // No-op when unchanged (the select only fires on a real change, but guard). + if (computerId === (ws.defaultComputerId ?? null)) return; + savingComputer = true; + computerError = null; + const result = await store.setDefaultComputer(ws.id, computerId); + savingComputer = false; + if (!result.ok) computerError = result.error; + } +</script> + +<div class="modal modal-open" role="dialog" aria-label={`Workspace details: ${ws.title}`}> + <div class="modal-box"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">{ws.title}</h2> + <p class="font-mono text-xs opacity-50">/{ws.id}</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close details" + onclick={onClose}>✕</button + > + </div> + + <div class="mt-4 flex flex-col gap-3"> + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> + <input + type="text" + class="input input-bordered input-sm flex-1 font-mono text-xs" + placeholder="inherits the server default" + bind:value={cwdDraft} + aria-label="Default working directory" + onkeydown={(e) => { + if (e.key === "Enter") void saveCwd(); + }} + /> + <button + type="button" + class="btn btn-primary btn-xs" + disabled={!cwdDirty || savingCwd} + onclick={() => void saveCwd()} + > + {#if savingCwd} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + + {#if cwdError} + <p class="text-xs text-error">{cwdError}</p> + {:else if !cwdDirty && !ws.defaultCwd} + <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> + {/if} + + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> + <ComputerSelect + value={ws.defaultComputerId} + {computers} + disabled={savingComputer} + onSelect={(id) => void saveComputer(id)} + /> + {#if savingComputer} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + + {#if computerError} + <p class="text-xs text-error">{computerError}</p> + {:else if !ws.defaultComputerId} + <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close details backdrop" + onclick={onClose}></button> +</div> diff --git a/src/features/workspaces/ui/WorkspaceDetails.test.ts b/src/features/workspaces/ui/WorkspaceDetails.test.ts new file mode 100644 index 0000000..9138742 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts @@ -0,0 +1,115 @@ +import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceDetails from "./WorkspaceDetails.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + setDefaultComputer: vi.fn( + async ( + id: string, + defaultComputerId: string | null, + ): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId }), + }), + ), + } as unknown as WorkspaceStore; +} + +describe("WorkspaceDetails", () => { + it("renders the title, slug, cwd input, and computer select", () => { + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose: () => {} }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + expect(screen.getByLabelText("Default working directory")).toBeInTheDocument(); + expect(screen.getByLabelText("Computer")).toBeInTheDocument(); + }); + + it("saves the cwd via the store; an empty value clears to null", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose }, + }); + + const input = screen.getByLabelText("Default working directory"); + await user.clear(input); + await user.click(screen.getByRole("button", { name: "Set" })); + expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); + }); + + it("a failed cwd save surfaces the error", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + store.setDefaultCwd = vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "nope" }), + ); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose: () => {} }, + }); + + await user.type(screen.getByLabelText("Default working directory"), "!{Enter}"); + expect(await screen.findByText("nope")).toBeInTheDocument(); + }); + + it("picking a computer saves it via the store", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const computers: ComputerEntry[] = [ + { + alias: "lab", + hostName: "lab.example", + port: 22, + user: "me", + identityFile: null, + knownHost: true, + usageCount: 0, + }, + ]; + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store, computers, onClose: () => {} }, + }); + + await user.selectOptions(screen.getByLabelText("Computer"), "lab"); + expect(store.setDefaultComputer).toHaveBeenCalledWith("my-ws", "lab"); + }); + + it("the close button dismisses the dialog", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose }, + }); + + await user.click(screen.getByRole("button", { name: "Close details" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceTile.svelte b/src/features/workspaces/ui/WorkspaceTile.svelte new file mode 100644 index 0000000..b101192 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + import type { Placement } from "../logic/tile-layout"; + + let { + ws, + store, + placement, + selected = false, + renaming = false, + active = false, + onRenameEnd, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + /** The tile's resolved footprint (already preview-adjusted by the grid). */ + placement: Placement; + selected?: boolean; + /** The parent-driven rename mode (the action bar's Rename action). */ + renaming?: boolean; + /** At least one conversation in this workspace is generating/queued. */ + active?: boolean; + /** The tile finished its rename session (saved or cancelled). */ + onRenameEnd: () => void; + } = $props(); + + // ── Title: double-click to rename inline (semantics preserved from the + // WorkspaceCard), or enter via the action bar's Rename action (`renaming`). + let editing = $state(false); + let titleDraft = $state(""); + let titleInput = $state<HTMLInputElement | undefined>(); + let titleError = $state<string | null>(null); + + function startEdit(): void { + titleDraft = ws.title; + titleError = null; + editing = true; + queueMicrotask(() => titleInput?.focus()); + } + + // Seed (only) when the parent-driven rename mode turns on. + $effect(() => { + if (renaming) startEdit(); + }); + + async function saveTitle(): Promise<void> { + if (!editing) return; + const title = titleDraft.trim(); + editing = false; + onRenameEnd(); + if (title === "" || title === ws.title) return; + const result = await store.rename(ws.id, title); + if (!result.ok) titleError = result.error; + } + + function cancelTitle(): void { + editing = false; + titleError = null; + onRenameEnd(); + } + + // The face's semantic key (tests read it). + const faceKey = $derived(`tile-${placement.id}`); + + // Span-aware face: bigger footprints get more air + bigger type (all styles + // static so Tailwind sees them). + const wide = $derived(placement.colSpan > 1); + const tall = $derived(placement.rowSpan > 1); + const facePadding = $derived(wide || tall ? "p-5" : "p-4"); + const titleClass = $derived( + wide && tall + ? "text-3xl leading-tight line-clamp-3" + : wide + ? "text-2xl leading-snug line-clamp-2" + : tall + ? "text-xl leading-snug line-clamp-4" + : "text-lg leading-snug line-clamp-3", + ); + /** 1×1 tiles are tight: "20 conv." — wider tiles spell "conversations" out. */ + const countLabel = $derived( + ws.conversationCount === 1 ? (wide ? "conversation" : "conv.") : wide ? "conversations" : "conv.", + ); +</script> + +<div data-tile-face={faceKey} class={`relative flex h-full w-full flex-col ${facePadding}`}> + <!-- The slug label at the very top — the Win8 tile's small "app" caption. --> + <div class="flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase"> + <span class="truncate opacity-70">/{ws.id}</span> + {#if active} + <span + class="loading loading-dots loading-xs shrink-0" + aria-label="Workspace has active conversations" + title="A conversation in this workspace is generating"></span> + {/if} + </div> + + {#if ws.starred} + <!-- Starred is a badge, not a tint — the tone stays the tile's identity. --> + <span + class="absolute top-2.5 right-3 text-lg leading-none opacity-90" + aria-label="Starred workspace" + title="Starred — priority in the agent queue">★</span + > + {/if} + + {#if editing} + <input + bind:this={titleInput} + bind:value={titleDraft} + class="input input-sm mt-2 w-full border-current/40 bg-black/25 text-inherit placeholder:text-current/50" + aria-label="Workspace title" + onkeydown={(e) => { + if (e.key === "Enter") void saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={() => void saveTitle()} + /> + {:else} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <span + class={`mt-1 cursor-default font-bold ${titleClass}`} + title="Double-click to rename" + ondblclick={startEdit}>{ws.title}</span + > + {/if} + + {#if titleError} + <p class="mt-1 text-xs font-semibold">{titleError}</p> + {/if} + + <!-- The stat line pinned to the tile's bottom edge, like a live tile's + status row. Single-line, truncation-safe (narrow 1×1 tiles stay clean). --> + <div + class="mt-auto flex items-center gap-2 overflow-hidden border-t border-current/20 pt-2 text-xs font-medium whitespace-nowrap" + > + <span class="min-w-0 truncate"> + {ws.conversationCount} + {countLabel} + </span> + <span class="ml-auto shrink-0 font-mono opacity-70"> + {relativeTime(ws.lastActivityAt, Date.now())} + </span> + </div> +</div> diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts new file mode 100644 index 0000000..420791a --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.test.ts @@ -0,0 +1,171 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceTile from "./WorkspaceTile.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: Date.now() - 90_000, // "1m" relative time + conversationCount: 3, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn( + async (id: string, title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, title }), + }), + ), + } as unknown as WorkspaceStore; +} + +function fakePlacement(overrides: Partial<Placement> = {}): Placement { + return { id: "my-ws", col: 1, row: 1, colSpan: 1, rowSpan: 1, ...overrides }; +} + +describe("WorkspaceTile", () => { + it("renders the title, slug, conversation count, and relative last-activity", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + // A 1×1 tile is tight: the count compacts to "conv." (wide tiles spell it out). + expect(screen.getByText(/3 conv\./)).toBeInTheDocument(); + expect(screen.getByText("1m")).toBeInTheDocument(); + }); + + it("spells out 'conversations' on a wide tile", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement({ colSpan: 2, rowSpan: 1 }), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText(/3 conversations/)).toBeInTheDocument(); + }); + + it("shows the active indicator only when the active port says so", async () => { + const { container, rerender } = render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: true, + onRenameEnd: () => {}, + }, + }); + expect( + container.querySelector('[aria-label="Workspace has active conversations"]'), + ).not.toBeNull(); + + await rerender({ + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: false, + onRenameEnd: () => {}, + }); + expect(container.querySelector('[aria-label="Workspace has active conversations"]')).toBeNull(); + }); + + it("double-clicking the title reveals the rename input (semantics preserved)", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); + }); + + it("the parent-driven rename mode (action bar) opens the same inline editor", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + const input = screen.getByLabelText("Workspace title"); + expect(input).toHaveValue("My Workspace"); + + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); + }); + + it("a failed rename surfaces the error on the face", async () => { + const user = userEvent.setup(); + const store = { + rename: vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: false, + error: "boom", + }), + ), + } as unknown as WorkspaceStore; + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + + await user.type(screen.getByLabelText("Workspace title"), "x{Enter}"); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("an unchanged title does not hit the store and still ends the session", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onRenameEnd = vi.fn(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd, + }, + }); + + await user.type(screen.getByLabelText("Workspace title"), "{Enter}"); + expect(store.rename).not.toHaveBeenCalled(); + expect(onRenameEnd).toHaveBeenCalled(); + }); +}); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index d97eab7..196e831 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,110 +1,363 @@ <script lang="ts"> - import { onMount } from "svelte"; + import { onMount, untrack } from "svelte"; import type { ComputerEntry } from "@dispatch/wire"; + import { + PluginManager, + WorkspacePluginModal, + type PluginsStore, + type WorkspaceLayoutPort, + } from "../../plugins"; import type { WorkspaceStore } from "../store.svelte"; - import { isValidSlug, workspacePath } from "../logic/route"; - import WorkspaceCard from "./WorkspaceCard.svelte"; + import { workspacePath } from "../logic/route"; + import { + DEFAULT_VIEWPORT, + geometryFor, + mergeLayout, + pack, + type GridGeometry, + type Span, + } from "../logic/tile-layout"; + import { type TileLayoutStore } from "../tile-layout-store.svelte"; + import HomeHeader from "./HomeHeader.svelte"; + import HomeTileGrid from "./HomeTileGrid.svelte"; + import TileActionBar from "./TileActionBar.svelte"; + import ResizePicker from "./ResizePicker.svelte"; + import WorkspaceDetails from "./WorkspaceDetails.svelte"; + import ArchivedWorkspaces from "./ArchivedWorkspaces.svelte"; let { store, onNavigate, computers, hasActive, + plugins, + layout, + tiles: tilesOverride, }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[]; /** - * Optional port forwarded to each {@link WorkspaceCard}: whether the - * workspace has at least one active (generating / queued) conversation. + * Optional port: whether the workspace has at least one active + * (generating / queued) conversation — the tile's live indicator. * Wired by the composition root to the app store. Absent → no indicator. */ hasActive?: (workspaceId: string) => boolean; + /** + * The plugin manager store (plan 07): drives the header's global + * "Plugins" button + the action bar's per-workspace Plugins action. + * Optional (absent in tests that don't exercise plugins). + */ + plugins?: PluginsStore; + /** + * The layout port (plan 04 §4.4): forwarded to the per-workspace plugins + * modal's "Reset layout to default" affordance. Absent → the modal's + * layout section doesn't render. + */ + layout?: WorkspaceLayoutPort; + /** + * The synced tile layout store (ONE shared document per device + * orientation, persisted by the backend's home-tile-layout plugin). + * Created by the page entry (App.svelte — the composition root that owns + * the httpBase); tests inject one. + */ + tiles: TileLayoutStore; } = $props(); onMount(() => { void store.refresh(); }); - let newSlug = $state(""); - let slugError = $state<string | null>(null); + // The store arrives fully wired (the page entry created it over the real + // HTTP edge). The `untrack` capture (the repo's initial-value pattern, cf. + // App.svelte) dealiases the prop once — the component instance is the + // store's single owner from here on. + const tiles: TileLayoutStore = untrack(() => tilesOverride); + + // Keep the layout merged with the live workspace list: unknown workspaces + // (deep-link auto-creates) append at the end; deleted ones drop. The + // no-op guards inside sync keep this effect from looping. Skipped until + // the first non-empty list arrives — `sync([])` before the initial + // `refresh()` resolves would WIPE the persisted order (the backend always + // serves "default", so an empty list only ever means "not loaded yet"). + $effect(() => { + const ids = store.list.map((ws) => ws.id); + if (ids.length === 0) return; + tiles.sync(ids); + }); + + // ── The tile canvas' size decides the geometry (ONE pure function — no + // 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 ?? initialViewport.width, size?.height ?? initialViewport.height), + ); + + $effect(() => { + const el = regionEl; + if (!el || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const rect = entries[0]?.contentRect; + if (rect) size = { width: rect.width, height: rect.height }; + }); + observer.observe(el); + return () => observer.disconnect(); + }); + + // Lock the store's active orientation to the resolved grid geometry: the + // landscape/portrait split IS the device-type split — every landscape + // device shares the `wide` document, every portrait device the `tall` one. + // Same-mode calls are a no-op inside the store; an orientation change + // resets + re-probes (never flashing the other orientation's arrangement). + $effect(() => { + tiles.setMode(geometry.mode); + }); + + // The grid view: the SAME persisted data packs into either geometry — + // positions re-derive here, spans clamp for the RENDER only. + // 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 ───────────────────────────────────────────────── + let selectedId = $state<string | null>(null); + const selected = $derived(selectedId !== null ? (workspacesById.get(selectedId) ?? null) : null); + + function select(id: string): void { + selectedId = id; + } + + function deselect(): void { + selectedId = null; + } + + // ── Rename mode (the create flow + the action bar's Rename action) ──────── + let renamingId = $state<string | null>(null); + + // ── Resize picker (transient ghost preview; commit persists) ────────────── + let resizing = $state<{ id: string; span: Span | null } | null>(null); + const resizingEntry = $derived( + resizing ? (tiles.entries.find((tile) => tile.id === resizing?.id) ?? null) : null, + ); + + function startResize(): void { + if (!selected) return; + const entry = tiles.entries.find((tile) => tile.id === selected.id); + resizing = { + id: selected.id, + span: entry + ? { colSpan: entry.colSpan, rowSpan: entry.rowSpan } + : { colSpan: 1, rowSpan: 1 }, + }; + } + + function commitResize(colSpan: number, rowSpan: number): void { + if (!resizing) return; + tiles.resize(resizing.id, colSpan, rowSpan); + resizing = null; + } + + function cancelResize(): void { + resizing = null; + } + + // ── Details flyout (the dense per-card settings). Id-based + gated on the + // selection, so deselecting (Escape) also dismisses it. ───────────────────── + let detailsForId = $state<string | null>(null); + const detailsFor = $derived( + selectedId !== null && detailsForId === selectedId + ? (workspacesById.get(selectedId) ?? null) + : null, + ); - const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + // ── Per-workspace plugin modal ───────────────────────────────────────────── + let pluginsModalId = $state<string | null>(null); + const pluginsModalFor = $derived( + pluginsModalId !== null ? (workspacesById.get(pluginsModalId) ?? null) : null, + ); - function createWorkspace(): void { - const slug = newSlug.trim(); - if (!isValidSlug(slug)) { - slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + // Global plugin manager modal (plan 07 §7.2): the ONLY surface that + // loads/unloads code (strict layering). + let managerOpen = $state(false); + + // ── Archived workspaces modal ────────────────────────────────────────────── + let archivedOpen = $state(false); + + // ── Actions (the action bar) ─────────────────────────────────────────────── + function openWorkspace(id: string): void { + deselect(); + onNavigate(workspacePath(id)); + } + + async function deleteSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + if ( + !window.confirm( + `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, + ) + ) { return; } - slugError = null; - newSlug = ""; - onNavigate(workspacePath(slug)); + const result = await store.remove(ws.id); + if (result.ok) deselect(); + } + + async function toggleStar(): Promise<void> { + if (!selected) return; + await store.setStarred(selected.id, !selected.starred); + } + + async function archiveSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + // Reversible without a confirm dialog — the workspace is hidden, not deleted. + const result = await store.setArchived(ws.id, true); + if (result.ok) deselect(); + } + + // ── 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.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 + // MOVES it into the first empty slot instead of duplicating. + tiles.placeFirstEmpty(slug, geometry); + renamingId = slug; + return { ok: true }; + } + + // ── Sort overflow: re-apply the recent-activity order ────────────────────── + function sortByRecent(): void { + const activity = new Map(store.list.map((ws) => [ws.id, ws.lastActivityAt])); + tiles.sortByRecentActivity(activity); + } + + // Escape unwinds the overlays: resize → selection. + function handleKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape") return; + if (resizing) { + cancelResize(); + } else if (selectedId !== null) { + deselect(); + } } </script> -<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6"> - <header class="flex items-center justify-between"> - <h1 class="text-2xl font-bold">Workspaces</h1> - <a - href="/default" - class="btn btn-ghost btn-sm" - onclick={(e) => { - e.preventDefault(); - onNavigate("/default"); - }} - > - Default - </a> - </header> - - <form - class="flex items-end gap-2" - onsubmit={(e) => { - e.preventDefault(); - createWorkspace(); - }} - > - <div class="flex-1"> - <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60" - >New workspace</label - > - <input - id="new-ws" - class="input input-bordered w-full" - placeholder="my-workspace" - bind:value={newSlug} - autocomplete="off" - spellcheck="false" - /> - </div> - <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button> - </form> - {#if slugError} - <p class="text-xs text-error">{slugError}</p> - {/if} - - <div class="flex-1 overflow-y-auto"> +<svelte:window onkeydown={handleKeydown} /> + +<div class="h-app flex w-full flex-col"> + <HomeHeader + compact={geometry.mode === "tall"} + withPlugins={plugins !== undefined} + withSort={store.list.length > 1} + onCreate={createWorkspace} + onOpenPlugins={() => (managerOpen = true)} + onSort={() => void sortByRecent()} + onOpenArchived={() => (archivedOpen = true)} + /> + + <div bind:this={regionEl} class="min-h-0 w-full flex-1"> {#if store.loading && store.list.length === 0} - <div class="flex h-32 items-center justify-center"> - <span class="loading loading-spinner loading-sm opacity-60"></span> + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-lg opacity-60"></span> </div> {:else if store.list.length === 0} - <p class="py-8 text-center text-sm opacity-60"> - No workspaces yet. Create one above or visit <code>/your-name</code> in the URL. - </p> + <div class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center"> + <p class="text-2xl font-black tracking-tight opacity-80">No workspaces yet</p> + <p class="max-w-md text-sm opacity-60"> + Create one with <strong>New workspace</strong> above, or visit + <code class="rounded bg-base-200 px-1.5 py-0.5">/your-name</code> in the URL — the first + tile drops onto the canvas ready to rename. + </p> + </div> {:else} - <ul class="flex flex-col gap-2"> - {#each store.list as ws (ws.id)} - <WorkspaceCard - {ws} - {store} - {onNavigate} - {computers} - {...(hasActive ? { hasActive } : {})} - /> - {/each} - </ul> + <HomeTileGrid + {geometry} + {placements} + {workspacesById} + {store} + {selectedId} + {renamingId} + preview={ + resizing?.id !== undefined && resizing.span !== null + ? { id: resizing.id, ...resizing.span } + : null + } + {...(hasActive ? { hasActive } : {})} + onOpen={openWorkspace} + onSelect={select} + onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)} + onRenameEnd={() => (renamingId = null)} + /> {/if} </div> </div> + +{#if selected && !renamingId} + <TileActionBar + ws={selected} + withPlugins={plugins !== undefined} + onOpen={() => openWorkspace(selected.id)} + onRename={() => (renamingId = selected.id)} + onResize={startResize} + onToggleStar={() => void toggleStar()} + onDetails={() => (detailsForId = selected.id)} + onPlugins={() => (pluginsModalId = selected.id)} + onArchive={() => void archiveSelected()} + onDelete={() => void deleteSelected()} + onClose={deselect} + /> +{/if} + +{#if resizing && resizingEntry} + <ResizePicker + {geometry} + initial={{ colSpan: resizingEntry.colSpan, rowSpan: resizingEntry.rowSpan }} + onPreview={(span) => { + if (resizing) resizing = { id: resizing.id, span }; + }} + onCommit={commitResize} + onCancel={cancelResize} + /> +{/if} + +{#if detailsFor} + <WorkspaceDetails ws={detailsFor} {store} {computers} onClose={() => (detailsForId = null)} /> +{/if} + +{#if pluginsModalFor && plugins} + <WorkspacePluginModal + workspaceId={pluginsModalFor.id} + workspaceTitle={pluginsModalFor.title} + {plugins} + {...(layout ? { layout } : {})} + onClose={() => (pluginsModalId = null)} + /> +{/if} + +{#if managerOpen && plugins} + <PluginManager {plugins} onClose={() => (managerOpen = false)} /> +{/if} + +{#if archivedOpen} + <ArchivedWorkspaces + {store} + onOpen={(id) => openWorkspace(id)} + onClose={() => (archivedOpen = false)} + /> +{/if} diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts new file mode 100644 index 0000000..5a66a42 --- /dev/null +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -0,0 +1,333 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; +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, TileLayoutHttpResult } from "../adapter/tile-layout-http"; +import type { WorkspaceLookup } from "../logic/create"; +import { createTileLayoutStore } from "../tile-layout-store.svelte"; +import WorkspacesHome from "./WorkspacesHome.svelte"; + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 1, + ...overrides, + }; +} + +/** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */ +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 + // `list` serves it too. + ensure: vi.fn(async (id: string): Promise<WorkspaceResult<Workspace>> => { + if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id })); + return { ok: true, value: ws({ id, title: id }) }; + }), + get: vi.fn(lookup), + setTitle: vi.fn(), + setDefaultCwd: vi.fn(), + setDefaultComputer: vi.fn(), + star: vi.fn(), + unstar: vi.fn(), + delete: vi.fn(async (): Promise<WorkspaceResult<{ closedCount: number }>> => { + // Mirrors the backend: the workspace leaves the list. + list.splice(0); + return { ok: true, value: { closedCount: 0 } }; + }), + } as unknown as WorkspaceHttp; +} + +/** + * A fake tile-layout HTTP edge serving ONE controllable document per mode + * (no mocks of our modules). `docs` is the live store — assertions read it + * after `waitFor` (the boot probe + debounced sync writes land there). + */ +function memoryLayout( + initial: TileLayoutData | null = null, +): TileLayoutHttp & { docs: Map<TileGeometryMode, TileLayoutData> } { + const docs = new Map<TileGeometryMode, TileLayoutData>(); + if (initial !== null) docs.set("wide", initial); + return { + docs, + async getLayout(mode) { + return { ok: true, layout: docs.get(mode) ?? { version: 2, tiles: [] } }; + }, + async putLayout(mode, layout) { + docs.set(mode, layout); + return { ok: true, layout }; + }, + async deleteLayout(mode) { + docs.set(mode, { version: 2, tiles: [] }); + return { ok: true, layout: docs.get(mode) as TileLayoutData }; + }, + }; +} + +describe("WorkspacesHome (tile canvas)", () => { + it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 2, + tiles: [ + { id: "b", order: 0, colSpan: 1, rowSpan: 1 }, + { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, + ], + }); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: layout }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace B" }); + const wrappers = document.querySelectorAll("[data-tile-id]"); + 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(); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), + onNavigate, + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + const tile = await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(tile); + expect(onNavigate).toHaveBeenCalledWith("/a"); + }); + + it("creating a workspace from the header does NOT navigate; the new tile enters rename mode", async () => { + const user = userEvent.setup(); + const list = [ws({ id: "a", title: "A" })]; + const onNavigate = vi.fn(); + const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)); + render(WorkspacesHome, { + props: { + store, + onNavigate, + 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" }), "fresh-ws{Enter}"); + + await waitFor(() => { + expect(screen.getByLabelText("Workspace title", { selector: "input" })).toBeInTheDocument(); + }); + expect(onNavigate).not.toHaveBeenCalled(); + // The backend was asked to create it (create-on-miss). + 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); + const list = [ws({ id: "a", title: "A" })]; + const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)); + render(WorkspacesHome, { + props: { + store, + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + const tile = await screen.findByRole("button", { name: "Open workspace A" }); + await user.pointer({ keys: "[MouseRight]", target: tile }); + + const deleteButton = await screen.findByRole("button", { name: "Delete" }); + await user.click(deleteButton); + + expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Delete workspace "A"?')); + await waitFor(() => { + expect(store.list.length).toBe(0); + }); + confirmSpy.mockRestore(); + }); + + it("workspaces unknown to the layout append at the end (deep-link auto-create)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 2, + tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }], + }); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: layout }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace B" }); + await screen.findByRole("button", { name: "Open workspace A" }); + const wrappers = document.querySelectorAll("[data-tile-id]"); + expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); + // And the sync persisted the appended entry. + await waitFor(() => { + const tiles = layout.docs.get("wide")?.tiles ?? []; + expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]); + }); + }); +}); diff --git a/src/plugin-runtime/svelte-api.ts b/src/plugin-runtime/svelte-api.ts new file mode 100644 index 0000000..cfc1de5 --- /dev/null +++ b/src/plugin-runtime/svelte-api.ts @@ -0,0 +1,6 @@ +// The Svelte API surface exposed to dynamically-imported frontend plugins +// (plan 03): the import map maps the bare `svelte` specifier to this module's +// emitted chunk, whose star re-exports are the SAME module instances the app +// bundles (one rollup graph → one runtime). Entry-module exports are +// preserved in full, so plugins can import bindings the app never uses. +export * from "svelte"; diff --git a/src/plugin-runtime/svelte-internal-client.ts b/src/plugin-runtime/svelte-internal-client.ts new file mode 100644 index 0000000..cc6b4d6 --- /dev/null +++ b/src/plugin-runtime/svelte-internal-client.ts @@ -0,0 +1,12 @@ +// The compiled-runes runtime exposed to dynamically-imported frontend +// plugins (plan 03): the import map maps the bare `svelte/internal/client` +// specifier to this module's emitted chunk — the same runtime instance the +// app's own compiled components use. +// +// `svelte/internal/client` is intentionally untyped (the compiled-runes +// runtime — its API is compiler-private), so the re-export needs the +// suppression below. Nothing in the app imports THROUGH this module — the +// import map hands it to plugins; its entry exports are preserved in full. +// +// @ts-expect-error — svelte/internal/client ships no declaration file +export * from "svelte/internal/client"; |
