diff --git a/NOTICE.md b/NOTICE.md index 21b81b0c13..35ba69aad8 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -88,6 +88,36 @@ cropped the border, padded it to square, resized to 64×64 and quantised it to 16 colours. The MIT license terms above also apply to this artwork, with the Nous Research copyright notice. +## Model-family brand marks + +The additional artwork in `src/shared/design-system/icons/HarnessLogos.tsx` +identifies model families in shared pickers, including Builderlab and Goose. +All marks remain the property of their respective owners; their inclusion +identifies supported models and does not imply endorsement. + +DeepSeek: [DeepSeek-icon.svg](https://commons.wikimedia.org/wiki/File:DeepSeek-icon.svg), +file revision June 30, 2025. Copyright © DeepSeek, MIT (Expat) license. +The MIT terms reproduced above apply with this copyright notice. The original +blue fill and path geometry are preserved; this app adds its shared SVG wrapper. + +Grok: [Grok-feb-2025-logo.svg](https://commons.wikimedia.org/wiki/File:Grok-feb-2025-logo.svg), +by X.AI Corp., classified by Wikimedia Commons as public domain because it +consists of simple geometric shapes or text. This app retains the two mark +paths, omits the wordmark, crops the viewBox to square, and preserves the +original `currentColor` fill. Grok is a trademark of xAI. + +Z.ai / GLM: [Z.ai (company logo).svg](https://commons.wikimedia.org/wiki/File:Z.ai_(company_logo).svg), +by Z.ai, sourced from its [official favicon](https://z-cdn.chatglm.cn/z-ai/static/logo.svg). +Wikimedia Commons classifies the artwork as public domain because it consists +of simple geometric shapes or text. This app preserves the favicon geometry, +colors and border, removes unused styles and groups, and adds its shared SVG +wrapper. Z.ai and GLM are marks of their respective owner. + +Kimi: the official [Moonshot Kimi favicon](https://www.kimi.com/favicon-light.ico), +retrieved October 28, 2026, converted from its 48×48 ICO frame to the colocated +`icons/kimi.png` without resizing or changing colors. No open-source license is +asserted for this official brand asset. Kimi is a trademark of Moonshot AI. + ## Plugin catalog artwork The generated plugin PNG artwork in `src/bundled/*/icon.png` was created by diff --git a/src/bundled/agents/AgentSettingsHost.test.tsx b/src/bundled/agents/AgentSettingsHost.test.tsx new file mode 100644 index 0000000000..9b1aea2d1b --- /dev/null +++ b/src/bundled/agents/AgentSettingsHost.test.tsx @@ -0,0 +1,178 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import type { + Agent, + Agents, + RegisteredAgentType, +} from "../../features/agents/service"; +import { RobotIcon } from "../../shared/design-system/icons"; +import { useAgentSettingsHost } from "./AgentSettingsHost"; + +afterEach(cleanup); + +function fixture({ hosted = true, anotherLocal = true, place = "here" } = {}) { + const agent = { + key: "a".repeat(64), + pubkey: "a".repeat(64), + name: "Ada", + place, + type: "local/echo", + config: {}, + } as Agent; + const local = { + key: agent.type, + pluginId: "local", + revision: "1", + id: "echo", + title: "Echo", + defaults: () => ({ config: {} }), + } satisfies RegisteredAgentType; + const remote = { + ...local, + key: "hosted/builderlab", + pluginId: "hosted", + id: "builderlab", + title: "Builderlab", + icon: RobotIcon, + remote: { + provision: vi.fn(), + activate: vi.fn(), + profile: vi.fn(), + remove: vi.fn(), + }, + } satisfies RegisteredAgentType; + const next = { ...local, key: "local/next", id: "next", title: "Next" }; + const types = [ + local, + ...(hosted ? [remote] : []), + ...(anotherLocal ? [next] : []), + ]; + const previewRetype = vi.fn(() => ({ + config: {}, + moved: [], + dropped: [], + settings: [], + })); + const retype = vi.fn(); + const agents = { previewRetype, retype } as unknown as Agents; + function Host() { + const host = useAgentSettingsHost({ agents, agent, type: local, types }); + return ( + <> + {host.settingsHost.typeControl?.(false)} + {host.agent.type} + + {String(Boolean(host.settingsHost.pendingSave))} + + + ); + } + render(); + return { previewRetype, retype, remote }; +} + +it.each([false, true])( + "explains hosted choices without staging a migration (another local %s)", + async (anotherLocal) => { + const user = userEvent.setup(); + const { previewRetype, retype, remote } = fixture({ anotherLocal }); + await user.click( + screen.getByRole("button", { name: "Change harness from Echo" }), + ); + const hosted = await screen.findByRole("menuitemradio", { + name: "Builderlab", + }); + expect(hosted).toHaveAttribute("aria-disabled", "true"); + expect(hosted).not.toBeChecked(); + expect(hosted.querySelector("svg")).not.toBeNull(); + await user.hover(hosted); + const help = await screen.findByRole("dialog", { + name: "Builderlab hosting", + }); + expect(help).toHaveTextContent( + "This agent’s signing key is stored on this device. Buzz can’t move that identity to Builderlab yet. To run hosted, create a new Builderlab agent.", + ); + await user.hover(help); + expect(screen.getByRole("menu")).toBeVisible(); + expect(help).toBeVisible(); + await user.hover(hosted); + await user.click(hosted); + expect(screen.getByRole("menu")).toBeVisible(); + expect(screen.getByLabelText("Selected harness")).toHaveTextContent( + "local/echo", + ); + expect(screen.getByLabelText("Pending change")).toHaveTextContent("false"); + expect(previewRetype).not.toHaveBeenCalled(); + expect(retype).not.toHaveBeenCalled(); + expect(remote.remote.provision).not.toHaveBeenCalled(); + await user.unhover(hosted); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Builderlab hosting" }), + ).toBeNull(), + ); + await user.keyboard("{Home}{ArrowDown}{Enter}"); + expect(hosted).toHaveFocus(); + expect( + await screen.findByRole("dialog", { name: "Builderlab hosting" }), + ).toBeVisible(); + expect(hosted).toHaveAccessibleDescription(/signing key/); + await user.keyboard("{ArrowUp}"); + expect(screen.getByRole("menuitemradio", { name: "Echo" })).toHaveFocus(); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Builderlab hosting" }), + ).toBeNull(), + ); + await user.keyboard("{ArrowDown}{Enter}"); + expect( + await screen.findByRole("dialog", { name: "Builderlab hosting" }), + ).toBeVisible(); + await user.keyboard("{Escape}"); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Builderlab hosting" }), + ).toBeNull(), + ); + expect(screen.getByRole("menu")).toBeVisible(); + await user.keyboard("{Enter}"); + expect( + await screen.findByRole("dialog", { name: "Builderlab hosting" }), + ).toBeVisible(); + expect(previewRetype).not.toHaveBeenCalled(); + expect(retype).not.toHaveBeenCalled(); + }, +); + +it("keeps the existing local migration flow available", async () => { + const user = userEvent.setup(); + const { previewRetype, retype } = fixture({ hosted: false }); + await user.click( + screen.getByRole("button", { name: "Change harness from Echo" }), + ); + expect( + screen.queryByRole("menuitemradio", { name: "Builderlab" }), + ).toBeNull(); + await user.click(await screen.findByRole("menuitemradio", { name: "Next" })); + expect( + await screen.findByRole("dialog", { name: "Move Ada to Next?" }), + ).toBeVisible(); + expect(previewRetype).toHaveBeenCalledWith("a".repeat(64), "local/next"); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(screen.getByLabelText("Selected harness")).toHaveTextContent( + "local/next", + ); + expect(screen.getByLabelText("Pending change")).toHaveTextContent("true"); + expect(retype).not.toHaveBeenCalled(); +}); + +it.each(["hosted", "elsewhere"])( + "does not offer local migration for an agent %s", + (place) => { + fixture({ place }); + expect(screen.queryByRole("button", { name: /Change harness/ })).toBeNull(); + }, +); diff --git a/src/bundled/agents/AgentSettingsHost.tsx b/src/bundled/agents/AgentSettingsHost.tsx index c91ea3695d..76da4011c3 100644 --- a/src/bundled/agents/AgentSettingsHost.tsx +++ b/src/bundled/agents/AgentSettingsHost.tsx @@ -1,5 +1,5 @@ import { ProviderDialog } from "./ProviderDialog"; -import { useRef, useState } from "react"; +import { useId, useRef, useState } from "react"; import type { Agent, AgentSettingsHost, @@ -20,6 +20,12 @@ import { MenuRoot, MenuTrigger, } from "../../shared/design-system/ui/Menu"; +import { + PopoverRoot, + PopoverTrigger, + PopoverPopup, + PopoverDescription, +} from "../../shared/design-system/ui/Popover"; const message = (error: unknown) => error instanceof Error ? error.message : String(error); @@ -82,9 +88,8 @@ export function useAgentSettingsHost({ setPendingType(undefined); } : undefined; - const canRetype = - agent?.place === "here" && - types.some((item) => !item.remote && item.key !== agent.type); + const showTypeControl = + agent?.place === "here" && types.some((item) => item.key !== agent.type); return { agent: settingsAgent, type: settingsType, @@ -93,7 +98,7 @@ export function useAgentSettingsHost({ agents && agent ? { ...(pendingTypeSave ? { pendingSave: pendingTypeSave } : {}), - ...(canRetype + ...(showTypeControl ? { typeControl: (dirty: boolean) => ( !item.remote) : []; + const available = agent.place === "here" ? types : []; const others = available.filter((item) => item.key !== agent.type); const selectedType = available.find((item) => item.key === selected) ?? type; const TypeIcon = selectedType?.icon; @@ -156,7 +160,7 @@ function TypeField({ return; } const target = available.find((item) => item.key === next); - if (!target) return; + if (!target || target.remote) return; setError(""); try { setMigration({ @@ -224,6 +228,10 @@ function TypeField({ onValueChange={(next) => choose(String(next))} > {available.map((item) => { + if (item.remote) + return ( + + ); const Icon = item.icon; return ( (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( } />