From 209191202c4c1a25c5c992ba1cf764652bba7133 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 12:55:53 -0700 Subject: [PATCH 01/18] Refine agent harness configuration flow --- .../agents/ui/AgentAiConfigurationMode.tsx | 76 +++++- .../features/agents/ui/AgentAiDefaults.tsx | 10 + .../agents/ui/AgentDefinitionDialog.tsx | 220 +++++++++--------- .../features/agents/ui/AgentHarnessField.tsx | 40 ++++ .../global-agent-config-screenshots.spec.ts | 28 ++- 5 files changed, 254 insertions(+), 120 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentHarnessField.tsx diff --git a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx index 92f18a2163..b932e98672 100644 --- a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx +++ b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx @@ -1,20 +1,64 @@ +import type * as React from "react"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy"; +import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; +import type { InheritedDefault } from "./bakedEnvHelpers"; export type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy"; export function HarnessModelDefaultNotice({ + harness, model, }: { + harness: string; model?: string | null; }) { return ( -
- Model{" "} - +
+
Harness
+
+ {harness || "Not configured"} +
+
Model
+
{model?.trim() || "Harness default"} - -
+ + + ); +} + +export function AgentCreateAiDefaultsSummary({ + canChooseProvider, + harness, + inheritedModel, + inheritedProvider, + model, + onEditDefaults, + triggerRef, +}: { + canChooseProvider: boolean; + harness: string; + inheritedModel: InheritedDefault; + inheritedProvider: InheritedDefault; + model?: string | null; + onEditDefaults: () => void; + triggerRef?: React.Ref; +}) { + return canChooseProvider ? ( + + ) : ( + ); } @@ -36,13 +80,29 @@ export function AgentAiConfigurationModeField({ } value={mode} > - - + + -
- - - {runtimeWarning} -
- {modelFieldVisible ? ( ) : null} - {llmProviderFieldVisible && aiConfigurationMode === "custom" ? ( -
- - LLM provider - {!providerIsRequired ? ( - - Optional - - ) : null} - - + {aiConfigurationMode === "custom" ? ( + - {showCustomProviderInput ? ( -
+ - + Optional + + ) : null} + + + {showCustomProviderInput ? ( +
setProvider(event.target.value)} - placeholder="Custom provider ID" - value={provider} - /> -
- ) : null} -
- ) : null} - - {llmProviderFieldVisible && - aiConfigurationMode === "custom" && - topLevelSecretEnvVar ? ( - { - setEnvVars((prev) => ({ - ...prev, - [topLevelSecretEnvVar]: next, - })); - }} - value={apiKeyValue} - /> - ) : null} + > + setProvider(event.target.value)} + placeholder="Custom provider ID" + value={provider} + /> +
+ ) : null} + + ) : null} - - {modelFieldVisible && aiConfigurationMode === "custom" ? ( - { + setEnvVars((prev) => ({ + ...prev, + [topLevelSecretEnvVar]: next, + })); + }} + value={apiKeyValue} /> ) : null} - - {aiConfigurationMode === "defaults" ? ( - runtimeCanChooseLlmProvider ? ( - setAiDefaultsOpen(true)} - triggerRef={aiDefaultsTriggerRef} - explicitModel="" - explicitProvider="" + + {modelFieldVisible && aiConfigurationMode === "custom" ? ( + + ) : null} + + + {aiConfigurationMode === "defaults" ? ( + setAiDefaultsOpen(true)} + triggerRef={aiDefaultsTriggerRef} /> - ) : ( - - ) - ) : null} + ) : null} + void; + options: PersonaDropdownOption[]; + placeholder: string; + value: string; + warning?: ReactNode; +}) { + return ( +
+ + + {warning} +
+ ); +} diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 59d6759e3d..812f4477e4 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -267,6 +267,11 @@ test.describe("global agent config screenshots", () => { await openCreateDialog(page); await customizeAgentAi(page); + await expect( + page + .getByTestId("agent-custom-configuration-section") + .locator("#persona-runtime"), + ).toBeVisible(); await expect(page.getByLabel("Anthropic API Key")).toBeVisible({ timeout: 10_000, }); @@ -274,6 +279,11 @@ test.describe("global agent config screenshots", () => { page.getByRole("button", { name: "Advanced", exact: true }), ).toHaveAttribute("aria-expanded", "false"); await expect(page.getByTestId("env-vars-required-key")).not.toBeVisible(); + + await waitForAnimations(page); + await page.getByRole("dialog").screenshot({ + path: `${SHOTS}/02-create-custom-agent-configuration.png`, + }); }); test("03-global-env-satisfies-required-key", async ({ page }) => { @@ -419,6 +429,20 @@ test.describe("global agent config screenshots", () => { await openCreateDialog(page); + const defaultsSection = page.getByTestId( + "agent-defaults-configuration-section", + ); + await expect(defaultsSection.locator("#persona-runtime")).toHaveCount(0); + await expect( + defaultsSection.getByTestId("agent-ai-defaults-notice"), + ).toBeVisible(); + await expect( + defaultsSection.getByText("Harness", { exact: true }), + ).toBeVisible(); + await expect( + defaultsSection.getByText("Buzz Agent", { exact: true }), + ).toBeVisible(); + // Global provider satisfies the provider-default rule → submit enabled. await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled({ timeout: 10_000, @@ -449,12 +473,14 @@ test.describe("global agent config screenshots", () => { await openCreateDialog(page); - // Switch the auto-selected buzz-agent runtime to the CLI-login runtime. + // Harness selection belongs to the per-agent customization flow. + await customizeAgentAi(page); await selectDropdownOption( page, page.locator("#persona-runtime"), "Claude Code", ); + await page.getByRole("tab", { name: "Use harness defaults" }).click(); // Provider picker hidden — the runtime drives its own provider. await expect(page.locator("#persona-llm-provider")).not.toBeVisible(); From bd36b13d7c5668954098c43348fa82f05dfaa5f2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:06:45 -0700 Subject: [PATCH 02/18] Remove agent configuration helper copy --- .../src/features/agents/ui/AgentAiConfigurationMode.tsx | 7 ------- desktop/tests/e2e/agent-provider-dropdowns.spec.ts | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx index b932e98672..71b4006e4c 100644 --- a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx +++ b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx @@ -105,13 +105,6 @@ export function AgentAiConfigurationModeField({
- {mode === "custom" ? ( -

- {needsProviderSelection - ? "Provider and model changes apply only to this agent." - : "Model changes apply only to this agent."} -

- ) : null} ); } diff --git a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts index 085e202da9..351a25855d 100644 --- a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts +++ b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts @@ -250,6 +250,6 @@ test.describe("agent provider dropdown screenshots", () => { await expect( dialog.getByRole("combobox", { name: /model/i }), ).toBeVisible(); - await expect(dialog.getByText("Model changes apply only")).toBeVisible(); + await expect(dialog.getByText("Model changes apply only")).toHaveCount(0); }); }); From 13d63609f215f7352bd99675ec2e6891696ce54f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:26:04 -0700 Subject: [PATCH 03/18] Refine incomplete agent defaults state --- .../agents/ui/AgentAiConfigurationMode.tsx | 3 ++ .../features/agents/ui/AgentAiDefaults.tsx | 26 ++++++++++++++++ .../agents/ui/AgentDefinitionDialog.tsx | 6 ++-- .../agents/ui/personaSubmitBlock.test.mjs | 21 ++++++++++--- .../features/agents/ui/personaSubmitBlock.ts | 11 ++++--- .../global-agent-config-screenshots.spec.ts | 31 ++++++++++++++----- 6 files changed, 80 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx index 71b4006e4c..6750649021 100644 --- a/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx +++ b/desktop/src/features/agents/ui/AgentAiConfigurationMode.tsx @@ -35,6 +35,7 @@ export function AgentCreateAiDefaultsSummary({ harness, inheritedModel, inheritedProvider, + isConfigured, model, onEditDefaults, triggerRef, @@ -43,12 +44,14 @@ export function AgentCreateAiDefaultsSummary({ harness: string; inheritedModel: InheritedDefault; inheritedProvider: InheritedDefault; + isConfigured: boolean; model?: string | null; onEditDefaults: () => void; triggerRef?: React.Ref; }) { return canChooseProvider ? ( void; triggerRef?: React.Ref; explicitModel: string; @@ -45,6 +47,30 @@ export function AgentAiDefaultsNotice({ const provider = explicitProvider.trim() || inheritedProvider.value; const model = explicitModel.trim() || inheritedModel.value; + if (!isConfigured) { + return ( +
+

+ Global defaults not set +

+ +
+ ); + } + return (
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index d55497e2ec..05ab2358aa 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -466,8 +466,9 @@ export function AgentDefinitionDialog({ // Derive the single, deterministic reason the action is disabled from the // same gate outputs that feed canSubmit — no policy is recomputed here. - // Precedence mirrors canSubmit's term order, so the reason is `null` exactly - // when the form can be submitted (transient Saving/Uploading states aside). + // Precedence mirrors canSubmit's term order. In create mode, incomplete + // global defaults are explained next to the defaults themselves instead of + // being repeated in the footer. const submitBlockReason = personaSubmitBlock({ isPending, isAvatarUploadPending, @@ -958,6 +959,7 @@ export function AgentDefinitionDialog({ harness={runtimeSummaryLabel} inheritedModel={inheritedModelDefault} inheritedProvider={inheritedProviderDefault} + isConfigured={localModeSatisfied} model={runtimeFileConfig?.model} onEditDefaults={() => setAiDefaultsOpen(true)} triggerRef={aiDefaultsTriggerRef} diff --git a/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs b/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs index 4984a1d554..2847b19d05 100644 --- a/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs +++ b/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs @@ -37,7 +37,7 @@ test("missing name is reported first", () => { ); }); -test("Buzz Agent + Use AI defaults with no global provider/model names the fix", () => { +test("create mode leaves incomplete global-default guidance to the inline callout", () => { const reason = personaSubmitBlock( submittable({ aiConfigurationMode: "defaults", @@ -45,12 +45,10 @@ test("Buzz Agent + Use AI defaults with no global provider/model names the fix", localModeMissingFields: ["provider", "model"], }), ); - assert.match(reason, /global AI defaults are incomplete/); - assert.match(reason, /a provider and a model/); - assert.match(reason, /Settings → AI defaults/); + assert.equal(reason, null); }); -test("incomplete defaults also names missing credential keys", () => { +test("create mode also leaves missing-default credentials to the inline callout", () => { const reason = personaSubmitBlock( submittable({ localModeSatisfied: false, @@ -58,11 +56,24 @@ test("incomplete defaults also names missing credential keys", () => { localModeMissingEnvKeys: ["ANTHROPIC_API_KEY"], }), ); + assert.equal(reason, null); +}); + +test("edit mode still explains incomplete defaults in the footer", () => { + const reason = personaSubmitBlock( + submittable({ + isCreateMode: false, + localModeSatisfied: false, + localModeMissingFields: [], + localModeMissingEnvKeys: ["ANTHROPIC_API_KEY"], + }), + ); assert.match(reason, /a value for ANTHROPIC_API_KEY/); }); test("the reason disappears once the blocking input is corrected", () => { const blocked = submittable({ + isCreateMode: false, localModeSatisfied: false, localModeMissingFields: ["provider", "model"], }); diff --git a/desktop/src/features/agents/ui/personaSubmitBlock.ts b/desktop/src/features/agents/ui/personaSubmitBlock.ts index 8ee7d9f428..c57722ea7c 100644 --- a/desktop/src/features/agents/ui/personaSubmitBlock.ts +++ b/desktop/src/features/agents/ui/personaSubmitBlock.ts @@ -64,10 +64,10 @@ function describeMissingAiPieces( } /** - * Human-readable reason the Create/Save button is disabled, or `null` when the - * form can be submitted. Precedence mirrors the `canSubmit` term order in - * AgentDefinitionDialog so the surfaced reason is deterministic and always the - * first blocking input — correcting it makes the reason advance or disappear. + * Human-readable footer reason the Create/Save button is disabled, or `null` + * when no footer reason should be shown. Precedence mirrors the `canSubmit` + * term order in AgentDefinitionDialog so the surfaced reason is deterministic. + * Create mode explains incomplete global defaults inline beside their controls. * * While a request or avatar upload is in flight the button communicates the * progress itself ("Saving..." / "Uploading..."), so no reason is returned. @@ -104,6 +104,9 @@ export function personaSubmitBlock( // 6. Resolved AI configuration (provider/model/credentials) incomplete. if (!input.localModeSatisfied) { + if (input.isCreateMode && input.aiConfigurationMode === "defaults") { + return null; + } const missing = describeMissingAiPieces( input.localModeMissingFields, input.localModeMissingEnvKeys, diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 812f4477e4..2aa2015586 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -400,13 +400,30 @@ test.describe("global agent config screenshots", () => { timeout: 10_000, }); - // The footer must explain WHY it is disabled (regression guard for the - // submitBlockReason wiring, not just the boolean gate): defaults mode with - // no resolvable provider names the missing piece and points to Settings. - const reason = page.getByTestId("persona-dialog-submit-reason"); - await expect(reason).toBeVisible({ timeout: 10_000 }); - await expect(reason).toContainText("provider"); - await expect(reason).toContainText("Settings → AI defaults"); + const defaults = page.getByTestId("agent-ai-defaults-notice"); + await expect(defaults).toContainText("Global defaults not set"); + await expect(defaults.getByText("Harness", { exact: true })).toHaveCount(0); + await expect(defaults.getByText("Provider", { exact: true })).toHaveCount( + 0, + ); + await expect(defaults.getByText("Model", { exact: true })).toHaveCount(0); + + const setDefaults = defaults.getByRole("button", { + name: "Set global defaults", + }); + await expect(setDefaults).toBeVisible(); + await expect(page.getByTestId("persona-dialog-submit-reason")).toHaveCount( + 0, + ); + + await setDefaults.click(); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toBeVisible(); + await page + .getByTestId("agent-ai-defaults-dialog") + .getByRole("button", { + name: "Close", + }) + .click(); await waitForAnimations(page); From 1e0c9c3f6c07964b090380d73605a412feea8e1c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:30:15 -0700 Subject: [PATCH 04/18] Remove agent dialog footer messages --- .../agents/ui/AgentDefinitionDialog.tsx | 80 +++----- .../agents/ui/personaSubmitBlock.test.mjs | 171 ------------------ .../features/agents/ui/personaSubmitBlock.ts | 141 --------------- .../global-agent-config-screenshots.spec.ts | 26 ++- 4 files changed, 41 insertions(+), 377 deletions(-) delete mode 100644 desktop/src/features/agents/ui/personaSubmitBlock.test.mjs delete mode 100644 desktop/src/features/agents/ui/personaSubmitBlock.ts diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 05ab2358aa..ad256af7e1 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -31,7 +31,6 @@ import { emptyPersonaBehaviorDraft, personaBehaviorDraftValid, } from "./personaBehaviorDraft"; -import { personaSubmitBlock } from "./personaSubmitBlock"; import { AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, @@ -464,30 +463,6 @@ export function AgentDefinitionDialog({ customAiPairSatisfied && !isAvatarUploadPending; - // Derive the single, deterministic reason the action is disabled from the - // same gate outputs that feed canSubmit — no policy is recomputed here. - // Precedence mirrors canSubmit's term order. In create mode, incomplete - // global defaults are explained next to the defaults themselves instead of - // being repeated in the footer. - const submitBlockReason = personaSubmitBlock({ - isPending, - isAvatarUploadPending, - displayNameEmpty: displayName.trim().length === 0, - isCreateMode, - runtimeChosen: runtime.trim().length > 0, - runtimeAvailable: selectedRuntimeIsAvailable, - createBackendBlocked: createSubmitBlocked, - allowlistEmpty: !personaBehaviorDraftValid(behaviorDraft), - aiConfigurationMode, - localModeSatisfied, - localModeMissingFields: localModeGate.missingNormalizedFields, - localModeMissingEnvKeys: localModeGate.missingEnvKeys, - customAiPairSatisfied, - runtimeNeedsProviderSelection: runtimeCanChooseLlmProvider, - customProviderEmpty: provider.trim().length === 0, - customModelEmpty: model.trim().length === 0, - }); - // Merge global env as the base layer so credential keys satisfied via global // config are available to model discovery — same rationale as in AgentInstanceEditDialog. const envVarsForDiscovery = React.useMemo( @@ -738,40 +713,27 @@ export function AgentDefinitionDialog({ headerClassName="pb-2" title={title} footer={ -
-
- {submitBlockReason ? ( -

- {submitBlockReason} -

- ) : null} -
- -
- - -
+
+ +
} > diff --git a/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs b/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs deleted file mode 100644 index 2847b19d05..0000000000 --- a/desktop/src/features/agents/ui/personaSubmitBlock.test.mjs +++ /dev/null @@ -1,171 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { personaSubmitBlock } from "./personaSubmitBlock.ts"; - -/** A fully valid, submittable form: personaSubmitBlock returns null. */ -function submittable(overrides = {}) { - return { - isPending: false, - isAvatarUploadPending: false, - displayNameEmpty: false, - isCreateMode: true, - runtimeChosen: true, - runtimeAvailable: true, - createBackendBlocked: false, - allowlistEmpty: false, - aiConfigurationMode: "defaults", - localModeSatisfied: true, - localModeMissingFields: [], - localModeMissingEnvKeys: [], - customAiPairSatisfied: true, - runtimeNeedsProviderSelection: true, - customProviderEmpty: false, - customModelEmpty: false, - ...overrides, - }; -} - -test("a valid form has no disabled reason", () => { - assert.equal(personaSubmitBlock(submittable()), null); -}); - -test("missing name is reported first", () => { - assert.equal( - personaSubmitBlock(submittable({ displayNameEmpty: true })), - "Enter a name for this agent.", - ); -}); - -test("create mode leaves incomplete global-default guidance to the inline callout", () => { - const reason = personaSubmitBlock( - submittable({ - aiConfigurationMode: "defaults", - localModeSatisfied: false, - localModeMissingFields: ["provider", "model"], - }), - ); - assert.equal(reason, null); -}); - -test("create mode also leaves missing-default credentials to the inline callout", () => { - const reason = personaSubmitBlock( - submittable({ - localModeSatisfied: false, - localModeMissingFields: [], - localModeMissingEnvKeys: ["ANTHROPIC_API_KEY"], - }), - ); - assert.equal(reason, null); -}); - -test("edit mode still explains incomplete defaults in the footer", () => { - const reason = personaSubmitBlock( - submittable({ - isCreateMode: false, - localModeSatisfied: false, - localModeMissingFields: [], - localModeMissingEnvKeys: ["ANTHROPIC_API_KEY"], - }), - ); - assert.match(reason, /a value for ANTHROPIC_API_KEY/); -}); - -test("the reason disappears once the blocking input is corrected", () => { - const blocked = submittable({ - isCreateMode: false, - localModeSatisfied: false, - localModeMissingFields: ["provider", "model"], - }); - assert.notEqual(personaSubmitBlock(blocked), null); - // Correct the blocking input: defaults now resolve. - const corrected = { - ...blocked, - localModeSatisfied: true, - localModeMissingFields: [], - }; - assert.equal(personaSubmitBlock(corrected), null); -}); - -test("create mode requires a chosen, available runtime", () => { - assert.equal( - personaSubmitBlock(submittable({ runtimeChosen: false })), - "Choose where this agent runs.", - ); - assert.equal( - personaSubmitBlock(submittable({ runtimeAvailable: false })), - "The selected runtime isn't available on this machine.", - ); -}); - -test("runtime gates do not apply in edit mode", () => { - assert.equal( - personaSubmitBlock( - submittable({ isCreateMode: false, runtimeChosen: false }), - ), - null, - ); -}); - -test("empty allowlist is reported (create and edit)", () => { - const reason = personaSubmitBlock( - submittable({ isCreateMode: false, allowlistEmpty: true }), - ); - assert.match(reason, /allowed sender/); -}); - -test("Customize with an empty pair but satisfied global fallback points at the pair", () => { - const reason = personaSubmitBlock( - submittable({ - aiConfigurationMode: "custom", - localModeSatisfied: true, - customAiPairSatisfied: false, - customProviderEmpty: true, - customModelEmpty: true, - }), - ); - assert.match(reason, /Select a provider and a model/); - assert.match(reason, /Use AI defaults/); -}); - -test("Customize on Codex/Claude asks only for a model, never a provider", () => { - const reason = personaSubmitBlock( - submittable({ - aiConfigurationMode: "custom", - customAiPairSatisfied: false, - runtimeNeedsProviderSelection: false, - customProviderEmpty: true, - customModelEmpty: true, - }), - ); - assert.match(reason, /Select a model/); - assert.doesNotMatch(reason, /provider/); - assert.match(reason, /Use harness defaults/); - assert.doesNotMatch(reason, /Use AI defaults/); -}); - -test("precedence: a missing name outranks incomplete AI defaults", () => { - assert.equal( - personaSubmitBlock( - submittable({ - displayNameEmpty: true, - localModeSatisfied: false, - localModeMissingFields: ["provider", "model"], - }), - ), - "Enter a name for this agent.", - ); -}); - -test("in-flight save/upload shows no reason (the button label communicates it)", () => { - assert.equal( - personaSubmitBlock( - submittable({ isPending: true, displayNameEmpty: true }), - ), - null, - ); - assert.equal( - personaSubmitBlock(submittable({ isAvatarUploadPending: true })), - null, - ); -}); diff --git a/desktop/src/features/agents/ui/personaSubmitBlock.ts b/desktop/src/features/agents/ui/personaSubmitBlock.ts deleted file mode 100644 index c57722ea7c..0000000000 --- a/desktop/src/features/agents/ui/personaSubmitBlock.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy"; - -/** - * Inputs for {@link personaSubmitBlock}. Every field is an OUTPUT of a gate the - * dialog already computes for `canSubmit` — this module maps those outputs to a - * single human-readable reason. It must not recompute policy: the derivation - * stays a pure function of the gate results so the message can never disagree - * with whether the button is actually disabled. - */ -export type PersonaSubmitBlockInput = { - /** A save/create request is in flight (button shows "Saving..."). */ - isPending: boolean; - /** The avatar upload is in flight (button shows "Uploading..."). */ - isAvatarUploadPending: boolean; - /** Trimmed display name is empty. */ - displayNameEmpty: boolean; - /** Create (new definition) vs edit (existing). Some gates are create-only. */ - isCreateMode: boolean; - /** A runtime has been chosen (create-only gate). */ - runtimeChosen: boolean; - /** The chosen runtime is available on this machine (create-only gate). */ - runtimeAvailable: boolean; - /** The remote / where-to-run backend selection is incomplete (create-only). */ - createBackendBlocked: boolean; - /** Respond-to allowlist mode is selected but the allowlist is empty. */ - allowlistEmpty: boolean; - /** Selected AI configuration mode: inherit global defaults vs customize. */ - aiConfigurationMode: AgentAiConfigurationMode; - /** `computeLocalModeGate(...).satisfied` — resolved AI config is complete. */ - localModeSatisfied: boolean; - /** `computeLocalModeGate(...).missingNormalizedFields`, e.g. ["provider"]. */ - localModeMissingFields: readonly string[]; - /** `computeLocalModeGate(...).missingEnvKeys` — required credentials unset. */ - localModeMissingEnvKeys: readonly string[]; - /** `agentAiConfigurationModeSatisfied(...)` for the Customize pair. */ - customAiPairSatisfied: boolean; - /** Runtime exposes a provider picker (Buzz Agent / Goose), not Codex/Claude. */ - runtimeNeedsProviderSelection: boolean; - /** Customize provider field is empty. */ - customProviderEmpty: boolean; - /** Customize model field is empty. */ - customModelEmpty: boolean; -}; - -function joinWithAnd(parts: readonly string[]): string { - if (parts.length <= 1) return parts[0] ?? ""; - if (parts.length === 2) return `${parts[0]} and ${parts[1]}`; - return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`; -} - -/** - * Describe the concrete missing pieces behind an unsatisfied AI-config gate, - * naming the actual fix rather than a generic "configuration incomplete". - */ -function describeMissingAiPieces( - fields: readonly string[], - envKeys: readonly string[], -): string { - const parts: string[] = []; - if (fields.includes("provider")) parts.push("a provider"); - if (fields.includes("model")) parts.push("a model"); - for (const key of envKeys) parts.push(`a value for ${key}`); - return joinWithAnd(parts); -} - -/** - * Human-readable footer reason the Create/Save button is disabled, or `null` - * when no footer reason should be shown. Precedence mirrors the `canSubmit` - * term order in AgentDefinitionDialog so the surfaced reason is deterministic. - * Create mode explains incomplete global defaults inline beside their controls. - * - * While a request or avatar upload is in flight the button communicates the - * progress itself ("Saving..." / "Uploading..."), so no reason is returned. - */ -export function personaSubmitBlock( - input: PersonaSubmitBlockInput, -): string | null { - if (input.isPending || input.isAvatarUploadPending) { - return null; - } - - // 1. Required definition fields. - if (input.displayNameEmpty) { - return "Enter a name for this agent."; - } - - // 2–4. Create-only runtime / backend gates. - if (input.isCreateMode) { - if (!input.runtimeChosen) { - return "Choose where this agent runs."; - } - if (!input.runtimeAvailable) { - return "The selected runtime isn't available on this machine."; - } - if (input.createBackendBlocked) { - return "Finish configuring the remote backend before creating this agent."; - } - } - - // 5. Access / allowlist crash-loop guard (create and edit). - if (input.allowlistEmpty) { - return "Add at least one allowed sender, or change who this agent responds to."; - } - - // 6. Resolved AI configuration (provider/model/credentials) incomplete. - if (!input.localModeSatisfied) { - if (input.isCreateMode && input.aiConfigurationMode === "defaults") { - return null; - } - const missing = describeMissingAiPieces( - input.localModeMissingFields, - input.localModeMissingEnvKeys, - ); - if (input.aiConfigurationMode === "defaults") { - const detail = missing ? ` — missing ${missing}` : ""; - return `Your global AI defaults are incomplete${detail}. Set them in Settings → AI defaults, or choose Customize to configure this agent directly.`; - } - return missing - ? `This agent's AI configuration is missing ${missing}.` - : "Complete this agent's AI configuration."; - } - - // 7. Customize pair incomplete (form provider/model empty while a global - // fallback keeps localMode satisfied). Provider only counts where the runtime - // exposes a picker — Codex/Claude drive their own provider. - if (!input.customAiPairSatisfied) { - const needProvider = - input.runtimeNeedsProviderSelection && input.customProviderEmpty; - const pieces: string[] = []; - if (needProvider) pieces.push("a provider"); - if (input.customModelEmpty) pieces.push("a model"); - const what = - pieces.length > 0 ? joinWithAnd(pieces) : "the AI configuration"; - const defaultsLabel = input.runtimeNeedsProviderSelection - ? "Use AI defaults" - : "Use harness defaults"; - return `Select ${what} for this agent, or switch to ${defaultsLabel}.`; - } - - return null; -} diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 2aa2015586..ce9a871fc5 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -433,6 +433,22 @@ test.describe("global agent config screenshots", () => { }); }); + test("create with a missing name has no footer message", async ({ page }) => { + await installMockBridge(page); + + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + + await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({ + timeout: 10_000, + }); + await expect(page.getByTestId("persona-dialog-submit-reason")).toHaveCount( + 0, + ); + }); + // Shot 05: Create gate ENABLED — global provider = anthropic provides a // default, so the empty per-agent provider is resolved → submit enabled. test("05-create-enabled-with-global-provider", async ({ page }) => { @@ -679,12 +695,10 @@ test.describe("global agent config screenshots", () => { await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled({ timeout: 10_000, }); - // … and the reason must be the Customize-pair provider gate, not the - // global-defaults gate (which would say "Settings → AI defaults"). - const reason = page.getByTestId("persona-dialog-submit-reason"); - await expect(reason).toBeVisible({ timeout: 10_000 }); - await expect(reason).toContainText("Select a provider"); - await expect(reason).not.toContainText("Settings → AI defaults"); + // Disabled-state guidance belongs with the fields, not in the modal footer. + await expect(page.getByTestId("persona-dialog-submit-reason")).toHaveCount( + 0, + ); await waitForAnimations(page); From bce4d8fc0ce9b02f3b2b5c29e3162b317882418e Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:32:07 -0700 Subject: [PATCH 05/18] Shorten agent defaults action --- desktop/src/features/agents/ui/AgentAiDefaults.tsx | 2 +- desktop/tests/e2e/global-agent-config-screenshots.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentAiDefaults.tsx b/desktop/src/features/agents/ui/AgentAiDefaults.tsx index 1f27c5727e..a577113a0a 100644 --- a/desktop/src/features/agents/ui/AgentAiDefaults.tsx +++ b/desktop/src/features/agents/ui/AgentAiDefaults.tsx @@ -65,7 +65,7 @@ export function AgentAiDefaultsNotice({ type="button" variant="outline" > - Set global defaults + Set
); diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index ce9a871fc5..9ac05a395f 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -409,7 +409,7 @@ test.describe("global agent config screenshots", () => { await expect(defaults.getByText("Model", { exact: true })).toHaveCount(0); const setDefaults = defaults.getByRole("button", { - name: "Set global defaults", + name: "Set", }); await expect(setDefaults).toBeVisible(); await expect(page.getByTestId("persona-dialog-submit-reason")).toHaveCount( From 5ebcb62f66f2b7ecb0a190942507bb747a7abc2a Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 13:37:19 -0700 Subject: [PATCH 06/18] Flatten agent defaults modal layout --- .../features/agents/ui/AgentConfigFields.tsx | 17 ++++++++++++--- .../agents/ui/AgentDefaultsDialog.tsx | 1 + .../agents/ui/AgentDefaultsEditor.tsx | 14 ++++++++++--- .../global-agent-config-screenshots.spec.ts | 21 ++++++++++++++++--- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 4e5ba17607..eb625b722e 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -817,7 +817,10 @@ export function AgentConfigFields({
- {advancedOpen ? ( + {disclosure === "progressive-defaults" ? ( + + {advancedOpen ? ( + + k !== BUZZ_AGENT_THINKING_EFFORT, + ), + )} + /> + + ) : null} + + ) : advancedOpen ? ( ); + const content = ( + <> + {providerContent} + {disclosure === "progressive-defaults" ? ( + + {revealDependentFields ? ( + + {dependentContent} + + ) : null} + + ) : ( + dependentContent + )} + + ); + if (unstyled) { return (
diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 6e2f286f8f..e3e5620c53 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -8,6 +8,7 @@ * Precedence: baked floor < GLOBAL (this card) < persona < per-agent. */ import { AlertCircle, Check, Loader } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; @@ -36,6 +37,11 @@ import { Button } from "@/shared/ui/button"; type SaveState = "idle" | "saving" | "saved" | "error"; +const PROGRESSIVE_FIELDS_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + export type GlobalAgentConfigSaveResult = Awaited< ReturnType >; @@ -56,6 +62,7 @@ export function AgentDefaultsEditor({ secondaryAction, }: AgentDefaultsEditorProps) { const flatLayout = layout === "flat"; + const shouldReduceMotion = useReducedMotion(); const [config, setConfig] = React.useState(EMPTY_GLOBAL_CONFIG); const configRef = React.useRef(config); @@ -121,18 +128,20 @@ export function AgentDefaultsEditor({ () => sortPersonaRuntimes(runtimesQuery.data ?? []), [runtimesQuery.data], ); - // A missing/stale preference displays the same effective fallback the backend - // would use; it is persisted only after the user edits and saves this form. - // Keep persona ordering here so this shared editor matches agent dialogs. - const selectedRuntime = React.useMemo( - () => - sortedRuntimes.find( - (runtime) => runtime.id === config.preferred_runtime, - ) ?? + // The settings card keeps displaying the backend's effective fallback for + // legacy configs. The modal requires an explicit harness choice so its + // progressive flow starts from a truthful empty state. + const selectedRuntime = React.useMemo(() => { + const configuredRuntime = sortedRuntimes.find( + (runtime) => runtime.id === config.preferred_runtime, + ); + if (flatLayout) return configuredRuntime; + return ( + configuredRuntime ?? getDefaultPersonaRuntime(sortedRuntimes) ?? - sortedRuntimes[0], - [config.preferred_runtime, sortedRuntimes], - ); + sortedRuntimes[0] + ); + }, [config.preferred_runtime, flatLayout, sortedRuntimes]); const harnessOptions = React.useMemo( () => sortedRuntimes.map((runtime) => ({ @@ -145,7 +154,7 @@ export function AgentDefaultsEditor({ const configSurfaceError = loadError || runtimesQuery.isError || - (!configSurfaceLoading && selectedRuntime === undefined); + (!configSurfaceLoading && sortedRuntimes.length === 0); function handleConfigChange(next: GlobalAgentConfig) { configRef.current = next; @@ -157,6 +166,7 @@ export function AgentDefaultsEditor({ function handleHarnessChange(runtimeId: string) { handleConfigChange(resetConfigForHarnessChange(config, runtimeId)); + setConfigIsValid(false); setIsCustomModelEditing(false); setIsCustomProvider(false); } @@ -206,6 +216,26 @@ export function AgentDefaultsEditor({ } } + const configFields = selectedRuntime ? ( + + ) : null; + const progressiveFieldsTransition = shouldReduceMotion + ? { duration: 0 } + : PROGRESSIVE_FIELDS_TRANSITION; + return (
{configSurfaceLoading ? ( @@ -239,19 +269,25 @@ export function AgentDefaultsEditor({ value={selectedRuntime?.id ?? ""} />
- + {flatLayout ? ( + + {configFields ? ( + + {configFields} + + ) : null} + + ) : ( + configFields + )} )} @@ -277,7 +313,12 @@ export function AgentDefaultsEditor({
{secondaryAction}
) : null} -

- {modelDiscoveryLoading - ? "Loading models..." - : modelDiscoveryStatus !== null - ? modelDiscoveryStatus.message - : discoveredModelOptions !== null - ? "Saved changes take effect on the next start." - : "Select a provider above to see available models."} -

+ {modelStatusMessage ? ( +

+ {modelStatusMessage} +

+ ) : null}
{ @@ -66,3 +67,41 @@ test("appendNoModelsSentinel_nonEmptyOptionsDiscoveryFinished_doesNotAddRow", () assert.equal(options.length, 1); assert.equal(options[0].label, "Default model"); }); + +test("model status omits provider selection guidance before discovery", () => { + assert.equal( + resolveModelFieldStatusMessage({ + discoveredModelOptions: null, + loading: false, + status: null, + }), + null, + ); +}); + +test("model status preserves loading, discovery, and saved-state messages", () => { + assert.equal( + resolveModelFieldStatusMessage({ + discoveredModelOptions: null, + loading: true, + status: null, + }), + "Loading models...", + ); + assert.equal( + resolveModelFieldStatusMessage({ + discoveredModelOptions: null, + loading: false, + status: { message: "Couldn't load models", tone: "warning" }, + }), + "Couldn't load models", + ); + assert.equal( + resolveModelFieldStatusMessage({ + discoveredModelOptions: [], + loading: false, + status: null, + }), + "Saved changes take effect on the next start.", + ); +}); diff --git a/desktop/src/features/agents/ui/agentConfigControls.tsx b/desktop/src/features/agents/ui/agentConfigControls.tsx index 05cc2e48ee..3088ae67b7 100644 --- a/desktop/src/features/agents/ui/agentConfigControls.tsx +++ b/desktop/src/features/agents/ui/agentConfigControls.tsx @@ -54,6 +54,22 @@ export function appendNoModelsSentinel( return options; } +export function resolveModelFieldStatusMessage({ + discoveredModelOptions, + loading, + status, +}: { + discoveredModelOptions: readonly PersonaModelOption[] | null; + loading: boolean; + status: PersonaModelDiscoveryStatus | null; +}): string | null { + if (loading) return "Loading models..."; + if (status !== null) return status.message; + return discoveredModelOptions !== null + ? "Saved changes take effect on the next start." + : null; +} + function optionTestId(testId: string | undefined, value: string) { if (!testId) return undefined; return `${testId}-option-${value || "empty"}`; @@ -474,6 +490,11 @@ export function AgentModelField({ !isCustomModelEditing ? "Loading models..." : placeholder; + const statusMessage = resolveModelFieldStatusMessage({ + discoveredModelOptions, + loading: modelDiscoveryLoading, + status: modelDiscoveryStatus, + }); const modelSelect = useCustomSelect ? ( ) : null} - {showStatusMessage ? ( -

- {modelDiscoveryLoading - ? "Loading models..." - : modelDiscoveryStatus !== null - ? modelDiscoveryStatus.message - : discoveredModelOptions !== null - ? "Saved changes take effect on the next start." - : "Select a provider above to see available models."} -

+ {showStatusMessage && statusMessage ? ( +

{statusMessage}

) : null}
); From 86f4e13e27bdf4dc2655e1c7200869faa5fa0d48 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:06:04 -0700 Subject: [PATCH 09/18] Match global defaults field styling --- .../features/agents/ui/AgentConfigFields.tsx | 26 +++++++++--- .../agents/ui/AgentDefaultsEditor.tsx | 21 +++++++--- .../agents/ui/agentConfigControls.tsx | 40 ++++++++++++++++++- .../global-agent-config-screenshots.spec.ts | 11 +++++ 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index f5d777067f..636b27f7c5 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -36,6 +36,7 @@ import { runtimeSupportsLlmProviderSelection, } from "@/features/agents/ui/agentConfigOptions"; import { + AgentConfigTextInput, AgentDropdownSelect, AgentModelField, } from "@/features/agents/ui/agentConfigControls"; @@ -49,7 +50,6 @@ import { EffortSelectField, useEffortAutoClear, } from "@/features/agents/ui/buzzAgentModelTuningFields"; -import { Input } from "@/shared/ui/input"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; /** Sentinel value for an unconfigured global agent config. */ @@ -638,9 +638,15 @@ export function AgentConfigFields({ : ""; const effortFieldVisible = showEffortField && effortField !== undefined; - const fieldClassName = unstyled ? "space-y-4" : "space-y-1.5 p-3"; + const progressiveDefaults = disclosure === "progressive-defaults"; + const fieldClassName = unstyled + ? progressiveDefaults + ? "space-y-1.5" + : "space-y-4" + : "space-y-1.5 p-3"; const blockClassName = unstyled ? "" : "p-3"; - const fieldLabelClassName = unstyled ? "pl-3" : undefined; + const fieldLabelClassName = + unstyled && !progressiveDefaults ? "pl-3" : undefined; const providerDropdownOptions = [ ...providerOptions .filter( @@ -721,11 +727,12 @@ export function AgentConfigFields({ providerSelect )} {isCustomProvider ? ( - handleCustomProviderInput(e.target.value)} placeholder="Custom provider ID" + usePersonaInputStyle={progressiveDefaults} value={providerValue} /> ) : null} @@ -803,6 +810,7 @@ export function AgentConfigFields({ testId="global-agent-model" useCustomSelect={useCustomSelect} useChevronIcon={useChevronSelectIcon} + usePersonaInputStyle={progressiveDefaults} />
) : null} @@ -933,7 +941,10 @@ export function AgentConfigFields({ {revealDependentFields ? ( +
{content}
); diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index e3e5620c53..36cb43793c 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -24,6 +24,8 @@ import { useAcpRuntimesQuery } from "@/features/agents/hooks"; import { formatRuntimeOptionLabel, getDefaultPersonaRuntime, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, resetConfigForHarnessChange, sortPersonaRuntimes, } from "@/features/agents/ui/agentConfigOptions"; @@ -42,6 +44,12 @@ const PROGRESSIVE_FIELDS_TRANSITION = { ease: [0.23, 1, 0.32, 1], } as const; +const PERSONA_SELECT_TRIGGER_CLASS = cn( + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + "h-11 px-3 py-2 leading-6 hover:bg-muted/40 focus:bg-muted/40 [&>svg]:text-muted-foreground/60", +); + export type GlobalAgentConfigSaveResult = Awaited< ReturnType >; @@ -228,6 +236,8 @@ export function AgentDefaultsEditor({ onCustomModelEditingChange={setIsCustomModelEditing} onIsCustomProviderChange={setIsCustomProvider} onValidityChange={setConfigIsValid} + placeholderClassName={flatLayout ? "text-muted-foreground/55" : undefined} + selectClassName={flatLayout ? PERSONA_SELECT_TRIGGER_CLASS : undefined} unstyled={flatLayout} useCustomSelect /> @@ -250,21 +260,22 @@ export function AgentDefaultsEditor({ ) : ( <> -
+
diff --git a/desktop/src/features/agents/ui/agentConfigControls.tsx b/desktop/src/features/agents/ui/agentConfigControls.tsx index 3088ae67b7..8c2f5b6744 100644 --- a/desktop/src/features/agents/ui/agentConfigControls.tsx +++ b/desktop/src/features/agents/ui/agentConfigControls.tsx @@ -20,6 +20,8 @@ import { getModelSelectValue, getPersonaProviderOptions, hasPersonaModelOption, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, providerDisplayLabel, type PersonaModelOption, } from "./agentConfigOptions"; @@ -75,6 +77,38 @@ function optionTestId(testId: string | undefined, value: string) { return `${testId}-option-${value || "empty"}`; } +export function AgentConfigTextInput({ + className, + usePersonaInputStyle = false, + ...props +}: React.ComponentProps & { + usePersonaInputStyle?: boolean; +}) { + const input = ( + + ); + + return usePersonaInputStyle ? ( +
+ {input} +
+ ) : ( + input + ); +} + export function AgentDropdownSelect({ ariaRequired, className, @@ -323,6 +357,7 @@ export function AgentModelField({ showStatusMessage = true, showCustomModelOption = true, useChevronIcon = false, + usePersonaInputStyle = false, }: { disabled: boolean; discoveredModelOptions: readonly PersonaModelOption[] | null; @@ -368,6 +403,8 @@ export function AgentModelField({ showCustomModelOption?: boolean; /** Render a controlled chevron instead of the native select indicator. */ useChevronIcon?: boolean; + /** Match custom agent text inputs when this field is shown in that flow. */ + usePersonaInputStyle?: boolean; }) { const trimmedModel = model.trim(); const isSharedCompute = provider?.trim() === "relay-mesh"; @@ -558,12 +595,13 @@ export function AgentModelField({ modelSelect )} {showCustomModelInput ? ( - onModelChange(event.target.value)} placeholder="Custom model ID" + usePersonaInputStyle={usePersonaInputStyle} value={model} /> ) : null} diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 56894f39a4..7c9e9fe871 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -449,6 +449,17 @@ test.describe("global agent config screenshots", () => { await expect( defaultsDialog.getByTestId("global-agent-model"), ).toBeVisible(); + for (const field of [ + harness, + provider, + defaultsDialog.getByTestId("global-agent-model"), + defaultsDialog.getByTestId("global-agent-thinking-effort-select"), + ]) { + await expect(field).toHaveClass(/h-11/); + await expect(field).toHaveClass(/rounded-xl/); + await expect(field).toHaveClass(/bg-muted\/40/); + await expect(field).toHaveClass(/shadow-none/); + } await waitForAnimations(page); const configuredHeight = (await defaultsDialog.boundingBox())?.height ?? 0; expect(configuredHeight).toBeGreaterThan(providerHeight); From bd518d5c742a64c78dd3199d4c84f8309189294d Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:13:47 -0700 Subject: [PATCH 10/18] Keep agent advanced settings user-controlled --- desktop/src/features/agents/AGENTS.md | 3 + .../features/agents/ui/AgentConfigFields.tsx | 11 +-- .../agents/ui/AgentDefinitionDialog.tsx | 16 ++-- .../agents/ui/AgentInstanceEditDialog.tsx | 43 +++------- .../ui/editAgentProviderDiscovery.test.mjs | 80 ------------------- .../agents/ui/providerApiKeyFieldState.ts | 30 +------ .../agents/ui/useRequiredCredentialState.ts | 17 ++-- .../global-agent-config-screenshots.spec.ts | 21 +++++ 8 files changed, 51 insertions(+), 170 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 093f0508bd..93592a9cae 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -86,6 +86,9 @@ with a TypeScript lookup table or an id comparison in a component. and Advanced only after a provider is configured. Harnesses whose runtime metadata has no provider field skip that gate. Reveals animate their height through Motion and become immediate when reduced motion is requested. + Once the Advanced toggle is visible, its expanded state is exclusively + user-controlled: provider, harness, and required-env changes must never + open it automatically in defaults, create, or edit flows. ## The tests that enforce this diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 636b27f7c5..9fda2be78b 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -383,16 +383,9 @@ export function AgentConfigFields({ const healOnMount = fieldModel.dependentValuePolicy.onCatalogMismatch === "onboardingCleanup"; const userEditedProviderRef = React.useRef(false); - // Env vars live under a collapsed Advanced section (matching the create - // flow). Auto-open when a required key is missing so the field the user - // must fill is never hidden behind the toggle. + // Advanced visibility is user-controlled. Provider changes can add required + // rows, but must not open this section without an explicit toggle click. const [advancedOpen, setAdvancedOpen] = React.useState(false); - const requiredAdvancedKeyMissing = advancedRequiredEnvKeys.some( - (key) => !(config.env_vars[key] ?? "").trim(), - ); - React.useEffect(() => { - if (requiredAdvancedKeyMissing) setAdvancedOpen(true); - }, [requiredAdvancedKeyMissing]); // Read inside effects via ref so biome's exhaustive-deps stays honest: // refs are stable, and healOnMount is captured at declaration. const mayMutateDependentFieldsRef = React.useRef(false); diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 1bd9749083..5eedc4fe7c 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -207,9 +207,7 @@ export function AgentDefinitionDialog({ setBehaviorDraft(nextBehaviorDraft); setNamePoolText(nextNamePoolText); setEnvVars(nextEnvVars); - // Item 5: collapsed by default in edit mode — only expand if a non-default - // behavior value demands attention. Having env vars or a name pool is not - // sufficient reason to auto-open. + // Advanced always starts collapsed and only changes from its toggle. setShowAdvancedFields(false); setIsAvatarUploadPending(false); isRuntimeAutoSeededRef.current = false; @@ -338,8 +336,9 @@ export function AgentDefinitionDialog({ // locked rows in the env vars editor. // File-layer config for the selected runtime (e.g. goose config.yaml). // Used to silence requirements already satisfied there. - const { data: runtimeFileConfig, isLoading: fileConfigLoading } = - useRuntimeFileConfigQuery(runtime, { enabled: open }); + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, { + enabled: open, + }); function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) { setAiConfigurationMode(nextMode); setIsCustomProviderEditing(false); @@ -358,9 +357,7 @@ export function AgentDefinitionDialog({ setProvider(nextPair.provider); setModel(nextPair.model); } - const { data: bakedEnvKeys, isLoading: bakedLoading } = - useBakedBuildEnvKeysQuery({ enabled: open }); - const credentialSettled = !fileConfigLoading && !bakedLoading; + const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); const localModeGate = React.useMemo( () => computeLocalModeGate({ @@ -405,11 +402,8 @@ export function AgentDefinitionDialog({ envVars, fileSatisfiedEnvKeys: localModeGate.fileSatisfiedEnvKeys, globalEnvVars: globalConfig.env_vars, - open, provider: effectiveProvider, requiredEnvKeys, - satisfactionSettled: credentialSettled, - setShowAdvancedFields, }); const { advancedRequiredEnvKeys, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 8335fce65e..4d7f78f3fd 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -368,26 +368,21 @@ export function AgentInstanceEditDialog({ } = useAgentDialogDefaults({ inheritedEnvVars, open }); // Runtime/provider-required credential state, derived from the PROSPECTIVE - // post-submit runtime — see the hook for the inherit-transition and - // Advanced-auto-expand rationale. + // post-submit runtime — see the hook for the inherit-transition rationale. // Pass globalProvider so the hook uses it as a fallback when the per-agent // provider is empty (global-provider-only configs must surface required keys). // Pass globalEnvVars so keys satisfied by global config are excluded from // requiredEnvKeys and do not block Save (display and gate agree). - const { - requiredEnvKeys, - fileSatisfiedEnvKeys, - requiredEnvKeyMissing, - settled: credentialSettled, - } = useRequiredCredentialState({ - open, - prospectiveRuntimeId, - provider: inheritedSubmission.provider ?? "", - globalProvider: inheritedProviderDefault.value, - envVars: inheritedSubmission.envVars, - globalEnvVars: globalConfig.env_vars, - personaEnvVars: inheritHarness ? inheritedEnvVars : undefined, - }); + const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } = + useRequiredCredentialState({ + open, + prospectiveRuntimeId, + provider: inheritedSubmission.provider ?? "", + globalProvider: inheritedProviderDefault.value, + envVars: inheritedSubmission.envVars, + globalEnvVars: globalConfig.env_vars, + personaEnvVars: inheritHarness ? inheritedEnvVars : undefined, + }); const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); @@ -419,7 +414,7 @@ export function AgentInstanceEditDialog({ selectedRuntime, }); - // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display + auto-open. + // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display. // The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating. // D2/D3: the top-level API key owns display, while the readiness gate keeps // the complete required-key list. The effective snapshot covers persona @@ -435,12 +430,9 @@ export function AgentInstanceEditDialog({ envVars, fileSatisfiedEnvKeys, globalEnvVars: globalConfig.env_vars, - open, personaSatisfied, provider: effectiveProvider, requiredEnvKeys, - satisfactionSettled: credentialSettled, - setShowAdvancedFields, }); const { advancedRequiredEnvKeys, @@ -521,17 +513,6 @@ export function AgentInstanceEditDialog({ setInheritHarness(false); } - // "Custom command" is the only selection whose command must be typed by - // the user, and that input lives inside the collapsed Advanced section. - // Auto-expand Advanced so the command field is visible — otherwise the - // user can Save without ever seeing it, leaving agentCommand equal to the - // original effective command (so the update is omitted) and the custom - // selection silently no-ops. See handleSubmit's customCommandPinned gate, - // which blocks Save when the revealed field is still empty. - if (isCustomCommand) { - setShowAdvancedFields(true); - } - // When switching to a catalog-known runtime, update the agent command to // its resolved command so the command field stays consistent. if (nextRuntime?.command) { diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 8b1003e951..6b797db1cf 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -542,86 +542,6 @@ test("editAgent_resolveAgentCommandUpdate_inheritSentinelOnlyWhenPinToClear", () ); }); -test("editAgent_customCommandSelected_autoExpandsAdvancedSection", () => { - // Selecting "Custom command" must reveal the Advanced command input, which is - // otherwise collapsed. Without this the user can Save without ever seeing the - // field, leaving agentCommand equal to the original effective command (so the - // update is omitted) and the custom selection silently no-ops. - let showAdvancedFields = false; // starts collapsed on open - - const NO_RUNTIME_DROPDOWN_VALUE = "__none__"; - const nextValue = "custom"; - const nextRuntimeId = - nextValue === NO_RUNTIME_DROPDOWN_VALUE ? "" : nextValue; - const resolvedRuntimeId = nextRuntimeId || "custom"; - const isCustomCommand = resolvedRuntimeId === "custom"; - - // Mirror the handler's auto-expand branch. - if (isCustomCommand) { - showAdvancedFields = true; - } - - assert.equal( - showAdvancedFields, - true, - "selecting 'Custom command' must auto-expand Advanced so the command input is visible", - ); -}); - -test("editAgent_missingRequiredEnvKey_autoExpandsAdvancedOnTransition", () => { - // Codex P2: when a provider change makes a credential newly required, the - // EnvVarsEditor lives inside the collapsed Advanced section, so the amber - // required row would stay unmounted (invisible) while Save is disabled. The - // effect auto-expands Advanced on the missing→present-requirement transition. - let showAdvancedFields = false; // collapsed by default on open - let previousMissing = false; - - // Mirror the effect's transition guard. - function applyMissingEffect(requiredEnvKeyMissing) { - if (requiredEnvKeyMissing && !previousMissing) { - showAdvancedFields = true; - } - previousMissing = requiredEnvKeyMissing; - } - - // Initial render: buzz-agent with no provider — nothing required yet. - applyMissingEffect( - hasMissingRequiredEnvKey(requiredCredentialEnvKeys("buzz-agent", ""), {}), - ); - assert.equal( - showAdvancedFields, - false, - "Advanced stays collapsed while no credential is required", - ); - - // User picks anthropic → ANTHROPIC_API_KEY becomes required and is unset. - applyMissingEffect( - hasMissingRequiredEnvKey( - requiredCredentialEnvKeys("buzz-agent", "anthropic"), - {}, - ), - ); - assert.equal( - showAdvancedFields, - true, - "Advanced auto-expands when a required credential is newly missing", - ); - - // User fills the key, then collapses Advanced manually — no re-expand. - showAdvancedFields = false; - applyMissingEffect( - hasMissingRequiredEnvKey( - requiredCredentialEnvKeys("buzz-agent", "anthropic"), - { ANTHROPIC_API_KEY: "sk-ant-test" }, - ), - ); - assert.equal( - showAdvancedFields, - false, - "Advanced does not re-expand once the required credential is filled", - ); -}); - test("editAgent_missingRequiredEnvKey_blocksSaveViaValidity", () => { // The block-save gate is folded into computeEditAgentFormValidity so the // Save button disables when a runtime/provider-required credential is unset. diff --git a/desktop/src/features/agents/ui/providerApiKeyFieldState.ts b/desktop/src/features/agents/ui/providerApiKeyFieldState.ts index 13ed31101a..0aabc3d798 100644 --- a/desktop/src/features/agents/ui/providerApiKeyFieldState.ts +++ b/desktop/src/features/agents/ui/providerApiKeyFieldState.ts @@ -105,26 +105,20 @@ export function useProviderApiKeyFieldState({ envVars, fileSatisfiedEnvKeys, globalEnvVars, - open, personaSatisfied, provider, requiredEnvKeys, - satisfactionSettled = true, - setShowAdvancedFields, }: { bakedEnvKeys: readonly string[] | undefined; effectiveEnvVars: EnvVarsValue; envVars: EnvVarsValue; fileSatisfiedEnvKeys?: readonly string[]; globalEnvVars: EnvVarsValue; - open: boolean; personaSatisfied?: boolean; provider: string; requiredEnvKeys: readonly string[]; - satisfactionSettled?: boolean; - setShowAdvancedFields: React.Dispatch>; }): ProviderApiKeyFieldState { - const fieldState = React.useMemo( + return React.useMemo( () => getProviderApiKeyFieldState({ bakedEnvKeys, @@ -147,26 +141,4 @@ export function useProviderApiKeyFieldState({ requiredEnvKeys, ], ); - const hasAutoOpenedAdvancedRef = React.useRef(false); - React.useEffect(() => { - if (!open) { - hasAutoOpenedAdvancedRef.current = false; - return; - } - if ( - satisfactionSettled && - fieldState.advancedRequiredEnvKeys.length > 0 && - !hasAutoOpenedAdvancedRef.current - ) { - hasAutoOpenedAdvancedRef.current = true; - setShowAdvancedFields(true); - } - }, [ - fieldState.advancedRequiredEnvKeys.length, - open, - satisfactionSettled, - setShowAdvancedFields, - ]); - - return fieldState; } diff --git a/desktop/src/features/agents/ui/useRequiredCredentialState.ts b/desktop/src/features/agents/ui/useRequiredCredentialState.ts index c996e159ca..44c03e812a 100644 --- a/desktop/src/features/agents/ui/useRequiredCredentialState.ts +++ b/desktop/src/features/agents/ui/useRequiredCredentialState.ts @@ -21,8 +21,6 @@ export interface RequiredCredentialState { fileSatisfiedEnvKeys: string[]; /** Whether any required env key is still missing (blocks Save). */ requiredEnvKeyMissing: boolean; - /** True once all async satisfaction sources (baked, file config) have resolved. */ - settled: boolean; } /** @@ -35,7 +33,8 @@ export interface RequiredCredentialState { * actually be saved. * * The caller owns the visibility policy: top-level API-key fields are already - * visible, while non-secret Advanced-only keys can open that section. + * visible, while non-secret keys remain available under user-controlled + * Advanced disclosure. * * `globalProvider` is used as the fallback when the per-agent provider is * empty — without it, a global-provider-only config produces no required keys @@ -78,13 +77,12 @@ export function useRequiredCredentialState(params: { ? provider.trim() || globalProvider.trim() : ""; - const { data: runtimeFileConfig, isLoading: fileConfigLoading } = - useRuntimeFileConfigQuery(prospectiveRuntimeId, { enabled: open }); - - const { data: bakedEnvKeys, isLoading: bakedLoading } = - useBakedBuildEnvKeysQuery({ enabled: open }); + const { data: runtimeFileConfig } = useRuntimeFileConfigQuery( + prospectiveRuntimeId, + { enabled: open }, + ); - const settled = !fileConfigLoading && !bakedLoading; + const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); // All required keys for this runtime + provider combination. const allRequiredKeys = React.useMemo( @@ -137,6 +135,5 @@ export function useRequiredCredentialState(params: { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing, - settled, }; } diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 7c9e9fe871..07e278bc92 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -284,6 +284,19 @@ test.describe("global agent config screenshots", () => { await page.getByRole("dialog").screenshot({ path: `${SHOTS}/02-create-custom-agent-configuration.png`, }); + + const advanced = page.getByRole("button", { + name: "Advanced", + exact: true, + }); + await selectDropdownOption( + page, + page.locator("#persona-llm-provider"), + "Databricks v2", + ); + await expect(advanced).toHaveAttribute("aria-expanded", "false"); + await advanced.click(); + await expect(advanced).toHaveAttribute("aria-expanded", "true"); }); test("03-global-env-satisfies-required-key", async ({ page }) => { @@ -478,6 +491,14 @@ test.describe("global agent config screenshots", () => { await defaultsDialog.screenshot({ path: `${SHOTS}/04-global-defaults-dialog-flat.png`, }); + const advanced = defaultsDialog.getByTestId("global-agent-advanced-toggle"); + await provider.click(); + await page + .getByTestId("global-agent-provider-option-databricks_v2") + .click(); + await expect(advanced).toHaveAttribute("aria-expanded", "false"); + await advanced.click(); + await expect(advanced).toHaveAttribute("aria-expanded", "true"); await defaultsDialog .getByRole("button", { name: "Close", From 1e0a7edcff0477e8d754c0aa3ff9dff83279b18f Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:21:18 -0700 Subject: [PATCH 11/18] Default unset agent harness to Buzz Agent --- desktop/src/features/agents/AGENTS.md | 12 ++-- .../agents/ui/AgentDefaultsEditor.tsx | 18 ++++-- .../global-agent-config-screenshots.spec.ts | 55 ++++++++++++++----- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 93592a9cae..9466150d72 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -80,12 +80,12 @@ with a TypeScript lookup table or an id comparison in a component. harness has empty discovery` (and the failed-discovery counterpart) in `onboarding-agent-defaults.spec.ts`. 9. **The defaults modal is progressively disclosed.** An unset global config - starts with an explicit harness choice instead of displaying the backend - fallback as though it were saved. The `progressive-defaults` disclosure - preset shows Provider after harness selection, then reveals Model, Effort, - and Advanced only after a provider is configured. Harnesses whose runtime - metadata has no provider field skip that gate. Reveals animate their height - through Motion and become immediate when reduced motion is requested. + starts on the Buzz Agent-first deployment fallback and carries that visible + harness into the next saved edit. The `progressive-defaults` disclosure + preset therefore begins at Provider for Buzz Agent, then reveals Model, + Effort, and Advanced only after a provider is configured. Harnesses whose + runtime metadata has no provider field skip that gate. Reveals animate their + height through Motion and become immediate when reduced motion is requested. Once the Advanced toggle is visible, its expanded state is exclusively user-controlled: provider, harness, and required-env changes must never open it automatically in defaults, create, or edit flows. diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 36cb43793c..a94f29a720 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -136,20 +136,26 @@ export function AgentDefaultsEditor({ () => sortPersonaRuntimes(runtimesQuery.data ?? []), [runtimesQuery.data], ); - // The settings card keeps displaying the backend's effective fallback for - // legacy configs. The modal requires an explicit harness choice so its - // progressive flow starts from a truthful empty state. + // An unset preferred runtime uses the same Buzz Agent-first fallback as + // deployment. The rendered draft below carries that fallback forward so the + // next user edit persists the visible harness instead of saving null. const selectedRuntime = React.useMemo(() => { const configuredRuntime = sortedRuntimes.find( (runtime) => runtime.id === config.preferred_runtime, ); - if (flatLayout) return configuredRuntime; return ( configuredRuntime ?? getDefaultPersonaRuntime(sortedRuntimes) ?? sortedRuntimes[0] ); - }, [config.preferred_runtime, flatLayout, sortedRuntimes]); + }, [config.preferred_runtime, sortedRuntimes]); + const renderedConfig = React.useMemo( + () => + config.preferred_runtime || !selectedRuntime + ? config + : { ...config, preferred_runtime: selectedRuntime.id }, + [config, selectedRuntime], + ); const harnessOptions = React.useMemo( () => sortedRuntimes.map((runtime) => ({ @@ -228,7 +234,7 @@ export function AgentDefaultsEditor({ { await expect(defaultsDialog).toBeVisible(); await expect( defaultsDialog.getByTestId("global-agent-config-fields"), - ).toHaveCount(0); - await expect( - defaultsDialog.getByTestId("global-agent-provider"), - ).toHaveCount(0); + ).toBeVisible(); await expect(defaultsDialog.getByTestId("global-agent-model")).toHaveCount( 0, ); - const harnessOnlyHeight = (await defaultsDialog.boundingBox())?.height ?? 0; const harness = defaultsDialog.getByTestId("global-agent-default-harness"); - await harness.click(); - await page - .getByTestId("global-agent-default-harness-option-buzz-agent") - .click(); + await expect(harness).toHaveText("Buzz Agent"); const provider = defaultsDialog.getByTestId("global-agent-provider"); await expect(provider).toBeVisible(); - await expect(defaultsDialog.getByTestId("global-agent-model")).toHaveCount( - 0, - ); await waitForAnimations(page); const providerHeight = (await defaultsDialog.boundingBox())?.height ?? 0; - expect(providerHeight).toBeGreaterThan(harnessOnlyHeight); await provider.click(); await page.getByTestId("global-agent-provider-option-anthropic").click(); @@ -515,6 +504,46 @@ test.describe("global agent config screenshots", () => { }); }); + test("unset defaults persist the visible Buzz Agent fallback", async ({ + page, + }) => { + await installMockBridge(page); + await openCreateDialog(page); + await page + .getByTestId("agent-ai-defaults-notice") + .getByRole("button", { name: "Set" }) + .click(); + + const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog"); + await expect( + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toHaveText("Buzz Agent"); + + await defaultsDialog.getByTestId("global-agent-provider").click(); + await page.getByTestId("global-agent-provider-option-anthropic").click(); + await defaultsDialog.getByLabel("Anthropic API Key").fill("sk-ant-test"); + await expect( + defaultsDialog.getByRole("button", { name: "Save defaults" }), + ).toBeEnabled(); + await defaultsDialog.getByRole("button", { name: "Save defaults" }).click(); + await expect(defaultsDialog).not.toBeVisible(); + + const saved = await page.evaluate(async () => + ( + window as typeof window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null), + ); + expect(saved).toMatchObject({ + preferred_runtime: "buzz-agent", + provider: "anthropic", + }); + }); + test("create with a missing name has no footer message", async ({ page }) => { await installMockBridge(page); From 396d98f60d72c7532c0596119d82ee712b8f52df Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:34:42 -0700 Subject: [PATCH 12/18] Center agent card grids --- desktop/src/features/agents/ui/TeamsSection.tsx | 2 +- desktop/src/features/agents/ui/UnifiedAgentsSection.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index a5e3d61dfc..d9b1931a66 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -23,7 +23,7 @@ import { CreateIdentityCard } from "./CreateIdentityCard"; import { TeamIdentityCard } from "./TeamIdentityCard"; const TEAM_CARD_COLUMN_CLASS = "w-full"; -const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`; +const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; type TeamsSectionProps = { teams: AgentTeam[]; diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 5b6da92c21..426eb682d2 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -61,7 +61,7 @@ type UnifiedAgentsSectionProps = { }; const AGENT_CARD_COLUMN_CLASS = "w-full"; -const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-start gap-3`; +const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { From 97f2885b590d4a39d74e0dce98dd15d100d1a588 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:40:34 -0700 Subject: [PATCH 13/18] Align agents header with card grid --- desktop/src/features/agents/ui/AgentsView.tsx | 1 + desktop/src/features/agents/ui/TeamsSection.tsx | 2 +- desktop/src/features/agents/ui/UnifiedAgentsSection.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f750e266db..77d9e82060 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -136,6 +136,7 @@ export function AgentsView() { ) : null}
} + className="mx-auto w-full max-w-[996px]" description="Set up and manage your agents." title="Agents" /> diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index d9b1931a66..daf4419592 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -23,7 +23,7 @@ import { CreateIdentityCard } from "./CreateIdentityCard"; import { TeamIdentityCard } from "./TeamIdentityCard"; const TEAM_CARD_COLUMN_CLASS = "w-full"; -const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; +const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; type TeamsSectionProps = { teams: AgentTeam[]; diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 426eb682d2..fe792b0c39 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -61,7 +61,7 @@ type UnifiedAgentsSectionProps = { }; const AGENT_CARD_COLUMN_CLASS = "w-full"; -const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} grid grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; +const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { From 416de2bfd62a787dc0b77db0411def3fc1f08278 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 14:53:25 -0700 Subject: [PATCH 14/18] Address create defaults review feedback --- desktop/src/features/agents/AGENTS.md | 7 +- .../agents/ui/AgentDefinitionDialog.tsx | 51 +++++++- .../global-agent-config-screenshots.spec.ts | 109 ++++++++++++++++++ 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 9466150d72..41a17f217c 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -88,7 +88,12 @@ with a TypeScript lookup table or an id comparison in a component. height through Motion and become immediate when reduced motion is requested. Once the Advanced toggle is visible, its expanded state is exclusively user-controlled: provider, harness, and required-env changes must never - open it automatically in defaults, create, or edit flows. + open it automatically in defaults, create, or edit flows. In Create mode, + the defaults summary follows preferred-harness changes saved while the + dialog is open, and its configured state includes required credentials as + well as provider/model values. If no available harness can resolve, Create + starts in Customize and lets unavailable catalog entries be selected only + to expose their setup guidance; submission remains blocked. ## The tests that enforce this diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 5eedc4fe7c..406567c060 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -169,6 +169,7 @@ export function AgentDefinitionDialog({ () => getDefaultPersonaRuntime(runtimes, globalConfig.preferred_runtime), [globalConfig.preferred_runtime, runtimes], ); + const isCreateMode = Boolean(initialValues && !("id" in initialValues)); const shouldReduceMotion = useReducedMotion(); const initialModelProviderEditableWithoutRuntime = Boolean( initialValues && @@ -238,6 +239,46 @@ export function AgentDefinitionDialog({ } }, [defaultRuntime, initialValues, open, runtime, runtimesLoading]); + // Keep an inherited Create runtime synced with defaults saved in-place. + React.useEffect(() => { + if ( + !open || + !initialValues || + "id" in initialValues || + initialValues.runtime?.trim() || + aiConfigurationMode !== "defaults" || + runtimesLoading || + defaultRuntime === null || + (runtime.trim().length > 0 && !isRuntimeAutoSeededRef.current) + ) { + return; + } + + if (runtime !== defaultRuntime.id) setRuntime(defaultRuntime.id); + isRuntimeAutoSeededRef.current = true; + hasSeededForOpenRef.current = true; + }, [ + aiConfigurationMode, + defaultRuntime, + initialValues, + open, + runtime, + runtimesLoading, + ]); + + // Keep setup guidance reachable when no available runtime can be inherited. + React.useEffect(() => { + if ( + open && + isCreateMode && + !runtimesLoading && + defaultRuntime === null && + runtime.trim().length === 0 + ) { + setAiConfigurationMode("custom"); + } + }, [defaultRuntime, isCreateMode, open, runtime, runtimesLoading]); + function handleOpenChange(next: boolean) { if (!next) { setDisplayName(""); @@ -437,7 +478,6 @@ export function AgentDefinitionDialog({ { provider, model }, runtimeCanChooseLlmProvider, ); - const isCreateMode = Boolean(initialValues && !("id" in initialValues)); const selectedRuntimeIsAvailable = runtime.trim().length === 0 || selectedRuntime?.availability === "available"; @@ -541,7 +581,10 @@ export function AgentDefinitionDialog({ ] : []), ...sortedRuntimes.map((candidate) => ({ - disabled: isCreateMode && candidate.availability !== "available", + disabled: + isCreateMode && + defaultRuntime !== null && + candidate.availability !== "available", label: `${formatRuntimeOptionLabel(candidate)}${ isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : "" }`, @@ -915,9 +958,7 @@ export function AgentDefinitionDialog({ harness={runtimeSummaryLabel} inheritedModel={inheritedModelDefault} inheritedProvider={inheritedProviderDefault} - isConfigured={ - localModeGate.missingNormalizedFields.length === 0 - } + isConfigured={localModeGate.satisfied} model={runtimeFileConfig?.model} onEditDefaults={() => setAiDefaultsOpen(true)} triggerRef={aiDefaultsTriggerRef} diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 6d7ad89de2..de3af9b928 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -383,6 +383,11 @@ test.describe("global agent config screenshots", () => { value: "high", masked: false, }, + { + key: "ANTHROPIC_API_KEY", + value: "sk-ant-baked-test", + masked: true, + }, ], }); @@ -544,6 +549,110 @@ test.describe("global agent config screenshots", () => { }); }); + test("create defaults follow a preferred harness saved while open", async ({ + page, + }) => { + await installMockBridge(page, { + acpRuntimesCatalog: CATALOG_WITH_CLAUDE, + globalAgentConfig: { + preferred_runtime: "buzz-agent", + provider: "anthropic", + model: "claude-opus-4-5", + env_vars: { ANTHROPIC_API_KEY: "sk-ant-global-value" }, + }, + }); + await openCreateDialog(page); + + const defaults = page.getByTestId("agent-ai-defaults-notice"); + await expect(defaults).toContainText("Buzz Agent"); + await defaults + .getByRole("button", { name: "Edit global defaults" }) + .click(); + + const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog"); + const harness = defaultsDialog.getByTestId("global-agent-default-harness"); + await harness.press("Enter"); + await page + .getByTestId("global-agent-default-harness-option-claude") + .click(); + await defaultsDialog.getByRole("button", { name: "Save defaults" }).click(); + await expect(defaultsDialog).not.toBeVisible(); + + const harnessDefaults = page.getByTestId("agent-harness-defaults-notice"); + await expect(harnessDefaults).toContainText("Claude Code"); + await expect(page.getByTestId("persona-dialog-submit")).toBeEnabled(); + await page.getByTestId("persona-dialog-submit").click(); + + await expect + .poll(() => + page.evaluate(() => { + const log = ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { input?: Record }; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__; + const createPayload = log?.find( + (entry) => entry.command === "create_persona", + )?.payload.input; + return createPayload?.runtime; + }), + ) + .toBe("claude"); + }); + + test("missing global credentials show the unset defaults notice", async ({ + page, + }) => { + await installMockBridge(page, { + globalAgentConfig: { + preferred_runtime: "buzz-agent", + provider: "anthropic", + model: "claude-opus-4-5", + env_vars: {}, + }, + }); + await openCreateDialog(page); + + const defaults = page.getByTestId("agent-ai-defaults-notice"); + await expect(defaults).toContainText("Global defaults not set"); + await expect(defaults.getByRole("button", { name: "Set" })).toBeVisible(); + await expect(defaults.getByText("Harness", { exact: true })).toHaveCount(0); + await expect(defaults.getByText("Provider", { exact: true })).toHaveCount( + 0, + ); + await expect(defaults.getByText("Model", { exact: true })).toHaveCount(0); + await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled(); + }); + + test("create exposes setup guidance when no harness is available", async ({ + page, + }) => { + await installMockBridge(page, { + acpRuntimesCatalog: CATALOG_NONE_AVAILABLE, + }); + await openCreateDialog(page); + + const customSection = page.getByTestId( + "agent-custom-configuration-section", + ); + const harness = customSection.locator("#persona-runtime"); + await expect(harness).toBeVisible(); + await expect(harness).toContainText("Choose a harness"); + + await selectDropdownOption(page, harness, "Buzz Agent (not installed)"); + await expect( + customSection + .locator("p") + .filter({ hasText: "Buzz Agent is not installed." }), + ).toContainText( + "Buzz Agent is not installed. Visit Settings > Agents to set it up.", + ); + await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled(); + }); + test("create with a missing name has no footer message", async ({ page }) => { await installMockBridge(page); From 11494be5235c39cdc799e077ceddfcf7daf85520 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 23 Jul 2026 15:01:13 -0700 Subject: [PATCH 15/18] Address remaining agent config review feedback --- desktop/src/features/agents/AGENTS.md | 3 ++ .../agents/ui/AgentDefinitionDialog.tsx | 21 ++++++----- .../agents/ui/AgentInstanceEditDialog.tsx | 32 ++++++++++++++-- .../agents/ui/EditAgentAdvancedFields.tsx | 37 ------------------- desktop/tests/e2e/edit-agent.spec.ts | 26 +++++++++++++ .../global-agent-config-screenshots.spec.ts | 3 ++ 6 files changed, 72 insertions(+), 50 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 41a17f217c..e8fc125cc8 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -94,6 +94,9 @@ with a TypeScript lookup table or an id comparison in a component. well as provider/model values. If no available harness can resolve, Create starts in Customize and lets unavailable catalog entries be selected only to expose their setup guidance; submission remains blocked. + Advanced-only required credentials mark the collapsed Advanced toggle + without opening it. In Edit, selecting Custom command keeps its required + command field beside the harness picker rather than hiding it in Advanced. ## The tests that enforce this diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 406567c060..7556a79ba2 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -428,7 +428,6 @@ export function AgentDefinitionDialog({ // requiredEnvKeys: the gate already handles baked-, global-, and file- // satisfied keys so no further filtering is needed. const { requiredEnvKeys } = localModeGate; - // D1: single boolean for both canSubmit and handleSubmit — never recompose. const localModeSatisfied = localModeGate.satisfied; // Effective provider: agent value → global fallback → file fallback. // Mirrors the chain inside computeLocalModeGate so model-option scoping and @@ -436,7 +435,6 @@ export function AgentDefinitionDialog({ const fileProvider = runtimeFileConfig?.provider?.trim() ?? ""; const effectiveProvider = trimmedProvider || inheritedProviderDefault.value || fileProvider; - // D2: the top-level API key owns display while the full gate remains intact. const apiKeyFieldState = useProviderApiKeyFieldState({ bakedEnvKeys, effectiveEnvVars: envVars, @@ -454,14 +452,6 @@ export function AgentDefinitionDialog({ secretEnvVar: topLevelSecretEnvVar, value: apiKeyValue, } = apiKeyFieldState; - // Provider required-ness is a static property of the field's visibility — it - // does not change based on whether the field is currently filled. Using the - // dynamic missingNormalizedFields check would flip the asterisk off once a - // value is selected, which is incoherent (required means required, not - // "required until satisfied"). runtimeCanChooseLlmProvider is the authoritative - // gate: it tracks exactly when the provider picker is shown (Buzz Agent/Goose, - // plus runtime-less legacy/builtin definitions), so the required marker never - // drifts from whether Save actually needs a provider. const providerIsRequired = aiConfigurationMode === "custom" && runtimeCanChooseLlmProvider; const modelFieldVisible = @@ -982,6 +972,17 @@ export function AgentDefinitionDialog({ type="button" > Advanced + {localModeGate.missingEnvKeys.some((key) => + advancedRequiredEnvKeys.includes(key), + ) ? ( + + ) : null} ) : null}
+ {selectedRuntimeId === "custom" && !inheritHarness ? ( +
+ +
+ setAgentCommand(event.target.value)} + placeholder="Full path or shell command" + value={agentCommand} + /> +
+
+ ) : null} {/* LLM provider */} {llmProviderFieldVisible ? (
@@ -1112,7 +1141,6 @@ export function AgentInstanceEditDialog({ void; onAgentArgsChange: (value: string) => void; - onAgentCommandChange: (value: string) => void; onEnvVarsChange: (value: EnvVarsValue) => void; onInheritHarnessChange: (value: boolean) => void; onParallelismChange: (value: string) => void; @@ -124,37 +118,6 @@ export function EditAgentAdvancedFields({

- {/* Custom agent command (when custom runtime) */} - {selectedRuntimeId === "custom" && !inheritHarness ? ( -
- -
- onAgentCommandChange(event.target.value)} - placeholder="Full path or shell command" - value={agentCommand} - /> -
-
- ) : null} - {/* Agent runtime args */}