(null);
+ const description = useId();
+ const [open, setOpen] = useState(false);
+ const Icon = type.icon;
+ return (
+ {
+ // The menu returns focus to itself when the pointer enters this card.
+ // Let hover dismissal handle that move; another item still closes it.
+ if (
+ details.reason === "focus-out" &&
+ "relatedTarget" in details.event &&
+ details.event.relatedTarget ===
+ trigger.current?.closest('[role="menu"]')
+ ) {
+ details.cancel();
+ return;
+ }
+ setOpen(next);
+ }}
+ >
+ {
+ if (event.currentTarget.matches(":focus-visible")) setOpen(true);
+ }}
+ onKeyDownCapture={(event) => {
+ if (event.key === "Escape" && open) {
+ event.preventDefault();
+ event.stopPropagation();
+ setOpen(false);
+ } else if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setOpen(true);
+ }
+ }}
+ aria-describedby={open ? description : undefined}
+ render={
+
+ : undefined}
+ />
+
+ }
+ />
+
+
+ This agent’s signing key is stored on this device. Buzz can’t move
+ that identity to {type.title} yet. To run hosted, create a new{" "}
+ {type.title} agent.
+
+
+
+ );
+}
+
export function MigrationPreview({
source,
target,
diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md
index ea792e20cb..a6225ceec6 100644
--- a/src/bundled/builderlab/README.md
+++ b/src/bundled/builderlab/README.md
@@ -7,8 +7,8 @@ One Builderlab account for communities and agents that Builderlab hosts for you,
1. Go to **Settings → Plugins → Builderlab** and choose **Sign in with Builderlab**.
2. To host a community, choose **Create community** under **Communities**, then **Copy address**, open the community picker and choose **Join a community**.
3. To host an agent, in **Agents** choose **New agent** and pick **Builderlab (hosted)** as the **Harness**.
-4. Choose a model and reasoning effort, optionally add instructions, then create the agent.
-5. Edit instructions later in **Identity**, and model settings in **Settings**.
+4. Choose a model and thinking level, optionally add instructions, then create the agent.
+5. Edit model settings and instructions later in **Identity**.
---
@@ -81,17 +81,28 @@ The agent's Identity tab reads its current instructions from Builderlab and
saves changes directly to the provider. Loading and saving use the same login;
failed saves keep the draft, and switching accounts requires the original
account before saving. Empty instructions can be saved to clear them.
-The Settings tab loads a separate model catalog from Builderlab. Choose a model
-and one of that model's reasoning efforts, then save the pair for new tasks.
+The Identity tab also loads a separate model catalog from Builderlab. Choose a
+model and one of that model's thinking levels, then save the pair for new tasks.
There is no default or fallback selection. Missing or unavailable saved choices
show the server's setup status and require a supported pair. Changing the model
-clears the effort choice. Instructions and model settings save independently.
+clears the effort choice. One shared Save action sits in the agent pane's bottom
+footer and saves the Identity draft. When model settings and instructions both
+change, they are sent in one update. Instructions alone remain editable when the
+model catalog is unavailable; a changed model requires a supported effort before
+the draft can be saved.
+The model control uses the same family submenus as Goose. Builderlab's catalog
+aliases are mapped to families in `adapter-type/models.ts`; update that mapping
+with Builderlab's catalog information until its API supplies family metadata.
+The server still owns available choices, names and thinking levels. Unmapped
+aliases use shared family inference from the model ID and name; unrecognized
+models remain selectable under Other. Families without a shared brand mark use
+the generic model icon.
Creation offers the same catalog choices and optional instructions. The provider
registers and activates the agent before saving the settings together. Unfinished
setup retains these values on this device so retry continues the same agent,
including after a restart. Setup completes only once the settings save succeeds.
-Settings shows the fixed remote workspace and per-thread session behavior. Both
-tabs link to the configured Builderlab site for remote runtime management.
+Settings shows the fixed remote workspace and per-thread session behavior, with
+a link to the configured Builderlab site for remote runtime management.
Activity shows the turns the agent published to its owner while Buzz has been
open, across all of its conversations. It doesn't scope to the conversation it
was opened from, because hosted frames don't always name their thread. It needs
diff --git a/src/bundled/builderlab/adapter-type/Create.test.tsx b/src/bundled/builderlab/adapter-type/Create.test.tsx
index 06097c0c03..1dbb6523f3 100644
--- a/src/bundled/builderlab/adapter-type/Create.test.tsx
+++ b/src/bundled/builderlab/adapter-type/Create.test.tsx
@@ -22,7 +22,7 @@ const catalog = [
{
id: "one",
display_name: "Model One",
- supported_reasoning_efforts: ["low", "high"],
+ supported_reasoning_efforts: ["low", "high", "xhigh"],
},
{
id: "two",
@@ -92,9 +92,7 @@ it("loads the catalog and keeps instructions alongside explicit model and effort
await act(async () => h.pending.resolve());
const model = await screen.findByRole("combobox", { name: "Model" });
expect(model).toHaveTextContent("Choose a model");
- expect(
- screen.getByRole("combobox", { name: "Reasoning effort" }),
- ).toBeDisabled();
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toBeDisabled();
await userEvent
.setup()
.type(
@@ -102,13 +100,14 @@ it("loads the catalog and keeps instructions alongside explicit model and effort
"Be precise.",
);
await choose("Model", "Model One");
- await choose("Reasoning effort", "high");
+ await choose("Thinking", "Extra high");
+ expect(h.draft().reasoningEffort).toBe("xhigh");
await choose("Model", "Model Two");
expect(h.draft().reasoningEffort).toBe("");
expect(
screen.getByRole("textbox", { name: "Instructions Optional" }),
).toHaveValue("Be precise.");
- await choose("Reasoning effort", "none");
+ await choose("Thinking", "None");
expect(h.draft()).toEqual({
instructions: "Be precise.",
modelId: "two",
diff --git a/src/bundled/builderlab/adapter-type/Create.tsx b/src/bundled/builderlab/adapter-type/Create.tsx
index c3fa45bc3c..556dca3fe0 100644
--- a/src/bundled/builderlab/adapter-type/Create.tsx
+++ b/src/bundled/builderlab/adapter-type/Create.tsx
@@ -9,6 +9,7 @@ import {
validInstructions,
} from "../agents/client";
import type { OAuthSession } from "../oauth/session";
+import { effortLabel } from "./models";
export type CreationConfig = {
instructions: string;
@@ -34,7 +35,7 @@ export function creationConfig(value: unknown): CreationConfig {
typeof config.subject !== "string"
)
throw new Error(
- "Choose a model and reasoning effort and check the instructions.",
+ "Choose a model and thinking level and check the instructions.",
);
return config as CreationConfig;
}
@@ -83,7 +84,7 @@ export function createCreationFields(
{login.status === "pending"
? "Finish sign-in in your browser, then choose a model."
- : "Sign in to choose a model and reasoning effort."}
+ : "Sign in to choose a model and thinking level."}
{login.error && (
@@ -131,18 +132,18 @@ export function createCreationFields(
}
/>
({ value, label: value }),
+ (value) => ({ value, label: effortLabel(value) }),
),
},
]}
@@ -153,7 +154,7 @@ export function createCreationFields(
{wrongAccount && (
These choices belong to another Builderlab account. Choose a
- model and reasoning effort again.
+ model and thinking level again.
)}
>
diff --git a/src/bundled/builderlab/adapter-type/Instructions.test.tsx b/src/bundled/builderlab/adapter-type/Instructions.test.tsx
index a6c7627d47..d2e80a21c9 100644
--- a/src/bundled/builderlab/adapter-type/Instructions.test.tsx
+++ b/src/bundled/builderlab/adapter-type/Instructions.test.tsx
@@ -3,7 +3,8 @@ import "@testing-library/jest-dom/vitest";
import type { RelayData } from "../../../features/relay/service";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { StrictMode } from "react";
+import { StrictMode, useState } from "react";
+import { AgentSaveFooter } from "../../../features/agents/AgentSaveButton";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { AgentTabs } from "../../agents/AgentTabs";
import type { Agents, Agent } from "../../../features/agents/service";
@@ -12,6 +13,7 @@ import { createAgentClient } from "../agents/client";
import { createOAuthSession } from "../oauth/session";
import { deferred } from "../test-helpers";
import { builderlabType } from "./index";
+import { PanelCard } from "../../../features/panels/PanelCard";
const first = "ab".repeat(32);
const second = "cd".repeat(32);
@@ -65,8 +67,23 @@ function fixture() {
value: "credential",
account: { subject, email: "a@example.com", canUseRemoteAgents: true },
}));
- const request = vi.fn(async ({ url }) =>
- url.endsWith("/get-model-catalog")
+ let stored = response();
+ const request = vi.fn(async ({ url, body }) => {
+ if (url.endsWith("/update-agent")) {
+ const update = JSON.parse(body ?? "{}");
+ const data = JSON.parse(stored.body);
+ data.agents = data.agents.map((row: { agent_pubkey: string }) =>
+ row.agent_pubkey === update.agent_pubkey
+ ? {
+ ...row,
+ ...update,
+ ...(update.model_id ? { model_selection_status: 1 } : {}),
+ }
+ : row,
+ );
+ stored = { ...stored, body: JSON.stringify(data) };
+ }
+ return url.endsWith("/get-model-catalog")
? {
status: 200,
headers: {},
@@ -80,8 +97,8 @@ function fixture() {
],
}),
}
- : response(),
- );
+ : stored;
+ });
const client = createAgentClient({ request, runCommand: vi.fn() }, session);
const type = {
...builderlabType(client, session, {
@@ -116,9 +133,9 @@ it("retries a failed load and refuses to save a draft through another account",
await user.type(field, "Changed.");
await act(async () => h.changeAccount());
const before = h.request.mock.calls.length;
- await user.click(screen.getByRole("button", { name: "Save instructions" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
- "account that loaded these instructions",
+ "account that loaded these settings",
);
expect(h.request).toHaveBeenCalledTimes(before);
expect(field).toHaveValue("First instructions.Changed.");
@@ -142,9 +159,7 @@ it("switching agents hides the old draft and ignores the switched-away read", as
),
).toHaveLength(2),
);
- expect(screen.getByRole("status")).toHaveTextContent(
- "Loading instructions",
- );
+ expect(screen.getByRole("status")).toHaveTextContent("Loading settings");
expect(
screen.queryByRole("textbox", { name: "Instructions" }),
).not.toBeInTheDocument();
@@ -163,31 +178,85 @@ it("switching agents hides the old draft and ignores the switched-away read", as
);
});
-function renderTabs(h: ReturnType) {
+function renderTabs(h: ReturnType, close?: () => void) {
+ function Pane() {
+ const [target, setTarget] = useState(null);
+ return (
+
+ Hosted provider
}}
+ settingsContent={Agent settings
}
+ />
+
+
+ );
+ }
render(
- Hosted provider
}}
- settingsContent={Agent settings
}
- />,
+ close ? (
+ true,
+ component: Pane,
+ }}
+ />
+ ) : (
+
+ ),
{ wrapper: StrictMode },
);
}
+it("dismisses Harness help before allowing Escape to close the agent pane", async () => {
+ const h = fixture();
+ const user = userEvent.setup();
+ const close = vi.fn();
+ renderTabs(h, close);
+ const harness = await screen.findByRole("button", {
+ name: "Harness: Builderlab",
+ });
+ act(() => harness.focus());
+ expect(
+ await screen.findByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeVisible();
+ await user.keyboard("{Escape}");
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeNull(),
+ );
+ expect(screen.getByRole("complementary", { name: "Agent" })).toBeVisible();
+ expect(harness).toHaveFocus();
+ expect(close).not.toHaveBeenCalled();
+ await user.keyboard("{Escape}");
+ expect(close).toHaveBeenCalledOnce();
+});
+
it("keeps hosted instructions on cancellation and resets a confirmed discard", async () => {
const h = fixture();
const user = userEvent.setup();
renderTabs(h);
expect(screen.queryByRole("tab", { name: "Instructions" })).toBeNull();
- const field = await screen.findByRole("textbox", { name: "Instructions" });
- expect(screen.getByRole("link", { name: "Builderlab" })).toHaveAttribute(
- "href",
- "https://builderlab.example",
+ expect(screen.getByRole("tab", { name: "Identity" })).toHaveAttribute(
+ "aria-selected",
+ "true",
);
+ const field = await screen.findByRole("textbox", { name: "Instructions" });
+ expect(await screen.findByRole("button", { name: /^Model:/ })).toBeVisible();
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toBeVisible();
+ expect(screen.queryByText("Workspace")).toBeNull();
+ expect(screen.queryByRole("link", { name: "Builderlab" })).toBeNull();
await user.type(field, " Unsaved.");
await user.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByRole("alertdialog")).toHaveTextContent(
@@ -195,7 +264,7 @@ it("keeps hosted instructions on cancellation and resets a confirmed discard", a
);
await user.click(screen.getByRole("button", { name: "Keep editing" }));
expect(field).toHaveValue("First instructions. Unsaved.");
- const save = screen.getByRole("button", { name: "Save instructions" });
+ const save = screen.getByRole("button", { name: "Save" });
const pending = deferred();
h.request.mockReturnValueOnce(pending.promise);
try {
@@ -226,14 +295,25 @@ it("keeps hosted instructions on cancellation and resets a confirmed discard", a
expect(screen.getByText("Workspace")).toBeVisible();
expect(screen.getByText("One session per thread")).toBeVisible();
expect(screen.getByText("Agent settings")).toBeVisible();
- expect(await screen.findByRole("combobox", { name: "Model" })).toBeVisible();
+ const runtime = screen.getByRole("link", { name: "Builderlab" });
+ expect(runtime).toHaveAttribute("href", "https://builderlab.example");
+ expect(runtime.closest(".buzz-preference-row-description")).toHaveTextContent(
+ "Hosted and managed by Builderlab.",
+ );
+ expect(runtime.closest(".buzz-settings-group")).toHaveTextContent("Runtime");
+ expect(runtime.closest(".buzz-settings-group")).toHaveTextContent(
+ "Workspace",
+ );
+ expect(runtime.closest(".buzz-settings-group")).toHaveTextContent("Sessions");
+ expect(screen.queryByRole("button", { name: /^Model:/ })).toBeNull();
+ expect(screen.queryByRole("combobox", { name: "Thinking" })).toBeNull();
await user.click(screen.getByRole("tab", { name: "Identity" }));
const fresh = await screen.findByRole("textbox", { name: "Instructions" });
expect(fresh).not.toBe(field);
expect(fresh).toHaveValue("First instructions.");
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText("Workspace")).toBeNull();
- expect(screen.queryByRole("combobox", { name: "Model" })).toBeNull();
+ expect(await screen.findByRole("button", { name: /^Model:/ })).toBeVisible();
});
it("guards hosted model drafts and a pending model save", async () => {
@@ -241,47 +321,116 @@ it("guards hosted model drafts and a pending model save", async () => {
const user = userEvent.setup();
renderTabs(h);
await screen.findByRole("textbox", { name: "Instructions" });
- await user.click(screen.getByRole("tab", { name: "Settings" }));
- await user.click(await screen.findByRole("combobox", { name: "Model" }));
+ const modelTrigger = await screen.findByRole("button", { name: /^Model:/ });
+ await waitFor(() => expect(modelTrigger).toBeEnabled());
+ await user.click(modelTrigger);
+ (await screen.findByRole("menuitem", { name: "Other" })).focus();
+ await user.keyboard("{ArrowRight}");
await user.click(
- await screen.findByRole("option", { name: "Friendly Model" }),
+ await screen.findByRole("menuitemradio", { name: "Friendly Model" }),
);
- expect(screen.getByRole("button", { name: "Save model" })).toBeDisabled();
- await user.click(screen.getByRole("tab", { name: "Identity" }));
+ expect(
+ screen.queryByRole("button", { name: "Save" }),
+ ).not.toBeInTheDocument();
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByRole("alertdialog")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Keep editing" }));
- expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent(
+ expect(screen.getByRole("button", { name: /^Model:/ })).toHaveTextContent(
"Friendly Model",
);
- await user.click(screen.getByRole("combobox", { name: "Reasoning effort" }));
- await user.click(await screen.findByRole("option", { name: "high" }));
+ await user.click(screen.getByRole("combobox", { name: "Thinking" }));
+ await user.click(await screen.findByRole("option", { name: "High" }));
const pending = deferred();
h.request.mockReturnValueOnce(pending.promise);
try {
- await user.click(screen.getByRole("button", { name: "Save model" }));
- expect(screen.getByRole("tab", { name: "Identity" })).toHaveAttribute(
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute(
"aria-disabled",
"true",
);
- await user.click(screen.getByRole("tab", { name: "Identity" }));
- expect(screen.getByRole("combobox", { name: "Model" })).toBeVisible();
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ expect(screen.getByRole("button", { name: /^Model:/ })).toBeVisible();
} finally {
await act(async () => pending.reject(new Error("offline")));
}
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not reach Builderlab",
);
- await user.click(screen.getByRole("tab", { name: "Identity" }));
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(screen.getByRole("button", { name: "Discard changes" }));
+ expect(screen.getByText("Agent settings")).toBeVisible();
+ expect(screen.queryByRole("button", { name: /^Model:/ })).toBeNull();
+ await user.click(screen.getByRole("tab", { name: "Identity" }));
expect(
await screen.findByRole("textbox", { name: "Instructions" }),
).toHaveValue("First instructions.");
- await user.click(screen.getByRole("tab", { name: "Settings" }));
expect(
- await screen.findByRole("combobox", { name: "Model" }),
+ await screen.findByRole("button", { name: /^Model:/ }),
).toHaveTextContent("Choose a model");
- expect(
- screen.getByRole("combobox", { name: "Reasoning effort" }),
- ).toHaveTextContent("Choose reasoning effort");
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveTextContent(
+ "Choose thinking",
+ );
expect(screen.queryByRole("alert")).toBeNull();
});
+
+it("saves model and instructions together through one footer action", async () => {
+ const h = fixture();
+ const user = userEvent.setup();
+ renderTabs(h);
+ const field = await screen.findByRole("textbox", { name: "Instructions" });
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ const modelTrigger = await screen.findByRole("button", { name: /^Model:/ });
+ await waitFor(() => expect(modelTrigger).toBeEnabled());
+ await user.click(modelTrigger);
+ (await screen.findByRole("menuitem", { name: "Other" })).focus();
+ await user.keyboard("{ArrowRight}");
+ await user.click(
+ await screen.findByRole("menuitemradio", { name: "Friendly Model" }),
+ );
+ await user.type(field, " Updated.");
+ // A partial model draft cannot be silently skipped by Save.
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull();
+ await user.click(screen.getByRole("combobox", { name: "Thinking" }));
+ await user.click(await screen.findByRole("option", { name: "High" }));
+ const save = screen.getByRole("button", { name: "Save" });
+ expect(screen.getByTestId("save-footer")).toContainElement(save);
+ expect(save).toHaveAttribute("form", field.closest("form")?.id);
+ await user.click(save);
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull(),
+ );
+ const updates = h.request.mock.calls.filter(([request]) =>
+ request.url.endsWith("/update-agent"),
+ );
+ expect(updates).toHaveLength(1);
+ expect(JSON.parse(updates[0]?.[0].body ?? "{}")).toEqual({
+ agent_pubkey: first,
+ agent_instructions: "First instructions. Updated.",
+ model_id: "one",
+ reasoning_effort: "high",
+ });
+ expect(field).toHaveValue("First instructions. Updated.");
+ expect(modelTrigger).toHaveTextContent("Friendly Model");
+ await user.click(screen.getByRole("tab", { name: "Settings" }));
+ expect(screen.getByText("Agent settings")).toBeVisible();
+ expect(screen.queryByRole("alertdialog")).toBeNull();
+});
+
+it("keeps runtime settings available without loading Builderlab configuration", () => {
+ const h = fixture();
+ render(
+ Agent settings
}
+ />,
+ { wrapper: StrictMode },
+ );
+ expect(screen.getByText("Workspace")).toBeVisible();
+ expect(screen.getByText("One session per thread")).toBeVisible();
+ expect(screen.getByText("Agent settings")).toBeVisible();
+ expect(screen.queryByRole("textbox", { name: "Instructions" })).toBeNull();
+ expect(screen.queryByRole("button", { name: /^Model:/ })).toBeNull();
+ expect(h.request).not.toHaveBeenCalled();
+});
diff --git a/src/bundled/builderlab/adapter-type/Instructions.tsx b/src/bundled/builderlab/adapter-type/Instructions.tsx
index 9fcd9d97b7..42ee488a01 100644
--- a/src/bundled/builderlab/adapter-type/Instructions.tsx
+++ b/src/bundled/builderlab/adapter-type/Instructions.tsx
@@ -1,53 +1,29 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import type { AgentViewProps } from "../../../features/agents/service";
-import { useAgentSaveState } from "../../../features/agents/AgentSaveButton";
import { Button } from "../../../shared/design-system/ui/Button";
-import { Field } from "../../../shared/design-system/ui/Field";
import { PreferenceRow } from "../../../shared/design-system/ui/PreferenceRow";
import { SettingsGroup } from "../../../shared/design-system/ui/SettingsGroup";
-import { Textarea } from "../../../shared/design-system/ui/Textarea";
import inlineStyles from "../../../shared/InlineReference.module.css";
-import {
- MAX_INSTRUCTIONS_LENGTH,
- validInstructions,
- type AgentClient,
- type RemoteAgent,
-} from "../agents/client";
+import type { AgentClient, RemoteAgent } from "../agents/client";
import type { OAuthSession } from "../oauth/session";
import { ModelSettings } from "./ModelSettings";
const message = (reason: unknown) =>
reason instanceof Error
? reason.message
- : "Could not update Builderlab instructions.";
+ : "Could not load Builderlab settings.";
/** Hosted settings belong to Builderlab, rather than Agents' local config. */
export function createSettingsTab(client: AgentClient, session: OAuthSession) {
- function Editor({
- pubkey,
- retry,
- section,
- }: {
- pubkey: string;
- retry(): void;
- section: AgentViewProps["settingsSection"];
- }) {
+ function Editor({ pubkey, retry }: { pubkey: string; retry(): void }) {
const [loaded, setLoaded] = useState<{
agent: RemoteAgent;
subject: string;
}>();
- const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
- useAgentSaveState(
- !!loaded && draft !== (loaded.agent.instructions ?? ""),
- !!loaded && busy,
- section !== "runtime",
- );
- const controller = useRef(null);
useEffect(() => {
const current = new AbortController();
- controller.current = current;
const load = async () => {
await session.ensureSignedIn(current.signal);
const credential = session.credential();
@@ -60,11 +36,8 @@ export function createSettingsTab(client: AgentClient, session: OAuthSession) {
if (!remote)
throw new Error("Agent not found in this Builderlab account.");
if (remote.status !== "Active")
- throw new Error(
- "Only Active agents can have their instructions edited.",
- );
+ throw new Error("Only Active agents can have their settings edited.");
setLoaded({ agent: remote, subject: credential.account.subject });
- setDraft(remote.instructions ?? "");
};
void load()
.catch((reason: unknown) => {
@@ -75,101 +48,31 @@ export function createSettingsTab(client: AgentClient, session: OAuthSession) {
});
return () => current.abort();
}, [pubkey]);
- const submit = async () => {
- const signal = controller.current?.signal;
- if (!loaded || !signal || busy || !validInstructions(draft)) return;
- setBusy(true);
- setError("");
- try {
- await session.ensureSignedIn(signal);
- if (session.credential().account.subject !== loaded.subject)
- throw new Error(
- "Sign in to the Builderlab account that loaded these instructions.",
- );
- await client.updateInstructions(loaded.agent, draft, signal);
- signal.throwIfAborted();
- setLoaded({
- ...loaded,
- agent: { ...loaded.agent, instructions: draft },
- });
- } catch (reason) {
- if (!signal.aborted) setError(message(reason));
- } finally {
- if (!signal.aborted) setBusy(false);
- }
- };
return (
{loaded && (
-
-
-
+
)}
-
+ ))}
+ {error && (
+
+ {error}
+
+ )}
);
}
@@ -181,15 +84,33 @@ export function createSettingsTab(client: AgentClient, session: OAuthSession) {
const [revision, setRevision] = useState(0);
return (
-
setRevision((value) => value + 1)}
- section={settingsSection}
- />
+ {settingsSection !== "runtime" && (
+ setRevision((value) => value + 1)}
+ />
+ )}
{settingsSection !== "identity" && (
<>
+
+ Hosted and managed by{" "}
+
+ Builderlab
+
+ .
+ >
+ }
+ />
)}
-
- Remote runtime managed via{" "}
-
- Builderlab
-
-
);
};
diff --git a/src/bundled/builderlab/adapter-type/ModelSettings.test.tsx b/src/bundled/builderlab/adapter-type/ModelSettings.test.tsx
index 796e62e24f..5b88c7383c 100644
--- a/src/bundled/builderlab/adapter-type/ModelSettings.test.tsx
+++ b/src/bundled/builderlab/adapter-type/ModelSettings.test.tsx
@@ -1,11 +1,15 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
-import { act, cleanup, render, screen } from "@testing-library/react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StrictMode } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Host } from "../../../features/host/service";
-import { createAgentClient, type RemoteAgent } from "../agents/client";
+import {
+ createAgentClient,
+ type RemoteAgent,
+ type RemoteAgentModel,
+} from "../agents/client";
import { createOAuthSession } from "../oauth/session";
import { ModelSettings } from "./ModelSettings";
import { ToastProvider } from "../../../shared/design-system/ui/Toast";
@@ -17,7 +21,10 @@ afterEach(() => {
cleanup();
vi.unstubAllEnvs();
});
-async function fixture(selection: Partial = {}) {
+async function fixture(
+ selection: Partial = {},
+ catalog?: readonly RemoteAgentModel[],
+) {
let agent: RemoteAgent = {
id: "agent",
name: "Helper",
@@ -38,7 +45,11 @@ async function fixture(selection: Partial = {}) {
let result: unknown;
if (url.endsWith("/get-model-catalog"))
result = {
- models: [
+ models: catalog?.map((model) => ({
+ id: model.id,
+ display_name: model.displayName,
+ supported_reasoning_efforts: model.supportedReasoningEfforts,
+ })) ?? [
{
id: "model-one",
display_name: "Friendly One",
@@ -89,7 +100,7 @@ async function fixture(selection: Partial = {}) {
),
});
- await screen.findByRole("combobox", { name: "Model" });
+ await waitFor(() => expect(screen.queryByText("Loading models…")).toBeNull());
return {
request,
session,
@@ -105,27 +116,108 @@ async function fixture(selection: Partial = {}) {
}
async function choose(label: string, value: string) {
const user = userEvent.setup();
- await user.click(screen.getByRole("combobox", { name: label }));
- await user.click(await screen.findByRole("option", { name: value }));
+ if (label === "Model") {
+ const trigger = screen.getByRole("button", { name: /^Model:/ });
+ await waitFor(() => expect(trigger).toBeEnabled());
+ await user.click(trigger);
+ (await screen.findByRole("menuitem", { name: "Other" })).focus();
+ await user.keyboard("{ArrowRight}");
+ await user.click(await screen.findByRole("menuitemradio", { name: value }));
+ } else {
+ await user.click(screen.getByRole("combobox", { name: label }));
+ await user.click(await screen.findByRole("option", { name: value }));
+ }
}
-it("requires an explicit supported pair, clears effort when the model changes, and reads back server validity", async () => {
+it("explains the disabled harness selector on hover and keyboard focus without offering migration", async () => {
const h = await fixture();
- expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent(
+ const user = userEvent.setup();
+ const harness = screen.getByRole("button", { name: "Harness: Builderlab" });
+ expect(harness).toHaveAttribute("aria-disabled", "true");
+ await user.hover(harness);
+ expect(
+ await screen.findByRole("dialog", { name: "Builderlab harness" }),
+ ).toHaveTextContent(
+ "Builderlab holds this agent’s signing key. Buzz can’t move that identity to a local harness yet. To run locally, create a new agent.",
+ );
+ await user.unhover(harness);
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeNull(),
+ );
+ await user.tab();
+ expect(harness).toHaveFocus();
+ expect(
+ await screen.findByRole("dialog", { name: "Builderlab harness" }),
+ ).toHaveTextContent("Builderlab holds this agent’s signing key.");
+ expect(harness).toHaveAccessibleDescription(
+ "Builderlab holds this agent’s signing key. Buzz can’t move that identity to a local harness yet. To run locally, create a new agent.",
+ );
+ await user.keyboard("{Escape}");
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeNull(),
+ );
+ expect(harness).toHaveFocus();
+ await user.keyboard("{Enter}");
+ expect(
+ await screen.findByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeVisible();
+ await user.click(harness);
+ expect(
+ screen.getByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeVisible();
+ await user.keyboard("{Escape}");
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeNull(),
+ );
+ await user.keyboard(" ");
+ expect(
+ await screen.findByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeVisible();
+ await user.click(harness);
+ expect(
+ screen.getByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeVisible();
+ await user.click(screen.getByRole("textbox", { name: "Instructions" }));
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("dialog", { name: "Builderlab harness" }),
+ ).toBeNull(),
+ );
+ expect(screen.queryByRole("menu")).toBeNull();
+ expect(
+ h.request.mock.calls.filter(([request]) =>
+ request.url.endsWith("/update-agent"),
+ ),
+ ).toHaveLength(0);
+});
+
+it("requires an explicit supported pair, clears effort when the model changes, and reads back server validity", async () => {
+ const h = await fixture({ instructions: "Saved instructions." });
+ expect(screen.getByRole("button", { name: /^Model:/ })).toHaveTextContent(
"Choose a model",
);
- expect(screen.getByRole("button", { name: "Save model" })).toBeDisabled();
+ expect(
+ screen.queryByRole("button", { name: "Save" }),
+ ).not.toBeInTheDocument();
await choose("Model", "Friendly One");
- expect(screen.getByRole("button", { name: "Save model" })).toBeDisabled();
- await choose("Reasoning effort", "high");
+ expect(
+ screen.queryByRole("button", { name: "Save" }),
+ ).not.toBeInTheDocument();
+ await choose("Thinking", "High");
await choose("Model", "Friendly Two");
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveTextContent(
+ "Choose thinking",
+ );
expect(
- screen.getByRole("combobox", { name: "Reasoning effort" }),
- ).toHaveTextContent("Choose reasoning effort");
- expect(screen.getByRole("button", { name: "Save model" })).toBeDisabled();
- await choose("Reasoning effort", "none");
- await userEvent
- .setup()
- .click(screen.getByRole("button", { name: "Save model" }));
+ screen.queryByRole("button", { name: "Save" }),
+ ).not.toBeInTheDocument();
+ await choose("Thinking", "None");
+ await userEvent.setup().click(screen.getByRole("button", { name: "Save" }));
expect(
await screen.findByRole("dialog", { name: "Model preferences saved" }),
).toBeVisible();
@@ -139,20 +231,148 @@ it("requires an explicit supported pair, clears effort when the model changes, a
model_id: "model-two",
reasoning_effort: "none",
});
- expect(screen.getByRole("button", { name: "Save model" })).toBeDisabled();
+ expect(screen.getByRole("textbox", { name: "Instructions" })).toHaveValue(
+ "Saved instructions.",
+ );
+ expect(
+ screen.queryByRole("button", { name: "Save" }),
+ ).not.toBeInTheDocument();
+});
+it("can clear instructions without changing a model when the catalog is empty", async () => {
+ const h = await fixture({ instructions: "Saved instructions." }, []);
+ const user = userEvent.setup();
+ await user.clear(screen.getByRole("textbox", { name: "Instructions" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull(),
+ );
+ const updates = h.request.mock.calls.filter(([request]) =>
+ request.url.endsWith("/update-agent"),
+ );
+ expect(updates).toHaveLength(1);
+ expect(JSON.parse(updates[0]?.[0].body ?? "{}")).toEqual({
+ agent_pubkey: "ab".repeat(32),
+ agent_instructions: "",
+ });
+ expect(screen.getByRole("textbox", { name: "Instructions" })).toHaveValue("");
});
+
+it("preserves the combined draft when model readback fails and retries one combined update", async () => {
+ const h = await fixture({ instructions: "Saved instructions." });
+ const user = userEvent.setup();
+ await choose("Model", "Friendly One");
+ await choose("Thinking", "High");
+ const field = screen.getByRole("textbox", { name: "Instructions" });
+ await user.type(field, " Updated.");
+ const implementation = h.request.getMockImplementation();
+ if (!implementation) throw new Error("Missing request fixture");
+ h.request
+ .mockImplementationOnce(implementation)
+ .mockResolvedValueOnce({ status: 400, headers: {}, body: "{}" });
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("HTTP 400");
+ expect(field).toHaveValue("Saved instructions. Updated.");
+ expect(screen.getByRole("button", { name: /^Model:/ })).toHaveTextContent(
+ "Friendly One",
+ );
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveTextContent(
+ "High",
+ );
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: "Save" })).toBeNull(),
+ );
+ expect(screen.queryByRole("alert")).toBeNull();
+ const updates = h.request.mock.calls.filter(([request]) =>
+ request.url.endsWith("/update-agent"),
+ );
+ expect(updates).toHaveLength(2);
+ for (const [request] of updates)
+ expect(JSON.parse(request.body ?? "{}")).toEqual({
+ agent_pubkey: "ab".repeat(32),
+ agent_instructions: "Saved instructions. Updated.",
+ model_id: "model-one",
+ reasoning_effort: "high",
+ });
+});
+
+it("refuses a combined update for an agent that is no longer Active", async () => {
+ const h = await fixture({ status: "Revoked" });
+ const user = userEvent.setup();
+ await choose("Model", "Friendly One");
+ await choose("Thinking", "High");
+ await user.type(
+ screen.getByRole("textbox", { name: "Instructions" }),
+ "Changed.",
+ );
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Only Active agents",
+ );
+ expect(
+ h.request.mock.calls.filter(([request]) =>
+ request.url.endsWith("/update-agent"),
+ ),
+ ).toHaveLength(0);
+});
+
+it("uses Builderlab families in the shared menu and keeps unknown catalog choices selectable", async () => {
+ await fixture(
+ {
+ modelId: "opaque-id",
+ reasoningEffort: "high",
+ modelSelectionStatus: "valid",
+ },
+ [
+ {
+ id: "opaque-id",
+ displayName: "Sol 6.1",
+ supportedReasoningEfforts: ["high"],
+ },
+ {
+ id: "future-id",
+ displayName: "Future Model",
+ supportedReasoningEfforts: ["none"],
+ },
+ ],
+ );
+ const trigger = screen.getByRole("button", { name: "Model: Sol 6.1" });
+ await waitFor(() => expect(trigger).toBeEnabled());
+ expect(trigger.querySelector("svg")).toHaveAttribute(
+ "viewBox",
+ "176 176 364 364",
+ );
+ const user = userEvent.setup();
+ await user.click(trigger);
+ expect(await screen.findByRole("menuitem", { name: "OpenAI" })).toBeVisible();
+ expect(screen.queryByRole("menuitemradio", { name: /Default/ })).toBeNull();
+ const other = screen.getByRole("menuitem", { name: "Other" });
+ other.focus();
+ await user.keyboard("{ArrowRight}");
+ await user.click(
+ await screen.findByRole("menuitemradio", { name: "Future Model" }),
+ );
+ expect(trigger).toHaveTextContent("Future Model");
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveTextContent(
+ "Choose thinking",
+ );
+ expect(
+ screen.queryByRole("button", { name: "Save" }),
+ ).not.toBeInTheDocument();
+});
+
it("shows no warning or success message when opening valid saved preferences", async () => {
await fixture({
modelId: "model-one",
reasoningEffort: "high",
modelSelectionStatus: "valid",
});
- expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent(
+ expect(screen.getByRole("button", { name: /^Model:/ })).toHaveTextContent(
"Friendly One",
);
- expect(
- screen.getByRole("combobox", { name: "Reasoning effort" }),
- ).toHaveTextContent("high");
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveTextContent(
+ "High",
+ );
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "Model preferences saved" }),
@@ -180,31 +400,33 @@ it.each([
selection.modelId === "retired" ? "retired" : "Friendly Two",
);
expect(
- screen.getByRole("combobox", { name: "Reasoning effort" }),
- ).toHaveTextContent("Choose reasoning effort");
- expect(screen.getByRole("button", { name: "Save model" })).toBeDisabled();
+ screen.getByRole("combobox", { name: "Thinking" }),
+ ).toHaveTextContent("Choose thinking");
+ expect(
+ screen.queryByRole("button", { name: "Save" }),
+ ).not.toBeInTheDocument();
},
);
it("preserves a failed draft and refuses to save it through a different account", async () => {
const h = await fixture();
await choose("Model", "Friendly One");
- await choose("Reasoning effort", "low");
+ await choose("Thinking", "Low");
h.offline(true);
const user = userEvent.setup();
- await user.click(screen.getByRole("button", { name: "Save model" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent("HTTP 400");
expect(
screen.queryByRole("dialog", { name: "Model preferences saved" }),
).not.toBeInTheDocument();
- expect(
- screen.getByRole("combobox", { name: "Reasoning effort" }),
- ).toHaveTextContent("low");
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveTextContent(
+ "Low",
+ );
h.offline(false);
await act(async () => h.changeAccount());
const before = h.request.mock.calls.length;
- await user.click(screen.getByRole("button", { name: "Save model" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
- "account that loaded these model settings",
+ "account that loaded these settings",
);
expect(h.request).toHaveBeenCalledTimes(before);
});
@@ -212,10 +434,10 @@ it("preserves a failed draft and refuses to save it through a different account"
it("retries a failed catalog refresh without retrying a failed save", async () => {
const h = await fixture();
await choose("Model", "Friendly One");
- await choose("Reasoning effort", "low");
+ await choose("Thinking", "Low");
const user = userEvent.setup();
h.offline(true);
- await user.click(screen.getByRole("button", { name: "Save model" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
await user.click(
await screen.findByRole("button", { name: "Refresh models" }),
);
@@ -225,15 +447,15 @@ it("retries a failed catalog refresh without retrying a failed save", async () =
await user.click(retry);
await screen.findByRole("button", { name: "Refresh models" });
expect(screen.getByRole("alert")).toHaveTextContent("HTTP 400");
- expect(
- screen.getByRole("combobox", { name: "Reasoning effort" }),
- ).toHaveTextContent("low");
+ expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveTextContent(
+ "Low",
+ );
expect(
h.request.mock.calls.filter(([request]) =>
request.url.endsWith("/update-agent"),
),
).toHaveLength(1);
- await user.click(screen.getByRole("button", { name: "Save model" }));
+ await user.click(screen.getByRole("button", { name: "Save" }));
expect(
await screen.findByRole("dialog", { name: "Model preferences saved" }),
).toBeVisible();
diff --git a/src/bundled/builderlab/adapter-type/ModelSettings.tsx b/src/bundled/builderlab/adapter-type/ModelSettings.tsx
index e08ec8fd8e..7b55ed27c1 100644
--- a/src/bundled/builderlab/adapter-type/ModelSettings.tsx
+++ b/src/bundled/builderlab/adapter-type/ModelSettings.tsx
@@ -1,25 +1,38 @@
-import { useEffect, useRef, useState } from "react";
-import { useAgentSaveState } from "../../../features/agents/AgentSaveButton";
+import { useEffect, useId, useRef, useState } from "react";
+import { AgentSaveButton } from "../../../features/agents/AgentSaveButton";
+import { ModelFamilyMenu } from "../../../features/agents/ModelFamilyMenu";
+import { CaretDownIcon } from "../../../shared/design-system/icons";
import { Button } from "../../../shared/design-system/ui/Button";
-import { InlineHeader } from "../../../shared/design-system/ui/Header";
+import { Field } from "../../../shared/design-system/ui/Field";
+import { PreferenceRow } from "../../../shared/design-system/ui/PreferenceRow";
+import {
+ PopoverRoot,
+ PopoverTrigger,
+ PopoverPopup,
+ PopoverDescription,
+} from "../../../shared/design-system/ui/Popover";
import { Select } from "../../../shared/design-system/ui/Select";
import { SettingsGroup } from "../../../shared/design-system/ui/SettingsGroup";
+import { Textarea } from "../../../shared/design-system/ui/Textarea";
import { useToastNotification } from "../../../shared/design-system/ui/Toast";
-import type {
- AgentClient,
- RemoteAgent,
- RemoteAgentModel,
+import {
+ MAX_INSTRUCTIONS_LENGTH,
+ validInstructions,
+ type AgentClient,
+ type RemoteAgent,
+ type RemoteAgentModel,
} from "../agents/client";
import type { OAuthSession } from "../oauth/session";
+import { effortLabel, modelChoice } from "./models";
const statuses = {
- "setup-required": "Choose a model and reasoning effort to finish setup.",
+ "setup-required": "Choose a model and thinking level to finish setup.",
"model-unavailable":
- "The saved model is no longer available. Choose another model and reasoning effort.",
+ "The saved model is no longer available. Choose another model and thinking level.",
"effort-unsupported":
- "The saved reasoning effort is no longer supported. Choose a supported effort.",
+ "The saved thinking level is no longer supported. Choose a supported level.",
unknown:
- "The server has not confirmed the saved model selection. Choose a model and reasoning effort.",
+ "The server has not confirmed the saved model selection. Choose a model and thinking level.",
};
const message = (reason: unknown) =>
reason instanceof Error ? reason.message : "Could not load model settings.";
@@ -29,20 +42,24 @@ export function ModelSettings({
session,
agent,
subject,
- active = true,
}: {
client: AgentClient;
session: OAuthSession;
agent: RemoteAgent;
subject: string;
- active?: boolean;
}) {
+ const form = useId();
+ const harnessHelp = useId();
+ const [harnessHelpOpen, setHarnessHelpOpen] = useState(false);
const notify = useToastNotification();
const [saved, setSaved] = useState(agent);
const [models, setModels] = useState();
const [modelId, setModelId] = useState(agent.modelId ?? "");
const [effort, setEffort] = useState(agent.reasoningEffort ?? "");
- const [busy, setBusy] = useState(true);
+ const [instructions, setInstructions] = useState(agent.instructions ?? "");
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const busy = loading || saving;
const [error, setError] = useState("");
const [catalogError, setCatalogError] = useState("");
const [revision, setRevision] = useState(0);
@@ -55,7 +72,7 @@ export function ModelSettings({
// biome-ignore lint/correctness/useExhaustiveDependencies: revision retries a failed catalog request while keeping the draft.
useEffect(() => {
const current = new AbortController();
- setBusy(true);
+ setLoading(true);
setCatalogError("");
void client
.modelCatalog(current.signal)
@@ -68,36 +85,65 @@ export function ModelSettings({
},
)
.finally(() => {
- if (!current.signal.aborted) setBusy(false);
+ if (!current.signal.aborted) setLoading(false);
});
return () => current.abort();
}, [client, revision]);
const model = models?.find((choice) => choice.id === modelId);
const valid = !!model?.supportedReasoningEfforts.includes(effort);
- const changed =
+ const modelChanged =
modelId !== (saved.modelId ?? "") ||
effort !== (saved.reasoningEffort ?? "");
- useAgentSaveState(changed, busy, active);
+ const instructionsChanged = instructions !== (saved.instructions ?? "");
+ const saveModel =
+ modelChanged ||
+ (!instructionsChanged && saved.modelSelectionStatus !== "valid");
+ const disabled =
+ saving ||
+ !validInstructions(instructions) ||
+ (!instructionsChanged && !saveModel) ||
+ (saveModel && (loading || !valid));
const save = async () => {
- if (!valid || busy) return;
+ if (disabled) return;
const signal = controller.current?.signal;
if (!signal) return;
- setBusy(true);
+ setSaving(true);
setError("");
try {
await session.ensureSignedIn(signal);
if (session.credential().account.subject !== subject)
throw new Error(
- "Sign in to the Builderlab account that loaded these model settings.",
+ "Sign in to the Builderlab account that loaded these settings.",
);
- await client.updateModel(saved, modelId, effort, signal);
+ if (saved.status !== "Active")
+ throw new Error("Only Active agents can have their settings edited.");
+ if (saveModel && instructionsChanged)
+ await client.configure(
+ saved.pubkey,
+ {
+ instructions,
+ modelId,
+ reasoningEffort: effort,
+ },
+ signal,
+ );
+ else if (saveModel)
+ await client.updateModel(saved, modelId, effort, signal);
+ else await client.updateInstructions(saved, instructions, signal);
+ signal.throwIfAborted();
+ if (!saveModel) {
+ setSaved({ ...saved, instructions });
+ return;
+ }
const remote = (await client.list(signal)).find(
(row) => row.pubkey === saved.pubkey,
);
signal.throwIfAborted();
if (!remote)
throw new Error("Agent not found in this Builderlab account.");
- setSaved(remote);
+ // Model readback must not replace the instructions draft with an omitted
+ // or stale value. Only model fields need server validity confirmation.
+ setSaved({ ...remote, instructions });
setModelId(remote.modelId ?? "");
setEffort(remote.reasoningEffort ?? "");
if (remote.modelSelectionStatus === "valid")
@@ -105,103 +151,179 @@ export function ModelSettings({
} catch (reason) {
if (!signal.aborted) setError(message(reason));
} finally {
- if (!signal.aborted) setBusy(false);
+ if (!signal.aborted) setSaving(false);
}
};
return (
-
-
-
- {saved.modelSelectionStatus !== "valid" && (
-
- {statuses[saved.modelSelectionStatus ?? "unknown"]}
- {saved.modelId &&
- ` Saved model: ${models?.find((choice) => choice.id === saved.modelId)?.displayName ?? saved.modelId}.`}
- {saved.reasoningEffort &&
- ` Saved effort: ${saved.reasoningEffort}.`}
-
- )}
- {models ? (
- models.length ? (
- <>
- ({
- value: choice.id,
- label: choice.displayName,
- })),
- },
- ]}
- onValueChange={(next) => {
- setModelId(next);
- setEffort("");
+ {
+ event.preventDefault();
+ void save();
+ }}
+ >
+ {saved.modelSelectionStatus !== "valid" && (
+
+ {statuses[saved.modelSelectionStatus ?? "unknown"]}
+ {saved.modelId &&
+ ` Saved model: ${models?.find((choice) => choice.id === saved.modelId)?.displayName ?? saved.modelId}.`}
+ {saved.reasoningEffort &&
+ ` Saved thinking: ${effortLabel(saved.reasoningEffort)}.`}
+
+ )}
+
+
+ {
+ if (!open && details.reason === "trigger-press") {
+ details.cancel();
+ return;
+ }
+ setHarnessHelpOpen(open);
}}
- />
- ({ value, label: value }),
- ),
- },
- ]}
- onValueChange={setEffort}
- />
-
-
+ {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setHarnessHelpOpen(true);
+ }
+ }}
+ render={
+
+ setHarnessHelpOpen(true)}
+ >
+ Builderlab
+
+
+
}
- onClick={() => void save()}
+ />
+
- Save model
-
-
- >
- ) : (
+
+ Builderlab holds this agent’s signing key. Buzz can’t move
+ that identity to a local harness yet. To run locally, create
+ a new agent.
+
+
+
+
+ }
+ />
+ {!!models?.length && (
+ <>
+ {
+ setModelId(next);
+ setEffort("");
+ }}
+ />
+ ({ value, label: effortLabel(value) }),
+ ),
+ },
+ ]}
+ onValueChange={setEffort}
+ />
+ >
+ )}
+
+ {models
+ ? !models.length && (
No models are available. Retry when the catalog is available.
)
- ) : (
- busy && (
+ : loading && (
Loading models…
- )
- )}
- {(catalogError || error) && (
-
- {catalogError || error}
-
- )}
- {(catalogError || error || models?.length === 0) && (
-
- setRevision((value) => value + 1)}
- >
- {catalogError || models?.length === 0
- ? "Retry models"
- : "Refresh models"}
-
-
- )}
-
-
+ )}
+
+ setInstructions(event.target.value)}
+ disabled={saving}
+ rows={8}
+ maxLength={MAX_INSTRUCTIONS_LENGTH}
+ />
+
+
+ {(catalogError || error) && (
+
+ {error || catalogError}
+
+ )}
+ {(catalogError || error || models?.length === 0) && (
+
+ setRevision((value) => value + 1)}
+ >
+ {catalogError || models?.length === 0
+ ? "Retry models"
+ : "Refresh models"}
+
+
+ )}
+
);
}
diff --git a/src/bundled/builderlab/adapter-type/index.ts b/src/bundled/builderlab/adapter-type/index.ts
index 4be7589bee..93f8ba1c07 100644
--- a/src/bundled/builderlab/adapter-type/index.ts
+++ b/src/bundled/builderlab/adapter-type/index.ts
@@ -87,7 +87,7 @@ export function builderlabType(
?.supportedReasoningEfforts.includes(draft.reasoningEffort)
)
throw new Error(
- "Choose a model and reasoning effort offered in the model catalog.",
+ "Choose a model and thinking level offered in the model catalog.",
);
return draft;
}
diff --git a/src/bundled/builderlab/adapter-type/models.test.ts b/src/bundled/builderlab/adapter-type/models.test.ts
new file mode 100644
index 0000000000..5403a71eb6
--- /dev/null
+++ b/src/bundled/builderlab/adapter-type/models.test.ts
@@ -0,0 +1,37 @@
+import { expect, it } from "vitest";
+import { modelChoice } from "./models";
+import { modelFamily } from "../../../features/agents/ModelFamilyMenu";
+
+it.each([
+ ["Sol 6", "OpenAI"],
+ ["Sol 6.1", "OpenAI"],
+ ["Luna 6", "OpenAI"],
+ ["Astra 6", "OpenAI"],
+ ["Sonnet 5.5", "Claude"],
+ ["Opus 5.5", "Claude"],
+ ["Grok 4.7", "Grok"],
+ ["GLM 5.3", "GLM"],
+ ["GLM 5.3 Flash", "GLM"],
+ ["Kimi K3", "Kimi"],
+ ["DeepSeek 4.1 Flash", "DeepSeek"],
+ ["Gemini 3 Pro", "Gemini"],
+ ["Claude Sonnet 5", "Claude"],
+ ["GPT-6", "OpenAI"],
+ ["Future Model", "Other"],
+ ["Solstice", "Other"],
+ ["constructor", "Other"],
+])(
+ "maps the Builderlab catalog alias %s to %s without rewriting its identity",
+ (displayName, family) => {
+ const choice = modelChoice({
+ id: "opaque-provider-id",
+ displayName,
+ supportedReasoningEfforts: ["high"],
+ });
+ expect(choice).toMatchObject({
+ id: "opaque-provider-id",
+ label: displayName,
+ });
+ expect(modelFamily(choice)).toBe(family);
+ },
+);
diff --git a/src/bundled/builderlab/adapter-type/models.ts b/src/bundled/builderlab/adapter-type/models.ts
new file mode 100644
index 0000000000..9442861376
--- /dev/null
+++ b/src/bundled/builderlab/adapter-type/models.ts
@@ -0,0 +1,33 @@
+import type { ModelChoice } from "../../../features/agents/ModelPicker";
+import type { RemoteAgentModel } from "../agents/client";
+
+export const effortLabel = (id: string) =>
+ id === "xhigh" ? "Extra high" : `${id.charAt(0).toUpperCase()}${id.slice(1)}`;
+
+// Builderlab owns these catalog aliases. Keep this mapping in sync with its
+// catalog information until the API supplies a model-family field. Unknown
+// names fall through to shared inference; labels, IDs and efforts remain server-owned.
+const families = new Map([
+ ["sol", "OpenAI"],
+ ["luna", "OpenAI"],
+ ["astra", "OpenAI"],
+ ["sonnet", "Claude"],
+ ["opus", "Claude"],
+ ["grok", "Grok"],
+ ["glm", "GLM"],
+ ["kimi", "Kimi"],
+ ["deepseek", "DeepSeek"],
+]);
+
+export function modelChoice(model: RemoteAgentModel): ModelChoice {
+ const alias = model.displayName
+ .trim()
+ .toLowerCase()
+ .split(/[\s._-]/)[0];
+ const family = families.get(alias ?? "");
+ return {
+ id: model.id,
+ label: model.displayName,
+ ...(family ? { family } : {}),
+ };
+}
diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx
index 3cd793a3ab..822b431c9d 100644
--- a/src/bundled/builderlab/index.test.tsx
+++ b/src/bundled/builderlab/index.test.tsx
@@ -899,10 +899,10 @@ it.each([
);
await user.click(
within(createDialog).getByRole("combobox", {
- name: "Reasoning effort",
+ name: "Thinking",
}),
);
- await user.click(await screen.findByRole("option", { name: "high" }));
+ await user.click(await screen.findByRole("option", { name: "High" }));
await user.type(
within(createDialog).getByRole("textbox", {
name: "Instructions Optional",
@@ -978,9 +978,9 @@ it.each([
name: "Instructions",
});
expect(instructions).toHaveValue("Be concise.");
- const save = screen.getByRole("button", { name: "Save instructions" });
await user.clear(instructions);
await user.type(instructions, "New direction.");
+ const save = screen.getByRole("button", { name: "Save" });
instructionsSave = deferred();
const updatesBeforeEdit = requestsTo("update-agent").length;
try {
@@ -1014,8 +1014,10 @@ it.each([
await waitFor(() => expect(instructions).toBeEnabled());
expect(browserInstructions).toBe("");
+ await screen.findByRole("button", { name: /^Model:/ });
await user.click(screen.getByRole("tab", { name: "Settings" }));
- await screen.findByRole("combobox", { name: "Model" });
+ expect(screen.queryByRole("button", { name: /^Model:/ })).toBeNull();
+ expect(screen.getByText("Workspace")).toBeVisible();
// Confirmed deletion waits for login before provider or community removal.
await user.click(screen.getByRole("button", { name: "Sign out" }));
await user.click(screen.getByRole("button", { name: "Delete agent" }));
diff --git a/src/features/agents/ModelFamilyMenu.tsx b/src/features/agents/ModelFamilyMenu.tsx
index 61b2052f86..d17f4929cf 100644
--- a/src/features/agents/ModelFamilyMenu.tsx
+++ b/src/features/agents/ModelFamilyMenu.tsx
@@ -18,6 +18,7 @@ import {
/** Catalogs expose IDs, not a common family field. Unknown IDs remain available. */
export function modelFamily(model: ModelChoice): string {
+ if (model.family) return model.family;
const name = `${model.id} ${model.label}`.toLowerCase();
for (const [family, pattern] of [
["OpenAI", /codex|gpt|openai|(?:^|[\s/._-])o[134](?:[\s/._-]|$)/],
@@ -29,6 +30,7 @@ export function modelFamily(model: ModelChoice): string {
["Llama", /llama/],
["Mistral", /mistral|mixtral|codestral/],
["Grok", /grok/],
+ ["GLM", /(?:^|[\s/._-])glm(?:[\s/._-]|\d|$)|zhipu|z[.-]ai/],
] as const)
if (pattern.test(name)) return family;
return "Other";
@@ -51,6 +53,7 @@ export function ModelFamilyMenu({
disabled,
loading = false,
description,
+ align = "start",
onChange,
onCustom,
}: {
@@ -70,6 +73,7 @@ export function ModelFamilyMenu({
disabled: boolean;
loading?: boolean;
description?: string | undefined;
+ align?: "start" | "center" | "end";
onChange(id: string): void;
onCustom?: (() => void) | undefined;
}) {
@@ -101,7 +105,7 @@ export function ModelFamilyMenu({
)}
-
+
{/* Card labels keep Default accessible without crowding the model. */}
@@ -123,7 +127,7 @@ export function ModelFamilyMenu({
)}
/>
-
+
{defaultChoice && (
{
+ const user = userEvent.setup();
+ const reference = render( );
+ const artwork = reference.container.querySelector("svg")?.innerHTML;
+ reference.unmount();
+ render(
+ ,
+ );
+ const trigger = screen.getByRole("button", { name: /^Model:/ });
+ expect(trigger.querySelector("svg")?.innerHTML).toBe(artwork);
+ await user.click(trigger);
+ const submenu = await screen.findByRole("menuitem", { name: family });
+ expect(submenu.querySelector("svg")?.innerHTML).toBe(artwork);
+ },
+);
+
+it.each([
+ ["DeepSeek", "deepseek/deepseek-v3", DeepSeekLogoIcon],
+ ["Grok", "xai/grok-4", GrokLogoIcon],
+ ["GLM", "zhipu/glm-4.6", ZaiLogoIcon],
+ ["Kimi", "moonshot/kimi-k2", KimiLogoIcon],
+] as const)(
+ "uses the %s mark for catalog families and inferred model IDs",
+ async (family, modelId, Icon) => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ const reference = render( );
+ const artwork = reference.container.querySelector("svg")?.innerHTML;
+ function Picker() {
+ const [value, setValue] = useState("opaque-catalog-id");
+ return (
+ {
+ onChange(id);
+ setValue(id);
+ }}
+ />
+ );
+ }
+ render( );
+ const trigger = screen.getByRole("button", { name: /^Model:/ });
+ expect(trigger.querySelector("svg")?.innerHTML).toBe(artwork);
+ await user.click(trigger);
+ const submenu = await screen.findByRole("menuitem", { name: family });
+ expect(submenu.querySelector("svg")?.innerHTML).toBe(artwork);
+ submenu.focus();
+ await user.keyboard("{ArrowRight}");
+ await user.click(
+ await screen.findByRole("menuitemradio", { name: "Provider model" }),
+ );
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(modelId);
+ expect(trigger.querySelector("svg")?.innerHTML).toBe(artwork);
+ },
+);
+
const models: ModelChoice[] = [
{ id: "sonnet", label: "Sonnet" },
{ id: "opus", label: "Opus", description: "Most capable" },
@@ -317,6 +403,38 @@ it("opens a family before selecting a model and strips only its display prefix",
expect(onChange).toHaveBeenCalledExactlyOnceWith("databricks-gpt-6-codex");
});
+it("uses a catalog-owned family for grouping and the selected model icon", async () => {
+ const onChange = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ { wrapper: StrictMode },
+ );
+ const trigger = screen.getByRole("button", { name: /^Model:/ });
+ expect(trigger.querySelector("svg")).toHaveAttribute(
+ "viewBox",
+ "176 176 364 364",
+ );
+ await user.click(trigger);
+ const family = await screen.findByRole("menuitem", { name: "OpenAI" });
+ expect(screen.queryByRole("menuitem", { name: "Claude" })).toBeNull();
+ family.focus();
+ await user.keyboard("{ArrowRight}");
+ await user.click(
+ await screen.findByRole("menuitemradio", { name: "Sol 6.1" }),
+ );
+ expect(onChange).toHaveBeenCalledExactlyOnceWith("anthropic-looking-id");
+});
+
it("keeps custom entry and default selection in the family menu", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
diff --git a/src/features/agents/ModelPicker.tsx b/src/features/agents/ModelPicker.tsx
index 5c53736288..351af37c25 100644
--- a/src/features/agents/ModelPicker.tsx
+++ b/src/features/agents/ModelPicker.tsx
@@ -12,6 +12,10 @@ import {
GoogleLogoIcon,
MetaLogoIcon,
CodexLogoIcon,
+ DeepSeekLogoIcon,
+ GrokLogoIcon,
+ ZaiLogoIcon,
+ KimiLogoIcon,
RobotIcon,
} from "../../shared/design-system/icons";
import { Button } from "../../shared/design-system/ui/Button";
@@ -25,6 +29,8 @@ export type ModelChoice = Readonly<{
/** The model ID saved in the type's config. */
id: string;
label: string;
+ /** Catalog-owned family, when the plugin knows more than the ID/name matcher. */
+ family?: string;
description?: string | undefined;
efforts?: readonly { id: string; label: string }[];
effortScope?: string;
@@ -48,7 +54,15 @@ export function ModelIcon({ model }: { model: string }) {
? GoogleLogoIcon
: /llama/.test(name)
? MetaLogoIcon
- : RobotIcon;
+ : /deepseek/.test(name)
+ ? DeepSeekLogoIcon
+ : /grok/.test(name)
+ ? GrokLogoIcon
+ : /(?:^|[\s/._-])glm(?:[\s/._-]|\d|$)|zhipu|z[.-]ai/.test(name)
+ ? ZaiLogoIcon
+ : /kimi|moonshot/.test(name)
+ ? KimiLogoIcon
+ : RobotIcon;
return ;
}
diff --git a/src/shared/design-system/icons/HarnessLogos.tsx b/src/shared/design-system/icons/HarnessLogos.tsx
index d6c241fc83..6a4246c5c2 100644
--- a/src/shared/design-system/icons/HarnessLogos.tsx
+++ b/src/shared/design-system/icons/HarnessLogos.tsx
@@ -1,6 +1,7 @@
import { forwardRef } from "react";
import claudeLogoUrl from "./claude.png?inline";
import hermesLogoUrl from "./hermes.png?inline";
+import kimiLogoUrl from "./kimi.png?inline";
import type { IconProps } from "./createDecorativeIcon";
/** Goose's official mark, adapted from old Buzz. Apache-2.0 © Block, Inc.; see NOTICE.md. */
@@ -105,3 +106,107 @@ export const CodexLogoArtwork = forwardRef(
);
},
);
+
+/** DeepSeek’s MIT-licensed mark, from Wikimedia Commons; see NOTICE.md. */
+export const DeepSeekLogoArtwork = forwardRef(
+ function DeepSeekLogoArtwork({ size = "1em", ...props }, ref) {
+ return (
+ // biome-ignore lint/a11y/noSvgWithoutTitle: the public gateway supplies accessibility semantics.
+
+
+
+ );
+ },
+);
+
+/** Grok’s public-domain mark, extracted from its Commons wordmark; see NOTICE.md. */
+export const GrokLogoArtwork = forwardRef(
+ function GrokLogoArtwork({ size = "1em", ...props }, ref) {
+ return (
+ // biome-ignore lint/a11y/noSvgWithoutTitle: the public gateway supplies accessibility semantics.
+
+
+
+
+ );
+ },
+);
+
+/** Z.ai’s public-domain favicon identifies its GLM models; see NOTICE.md. */
+export const ZaiLogoArtwork = forwardRef(
+ function ZaiLogoArtwork({ size = "1em", ...props }, ref) {
+ return (
+ // biome-ignore lint/a11y/noSvgWithoutTitle: the public gateway supplies accessibility semantics.
+
+
+
+
+
+
+ );
+ },
+);
+
+/** Kimi’s official favicon, converted losslessly from ICO to PNG; see NOTICE.md. */
+export const KimiLogoArtwork = forwardRef(
+ function KimiLogoArtwork({ size = "1em", ...props }, ref) {
+ return (
+ // biome-ignore lint/a11y/noSvgWithoutTitle: the public gateway supplies accessibility semantics.
+
+
+
+ );
+ },
+);
diff --git a/src/shared/design-system/icons/index.ts b/src/shared/design-system/icons/index.ts
index 18931ddbf6..f7e150690b 100644
--- a/src/shared/design-system/icons/index.ts
+++ b/src/shared/design-system/icons/index.ts
@@ -10,6 +10,10 @@ import {
ClaudeLogoArtwork,
CodexLogoArtwork,
HermesLogoArtwork,
+ DeepSeekLogoArtwork,
+ GrokLogoArtwork,
+ ZaiLogoArtwork,
+ KimiLogoArtwork,
} from "./HarnessLogos";
// Brand artwork requested for Harness Settings; general UI glyphs remain Tabler.
@@ -28,6 +32,33 @@ export const PiLogoIcon = defineIcon("custom", PiLogoArtwork, {
intendedSizes: [{ width: 32, height: 32 }],
});
+export const DeepSeekLogoIcon = defineIcon("custom", DeepSeekLogoArtwork, {
+ meaning: "DeepSeek models",
+ category: "Custom brand mark",
+ provenance: "DeepSeek mark (MIT), from Wikimedia Commons; see NOTICE.md",
+ intendedSizes: [{ width: 20, height: 20 }],
+});
+export const GrokLogoIcon = defineIcon("custom", GrokLogoArtwork, {
+ meaning: "Grok models",
+ category: "Custom brand mark",
+ provenance:
+ "Grok mark (public domain), from Wikimedia Commons; see NOTICE.md",
+ intendedSizes: [{ width: 20, height: 20 }],
+});
+export const ZaiLogoIcon = defineIcon("custom", ZaiLogoArtwork, {
+ meaning: "Z.ai / GLM models",
+ category: "Custom brand mark",
+ provenance:
+ "Z.ai favicon (public domain), from Wikimedia Commons; see NOTICE.md",
+ intendedSizes: [{ width: 20, height: 20 }],
+});
+export const KimiLogoIcon = defineIcon("custom", KimiLogoArtwork, {
+ meaning: "Kimi models",
+ category: "Custom brand mark",
+ provenance: "Official Moonshot Kimi favicon, converted to PNG; see NOTICE.md",
+ intendedSizes: [{ width: 20, height: 20 }],
+});
+
import TablerArrowClockwiseIcon from "@tabler/icons-react/dist/esm/icons/IconRotateClockwise.mjs";
export const ArrowClockwiseIcon = defineIcon(
"tabler",
diff --git a/src/shared/design-system/icons/kimi.png b/src/shared/design-system/icons/kimi.png
new file mode 100644
index 0000000000..390565afad
Binary files /dev/null and b/src/shared/design-system/icons/kimi.png differ
diff --git a/src/shared/design-system/styles/forms.css b/src/shared/design-system/styles/forms.css
index f87fc7bd7d..d39e152bb9 100644
--- a/src/shared/design-system/styles/forms.css
+++ b/src/shared/design-system/styles/forms.css
@@ -333,7 +333,8 @@
max-width: 100%;
color: var(--text-standard);
}
- .buzz-select[data-variant="compact"] .buzz-button:disabled {
+ .buzz-select[data-variant="compact"]
+ .buzz-button:is(:disabled, [data-disabled]):not([data-loading]) {
color: var(--text-unavailable);
}
.buzz-select[data-variant="compact"] .buzz-button-label {