summaryrefslogtreecommitdiffhomepage
path: root/src/features/named-agents
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/named-agents')
-rw-r--r--src/features/named-agents/index.ts64
-rw-r--r--src/features/named-agents/logic/auto-save.test.ts157
-rw-r--r--src/features/named-agents/logic/auto-save.ts138
-rw-r--r--src/features/named-agents/logic/normalize.test.ts78
-rw-r--r--src/features/named-agents/logic/normalize.ts68
-rw-r--r--src/features/named-agents/logic/options.test.ts72
-rw-r--r--src/features/named-agents/logic/options.ts84
-rw-r--r--src/features/named-agents/logic/snapshot.test.ts35
-rw-r--r--src/features/named-agents/logic/snapshot.ts33
-rw-r--r--src/features/named-agents/logic/types.ts57
-rw-r--r--src/features/named-agents/logic/validate.test.ts58
-rw-r--r--src/features/named-agents/logic/validate.ts47
-rw-r--r--src/features/named-agents/ui/NamedAgentCard.svelte120
-rw-r--r--src/features/named-agents/ui/NamedAgentsView.svelte126
-rw-r--r--src/features/named-agents/ui/NamedAgentsView.test.ts181
-rw-r--r--src/features/named-agents/ui/controller.svelte.ts257
-rw-r--r--src/features/named-agents/ui/controller.test.ts237
17 files changed, 1812 insertions, 0 deletions
diff --git a/src/features/named-agents/index.ts b/src/features/named-agents/index.ts
new file mode 100644
index 0000000..e66ef8e
--- /dev/null
+++ b/src/features/named-agents/index.ts
@@ -0,0 +1,64 @@
+export {
+ type AgentDraft,
+ type AgentSaved,
+ type DraftValidity,
+ draftModel,
+ draftValidity,
+ isClean,
+ isDirty,
+ type SaveStatus,
+ saveStatus,
+ saveStatusLabel,
+} from "./logic/auto-save";
+export {
+ normalizeNamedAgent,
+ normalizeNamedAgentRecord,
+ normalizeNamedAgents,
+ toNamedAgentInput,
+} from "./logic/normalize";
+export {
+ fullModelFromSelection,
+ joinModel,
+ modelFromFull,
+ modelsForProvider,
+ openCodeNumber,
+ providerFromModel,
+ providerLabel,
+ providerOptions,
+} from "./logic/options";
+export { findNamedAgent, snapshotFor } from "./logic/snapshot";
+export type {
+ CreateNamedAgent,
+ DeleteNamedAgent,
+ LoadNamedAgents,
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentResponse,
+ NamedAgentSaveResult,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResponse,
+ NamedAgentsResult,
+ UpdateNamedAgent,
+} from "./logic/types";
+export {
+ isUsableModel,
+ isUsableName,
+ nameError,
+ normalizeName,
+} from "./logic/validate";
+export {
+ type CardController,
+ type CardControllerOptions,
+ type CardSaveState,
+ createCardController,
+ type NamedAgentPorts,
+} from "./ui/controller.svelte";
+export { default as NamedAgentCard } from "./ui/NamedAgentCard.svelte";
+export { default as NamedAgentsView } from "./ui/NamedAgentsView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "named-agents",
+ description: "Curated Named Agent presets (name + model) in the sidebar",
+} as const;
diff --git a/src/features/named-agents/logic/auto-save.test.ts b/src/features/named-agents/logic/auto-save.test.ts
new file mode 100644
index 0000000..24c9571
--- /dev/null
+++ b/src/features/named-agents/logic/auto-save.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from "vitest";
+import {
+ type AgentDraft,
+ draftModel,
+ draftValidity,
+ isDirty,
+ saveStatus,
+ saveStatusLabel,
+} from "./auto-save";
+
+const draft = (p: Partial<AgentDraft>): AgentDraft => ({
+ id: null,
+ name: "",
+ provider: "",
+ model: "",
+ ...p,
+});
+
+describe("draftModel", () => {
+ it("joins provider + model suffix", () => {
+ expect(draftModel(draft({ provider: "opencode-1", model: "claude" }))).toBe(
+ "opencode-1/claude",
+ );
+ });
+
+ it("falls back to provider-only when no model", () => {
+ expect(draftModel(draft({ provider: "opencode-1" }))).toBe("opencode-1");
+ });
+});
+
+describe("draftValidity", () => {
+ it("is usable when name, provider, and model are all valid", () => {
+ const v = draftValidity(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), []);
+ expect(v.usable).toBe(true);
+ expect(v.message).toBeNull();
+ });
+
+ it("is unusable with an empty name / duplicate / missing model", () => {
+ expect(draftValidity(draft({ name: "", provider: "p", model: "m" }), []).usable).toBe(false);
+ expect(
+ draftValidity(draft({ name: "coding", provider: "p", model: "m" }), ["Coding"]).usable,
+ ).toBe(false);
+ expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).usable).toBe(
+ false,
+ );
+ });
+
+ it("reports the first invalid field's message", () => {
+ expect(draftValidity(draft({ name: "", provider: "", model: "" }), []).message).toBe(
+ "Name is required.",
+ );
+ expect(
+ draftValidity(draft({ name: "coding", provider: "", model: "" }), ["Coding"]).message,
+ ).toBe("A named agent with this name already exists.");
+ expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).message).toBe(
+ "Select a provider.",
+ );
+ });
+});
+
+describe("isDirty", () => {
+ it("is false when the draft matches the saved definition", () => {
+ expect(
+ isDirty(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(false);
+ });
+
+ it("is true when name or model differ", () => {
+ expect(
+ isDirty(draft({ name: "Coding X", provider: "opencode-1", model: "claude" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ expect(
+ isDirty(draft({ name: "Coding", provider: "umans", model: "glm" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("saveStatus", () => {
+ const saved = { id: "a", name: "Coding", model: "opencode-1/claude" };
+
+ it("is saved when clean with an id", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ null,
+ ),
+ ).toBe("saved");
+ });
+
+ it("is dirty when input diverges from the saved definition", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding X", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ null,
+ ),
+ ).toBe("dirty");
+ });
+
+ it("is an idle draft when it has no id", () => {
+ expect(
+ saveStatus(
+ draft({ name: "Coding", provider: "opencode-1", model: "claude" }),
+ { id: null, name: "", model: "" },
+ false,
+ null,
+ ),
+ ).toBe("idle");
+ });
+
+ it("is saving while a save is in flight", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ true,
+ null,
+ ),
+ ).toBe("saving");
+ });
+
+ it("reports an error when the last save failed", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ "boom",
+ ),
+ ).toBe("error");
+ });
+});
+
+describe("saveStatusLabel", () => {
+ it("maps every status to a short label", () => {
+ expect(saveStatusLabel("idle")).toBe("Not saved");
+ expect(saveStatusLabel("dirty")).toBe("Not saved");
+ expect(saveStatusLabel("saving")).toBe("Saving…");
+ expect(saveStatusLabel("saved")).toBe("Saved");
+ expect(saveStatusLabel("error")).toBe("Save failed");
+ });
+});
diff --git a/src/features/named-agents/logic/auto-save.ts b/src/features/named-agents/logic/auto-save.ts
new file mode 100644
index 0000000..c89d12e
--- /dev/null
+++ b/src/features/named-agents/logic/auto-save.ts
@@ -0,0 +1,138 @@
+/**
+ * Pure auto-save model for a Named Agent card (plan §5.3/§5.4).
+ *
+ * A card has NO explicit Save button: provider/model changes save immediately,
+ * a name edit saves after a short debounce, and the card shows a status
+ * (`Not saved` / `Saving…` / `Saved` / `Save failed` + retry). This module is
+ * the PURE model of that: given the card's current DRAFT (the raw input
+ * controls), the last PERSISTED state, whether a save is in flight, and an
+ * error, it derives the card's display status and what a save would persist.
+ *
+ * The effectful coordination (debounce timers, dispatching saves, and the
+ * monotonic sequence that stops an older response clobbering a newer edit)
+ * lives in the component-wired controller (`ui/controller.svelte.ts`), which
+ * is itself thin over this purity.
+ */
+
+import { isUsableModel, isUsableName, normalizeName } from "./validate";
+
+/**
+ * The four auto-save lifecycle states a card reports. `"idle"` = a brand-new
+ * unsaved draft (never persisted yet) — shown as "Not saved". `"dirty"` = an
+ * existing card whose input differs from what was last saved — also "Not saved"
+ * until the (debounced/immediate) save lands. `"saving"` = a save is in flight.
+ * `"saved"` = current input matches the last persisted value. `"error"` = the
+ * last save failed (a retry is offered). A card that is not yet usable hovers
+ * in `"idle"` (never issued a save).
+ */
+export type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
+
+/** A card's current EDITOR state (the raw form controls). */
+export interface AgentDraft {
+ /** The persisted id once the card has been created; null for a new draft. */
+ readonly id: string | null;
+ /** The raw name input (may be unterminated/unedited). */
+ readonly name: string;
+ /** Selected provider id ("" when none). */
+ readonly provider: string;
+ /** Selected model suffix under `provider` ("" when none). */
+ readonly model: string;
+}
+
+/** The card's last PERSISTED definition (the server's canonical record). */
+export interface AgentSaved {
+ readonly id: string | null;
+ readonly name: string;
+ readonly model: string;
+}
+
+/** The full canonical `<provider>/<model>` name a draft would persist. */
+export function draftModel(draft: AgentDraft): string {
+ return draft.model === "" ? draft.provider : `${draft.provider}/${draft.model}`;
+}
+
+/** The full `<provider>/<model>` name a draft's model suffix maps to. */
+export interface DraftValidity {
+ readonly nameOk: boolean;
+ readonly modelOk: boolean;
+ /** True when the card is fully usable (name nonempty+unique, provider+model chosen). */
+ readonly usable: boolean;
+ /** A message for the first invalid field (for inline hints), or null when usable. */
+ readonly message: string | null;
+}
+
+/**
+ * Validate a draft against its sibling names (the OTHER agents' names, EXCLUDING
+ * this card's own id — uniqueness is among distinct agents).
+ */
+export function draftValidity(draft: AgentDraft, otherNames: readonly string[]): DraftValidity {
+ const nameMessage = nameErrorMessage(draft.name, otherNames);
+ const nameOk = nameMessage === null;
+ const modelOk = draft.provider !== "" && isUsableModel(draftModel(draft));
+ const usable = nameOk && modelOk;
+ const message = usable ? null : (nameMessage ?? modelMessage(draft));
+ return { nameOk, modelOk, usable, message };
+}
+
+function nameErrorMessage(name: string, otherNames: readonly string[]): string | null {
+ if (normalizeName(name).length === 0) return "Name is required.";
+ return isUsableName(name, otherNames) ? null : "A named agent with this name already exists.";
+}
+
+function modelMessage(draft: AgentDraft): string | null {
+ if (draft.provider === "") return "Select a provider.";
+ if (draft.model === "") return "Select a model.";
+ return "Select a valid model.";
+}
+
+/**
+ * Whether the current draft differs from what was last persisted — i.e. a save
+ * is owed. Draft is compared by its NAME (normalized) and full MODEL string.
+ */
+export function isDirty(draft: AgentDraft, saved: AgentSaved): boolean {
+ return (
+ normalizeName(draft.name) !== normalizeName(saved.name) || draftModel(draft) !== saved.model
+ );
+}
+
+/**
+ * The card's display status, purely from its orchestration inputs:
+ * - No id yet → a never-persisted draft → `"idle"` while unusable or unsaved,
+ * `"error"` if a create attempt failed.
+ * - In-flight save → `"saving"`.
+ * - Last save errored → `"error"`.
+ * - No id and clean (nothing to save) → `"idle"`.
+ * - Dirty (not yet (re)saved) → `"dirty"`.
+ * - Clean + has an id → `"saved"`.
+ */
+export function saveStatus(
+ draft: AgentDraft,
+ saved: AgentSaved,
+ saving: boolean,
+ error: string | null,
+): SaveStatus {
+ if (saving) return "saving";
+ if (error !== null) return "error";
+ const hasId = draft.id !== null || saved.id !== null;
+ if (!hasId) return "idle";
+ return isDirty(draft, saved) ? "dirty" : "saved";
+}
+
+/** Short human label for a save status (the card's status line). */
+export function saveStatusLabel(status: SaveStatus): string {
+ switch (status) {
+ case "saving":
+ return "Saving…";
+ case "saved":
+ return "Saved";
+ case "error":
+ return "Save failed";
+ default:
+ return "Not saved";
+ }
+}
+
+/** True iff a status warrants showing the card body as "saved clean" (no warning tint). */
+export function isClean(status: SaveStatus): boolean {
+ return status === "saved";
+}
diff --git a/src/features/named-agents/logic/normalize.test.ts b/src/features/named-agents/logic/normalize.test.ts
new file mode 100644
index 0000000..5009194
--- /dev/null
+++ b/src/features/named-agents/logic/normalize.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeNamedAgent,
+ normalizeNamedAgentRecord,
+ normalizeNamedAgents,
+ toNamedAgentInput,
+} from "./normalize";
+import type { NamedAgent } from "./types";
+
+describe("normalizeNamedAgent", () => {
+ it("coerces a well-formed record", () => {
+ expect(normalizeNamedAgent({ id: "x", name: "Coding", model: "opencode-1/claude" })).toEqual({
+ id: "x",
+ name: "Coding",
+ model: "opencode-1/claude",
+ });
+ });
+
+ it("rejects a record missing any field", () => {
+ expect(normalizeNamedAgent({ id: "x", name: "Coding" })).toBeNull();
+ expect(normalizeNamedAgent({ name: "Coding", model: "m" })).toBeNull();
+ expect(normalizeNamedAgent(null)).toBeNull();
+ });
+});
+
+describe("normalizeNamedAgents", () => {
+ it("coerces a list body", () => {
+ const agents: readonly NamedAgent[] = normalizeNamedAgents({
+ namedAgents: [
+ { id: "a", name: "Coding", model: "opencode-1/claude" },
+ { id: "b", name: "Research", model: "umans/glm" },
+ ],
+ });
+ expect(agents).toHaveLength(2);
+ expect(agents[0]?.name).toBe("Coding");
+ });
+
+ it("drops malformed entries and survives a missing list key", () => {
+ expect(
+ normalizeNamedAgents({
+ namedAgents: [
+ { id: "a", name: "x" },
+ { id: "b", name: "y", model: "m" },
+ ],
+ }).map((a) => a.id),
+ ).toEqual(["b"]);
+ expect(normalizeNamedAgents({})).toEqual([]);
+ expect(normalizeNamedAgents(null)).toEqual([]);
+ });
+});
+
+describe("normalizeNamedAgentRecord", () => {
+ it("reads the canonical saved record", () => {
+ expect(
+ normalizeNamedAgentRecord({
+ namedAgent: { id: "a", name: "Coding", model: "opencode-1/claude" },
+ })?.id,
+ ).toBe("a");
+ });
+
+ it("returns null for a malformed record", () => {
+ expect(normalizeNamedAgentRecord({ namedAgent: {} })).toBeNull();
+ expect(normalizeNamedAgentRecord({})).toBeNull();
+ });
+});
+
+describe("toNamedAgentInput", () => {
+ it("trims a name and keeps the model", () => {
+ expect(toNamedAgentInput(" Coding ", "opencode-1/claude")).toEqual({
+ name: "Coding",
+ model: "opencode-1/claude",
+ });
+ });
+
+ it("defaults non-strings to empty", () => {
+ expect(toNamedAgentInput(null, 42)).toEqual({ name: "", model: "" });
+ });
+});
diff --git a/src/features/named-agents/logic/normalize.ts b/src/features/named-agents/logic/normalize.ts
new file mode 100644
index 0000000..c1acec2
--- /dev/null
+++ b/src/features/named-agents/logic/normalize.ts
@@ -0,0 +1,68 @@
+/**
+ * Pure network-seam normalizers for the Named Agents API — zero DOM, zero
+ * effects. The composition root coerces the UNTYPED JSON it receives from
+ * `GET/POST/PUT/DELETE /named-agents` through these so a malformed / partial
+ * response can never crash the renderer. Mirrors the concurrency / heartbeat
+ * network-seam guard pattern.
+ */
+
+import type { NamedAgent, NamedAgentInput } from "./types";
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}
+
+function asString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+/**
+ * Coerce one untrusted record into a typed `NamedAgent`, or `null` when it is
+ * malformed (missing `id`, `name`, or `model` — or a non-string `model`).
+ */
+export function normalizeNamedAgent(value: unknown): NamedAgent | null {
+ if (!isRecord(value)) return null;
+ const id = asString(value.id);
+ const name = asString(value.name);
+ const model = asString(value.model);
+ if (id === null || name === null || model === null) return null;
+ return { id, name, model };
+}
+
+/**
+ * Coerce an untrusted `GET /named-agents` body into a typed Named Agent list.
+ * When the list key is absent/malformed, returns `[]` (defensive — the renderer
+ * shows the empty state rather than crashing).
+ */
+export function normalizeNamedAgents(data: unknown): readonly NamedAgent[] {
+ if (!isRecord(data) || !Array.isArray(data.namedAgents)) return [];
+ const out: NamedAgent[] = [];
+ for (const entry of data.namedAgents as readonly unknown[]) {
+ const agent = normalizeNamedAgent(entry);
+ if (agent !== null) out.push(agent);
+ }
+ return out;
+}
+
+/**
+ * Coerce an untrusted `POST`/`PUT /named-agents` body (the canonical saved
+ * record) into a typed `NamedAgent`, or `null` when malformed. The composition
+ * root surfaces an error when this returns null on an ok response.
+ */
+export function normalizeNamedAgentRecord(data: unknown): NamedAgent | null {
+ if (!isRecord(data)) return null;
+ return normalizeNamedAgent(data.namedAgent);
+}
+
+/**
+ * Coerce an untrusted `NamedAgentInput` at the create/update boundary so only
+ * clean strings reach the wire. Non-string fields drop to "" (name nonempty is
+ * validated separately; model nonempty too). The caller still runs name/model
+ * validation so a malformed body never reaches the backend.
+ */
+export function toNamedAgentInput(name: unknown, model: unknown): NamedAgentInput {
+ return {
+ name: typeof name === "string" ? name.trim() : "",
+ model: typeof model === "string" ? model : "",
+ };
+}
diff --git a/src/features/named-agents/logic/options.test.ts b/src/features/named-agents/logic/options.test.ts
new file mode 100644
index 0000000..8b774ab
--- /dev/null
+++ b/src/features/named-agents/logic/options.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import {
+ fullModelFromSelection,
+ modelFromFull,
+ modelsForProvider,
+ providerFromModel,
+ providerLabel,
+ providerOptions,
+} from "./options";
+
+const MODELS = [
+ "opencode-2/gpt-5",
+ "opencode-1/claude-sonnet-4.5",
+ "umans/umans-glm-5.2",
+ "opencode-10/defiant",
+ "umans/umans-gpt-5",
+ "hyper/fast-1",
+] as const;
+
+describe("providerFromModel / modelFromFull", () => {
+ it("splits a <provider>/<model> on the first slash", () => {
+ expect(providerFromModel("opencode-1/claude-sonnet-4.5")).toBe("opencode-1");
+ expect(modelFromFull("opencode-1/claude-sonnet-4.5")).toBe("claude-sonnet-4.5");
+ });
+
+ it("treats a slashless name as all-provider", () => {
+ expect(providerFromModel("umans")).toBe("umans");
+ expect(modelFromFull("umans")).toBe("");
+ });
+});
+
+describe("providerOptions", () => {
+ it("sorts numbered OpenCode credentials numerically before other providers", () => {
+ expect(providerOptions([...MODELS])).toEqual([
+ "opencode-1",
+ "opencode-2",
+ "opencode-10",
+ "umans",
+ "hyper",
+ ]);
+ });
+
+ it("keeps first-seen order and dedupes", () => {
+ expect(providerOptions(["b/x", "a/y", "b/z"])).toEqual(["b", "a"]);
+ });
+});
+
+describe("modelsForProvider", () => {
+ it("returns only the model suffixes under the provider, in order", () => {
+ expect(modelsForProvider([...MODELS], "umans")).toEqual(["umans-glm-5.2", "umans-gpt-5"]);
+ expect(modelsForProvider([...MODELS], "hyper")).toEqual(["fast-1"]);
+ });
+
+ it("returns [] for an unknown provider", () => {
+ expect(modelsForProvider([...MODELS], "missing")).toEqual([]);
+ });
+});
+
+describe("providerLabel", () => {
+ it("labels numbered OpenCode providers, leaves others unchanged", () => {
+ expect(providerLabel("opencode-2")).toBe("OpenCode #2");
+ expect(providerLabel("umans")).toBe("umans");
+ });
+});
+
+describe("fullModelFromSelection", () => {
+ it("recombines a provider + model suffix", () => {
+ expect(fullModelFromSelection("opencode-1", "claude-sonnet-4.5")).toBe(
+ "opencode-1/claude-sonnet-4.5",
+ );
+ });
+});
diff --git a/src/features/named-agents/logic/options.ts b/src/features/named-agents/logic/options.ts
new file mode 100644
index 0000000..0946cbe
--- /dev/null
+++ b/src/features/named-agents/logic/options.ts
@@ -0,0 +1,84 @@
+/**
+ * Pure provider/model dropdown helpers for the Named Agents editor — zero DOM.
+ *
+ * A Named Agent's persisted `model` is the canonical `<provider>/<model>`
+ * identifier, but the edit UI splits it into a provider dropdown + a model
+ * dropdown (plan §3/§5.2). These helpers derive those dropdowns from the raw
+ * `GET /models` catalog — the SAME key grouping the regular model picker uses.
+ * Catalog latency is acceptable here (human configuration), and it must not
+ * affect the agent-facing list/summon hot paths (plan §9).
+ */
+
+/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */
+export function providerFromModel(full: string): string {
+ const i = full.indexOf("/");
+ return i === -1 ? full : full.slice(0, i);
+}
+
+/** The model suffix of a `<provider>/<model>` name (the part after the first `/`; empty when slashless). */
+export function modelFromFull(full: string): string {
+ const i = full.indexOf("/");
+ return i === -1 ? "" : full.slice(i + 1);
+}
+
+/** Recombine a provider + model suffix into a canonical `<provider>/<model>` name (provider-only if no model). */
+export function joinModel(provider: string, model: string): string {
+ return model === "" ? provider : `${provider}/${model}`;
+}
+
+/**
+ * Distinct provider ids across the catalog, in first-seen order, with numbered
+ * OpenCode credentials sorted NUMERICALLY before other providers (the same
+ * grouping the regular model picker uses — `opencode-2` before `opencode-10`,
+ * then everything else). A friendly display label is produced separately so the
+ * RAW id stays what gets persisted.
+ */
+export function providerOptions(models: readonly string[]): string[] {
+ const seen = new Set<string>();
+ const openCodeKeys: Array<{ key: string; n: number }> = [];
+ const otherKeys: string[] = [];
+ for (const full of models) {
+ const p = providerFromModel(full);
+ if (p === "" || seen.has(p)) continue;
+ seen.add(p);
+ const n = openCodeNumber(p);
+ if (n !== null) openCodeKeys.push({ key: p, n });
+ else otherKeys.push(p);
+ }
+ openCodeKeys.sort((a, b) => a.n - b.n);
+ return [...openCodeKeys.map((e) => e.key), ...otherKeys];
+}
+
+/** The numeric identity of a numbered OpenCode provider (`opencode-1` → 1), or null. */
+export function openCodeNumber(key: string): number | null {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(key);
+ return m === null ? null : Number(m[1]);
+}
+
+/**
+ * Friendly display label for a provider id: `opencode-<n>` → "OpenCode #<n>";
+ * any other id unchanged. Presentation only — the RAW id is what gets persisted.
+ */
+export function providerLabel(provider: string): string {
+ const n = openCodeNumber(provider);
+ return n === null ? provider : `OpenCode #${n}`;
+}
+
+/** The model suffixes available under a given provider, in catalog order. */
+export function modelsForProvider(models: readonly string[], provider: string): string[] {
+ const out: string[] = [];
+ for (const full of models) {
+ if (providerFromModel(full) === provider) out.push(modelFromFull(full));
+ }
+ return out;
+}
+
+/**
+ * The model suffixes in the catalog that a Named Agent's model suffix may match.
+ * A saved Named Agent may reference a provider/model pair no longer present in a
+ * live catalog (e.g. a credential was removed); the editor then shows the saved
+ * RAW full name as an unavailable selection rather than silently rewriting it.
+ */
+export function fullModelFromSelection(provider: string, model: string): string {
+ return joinModel(provider, model);
+}
diff --git a/src/features/named-agents/logic/snapshot.test.ts b/src/features/named-agents/logic/snapshot.test.ts
new file mode 100644
index 0000000..3f1b5cf
--- /dev/null
+++ b/src/features/named-agents/logic/snapshot.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { findNamedAgent, snapshotFor } from "./snapshot";
+import type { NamedAgent } from "./types";
+
+const AGENTS: readonly NamedAgent[] = [
+ { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "b2", name: "Research Agent", model: "umans/umans-glm-5.2" },
+];
+
+describe("snapshotFor", () => {
+ it("builds a snapshot carrying id, name, and the raw model", () => {
+ const coding = AGENTS[0];
+ if (coding === undefined) throw new Error("test fixture missing");
+ expect(coding).toBeDefined();
+ expect(snapshotFor(coding)).toEqual({
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+ });
+});
+
+describe("findNamedAgent", () => {
+ it("finds by stable id", () => {
+ expect(findNamedAgent(AGENTS, "b2")?.name).toBe("Research Agent");
+ });
+
+ it("finds by name case-insensitively", () => {
+ expect(findNamedAgent(AGENTS, "coding agent")?.id).toBe("a1");
+ });
+
+ it("returns undefined for an unknown id/name", () => {
+ expect(findNamedAgent(AGENTS, "nope")).toBeUndefined();
+ });
+});
diff --git a/src/features/named-agents/logic/snapshot.ts b/src/features/named-agents/logic/snapshot.ts
new file mode 100644
index 0000000..482ceb3
--- /dev/null
+++ b/src/features/named-agents/logic/snapshot.ts
@@ -0,0 +1,33 @@
+/**
+ * Pure snapshot helpers for Named Agent selection (plan §7).
+ *
+ * Selecting a Named Agent for a conversation resolves it to its raw model AND
+ * records a `NamedAgentSelectionSnapshot` ({namedAgentId, namedAgentName, model})
+ * so the conversation retains how it was selected. Later edits to the shared
+ * Named Agent (rename / model change / removal) therefore do NOT retroactively
+ * change an existing conversation — the snapshot is what the UI keeps showing.
+ */
+
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "./types";
+
+/** Build the snapshot a conversation would retain for a Named Agent selection. */
+export function snapshotFor(agent: NamedAgent): NamedAgentSelectionSnapshot {
+ return {
+ namedAgentId: agent.id,
+ namedAgentName: agent.name,
+ model: agent.model,
+ };
+}
+
+/**
+ * Find a Named Agent by stable id or (case-insensitively) by name. Used to
+ * resolve a name/id picked in the model selector back to its current definition.
+ * Returns undefined when unknown (a stale name/id after deletion).
+ */
+export function findNamedAgent(
+ agents: readonly NamedAgent[],
+ idOrName: string,
+): NamedAgent | undefined {
+ const lower = idOrName.trim().toLowerCase();
+ return agents.find((a) => a.id === idOrName || a.name.trim().toLowerCase() === lower);
+}
diff --git a/src/features/named-agents/logic/types.ts b/src/features/named-agents/logic/types.ts
new file mode 100644
index 0000000..2e4d4d3
--- /dev/null
+++ b/src/features/named-agents/logic/types.ts
@@ -0,0 +1,57 @@
+/**
+ * Pure shared types for the Named Agents feature — zero DOM, zero effects,
+ * zero Svelte.
+ *
+ * The shared shapes — `NamedAgent`, `NamedAgentSelectionSnapshot`, and the
+ * `/named-agents` HTTP request/response types — are RE-EXPORTED from the
+ * backend-contract packages (`@dispatch/wire`, `@dispatch/transport-contract`),
+ * which now carry them (the backend shipped the named-agents contract). The FE
+ * consumes those packages as the cross-repo seam; only the FE's port/result
+ * types are defined locally.
+ */
+
+import type {
+ CreateNamedAgentRequest,
+ DeleteNamedAgentResponse,
+ NamedAgentResponse,
+ NamedAgentsResponse,
+ UpdateNamedAgentRequest,
+} from "@dispatch/transport-contract";
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "@dispatch/wire";
+
+export type {
+ DeleteNamedAgentResponse,
+ NamedAgent,
+ NamedAgentResponse,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResponse,
+ UpdateNamedAgentRequest,
+};
+
+/** The subset of a Named Agent a client creates/updates (plan §9 — id is server-minted). */
+export type NamedAgentInput = CreateNamedAgentRequest;
+
+// ── Ports (injected effects — the composition root supplies real responders) ──
+
+/** Outcome of listing Named Agents. */
+export type NamedAgentsResult =
+ | { readonly ok: true; readonly agents: readonly NamedAgent[] }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of creating / updating a Named Agent (the canonical saved record). */
+export type NamedAgentSaveResult =
+ | { readonly ok: true; readonly agent: NamedAgent }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of removing a Named Agent. */
+export type NamedAgentDeleteResult =
+ | { readonly ok: true }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadNamedAgents = () => Promise<NamedAgentsResult>;
+export type CreateNamedAgent = (input: NamedAgentInput) => Promise<NamedAgentSaveResult>;
+export type UpdateNamedAgent = (
+ id: string,
+ input: NamedAgentInput,
+) => Promise<NamedAgentSaveResult>;
+export type DeleteNamedAgent = (id: string) => Promise<NamedAgentDeleteResult>;
diff --git a/src/features/named-agents/logic/validate.test.ts b/src/features/named-agents/logic/validate.test.ts
new file mode 100644
index 0000000..d647354
--- /dev/null
+++ b/src/features/named-agents/logic/validate.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { isUsableModel, isUsableName, nameError, normalizeName } from "./validate";
+
+describe("normalizeName", () => {
+ it("trims surrounding whitespace", () => {
+ expect(normalizeName(" Coding ")).toBe("Coding");
+ });
+
+ it("collapses internal runs of whitespace to one space", () => {
+ expect(normalizeName(" Research Agent ")).toBe("Research Agent");
+ });
+
+ it("collapses an all-whitespace name to empty", () => {
+ expect(normalizeName(" ")).toBe("");
+ });
+});
+
+describe("nameError", () => {
+ it("returns null for a nonempty unique name", () => {
+ expect(nameError("Coding", ["Research", "Fast"])).toBeNull();
+ });
+
+ it("flags an empty/normalized-empty name", () => {
+ expect(nameError("", ["Research"])).toBe("Name is required.");
+ expect(nameError(" ", ["Research"])).toBe("Name is required.");
+ });
+
+ it("flags a name duplicate case-insensitively", () => {
+ expect(nameError("coding", ["Coding"])).toBe("A named agent with this name already exists.");
+ expect(nameError("FAST", ["Fast"])).toBe("A named agent with this name already exists.");
+ });
+
+ it("ignores whitespace when comparing uniqueness", () => {
+ expect(nameError(" coding ", ["Coding "])).toBe(
+ "A named agent with this name already exists.",
+ );
+ });
+});
+
+describe("isUsableName", () => {
+ it("is true only for a unique nonempty name", () => {
+ expect(isUsableName("Coding", ["Research"])).toBe(true);
+ expect(isUsableName("coding", ["Coding"])).toBe(false);
+ expect(isUsableName("", [])).toBe(false);
+ });
+});
+
+describe("isUsableModel", () => {
+ it("accepts a canonical provider/model pair", () => {
+ expect(isUsableModel("opencode-1/claude-sonnet-4.5")).toBe(true);
+ });
+
+ it("rejects a slashless / provider-only value", () => {
+ expect(isUsableModel("opencode-1")).toBe(false);
+ expect(isUsableModel("/claude")).toBe(false);
+ expect(isUsableModel("")).toBe(false);
+ });
+});
diff --git a/src/features/named-agents/logic/validate.ts b/src/features/named-agents/logic/validate.ts
new file mode 100644
index 0000000..c25524a
--- /dev/null
+++ b/src/features/named-agents/logic/validate.ts
@@ -0,0 +1,47 @@
+/**
+ * Pure validation for Named Agent form fields — zero DOM, zero effects.
+ *
+ * A Named Agent is usable only when (plan §5.4): the name is nonempty + unique
+ * case-insensitively, and a provider + model are selected. These pure helpers
+ * drive the card UI's validity gating + inline messages; the backend remains
+ * the authority (it 400s/conflicts on a duplicate or empty name / malformed
+ * model identifier).
+ */
+
+/** Trim + collapse runs of whitespace inside a name (survey-style display). */
+export function normalizeName(raw: string): string {
+ return raw.trim().replace(/\s+/g, " ");
+}
+
+/**
+ * A name-validation error message, or `null` when the name is valid. `otherNames`
+ * are the OTHER agents' names for the case-insensitive uniqueness check (the
+ * caller EXCLUDES the card's own last-persisted name / the card being edited).
+ */
+export function nameError(name: string, otherNames: readonly string[]): string | null {
+ const normalized = normalizeName(name);
+ if (normalized.length === 0) return "Name is required.";
+ const lower = normalized.toLowerCase();
+ for (const other of otherNames) {
+ if (other.trim().toLowerCase() === lower) return "A named agent with this name already exists.";
+ }
+ return null;
+}
+
+/** True iff `name` is a unique, nonempty agent name among `otherNames`. */
+export function isUsableName(name: string, otherNames: readonly string[]): boolean {
+ return nameError(name, otherNames) === null;
+}
+
+/**
+ * Whether a full model identifier is syntactically valid — a canonical
+ * `<provider>/<model>` with a nonempty provider (the part before the first "/")
+ * AND a nonempty model (the part after). A slashless or provider-only value is
+ * invalid for a Named Agent (it must resolve to a concrete model).
+ */
+export function isUsableModel(full: string): boolean {
+ const i = full.indexOf("/");
+ if (i <= 0) return false; // no slash, or empty provider
+ const model = full.slice(i + 1);
+ return model.length > 0;
+}
diff --git a/src/features/named-agents/ui/NamedAgentCard.svelte b/src/features/named-agents/ui/NamedAgentCard.svelte
new file mode 100644
index 0000000..b81c59c
--- /dev/null
+++ b/src/features/named-agents/ui/NamedAgentCard.svelte
@@ -0,0 +1,120 @@
+<script lang="ts">
+ import type { NamedAgent } from "../logic/types";
+ import type { CardController } from "./controller.svelte";
+ import {
+ modelsForProvider,
+ providerLabel,
+ providerOptions,
+ fullModelFromSelection,
+ } from "../logic/options";
+ import { draftValidity } from "../logic/auto-save";
+ import { saveStatusLabel } from "../logic/auto-save";
+
+ let {
+ models,
+ agent,
+ controller,
+ otherNames,
+ }: {
+ /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */
+ models: readonly string[];
+ /** The persisted agent this card edits, or null for a new draft. */
+ agent: NamedAgent | null;
+ /** The card's auto-save controller (draft + status + save lifecycle). */
+ controller: CardController;
+ /** The OTHER agents' names (excludes this card) for the uniqueness hint. */
+ otherNames: readonly string[];
+ } = $props();
+
+ const providers = $derived(providerOptions(models));
+ const providerModels = $derived(modelsForProvider(models, controller.draft.provider));
+ // The full model string a provider+model selection maps to (for the "unavailable"
+ // fallback when the saved model leaves the catalog).
+ const fullModel = $derived(
+ fullModelFromSelection(controller.draft.provider, controller.draft.model),
+ );
+ const validity = $derived(draftValidity(controller.draft, otherNames));
+ const statusLabel = $derived(saveStatusLabel(controller.save.status));
+ // Whether the current provider+model actually resolves inside the catalog.
+ const selectedResolves = $derived(
+ controller.draft.provider !== "" &&
+ modelsForProvider(models, controller.draft.provider).includes(controller.draft.model),
+ );
+</script>
+
+<div class="flex flex-col gap-1 rounded-box bg-base-200 p-3">
+ <span class="text-[10px] uppercase opacity-60">Name</span>
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full"
+ placeholder="Enter a name…"
+ aria-label="Named agent name"
+ value={controller.draft.name}
+ oninput={(e) => controller.setName(e.currentTarget.value)}
+ />
+
+ <span class="mt-1 text-[10px] uppercase opacity-60">Provider</span>
+ <select
+ class="select select-bordered select-sm w-full"
+ aria-label="Named agent provider"
+ value={controller.draft.provider}
+ onchange={(e) => controller.setProvider(e.currentTarget.value)}
+ >
+ {#if controller.draft.provider === ""}
+ <option value="" disabled>Select provider…</option>
+ {/if}
+ {#each providers as provider (provider)}
+ <option value={provider}>{providerLabel(provider)}</option>
+ {/each}
+ </select>
+
+ <span class="mt-1 text-[10px] uppercase opacity-60">Model</span>
+ <select
+ class="select select-bordered select-sm w-full"
+ aria-label="Named agent model"
+ value={controller.draft.model}
+ disabled={controller.draft.provider === ""}
+ onchange={(e) => controller.setModel(e.currentTarget.value)}
+ >
+ {#if controller.draft.provider === ""}
+ <option value="" disabled>Select provider first</option>
+ {:else if controller.draft.model === ""}
+ <option value="" disabled>Select a model…</option>
+ {/if}
+ {#each providerModels as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ {#if !selectedResolves && controller.draft.model !== ""}
+ <!-- The saved selection is no longer in the live catalog (its provider/
+ credential was removed) — keep showing it as unavailable rather than
+ silently rewriting it. -->
+ <option value={controller.draft.model} disabled>
+ {fullModel} (unavailable)
+ </option>
+ {/if}
+ </select>
+
+ {#if !validity.usable && validity.message !== null}
+ <p class="mt-1 text-xs text-warning" role="status">{validity.message}</p>
+ {/if}
+
+ <div class="mt-2 flex items-center justify-between gap-2">
+ <span
+ class="text-xs"
+ class:text-error={controller.save.status === "error" && controller.save.error !== null}
+ class:opacity-60={controller.save.status !== "saving" && controller.save.status !== "error"}
+ role="status"
+ >
+ {statusLabel}{#if controller.save.error !== null}: {controller.save.error}{/if}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs text-error"
+ aria-label="Remove named agent"
+ disabled={controller.saving}
+ onclick={() => controller.remove()}
+ >
+ Remove
+ </button>
+ </div>
+</div> \ No newline at end of file
diff --git a/src/features/named-agents/ui/NamedAgentsView.svelte b/src/features/named-agents/ui/NamedAgentsView.svelte
new file mode 100644
index 0000000..743ac20
--- /dev/null
+++ b/src/features/named-agents/ui/NamedAgentsView.svelte
@@ -0,0 +1,126 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { NamedAgent } from "../logic/types";
+ import type { NamedAgentPorts } from "./controller.svelte";
+ import { createCardController, type CardController } from "./controller.svelte";
+ import NamedAgentCard from "./NamedAgentCard.svelte";
+
+ interface Row {
+ key: number;
+ agent: NamedAgent | null;
+ controller: CardController;
+ }
+
+ let {
+ agents,
+ models,
+ ports,
+ }: {
+ /** The persisted Named Agents (seed for the cards; the store stays authoritative for consumers). */
+ agents: readonly NamedAgent[];
+ /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */
+ models: readonly string[];
+ ports: NamedAgentPorts;
+ } = $props();
+
+ // The view OWNS the editable card rows (each a controller + its last-saved
+ // canonical agent). Seeded once from the `agents` snapshot on mount (untracked
+ // so a later store update — which this view drove — never clobbers an
+ // in-flight draft). The store remains authoritative for other consumers.
+ let rows = $state<Row[]>([]);
+ let nextKey = $state(0);
+ let seeded = $state(false);
+
+ $effect(() => {
+ untrack(() => {
+ if (seeded) return;
+ seeded = true;
+ for (const a of agents) addRow(a);
+ });
+ });
+
+ /**
+ * The other rows' names (excluding the row with `rowKey`) for the uniqueness
+ * hint. Identity is the ROW's stable `key` — a draft (`agent === null`) must
+ * still exclude ITSELF (otherwise a fresh draft collides with its own name),
+ * while any OTHER draft is a genuine sibling.
+ */
+ function otherNamesOf(rowKey: number): readonly string[] {
+ const out: string[] = [];
+ for (const r of rows) {
+ if (r.key === rowKey) continue;
+ out.push(r.agent !== null ? r.agent.name : r.controller.draft.name);
+ }
+ return out;
+ }
+
+ /**
+ * Live uniqueness predicate for a card: true when `name` collides with a
+ * SIBLING row (excludes the card's own row by its stable `rowKey` — not by a
+ * nullable persisted id, so a draft excludes itself exactly once).
+ */
+ function isNameTakenFor(rowKey: number): (name: string) => boolean {
+ return (name: string): boolean => {
+ const lower = name.trim().toLowerCase();
+ for (const r of rows) {
+ if (r.key === rowKey) continue;
+ const candidate = r.agent !== null ? r.agent.name : r.controller.draft.name;
+ if (candidate.trim().toLowerCase() === lower) return true;
+ }
+ return false;
+ };
+ }
+
+ function addRow(agent: NamedAgent | null): void {
+ // Capture the row's stable key FIRST so the controller's callbacks reconcile
+ // THIS row only — never a sibling draft (`agent === null` matches all).
+ const key = nextKey++;
+ const controller = createCardController({
+ agent,
+ savedModel: agent?.model ?? "",
+ isNameTaken: isNameTakenFor(key),
+ ports,
+ onChanged: (saved) => replaceAgent(key, saved),
+ onRemoved: () => removeRow(key),
+ });
+ rows = [...rows, { key, agent, controller }];
+ }
+
+ function replaceAgent(key: number, saved: NamedAgent): void {
+ rows = rows.map((r) => (r.key === key ? { ...r, agent: saved } : r));
+ }
+
+ function removeRow(key: number): void {
+ const row = rows.find((r) => r.key === key);
+ if (row === undefined) return;
+ row.controller.dispose();
+ rows = rows.filter((r) => r.key !== key);
+ }
+
+ function addDraft(): void {
+ addRow(null);
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <ul class="flex flex-col gap-2">
+ {#each rows as row (row.key)}
+ <li>
+ <NamedAgentCard
+ models={models}
+ agent={row.agent}
+ controller={row.controller}
+ otherNames={otherNamesOf(row.key)}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ {#if rows.length === 0}
+ <p class="text-xs opacity-60">No named agents yet.</p>
+ {/if}
+
+ <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={addDraft}>
+ + Add Named Agent
+ </button>
+</div> \ No newline at end of file
diff --git a/src/features/named-agents/ui/NamedAgentsView.test.ts b/src/features/named-agents/ui/NamedAgentsView.test.ts
new file mode 100644
index 0000000..bac4e25
--- /dev/null
+++ b/src/features/named-agents/ui/NamedAgentsView.test.ts
@@ -0,0 +1,181 @@
+import { render, screen } from "@testing-library/svelte";
+import type { UserEvent } from "@testing-library/user-event";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type {
+ CreateNamedAgent,
+ DeleteNamedAgent,
+ NamedAgentInput,
+ UpdateNamedAgent,
+} from "../logic/types";
+import type { NamedAgentPorts } from "./controller.svelte";
+import NamedAgentsView from "./NamedAgentsView.svelte";
+
+// ── Fakes for the injected ports (ONLY the outermost edge — no sibling module
+// is mocked). A modest `debounceMs` keeps name edits coalesced into one save
+// (with 0, each keystroke fires its own update) while provider/model saves stay
+// immediate (the model setter dispatches synchronously).
+function makePorts(): {
+ ports: NamedAgentPorts;
+ created: NamedAgentInput[];
+ updated: { id: string; input: NamedAgentInput }[];
+ removed: string[];
+} {
+ const created: NamedAgentInput[] = [];
+ const updated: { id: string; input: NamedAgentInput }[] = [];
+ const removed: string[] = [];
+ const ports: NamedAgentPorts = {
+ create: (async (input) => {
+ created.push(input);
+ return { ok: true, agent: { id: `new-${created.length}`, ...input } };
+ }) as CreateNamedAgent,
+ update: (async (id, input) => {
+ updated.push({ id, input });
+ return { ok: true, agent: { id, ...input } };
+ }) as UpdateNamedAgent,
+ remove: (async (id) => {
+ removed.push(id);
+ return { ok: true } as const;
+ }) as DeleteNamedAgent,
+ debounceMs: 150,
+ };
+ return { ports, created, updated, removed };
+}
+
+const MODELS = ["opencode-1/claude-sonnet-4.5", "anthropic/sonnet"] as unknown as readonly string[];
+
+const DUP_MESSAGE = "A named agent with this name already exists.";
+
+function renderView() {
+ const fakes = makePorts();
+ render(NamedAgentsView, { props: { agents: [], models: MODELS, ports: fakes.ports } });
+ return fakes;
+}
+
+async function addDraft(user: UserEvent): Promise<void> {
+ await user.click(screen.getByRole("button", { name: "+ Add Named Agent" }));
+}
+
+async function typeName(index: number, name: string): Promise<void> {
+ const inputs = screen.getAllByLabelText("Named agent name");
+ const input = inputs[index];
+ if (input === undefined) throw new Error(`No named-agent name input at index ${index}`);
+ await userEvent.setup().clear(input);
+ await userEvent.setup().type(input, name);
+}
+
+async function completeDraft(index: number): Promise<void> {
+ const user = userEvent.setup();
+ // Provider first (the model select is disabled until a provider is chosen).
+ const providerSelect = screen.getAllByLabelText("Named agent provider")[index];
+ if (providerSelect === undefined) throw new Error(`No provider select at index ${index}`);
+ await user.selectOptions(providerSelect, "opencode-1");
+ const modelSelect = screen.getAllByLabelText("Named agent model")[index];
+ if (modelSelect === undefined) throw new Error(`No model select at index ${index}`);
+ await user.selectOptions(modelSelect, "claude-sonnet-4.5");
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("NamedAgentsView", () => {
+ it("a fresh single draft does not self-collide with its own name", async () => {
+ renderView();
+ await addDraft(userEvent.setup());
+ await typeName(0, "Coding Agent");
+
+ // The card is not yet usable (no model) so it shows a model hint, but it
+ // must NOT report its own draft name as an existing duplicate.
+ await screen.findByText("Select a provider.");
+ expect(screen.queryByText(DUP_MESSAGE)).toBeNull();
+ });
+
+ it("two drafts with DISTINCT names neither self-collide", async () => {
+ const user = userEvent.setup();
+ renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await addDraft(user);
+ await typeName(1, "Writer");
+
+ // Each must see the OTHER's name as an other-name, never its OWN.
+ expect(screen.queryByText(DUP_MESSAGE)).toBeNull();
+ });
+
+ it("two drafts with the SAME name are a genuine sibling collision (each flags it)", async () => {
+ const user = userEvent.setup();
+ renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await addDraft(user);
+ await typeName(1, "Coding Agent");
+
+ // Duplicate names among DISTINCT rows are invalid — both rows must report it
+ // (the self is excluded, but the sibling's identical name is a real collision).
+ expect((await screen.findAllByText(DUP_MESSAGE)).length).toBe(2);
+ });
+
+ it("saving one of multiple drafts persists only that row (distinct records)", async () => {
+ const user = userEvent.setup();
+ const fakes = renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await addDraft(user);
+ await typeName(1, "Writer");
+
+ // Both drafts complete to usable cards → each dispatches its OWN create.
+ await completeDraft(0);
+ await completeDraft(1);
+
+ await vi.waitFor(() => expect(fakes.created).toHaveLength(2));
+ expect(fakes.created[0]?.name).toBe("Coding Agent");
+ expect(fakes.created[1]?.name).toBe("Writer");
+ // Each persisted record got its own server-minted id (a single draft must
+ // not clobber its sibling's reconciling to the same record).
+ expect(await screen.findAllByText("Saved")).toHaveLength(2);
+ });
+
+ it("after a draft persists, a sibling draft does not self-collide and the saved row re-saves on edit", async () => {
+ const user = userEvent.setup();
+ const fakes = renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await completeDraft(0);
+ await vi.waitFor(() => expect(fakes.created).toHaveLength(1));
+
+ // Add a second draft with a DIFFERENT name. After row 0 is persisted, this
+ // still-draft row must not treat its own name as a duplicate.
+ await addDraft(user);
+ await typeName(1, "Writer");
+ expect(screen.queryByText(DUP_MESSAGE)).toBeNull();
+
+ // The saved card's controller now owns a real id → a later edit targets an
+ // UPDATE of the SAME row (id new-1), never a fresh create.
+ await typeName(0, "Coding Agent X");
+ await vi.waitFor(() => expect(fakes.updated.length).toBeGreaterThanOrEqual(1));
+ expect(fakes.updated[0]?.id).toBe("new-1");
+ expect(fakes.updated[0]?.input.name).toBe("Coding Agent X");
+ // Still exactly one create — the edit reconciled, not re-created.
+ expect(fakes.created).toHaveLength(1);
+ });
+
+ it("removing one of multiple drafts removes only that row", async () => {
+ const user = userEvent.setup();
+ renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await addDraft(user);
+ await typeName(1, "Writer");
+
+ // Remove the SECOND card — the first must survive.
+ const removeButtons = screen.getAllByRole("button", { name: "Remove named agent" });
+ const second = removeButtons[1];
+ if (second === undefined) throw new Error("Expected a second Remove button");
+ await user.click(second);
+
+ const inputs = await screen.findAllByLabelText("Named agent name");
+ expect(inputs).toHaveLength(1);
+ expect((inputs[0] as HTMLInputElement).value).toBe("Coding Agent");
+ });
+});
diff --git a/src/features/named-agents/ui/controller.svelte.ts b/src/features/named-agents/ui/controller.svelte.ts
new file mode 100644
index 0000000..51c85b0
--- /dev/null
+++ b/src/features/named-agents/ui/controller.svelte.ts
@@ -0,0 +1,257 @@
+// Injected shell for Named Agent auto-save: owns the reactive per-card editing
+// + save lifecycle (the thin rune wrapper over `logic/auto-save`), the debounce
+// timer for name edits, and the monotonic sequence guard that stops an older
+// save response clobbering a newer edit. No ambient state: ONE controller per
+// card, created by the view and disposed on unmount. The network calls
+// (create/update/delete) are INJECTED ports — this module never touches
+// fetch/DOM.
+//
+// The pure model (`logic/auto-save`) does the status/validity reasoning; this
+// controller only owns the effectful plumbing + the sequence guard. A live
+// `isNameTaken` callback (supplied by the owning view against the CURRENT
+// sibling list) keeps the controller from persisting a duplicate name.
+
+import {
+ type AgentDraft,
+ type AgentSaved,
+ draftModel,
+ type SaveStatus,
+ saveStatus,
+} from "../logic/auto-save";
+import type {
+ CreateNamedAgent,
+ DeleteNamedAgent,
+ NamedAgent,
+ NamedAgentInput,
+ UpdateNamedAgent,
+} from "../logic/types";
+import { isUsableModel, normalizeName } from "../logic/validate";
+
+/** Named-agent edit port bundle (injected by the composition root). */
+export interface NamedAgentPorts {
+ create: CreateNamedAgent;
+ update: UpdateNamedAgent;
+ remove: DeleteNamedAgent;
+ /** Debounce delay (ms) for NAME edits only (provider/model saves are immediate). */
+ debounceMs?: number;
+}
+
+export interface CardSaveState {
+ /** The current reactive save status (drives the status line). */
+ readonly status: SaveStatus;
+ /** A save-failure error message, or null. */
+ readonly error: string | null;
+}
+
+/** A fully-wired Named Agent card controller (one per card row). */
+export interface CardController {
+ /** The current reactive status + error. */
+ readonly save: CardSaveState;
+ /** Read the current draft (const snapshots already applied via the setter methods). */
+ readonly draft: AgentDraft;
+ /** Whether the card is currently awaiting a save response. */
+ readonly saving: boolean;
+ /** Bind a provider id into the draft (immediate save once a model is chosen). */
+ setProvider(provider: string): void;
+ /** Bind a model suffix under the current provider (immediate save). */
+ setModel(model: string): void;
+ /** Bind a raw name edit (debounced save when it becomes usable). */
+ setName(name: string): void;
+ /** Remove the Named Agent (for an existing card) — notifies via `onRemoved`. */
+ remove(): void;
+ /** Dispose timers; call on unmount. */
+ dispose(): void;
+}
+
+export interface CardControllerOptions {
+ /** The existing Named Agent this card edits, or null for a new draft. */
+ agent: NamedAgent | null;
+ /** The saved full `<provider>/<model>` ('' for a new draft). */
+ savedModel: string;
+ /**
+ * Live uniqueness predicate: true when `name` is already taken by a SIBLING
+ * card (the view EXCLUDES this card's own id). Drives the controller's
+ * usable-name gate so a duplicate is never persisted.
+ */
+ isNameTaken: (name: string) => boolean;
+ ports: NamedAgentPorts;
+ /** Fired with the canonical saved record when a create/update settles. */
+ onChanged: (agent: NamedAgent) => void;
+ /** Fired when the card should leave the list (after a successful remove, or an unsaved draft). */
+ onRemoved: () => void;
+}
+
+/**
+ * A card controller bound to one Named Agent (existing) or a new draft.
+ *
+ * The controller owns ONLY this card's draft + save lifecycle; the sibling list
+ * (for uniqueness + reconciling the authoritative list after a save) is the
+ * parent view's. `onChanged` lets the parent replace the card's canonical record
+ * on success; `onRemoved` fires when the card is to be dropped.
+ */
+export function createCardController(opts: CardControllerOptions): CardController {
+ const debounceMs = opts.ports.debounceMs ?? 400;
+
+ // ── The draft: raw form controls (name/provider/model) owned reactively. ──
+ const { agent, savedModel } = opts;
+ let id = $state<string | null>(agent?.id ?? null);
+ let name = $state<string>(agent?.name ?? "");
+ let provider = $state<string>(savedModel === "" ? "" : (savedModel.split("/")[0] ?? ""));
+ let model = $state<string>(
+ savedModel === "" ? "" : savedModel.slice(savedModel.indexOf("/") + 1),
+ );
+
+ // ── Saved-state + in-flight/sequence tracking. ──
+ // `savedName`/`savedModelValue` = the last PERSISTED definition; `error` =
+ // last save failure. `editSeq` increments on every input change; a save
+ // captures the seq at dispatch time and applies its result ONLY if it is still
+ // the latest — an older response can never overwrite a newer edit (§5.4).
+ let savedName = $state<string>(agent?.name ?? "");
+ let savedModelValue = $state<string>(savedModel);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+
+ let editSeq = 0;
+ let nameTimer: ReturnType<typeof setTimeout> | null = null;
+
+ function currentDraft(): AgentDraft {
+ return { id, name, provider, model };
+ }
+
+ function bump(): number {
+ return ++editSeq;
+ }
+
+ function nameUsable(): boolean {
+ // A usable name is nonempty + not already taken by a sibling card.
+ const normalized = normalizeName(name);
+ return normalized !== "" && !opts.isNameTaken(normalized);
+ }
+
+ function usable(full: string): boolean {
+ return nameUsable() && isUsableModel(full);
+ }
+
+ // A save is owed + legal to dispatch right now when the card is usable and
+ // differs from the persisted definition (or is a fresh, usable draft).
+ function shouldSaveNow(): boolean {
+ const d = currentDraft();
+ const full = draftModel(d);
+ if (!usable(full)) return false;
+ if (id === null && savedName === "" && savedModelValue === "") return true; // fresh draft
+ return normalizeName(d.name) !== savedName || full !== savedModelValue;
+ }
+
+ function doSave(seq: number): void {
+ const d = currentDraft();
+ const full = draftModel(d);
+ if (!usable(full)) return;
+ saving = true;
+ error = null;
+
+ function settle(ok: boolean, saved?: NamedAgent, err?: string): void {
+ // Stale-response guard: only apply if this is still the latest edit.
+ if (seq !== editSeq) return;
+ saving = false;
+ if (ok && saved) {
+ savedName = saved.name;
+ savedModelValue = saved.model;
+ id = saved.id;
+ opts.onChanged(saved);
+ } else {
+ error = err ?? "Save failed";
+ }
+ }
+
+ const input: NamedAgentInput = { name: normalizeName(d.name), model: full };
+ if (id === null) {
+ void opts.ports.create(input).then(
+ (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)),
+ (e) => settle(false, undefined, String(e)),
+ );
+ } else {
+ void opts.ports.update(id, input).then(
+ (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)),
+ (e) => settle(false, undefined, String(e)),
+ );
+ }
+ }
+
+ function scheduleNameSave(): void {
+ if (nameTimer !== null) clearTimeout(nameTimer);
+ const seq = editSeq;
+ nameTimer = setTimeout(() => {
+ nameTimer = null;
+ if (shouldSaveNow()) doSave(seq);
+ }, debounceMs);
+ }
+
+ function saveImmediately(): void {
+ if (nameTimer !== null) {
+ clearTimeout(nameTimer);
+ nameTimer = null;
+ }
+ const seq = editSeq;
+ if (shouldSaveNow()) doSave(seq);
+ }
+
+ // Reactive derived status: from the current draft + saved + in-flight + error,
+ // via the PURE `saveStatus` (this shell supplies only the orchestration inputs).
+ const save = $derived.by<CardSaveState>(() => {
+ const d = currentDraft();
+ const saved: AgentSaved = { id, name: savedName, model: savedModelValue };
+ return { status: saveStatus(d, saved, saving, error), error };
+ });
+
+ return {
+ get save(): CardSaveState {
+ return save;
+ },
+ get draft(): AgentDraft {
+ return currentDraft();
+ },
+ get saving(): boolean {
+ return saving;
+ },
+ setProvider(next: string): void {
+ bump();
+ provider = next;
+ // Changing provider clears the prior model if it may not belong to the new
+ // one; the model dropdown re-populates from the new provider. Persist only
+ // once a valid model is selected (§5.5) → the model change saves immediately.
+ model = "";
+ },
+ setModel(next: string): void {
+ bump();
+ model = next;
+ saveImmediately();
+ },
+ setName(next: string): void {
+ bump();
+ name = next;
+ scheduleNameSave();
+ },
+ remove(): void {
+ if (id === null) {
+ // An unsaved draft — nothing server-side to delete.
+ opts.onRemoved();
+ return;
+ }
+ saving = true;
+ void opts.ports.remove(id).then(
+ () => {
+ saving = false;
+ opts.onRemoved();
+ },
+ (e) => {
+ saving = false;
+ error = String(e);
+ },
+ );
+ },
+ dispose(): void {
+ if (nameTimer !== null) clearTimeout(nameTimer);
+ nameTimer = null;
+ },
+ };
+}
diff --git a/src/features/named-agents/ui/controller.test.ts b/src/features/named-agents/ui/controller.test.ts
new file mode 100644
index 0000000..1655a31
--- /dev/null
+++ b/src/features/named-agents/ui/controller.test.ts
@@ -0,0 +1,237 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { NamedAgent, NamedAgentInput, NamedAgentSaveResult } from "../logic/types";
+import { createCardController, type NamedAgentPorts } from "./controller.svelte";
+
+function fakePorts(overrides?: {
+ create?: NamedAgentPorts["create"];
+ update?: NamedAgentPorts["update"];
+ remove?: NamedAgentPorts["remove"];
+ debounceMs?: number;
+}): {
+ ports: NamedAgentPorts;
+ created: NamedAgentInput[];
+ updated: Array<{ id: string; input: NamedAgentInput }>;
+ removed: string[];
+} {
+ const created: NamedAgentInput[] = [];
+ const updated: Array<{ id: string; input: NamedAgentInput }> = [];
+ const removed: string[] = [];
+ const settle = (r: NamedAgentSaveResult) => Promise.resolve(r);
+ return {
+ ports: {
+ create:
+ overrides?.create ??
+ ((input) => {
+ created.push(input);
+ return settle({ ok: true, agent: { id: `new-${created.length}`, ...input } });
+ }),
+ update:
+ overrides?.update ??
+ ((id, input) => {
+ updated.push({ id, input });
+ return settle({ ok: true, agent: { id, ...input } });
+ }),
+ remove:
+ overrides?.remove ??
+ ((id) => {
+ removed.push(id);
+ return Promise.resolve({ ok: true } as const);
+ }),
+ debounceMs: overrides?.debounceMs ?? 0,
+ } as NamedAgentPorts,
+ created,
+ updated,
+ removed,
+ };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("createCardController", () => {
+ it("creates a new draft when it becomes fully usable and the model is set", async () => {
+ vi.useFakeTimers();
+ const { ports, created } = fakePorts();
+ const changed: NamedAgent[] = [];
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: (a) => changed.push(a),
+ onRemoved: () => {},
+ });
+ c.setName("Coding Agent");
+ c.setProvider("opencode-1");
+ c.setModel("claude-sonnet-4.5");
+ // The model setter saves immediately (the create is dispatched synchronously).
+ expect(created).toHaveLength(1);
+ expect(created[0]).toEqual({ name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" });
+ // The canonical record + onChanged land after the create promise resolves.
+ await vi.runAllTimersAsync();
+ expect(changed).toHaveLength(1);
+ expect(changed[0]?.id).toBe("new-1");
+ c.dispose();
+ });
+
+ it("does not create with only a name (needs a provider + model)", () => {
+ vi.useFakeTimers();
+ const { ports, created } = fakePorts();
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ c.setName("Coding Agent");
+ vi.advanceTimersByTime(500);
+ expect(created).toHaveLength(0);
+ c.dispose();
+ });
+
+ it("does not persist a name that is already taken", () => {
+ vi.useFakeTimers();
+ const { ports, created } = fakePorts();
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: (name) => name.toLowerCase() === "coding agent",
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ c.setName("Coding Agent");
+ c.setProvider("opencode-1");
+ c.setModel("claude-sonnet-4.5");
+ expect(created).toHaveLength(0);
+ c.dispose();
+ });
+
+ it("updates an existing card and reports Saved once clean", async () => {
+ vi.useFakeTimers();
+ const existing: NamedAgent = {
+ id: "a1",
+ name: "Coding",
+ model: "opencode-1/claude-sonnet-4.5",
+ };
+ const { ports, updated } = fakePorts({ debounceMs: 100 });
+ const c = createCardController({
+ agent: existing,
+ savedModel: existing.model,
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ expect(c.save.status).toBe("saved");
+ c.setName("Coding X");
+ // Within the debounce window the edit is unsaved → "dirty".
+ await vi.advanceTimersByTimeAsync(20);
+ expect(c.save.status).toBe("dirty");
+ // Past the debounce the name save fires → the update is dispatched.
+ await vi.advanceTimersByTimeAsync(200);
+ expect(updated).toHaveLength(1);
+ expect(updated[0]?.id).toBe("a1");
+ expect(updated[0]?.input.name).toBe("Coding X");
+ // After the update promise resolves the card reads clean → "saved".
+ await vi.runAllTimersAsync();
+ expect(c.save.status).toBe("saved");
+ c.dispose();
+ });
+
+ it("marks the status as error and keeps the draft on save failure", async () => {
+ vi.useFakeTimers();
+ const { ports } = fakePorts({
+ create: () => Promise.resolve({ ok: false, error: "duplicate name" as const }),
+ });
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ c.setName("Coding");
+ c.setProvider("opencode-1");
+ c.setModel("claude");
+ await vi.runAllTimersAsync();
+ expect(c.save.status).toBe("error");
+ expect(c.save.error).toBe("duplicate name");
+ expect(c.draft.name).toBe("Coding");
+ c.dispose();
+ });
+
+ it("guards stale responses: a newer edit is not overwritten by an older save result", async () => {
+ vi.useFakeTimers();
+ const pending: { resolve: ((r: NamedAgentSaveResult) => void) | null } = { resolve: null };
+ const { ports } = fakePorts({
+ create: () => new Promise<NamedAgentSaveResult>((res) => (pending.resolve = res)),
+ });
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ // Kick a save in flight, then edit again before it resolves.
+ c.setName("A");
+ c.setProvider("opencode-1");
+ c.setModel("claude");
+ c.setName("B"); // sequence bumps past the in-flight save
+ expect(pending.resolve).not.toBeNull();
+ pending.resolve?.({ ok: true, agent: { id: "x", name: "A", model: "opencode-1/claude" } });
+ await vi.runAllTimersAsync();
+ // The stale "A" create must NOT have applied the old result (saving cleared,
+ // but the draft still reads B and no onChanged with "A").
+ expect(c.draft.name).toBe("B");
+ c.dispose();
+ });
+
+ it("remove() on an existing card calls remove and drops via onRemoved", async () => {
+ vi.useFakeTimers();
+ const existing: NamedAgent = {
+ id: "a1",
+ name: "Coding",
+ model: "opencode-1/claude",
+ };
+ const { ports, removed } = fakePorts();
+ let removedCard = false;
+ const c = createCardController({
+ agent: existing,
+ savedModel: existing.model,
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => (removedCard = true),
+ });
+ c.remove();
+ await vi.runAllTimersAsync();
+ expect(removed).toEqual(["a1"]);
+ expect(removedCard).toBe(true);
+ c.dispose();
+ });
+
+ it("remove() on an unsaved draft drops it without a server call", () => {
+ vi.useFakeTimers();
+ const { ports, removed } = fakePorts();
+ let removedCard = false;
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => (removedCard = true),
+ });
+ c.remove();
+ expect(removed).toHaveLength(0);
+ expect(removedCard).toBe(true);
+ c.dispose();
+ });
+});