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/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 +++++++++++++++++++ 4 files changed, 737 insertions(+) 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/features/named-agents/ui') 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 @@ + + +
+ 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 @@ + + +
+ + + {#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(); + }); +}); -- cgit v1.2.3