summaryrefslogtreecommitdiffhomepage
path: root/src/features/named-agents/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
committerAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
commitfb7eb3105e1fbbdefdad9348f9132815b5bbe52d (patch)
treedeb95e97a917424ea67470b168b91349a41d1c08 /src/features/named-agents/ui
parent63588c3c69b32d180dc78a2cc187cc8aa22ad7e0 (diff)
downloaddispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.tar.gz
dispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.zip
feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics
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.
Diffstat (limited to 'src/features/named-agents/ui')
-rw-r--r--src/features/named-agents/ui/NamedAgentCard.svelte120
-rw-r--r--src/features/named-agents/ui/NamedAgentsView.svelte123
-rw-r--r--src/features/named-agents/ui/controller.svelte.ts257
-rw-r--r--src/features/named-agents/ui/controller.test.ts237
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();
+ });
+});