summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte35
-rw-r--r--src/app/store.svelte.ts213
-rw-r--r--src/app/store.test.ts174
3 files changed, 417 insertions, 5 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index e50648a..9436648 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -45,6 +45,12 @@
import { manifest as tabsManifest, TabList } from "../features/tabs";
import { manifest as viewsManifest, ViewSidebar } from "../features/views";
import {
+ manifest as namedAgentsManifest,
+ NamedAgentsView,
+ type NamedAgentInput,
+ type NamedAgentSelectionSnapshot,
+ } from "../features/named-agents";
+ import {
CwdField,
type CwdSaveResult,
LspStatusView,
@@ -119,6 +125,7 @@
const viewKinds = [
{ id: "tabs", label: "Tabs" },
{ id: "model", label: "Model" },
+ { id: "named-agents", label: "Named Agents" },
{ id: "lsp", label: "Language Servers" },
{ id: "mcp", label: "MCP Servers" },
{ id: "extensions", label: "Extensions" },
@@ -167,6 +174,7 @@
heartbeatManifest,
concurrencyManifest,
visionManifest,
+ namedAgentsManifest,
].map((m) => [m.name, m.description] as const);
// Smart-scroll: keep the transcript pinned to the bottom while it streams,
@@ -310,6 +318,21 @@
store.selectModel(model);
}
+ // A Named Agent selected in the model selector: resolve it to its raw model
+ // AND record the snapshot on the active conversation (plan §6/§7). The store
+ // persists the raw model to `/conversations/:id/model` + retains the snapshot
+ // on the tab.
+ function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot) {
+ store.selectModel(snapshot.model, snapshot);
+ }
+
+ // Adapt the store's Named Agent CRUD to the named-agents feature's ports.
+ const namedAgentPorts = {
+ create: (input: NamedAgentInput) => store.createNamedAgent(input),
+ update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input),
+ remove: (id: string) => store.deleteNamedAgent(id),
+ };
+
// Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
async function warmNow(): Promise<WarmFeedback | null> {
const result = await store.warmNow();
@@ -754,6 +777,9 @@
selected={store.activeModel}
onSelect={handleSelectModel}
modelInfo={store.modelInfo}
+ namedAgents={store.namedAgents}
+ namedAgentSnapshot={store.namedAgentSnapshot}
+ onSelectNamedAgent={handleSelectNamedAgent}
/>
<!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
re-mount per conversation — incl. switching between drafts — and can't
@@ -775,6 +801,15 @@
/>
{/key}
</div>
+ {:else if kind === "named-agents"}
+ <!-- Named Agents: a global, collapsible sidebar view of curated presets.
+ The store owns the list (authoritative for the model selector + agent
+ tooling); the view drives CRUD via injected ports (auto-save). -->
+ <NamedAgentsView
+ agents={store.namedAgents}
+ models={store.models}
+ ports={namedAgentPorts}
+ />
{:else if kind === "lsp"}
<!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
{#key store.currentConversationId}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 9d0e605..08a7cd8 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -80,6 +80,15 @@ import type {
HeartbeatStopResult,
} from "../features/heartbeat";
import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
+import type {
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentSaveResult,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResult,
+} from "../features/named-agents";
+import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents";
import type { Tab, TabsState } from "../features/tabs";
import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs";
import {
@@ -224,7 +233,8 @@ export interface AppStore {
* message is a silent server no-op (nothing to roll back).
*/
cancelQueuedMessage(messageId: string): void;
- selectModel(model: string): void;
+ /** Select a raw model for the active conversation. Pass `snapshot` to record a Named Agent selection (plan §7). */
+ selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void;
newDraft(): void;
/** Switch the active workspace (on route change) + reset to a fresh draft in it. */
setActiveWorkspace(workspaceId: string): void;
@@ -518,6 +528,39 @@ export interface AppStore {
readonly fatalError: string | null;
/** Dismiss the fatal error (called by the error modal's X button). */
clearFatalError(): void;
+ // ── Named Agents (global config; plan §9) ──────────────────────────────────
+ /**
+ * The persisted Named Agents (from `GET /named-agents`). Global user config
+ * (not per-workspace); seeded on boot. Drives the sidebar editor AND the
+ * synthetic "Named Agents" provider in the model selector (plan §6).
+ */
+ readonly namedAgents: readonly NamedAgent[];
+ /**
+ * The active conversation's Named Agent selection snapshot (plan §7), or null
+ * when it was selected via a raw model. Survives the Named Agent being renamed
+ * / removed (the snapshot retains the friendly label + captured raw model).
+ */
+ readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null;
+ /** Refetch the Named Agents (`GET /named-agents`). */
+ refreshNamedAgents(): Promise<NamedAgentsResult>;
+ /**
+ * Create a Named Agent (`POST /named-agents`). On success the store's
+ * `namedAgents` list is updated (append/replace) so every consumer — the
+ * sidebar editor AND the model selector — reflects it without a reload.
+ */
+ createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult>;
+ /**
+ * Update a Named Agent (`PUT /named-agents/:id`). On success the store list is
+ * updated in place. Returns the canonical saved record.
+ */
+ updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult>;
+ /**
+ * Remove a Named Agent (`DELETE /named-agents/:id`). On success it leaves the
+ * store list immediately (removing it from the sidebar + model selector), and
+ * does NOT alter conversations that previously selected it (those keep their
+ * snapshot — plan §7).
+ */
+ deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult>;
dispose(): void;
}
@@ -557,6 +600,16 @@ function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSy
};
}
+/** Replace-or-append a Named Agent into a list (immutably), keyed by id. */
+function upsertNamedAgentList(
+ agents: readonly NamedAgent[],
+ agent: NamedAgent,
+): readonly NamedAgent[] {
+ const idx = agents.findIndex((a) => a.id === agent.id);
+ if (idx === -1) return [...agents, agent];
+ return agents.map((a) => (a.id === agent.id ? agent : a));
+}
+
export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
let protocol = $state<ProtocolState>(protocolInitialState());
let models = $state<readonly string[]>([]);
@@ -566,8 +619,20 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// `~/.ssh/config`).
let computers = $state<readonly ComputerEntry[]>([]);
let activeModel = $state(DEFAULT_MODEL);
+ // The persisted Named Agents (global user config). Seeded from the backend on
+ // boot + kept in sync by the CRUD methods; drives both the sidebar editor and
+ // the synthetic model-selector provider.
+ let namedAgents = $state<readonly NamedAgent[]>([]);
+ // The active conversation's Named Agent snapshot (null when selected via a raw
+ // model). Derived from the active tab; survives a Named Agent being edited.
+ let namedAgentSnapshot = $state<NamedAgentSelectionSnapshot | null>(null);
let fatalError = $state<string | null>(null);
+ /** Replace-or-append a Named Agent into the reactive list (immutably). */
+ function upsertNamedAgent(agent: NamedAgent): void {
+ namedAgents = upsertNamedAgentList(namedAgents, agent);
+ }
+
// The workspace currently in view (its URL slug); "default" until routing
// sets it. Tabs are filtered to this workspace; a new conversation is stamped
// with it on `chat.send`.
@@ -725,14 +790,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (!res.ok) return;
const data = (await res.json()) as ModelResponse;
if (workspaceConversationId() !== id) return;
+ // The backend is authoritative for the persisted snapshot: restore how the
+ // conversation was selected (a Named Agent, or none). Falls back to the tab's
+ // local snapshot when the response omits it (older backend).
+ namedAgentSnapshot = data.namedAgent ?? namedAgentSnapshot;
if (typeof data.model === "string" && data.model.length > 0) {
activeModel = data.model;
const activeId = tabsStore.activeConversationId;
if (activeId !== null) {
tabsStore.setModel(activeId, data.model);
+ tabsStore.setSnapshot(activeId, namedAgentSnapshot);
chatStores.get(activeId)?.setModel(data.model);
+ chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot);
} else {
draftStore.setModel(data.model);
+ draftStore.setNamedAgent(namedAgentSnapshot);
}
}
} catch (err) {
@@ -1267,6 +1339,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
reportError("Failed to load computer list", err);
});
+ // Fetch the persisted Named Agents (global user config). Seeded on boot so the
+ // sidebar editor + the synthetic model-selector provider have data immediately.
+ // Non-fatal: a failure leaves an empty list (the user can add agents).
+ void fetchImpl(`${httpBase}/named-agents`)
+ .then((res) => {
+ if (!res.ok) return { namedAgents: [] } as { namedAgents: NamedAgent[] };
+ return res.json() as Promise<{ namedAgents: NamedAgent[] }>;
+ })
+ .then((data) => {
+ namedAgents = normalizeNamedAgents(data);
+ })
+ .catch((err) => {
+ reportError("Failed to load named agents", err);
+ });
+
// Restore persisted tabs
const persistedState = storageAdapter.load();
if (persistedState !== null && persistedState.tabs.length > 0) {
@@ -1292,6 +1379,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
);
if (activeTab !== undefined) {
activeModel = migrateLegacyModelName(activeTab.model);
+ namedAgentSnapshot = activeTab.snapshot ?? null;
}
}
}
@@ -1331,6 +1419,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
draftStore = createChatFor(nextDraftId, activeModel, workspaceId);
draftConversationId = nextDraftId;
tabsStore.newDraft();
+ namedAgentSnapshot = null;
refreshActiveChat();
syncSubscriptions();
void refreshCwd();
@@ -1414,14 +1503,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
send(text: string, images?: readonly ImageInput[]): void {
if (tabsStore.activeConversationId === null) {
- // Draft: promote to tab on first send
+ // Draft: promote to tab on first send. Carry a Named Agent selection's
+ // snapshot so the promoted conversation retains how it was selected (§7).
const conversationId = draftConversationId;
const model = activeModel;
+ const snapshot = namedAgentSnapshot ?? undefined;
tabsStore.createTab({
conversationId,
model,
title: deriveTitle(text),
workspaceId: activeWorkspaceId,
+ ...(snapshot ? { snapshot } : {}),
});
chatStores.set(conversationId, draftStore);
void draftStore.load();
@@ -1465,21 +1557,26 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
activeChat.cancelQueuedMessage(messageId);
},
- selectModel(model: string): void {
+ selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void {
activeModel = model;
+ namedAgentSnapshot = snapshot ?? null;
const activeId = tabsStore.activeConversationId;
if (activeId !== null) {
tabsStore.setModel(activeId, model);
+ tabsStore.setSnapshot(activeId, snapshot ?? null);
chatStores.get(activeId)?.setModel(model);
+ chatStores.get(activeId)?.setNamedAgent(snapshot ?? null);
+ const body: SetModelRequest = { model, ...(snapshot ? { namedAgent: snapshot } : {}) };
void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model } satisfies SetModelRequest),
+ body: JSON.stringify(body),
}).catch((err) => {
reportError("Failed to persist model", err);
});
} else {
draftStore.setModel(model);
+ draftStore.setNamedAgent(snapshot ?? null);
}
},
@@ -1488,6 +1585,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const nextDraftId = randomId();
draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
draftConversationId = nextDraftId;
+ namedAgentSnapshot = null;
refreshActiveChat();
syncSubscriptions();
void refreshCwd();
@@ -1503,6 +1601,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId);
if (tab !== undefined) {
activeModel = tab.model;
+ namedAgentSnapshot = tab.snapshot ?? null;
+ } else {
+ namedAgentSnapshot = null;
}
refreshActiveChat();
syncSubscriptions();
@@ -2285,6 +2386,110 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
unloadGate = gate;
},
+ // ── Named Agents (global config; plan §9) ────────────────────────────────
+
+ get namedAgents(): readonly NamedAgent[] {
+ return namedAgents;
+ },
+ get namedAgentSnapshot(): NamedAgentSelectionSnapshot | null {
+ return namedAgentSnapshot;
+ },
+
+ async refreshNamedAgents(): Promise<NamedAgentsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Named agents failed (HTTP ${res.status})`,
+ };
+ }
+ const agents = normalizeNamedAgents(await res.json());
+ namedAgents = agents;
+ return { ok: true, agents };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Named agents request failed",
+ };
+ }
+ },
+
+ async createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Create named agent failed (HTTP ${res.status})`,
+ };
+ }
+ const agent = normalizeNamedAgentRecord(await res.json());
+ if (agent === null) return { ok: false, error: "Malformed named agent response" };
+ upsertNamedAgent(agent);
+ return { ok: true, agent };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Create named agent request failed",
+ };
+ }
+ },
+
+ async updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Update named agent failed (HTTP ${res.status})`,
+ };
+ }
+ const agent = normalizeNamedAgentRecord(await res.json());
+ if (agent === null) return { ok: false, error: "Malformed named agent response" };
+ upsertNamedAgent(agent);
+ return { ok: true, agent };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Update named agent request failed",
+ };
+ }
+ },
+
+ async deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete named agent failed (HTTP ${res.status})`,
+ };
+ }
+ namedAgents = namedAgents.filter((a) => a.id !== id);
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete named agent request failed",
+ };
+ }
+ },
+
get fatalError(): string | null {
return fatalError;
},
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index d30148e..c41efc2 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1,5 +1,6 @@
import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
+import type { NamedAgent } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import { createAppStore } from "./store.svelte";
@@ -104,16 +105,67 @@ interface FakeFetchOptions {
models?: readonly string[];
history?: Record<string, ConversationHistoryResponse>;
model?: string | null;
+ /** Persisted Named Agents (GET /named-agents + CRUD in-memory). */
+ namedAgents?: readonly NamedAgent[];
}
function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"];
const history = opts?.history ?? {};
- return async (input: string | URL | Request): Promise<Response> => {
+ // In-memory Named Agent store for the CRUD routes (the fake fetch mimics the
+ // backend's /named-agents surface).
+ let namedAgents: NamedAgent[] = opts?.namedAgents ? [...opts.namedAgents] : [];
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method =
+ init?.method ?? (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET");
+ const bodyText =
+ typeof init?.body === "string"
+ ? init.body
+ : typeof input !== "string" &&
+ !(input instanceof URL) &&
+ typeof input.body !== "undefined" &&
+ "body" in input
+ ? await input.clone().text()
+ : null;
if (url.endsWith("/models")) {
return new Response(JSON.stringify({ models }), { status: 200 });
}
+ if (url.endsWith("/named-agents") && method === "GET") {
+ return new Response(JSON.stringify({ namedAgents }), { status: 200 });
+ }
+ if (url.endsWith("/named-agents") && method === "POST") {
+ const payload =
+ bodyText === null
+ ? null
+ : (JSON.parse(bodyText) as { name?: string; model?: string } | null);
+ const agent: NamedAgent = {
+ id: `na-${namedAgents.length + 1}`,
+ name: payload?.name ?? "",
+ model: payload?.model ?? "",
+ };
+ namedAgents = [...namedAgents, agent];
+ return new Response(JSON.stringify({ namedAgent: agent }), { status: 201 });
+ }
+ if (url.includes("/named-agents/") && method === "PUT") {
+ const id = url.split("/named-agents/")[1] ?? "";
+ const payload =
+ bodyText === null
+ ? null
+ : (JSON.parse(bodyText) as { name?: string; model?: string } | null);
+ const updated: NamedAgent = {
+ id,
+ name: payload?.name ?? "",
+ model: payload?.model ?? "",
+ };
+ namedAgents = namedAgents.map((a) => (a.id === id ? updated : a));
+ return new Response(JSON.stringify({ namedAgent: updated }), { status: 200 });
+ }
+ if (url.includes("/named-agents/") && method === "DELETE") {
+ const id = url.split("/named-agents/")[1] ?? "";
+ namedAgents = namedAgents.filter((a) => a.id !== id);
+ return new Response(JSON.stringify({ id }), { status: 200 });
+ }
if (url.endsWith("/model")) {
return new Response(
JSON.stringify({
@@ -2276,3 +2328,123 @@ describe("createAppStore — vision settings (global)", () => {
store.dispose();
});
});
+
+describe("createAppStore — Named Agents", () => {
+ it("seeds namedAgents from GET /named-agents on boot", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => expect(store.namedAgents).toEqual(agents));
+ store.dispose();
+ });
+
+ it("createNamedAgent appends to the store list", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.createNamedAgent({ name: "Research", model: "umans/glm" });
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.agent.id).toBe("na-1");
+ expect(store.namedAgents).toHaveLength(1);
+ expect(store.namedAgents[0]?.name).toBe("Research");
+ }
+ store.dispose();
+ });
+
+ it("updateNamedAgent updates the record in place", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.updateNamedAgent("na-1", {
+ name: "Coding",
+ model: "opencode-1/new",
+ });
+ expect(result.ok).toBe(true);
+ expect(store.namedAgents[0]?.name).toBe("Coding");
+ expect(store.namedAgents[0]?.model).toBe("opencode-1/new");
+ store.dispose();
+ });
+
+ it("deleteNamedAgent removes the record immediately", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "na-2", name: "Research Agent", model: "umans/glm" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.namedAgents).toHaveLength(agents.length));
+
+ const result = await store.deleteNamedAgent("na-1");
+ expect(result.ok).toBe(true);
+ expect(store.namedAgents.map((a) => a.id)).toEqual(["na-2"]);
+ store.dispose();
+ });
+
+ it("selectModel records a Named Agent snapshot on the active conversation", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.selectModel("opencode-1/claude-sonnet-4.5", {
+ namedAgentId: "na-1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+
+ expect(store.activeModel).toBe("opencode-1/claude-sonnet-4.5");
+ expect(store.namedAgentSnapshot?.namedAgentName).toBe("Coding Agent");
+ store.dispose();
+ });
+
+ it("selectModel without a snapshot clears a prior Named Agent selection", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.selectModel("opencode-1/claude-sonnet-4.5", {
+ namedAgentId: "na-1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+ store.selectModel("openai/gpt-4o");
+
+ expect(store.activeModel).toBe("openai/gpt-4o");
+ expect(store.namedAgentSnapshot).toBeNull();
+ store.dispose();
+ });
+});