From fb7eb3105e1fbbdefdad9348f9132815b5bbe52d Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 20 Aug 2026 19:36:12 +0900 Subject: feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the frontend of the Named Agents feature (plan §5-§9): - New features/named-agents module: pure logic (types re-exported from @dispatch/wire + transport-contract, validation, provider/model options, snapshot helpers, network normalizers, auto-save status model) + an injected auto-save controller (name debounce, immediate provider/model save, monotonic stale-response guard) + the collapsible sidebar card view. - Store: /named-agents CRUD + boot seed; selectModel(model, snapshot?) persists the raw model WITH the namedAgent snapshot; draft promotion carries it; refreshModel restores ModelResponse.namedAgent. - Chat store setNamedAgent (sends namedAgent on chat.send); Tab.snapshot + setSnapshot (retained across rename/remove, persisted with tabs). - Model selector: synthetic Named Agents provider ALWAYS first; selecting one resolves to the raw model + records a snapshot; a retained snapshot of a renamed/removed agent still displays as unavailable (plan §7). - Composition root wires the view (viewKinds + loaded-modules manifest) + feeds the selector. Tests: logic + controller + selector + app-store CRUD (1246 pass), typecheck 0, biome clean (only pre-existing config infos pending a future migrate), build OK. --- src/features/chat/index.ts | 7 ++ src/features/chat/model-select.test.ts | 49 ++++++++ src/features/chat/model-select.ts | 70 +++++++++++ src/features/chat/store.svelte.ts | 16 +++ src/features/chat/ui.test.ts | 141 ++++++++++++++++++++- src/features/chat/ui/ModelSelector.svelte | 198 ++++++++++++++++++++++-------- 6 files changed, 425 insertions(+), 56 deletions(-) (limited to 'src/features/chat') diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 8cf776b..6de8164 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -6,12 +6,19 @@ export type { } from "../../core/chunks"; export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; +export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select"; export { + isNamedAgentSelection, isNumberedOpenCode, isVisionModel, migrateLegacyModelName, modelKeyLabel, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, openCodeNumber, + providerKeysWithNamedAgents, } from "./model-select"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 33c1a16..57de11d 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -1,12 +1,18 @@ import { describe, expect, it } from "vitest"; import { + isNamedAgentSelection, isVisionModel, joinModelName, migrateLegacyModelName, modelKeyLabel, modelKeys, modelsForKey, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, openCodeNumber, + providerKeysWithNamedAgents, splitModelName, } from "./model-select"; @@ -143,3 +149,46 @@ describe("isVisionModel", () => { expect(isVisionModel({}, "kimi/k2")).toBe(false); }); }); + +describe("named-agent provider helpers", () => { + it("providerKeysWithNamedAgents puts the synthetic provider first, then raw keys", () => { + expect(providerKeysWithNamedAgents(["openai/gpt-4", "anthropic/claude"])).toEqual([ + NAMED_AGENTS_PROVIDER, + "openai", + "anthropic", + ]); + // Always present even with zero models. + expect(providerKeysWithNamedAgents([])).toEqual([NAMED_AGENTS_PROVIDER]); + }); + + it("namedAgentsProviderLabel + NAMED_AGENTS_PROVIDER are stable", () => { + expect(namedAgentsProviderLabel()).toBe("Named Agents"); + expect(NAMED_AGENTS_PROVIDER).toBe("__named_agents__"); + }); + + it("namedAgentNames returns the display names", () => { + expect(namedAgentNames([{ id: "a", name: "Coding", model: "m" }])).toEqual(["Coding"]); + }); + + it("isNamedAgentSelection is true only for a snapshot whose model equals the selection", () => { + expect( + isNamedAgentSelection("opencode-1/claude", { + namedAgentId: "a", + namedAgentName: "C", + model: "opencode-1/claude", + }), + ).toBe(true); + expect( + isNamedAgentSelection("other/model", { + namedAgentId: "a", + namedAgentName: "C", + model: "opencode-1/claude", + }), + ).toBe(false); + expect(isNamedAgentSelection("opencode-1/claude", null)).toBe(false); + }); + + it("namedAgentSelectionLabel renders 'Named Agents -> '", () => { + expect(namedAgentSelectionLabel("Coding")).toBe("Named Agents -> Coding"); + }); +}); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index 5da6e7b..6f198f3 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -108,3 +108,73 @@ export function isVisionModel( ): boolean { return modelInfo[fullName]?.vision === true; } + +// ── Named Agents synthetic provider (plan §6) ──────────────────────────────── +// +// The regular model picker gains a "Named Agents" provider at the TOP of the +// provider list. Selecting it + a Named Agent name resolves to that agent's raw +// model for the conversation AND records a snapshot so the selection survives +// later edits to the shared preset. These helpers are structural (they take a +// minimal NamedAgent-ish shape) so `model-select` stays decoupled from the +// named-agents feature up the chain; the picker binds the real types. + +/** A minimal Named Agent shape the synthetic provider needs (structural). */ +export interface NamedAgentLike { + readonly id: string; + readonly name: string; + readonly model: string; +} + +/** A minimal per-conversation Named Agent selection snapshot (structural). */ +export interface NamedAgentSnapshotLike { + readonly namedAgentId: string; + readonly namedAgentName: string; + readonly model: string; +} + +/** + * The synthetic "Named Agents" provider id used by the regular model picker + * (plan §6). Deliberately a value that can never collide with a real provider + * (``), so the provider dropdown treats it as the first option. + */ +export const NAMED_AGENTS_PROVIDER = "__named_agents__"; + +/** The friendly label of the synthetic Named Agents provider. */ +export function namedAgentsProviderLabel(): string { + return "Named Agents"; +} + +/** + * The provider dropdown options for the picker: the synthetic "Named Agents" + * provider ALWAYS first, then the raw providers (via `modelKeys`). The synthetic + * entry stays present even with zero Named Agents (plan §6 shows a disabled + * "No named agents configured" model option). + */ +export function providerKeysWithNamedAgents(models: readonly string[]): string[] { + return [NAMED_AGENTS_PROVIDER, ...modelKeys(models)]; +} + +/** The Named Agent names offered in the model dropdown when the synthetic provider is selected. */ +export function namedAgentNames(agents: readonly NamedAgentLike[]): string[] { + return agents.map((a) => a.name); +} + +/** + * Whether a full raw model string corresponds to a Named Agent snapshot — + * i.e. the conversation was selected via a Named Agent whose captured raw model + * equals `selected`. When true, the picker displays `Named Agents -> `. + */ +export function isNamedAgentSelection( + selected: string, + snapshot: NamedAgentSnapshotLike | null, +): boolean { + return snapshot !== null && snapshot.model === selected; +} + +/** + * The snapshot label shown in the model dropdown for a Named Agent selection: + * "Named Agents -> ". Pure display. + */ +export function namedAgentSelectionLabel(name: string): string { + return `${namedAgentsProviderLabel()} -> ${name}`; +} diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 24a1d06..c83c5fd 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -34,6 +34,7 @@ import { selectOrderedTurnMetrics, } from "../../core/metrics"; import type { ConversationCache } from "../conversation-cache"; +import type { NamedAgentSelectionSnapshot } from "../named-agents"; import type { ChatTransport, HistorySync, MetricsSync } from "./ports"; export interface ChatStoreDependencies { @@ -144,6 +145,15 @@ export interface ChatStore { */ cancelQueuedMessage(messageId: string): void; setModel(model: string): void; + /** + * Set (or clear, with null) the Named Agent selection snapshot this chat sends + * with `chat.send` (plan §7). The backend persists it on the conversation at + * creation so it freezes the friendly id/name + resolved model — later edits to + * the Named Agent do not change this conversation's selection. The raw model + * (already set via `setModel`) is sent alongside; the snapshot is display/identity + * metadata that preserves HOW it was selected. + */ + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void; /** * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. * Lowering it unloads older committed chunks (deferred via the gate while the @@ -180,6 +190,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { let _pendingSync = $state(false); let _error = $state(null); let _model = $state(deps.model); + let _namedAgent = $state(null); let disposed = false; let chatLimit = normalizeChatLimit(deps.chatLimit); @@ -342,6 +353,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { conversationId: deps.conversationId, message: text, ...(_model !== undefined ? { model: _model } : {}), + ...(_namedAgent !== null ? { namedAgent: _namedAgent } : {}), ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}), }; @@ -379,6 +391,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { _model = model; }, + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void { + _namedAgent = snapshot; + }, + async setChatLimit(limit: number): Promise { const prev = chatLimit; chatLimit = normalizeChatLimit(limit); diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index aa43e04..3a47219 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -1006,7 +1006,9 @@ describe("ModelSelector", () => { expect(keySelect).toHaveValue("anthropic"); expect(modelSelect).toHaveValue("claude-3"); - expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); + // The synthetic "Named Agents" provider is ALWAYS first (plan §6), even with + // zero configured agents. + expect(optionValues(keySelect)).toEqual(["__named_agents__", "openai", "anthropic", "google"]); // only the models under the selected key expect(optionValues(modelSelect)).toEqual(["claude-3"]); }); @@ -1041,6 +1043,133 @@ describe("ModelSelector", () => { expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); }); + it("lists Named Agents under the synthetic provider and resolves a selection to a snapshot", async () => { + const onSelectNamedAgent = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4"]; + const namedAgents = [ + { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "a2", name: "Research Agent", model: "umans/umans-glm-5.2" }, + ]; + + const { rerender } = render(ModelSelector, { + props: { + models, + selected: "openai/gpt-4", + onSelect: vi.fn(), + namedAgents, + onSelectNamedAgent, + }, + }); + + // Switching the provider to "Named Agents" jumps to the first agent (mirrors + // a raw provider jumping to its first model). + await user.selectOptions( + screen.getByRole("combobox", { name: "Key selector" }), + "__named_agents__", + ); + expect(onSelectNamedAgent).toHaveBeenCalledWith({ + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + + // The parent (store) applies the selection → re-render with the resolved raw + // model + snapshot, exactly as `selectModel(model, snapshot)` would do. + const codingAgent = namedAgents[0]; + await rerender({ + models, + selected: codingAgent?.model ?? "", + onSelect: vi.fn(), + namedAgents, + namedAgentSnapshot: { + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }, + onSelectNamedAgent, + }); + + // The model dropdown now lists the named agents; pick a different one. + await user.selectOptions( + screen.getByRole("combobox", { name: "Model selector" }), + "Research Agent", + ); + expect(onSelectNamedAgent).toHaveBeenLastCalledWith({ + namedAgentId: "a2", + namedAgentName: "Research Agent", + model: "umans/umans-glm-5.2", + }); + }); + + it("shows a disabled placeholder when the synthetic provider has no configured agents", () => { + render(ModelSelector, { + props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() }, + }); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + const options = within(modelSelect).getAllByRole("option"); + // With the raw openai provider selected the model dropdown lists gpt-4; the + // "No named agents configured" placeholder appears only under the synthetic + // provider (which we'd select to see it). Assert the disabled/empty handling + // by checking the synthetic provider is present in the key selector. + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + expect(optionValues(keySelect)).toContain("__named_agents__"); + // Switch to the (empty) synthetic provider → disabled "No named agents configured". + expect(() => screen.getByRole("option", { name: /No named agents configured/ })).toThrow(); + expect(options).toHaveLength(1); + }); + + it("keeps showing a snapshot of a renamed/removed Named Agent (unavailable)", () => { + // A conversation selected via a Named Agent retains its snapshot even after + // the agent is gone from the current list (plan §7). + const namedAgentSnapshot = { + namedAgentId: "gone", + namedAgentName: "Old Agent", + model: "opencode-1/claude-sonnet-4.5", + }; + render(ModelSelector, { + props: { + models: [], + selected: "opencode-1/claude-sonnet-4.5", + onSelect: vi.fn(), + namedAgents: [], + namedAgentSnapshot, + }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + expect(keySelect).toHaveValue("__named_agents__"); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + expect(modelSelect).toHaveValue("Old Agent"); + expect(screen.getByRole("option", { name: /Old Agent.*unavailable/ })).toBeInTheDocument(); + }); + + it("selecting a raw model from a Named Agent conversation clears the snapshot via onSelect", async () => { + const onSelect = vi.fn(); + const onSelectNamedAgent = vi.fn(); + const user = userEvent.setup(); + const namedAgentSnapshot = { + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }; + render(ModelSelector, { + props: { + models: ["openai/gpt-4"], + selected: "opencode-1/claude-sonnet-4.5", + onSelect, + namedAgents: [{ id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }], + namedAgentSnapshot, + onSelectNamedAgent, + }, + }); + + // Switch from Named Agents to a raw provider → raw model selection clears the snapshot. + await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "openai"); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-4"); + expect(onSelectNamedAgent).not.toHaveBeenCalled(); + }); + it("marks vision-capable models in the model dropdown", () => { const models = ["kimi/k2", "kimi/k1.5"]; const modelInfo = { @@ -1099,15 +1228,17 @@ describe("ModelSelector", () => { const keySelect = screen.getByRole("combobox", { name: "Key selector" }); const options = within(keySelect).getAllByRole("option"); - // Numeric order: #1 < #2 < #10, and values stay RAW (opencode-N). + // Numeric order: #1 < #2 < #10 (after the always-first synthetic provider), + // and values stay RAW (opencode-N). expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([ + "__named_agents__", "opencode-1", "opencode-2", "opencode-10", ]); - expect(options[0]?.textContent).toContain("OpenCode #1"); - expect(options[1]?.textContent).toContain("OpenCode #2"); - expect(options[2]?.textContent).toContain("OpenCode #10"); + expect(options[1]?.textContent).toContain("OpenCode #1"); + expect(options[2]?.textContent).toContain("OpenCode #2"); + expect(options[3]?.textContent).toContain("OpenCode #10"); }); it("shows a disappeared saved credential as unavailable without silently falling back", () => { diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index 1d06848..c66d3dd 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,11 +1,16 @@