From fb7eb3105e1fbbdefdad9348f9132815b5bbe52d Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 20 Aug 2026 19:36:12 +0900 Subject: feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the frontend of the Named Agents feature (plan §5-§9): - New features/named-agents module: pure logic (types re-exported from @dispatch/wire + transport-contract, validation, provider/model options, snapshot helpers, network normalizers, auto-save status model) + an injected auto-save controller (name debounce, immediate provider/model save, monotonic stale-response guard) + the collapsible sidebar card view. - Store: /named-agents CRUD + boot seed; selectModel(model, snapshot?) persists the raw model WITH the namedAgent snapshot; draft promotion carries it; refreshModel restores ModelResponse.namedAgent. - Chat store setNamedAgent (sends namedAgent on chat.send); Tab.snapshot + setSnapshot (retained across rename/remove, persisted with tabs). - Model selector: synthetic Named Agents provider ALWAYS first; selecting one resolves to the raw model + records a snapshot; a retained snapshot of a renamed/removed agent still displays as unavailable (plan §7). - Composition root wires the view (viewKinds + loaded-modules manifest) + feeds the selector. Tests: logic + controller + selector + app-store CRUD (1246 pass), typecheck 0, biome clean (only pre-existing config infos pending a future migrate), build OK. --- src/app/App.svelte | 35 +++ src/app/store.svelte.ts | 213 ++++++++++++++++- src/app/store.test.ts | 174 +++++++++++++- src/features/chat/index.ts | 7 + src/features/chat/model-select.test.ts | 49 ++++ src/features/chat/model-select.ts | 70 ++++++ src/features/chat/store.svelte.ts | 16 ++ src/features/chat/ui.test.ts | 141 ++++++++++- src/features/chat/ui/ModelSelector.svelte | 198 ++++++++++++---- src/features/named-agents/index.ts | 64 +++++ src/features/named-agents/logic/auto-save.test.ts | 157 +++++++++++++ src/features/named-agents/logic/auto-save.ts | 138 +++++++++++ src/features/named-agents/logic/normalize.test.ts | 78 +++++++ src/features/named-agents/logic/normalize.ts | 68 ++++++ src/features/named-agents/logic/options.test.ts | 72 ++++++ src/features/named-agents/logic/options.ts | 84 +++++++ src/features/named-agents/logic/snapshot.test.ts | 35 +++ src/features/named-agents/logic/snapshot.ts | 33 +++ src/features/named-agents/logic/types.ts | 57 +++++ src/features/named-agents/logic/validate.test.ts | 58 +++++ src/features/named-agents/logic/validate.ts | 47 ++++ src/features/named-agents/ui/NamedAgentCard.svelte | 120 ++++++++++ .../named-agents/ui/NamedAgentsView.svelte | 123 ++++++++++ src/features/named-agents/ui/controller.svelte.ts | 257 +++++++++++++++++++++ src/features/named-agents/ui/controller.test.ts | 237 +++++++++++++++++++ src/features/tabs/tabs-store.svelte.ts | 10 + src/features/tabs/tabs.ts | 40 +++- 27 files changed, 2519 insertions(+), 62 deletions(-) create mode 100644 src/features/named-agents/index.ts create mode 100644 src/features/named-agents/logic/auto-save.test.ts create mode 100644 src/features/named-agents/logic/auto-save.ts create mode 100644 src/features/named-agents/logic/normalize.test.ts create mode 100644 src/features/named-agents/logic/normalize.ts create mode 100644 src/features/named-agents/logic/options.test.ts create mode 100644 src/features/named-agents/logic/options.ts create mode 100644 src/features/named-agents/logic/snapshot.test.ts create mode 100644 src/features/named-agents/logic/snapshot.ts create mode 100644 src/features/named-agents/logic/types.ts create mode 100644 src/features/named-agents/logic/validate.test.ts create mode 100644 src/features/named-agents/logic/validate.ts create mode 100644 src/features/named-agents/ui/NamedAgentCard.svelte create mode 100644 src/features/named-agents/ui/NamedAgentsView.svelte create mode 100644 src/features/named-agents/ui/controller.svelte.ts create mode 100644 src/features/named-agents/ui/controller.test.ts (limited to 'src') diff --git a/src/app/App.svelte b/src/app/App.svelte index e50648a..9436648 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -44,6 +44,12 @@ 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 { + manifest as namedAgentsManifest, + NamedAgentsView, + type NamedAgentInput, + type NamedAgentSelectionSnapshot, + } from "../features/named-agents"; import { CwdField, type CwdSaveResult, @@ -119,6 +125,7 @@ const viewKinds = [ { id: "tabs", label: "Tabs" }, { id: "model", label: "Model" }, + { id: "named-agents", label: "Named Agents" }, { id: "lsp", label: "Language Servers" }, { id: "mcp", label: "MCP Servers" }, { id: "extensions", label: "Extensions" }, @@ -167,6 +174,7 @@ heartbeatManifest, concurrencyManifest, visionManifest, + namedAgentsManifest, ].map((m) => [m.name, m.description] as const); // Smart-scroll: keep the transcript pinned to the bottom while it streams, @@ -310,6 +318,21 @@ 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) { + store.selectModel(snapshot.model, snapshot); + } + + // 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), + }; + // Adapt the store's WarmResult to the cache-warming feature's WarmNow port. async function warmNow(): Promise { const result = await store.warmNow(); @@ -754,6 +777,9 @@ selected={store.activeModel} onSelect={handleSelectModel} modelInfo={store.modelInfo} + namedAgents={store.namedAgents} + namedAgentSnapshot={store.namedAgentSnapshot} + onSelectNamedAgent={handleSelectNamedAgent} /> + {:else if kind === "lsp"} {#key store.currentConversationId} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 9d0e605..08a7cd8 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -80,6 +80,15 @@ import type { HeartbeatStopResult, } from "../features/heartbeat"; import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat"; +import type { + NamedAgent, + NamedAgentDeleteResult, + NamedAgentInput, + NamedAgentSaveResult, + NamedAgentSelectionSnapshot, + NamedAgentsResult, +} from "../features/named-agents"; +import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents"; import type { Tab, TabsState } from "../features/tabs"; import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs"; import { @@ -224,7 +233,8 @@ export interface AppStore { * message is a silent server no-op (nothing to roll back). */ cancelQueuedMessage(messageId: string): void; - selectModel(model: 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; @@ -518,6 +528,39 @@ export interface AppStore { 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; + /** + * 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; + /** + * 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; + /** + * 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; dispose(): void; } @@ -557,6 +600,16 @@ function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSy }; } +/** Replace-or-append a Named Agent into a list (immutably), keyed by id. */ +function upsertNamedAgentList( + agents: readonly NamedAgent[], + agent: NamedAgent, +): readonly NamedAgent[] { + const idx = agents.findIndex((a) => a.id === agent.id); + if (idx === -1) return [...agents, agent]; + return agents.map((a) => (a.id === agent.id ? agent : a)); +} + export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let protocol = $state(protocolInitialState()); let models = $state([]); @@ -566,8 +619,20 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // `~/.ssh/config`). let computers = $state([]); let activeModel = $state(DEFAULT_MODEL); + // The persisted Named Agents (global user config). Seeded from the backend on + // boot + kept in sync by the CRUD methods; drives both the sidebar editor and + // the synthetic model-selector provider. + let namedAgents = $state([]); + // 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(null); let fatalError = $state(null); + /** Replace-or-append a Named Agent into the reactive list (immutably). */ + function upsertNamedAgent(agent: NamedAgent): void { + namedAgents = upsertNamedAgentList(namedAgents, agent); + } + // The workspace currently in view (its URL slug); "default" until routing // sets it. Tabs are filtered to this workspace; a new conversation is stamped // with it on `chat.send`. @@ -725,14 +790,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); + tabsStore.setSnapshot(activeId, namedAgentSnapshot); chatStores.get(activeId)?.setModel(data.model); + chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot); } else { draftStore.setModel(data.model); + draftStore.setNamedAgent(namedAgentSnapshot); } } } catch (err) { @@ -1267,6 +1339,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { reportError("Failed to load computer list", err); }); + // Fetch the persisted Named Agents (global user config). Seeded on boot so the + // sidebar editor + the synthetic model-selector provider have data immediately. + // Non-fatal: a failure leaves an empty list (the user can add agents). + void fetchImpl(`${httpBase}/named-agents`) + .then((res) => { + if (!res.ok) return { namedAgents: [] } as { namedAgents: NamedAgent[] }; + return res.json() as Promise<{ namedAgents: NamedAgent[] }>; + }) + .then((data) => { + namedAgents = normalizeNamedAgents(data); + }) + .catch((err) => { + reportError("Failed to load named agents", err); + }); + // Restore persisted tabs const persistedState = storageAdapter.load(); if (persistedState !== null && persistedState.tabs.length > 0) { @@ -1292,6 +1379,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { ); if (activeTab !== undefined) { activeModel = migrateLegacyModelName(activeTab.model); + namedAgentSnapshot = activeTab.snapshot ?? null; } } } @@ -1331,6 +1419,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { draftStore = createChatFor(nextDraftId, activeModel, workspaceId); draftConversationId = nextDraftId; tabsStore.newDraft(); + namedAgentSnapshot = null; refreshActiveChat(); syncSubscriptions(); void refreshCwd(); @@ -1414,14 +1503,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { send(text: string, images?: readonly ImageInput[]): void { if (tabsStore.activeConversationId === null) { - // Draft: promote to tab on first send + // Draft: promote to tab on first send. Carry a Named Agent selection's + // snapshot so the promoted conversation retains how it was selected (§7). const conversationId = draftConversationId; const model = activeModel; + const snapshot = namedAgentSnapshot ?? undefined; tabsStore.createTab({ conversationId, model, title: deriveTitle(text), workspaceId: activeWorkspaceId, + ...(snapshot ? { snapshot } : {}), }); chatStores.set(conversationId, draftStore); void draftStore.load(); @@ -1465,21 +1557,26 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { activeChat.cancelQueuedMessage(messageId); }, - selectModel(model: string): void { + selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void { activeModel = model; + namedAgentSnapshot = snapshot ?? null; const activeId = tabsStore.activeConversationId; if (activeId !== null) { tabsStore.setModel(activeId, model); + tabsStore.setSnapshot(activeId, snapshot ?? null); chatStores.get(activeId)?.setModel(model); + 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); + draftStore.setNamedAgent(snapshot ?? null); } }, @@ -1488,6 +1585,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { const nextDraftId = randomId(); draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); draftConversationId = nextDraftId; + namedAgentSnapshot = null; refreshActiveChat(); syncSubscriptions(); void refreshCwd(); @@ -1503,6 +1601,9 @@ 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(); @@ -2285,6 +2386,110 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { unloadGate = gate; }, + // ── Named Agents (global config; plan §9) ──────────────────────────────── + + get namedAgents(): readonly NamedAgent[] { + return namedAgents; + }, + get namedAgentSnapshot(): NamedAgentSelectionSnapshot | null { + return namedAgentSnapshot; + }, + + async refreshNamedAgents(): Promise { + 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 { + 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 { + 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 { + 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", + }; + } + }, + get fatalError(): string | null { return fatalError; }, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index d30148e..c41efc2 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1,5 +1,6 @@ import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; +import type { NamedAgent } from "@dispatch/wire"; import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; import { createAppStore } from "./store.svelte"; @@ -104,16 +105,67 @@ interface FakeFetchOptions { models?: readonly string[]; history?: Record; 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-1/deepseek-v4-flash", "openai/gpt-4o"]; const history = opts?.history ?? {}; - return async (input: string | URL | Request): Promise => { + // 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 => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + 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; if (url.endsWith("/models")) { return new Response(JSON.stringify({ models }), { status: 200 }); } + if (url.endsWith("/named-agents") && method === "GET") { + return new Response(JSON.stringify({ namedAgents }), { status: 200 }); + } + if (url.endsWith("/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.includes("/named-agents/") && 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 (url.includes("/named-agents/") && method === "DELETE") { + const id = url.split("/named-agents/")[1] ?? ""; + namedAgents = namedAgents.filter((a) => a.id !== id); + return new Response(JSON.stringify({ id }), { status: 200 }); + } if (url.endsWith("/model")) { return new Response( JSON.stringify({ @@ -2276,3 +2328,123 @@ 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(); + }); +}); diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 8cf776b..6de8164 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -6,12 +6,19 @@ export type { } from "../../core/chunks"; export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; +export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select"; export { + isNamedAgentSelection, isNumberedOpenCode, isVisionModel, migrateLegacyModelName, modelKeyLabel, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, openCodeNumber, + providerKeysWithNamedAgents, } from "./model-select"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 33c1a16..57de11d 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -1,12 +1,18 @@ import { describe, expect, it } from "vitest"; import { + isNamedAgentSelection, isVisionModel, joinModelName, migrateLegacyModelName, modelKeyLabel, modelKeys, modelsForKey, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, openCodeNumber, + providerKeysWithNamedAgents, splitModelName, } from "./model-select"; @@ -143,3 +149,46 @@ 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 -> '", () => { + expect(namedAgentSelectionLabel("Coding")).toBe("Named Agents -> Coding"); + }); +}); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index 5da6e7b..6f198f3 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -108,3 +108,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 + * (``), 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 -> `. + */ +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 -> ". Pure display. + */ +export function namedAgentSelectionLabel(name: string): string { + return `${namedAgentsProviderLabel()} -> ${name}`; +} diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 24a1d06..c83c5fd 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -34,6 +34,7 @@ import { selectOrderedTurnMetrics, } from "../../core/metrics"; import type { ConversationCache } from "../conversation-cache"; +import type { NamedAgentSelectionSnapshot } from "../named-agents"; import type { ChatTransport, HistorySync, MetricsSync } from "./ports"; export interface ChatStoreDependencies { @@ -144,6 +145,15 @@ export interface ChatStore { */ 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 @@ -180,6 +190,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { let _pendingSync = $state(false); let _error = $state(null); let _model = $state(deps.model); + let _namedAgent = $state(null); let disposed = false; let chatLimit = normalizeChatLimit(deps.chatLimit); @@ -342,6 +353,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { 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] } : {}), }; @@ -379,6 +391,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { _model = model; }, + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void { + _namedAgent = snapshot; + }, + async setChatLimit(limit: number): Promise { const prev = chatLimit; chatLimit = normalizeChatLimit(limit); diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index aa43e04..3a47219 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -1006,7 +1006,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 +1043,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 = { @@ -1099,15 +1228,17 @@ describe("ModelSelector", () => { const keySelect = screen.getByRole("combobox", { name: "Key selector" }); const options = within(keySelect).getAllByRole("option"); - // Numeric order: #1 < #2 < #10, and values stay RAW (opencode-N). + // 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[0]?.textContent).toContain("OpenCode #1"); - expect(options[1]?.textContent).toContain("OpenCode #2"); - expect(options[2]?.textContent).toContain("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", () => { diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index 1d06848..c66d3dd 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,11 +1,16 @@ + +
+ Name + controller.setName(e.currentTarget.value)} + /> + + Provider + + + Model + + + {#if !validity.usable && validity.message !== null} +

{validity.message}

+ {/if} + +
+ + {statusLabel}{#if controller.save.error !== null}: {controller.save.error}{/if} + + +
+
\ 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..393c374 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.svelte @@ -0,0 +1,123 @@ + + +
+
    + {#each rows as row (row.key)} +
  • + +
  • + {/each} +
+ + {#if rows.length === 0} +

No named agents yet.

+ {/if} + + +
\ No newline at end of file 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 `/` ('' 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(agent?.id ?? null); + let name = $state(agent?.name ?? ""); + let provider = $state(savedModel === "" ? "" : (savedModel.split("/")[0] ?? "")); + let model = $state( + 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(agent?.name ?? ""); + let savedModelValue = $state(savedModel); + let saving = $state(false); + let error = $state(null); + + let editSeq = 0; + let nameTimer: ReturnType | 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(() => { + 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((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/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts index acf56fe..5ed08d4 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,6 +8,7 @@ import { openTab as reduceOpenTab, selectTab as reduceSelectTab, setModel as reduceSetModel, + setSnapshot as reduceSetSnapshot, setTitle as reduceSetTitle, setWorkspaceId as reduceSetWorkspaceId, activeTab as selectActiveTab, @@ -28,6 +30,11 @@ 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; } @@ -68,6 +75,9 @@ 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)); }, diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index 9fc9306..ff7bb4f 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -1,9 +1,18 @@ +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; } export interface TabsState { @@ -73,7 +82,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 }; } -- cgit v1.2.3