diff --git a/desktop/src/features/agents/ui/AgentAiDefaults.tsx b/desktop/src/features/agents/ui/AgentAiDefaults.tsx new file mode 100644 index 0000000000..694841d5ed --- /dev/null +++ b/desktop/src/features/agents/ui/AgentAiDefaults.tsx @@ -0,0 +1,104 @@ +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import type { InheritedDefault } from "./bakedEnvHelpers"; +import { getPersonaProviderOptions } from "./personaDialogPickers"; +import { Button } from "@/shared/ui/button"; + +function providerLabel(providerId: string) { + const option = getPersonaProviderOptions("", "buzz-agent").find( + (candidate) => candidate.id === providerId, + ); + return option?.label ?? providerId; +} + +export function formatAiDefaultsSummary({ + provider, + model, +}: { + provider: InheritedDefault; + model: InheritedDefault; +}) { + const parts = [ + provider.value ? providerLabel(provider.value) : null, + model.value || null, + ].filter((value): value is string => Boolean(value)); + + return parts.length > 0 ? parts.join(" · ") : "Not configured"; +} + +export function AgentAiDefaultsNotice({ + confirmNavigation = false, + explicitModel, + explicitProvider, + inheritedModel, + inheritedProvider, +}: { + confirmNavigation?: boolean; + explicitModel: string; + explicitProvider: string; + inheritedModel: InheritedDefault; + inheritedProvider: InheritedDefault; +}) { + const { goSettings } = useAppNavigation(); + const inheritsProvider = explicitProvider.trim().length === 0; + const inheritsModel = explicitModel.trim().length === 0; + + const usesCustomConfig = !inheritsProvider && !inheritsModel; + const requiredProviderMissing = inheritsProvider && !inheritedProvider.value; + + const inheritedParts = [ + inheritsProvider + ? inheritedProvider.value + ? `Provider ${providerLabel(inheritedProvider.value)}` + : "Provider not configured" + : null, + inheritsModel + ? inheritedModel.value + ? `Model ${inheritedModel.value}` + : "Model not configured" + : null, + ].filter((value): value is string => Boolean(value)); + + return ( +
+
+

+ {usesCustomConfig + ? "Custom AI configuration" + : requiredProviderMissing + ? "AI defaults aren’t configured" + : inheritsProvider && inheritsModel + ? "Uses AI defaults" + : "Partially uses AI defaults"} +

+

+ {usesCustomConfig + ? "This agent won’t follow provider or model default changes." + : requiredProviderMissing + ? "Choose a provider in AI defaults to use this agent." + : `${inheritedParts.join(" · ")}. Inherited fields follow future changes.`} +

+
+ +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 526fa19c8d..2e0582e07d 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -74,6 +74,7 @@ import { getBakedProviderInheritLabel, } from "./bakedEnvHelpers"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; +import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; @@ -864,6 +865,14 @@ export function AgentDefinitionDialog({ ) : null} + + {isCreateMode ? createRunSection : null}
diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 43d58b2131..7100c59b93 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -78,6 +78,7 @@ import { } from "./bakedEnvHelpers"; import { getProviderApiKeyEnvVar } from "./personaDialogPickers"; import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; +import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; const ADVANCED_FIELDS_MOTION_TRANSITION = { @@ -1087,6 +1088,14 @@ export function AgentInstanceEditDialog({

+ + {/* Advanced settings */}
+ + + } action={ agentCount > 0 ? ( diff --git a/desktop/src/features/agents/ui/bakedEnvHelpers.ts b/desktop/src/features/agents/ui/bakedEnvHelpers.ts index 23f9dd015f..1463c2aed8 100644 --- a/desktop/src/features/agents/ui/bakedEnvHelpers.ts +++ b/desktop/src/features/agents/ui/bakedEnvHelpers.ts @@ -96,7 +96,7 @@ export function getAdvancedInheritedSummary( ...(effort.value ? [`effort ${effort.value}`] : []), ...(globalEnvLabel ? [globalEnvLabel] : []), ]; - return `Using global defaults: ${parts.join(" · ")}`; + return `Using AI defaults: ${parts.join(" · ")}`; } export function getInheritedAgentDefaults( diff --git a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs index e5b6883316..a4f536652e 100644 --- a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -840,8 +840,8 @@ test("providerDefaultLabel_globalSet_returnsInheritLabel", () => { const label = getDefaultLlmProviderLabel("buzz-agent", "anthropic"); assert.equal( label, - "Inherit global default (anthropic)", - "global provider set must return 'Inherit global default ()'", + "Use AI defaults (anthropic)", + "global provider set must return 'Use AI defaults ()'", ); }); @@ -850,7 +850,7 @@ test("providerDefaultLabel_globalSetWithWhitespace_trimsAndReturnsInherit", () = const label = getDefaultLlmProviderLabel("buzz-agent", " openai "); assert.equal( label, - "Inherit global default (openai)", + "Use AI defaults (openai)", "global provider with surrounding whitespace must be trimmed in label", ); }); @@ -858,7 +858,7 @@ test("providerDefaultLabel_globalSetWithWhitespace_trimsAndReturnsInherit", () = test("providerDefaultLabel_sharedCompute_neverLeaksInternalId", () => { assert.equal( getDefaultLlmProviderLabel("buzz-agent", "relay-mesh"), - "Inherit global default (Buzz shared compute)", + "Use AI defaults (Buzz shared compute)", ); }); @@ -972,8 +972,8 @@ test("modelDefaultLabel_globalSet_returnsInheritLabel", () => { const label = getDefaultLlmModelLabel("claude-opus-4-5"); assert.equal( label, - "Inherit global default (claude-opus-4-5)", - "global model set must return 'Inherit global default ()'", + "Use AI defaults (claude-opus-4-5)", + "global model set must return 'Use AI defaults ()'", ); }); @@ -982,7 +982,7 @@ test("modelDefaultLabel_globalSetWithWhitespace_trimsAndReturnsInherit", () => { const label = getDefaultLlmModelLabel(" gpt-4o "); assert.equal( label, - "Inherit global default (gpt-4o)", + "Use AI defaults (gpt-4o)", "global model with surrounding whitespace must be trimmed in label", ); }); @@ -1131,11 +1131,11 @@ test("f3_templateDialog_localProviderBlankGlobalAnthropicNoModel_saveBlocked", ( test("f3_templateDialog_globalModelSet_zeroValueLabelIsInherit", () => { // Case 3: global model set → the zero-value model dropdown option must show - // "Inherit global default ()" not the generic "Default model". + // "Use AI defaults ()" not the generic "Default model". // getDefaultLlmModelLabel is what AgentDefinitionDialog now uses for that slot. assert.equal( getDefaultLlmModelLabel("claude-opus-4-5"), - "Inherit global default (claude-opus-4-5)", + "Use AI defaults (claude-opus-4-5)", "zero-value model option label must show the global model name when set", ); assert.equal( @@ -1175,7 +1175,7 @@ test("f3b_buildTemplateModelDropdownOptions_anthropicGlobalModelSet_containsInhe ); assert.equal( inheritEntry.label, - "Inherit global default (claude-opus-4-5)", + "Use AI defaults (claude-opus-4-5)", "inherit entry must carry the global model name", ); }); @@ -1215,7 +1215,7 @@ test("f3b_buildTemplateModelDropdownOptions_blankProviderGlobalModelSet_noDouble ); assert.equal( autoEntries[0].label, - "Inherit global default (claude-opus-4-5)", + "Use AI defaults (claude-opus-4-5)", "existing zero-value entry must be relabeled with the global model name", ); }); diff --git a/desktop/src/features/agents/ui/personaDialogPickers.tsx b/desktop/src/features/agents/ui/personaDialogPickers.tsx index 1f4447d426..34d5f19598 100644 --- a/desktop/src/features/agents/ui/personaDialogPickers.tsx +++ b/desktop/src/features/agents/ui/personaDialogPickers.tsx @@ -271,21 +271,19 @@ export function getDefaultLlmProviderLabel( ) { const trimmedGlobal = (globalProvider ?? "").trim(); return trimmedGlobal - ? `Inherit global default (${providerDisplayLabel(trimmedGlobal)})` + ? `Use AI defaults (${providerDisplayLabel(trimmedGlobal)})` : "Select a provider\u2026"; } /** Returns the zero-value model option label. * * When a global model is configured, the empty-model option reads - * `Inherit global default ()` so users can see which model will run. + * `Use AI defaults ()` so users can see which model will run. * Otherwise falls back to the generic `"Default model"` placeholder. */ export function getDefaultLlmModelLabel(globalModel?: string) { const trimmedGlobal = (globalModel ?? "").trim(); - return trimmedGlobal - ? `Inherit global default (${trimmedGlobal})` - : "Default model"; + return trimmedGlobal ? `Use AI defaults (${trimmedGlobal})` : "Default model"; } /** @@ -294,7 +292,7 @@ export function getDefaultLlmModelLabel(globalModel?: string) { * * Explicit-model providers (e.g. anthropic) have their zero-value option * filtered out by `getPersonaModelOptions`, so a relabel-only map would never - * produce the `Inherit global default ()` entry. This helper prepends + * produce the `Use AI defaults ()` entry. This helper prepends * it when `globalModel` is non-empty AND no zero-value option already exists, * making the inherited global model visible and selectable in the dropdown. * diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs index e0916020be..a17881cb51 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs +++ b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs @@ -118,12 +118,12 @@ test("resolveInheritedRuntimeSubmission preserves a user-edited provider + env w assert.equal(result.model, null); }); -test("resolveInheritedRuntimeSubmission clears an already-inheriting agent's provider override when the user picks Default", () => { - // Regression: an already-inheriting agent had a saved provider override - // (databricks). The user picks the "Default" option → empty local provider. - // Because the agent was NOT harness-pinned at open, this is a deliberate - // clear, not the inherit-transition — persist null (runtime default), do NOT - // resurrect the persona provider. +test("resolveInheritedRuntimeSubmission clears an already-inheriting agent's persona-backed provider and model to AI defaults", () => { + // Regression: an already-inheriting agent is linked to a persona with a + // provider and model. The user picks "Use AI defaults" for both fields. + // Because the agent was NOT harness-pinned at open, these empty local values + // are deliberate clears, not an inherit-transition — persist null for both + // rather than resurrecting the persona values. const result = resolveInheritedRuntimeSubmission({ inheritHarness: true, agentWasHarnessPinned: false, @@ -135,6 +135,7 @@ test("resolveInheritedRuntimeSubmission clears an already-inheriting agent's pro personaEnvVars: { ANTHROPIC_API_KEY: "sk-persona" }, }); assert.equal(result.provider, null); + assert.equal(result.model, null); assert.deepEqual(result.envVars, {}); }); diff --git a/desktop/src/features/settings/ui/GlobalAgentConfigSettingsCard.tsx b/desktop/src/features/settings/ui/GlobalAgentConfigSettingsCard.tsx index d6285500da..5f1e82db27 100644 --- a/desktop/src/features/settings/ui/GlobalAgentConfigSettingsCard.tsx +++ b/desktop/src/features/settings/ui/GlobalAgentConfigSettingsCard.tsx @@ -138,8 +138,8 @@ export function GlobalAgentConfigSettingsCard() { data-testid="settings-global-agent-config" > {isLoading ? ( diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 3ec811c859..0abd4ddda2 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -63,6 +63,7 @@ import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; +import { GlobalAgentConfigSettingsCard } from "./GlobalAgentConfigSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; @@ -712,7 +713,12 @@ export function renderSettingsSection( case "experimental": return ; case "agents": - return ; + return ( +
+ + +
+ ); case "channel-templates": return ; case "compute": diff --git a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts index 0c665619e6..0a3ec16f75 100644 --- a/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts +++ b/desktop/tests/e2e/agent-lifecycle-feedback.spec.ts @@ -26,16 +26,25 @@ const CASCADE_AGENT_A_PUBKEY = "aa".repeat(32); const CASCADE_AGENT_B_PUBKEY = "bb".repeat(32); /** - * Navigate to the Agents view and wait for the global agent config card to - * finish loading (spinner gone). The card lives at the bottom of the view. + * Navigate to the Agents view and wait for its unified list to mount. */ async function openAgentsView(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("unified-agents-groups")).toBeVisible({ + timeout: 10_000, + }); +} + +async function openAiDefaultsSettings(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({ timeout: 10_000, }); - // Spinner disappears once the load effect resolves. await expect(page.locator(".animate-spin").first()).not.toBeVisible({ timeout: 5_000, }); @@ -120,7 +129,7 @@ test.describe("agent lifecycle feedback screenshots", () => { globalConfigRestartedCount: 2, }); - await openAgentsView(page); + await openAiDefaultsSettings(page); const card = page.getByTestId("settings-global-agent-config"); @@ -150,7 +159,7 @@ test.describe("agent lifecycle feedback screenshots", () => { test("03-save-plain", async ({ page }) => { await installMockBridge(page); - await openAgentsView(page); + await openAiDefaultsSettings(page); const card = page.getByTestId("settings-global-agent-config"); @@ -264,7 +273,7 @@ test.describe("agent lifecycle feedback screenshots", () => { globalConfigRestartedCount: 1, }); - await openAgentsView(page); + await openAiDefaultsSettings(page); const card = page.getByTestId("settings-global-agent-config"); @@ -287,7 +296,7 @@ test.describe("agent lifecycle feedback screenshots", () => { globalConfigFailedRestartCount: 1, }); - await openAgentsView(page); + await openAiDefaultsSettings(page); const card = page.getByTestId("settings-global-agent-config"); @@ -319,7 +328,7 @@ test.describe("agent lifecycle feedback screenshots", () => { globalConfigSaveDelayMs: 2_000, }); - await openAgentsView(page); + await openAiDefaultsSettings(page); const card = page.getByTestId("settings-global-agent-config"); const provider = page.locator("#global-agent-provider"); diff --git a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts index 68cc6b8d9e..09fde8b8f9 100644 --- a/desktop/tests/e2e/agent-provider-dropdowns.spec.ts +++ b/desktop/tests/e2e/agent-provider-dropdowns.spec.ts @@ -24,12 +24,16 @@ import { waitForAnimations } from "../helpers/animations"; const SHOTS = "test-results/screenshots-dialogs"; /** - * Navigate to the agents view and wait for the global agent config card to - * finish its async load (spinner gone, card content visible). + * Open Settings → Agents through the app UI and wait for the defaults card to + * finish loading. The CI static server does not provide SPA fallbacks for a + * direct `/settings` request. */ -async function openAgentsView(page: import("@playwright/test").Page) { - await page.goto("/"); - await page.getByTestId("open-agents-view").click(); +async function openAiDefaultsSettings(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({ timeout: 10_000, }); @@ -58,7 +62,7 @@ test.describe("agent provider dropdown screenshots", () => { // BUZZ_AGENT_PROVIDER is baked and hideProviderIds is empty → v1 appears. test("01-provider-dropdown-oss", async ({ page }) => { await installMockBridge(page); - await openAgentsView(page); + await openAiDefaultsSettings(page); const providerSelect = page.locator("#global-agent-provider"); await expect(providerSelect).toBeVisible({ timeout: 5_000 }); @@ -98,7 +102,7 @@ test.describe("agent provider dropdown screenshots", () => { env_vars: {}, }, }); - await openAgentsView(page); + await openAiDefaultsSettings(page); const effortSelect = page.locator("#global-agent-thinking-effort"); await expect(effortSelect).toBeVisible({ timeout: 5_000 }); diff --git a/desktop/tests/e2e/edit-agent.spec.ts b/desktop/tests/e2e/edit-agent.spec.ts index ee5c99a7d8..216edea7f3 100644 --- a/desktop/tests/e2e/edit-agent.spec.ts +++ b/desktop/tests/e2e/edit-agent.spec.ts @@ -202,14 +202,12 @@ test.describe("edit agent dialog", () => { await openEditDialog(page); await expect(page.locator("#edit-agent-llm-provider")).toHaveText( - "Inherit global default (anthropic)", + "Use AI defaults (anthropic)", ); await expect(page.locator("#edit-agent-model")).toHaveText( - "Inherit global default (claude-opus-4-5)", + "Use AI defaults (claude-opus-4-5)", ); - await expect( - page.getByText("Using global defaults: effort low"), - ).toBeVisible(); + await expect(page.getByText("Using AI defaults: effort low")).toBeVisible(); }); test("profile Edit routes persona-linked agents to the definition editor", async ({ diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 3d05479a94..85b15aa7a8 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -12,17 +12,19 @@ async function settleAnimations(page: import("@playwright/test").Page) { } /** - * Navigate to the Agents view (where GlobalAgentConfigSettingsCard lives) and - * wait for the card to finish loading. + * Open Settings → Agents through the app UI and wait for the defaults card to + * load. CI serves the built SPA with a static file server, so navigating to + * `/settings` directly returns a 404 before the client router can start. */ -async function openAgentsView(page: import("@playwright/test").Page) { - await page.goto("/"); - await page.getByTestId("open-agents-view").click(); - // Wait for the global agent config card to mount and finish its load effect. +async function openAiDefaultsSettings(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({ timeout: 10_000, }); - // The card shows a spinner while loading; wait for it to disappear. await expect(page.locator(".animate-spin").first()).not.toBeVisible({ timeout: 5_000, }); @@ -65,7 +67,7 @@ test.describe("global agent config screenshots", () => { }, }); - await openAgentsView(page); + await openAiDefaultsSettings(page); const card = page.getByTestId("settings-global-agent-config"); await card.scrollIntoViewIfNeeded(); @@ -187,14 +189,12 @@ test.describe("global agent config screenshots", () => { await openCreateDialog(page); await expect(page.locator("#persona-llm-provider")).toHaveText( - "Inherit global default (anthropic)", + "Use AI defaults (anthropic)", ); await expect(page.locator("#persona-model")).toHaveText( - "Inherit global default (claude-opus-4-5)", + "Use AI defaults (claude-opus-4-5)", ); - await expect( - page.getByText("Using global defaults: effort low"), - ).toBeVisible(); + await expect(page.getByText("Using AI defaults: effort low")).toBeVisible(); }); // Shot 04: Create gate BLOCKED — no per-agent provider, no global provider