diff options
Diffstat (limited to 'src/features/named-agents/ui')
| -rw-r--r-- | src/features/named-agents/ui/NamedAgentCard.svelte | 120 | ||||
| -rw-r--r-- | src/features/named-agents/ui/NamedAgentsView.svelte | 123 | ||||
| -rw-r--r-- | src/features/named-agents/ui/controller.svelte.ts | 257 | ||||
| -rw-r--r-- | src/features/named-agents/ui/controller.test.ts | 237 |
4 files changed, 737 insertions, 0 deletions
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..393c374 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.svelte @@ -0,0 +1,123 @@ +<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 `exceptId`, or the given draft) — for the uniqueness hint. */ + function otherNamesOf(exceptId: string | null): readonly string[] { + const out: string[] = []; + for (const r of rows) { + if (r.agent !== null) { + if (r.agent.id !== exceptId) out.push(r.agent.name); + } else if (exceptId === null) { + out.push(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 id). */ + function isNameTakenFor(exceptId: string | null): (name: string) => boolean { + return (name: string): boolean => { + const lower = name.trim().toLowerCase(); + for (const r of rows) { + if (r.agent !== null) { + if (r.agent.id !== exceptId && r.agent.name.trim().toLowerCase() === lower) return true; + } else if (exceptId === null && r.controller.draft.name.trim().toLowerCase() === lower) { + return true; + } + } + return false; + }; + } + + function addRow(agent: NamedAgent | null): void { + const exceptId = agent?.id ?? null; + const controller = createCardController({ + agent, + savedModel: agent?.model ?? "", + isNameTaken: isNameTakenFor(exceptId), + ports, + onChanged: (saved) => replaceAgent(agent, saved), + onRemoved: () => removeRow(agent), + }); + rows = [...rows, { key: nextKey++, agent, controller }]; + } + + function replaceAgent(original: NamedAgent | null, saved: NamedAgent): void { + rows = rows.map((r) => { + const sameOriginal = r.agent === original || (r.agent === null && original === null); + return sameOriginal ? { ...r, agent: saved } : r; + }); + } + + function removeRow(agent: NamedAgent | null): void { + const row = rows.find((r) => r.agent === agent || (agent === null && r.agent === null)); + if (row === undefined) return; + row.controller.dispose(); + rows = rows.filter((r) => r !== row); + } + + 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.agent?.id ?? null)} + /> + </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/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(); + }); +}); |
