From 05aab24cf89d033b001c827602b28bdda0ac7b7f Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 17:01:20 -0700 Subject: [PATCH 1/7] [remove-agents-v1-ui] Remove the v1 Agents page, Channel templates and agent-mention popups Delete the bundled Agents and Channel templates plugins and every v1 mention popup: wake notices, owner-review requests, the mention attach path in agent control, and snapshot previews. Adding an agent to a channel no longer starts it. Settings > Agents keeps only "Remember mentioned agents" and the saved activity archive. The shell's collapsible-sidebar, retry and icon special cases move from the v1 Agents page to Agents2. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen Signed-off-by: Matt Toohey --- crates/plugin-manager/src/lib.rs | 46 +- crates/plugin-manager/tests/management.rs | 3 +- src/app/AgentDefaultsCard.test.tsx | 1078 ----- src/app/AgentDefaultsCard.tsx | 900 ---- src/app/AgentSettings.module.css | 58 - src/app/AgentSettings.test.tsx | 1040 +---- src/app/AgentSettings.tsx | 703 +--- src/app/App.profile.test.tsx | 31 - src/app/App.tsx | 13 - src/app/ClaudeHarnessSetup.tsx | 170 - src/app/Settings.tsx | 4 - src/app/agent-control.integration.test.ts | 190 - src/app/agent-mention.integration.test.ts | 269 -- src/app/entity-navigation.test.tsx | 103 +- src/app/navigation.ts | 2 +- src/app/pages.integration.test.mjs | 55 +- src/app/services.ts | 2 - src/app/shell/AppShell.test.tsx | 2 +- src/app/shell/AppShell.tsx | 3 +- src/app/shell/presentation.test.ts | 2 +- src/app/shell/presentation.ts | 2 +- src/bundled/agents/AgentControlPanel.test.tsx | 347 -- src/bundled/agents/AgentControlPanel.tsx | 566 --- .../agents/AgentCreateDialog.catalog.test.tsx | 252 -- src/bundled/agents/AgentCreateDialog.test.tsx | 92 - src/bundled/agents/AgentCreateDialog.tsx | 593 --- src/bundled/agents/AgentDeleteDialog.tsx | 92 - src/bundled/agents/AgentEditor.tsx | 1 + src/bundled/agents/AgentImport.test.tsx | 161 - src/bundled/agents/AgentImport.tsx | 462 --- src/bundled/agents/AgentLibrary.test.tsx | 74 - src/bundled/agents/AgentLibrary.tsx | 160 - src/bundled/agents/AgentSnapshots.test.tsx | 1591 -------- src/bundled/agents/AgentSnapshots.tsx | 751 ---- .../AgentUpdateReview.component.test.tsx | 440 -- src/bundled/agents/AgentUpdateReview.test.tsx | 256 -- src/bundled/agents/AgentUpdateReview.tsx | 395 -- src/bundled/agents/AgentsPage.test.tsx | 3606 ----------------- src/bundled/agents/AgentsPage.tsx | 534 --- .../agents/CommunityCatalog.module.css | 51 - src/bundled/agents/CommunityCatalog.test.tsx | 1007 ----- src/bundled/agents/CommunityCatalog.tsx | 882 ---- src/bundled/agents/DirectShare.test.tsx | 447 -- src/bundled/agents/DirectShare.tsx | 312 -- .../agents/InventoryIdentityCard.test.tsx | 274 -- src/bundled/agents/InventoryIdentityCard.tsx | 408 -- src/bundled/agents/InventoryView.test.tsx | 313 -- src/bundled/agents/InventoryView.tsx | 319 -- .../agents/LocalInventoryAction.test.tsx | 61 - src/bundled/agents/LocalInventoryAction.tsx | 114 - src/bundled/agents/ManagedAgentActions.tsx | 231 -- src/bundled/agents/RelayAgentRemove.tsx | 85 - src/bundled/agents/SnapshotPreview.test.tsx | 277 -- src/bundled/agents/SnapshotPreview.tsx | 197 - .../agents/TeamCatalogReshare.test.tsx | 388 -- src/bundled/agents/TeamDeployDialog.test.tsx | 72 - src/bundled/agents/TeamDeployDialog.tsx | 114 - src/bundled/agents/TeamExportDialog.test.tsx | 128 - src/bundled/agents/TeamExportDialog.tsx | 154 - src/bundled/agents/TeamImportDialog.tsx | 160 - .../agents/UnifiedInventory.native.test.tsx | 158 - src/bundled/agents/UnifiedInventory.test.tsx | 1443 ------- src/bundled/agents/UnifiedInventory.tsx | 408 -- src/bundled/agents/community-profiles.test.ts | 66 - src/bundled/agents/community-profiles.ts | 44 - src/bundled/agents/edit-route.test.ts | 18 - src/bundled/agents/edit-route.ts | 13 - src/bundled/agents/identity-tiles.test.ts | 36 - src/bundled/agents/identity-tiles.ts | 19 - src/bundled/agents/index.tsx | 40 - src/bundled/agents/inventory-archive.ts | 91 - .../agents/inventory-decisions.test.ts | 121 - src/bundled/agents/inventory-decisions.ts | 52 - src/bundled/agents/inventory-model.test.ts | 114 - src/bundled/agents/inventory-model.ts | 107 - src/bundled/agents/manifest.json | 1 - src/bundled/agents/use-community-inventory.ts | 151 - .../ChannelTemplatesDialog.catalog.test.tsx | 184 - .../ChannelTemplatesDialog.teamText.test.tsx | 490 --- .../ChannelTemplatesDialog.test.tsx | 658 --- .../ChannelTemplatesDialog.tsx | 534 --- .../channel-templates/TemplateEditor.tsx | 270 -- .../channel-templates/TemplateFields.tsx | 232 -- .../TemplateLibrary.module.css | 154 - .../TemplateLibrary.teamDelete.test.tsx | 233 -- .../channel-templates/TemplateLibrary.tsx | 714 ---- .../channel-templates/TemplateSettings.tsx | 159 - .../agent-selection.test.tsx | 2073 ---------- src/bundled/channel-templates/index.tsx | 28 - src/bundled/channel-templates/manifest.json | 5 - .../channel-templates/useTemplateCatalog.ts | 38 - .../channels/ChannelMembersDialog.test.tsx | 124 - src/bundled/channels/ChannelMembersDialog.tsx | 26 +- src/bundled/channels/ChannelsPage.tsx | 7 - src/bundled/channels/index.tsx | 3 - src/bundled/index.ts | 14 - src/bundled/sessions/SessionShare.tsx | 4 +- src/features/agents/AgentWakeNotice.test.tsx | 42 - src/features/agents/AgentWakeNotice.tsx | 19 - src/features/agents/control-native.test.ts | 14 - src/features/agents/control-native.ts | 6 - src/features/agents/control.test.ts | 278 -- src/features/agents/control.ts | 93 - src/features/agents/mention-wake.ts | 88 - src/features/agents/team-deployment.test.ts | 111 - src/features/agents/team-deployment.ts | 68 - src/features/channel-members/members.ts | 82 - .../channel-members/operations.test.ts | 65 +- src/features/channel-members/operations.ts | 45 +- .../channel-members/start-agent.test.ts | 114 - .../conversation/MessageLink.test.tsx | 42 - src/features/conversation/MessageLink.tsx | 20 - .../messages/MessageComposer.test.tsx | 50 +- src/features/messages/MessageComposer.tsx | 27 - src/features/messages/MessageRow.tsx | 11 - src/features/messages/SnapshotAttachment.tsx | 50 - .../messages/useNonmemberMentions.tsx | 4 +- src/features/relay/session.ts | 12 +- tests/browser/agent-activity.spec.mjs | 4 +- tests/browser/agent-control.spec.mjs | 1871 --------- tests/browser/agent-editor-grid.spec.mjs | 374 -- tests/browser/agent-management.spec.mjs | 130 - tests/browser/agent-models.spec.mjs | 247 -- tests/browser/agent-use-here.spec.mjs | 63 - tests/browser/agents.spec.mjs | 281 -- tests/browser/avatar-edit.spec.mjs | 141 +- tests/browser/avatar-loading.spec.mjs | 43 +- tests/browser/channel-header-menu.spec.mjs | 23 +- .../browser/channel-members-focus.fixture.tsx | 76 +- .../browser/channel-modal-dismissal.spec.mjs | 230 +- tests/browser/layout.spec.mjs | 3 - tests/browser/navigation-repairs.spec.mjs | 4 +- tests/browser/navigation-sidebar.spec.mjs | 2 +- tests/browser/navigation-sorting.spec.mjs | 2 +- tests/browser/navigation.spec.mjs | 20 +- tests/browser/sidenav-polish.spec.mjs | 14 +- tests/browser/template-dialog-layout.spec.mjs | 183 - tests/fixtures/agent-control.tsx | 60 +- tests/fixtures/agents.html | 2 - tests/fixtures/agents.tsx | 215 - tests/fixtures/avatar-loading.html | 2 + tests/fixtures/avatar-loading.tsx | 31 + tests/fixtures/dialog-gallery/Scene.tsx | 17 +- tests/fixtures/dialog-gallery/catalog.ts | 9 - tests/fixtures/mentions.tsx | 2 +- 145 files changed, 223 insertions(+), 34942 deletions(-) delete mode 100644 src/app/AgentDefaultsCard.test.tsx delete mode 100644 src/app/AgentDefaultsCard.tsx delete mode 100644 src/app/AgentSettings.module.css delete mode 100644 src/app/ClaudeHarnessSetup.tsx delete mode 100644 src/app/agent-control.integration.test.ts delete mode 100644 src/app/agent-mention.integration.test.ts delete mode 100644 src/bundled/agents/AgentControlPanel.test.tsx delete mode 100644 src/bundled/agents/AgentControlPanel.tsx delete mode 100644 src/bundled/agents/AgentCreateDialog.catalog.test.tsx delete mode 100644 src/bundled/agents/AgentCreateDialog.test.tsx delete mode 100644 src/bundled/agents/AgentCreateDialog.tsx delete mode 100644 src/bundled/agents/AgentDeleteDialog.tsx delete mode 100644 src/bundled/agents/AgentImport.test.tsx delete mode 100644 src/bundled/agents/AgentImport.tsx delete mode 100644 src/bundled/agents/AgentLibrary.test.tsx delete mode 100644 src/bundled/agents/AgentLibrary.tsx delete mode 100644 src/bundled/agents/AgentSnapshots.test.tsx delete mode 100644 src/bundled/agents/AgentSnapshots.tsx delete mode 100644 src/bundled/agents/AgentUpdateReview.component.test.tsx delete mode 100644 src/bundled/agents/AgentUpdateReview.test.tsx delete mode 100644 src/bundled/agents/AgentUpdateReview.tsx delete mode 100644 src/bundled/agents/AgentsPage.test.tsx delete mode 100644 src/bundled/agents/AgentsPage.tsx delete mode 100644 src/bundled/agents/CommunityCatalog.module.css delete mode 100644 src/bundled/agents/CommunityCatalog.test.tsx delete mode 100644 src/bundled/agents/CommunityCatalog.tsx delete mode 100644 src/bundled/agents/DirectShare.test.tsx delete mode 100644 src/bundled/agents/DirectShare.tsx delete mode 100644 src/bundled/agents/InventoryIdentityCard.test.tsx delete mode 100644 src/bundled/agents/InventoryIdentityCard.tsx delete mode 100644 src/bundled/agents/InventoryView.test.tsx delete mode 100644 src/bundled/agents/InventoryView.tsx delete mode 100644 src/bundled/agents/LocalInventoryAction.test.tsx delete mode 100644 src/bundled/agents/LocalInventoryAction.tsx delete mode 100644 src/bundled/agents/ManagedAgentActions.tsx delete mode 100644 src/bundled/agents/RelayAgentRemove.tsx delete mode 100644 src/bundled/agents/SnapshotPreview.test.tsx delete mode 100644 src/bundled/agents/SnapshotPreview.tsx delete mode 100644 src/bundled/agents/TeamCatalogReshare.test.tsx delete mode 100644 src/bundled/agents/TeamDeployDialog.test.tsx delete mode 100644 src/bundled/agents/TeamDeployDialog.tsx delete mode 100644 src/bundled/agents/TeamExportDialog.test.tsx delete mode 100644 src/bundled/agents/TeamExportDialog.tsx delete mode 100644 src/bundled/agents/TeamImportDialog.tsx delete mode 100644 src/bundled/agents/UnifiedInventory.native.test.tsx delete mode 100644 src/bundled/agents/UnifiedInventory.test.tsx delete mode 100644 src/bundled/agents/UnifiedInventory.tsx delete mode 100644 src/bundled/agents/community-profiles.test.ts delete mode 100644 src/bundled/agents/community-profiles.ts delete mode 100644 src/bundled/agents/edit-route.test.ts delete mode 100644 src/bundled/agents/edit-route.ts delete mode 100644 src/bundled/agents/identity-tiles.test.ts delete mode 100644 src/bundled/agents/identity-tiles.ts delete mode 100644 src/bundled/agents/index.tsx delete mode 100644 src/bundled/agents/inventory-archive.ts delete mode 100644 src/bundled/agents/inventory-decisions.test.ts delete mode 100644 src/bundled/agents/inventory-decisions.ts delete mode 100644 src/bundled/agents/inventory-model.test.ts delete mode 100644 src/bundled/agents/inventory-model.ts delete mode 100644 src/bundled/agents/manifest.json delete mode 100644 src/bundled/agents/use-community-inventory.ts delete mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.catalog.test.tsx delete mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.teamText.test.tsx delete mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.test.tsx delete mode 100644 src/bundled/channel-templates/ChannelTemplatesDialog.tsx delete mode 100644 src/bundled/channel-templates/TemplateEditor.tsx delete mode 100644 src/bundled/channel-templates/TemplateFields.tsx delete mode 100644 src/bundled/channel-templates/TemplateLibrary.module.css delete mode 100644 src/bundled/channel-templates/TemplateLibrary.teamDelete.test.tsx delete mode 100644 src/bundled/channel-templates/TemplateLibrary.tsx delete mode 100644 src/bundled/channel-templates/TemplateSettings.tsx delete mode 100644 src/bundled/channel-templates/agent-selection.test.tsx delete mode 100644 src/bundled/channel-templates/index.tsx delete mode 100644 src/bundled/channel-templates/manifest.json delete mode 100644 src/bundled/channel-templates/useTemplateCatalog.ts delete mode 100644 src/features/agents/AgentWakeNotice.test.tsx delete mode 100644 src/features/agents/AgentWakeNotice.tsx delete mode 100644 src/features/agents/mention-wake.ts delete mode 100644 src/features/agents/team-deployment.test.ts delete mode 100644 src/features/agents/team-deployment.ts delete mode 100644 src/features/channel-members/start-agent.test.ts delete mode 100644 src/features/messages/SnapshotAttachment.tsx delete mode 100644 tests/browser/agent-control.spec.mjs delete mode 100644 tests/browser/agent-editor-grid.spec.mjs delete mode 100644 tests/browser/agent-management.spec.mjs delete mode 100644 tests/browser/agent-models.spec.mjs delete mode 100644 tests/browser/agent-use-here.spec.mjs delete mode 100644 tests/browser/agents.spec.mjs delete mode 100644 tests/browser/template-dialog-layout.spec.mjs delete mode 100644 tests/fixtures/agents.html delete mode 100644 tests/fixtures/agents.tsx create mode 100644 tests/fixtures/avatar-loading.html create mode 100644 tests/fixtures/avatar-loading.tsx diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 7d24553a0..6b4ee0e3d 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -172,10 +172,6 @@ pub fn bundled_manifests() -> Vec { .expect("todos manifest"), serde_json::from_str(include_str!("../../../src/bundled/diffs/manifest.json")) .expect("bundled diffs manifest"), - serde_json::from_str(include_str!( - "../../../src/bundled/channel-templates/manifest.json" - )) - .expect("channel templates manifest"), serde_json::from_str(include_str!( "../../../src/bundled/identity-naming/manifest.json" )) @@ -216,8 +212,6 @@ pub fn bundled_manifests() -> Vec { .expect("bestie manifest"), serde_json::from_str(include_str!("../../../src/bundled/projects/manifest.json")) .expect("projects manifest"), - serde_json::from_str(include_str!("../../../src/bundled/agents/manifest.json")) - .expect("agents manifest"), serde_json::from_str(include_str!("../../../src/bundled/agents2/manifest.json")) .expect("agents2 manifest"), serde_json::from_str(include_str!( @@ -264,7 +258,6 @@ fn enabled_by_default(id: &str) -> bool { | "buzz.inbox" | "buzz.reminders" | "buzz.projects" - | "buzz.agents" | "buzz.agents2" | "buzz.claude-code" | "buzz.codex" @@ -1546,26 +1539,25 @@ mod tests { #[test] fn optional_plugins_default_off_and_preserve_explicit_overrides() { - for id in ["buzz.todos", "buzz.channel-templates"] { - let temp = tempfile::tempdir().unwrap(); - let manager = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); - let enabled = |manager: &Manager| { - manager - .catalog() - .unwrap() - .plugins - .into_iter() - .find(|plugin| plugin.manifest.id == id) - .unwrap() - .enabled - }; - assert!(!enabled(&manager), "{id} defaults off"); - manager.change("enable", id).unwrap(); - let reopened = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); - assert!(enabled(&reopened), "{id} retains enable after reopening"); - reopened.change("disable", id).unwrap(); - assert!(!enabled(&manager), "{id} exposes persisted disable"); - } + let id = "buzz.todos"; + let temp = tempfile::tempdir().unwrap(); + let manager = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); + let enabled = |manager: &Manager| { + manager + .catalog() + .unwrap() + .plugins + .into_iter() + .find(|plugin| plugin.manifest.id == id) + .unwrap() + .enabled + }; + assert!(!enabled(&manager), "{id} defaults off"); + manager.change("enable", id).unwrap(); + let reopened = Manager::open(Some(temp.path().into()), "optional-test", false).unwrap(); + assert!(enabled(&reopened), "{id} retains enable after reopening"); + reopened.change("disable", id).unwrap(); + assert!(!enabled(&manager), "{id} exposes persisted disable"); } #[test] diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 7ee667e92..d85b19ba3 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -534,7 +534,6 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() { "buzz.sessions", "buzz.inbox", "buzz.projects", - "buzz.agents", "buzz.emoji", "buzz.mentions", "buzz.voice-notes", @@ -671,7 +670,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { .any(|plugin| plugin.manifest.id == "buzz.me")); for plugin in &catalog.plugins { let id = plugin.manifest.id.as_str(); - let default = !matches!(id, "buzz.bestie" | "buzz.todos" | "buzz.channel-templates"); + let default = !matches!(id, "buzz.bestie" | "buzz.todos"); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { assert!(manager.change("disable", id).is_err()); diff --git a/src/app/AgentDefaultsCard.test.tsx b/src/app/AgentDefaultsCard.test.tsx deleted file mode 100644 index 7fbb06f2c..000000000 --- a/src/app/AgentDefaultsCard.test.tsx +++ /dev/null @@ -1,1078 +0,0 @@ -// @vitest-environment jsdom -import "@testing-library/jest-dom/vitest"; -import { - act, - cleanup, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, expect, it } from "vitest"; -import { createAgentControl } from "../features/agents/control"; -import { controlFixture } from "../features/agents/control-testing"; -import type { ModelRequest } from "../features/agents/models"; -import { AgentDefaultsCard } from "./AgentDefaultsCard"; -import { useSyncExternalStore } from "react"; - -const disposals: (() => void)[] = []; -afterEach(() => { - cleanup(); - for (const dispose of disposals.splice(0)) dispose(); -}); - -function setup( - restarted = 0, - restartFailures = 0, - configure?: (fixture: ReturnType) => void, -) { - const fixture = controlFixture(); - fixture.data.defaultSettings = { - harness: "buzz-agent", - provider: "databricks_v2", - model: "old-model", - effort: "high", - sessionPolicy: "channel", - environmentKeys: ["SAVED_TOKEN"], - }; - configure?.(fixture); - const saveDefaults = fixture.host.saveDefaults; - if (!saveDefaults) throw Error("Missing fixture"); - fixture.host.saveDefaults = async (edit) => ({ - ...(await saveDefaults(edit)), - restarted, - restartFailures, - }); - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - function Card() { - const state = useSyncExternalStore(control.subscribe, control.snapshot); - return ; - } - render(); - return { fixture, control }; -} - -it("uses harness provider choices, preserves custom IDs, and clears incompatible models", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(0, 0, (f) => { - f.data.harnessOptions?.push({ - command: "/usr/local/bin/goose", - label: "Goose", - available: true, - providers: [{ value: "anthropic", label: "Anthropic" }], - }); - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect(within(card).getByText(/Codex is selected per agent/i)).toBeVisible(); - await user.click( - within(card).getByRole("combobox", { name: "Default provider" }), - ); - expect( - await screen.findByRole("option", { name: "Databricks v2" }), - ).toBeVisible(); - await user.click(screen.getByRole("option", { name: "Custom ID" })); - const custom = within(card).getByRole("textbox", { - name: "Custom default provider ID", - }); - expect(custom).toHaveValue("databricks_v2"); - await user.clear(custom); - await user.type(custom, "private-provider"); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("Not set"); - await user.click(within(card).getByRole("button", { name: "Discard" })); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Databricks v2"); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Goose" })); - expect( - within(card).getByRole("combobox", { name: "Default harness" }), - ).toHaveTextContent("Goose"); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("Not set"); - await user.click( - within(card).getByRole("combobox", { name: "Default provider" }), - ); - expect( - await screen.findByRole("option", { name: "Anthropic" }), - ).toBeVisible(); - await user.click(screen.getByRole("option", { name: "Anthropic" })); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click(await screen.findByRole("option", { name: "Custom ID" })); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "custom-goose-model", - ); - await user.keyboard("{Enter}"); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect(fixture.data.defaultSettings).toMatchObject({ - harness: "goose", - provider: "anthropic", - model: "custom-goose-model", - }); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Anthropic"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("custom-goose-model"); -}); - -it("looks up Pi models only on Browse, then recovers from failure using the draft", async () => { - const user = userEvent.setup(); - const requests: ModelRequest[] = []; - let attempts = 0; - const { fixture, control } = setup(0, 0, (f) => { - f.data.defaultWorkspace = "/fixture/workspace"; - f.data.harnessOptions?.push({ - command: "/usr/local/bin/buzz-pi-acp", - label: "Pi", - available: true, - providers: [], - }); - f.host.models = { - begin: async () => ++attempts, - cancel: async () => {}, - run: async (_ticket, request) => { - requests.push(request); - if (requests.length === 1) throw "Pi catalog unavailable"; - return { - host: "", - models: [{ id: "anthropic/claude-sonnet", name: "Claude Sonnet" }], - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Pi" })); - await user.click( - within(card).getByRole("combobox", { name: "Default provider" }), - ); - expect( - await screen.findByRole("option", { - name: "OpenAI (API key may be needed)", - }), - ).toBeVisible(); - await user.click( - screen.getByRole("option", { name: "Not set (use harness default)" }), - ); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click( - await screen.findByRole("option", { - name: "Not set (use harness default)", - }), - ); - expect(requests).toHaveLength(0); - await user.click(within(card).getByRole("button", { name: "Browse models" })); - expect(await within(card).findByText("Pi catalog unavailable")).toBeVisible(); - expect( - fixture.calls.filter((call) => call.action === "saveDefaults"), - ).toHaveLength(0); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click(await screen.findByRole("option", { name: "Custom ID" })); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "local/custom-model", - ); - await user.keyboard("{Enter}"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("local/custom-model"); - await user.click(within(card).getByRole("button", { name: "Retry models" })); - expect(await within(card).findByText(/Model choices loaded/)).toBeVisible(); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("local/custom-model"); - expect(requests[1]?.edit?.harness).toMatchObject({ - command: "/usr/local/bin/buzz-pi-acp", - provider: "", - }); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click( - await screen.findByRole("option", { name: /Claude Sonnet/ }), - ); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Anthropic"); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect(fixture.data.defaultSettings).toMatchObject({ - harness: "pi", - provider: "anthropic", - model: "claude-sonnet", - }); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Anthropic"); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("Claude Sonnet"); - expect( - within(card).getByRole("button", { name: "Save defaults" }), - ).toBeDisabled(); -}); - -it.each([ - { - harness: "pi" as const, - command: "/usr/local/bin/buzz-pi-acp", - label: "Pi", - provider: "google", - env: "GEMINI_API_KEY", - id: "google/gemini-2.5-pro", - }, - { - harness: "goose" as const, - command: "/usr/local/bin/goose", - label: "Goose", - provider: "google", - env: "GOOGLE_API_KEY", - id: "gemini-2.5-pro", - }, -])( - "offers a masked $harness default key for lookup and saves it write-only", - async ({ harness, command, label, provider, env, id }) => { - const user = userEvent.setup(); - const requests: ModelRequest[] = []; - const { fixture, control } = setup(0, 0, (f) => { - f.data.defaultSettings = { - harness, - provider, - model: "gemini-2.5-pro", - effort: "", - sessionPolicy: "channel", - environmentKeys: [env], - }; - f.data.harnessOptions?.push({ - command, - label, - available: true, - providers: [{ value: provider, label: "Google Gemini" }], - }); - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async (_ticket, request) => { - requests.push(request); - return { - host: "", - models: [{ id, name: "Gemini 2.5 Pro" }], - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - const key = within(card).getByLabelText("Google Gemini API key"); - expect(key).toHaveAttribute("type", "password"); - expect(key).toHaveValue(""); - expect(key).toHaveAttribute("placeholder", "Saved key unchanged"); - await user.type(key, "replacement-key"); - expect(key).toHaveAttribute("type", "password"); - await user.click( - within(card).getByRole("button", { name: "Show API key" }), - ); - expect(key).toHaveAttribute("type", "text"); - await user.click( - within(card).getByRole("button", { name: "Browse models" }), - ); - expect(await within(card).findByText(/Model choices loaded/)).toBeVisible(); - expect(requests[0]?.edit?.environment).toEqual({ - [env]: "replacement-key", - }); - await user.click( - within(card).getByRole("button", { name: "Save defaults" }), - ); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect( - fixture.calls.find((call) => call.action === "saveDefaults"), - ).toMatchObject({ - payload: { edit: { environment: { [env]: "replacement-key" } } }, - }); - expect(within(card).getByLabelText("Google Gemini API key")).toHaveValue( - "", - ); - expect( - within(card).getByLabelText("Google Gemini API key"), - ).toHaveAttribute("placeholder", "Saved key unchanged"); - expect(card).not.toHaveTextContent("replacement-key"); - await user.type( - within(card).getByLabelText("Google Gemini API key"), - "next-key", - ); - expect( - within(card).getByLabelText("Google Gemini API key"), - ).toHaveAttribute("type", "password"); - }, -); - -it("searches the full catalog by ID while bounding choices and distinguishing duplicate names", async () => { - const user = userEvent.setup(); - const requests: ModelRequest[] = []; - const { fixture, control } = setup(0, 0, (f) => { - f.data.defaultSettings = { - harness: "goose", - provider: "openai", - model: "", - effort: "", - sessionPolicy: "channel", - environmentKeys: [], - }; - f.data.harnessOptions?.push({ - command: "/usr/local/bin/goose", - label: "Goose", - available: true, - providers: [{ value: "openai", label: "OpenAI" }], - }); - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async (_ticket, request) => { - requests.push(request); - return { - host: "", - models: Array.from({ length: 12 }, (_, index) => ({ - id: `model-${index}`, - name: "Same name", - })), - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click(within(card).getByRole("button", { name: "Browse models" })); - expect(await within(card).findByText(/Model choices loaded/)).toBeVisible(); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - expect( - await screen.findByRole("option", { name: "Same name · model-0" }), - ).toBeVisible(); - expect(screen.getAllByRole("option")).toHaveLength(12); // Ten models, default, custom. - await user.keyboard("{Escape}"); - await user.type( - within(card).getByRole("textbox", { name: "Search models" }), - "model-11", - ); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click( - await screen.findByRole("option", { name: "Same name · model-11" }), - ); - await user.clear( - within(card).getByRole("textbox", { name: "Search models" }), - ); - await user.type( - within(card).getByRole("textbox", { name: "Search models" }), - "no-match", - ); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("model-11"); - expect(requests).toHaveLength(1); - await user.type(within(card).getByLabelText("OpenAI API key"), "draft-key"); - expect(within(card).getByLabelText("OpenAI API key")).toHaveFocus(); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("model-11"); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect(fixture.data.defaultSettings?.model).toBe("model-11"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("model-11"); -}); - -it.each([ - { - environment: { DATABRICKS_HOST: "https://draft.example.test" }, - host: "", - filter: "build-filter", - }, - { - environment: { DATABRICKS_MODEL_FILTER: "" }, - host: "https://build.example.test", - filter: "", - }, - { - environment: { - DATABRICKS_HOST: "https://draft.example.test", - DATABRICKS_MODEL_FILTER: "draft-filter", - }, - host: "", - filter: "", - }, -])( - "browses with draft Databricks environment $environment without saving", - async ({ environment, host, filter }) => { - const user = userEvent.setup(); - const requests: ModelRequest[] = []; - const { fixture, control } = setup(0, 0, (f) => { - f.data.databricksDefaults = { - host: "https://build.example.test", - filter: "build-filter", - }; - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async (_ticket, request) => { - requests.push(request); - return { - host: "", - models: [{ id: "draft-model", name: "Draft model" }], - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click( - within(card).getByRole("button", { name: "Add environment variable" }), - ); - for (const [key, value] of Object.entries(environment)) { - await user.type(within(card).getByLabelText("Name"), key); - if (value) await user.type(within(card).getByLabelText("Value"), value); - await user.click( - within(card).getByRole("button", { name: "Add variable" }), - ); - } - await user.click( - within(card).getByRole("button", { name: "Browse models" }), - ); - expect(await within(card).findByText(/Model choices loaded/)).toBeVisible(); - expect(requests[0]).toMatchObject({ - host, - filter, - inheritWorkspace: true, - edit: { environment }, - }); - expect( - fixture.calls.filter((call) => call.action === "saveDefaults"), - ).toHaveLength(0); - }, -); - -it.each([ - { - provider: "databricks_v2", - savedKeys: [], - draft: undefined, - buildVisible: true, - }, - { provider: "openai", savedKeys: [], draft: undefined, buildVisible: false }, - { - provider: "", - savedKeys: ["BUZZ_AGENT_PROVIDER"], - draft: undefined, - buildVisible: false, - }, - { - provider: "databricks_v2", - savedKeys: ["DATABRICKS_MODEL"], - draft: undefined, - buildVisible: false, - }, - { - provider: "databricks_v2", - savedKeys: ["BUZZ_AGENT_MODEL"], - draft: undefined, - buildVisible: false, - }, - { - provider: "databricks_v2", - savedKeys: [], - draft: { key: "BUZZ_AGENT_PROVIDER", value: "openai" }, - buildVisible: false, - }, - { - provider: "openai", - savedKeys: [], - draft: { key: "BUZZ_AGENT_PROVIDER", value: "databricks-v2" }, - buildVisible: true, - }, - { - provider: "databricks_v2", - savedKeys: [], - draft: { key: "DATABRICKS_MODEL", value: "" }, - buildVisible: false, - }, -])( - "names the build model only when its fallback applies: $provider $savedKeys $draft", - async ({ provider, savedKeys, draft, buildVisible }) => { - const user = userEvent.setup(); - const { control } = setup(0, 0, (f) => { - if (!f.data.defaultSettings) throw Error("Missing defaults fixture"); - Object.assign(f.data.defaultSettings, { - provider, - model: "", - environmentKeys: savedKeys, - }); - f.data.agentDefaults = { - provider: "databricks_v2", - model: "build-model", - ownerOnly: true, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - if (draft) { - await user.click( - within(card).getByRole("button", { name: "Add environment variable" }), - ); - await user.type(within(card).getByLabelText("Name"), draft.key); - if (draft.value) - await user.type(within(card).getByLabelText("Value"), draft.value); - await user.click( - within(card).getByRole("button", { name: "Add variable" }), - ); - } - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent( - buildVisible - ? "Use build default (build-model)" - : "Not set (use harness default)", - ); - }, -); - -it("hands keyboard focus between a listed choice and its custom input", async () => { - const user = userEvent.setup(); - const { control } = setup(); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - within(card).getByRole("combobox", { name: "Default effort" }).focus(); - await user.keyboard("{Enter}"); - expect( - await screen.findByRole("option", { name: "Custom ID" }), - ).toBeVisible(); - await waitFor(() => - expect(screen.getByRole("option", { name: "High" })).toHaveFocus(), - ); - await user.keyboard("{End}"); - await waitFor(() => - expect(screen.getByRole("option", { name: "Custom ID" })).toHaveFocus(), - ); - await user.keyboard("{Enter}"); - expect( - within(card).getByRole("textbox", { name: "Custom default effort ID" }), - ).toHaveFocus(); - await user.keyboard("{Shift>}{Tab}{/Shift}{Enter}"); - expect(await screen.findByRole("option", { name: "None" })).toBeVisible(); - await waitFor(() => - expect(screen.getByRole("option", { name: "Custom ID" })).toHaveFocus(), - ); - await user.keyboard("{Home}{ArrowDown}"); - await waitFor(() => - expect(screen.getByRole("option", { name: "None" })).toHaveFocus(), - ); - await user.keyboard("{Enter}"); - expect( - within(card).queryByRole("textbox", { name: "Custom default effort ID" }), - ).not.toBeInTheDocument(); - expect( - within(card).getByRole("combobox", { name: "Default effort" }), - ).toHaveFocus(); - expect( - within(card).getByRole("combobox", { name: "Default effort" }), - ).toHaveTextContent("None"); -}); - -it.each([false, true])( - "discards a typed Pi key when clearing Model while retaining saved keys (%s)", - async (savedKey) => { - const user = userEvent.setup(); - const { fixture, control } = setup(0, 0, (f) => { - if (!f.data.defaultSettings) throw Error("Missing defaults fixture"); - Object.assign(f.data.defaultSettings, { - harness: "pi", - provider: "openai", - model: "gpt-test", - environmentKeys: savedKey ? ["OPENAI_API_KEY"] : [], - }); - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.type( - within(card).getByLabelText("OpenAI API key"), - "unsaved-key", - ); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - await user.click( - await screen.findByRole("option", { - name: "Not set (use harness default)", - }), - ); - await user.click( - within(card).getByRole("button", { name: "Save defaults" }), - ); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect( - fixture.calls.find((call) => call.action === "saveDefaults")?.payload, - ).toEqual({ - edit: expect.objectContaining({ - provider: "", - model: "", - environment: {}, - }), - }); - expect(fixture.data.defaultSettings?.environmentKeys).toEqual( - savedKey ? ["OPENAI_API_KEY"] : [], - ); - }, -); - -it.each([ - { harness: "buzz-agent" as const, override: "BUZZ_AGENT_MODEL" }, - { harness: "goose" as const, override: "GOOSE_MODEL" }, -])( - "warns about $override before browsing and respects its draft removal", - async ({ harness, override }) => { - const user = userEvent.setup(); - const { control } = setup(0, 0, (f) => { - if (!f.data.defaultSettings) throw Error("Missing defaults"); - f.data.defaultSettings.harness = harness; - f.data.defaultSettings.environmentKeys = [override]; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect( - within(card).getByText( - `${override} overrides this model selection. Replace or remove it under Environment variables.`, - ), - ).toBeVisible(); - await user.click( - within(card).getByRole("button", { name: `Remove ${override}` }), - ); - expect( - within(card).queryByText(/overrides this model selection/), - ).toBeNull(); - }, -); - -it("uses the native selector keys for defaults warnings instead of inferring them from the harness", async () => { - const { control } = setup(0, 0, (f) => { - const policy = f.data.harnessOptions?.[0]?.configurationPolicy; - if (!policy || !f.data.defaultSettings) - throw Error("Missing policy fixture"); - // A native contract change must take effect without a second UI mapping. - policy.selectorEnvironment = { - model: "NATIVE_MODEL", - provider: "NATIVE_PROVIDER", - }; - f.data.defaultSettings.environmentKeys = [ - "NATIVE_MODEL", - "NATIVE_PROVIDER", - ]; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect( - within(card).getByText(/NATIVE_MODEL overrides this model selection/), - ).toBeVisible(); - expect( - within(card).getByText(/NATIVE_PROVIDER overrides this provider selection/), - ).toBeVisible(); -}); - -it("waits for a hidden Goose provider override to be removed before offering its key", async () => { - const user = userEvent.setup(); - const { control } = setup(0, 0, (f) => { - f.data.defaultSettings = { - harness: "goose", - provider: "openai", - model: "", - effort: "", - sessionPolicy: "channel", - environmentKeys: ["GOOSE_PROVIDER"], - }; - f.data.harnessOptions?.push({ - command: "/usr/local/bin/goose", - label: "Goose", - available: true, - providers: [], - }); - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async () => { - throw "Unexpected lookup"; - }, - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect(within(card).queryByLabelText("OpenAI API key")).toBeNull(); - expect( - within(card).getByText(/saved GOOSE_PROVIDER override has a hidden value/), - ).toBeVisible(); - await user.click( - within(card).getByRole("button", { name: "Remove GOOSE_PROVIDER" }), - ); - expect(within(card).getByLabelText("OpenAI API key")).toHaveAttribute( - "type", - "password", - ); - expect( - within(card).getByRole("button", { name: "Browse models" }), - ).toBeDisabled(); - expect( - within(card).getByText( - "Save environment removals before browsing models so lookup uses the updated settings.", - ), - ).toBeVisible(); -}); - -it("drops an unsaved Pi key when its provider changes", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(0, 0, (f) => { - f.data.defaultSettings = { - harness: "pi", - provider: "openai", - model: "gpt-5", - effort: "", - sessionPolicy: "channel", - environmentKeys: [], - }; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.type(within(card).getByLabelText("OpenAI API key"), "draft-key"); - await user.click( - within(card).getByRole("combobox", { name: "Default provider" }), - ); - await user.click( - await screen.findByRole("option", { - name: /Anthropic \(API key may be needed\)/, - }), - ); - expect(within(card).queryByLabelText("OpenAI API key")).toBeNull(); - expect(within(card).getByLabelText("Anthropic API key")).toHaveValue(""); - expect(within(card).queryByText("OPENAI_API_KEY")).toBeNull(); - await user.type( - within(card).getByLabelText("Anthropic API key"), - "another-key", - ); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Goose" })); - expect(within(card).queryByText("ANTHROPIC_API_KEY")).toBeNull(); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect( - fixture.calls.find((call) => call.action === "saveDefaults"), - ).toMatchObject({ - payload: { - edit: { harness: "goose", provider: "anthropic", environment: {} }, - }, - }); -}); - -it("saves a selected effort level", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click( - within(card).getByRole("combobox", { name: "Default effort" }), - ); - expect(await screen.findByRole("option", { name: "Medium" })).toBeVisible(); - await user.click(screen.getByRole("option", { name: "Medium" })); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect(await within(card).findByText("Saved.")).toBeVisible(); - expect(fixture.data.defaultSettings?.effort).toBe("medium"); -}); - -it("keeps a custom effort editable and clears it on harness changes", async () => { - const user = userEvent.setup(); - const { control } = setup(0, 0, (fixture) => { - if (!fixture.data.defaultSettings) throw Error("Missing defaults"); - fixture.data.defaultSettings.effort = "special-level"; - }); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect( - within(card).getByRole("textbox", { name: "Custom default effort ID" }), - ).toHaveValue("special-level"); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Goose" })); - await user.click( - within(card).getByRole("combobox", { name: "Default effort" }), - ); - expect(await screen.findByRole("option", { name: "Off" })).toBeVisible(); - expect(screen.getByRole("option", { name: "Max" })).toBeVisible(); - await user.click(screen.getByRole("option", { name: "Max" })); - expect( - within(card).getByRole("combobox", { name: "Default effort" }), - ).toHaveTextContent("Max"); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Pi" })); - expect( - within(card).getByRole("combobox", { name: "Default harness" }), - ).toHaveTextContent("Pi"); - expect( - within(card).getByRole("combobox", { name: "Default effort" }), - ).toHaveTextContent("Not set"); - await user.click( - within(card).getByRole("combobox", { name: "Default effort" }), - ); - expect(await screen.findByRole("option", { name: "Off" })).toBeVisible(); -}); - -it("cancels an in-flight lookup without losing the editable defaults draft", async () => { - const user = userEvent.setup(); - let started!: () => void; - const start = new Promise((resolve) => { - started = resolve; - }); - let finished!: () => void; - const finish = new Promise((resolve) => { - finished = resolve; - }); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - const { fixture, control } = setup(0, 0, (f) => { - f.host.models = { - begin: async () => 1, - cancel: async () => {}, - run: async () => { - started(); - await gate; - finished(); - return { - host: "", - models: [{ id: "late-model", name: "Late model" }], - modelOverridden: false, - disconnected: false, - }; - }, - }; - }); - try { - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.click( - within(card).getByRole("button", { name: "Browse models" }), - ); - await start; - expect( - await within(card).findByRole("button", { name: "Cancel model lookup" }), - ).toBeVisible(); - await user.click( - within(card).getByRole("button", { name: "Cancel model lookup" }), - ); - expect( - await within(card).findByText("Cancelled. Retry when ready."), - ).toBeVisible(); - await user.clear( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "manual-model", - ); - await user.keyboard("{Enter}"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("manual-model"); - expect(fixture.data.defaultSettings?.model).toBe("old-model"); - await act(async () => { - release(); - await finish; - }); - expect( - within(card).getByRole("button", { name: "Retry models" }), - ).toBeEnabled(); - await user.click( - within(card).getByRole("combobox", { name: "Default model" }), - ); - expect( - await screen.findByRole("option", { name: "Custom ID" }), - ).toBeVisible(); - expect(screen.queryByRole("option", { name: /Late model/ })).toBeNull(); - await user.keyboard("{Escape}"); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("manual-model"); - } finally { - release(); - } -}); - -it("discard clears unfinished environment inputs as well as the saved draft", async () => { - const user = userEvent.setup(); - const { control } = setup(); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - const add = within(card).getByRole("button", { - name: "Add environment variable", - }); - expect(add).toHaveAttribute("aria-expanded", "false"); - expect(within(card).getByLabelText("Name")).not.toBeVisible(); - await user.click(add); - await user.type(within(card).getByLabelText("Name"), "UNSAVED_TOKEN"); - await user.type(within(card).getByLabelText("Value"), "unfinished-secret"); - await user.click(add); - await user.click(add); - expect(within(card).getByLabelText("Name")).toHaveValue("UNSAVED_TOKEN"); - expect(within(card).getByLabelText("Value")).toHaveValue("unfinished-secret"); - expect(within(card).getByRole("button", { name: "Discard" })).toBeVisible(); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "-draft", - ); - await user.keyboard("{Enter}"); - await user.click(within(card).getByRole("button", { name: "Discard" })); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("old-model"); - expect(within(card).getByLabelText("Name")).toHaveValue(""); - expect(within(card).getByLabelText("Value")).toHaveValue(""); -}); - -it("changing the default harness keeps provider, clears model and effort, and saves write-only env", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(2, 1); - await control.refresh(); - const card = await screen.findByRole("region", { name: "Agent defaults" }); - expect( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ).toHaveValue("old-model"); - // Saved environment values are never shown; only the key and its state. - expect(within(card).getByText("SAVED_TOKEN")).toBeVisible(); - expect(card).not.toHaveTextContent("secret"); - await user.click( - within(card).getByRole("combobox", { name: "Default harness" }), - ); - await user.click(await screen.findByRole("option", { name: "Goose" })); - await user.click( - within(card).getByRole("combobox", { name: "Conversation context" }), - ); - await user.click(await screen.findByRole("option", { name: "Each thread" })); - expect( - within(card).getByRole("combobox", { name: "Default provider" }), - ).toHaveTextContent("Custom ID"); - expect( - within(card).getByRole("textbox", { name: "Custom default provider ID" }), - ).toHaveValue("databricks_v2"); - expect( - within(card).getByRole("combobox", { name: "Default model" }), - ).toHaveTextContent("Not set"); - expect(within(card).getByLabelText("Default effort")).toHaveValue(""); - await user.click( - within(card).getByRole("button", { name: "Add environment variable" }), - ); - await user.type(within(card).getByLabelText("Name"), "NEW_KEY"); - await user.type(within(card).getByLabelText("Value"), "secret-value"); - await user.click(within(card).getByRole("button", { name: "Add variable" })); - expect(within(card).getByLabelText("Name")).toHaveFocus(); - await user.click( - within(card).getByRole("button", { name: "Remove SAVED_TOKEN" }), - ); - await user.click(within(card).getByRole("button", { name: "Save defaults" })); - expect( - await within(card).findByText( - "Saved. Restarted 2 agents. 1 agent couldn’t restart with the new settings; check Agents.", - ), - ).toBeVisible(); - expect(fixture.calls.find((c) => c.action === "saveDefaults")).toEqual({ - action: "saveDefaults", - payload: { - edit: { - harness: "goose", - provider: "databricks_v2", - model: "", - effort: "", - sessionPolicy: "thread", - environment: { NEW_KEY: "secret-value", SAVED_TOKEN: null }, - }, - }, - }); - expect(within(card).getByText("NEW_KEY")).toBeVisible(); - expect(within(card).queryByText("SAVED_TOKEN")).toBeNull(); - expect(card).not.toHaveTextContent("secret-value"); -}); - -it("keeps the uncertain-write explanation when Stop overtakes a committed save", async () => { - const user = userEvent.setup(); - const { fixture, control } = setup(); - await control.refresh(); - const commit = fixture.host.saveDefaults; - if (!commit) throw Error("Missing fixture"); - let committed!: () => void; - const written = new Promise((resolve) => { - committed = resolve; - }); - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - // Native commits the defaults, then waits on a restart credential prompt. - fixture.host.saveDefaults = async (edit) => { - const saved = await commit(edit); - committed(); - await gate; - return saved; - }; - const card = await screen.findByRole("region", { name: "Agent defaults" }); - await user.clear( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - ); - await user.type( - within(card).getByRole("textbox", { name: "Custom default model ID" }), - "committed", - ); - await user.keyboard("{Enter}"); - try { - await user.click( - within(card).getByRole("button", { name: "Save defaults" }), - ); - await written; - await control.action(fixture.agent.id, "stop"); - } finally { - release(); - } - const alert = await within(card).findByRole("alert"); - expect(alert).toHaveTextContent("Could not confirm the operation"); - expect(alert).toHaveTextContent("Check current status and saved settings"); - expect(alert).not.toHaveTextContent("weren’t saved"); - expect(fixture.data.defaultSettings?.model).toBe("committed"); -}); diff --git a/src/app/AgentDefaultsCard.tsx b/src/app/AgentDefaultsCard.tsx deleted file mode 100644 index 09d6668aa..000000000 --- a/src/app/AgentDefaultsCard.tsx +++ /dev/null @@ -1,900 +0,0 @@ -import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { - savedMessage, - type AgentControl, - type AgentControlState, - type AgentDefaultSettings, - type AgentDefaultsEdit, - type AgentEdit, - type HarnessConfigurationPolicy, -} from "../features/agents/control"; -import type { ModelCatalog } from "../features/agents/models"; -import { - effectiveGooseProvider, - gooseApiKey, - PI_API_KEYS, - harnessKind, -} from "../bundled/agents/agent-edit"; -import { ProviderApiKeyField } from "../bundled/agents/ProviderApiKeyField"; -import { InlineHeader } from "../shared/design-system/ui/Header"; -import { Button } from "../shared/design-system/ui/Button"; -import { Accordion } from "../shared/design-system/ui/Accordion"; -import { Field } from "../shared/design-system/ui/Field"; -import { Input } from "../shared/design-system/ui/Input"; -import { Select } from "../shared/design-system/ui/Select"; -import styles from "./AgentSettings.module.css"; - -const harnesses = [ - { value: "buzz-agent", label: "Buzz Agent" }, - { value: "goose", label: "Goose" }, - { value: "pi", label: "Pi" }, -] as const; - -// Legacy suggestions only, never model capability evidence. Native policy marks -// effort discovery unknown until the integration reports model-specific metadata. -const effortChoices = { - "buzz-agent": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], - goose: ["off", "low", "medium", "high", "max"], - pi: ["off", "minimal", "low", "medium", "high", "xhigh"], -} as const; - -const VISIBLE_MODEL_LIMIT = 10; - -function defaultLabel(harness: AgentDefaultsEdit["harness"], value: string) { - return value && harness === "buzz-agent" - ? `Use build default (${value})` - : "Not set (use harness default)"; -} - -type Choice = { value: string; label: string }; - -function defaultHarness( - state: AgentControlState, - kind: AgentDefaultsEdit["harness"], -) { - return state.data?.harnessOptions?.find( - (option) => harnessKind(option.command) === kind, - ); -} - -function environmentSet( - current: AgentDefaultsEdit, - savedKeys: string[], - key: string, -) { - return ( - typeof current.environment[key] === "string" || - (current.environment[key] === undefined && savedKeys.includes(key)) - ); -} - -/** A saved ID stays editable even when it is absent from today's suggestions. */ -function DefaultsChoice({ - label, - value, - customValue = value, - choices, - disabled, - onSelect, - onCustom, - resetKey, -}: { - label: string; - value: string; - customValue?: string; - choices: Choice[]; - disabled: boolean; - onSelect(value: string): void; - onCustom(value: string): void; - resetKey: string; -}) { - const [custom, setCustom] = useState(false); - const container = useRef(null); - const input = useRef(null); - const focusAfterSelection = useRef<"input" | "trigger" | null>(null); - // Only a user-selected mode change transfers focus; catalog retirement must - // leave focus in the credential or environment field being edited. - useLayoutEffect(() => { - if (focusAfterSelection.current === "input") input.current?.focus(); - else if (focusAfterSelection.current === "trigger") - container.current - ?.querySelector('[role="combobox"]') - ?.focus(); - focusAfterSelection.current = null; - }); - // biome-ignore lint/correctness/useExhaustiveDependencies: a new editing session resets the local input mode. - useEffect(() => setCustom(false), [resetKey]); - const index = choices.findIndex((choice) => choice.value === value); - const showInput = custom || index < 0; - return ( -
- { - setCustom(true); - onCustom(event.target.value); - }} - /> - - )} -
- ); -} - -function ProviderChoice({ - current, - state, - models, - disabled, - onChange, - editSession, -}: { - current: AgentDefaultsEdit; - state: AgentControlState; - models: ModelCatalog["models"]; - disabled: boolean; - onChange(provider: string): void; - editSession: number; -}) { - const harness = defaultHarness(state, current.harness); - const discoveredProviders = harness?.configurationPolicy - ? harness.configurationPolicy.provider === "discovered" - : current.harness === "pi"; - const discovered = discoveredProviders - ? [...new Set(models.map((model) => model.id.split("/")[0] ?? ""))].filter( - Boolean, - ) - : []; - const providers = discoveredProviders - ? [ - ...discovered.map((value) => ({ - value, - label: `${PI_API_KEYS[value]?.label ?? value} (available in Pi)`, - })), - ...Object.entries(PI_API_KEYS) - .filter(([value]) => !discovered.includes(value)) - .map(([value, details]) => ({ - value, - label: `${details.label} (API key may be needed)`, - })), - ] - : (harness?.providers ?? []); - const builtInProvider = state.data?.agentDefaults?.provider ?? ""; - const builtInLabel = - providers.find((provider) => provider.value === builtInProvider)?.label ?? - builtInProvider; - const overrideKey = harness?.configurationPolicy - ? harness.configurationPolicy.selectorEnvironment?.provider - : current.harness === "goose" - ? "GOOSE_PROVIDER" - : current.harness === "buzz-agent" - ? "BUZZ_AGENT_PROVIDER" - : null; - const providerOverridden = - !!overrideKey && - environmentSet( - current, - state.data?.defaultSettings?.environmentKeys ?? [], - overrideKey, - ); - return ( -
- - {providerOverridden && ( -

- {overrideKey} overrides this provider selection. Replace or remove it - under Environment variables. -

- )} -
- ); -} - -function defaultsApiKey( - current: AgentDefaultsEdit, - savedKeys: string[], - policy?: HarnessConfigurationPolicy, -) { - if (policy?.authentication === "external") return undefined; - if (current.harness === "pi") return PI_API_KEYS[current.provider]; - if (current.harness !== "goose") return undefined; - const provider = effectiveGooseProvider( - current.provider, - current.environment, - savedKeys, - ); - return provider ? gooseApiKey(provider) : undefined; -} - -function ModelChoice({ - control, - state, - current, - disabled, - onChange, - onModels, - editSession, -}: { - control: AgentControl; - state: AgentControlState; - current: AgentDefaultsEdit; - disabled: boolean; - onChange(patch: Partial): void; - onModels(models: ModelCatalog["models"]): void; - editSession: number; -}) { - const harness = defaultHarness(state, current.harness); - const policy = harness?.configurationPolicy; - const pi = policy - ? policy.provider === "discovered" - : current.harness === "pi"; - const [catalog, setCatalog] = useState<{ - key: string; - models: ModelCatalog["models"]; - } | null>(null); - const [status, setStatus] = useState(""); - const [busy, setBusy] = useState(false); - const [attempted, setAttempted] = useState(false); - const [query, setQuery] = useState(""); - const pending = useRef(null); - const key = JSON.stringify([ - current.harness, - pi ? null : current.provider, - harness?.command, - harness?.defaultArgs, - current.environment, - state.data?.defaultWorkspace, - state.data?.databricksDefaults, - state.data?.defaultSettings?.environmentKeys, - ]); - const currentKey = useRef(key); - currentKey.current = key; - // biome-ignore lint/correctness/useExhaustiveDependencies: search text belongs to this provider and editing session. - useEffect(() => setQuery(""), [current.provider, editSession]); - // biome-ignore lint/correctness/useExhaustiveDependencies: key is the lookup context; a changed context retires native work. - useEffect(() => { - pending.current?.abort(); - pending.current = null; - setBusy(false); - setAttempted(false); - setStatus(""); - setQuery(""); - onModels([]); - return () => { - pending.current?.abort(); - }; - }, [key]); - const fresh = catalog?.key === key ? catalog.models : []; - const entries = fresh.filter( - (model) => - !pi || !current.provider || model.id.startsWith(`${current.provider}/`), - ); - const selectedId = - pi && current.provider && current.model - ? `${current.provider}/${current.model}` - : current.model; - const matching = entries - .filter((model) => - `${model.name} ${model.id}`.toLowerCase().includes(query.toLowerCase()), - ) - .slice(0, VISIBLE_MODEL_LIMIT); - // Filtering must not turn the current catalog choice into a custom ID. - const selected = entries.find((model) => model.id === selectedId); - if (selected && !matching.some((model) => model.id === selected.id)) - matching.unshift(selected); - const modelKey = policy - ? policy.selectorEnvironment?.model - : { - "buzz-agent": "BUZZ_AGENT_MODEL", - goose: "GOOSE_MODEL", - pi: "", - }[current.harness]; - const savedKeys = state.data?.defaultSettings?.environmentKeys ?? []; - const modelOverridden = - !!modelKey && environmentSet(current, savedKeys, modelKey); - const providerOverride = current.environment.BUZZ_AGENT_PROVIDER; - const effectiveProvider = - typeof providerOverride === "string" - ? providerOverride - : environmentSet(current, savedKeys, "BUZZ_AGENT_PROVIDER") - ? null - : current.provider || state.data?.agentDefaults?.provider; - const buildModelApplies = - current.harness === "buzz-agent" && - ["databricks_v2", "databricks-v2", "databricks"].includes( - effectiveProvider ?? "", - ) && - !modelOverridden && - !environmentSet(current, savedKeys, "DATABRICKS_MODEL"); - const removingEnvironment = Object.values(current.environment).includes(null); - const choices: Choice[] = [ - { - value: "", - label: defaultLabel( - current.harness, - buildModelApplies ? (state.data?.agentDefaults?.model ?? "") : "", - ), - }, - ...matching.map((model) => ({ - value: model.id, - label: model.name === model.id ? model.id : `${model.name} · ${model.id}`, - })), - ]; - const choose = (value: string) => { - if (pi && !value) onChange({ provider: "", model: "" }); - else if (pi && fresh.some((model) => model.id === value)) { - const slash = value.indexOf("/"); - onChange({ - provider: value.slice(0, slash), - model: value.slice(slash + 1), - }); - } else onChange({ model: value }); - }; - const browse = async () => { - if ( - !control.models || - !harness || - harness.available === false || - removingEnvironment || - pending.current - ) - return; - const run = new AbortController(); - pending.current = run; - setBusy(true); - setAttempted(true); - setStatus(`Loading ${harness.label} models…`); - const environmentHost = environmentSet( - current, - savedKeys, - "DATABRICKS_HOST", - ); - const environmentFilter = environmentSet( - current, - savedKeys, - "DATABRICKS_MODEL_FILTER", - ); - const edit: AgentEdit = { - name: "", - systemPrompt: "", - sessionPolicy: current.sessionPolicy, - workspace: state.data?.defaultWorkspace ?? "", - harness: { - command: harness.command, - args: harness.defaultArgs ?? [], - provider: - current.harness === "buzz-agent" && !current.provider - ? state.data?.agentDefaults?.provider || "databricks_v2" - : current.provider, - model: current.model, - }, - environment: current.environment, - }; - try { - const result = await control.models.request( - { - edit, - host: - current.harness === "buzz-agent" && !environmentHost - ? (state.data?.databricksDefaults?.host ?? "") - : "", - filter: - current.harness === "buzz-agent" && !environmentFilter - ? (state.data?.databricksDefaults?.filter ?? "") - : "", - action: "connect", - integration: harness.id, - selectedModel: current.model || undefined, - ...(environmentHost || environmentFilter - ? { inheritWorkspace: true } - : {}), - }, - run.signal, - ); - if (run.signal.aborted || currentKey.current !== key) return; - setCatalog({ key, models: result.models }); - onModels(result.models); - setStatus( - result.modelOverridden - ? "A saved environment model override takes precedence over this selection." - : result.models.length - ? "Model choices loaded. Availability does not confirm inference access." - : `No ${harness.label} models found. Enter a custom model ID or retry.`, - ); - } catch (problem) { - if (!run.signal.aborted && currentKey.current === key) - setStatus((problem as Error).message); - } finally { - if (pending.current === run) { - pending.current = null; - setBusy(false); - } - } - }; - return ( -
- {entries.length > VISIBLE_MODEL_LIMIT && ( - - setQuery(event.target.value)} - /> - - )} - onChange({ model })} - /> - {modelOverridden && ( -

- {modelKey} overrides this model selection. Replace or remove it under - Environment variables. -

- )} -
- {busy ? ( - - ) : ( - - )} - {status && ( -

- {status} -

- )} -
- {harness?.available === false && ( -

- Install {harness.label} to browse its models. Custom IDs remain - editable. -

- )} - {!control.models && ( -

- Model browsing requires an updated desktop app. Custom IDs remain - editable. -

- )} - {current.harness === "goose" && !current.provider && ( -

- Choose a Goose provider to browse its models. -

- )} - {(policy ? policy.model === "withProvider" : pi) && - current.provider && - !current.model && ( -

- Choose a model for this Pi provider, or clear Provider to use Pi - defaults. -

- )} - {removingEnvironment && ( -

- Save environment removals before browsing models so lookup uses the - updated settings. -

- )} -
- ); -} - -function draftFrom(saved: AgentDefaultSettings): AgentDefaultsEdit { - const { environmentKeys: _keys, ...fields } = saved; - return { ...fields, environment: {} }; -} - -/** Device-wide defaults. Environment values are write-only: only keys return. */ -export function AgentDefaultsCard({ - control, - state, -}: { - control: AgentControl; - state: AgentControlState; -}) { - const saved = state.data?.defaultSettings; - const [draft, setDraft] = useState(null); - const [newKey, setNewKey] = useState(""); - const newKeyInput = useRef(null); - const [newValue, setNewValue] = useState(""); - const [notice, setNotice] = useState(""); - const [error, setError] = useState(""); - const [models, setModels] = useState([]); - const [editSession, setEditSession] = useState(0); - if (!saved || !control.saveDefaults) return null; - const current = draft ?? draftFrom(saved); - const disabled = state.busy || state.status !== "ready"; - const policy = defaultHarness(state, current.harness)?.configurationPolicy; - const apiKey = defaultsApiKey(current, saved.environmentKeys, policy); - const gooseProvider = - current.harness === "goose" - ? effectiveGooseProvider( - current.provider, - current.environment, - saved.environmentKeys, - ) - : undefined; - const change = (patch: Partial) => { - setNotice(""); - const next = { ...current, ...patch }; - const previousKey = apiKey?.env; - if ( - previousKey && - (next.harness !== current.harness || - defaultsApiKey( - next, - saved.environmentKeys, - defaultHarness(state, next.harness)?.configurationPolicy, - )?.env !== previousKey) && - typeof next.environment[previousKey] === "string" - ) { - next.environment = { ...next.environment }; - delete next.environment[previousKey]; - } - setDraft(next); - }; - const keys = [ - ...new Set([...saved.environmentKeys, ...Object.keys(current.environment)]), - ].sort(); - const save = () => { - setError(""); - void control.saveDefaults?.(current).then( - (snapshot) => { - setDraft(null); - setNewKey(""); - setNewValue(""); - setEditSession((session) => session + 1); - setNotice(savedMessage(snapshot.restarted, snapshot.restartFailures)); - }, - // The controller's sanitized reason: a save may already have committed - // (e.g. Stop overtook its restart), so never claim nothing was saved. - (problem: Error) => setError(problem.message), - ); - }; - return ( -
- - - - change({ - sessionPolicy: - sessionPolicy as AgentDefaultsEdit["sessionPolicy"], - }) - } - description="Share one conversation across the channel, or keep threads separate. Direct messages stay shared." - /> -
- Environment variables -

- Added to every agent; an agent’s own key wins. Saved values stay on - this device and are never shown again. -

- {keys.length > 0 && ( -
    - {keys.map((key) => { - const removed = current.environment[key] === null; - return ( -
  • - {key} - - - {removed - ? "Removed on save" - : typeof current.environment[key] === "string" - ? "Set on save" - : "Set"} - - - -
  • - ); - })} -
- )} - - - - setNewKey(event.target.value.trim()) - } - /> - - - setNewValue(event.target.value)} - /> - -
- -
- - ), - }, - ]} - /> -
-
- {(draft || newKey || newValue) && ( - - )} - -
- {notice && ( -

- {notice} -

- )} - {error && ( -

- {error} -

- )} -
-
- ); -} diff --git a/src/app/AgentSettings.module.css b/src/app/AgentSettings.module.css deleted file mode 100644 index 607cea12d..000000000 --- a/src/app/AgentSettings.module.css +++ /dev/null @@ -1,58 +0,0 @@ -.rows { - margin: var(--space-3) 0; - padding: 0; - list-style: none; -} - -.rows > li + li { - border-top: 1px solid var(--border-standard); -} - -.piSetup { - margin-top: var(--space-3); - margin-left: var(--space-2); - padding-left: var(--space-4); - border-left: 1px solid var(--border-standard); -} - -.command { - overflow-wrap: anywhere; - user-select: all; -} - -.catalog { - display: grid; - grid-template-columns: 12rem minmax(0, 1fr); - gap: var(--space-6); - min-height: 100%; -} - -.catalogSidebar { - border-right: 1px solid var(--border-standard); - padding-right: var(--space-4); -} - -.catalogDetails { - min-width: 0; - padding: var(--space-6); - border-radius: var(--radius-control); - background: var(--surface-inset); -} - -@media (max-width: 640px) { - .catalog { - grid-template-columns: minmax(0, 1fr); - gap: var(--space-3); - } - - .catalogDetails { - padding: var(--space-4); - } - - .catalogSidebar { - border-right: 0; - border-bottom: 1px solid var(--border-standard); - padding-right: 0; - padding-bottom: var(--space-3); - } -} diff --git a/src/app/AgentSettings.test.tsx b/src/app/AgentSettings.test.tsx index f4147ff22..72d5a52b5 100644 --- a/src/app/AgentSettings.test.tsx +++ b/src/app/AgentSettings.test.tsx @@ -1,35 +1,17 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { ToastProvider } from "../shared/design-system/ui/Toast"; -import { - act, - cleanup, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; -import { - createAgentControl, - type AgentControlHost, - type HarnessInstallReport, -} from "../features/agents/control"; -import { controlFixture } from "../features/agents/control-testing"; import { AgentSettings } from "./AgentSettings"; -import { AgentHarnessEditor } from "../bundled/agents/AgentHarnessEditor"; -import { agentDraft } from "../bundled/agents/agent-edit"; import { rememberAgentsPreference, setRememberAgentsPreference, } from "../features/messages/mention-preferences"; -const browserControl = createAgentControl(null); -const disposals: (() => void)[] = []; afterEach(() => { cleanup(); - for (const dispose of disposals.splice(0)) dispose(); vi.restoreAllMocks(); setRememberAgentsPreference(true); localStorage.clear(); @@ -37,7 +19,7 @@ afterEach(() => { it("defaults on, persists opt-out, and follows another window's preference", async () => { const user = userEvent.setup(); - render(, { + render(, { wrapper: ToastProvider, }); const toggle = () => @@ -46,7 +28,7 @@ it("defaults on, persists opt-out, and follows another window's preference", asy await user.click(toggle()); expect(rememberAgentsPreference()).toBe(false); cleanup(); - render(, { + render(, { wrapper: ToastProvider, }); expect(toggle()).not.toBeChecked(); @@ -61,7 +43,7 @@ it("defaults on, persists opt-out, and follows another window's preference", asy it("keeps an unsaved opt-out effective and offers retry without changing the choice", async () => { const user = userEvent.setup(); - render(, { + render(, { wrapper: ToastProvider, }); const write = vi @@ -86,1017 +68,3 @@ it("does not prefill when storage cannot be read", () => { }); expect(rememberAgentsPreference()).toBe(false); }); - -function setupHarnesses( - piStatus: "ready" | "cli-needed" | "adapter-needed", - pi: { - installSupported?: boolean; - updateSupported?: boolean; - installPi?: NonNullable; - claude?: NonNullable< - ReturnType["data"]["claudeSetup"] - >; - installClaude?: NonNullable; - checkClaudeAuth?: () => Promise; - } = {}, -) { - const fixture = controlFixture(); - if (pi.installPi) fixture.host.installPi = pi.installPi; - if (pi.installClaude) fixture.host.installClaude = pi.installClaude; - if (pi.checkClaudeAuth) - Object.assign(fixture.host, { checkClaudeAuth: pi.checkClaudeAuth }); - if (pi.claude) fixture.data.claudeSetup = pi.claude; - fixture.data.harnessOptions = [ - { - command: "buzz-agent", - label: "Buzz Agent", - available: true, - status: "ready", - providers: [], - }, - { - command: "goose", - label: "Goose", - available: true, - status: "ready", - providers: [], - }, - { - command: "buzz-pi-acp", - label: "Pi", - available: piStatus === "ready", - status: piStatus, - ...(pi.installSupported !== undefined - ? { installSupported: pi.installSupported } - : {}), - ...(pi.updateSupported !== undefined - ? { updateSupported: pi.updateSupported } - : {}), - providers: [], - }, - { - command: "hermes-acp", - label: "Hermes Agent", - available: false, - status: "cli-needed", - installSupported: false, - providers: [], - }, - ]; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - return { fixture, control }; -} - -it.each(["cli-needed", "ready"] as const)( - "shows Harnesses and keeps manual Pi commands collapsed until requested (%s)", - async (piStatus) => { - const user = userEvent.setup(); - setupHarnesses(piStatus); - const list = await screen.findByRole("list", { name: "Harnesses" }); - const rows = within(list) - .getAllByRole("listitem") - .filter((row) => row.parentElement === list); - expect(rows).toHaveLength(3); - const pi = rows[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(rows[0]).toHaveTextContent("Buzz AgentReady"); - expect(rows[1]).toHaveTextContent("GooseReady"); - expect(rows[2]).toHaveTextContent( - `Pi${piStatus === "ready" ? "Ready" : piStatus === "cli-needed" ? "CLI needed" : "Adapter needed"}`, - ); - const copyPi = screen.queryByRole("button", { name: "Copy Pi command" }); - if (piStatus === "ready") { - expect(copyPi).not.toBeInTheDocument(); - expect(screen.queryByText(/npm install -g/)).not.toBeInTheDocument(); - } else { - expect(screen.getByLabelText("Copy Pi command")).not.toBeVisible(); - const manual = within(pi).getByText("Manual setup"); - expect(manual.closest("details")).not.toHaveAttribute("open"); - await user.click(manual); - const steps = within( - screen.getByRole("list", { name: "Manual setup steps" }), - ).getAllByRole("listitem"); - expect(steps).toHaveLength(4); - expect(steps[0]).toHaveTextContent("Install Node.js"); - expect(steps[1]).toHaveTextContent("Install Pi"); - expect(steps[2]).toHaveTextContent("Install the ACP adapter"); - expect(steps[3]).toHaveTextContent("Check the installation"); - expect( - screen.getByText( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ), - ).toBeVisible(); - expect( - screen.getByText( - /git\+https:\/\/github.com\/salman1993\/buzz-pi-acp.git#72015de/, - ), - ).toBeVisible(); - const write = vi - .spyOn(navigator.clipboard, "writeText") - .mockResolvedValue(); - await user.click(screen.getByRole("button", { name: "Copy Pi command" })); - expect(write).toHaveBeenCalledWith( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ); - await user.click( - screen.getByRole("button", { name: "Copy Adapter command" }), - ); - expect(write).toHaveBeenCalledWith( - "npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#72015de'", - ); - expect(await screen.findByRole("status", { name: "" })).toHaveTextContent( - "Adapter command copied.", - ); - } - await user.hover(screen.getByRole("button", { name: "About ACP" })); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "Hermes Agent uses its own ACP launcher and sign-in.", - ); - }, -); - -it("discovers Tier 2 Hermes through Add harness and follows installation changes", async () => { - const user = userEvent.setup(); - const { fixture } = setupHarnesses("ready"); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).queryByText("Hermes Agent")).not.toBeInTheDocument(); - const add = screen.getByRole("button", { name: "Add harness" }); - await user.click(add); - const dialog = await screen.findByRole("dialog", { name: "Add harness" }); - expect(within(dialog).getByText("CLI needed")).toBeVisible(); - expect( - within(dialog).queryByRole("button", { name: /Install|Update/ }), - ).toBeNull(); - expect( - within(dialog).getByRole("link", { name: "Hermes Agent setup guide" }), - ).toHaveAttribute( - "href", - "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", - ); - await user.click(within(dialog).getByText("Terminal setup")); - expect(within(dialog).getByText("hermes model")).toBeVisible(); - expect(within(dialog).getByText("hermes acp --check")).toBeVisible(); - const hermes = fixture.data.harnessOptions?.find( - (option) => option.label === "Hermes Agent", - ); - if (!hermes) throw new Error("Missing Hermes option"); - Object.assign(hermes, { - command: "/local/hermes-acp", - available: true, - status: "ready", - }); - await user.click(within(dialog).getByRole("button", { name: "Check again" })); - await waitFor(() => expect(within(dialog).getByText("Ready")).toBeVisible()); - expect(within(dialog).getByText("/local/hermes-acp")).toBeVisible(); - await user.keyboard("{Escape}"); - await waitFor(() => - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), - ); - await waitFor(() => expect(add).toHaveFocus()); - expect( - within(list).getByText("Hermes Agent").closest("li"), - ).toHaveTextContent("Ready"); - expect( - within(list).queryByText(/Uses the default model and credentials/), - ).toBeNull(); - expect(within(list).queryByText("Manual Hermes Agent setup")).toBeNull(); - Object.assign(hermes, { - command: "hermes-acp", - available: false, - status: "cli-needed", - }); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => - expect(within(list).queryByText("Hermes Agent")).not.toBeInTheDocument(), - ); - await user.click(add); - expect( - within(await screen.findByRole("dialog")).getByText("CLI needed"), - ).toBeVisible(); -}); - -it("keeps Tier 1 harnesses usable when an older desktop has no Hermes option", async () => { - const user = userEvent.setup(); - const { fixture, control } = setupHarnesses("ready"); - fixture.data.harnessOptions = (fixture.data.harnessOptions ?? []).filter( - (option) => option.label !== "Hermes Agent", - ); - await act(() => control.refresh()); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).getByText("Goose")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Add harness" })); - expect( - await screen.findByRole("dialog", { name: "Add harness" }), - ).toHaveTextContent("Update the desktop app to check Hermes Agent."); -}); - -it("recognizes harness commands independently of native display labels", async () => { - const { fixture, control } = setupHarnesses("ready"); - for (const option of fixture.data.harnessOptions ?? []) { - option.label = `Renamed ${option.label}`; - } - await act(() => control.refresh()); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).getByText("Renamed Goose")).toBeVisible(); - expect( - within(list).queryByText("Renamed Hermes Agent"), - ).not.toBeInTheDocument(); - const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: "Add harness" })); - const dialog = await screen.findByRole("dialog", { name: "Add harness" }); - expect( - within(dialog).getByRole("heading", { name: "Renamed Hermes Agent" }), - ).toBeVisible(); - expect( - within(dialog).getByRole("link", { - name: "Renamed Hermes Agent setup guide", - }), - ).toHaveAttribute( - "href", - "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", - ); -}); - -type CodexStatus = "ready" | "cli-needed" | "adapter-needed"; -function codexHarnessOptions(status: CodexStatus, installSupported = true) { - return [ - ...(["buzz-agent", "goose", "buzz-pi-acp"] as const).map((command) => ({ - command, - label: command, - available: true, - status: "ready" as const, - providers: [], - })), - { - id: "codex" as const, - command: "/tools/codex-acp", - label: "Codex", - available: status === "ready", - status, - installSupported, - providers: [], - }, - ]; -} -function codexHarnesses(status: CodexStatus, installSupported = true) { - const fixture = controlFixture(); - fixture.data.harnessOptions = codexHarnessOptions(status, installSupported); - return fixture; -} -const codexRow = () => { - const row = screen.getByText("Codex").closest("li"); - if (!row) throw new Error("Missing Codex row"); - return within(row); -}; - -it("reads Codex sign-in only once its tools are found, and rechecks on Check again", async () => { - const user = userEvent.setup(); - const fixture = codexHarnesses("cli-needed"); - const checkCodexAuth = vi - .fn>() - .mockResolvedValue(false); - fixture.host.checkCodexAuth = checkCodexAuth; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - const codexIcon = screen - .getByText("Codex") - .closest("li") - ?.querySelector("svg"); - expect(codexIcon).not.toHaveClass("tabler-icon"); - expect(codexIcon).toHaveAttribute("viewBox", "176 176 364 364"); - expect(await codexRow().findByText("CLI needed")).toBeVisible(); - expect(codexRow().getByText(/Install the Codex CLI/)).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - expect(checkCodexAuth).not.toHaveBeenCalled(); - - fixture.data.harnessOptions = codexHarnessOptions("ready"); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await codexRow().findByText("Sign-in needed")).toBeVisible(); - expect(codexRow().getByText(/Run codex login/)).toBeVisible(); - - let finish!: (signedIn: boolean) => void; - checkCodexAuth.mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve; - }), - ); - await user.click(screen.getByRole("button", { name: "Check again" })); - try { - expect(await codexRow().findByText("Checking sign-in…")).toBeVisible(); - expect(codexRow().queryByText(/Run codex login/)).toBeNull(); - } finally { - await act(async () => finish(true)); - } - expect(await codexRow().findByText("Ready")).toBeVisible(); - // The status is the whole success state; no message or version suffix. - expect(screen.getByText("Codex").closest("li")).toHaveTextContent( - /^CodexReady$/, - ); -}); - -it("offers the Codex ACP adapter Install only when the adapter is missing, then re-detects", async () => { - const user = userEvent.setup(); - const unsupported = codexHarnesses("adapter-needed", false); - unsupported.host.installCodex = vi.fn(); - const blocked = createAgentControl(unsupported.host); - disposals.push(() => blocked.dispose()); - const first = render(, { - wrapper: ToastProvider, - }); - await screen.findByText("Codex"); - expect(await codexRow().findByText("Adapter needed")).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - first.unmount(); - - const fixture = codexHarnesses("adapter-needed"); - fixture.host.checkCodexAuth = vi.fn(async () => true); - const failed: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/codex-install.log", - output: "npm ERR! network", - error: "Installing the Codex ACP adapter failed", - }; - let complete!: (report: HarnessInstallReport) => void; - const install = vi - .fn>() - .mockResolvedValueOnce(failed) - .mockImplementationOnce( - () => - new Promise((resolve) => { - complete = resolve; - }), - ); - fixture.host.installCodex = install; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - expect(await codexRow().findByText("Adapter needed")).toBeVisible(); - expect( - codexRow().getByText(/Buzz installs Node.js and the Codex ACP adapter/), - ).toBeVisible(); - await user.click(codexRow().getByText("Manual Codex ACP adapter setup")); - expect( - codexRow().getByText("npm install -g @agentclientprotocol/codex-acp@2.1.1"), - ).toBeVisible(); - - await user.click(codexRow().getByRole("button", { name: "Install" })); - const alert = await codexRow().findByRole("alert"); - expect(alert).toHaveTextContent("Installing the Codex ACP adapter failed"); - await user.click(within(alert).getByText("Codex ACP adapter install log")); - expect(within(alert).getByText("npm ERR! network")).toBeVisible(); - - await user.click(codexRow().getByRole("button", { name: "Install" })); - expect( - await screen.findByText("Installing Node.js and the Codex ACP adapter…"), - ).toBeVisible(); - // Loading stays focusable for keyboard users but cannot start another install. - const installing = codexRow().getByRole("button", { name: "Install" }); - expect(installing).toHaveAttribute("aria-disabled", "true"); - await user.click(installing); - expect(install).toHaveBeenCalledTimes(2); - fixture.data.harnessOptions = codexHarnessOptions("ready"); - await act(async () => - complete({ ...failed, ready: true, error: null, output: "" }), - ); - expect(await codexRow().findByText("Ready")).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - expect(install).toHaveBeenCalledTimes(2); -}); - -it.each([false, true] as const)( - "keeps Codex keyboard retry usable and hands off success only while Install owns focus (moved: %s)", - async (moveFocus) => { - const user = userEvent.setup(); - const report: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/codex-install.log", - output: "npm ERR! network", - error: "Installation failed", - }; - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const install = vi - .fn>() - .mockReturnValue(pending); - const fixture = codexHarnesses("adapter-needed"); - fixture.host.installCodex = install; - fixture.host.checkCodexAuth = async () => !moveFocus; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - const button = await codexRow().findByRole("button", { name: "Install" }); - for (let i = 0; i < 20 && document.activeElement !== button; i++) { - await user.tab(); - } - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - try { - expect( - await screen.findByText( - "Installing Node.js and the Codex ACP adapter…", - ), - ).toBeVisible(); - expect(button).toHaveFocus(); - } finally { - await act(async () => { - finish(report); - await pending; - }); - } - expect(await codexRow().findByRole("alert")).toHaveTextContent( - "Installation failed", - ); - expect(button).toHaveFocus(); - const retry = new Promise((resolve) => { - finish = resolve; - }); - install.mockReturnValue(retry); - await user.keyboard("{Enter}"); - await waitFor(() => expect(install).toHaveBeenCalledTimes(2)); - let next: Element | null = null; - try { - expect(button).toHaveFocus(); - if (moveFocus) { - next = screen.getByRole("button", { name: "Add harness" }); - for (let i = 0; i < 20 && document.activeElement !== next; i++) { - await user.tab(); - } - expect(next).toHaveFocus(); - } - fixture.data.harnessOptions = codexHarnessOptions("ready"); - } finally { - await act(async () => { - finish({ ...report, ready: true, error: null }); - await retry; - }); - } - const status = await codexRow().findByText( - moveFocus ? "Sign-in needed" : "Ready", - ); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - if (moveFocus) expect(next).toHaveFocus(); - else expect(status).toHaveFocus(); - }, -); - -it("offers manual copying when clipboard access fails", async () => { - const user = userEvent.setup(); - setupHarnesses("cli-needed"); - await user.click(await screen.findByText("Manual setup")); - expect( - await screen.findByRole("button", { name: "Copy Pi command" }), - ).toBeVisible(); - vi.spyOn(navigator.clipboard, "writeText").mockRejectedValue( - new Error("denied"), - ); - await user.click(screen.getByRole("button", { name: "Copy Pi command" })); - expect(await screen.findByRole("status")).toHaveTextContent( - "Select it to copy manually.", - ); - expect( - screen.getByText( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ), - ).toBeVisible(); -}); - -it("Check again re-reads the native snapshot without restarting the app", async () => { - const user = userEvent.setup(); - const { fixture } = setupHarnesses("adapter-needed"); - expect(await screen.findByText("Adapter needed")).toBeVisible(); - await user.hover(screen.getByRole("button", { name: "Check again" })); - expect(await screen.findByRole("tooltip")).toHaveTextContent("Check again"); - const before = fixture.calls.filter( - (call) => call.action === "snapshot", - ).length; - const pi = fixture.data.harnessOptions?.[2]; - const goose = fixture.data.harnessOptions?.[1]; - if (!pi || !goose) throw new Error("Missing Harness fixture"); - pi.status = "ready"; - pi.available = true; - goose.status = "ready"; - goose.available = true; - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[2], - ).toHaveTextContent("PiReady"), - ); - expect(screen.queryByRole("button", { name: "Copy Pi command" })).toBeNull(); - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[1], - ).toHaveTextContent("GooseReady"); - expect( - fixture.calls.filter((call) => call.action === "snapshot"), - ).toHaveLength(before + 1); -}); - -it("keeps the last statuses and offers Check again after a failed read", async () => { - const user = userEvent.setup(); - const installPi = vi.fn(); - const { fixture } = setupHarnesses("cli-needed", { - installSupported: true, - installPi, - }); - const list = await screen.findByRole("list", { name: "Harnesses" }); - const piRow = within(list).getByText("Pi", { exact: true }).closest("li"); - if (!piRow) throw new Error("Missing Pi row"); - expect(within(piRow).getByText("CLI needed")).toBeVisible(); - const original = fixture.host.snapshot.bind(fixture.host); - fixture.host.snapshot = vi - .fn() - .mockRejectedValueOnce("unavailable") - .mockImplementation(original); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await screen.findByRole("alert")).toHaveTextContent("last check"); - expect(screen.getByRole("button", { name: "Install" })).toBeDisabled(); - expect(installPi).not.toHaveBeenCalled(); - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[0], - ).toHaveTextContent("Buzz AgentReady"); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); - expect(screen.getByRole("button", { name: "Install" })).toBeEnabled(); -}); - -it.each([ - ["cli-needed", true, true], - ["ready", true, false], - ["cli-needed", false, false], -] as const)( - "shows Pi Install only when needed and supported (%s, %s)", - async (status, supported, visible) => { - setupHarnesses(status, { installSupported: supported, installPi: vi.fn() }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(within(pi).queryByRole("button", { name: "Install" }) !== null).toBe( - visible, - ); - if (status !== "ready") { - expect(screen.getByLabelText("Copy Pi command")).not.toBeVisible(); - if (supported) - expect( - within(pi).getByText( - /Buzz installs Node.js, Pi, and its ACP adapter for you/, - ), - ).toBeVisible(); - else - expect( - within(pi).getByText(/Use Manual setup on this device/), - ).toBeVisible(); - } - }, -); - -it("offers no Pi update or command for a current or user-global Pi install", async () => { - setupHarnesses("ready", { - installSupported: true, - updateSupported: false, - installPi: vi.fn(), - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(within(pi).getByText("Ready")).toBeVisible(); - expect(screen.queryByRole("button", { name: "Update Pi" })).toBeNull(); - expect( - screen.queryByRole("button", { name: "Copy Adapter command" }), - ).toBeNull(); - expect(screen.queryByText(/buzz-pi-acp.git#/)).toBeNull(); -}); - -it("updates an outdated app-owned Pi and tells the user to restart running agents", async () => { - const user = userEvent.setup(); - const installPi = vi.fn().mockResolvedValue({ - ready: true, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "done", - error: null, - }); - setupHarnesses("ready", { - installSupported: true, - updateSupported: true, - installPi, - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - await user.click(within(pi).getByRole("button", { name: "Update Pi" })); - expect(installPi).toHaveBeenCalledTimes(1); - expect( - await screen.findByText(/Restart running Pi agents to use them/), - ).toBeVisible(); -}); - -it("keeps Pi install progress and report across Settings remounts without taking the agent-write lane", async () => { - const user = userEvent.setup(); - let complete!: (report: HarnessInstallReport) => void; - const installing = new Promise((resolve) => { - complete = resolve; - }); - const installPi = vi.fn(() => installing); - const { control, fixture } = setupHarnesses("adapter-needed", { - installSupported: true, - installPi, - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - await user.click(within(pi).getByRole("button", { name: "Install" })); - expect( - await screen.findByText("Installing Pi and its ACP adapter…"), - ).toBeVisible(); - expect(control.snapshot().busy).toBe(false); - expect(installPi).toHaveBeenCalledTimes(1); - cleanup(); - render(, { wrapper: ToastProvider }); - expect( - await screen.findByText("Installing Pi and its ACP adapter…"), - ).toBeVisible(); - const option = fixture.data.harnessOptions?.[2]; - if (!option) throw new Error("Missing Pi fixture"); - option.status = "ready"; - option.available = true; - complete({ - ready: true, - restarted: 1, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "done", - error: null, - }); - expect( - await screen.findByText( - /Pi and its adapter are up to date.*Restarted 1 waiting agents\./, - ), - ).toBeVisible(); - cleanup(); - render(, { wrapper: ToastProvider }); - expect( - await screen.findByText( - /Pi and its adapter are up to date.*Restarted 1 waiting agents\./, - ), - ).toBeVisible(); - expect(screen.queryByRole("button", { name: "Copy Pi command" })).toBeNull(); -}); - -it("keeps bundled Goose Ready during a Pi installation", async () => { - const user = userEvent.setup(); - let complete!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - complete = resolve; - }); - setupHarnesses("cli-needed", { - installSupported: true, - installPi: () => pending, - }); - const rows = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem"); - if (!rows[1] || !rows[2]) throw new Error("Missing Harness rows"); - await user.click(within(rows[2]).getByRole("button", { name: "Install" })); - expect(within(rows[1]).queryByRole("button", { name: "Install" })).toBeNull(); - expect(rows[1]).toHaveTextContent("GooseReady"); - complete({ - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "Full diagnostic output", - error: - "Installing Pi failed (exit code 1).\nnpm error code E404\nnpm error 404 No match found for version >=0.99.0", - }); - const alert = await screen.findByRole("alert"); - expect(rows[2]).toContainElement(alert); - expect(within(alert).getByText(/Installing Pi failed/)).toBeVisible(); - expect(alert).toHaveTextContent("E404"); - expect(alert).toHaveTextContent("No match found for version >=0.99.0"); - const log = within(alert).getByText("Pi install log"); - expect(log.closest("details")).not.toHaveAttribute("open"); - expect(within(alert).getByText("Full diagnostic output")).not.toBeVisible(); - await user.click(log); - expect(within(alert).getByText("Full diagnostic output")).toBeVisible(); -}); - -it("installs Claude Code with durable progress, failed-step logs, retry and native re-detection", async () => { - const user = userEvent.setup(); - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const failed: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Installing Claude Code failed", - }; - const install = vi - .fn() - .mockReturnValueOnce(pending) - .mockResolvedValueOnce({ ...failed, ready: true, error: null }); - const { control, fixture } = setupHarnesses("cli-needed", { - installSupported: true, - installPi: vi.fn(), - claude: { - status: "adapter-needed", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - installClaude: install, - }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - await user.click(row().getByRole("button", { name: "Install" })); - try { - expect(await row().findByRole("status")).toHaveTextContent( - "Installing Claude Code", - ); - expect(screen.getByRole("button", { name: "Check again" })).toBeDisabled(); - const piRow = screen.getByText("Pi", { exact: true }).closest("li"); - if (!piRow) throw new Error("Missing Pi row"); - expect( - within(piRow).getByRole("button", { name: "Install" }), - ).toBeDisabled(); - cleanup(); - render(, { wrapper: ToastProvider }); - expect(await row().findByRole("status")).toHaveTextContent( - "Installing Claude Code", - ); - } finally { - finish(failed); - } - expect(await row().findByRole("alert")).toHaveTextContent( - "Installing Claude Code failed", - ); - await user.click(row().getByText("Claude Code install log")); - expect(row().getByText("npm error EACCES")).toBeVisible(); - expect(row().getByText("/fixture/claude-install.log")).toBeVisible(); - if (!fixture.data.claudeSetup) throw new Error("Missing Claude fixture"); - fixture.data.claudeSetup.status = "ready"; - await user.click(row().getByRole("button", { name: "Install" })); - expect(await row().findByText("Sign-in unconfirmed")).toBeVisible(); - expect(row().queryByRole("alert")).toBeNull(); - expect(row().queryByRole("button", { name: "Install" })).toBeNull(); - await user.click(row().getByText("Sign in to Claude Code")); - expect(row().getByText("'/fixture/claude' auth login")).toBeVisible(); - expect(row().getByText(/Couldn’t confirm Claude Code sign-in/)).toBeVisible(); - expect(install).toHaveBeenCalledTimes(2); -}); - -it.each([false, true] as const)( - "keeps keyboard retry usable and hands off success only while Install owns focus (moved: %s)", - async (moveFocus) => { - const user = userEvent.setup(); - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const report: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Installation failed", - }; - const install = vi.fn(() => pending); - const { fixture } = setupHarnesses("ready", { - claude: { - status: "cli-needed", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - installClaude: install, - checkClaudeAuth: async () => !moveFocus, - }); - const row = within( - await screen.findByRole("listitem", { name: "Claude Code harness" }), - ); - const button = row.getByRole("button", { name: "Install" }); - for (let i = 0; i < 10 && document.activeElement !== button; i++) { - await user.tab(); - } - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - try { - expect(await row.findByText(/Installing Claude Code and/)).toBeVisible(); - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - expect(install).toHaveBeenCalledTimes(1); - } finally { - await act(async () => { - finish(report); - await pending; - }); - } - expect(await row.findByRole("alert")).toHaveTextContent( - "Installation failed", - ); - expect(button).toHaveFocus(); - expect(button).not.toBeDisabled(); - const retry = new Promise((resolve) => { - finish = resolve; - }); - install.mockReturnValue(retry); - await user.keyboard("{Enter}"); - await waitFor(() => expect(install).toHaveBeenCalledTimes(2)); - let next: Element | null = null; - try { - expect(button).toHaveFocus(); - if (moveFocus) { - next = screen.getByRole("button", { name: "Add harness" }); - for (let i = 0; i < 10 && document.activeElement !== next; i++) { - await user.tab(); - } - expect(next).toHaveFocus(); - } - if (!fixture.data.claudeSetup) throw new Error("Missing Claude fixture"); - fixture.data.claudeSetup.status = "ready"; - } finally { - await act(async () => { - finish({ ...report, ready: true, error: null }); - await retry; - }); - } - const status = await row.findByText(moveFocus ? "Sign-in needed" : "Ready"); - expect(row.queryByRole("button", { name: "Install" })).toBeNull(); - if (moveFocus) expect(next).toHaveFocus(); - else expect(status).toHaveFocus(); - }, -); - -it("offers manual Claude setup and disables creation when its tools are missing", async () => { - const user = userEvent.setup(); - const { control, fixture } = setupHarnesses("ready", { - claude: { - status: "cli-needed", - installSupported: false, - loginCommand: null, - }, - installClaude: vi.fn(), - }); - const row = within( - await screen.findByRole("listitem", { name: "Claude Code harness" }), - ); - expect(row.queryByRole("button", { name: "Install" })).toBeNull(); - await user.click(row.getByText("Manual Claude Code setup")); - expect( - row.getByText(/npm install -g @anthropic-ai\/claude-code/), - ).toBeVisible(); - expect( - row.getByText(/npm install -g @agentclientprotocol\/claude-agent-acp/), - ).toBeVisible(); - // Exercise creation's real picker with the same native snapshot as Settings. - fixture.data.harnessOptions?.push({ - command: "claude-agent-acp", - label: "Claude Code", - available: false, - status: "cli-needed", - providers: [], - defaultArgs: [], - }); - await control.refresh(); - cleanup(); - render( - {}} - />, - ); - await user.click(screen.getByRole("combobox", { name: "Harness" })); - expect( - await screen.findByRole("option", { name: "Buzz Agent" }), - ).toBeVisible(); - expect( - screen.getByRole("option", { name: "Claude Code (install first)" }), - ).toHaveAttribute("aria-disabled", "true"); - expect(control.snapshot().data?.claudeSetup?.status).toBe("cli-needed"); -}); - -it.each([true, false, null] as const)( - "shows Claude setup guidance only when sign-in needs attention (%s)", - async (signedIn) => { - const user = userEvent.setup(); - const checkClaudeAuth = vi.fn().mockResolvedValue(signedIn); - setupHarnesses("ready", { - claude: { - status: "ready", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - checkClaudeAuth, - }); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - if (signedIn === true) { - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(row().getByText("Ready")).toBeVisible()); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - expect( - row().queryByText(/Claude Code and its ACP adapter are installed/), - ).toBeNull(); - } else { - expect( - await row().findByText( - signedIn === false ? "Sign-in needed" : "Sign-in unconfirmed", - ), - ).toBeVisible(); - expect(row().queryByText("Ready")).toBeNull(); - await user.click(row().getByText("Sign in to Claude Code")); - expect(row().getByText("'/fixture/claude' auth login")).toBeVisible(); - } - checkClaudeAuth.mockResolvedValue(true); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(row().getByText("Ready")).toBeVisible()); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - }, -); - -it("ignores a late Claude auth check after Check again and retains an install failure on a ready row", async () => { - const user = userEvent.setup(); - let finish!: (signedIn: boolean) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const checkClaudeAuth = vi - .fn() - .mockReturnValueOnce(pending) - .mockResolvedValue(false); - const { control } = setupHarnesses("ready", { - claude: { - status: "ready", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - checkClaudeAuth, - installClaude: vi.fn().mockResolvedValue({ - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Update failed; previous tools retained", - }), - }); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - try { - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(1)); - expect(row().getByText("Checking sign-in…")).toBeVisible(); - expect(row().queryByText("Ready")).toBeNull(); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await row().findByText("Sign-in needed")).toBeVisible(); - } finally { - await act(async () => { - finish(true); - await pending; - }); - } - expect(row().getByText("Sign-in needed")).toBeVisible(); - checkClaudeAuth.mockResolvedValue(true); - await act(async () => { - await control.installClaude?.(); - }); - expect(await row().findByText("Ready")).toBeVisible(); - expect(row().getByRole("alert")).toHaveTextContent( - "Update failed; previous tools retained", - ); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - await user.click(row().getByText("Claude Code install log")); - expect(row().getByText("npm error EACCES")).toBeVisible(); -}); diff --git a/src/app/AgentSettings.tsx b/src/app/AgentSettings.tsx index 0b3a1ab9d..6422af27c 100644 --- a/src/app/AgentSettings.tsx +++ b/src/app/AgentSettings.tsx @@ -1,695 +1,28 @@ -import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; -import { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import { - harnessPresets, - harnessKind, - harnessPreset, -} from "../features/agents/harness-presets"; -import { PresetSetupHint } from "../features/agents/PresetSetupHint"; -import type { AgentControl } from "../features/agents/control"; +import { useState, type ReactNode } from "react"; import { setRememberAgentsPreference, useRememberAgentsPreference, } from "../features/messages/mention-preferences"; -import { - ArrowsClockwiseIcon, - ArrowSquareOutIcon, - CodexLogoIcon, - CopyIcon, - GooseLogoIcon, - HermesLogoIcon, - PiLogoIcon, - PlusIcon, - QuestionIcon, - RobotIcon, - TerminalWindowIcon, -} from "../shared/design-system/icons"; import { Header, InlineHeader } from "../shared/design-system/ui/Header"; import { Button } from "../shared/design-system/ui/Button"; -import { Dialog } from "../shared/design-system/ui/Dialog"; -import { NavigationItem } from "../shared/design-system/ui/NavigationItem"; -import { IconButton } from "../shared/design-system/ui/IconButton"; -import { PreferenceRow } from "../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; import { SwitchPreferenceRow } from "../shared/design-system/ui/SwitchPreferenceRow"; - import { ToastNotice } from "../shared/design-system/ui/Toast"; -import { Tooltip } from "../shared/design-system/ui/Tooltip"; -import type { ReactNode } from "react"; -import { AgentDefaultsCard } from "./AgentDefaultsCard"; -import { ClaudeHarnessSetup } from "./ClaudeHarnessSetup"; -import styles from "./AgentSettings.module.css"; - -const acpHint = - "Buzz talks to harnesses through the Agent Client Protocol (ACP). Goose ships with Buzz. Pi needs the buzz-pi-acp adapter. Hermes Agent uses its own ACP launcher and sign-in."; -const piCommand = "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'"; -const adapterCommand = - "npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#72015de'"; -const labels = { - ready: "Ready", - "cli-needed": "CLI needed", - "adapter-needed": "Adapter needed", - "check-needed": "Check needed", - "not-enabled": "Not enabled", -} as const; -// Artwork only; native harnessOptions still own availability and configuration. -const harnessIcons: Record = { - "buzz-agent": , - codex: , - goose: , - hermes: , - pi: , -}; -const codexAdapterCommand = - "npm install -g @agentclientprotocol/codex-acp@2.1.1"; -const commands = [ - ["Pi", "Install Pi", piCommand], - ["Adapter", "Install the ACP adapter", adapterCommand], -] as const; export function AgentSettings({ - control, - active = true, archive, + active = true, }: { - control: AgentControl; - active?: boolean; archive?: ReactNode; + active?: boolean; }) { const preference = useRememberAgentsPreference(); const [error, setError] = useState(null); - const [checking, setChecking] = useState(false); - const [authCheck, setAuthCheck] = useState(0); - const [catalogOpen, setCatalogOpen] = useState(false); - const [selectedPresetId, setSelectedPresetId] = useState(); - const addHarnessRef = useRef(null); - const [copyMessage, setCopyMessage] = useState(""); - const copyAttempt = useRef(0); - const state = useSyncExternalStore(control.subscribe, control.snapshot); - const { - installing: installingPi, - report: piResult, - error: piError, - } = state.piInstall ?? { installing: false, report: null, error: null }; - useEffect(() => { - if (active) void control.refresh(); - }, [active, control]); - const options = state.data?.harnessOptions; - const coreHarnesses = (["buzz-agent", "goose", "pi"] as const).map((id) => - options?.find((option) => harnessKind(option.command) === id), - ); - const presets = - options?.filter( - (option) => - !!harnessPreset(option.command) && - harnessKind(option.command) !== "claude", - ) ?? []; - const setup = - harnessPresets.find((preset) => preset.id === selectedPresetId) ?? - harnessPresets[0]; - // Native owns availability; registry metadata also works on older snapshots. - const selected = options?.find( - (option) => harnessPreset(option.command) === setup, - ); - const presetLabel = selected?.label ?? setup?.label ?? "Harness"; - const codexOption = options?.find((option) => option.id === "codex"); - const harnesses = [ - ...coreHarnesses, - ...presets.filter((option) => option.available), - ...(codexOption ? [codexOption] : []), - ]; - const available = coreHarnesses.every((option) => !!option?.status); - const pi = coreHarnesses[2]; - const codexInstall = state.codexInstall; - const codexToolsReady = codexOption?.status === "ready"; - const [codexAuth, setCodexAuth] = useState( - "checking", - ); - // Like Claude Code: read the existing CLI login only once its tools are found. - // biome-ignore lint/correctness/useExhaustiveDependencies: Check again and install completion must trigger this read even when tool presence is unchanged. - useEffect(() => { - if (!active || !codexToolsReady || codexInstall?.installing) return; - let current = true; - setCodexAuth("checking"); - void (control.checkCodexAuth?.() ?? Promise.resolve(null)) - .catch(() => null) - .then((result) => { - if (current) setCodexAuth(result); - }); - return () => { - current = false; - }; - }, [ - active, - codexToolsReady, - codexInstall?.installing, - codexInstall?.report, - authCheck, - control, - ]); - // Like Claude Code: keep keyboard focus when a successful install removes Install. - const codexStatusRef = useRef(null); - const codexInstallRef = useCallback((button: HTMLButtonElement | null) => { - if (!button) return; - return () => { - if (button.ownerDocument.activeElement === button) { - codexStatusRef.current?.focus(); - } - }; - }, []); - const offerCodexInstall = - !!control.installCodex && - !!codexOption?.installSupported && - codexOption.status === "adapter-needed"; - const codexStatus = !codexToolsReady - ? codexOption?.status - ? labels[codexOption.status] - : "Unknown" - : codexAuth === "checking" - ? "Checking sign-in…" - : codexAuth === true - ? "Ready" - : codexAuth === false - ? "Sign-in needed" - : "Sign-in unconfirmed"; - const checkDisabled = - state.status === "unavailable" || - state.busy || - installingPi || - state.claudeInstall?.installing || - codexInstall?.installing; - const checkAgain = () => { - setChecking(true); - void control.refresh().finally(() => { - setChecking(false); - setAuthCheck((value) => value + 1); - }); - }; const change = (enabled: boolean) => setError(setRememberAgentsPreference(enabled)); - const copy = async (name: string, command: string) => { - const attempt = ++copyAttempt.current; - try { - await navigator.clipboard.writeText(command); - if (attempt === copyAttempt.current) - setCopyMessage(`${name} command copied.`); - } catch { - if (attempt === copyAttempt.current) - setCopyMessage( - `Couldn’t copy the ${name} command. Select it to copy manually.`, - ); - } - }; return (
-
- - - - - - - } - /> - - {state.status === "unavailable" ? ( -

- Harness detection requires the desktop app. -

- ) : state.status === "loading" || state.status === "idle" ? ( -

- Checking harnesses… -

- ) : !available ? ( -

- {state.status === "error" - ? "Couldn’t check harnesses. Select Check again to retry." - : "Update the desktop app to check harnesses."} -

- ) : ( - <> - {state.status === "error" && ( -

- Couldn’t confirm harnesses. Showing the last check; try Check - again. -

- )} -
    - {harnesses.map((option) => ( -
  • -
    - - {option && - (harnessIcons[ - harnessKind(option.command) ?? option.id ?? "" - ] ?? ( - - ))} - {option?.label} - - - - {option?.id === "codex" - ? codexStatus - : option?.status - ? labels[option.status] - : "Unknown"} - - {option && - harnessKind(option.command) === "pi" && - (option.status !== "ready" || - option.updateSupported) && - option.installSupported && - control.installPi && - !piResult?.ready && ( - - )} - {option?.id === "codex" && offerCodexInstall && ( - - )} - -
    - {option && - option.status !== "ready" && - harnessPreset(option.command) && ( -
    -

    - Uses the default model and credentials configured in{" "} - {option.label}. Install and update the harness - yourself, then use Check again. -

    -
    - Manual {option.label} setup -
    - -
    -
    -
    - )} - {option?.id === "codex" && - (codexInstall?.installing || - codexInstall?.report?.error || - codexInstall?.error) && ( -
    - {codexInstall.installing ? ( -

    - Installing Node.js and the Codex ACP adapter… -

    - ) : ( -
    -

    - {codexInstall.report?.error || - codexInstall.error} -

    - {codexInstall.report && ( -
    - - Codex ACP adapter install log - -

    - {codexInstall.report.logPath} -

    -
    -                                    {codexInstall.report.output ||
    -                                      "No output was recorded."}
    -                                  
    -
    - )} -
    - )} -
    - )} - {option?.id === "codex" && - offerCodexInstall && - !codexInstall?.installing && ( -
    -

    - Click Install. Buzz installs Node.js and the Codex - ACP adapter for you. Your Codex CLI and sign-in are - left untouched. -

    -
    - Manual Codex ACP adapter setup - - {codexAdapterCommand} - -
    -
    - )} - {option?.id === "codex" && - (option.status === "cli-needed" || - (codexToolsReady && - (codexAuth === false || codexAuth === null))) && ( -

    - {option.status === "cli-needed" - ? "Install the Codex CLI, then use Check again." - : "Run codex login in your terminal, then use Check again."} -

    - )} - {option && - harnessKind(option.command) === "pi" && - (installingPi || - piResult?.ready || - piResult?.error || - piError || - pi?.status !== "ready") && ( -
    - {installingPi && ( -

    - Installing Pi and its ACP adapter… -

    - )} - {!installingPi && - piResult?.ready && - pi?.status === "ready" && ( -

    - Pi and its adapter are up to date. Restart - running Pi agents to use them. Restarted{" "} - {piResult.restarted} waiting agents. - {piResult.restartFailures > 0 && - ` ${piResult.restartFailures} agents could not restart; check Agents.`} -

    - )} - {!installingPi && (piResult?.error || piError) && ( -
    -

    - {piResult?.error || piError} -

    - {piResult && ( -
    - Pi install log -

    - {piResult.logPath} -

    -
    -                                    {piResult.output ||
    -                                      "No output was recorded."}
    -                                  
    -
    - )} -
    - )} - {pi?.status !== "ready" && ( -
    -

    - {pi?.installSupported && control.installPi - ? "Click Install. Buzz installs Node.js, Pi, and its ACP adapter for you." - : "Use Manual setup on this device, then click Check again."}{" "} - Your existing CLI setup and sign-in are left - untouched. -

    -
    - Manual setup -
    -
      -
    1. - -
    2. - {commands.map(([name, title, command]) => ( -
    3. - - {command} - - } - trailing={ - - - } - /> -
    4. - ))} -
    5. - -
    6. -
    - {copyMessage && ( -

    - {copyMessage} -

    - )} -
    -
    -
    - )} -
    - )} -
  • - ))} - -
- - - )} -
-
- - - {archive}
@@ -712,31 +45,3 @@ export function AgentSettings({
); } - -function PresetSetup({ - label, - setup, -}: { - label: string; - setup: { setupUrl: string; setupHint: string } | undefined; -}) { - if (!setup) return null; - return ( -
-

- Follow the{" "} - - {label} setup guide - {" "} - for installation instructions.{" "} - -

-

Use Check again to refresh the installation status.

-
- ); -} diff --git a/src/app/App.profile.test.tsx b/src/app/App.profile.test.tsx index 5262fce99..71dfbfd01 100644 --- a/src/app/App.profile.test.tsx +++ b/src/app/App.profile.test.tsx @@ -13,7 +13,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { finalizeEvent, getPublicKey } from "nostr-tools"; import { StrictMode } from "react"; import { App } from "./App"; -import { requestSnapshotPreview } from "../features/agents/snapshot-preview"; import { createServices, type AppServices } from "./services"; import { matchesEvent } from "../features/relay/projection"; import type { ReadFilter } from "../features/relay/events"; @@ -284,33 +283,3 @@ it("opens the viewer's profile from New message without discarding recipients or await user.keyboard(" and keep editing"); expect(editor).toHaveTextContent("Keep this unsent draft and keep editing"); }); - -it("mounts the received snapshot preview host without automatically importing", async () => { - const user = await setup(); - await screen.findByRole("button", { name: "Your profile" }); - const current = services; - if (!current) throw new Error("Missing services"); - const connection = current.relay.snapshot(); - if (connection.status !== "ready") throw new Error("Missing ready session"); - const create = vi.fn(); - Object.assign(current.agentControl, { create }); - act(() => - requestSnapshotPreview(connection.session, { - url: `${origin}/media/${"a".repeat(64)}.png`, - name: "helper.agent.png", - kind: "image", - }), - ); - const dialog = await screen.findByRole("dialog", { - name: "Preview snapshot", - }); - expect(dialog).toHaveTextContent("Preview snapshot"); - expect(create).not.toHaveBeenCalled(); - await user.click(within(dialog).getByRole("button", { name: "Cancel" })); - await waitFor(() => - expect( - screen.queryByRole("dialog", { name: "Preview snapshot" }), - ).not.toBeInTheDocument(), - ); - expect(create).not.toHaveBeenCalled(); -}); diff --git a/src/app/App.tsx b/src/app/App.tsx index 63cab60d1..41672f0e6 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -6,9 +6,6 @@ import { ChannelNavigationProvider } from "../features/channel-navigation/Channe import { Agents2Context } from "../features/agents2/react"; import { ToastProvider } from "../shared/design-system/ui/Toast"; import { Button } from "../shared/design-system/ui/Button"; -import { AgentWakeNotice } from "../features/agents/AgentWakeNotice"; -import { AgentUpdateReview } from "../bundled/agents/AgentUpdateReview"; -import { SnapshotPreview } from "../bundled/agents/SnapshotPreview"; import { UpdateNotice } from "../features/updates/UpdateNotice"; import { useEffect, useSyncExternalStore } from "react"; import { registerAppShortcuts } from "./shortcuts"; @@ -242,15 +239,6 @@ function ConnectedApp({ services }: { services: AppServices }) { startup === "ready" && route.page?.layout === "workspace" } > - - - {startup === "recovery" && !settings ? ( @@ -286,7 +274,6 @@ function ConnectedApp({ services }: { services: AppServices }) { shortcuts={services.shortcuts} shortcutBindings={services.shortcutBindings} notifications={services.notifications} - agentControl={services.agentControl} updates={services.updates} navigation={route.request} navigationPane diff --git a/src/app/ClaudeHarnessSetup.tsx b/src/app/ClaudeHarnessSetup.tsx deleted file mode 100644 index c6800e805..000000000 --- a/src/app/ClaudeHarnessSetup.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { - AgentControl, - AgentControlState, -} from "../features/agents/control"; -import { ClaudeLogoIcon } from "../shared/design-system/icons"; -import { Button } from "../shared/design-system/ui/Button"; -import styles from "./AgentSettings.module.css"; - -const cliCommand = "npm install -g @anthropic-ai/claude-code@2.1.289"; -const adapterCommand = - "npm install -g @agentclientprotocol/claude-agent-acp@0.85.1"; - -export function ClaudeHarnessSetup({ - control, - state, - active = true, -}: { - control: AgentControl; - state: AgentControlState; - active?: boolean; -}) { - const setup = state.data?.claudeSetup; - - const { installing, report, error } = state.claudeInstall ?? { - installing: false, - report: null, - error: null, - }; - const toolsReady = setup?.status === "ready"; - const [auth, setAuth] = useState("checking"); - const statusRef = useRef(null); - const installRef = useCallback((button: HTMLButtonElement | null) => { - if (!button) return; - return () => { - if (button.ownerDocument.activeElement === button) { - statusRef.current?.focus(); - } - }; - }, []); - // A new install result also rechecks auth if React batches a fast install. - // biome-ignore lint/correctness/useExhaustiveDependencies: install completion must trigger this read even when tool presence is unchanged. - useEffect(() => { - if (!active || !toolsReady || installing) return; - let current = true; - setAuth("checking"); - void (control.checkClaudeAuth?.() ?? Promise.resolve(null)) - .catch(() => null) - .then((result) => { - if (current) setAuth(result); - }); - return () => { - current = false; - }; - }, [active, toolsReady, installing, report, control]); - if (!setup) return null; - const ready = toolsReady && auth === true; - const needsGuidance = !toolsReady || auth === false || auth === null; - const showDetails = installing || report?.error || error || needsGuidance; - return ( -
  • -
    - - - Claude Code - - - - {ready - ? "Ready" - : toolsReady - ? auth === "checking" - ? "Checking sign-in…" - : auth === false - ? "Sign-in needed" - : "Sign-in unconfirmed" - : setup.status === "cli-needed" - ? "CLI needed" - : "Adapter needed"} - - {!toolsReady && setup.installSupported && control.installClaude && ( - - )} - -
    - {showDetails && ( -
    - {installing && ( -

    Installing Claude Code and its ACP adapter…

    - )} - {!installing && (report?.error || error) && ( -
    -

    - {report?.error || error} -

    - {report && ( -
    - Claude Code install log -

    {report.logPath}

    -
    -                    {report.output || "No output was recorded."}
    -                  
    -
    - )} -
    - )} - {!toolsReady && ( - <> -

    - {setup.installSupported && control.installClaude - ? "Click Install. Buzz installs Node.js, Claude Code, and its ACP adapter for you." - : "Use Manual setup on this device, then click Check again."} -

    -
    - Manual Claude Code setup -
    -

    - Install Node.js 22 or newer, then run these in your - terminal: -

    - {[cliCommand, adapterCommand].map((command) => ( - - {command} - - ))} -

    Use Check again to refresh the installation status.

    -
    -
    - - )} - {toolsReady && auth === null && ( -

    - Couldn’t confirm Claude Code sign-in. Use Check again to retry, or - sign in below. -

    - )} - {needsGuidance && setup.loginCommand && ( -
    - Sign in to Claude Code -

    - Run this in your terminal and follow Claude’s sign-in steps: -

    - - {setup.loginCommand} - -
    - )} -
    - )} -
  • - ); -} diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index ee7290f21..cbff4f34d 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -27,7 +27,6 @@ import { ShortcutSettings } from "./ShortcutSettings"; import { DeveloperSettings } from "./DeveloperSettings"; import { ArchiveSettings } from "./ArchiveSettings"; import { AgentSettings } from "./AgentSettings"; -import type { AgentControl } from "../features/agents/control"; import type { SettingsCards } from "../features/settings/service"; import { OwnedContribution } from "../plugins/OwnedContribution"; import type { Updates } from "../features/updates/updates"; @@ -43,14 +42,12 @@ export function Settings({ shortcuts, shortcutBindings, notifications, - agentControl, updates, navigation, onSection, navigationPane = false, }: { cards: SettingsCards; - agentControl: AgentControl; plugins: PluginManager; communities: Communities; identity?: Identity | undefined; @@ -163,7 +160,6 @@ export function Settings({