Redesign agent AI configuration and defaults (#1935)

Signed-off-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1s654z6utm59mkg4tjdqw69tlen05y2hrrwajlck4n2ar0jyx7hfsmp60kf <86a9516b8bdd0bbb22ab9340ed157fccdf422ae31bbb2fe2d59aba37c886f5d3@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1s654z6utm59mkg4tjdqw69tlen05y2hrrwajlck4n2ar0jyx7hfsmp60kf <86a9516b8bdd0bbb22ab9340ed157fccdf422ae31bbb2fe2d59aba37c886f5d3@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
morgmart
2026-07-16 04:08:47 +00:00
committed by GitHub
co-authored by npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex npub1s654z6utm59mkg4tjdqw69tlen05y2hrrwajlck4n2ar0jyx7hfsmp60kf
parent 0722346b1f
commit 2910ea45de
30 changed files with 1041 additions and 535 deletions
@@ -0,0 +1,34 @@
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy";
export type { AgentAiConfigurationMode } from "./agentAiConfigurationPolicy";
export function AgentAiConfigurationModeField({
mode,
onModeChange,
}: {
mode: AgentAiConfigurationMode;
onModeChange: (mode: AgentAiConfigurationMode) => void;
}) {
return (
<div className="space-y-1.5">
<p className="text-sm font-medium text-foreground">AI configuration</p>
<Tabs
onValueChange={(value) =>
onModeChange(value as AgentAiConfigurationMode)
}
value={mode}
>
<TabsList>
<TabsTrigger value="defaults">Use AI defaults</TabsTrigger>
<TabsTrigger value="custom">Customize for this agent</TabsTrigger>
</TabsList>
</Tabs>
{mode === "custom" ? (
<p className="text-xs text-muted-foreground">
Provider and model changes apply only to this agent.
</p>
) : null}
</div>
);
}
@@ -1,4 +1,4 @@
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import type * as React from "react";
import type { InheritedDefault } from "./bakedEnvHelpers";
import { getPersonaProviderOptions } from "./personaDialogPickers";
import { Button } from "@/shared/ui/button";
@@ -26,78 +26,45 @@ export function formatAiDefaultsSummary({
}
export function AgentAiDefaultsNotice({
confirmNavigation = false,
onEditDefaults,
triggerRef,
explicitModel,
explicitProvider,
inheritedModel,
inheritedProvider,
}: {
confirmNavigation?: boolean;
onEditDefaults: () => void;
triggerRef?: React.Ref<HTMLButtonElement>;
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));
const provider = explicitProvider.trim() || inheritedProvider.value;
const model = explicitModel.trim() || inheritedModel.value;
return (
<div
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2"
data-testid="agent-ai-defaults-notice"
>
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">
{usesCustomConfig
? "Custom AI configuration"
: requiredProviderMissing
? "AI defaults aren’t configured"
: inheritsProvider && inheritsModel
? "Uses AI defaults"
: "Partially uses AI defaults"}
</p>
<p className="text-xs text-muted-foreground">
{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.`}
</p>
</div>
<div className="space-y-1" data-testid="agent-ai-defaults-notice">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 text-sm">
<dt className="text-muted-foreground">Provider</dt>
<dd className="truncate text-foreground">
{provider ? providerLabel(provider) : "Not configured"}
</dd>
<dt className="text-muted-foreground">Model</dt>
<dd className="truncate text-foreground">
{model || "Not configured"}
</dd>
</dl>
<Button
onClick={() => {
if (
confirmNavigation &&
!window.confirm(
"Leave this agent without saving? Your changes will be discarded.",
)
) {
return;
}
void goSettings("agents");
}}
className="h-auto px-0 py-1"
data-testid="edit-ai-defaults"
onClick={onEditDefaults}
ref={triggerRef}
size="xs"
type="button"
variant="link"
>
Edit AI defaults
Edit global defaults
</Button>
</div>
);
@@ -0,0 +1,157 @@
import * as React from "react";
import {
GlobalAgentConfigEditor,
type GlobalAgentConfigSaveResult,
} from "./GlobalAgentConfigEditor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
export function AgentAiDefaultsDialog({
open,
onOpenChange,
returnFocusRef,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
returnFocusRef: React.RefObject<HTMLButtonElement | null>;
}) {
const [dirty, setDirty] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [confirmDiscard, setConfirmDiscard] = React.useState(false);
const [restartFailures, setRestartFailures] = React.useState(0);
const secondaryActionRef = React.useRef<HTMLButtonElement>(null);
const restoreDefaultsFocusRef = React.useRef(false);
const closeAndRestoreFocus = React.useCallback(() => {
onOpenChange(false);
requestAnimationFrame(() => returnFocusRef.current?.focus());
}, [onOpenChange, returnFocusRef]);
const requestClose = React.useCallback(() => {
if (saving) return;
if (dirty) {
setConfirmDiscard(true);
return;
}
closeAndRestoreFocus();
}, [closeAndRestoreFocus, dirty, saving]);
function handleSaveSuccess(result: GlobalAgentConfigSaveResult) {
if (result.failed_restart_count > 0) {
setRestartFailures(result.failed_restart_count);
return;
}
closeAndRestoreFocus();
}
React.useEffect(() => {
if (open) {
setDirty(false);
setConfirmDiscard(false);
setRestartFailures(0);
}
}, [open]);
return (
<>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) requestClose();
}}
>
<DialogContent
className="max-h-[calc(100vh-2rem)] max-w-2xl overflow-y-auto"
data-testid="agent-ai-defaults-dialog"
onCloseAutoFocus={(event) => {
event.preventDefault();
returnFocusRef.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>AI defaults</DialogTitle>
<DialogDescription>
These settings are inherited by agents using AI defaults. Saving
may restart affected running agents.
</DialogDescription>
</DialogHeader>
<GlobalAgentConfigEditor
onDirtyChange={setDirty}
onSaveSuccess={handleSaveSuccess}
onSavingChange={setSaving}
secondaryAction={
<Button
disabled={saving}
ref={secondaryActionRef}
onClick={
restartFailures > 0 ? closeAndRestoreFocus : requestClose
}
size="sm"
type="button"
variant="outline"
>
{restartFailures > 0 ? "Done" : "Cancel"}
</Button>
}
/>
</DialogContent>
</Dialog>
<AlertDialog open={confirmDiscard} onOpenChange={setConfirmDiscard}>
<AlertDialogContent
data-testid="discard-ai-defaults-dialog"
onCloseAutoFocus={(event) => {
if (!restoreDefaultsFocusRef.current) return;
event.preventDefault();
restoreDefaultsFocusRef.current = false;
requestAnimationFrame(() => secondaryActionRef.current?.focus());
}}
>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes to AI defaults?</AlertDialogTitle>
<AlertDialogDescription>
Unsaved changes made to AI defaults will be lost.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
restoreDefaultsFocusRef.current = true;
}}
>
Keep editing
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setConfirmDiscard(false);
closeAndRestoreFocus();
}}
variant="destructive"
>
Discard changes
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -38,13 +38,11 @@ import {
CUSTOM_PROVIDER_DROPDOWN_VALUE,
computeLocalModeGate,
formatRuntimeOptionLabel,
getDefaultLlmProviderLabel,
getDefaultPersonaRuntime,
getPersonaModelOptions,
getPersonaProviderOptions,
getRuntimePersonaModelOptions,
NO_RUNTIME_DROPDOWN_VALUE,
providerRequiresExplicitModel,
runtimeSupportsLlmProviderSelection,
type PersonaDropdownOption,
PERSONA_FIELD_CONTROL_CLASS,
@@ -69,12 +67,18 @@ import {
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
import { useBakedBuildEnvKeysQuery, useRuntimeFileConfigQuery } from "../hooks";
import {
getBakedModelInheritLabel,
getBakedProviderInheritLabel,
} from "./bakedEnvHelpers";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentAiDefaultsDialog } from "./AgentAiDefaultsDialog";
import {
AgentAiConfigurationModeField,
type AgentAiConfigurationMode,
} from "./AgentAiConfigurationMode";
import {
agentAiConfigurationModeSatisfied,
agentAiConfigurationPairForMode,
initialAgentAiConfigurationMode,
} from "./agentAiConfigurationPolicy";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
@@ -119,12 +123,16 @@ export function AgentDefinitionDialog({
createSubmitBlocked = false,
}: AgentDefinitionDialogProps) {
const [displayName, setDisplayName] = React.useState("");
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
const [avatarUrl, setAvatarUrl] = React.useState("");
const [systemPrompt, setSystemPrompt] = React.useState("");
const [runtime, setRuntime] = React.useState("");
const [model, setModel] = React.useState("");
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [provider, setProvider] = React.useState("");
const [aiConfigurationMode, setAiConfigurationMode] =
React.useState<AgentAiConfigurationMode>("defaults");
const [isCustomProviderEditing, setIsCustomProviderEditing] =
React.useState(false);
const [namePoolText, setNamePoolText] = React.useState("");
@@ -172,6 +180,12 @@ export function AgentDefinitionDialog({
setModel(initialValues.model ?? "");
setIsCustomModelEditing(false);
setProvider(initialValues.provider ?? "");
setAiConfigurationMode(
initialAgentAiConfigurationMode({
provider: initialValues.provider ?? "",
model: initialValues.model ?? "",
}),
);
setIsCustomProviderEditing(false);
const nextNamePoolText =
"namePool" in initialValues
@@ -226,6 +240,7 @@ export function AgentDefinitionDialog({
setModel("");
setIsCustomModelEditing(false);
setProvider("");
setAiConfigurationMode("defaults");
setIsCustomProviderEditing(false);
setNamePoolText("");
setEnvVars({});
@@ -251,8 +266,8 @@ export function AgentDefinitionDialog({
provider: providerForSubmit,
} = buildRuntimeModelProviderPayload({
runtime,
model,
provider,
model: aiConfigurationMode === "defaults" ? "" : model,
provider: aiConfigurationMode === "defaults" ? "" : provider,
isEditMode: "id" in initialValues,
isAutoSeeded: isRuntimeAutoSeededRef.current,
initialPreviousRuntime: initialValues.runtime?.trim() ?? "",
@@ -317,7 +332,6 @@ export function AgentDefinitionDialog({
const { data: runtimeFileConfig, isLoading: fileConfigLoading } =
useRuntimeFileConfigQuery(runtime, { enabled: open });
const {
advancedInheritedSummary,
globalConfig,
inheritedDefaults: {
provider: inheritedProviderDefault,
@@ -325,6 +339,21 @@ export function AgentDefinitionDialog({
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
} = useAgentDialogDefaults({ open });
function handleAiConfigurationModeChange(nextMode: AgentAiConfigurationMode) {
setAiConfigurationMode(nextMode);
setIsCustomProviderEditing(false);
setIsCustomModelEditing(false);
const nextPair = agentAiConfigurationPairForMode({
current: { provider, model },
inherited: {
provider: inheritedProviderDefault.value,
model: inheritedModelDefault.value,
},
mode: nextMode,
});
setProvider(nextPair.provider);
setModel(nextPair.model);
}
const { data: bakedEnvKeys, isLoading: bakedLoading } =
useBakedBuildEnvKeysQuery({ enabled: open });
const credentialSettled = !fileConfigLoading && !bakedLoading;
@@ -391,13 +420,18 @@ export function AgentDefinitionDialog({
// missingNormalizedFields check would flip the asterisk off once a value is
// selected, which is incoherent (required means required, not "required until
// satisfied"). runtimeSupportsLlmProviderSelection is the authoritative gate.
const providerIsRequired = runtimeSupportsLlmProviderSelection(runtime);
const providerIsRequired =
aiConfigurationMode === "custom" &&
runtimeSupportsLlmProviderSelection(runtime);
const modelFieldVisible =
runtime.trim().length > 0 || blankRuntimeModelProviderEditable;
// Static asterisk on the model label: uses effectiveProvider so a globally-
// set provider correctly marks the model field required.
const isExplicitModelRequired =
modelFieldVisible && providerRequiresExplicitModel(effectiveProvider);
// Customize pins a complete provider/model pair. Shared compute's concrete
// automatic-routing value is the only valid non-model-id choice.
const isExplicitModelRequired = aiConfigurationMode === "custom";
const customAiPairSatisfied = agentAiConfigurationModeSatisfied(
aiConfigurationMode,
{ provider, model },
);
const isCreateMode = Boolean(initialValues && !("id" in initialValues));
const selectedRuntimeIsAvailable =
runtime.trim().length === 0 ||
@@ -415,6 +449,7 @@ export function AgentDefinitionDialog({
// D1: localModeSatisfied covers both missingNormalizedFields AND
// missingEnvKeys — credential env keys now block submit, not just display.
localModeSatisfied &&
customAiPairSatisfied &&
!isAvatarUploadPending;
// Merge global env as the base layer so credential keys satisfied via global
@@ -476,13 +511,6 @@ export function AgentDefinitionDialog({
: "",
hideProviderIds,
);
const defaultLlmProviderLabel =
inheritedProviderDefault.source === "build"
? getBakedProviderInheritLabel(
inheritedProviderDefault.value,
providerOptions,
)
: getDefaultLlmProviderLabel(runtime, inheritedProviderDefault.value);
const providerSelectValue = isCustomProviderEditing
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
@@ -525,25 +553,30 @@ export function AgentDefinitionDialog({
});
}
const providerDropdownOptions: PersonaDropdownOption[] = [
...providerOptions.map((option) => ({
label: option.id === "" ? defaultLlmProviderLabel : option.label,
value: option.id || AUTO_PROVIDER_DROPDOWN_VALUE,
})),
...providerOptions
.filter((option) => option.id.trim().length > 0)
.map((option) => ({
label: option.label,
value: option.id,
})),
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
];
const inheritedModelLabel =
inheritedModelDefault.source === "build"
? getBakedModelInheritLabel(inheritedModelDefault.value)
: undefined;
const modelDropdownOptions: PersonaDropdownOption[] =
buildModelDropdownOptions({
allowCustom: !isRelayMesh,
globalModel: isRelayMesh ? undefined : inheritedModelDefault.value,
globalModelLabel: isRelayMesh ? undefined : inheritedModelLabel,
globalModel: undefined,
loading: modelDiscoveryLoading && discoveredModelOptions === null,
loadingValue: MODEL_DISCOVERY_LOADING_VALUE,
options: modelOptions,
});
})
.filter(
(option) => isRelayMesh || option.value !== AUTO_MODEL_DROPDOWN_VALUE,
)
.map((option) =>
isRelayMesh && option.value === AUTO_MODEL_DROPDOWN_VALUE
? { ...option, label: "Automatic" }
: option,
);
const previewLabel = displayName.trim() || "Agent name";
const previewAvatarUrl = avatarUrl.trim() || null;
const runtimeWarning =
@@ -778,6 +811,13 @@ export function AgentDefinitionDialog({
</div>
{llmProviderFieldVisible ? (
<AgentAiConfigurationModeField
mode={aiConfigurationMode}
onModeChange={handleAiConfigurationModeChange}
/>
) : null}
{llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
<div className="space-y-1.5">
<RequiredFieldLabel
htmlFor="persona-llm-provider"
@@ -795,7 +835,7 @@ export function AgentDefinitionDialog({
id="persona-llm-provider"
onValueChange={handleProviderDropdownChange}
options={providerDropdownOptions}
placeholder={defaultLlmProviderLabel}
placeholder="Choose a provider"
value={providerSelectValue}
/>
{showCustomProviderInput ? (
@@ -823,7 +863,9 @@ export function AgentDefinitionDialog({
</div>
) : null}
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
{llmProviderFieldVisible &&
aiConfigurationMode === "custom" &&
topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={isPending}
isInherited={apiKeyIsInherited}
@@ -845,7 +887,7 @@ export function AgentDefinitionDialog({
) : null}
<AnimatePresence initial={false}>
{modelFieldVisible ? (
{modelFieldVisible && aiConfigurationMode === "custom" ? (
<PersonaModelField
disabled={isPending}
isExplicitModelRequired={isExplicitModelRequired}
@@ -865,12 +907,21 @@ export function AgentDefinitionDialog({
) : null}
</AnimatePresence>
<AgentAiDefaultsNotice
confirmNavigation
explicitModel={model}
explicitProvider={provider}
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
{aiConfigurationMode === "defaults" ? (
<AgentAiDefaultsNotice
onEditDefaults={() => setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
explicitModel=""
explicitProvider=""
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
/>
) : null}
<AgentAiDefaultsDialog
onOpenChange={setAiDefaultsOpen}
open={aiDefaultsOpen}
returnFocusRef={aiDefaultsTriggerRef}
/>
{isCreateMode ? createRunSection : null}
@@ -890,12 +941,6 @@ export function AgentDefinitionDialog({
)}
/>
</button>
{!showAdvancedFields && advancedInheritedSummary ? (
<p className="text-xs text-muted-foreground">
{advancedInheritedSummary}
</p>
) : null}
<AnimatePresence initial={false}>
{showAdvancedFields ? (
<motion.div
@@ -73,7 +73,7 @@ export function AgentIdentityCard({
{label}
</span>
{modelLabel ? (
<span className="min-w-0 truncate font-normal text-secondary-foreground/75">
<span className="min-w-0 truncate text-xs font-normal text-secondary-foreground/75">
{modelLabel}
</span>
) : null}
@@ -79,6 +79,7 @@ import {
import { getProviderApiKeyEnvVar } from "./personaDialogPickers";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentAiDefaultsDialog } from "./AgentAiDefaultsDialog";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
const ADVANCED_FIELDS_MOTION_TRANSITION = {
@@ -111,6 +112,8 @@ export function AgentInstanceEditDialog({
const runtimes = runtimesQuery.data ?? [];
const [name, setName] = React.useState(agent.name);
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
const [relayUrl, setRelayUrl] = React.useState(agent.relayUrl);
const [acpCommand, setAcpCommand] = React.useState(agent.acpCommand);
const [agentCommand, setAgentCommand] = React.useState(agent.agentCommand);
@@ -357,7 +360,6 @@ export function AgentInstanceEditDialog({
);
const {
advancedInheritedSummary,
globalConfig,
inheritedDefaults: {
provider: inheritedProviderDefault,
@@ -1089,13 +1091,20 @@ export function AgentInstanceEditDialog({
</div>
<AgentAiDefaultsNotice
confirmNavigation
onEditDefaults={() => setAiDefaultsOpen(true)}
triggerRef={aiDefaultsTriggerRef}
explicitModel={inheritedSubmission.model ?? ""}
explicitProvider={inheritedSubmission.provider ?? ""}
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
/>
<AgentAiDefaultsDialog
onOpenChange={setAiDefaultsOpen}
open={aiDefaultsOpen}
returnFocusRef={aiDefaultsTriggerRef}
/>
{/* Advanced settings */}
<div className="space-y-3">
<button
@@ -1112,12 +1121,6 @@ export function AgentInstanceEditDialog({
)}
/>
</button>
{!showAdvancedFields && advancedInheritedSummary ? (
<p className="text-xs text-muted-foreground">
{advancedInheritedSummary}
</p>
) : null}
<AnimatePresence initial={false}>
{showAdvancedFields ? (
<motion.div
+44 -11
View File
@@ -1,10 +1,12 @@
import * as React from "react";
import { OctagonX } from "lucide-react";
import {
consumePendingSnapshotImport,
subscribeSnapshotImport,
} from "@/features/agents/openSnapshotImportFromUrlEvent";
import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog";
import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog";
import { AgentAiDefaultsDialog } from "./AgentAiDefaultsDialog";
import { AgentDialog } from "./AgentDialog";
import { PersonaCatalogDialog } from "./PersonaCatalogDialog";
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
@@ -24,23 +26,23 @@ import { useManagedAgentActions } from "./useManagedAgentActions";
import { usePersonaActions } from "./usePersonaActions";
import { useTeamActions } from "./useTeamActions";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useBakedBuildEnvQuery } from "@/features/agents/hooks";
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
import { Button } from "@/shared/ui/button";
import { PageHeader } from "@/shared/ui/PageHeader";
import { formatAiDefaultsSummary } from "./AgentAiDefaults";
import { getInheritedAgentDefaults } from "./bakedEnvHelpers";
export function AgentsView() {
const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel();
const { goSettings } = useAppNavigation();
const { globalConfig } = useGlobalAgentConfig();
const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: true });
const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv);
const aiDefaultsSummary = formatAiDefaultsSummary(inheritedDefaults);
const agents = useManagedAgentActions();
const personas = usePersonaActions();
const teamImportInputRef = React.useRef<HTMLInputElement | null>(null);
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false);
// Exclusivity: create never sets `personaDialogState` (edit/dup/import do),
// so the create-mode and definition-edit AgentDialog mounts never coexist.
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false);
@@ -66,6 +68,10 @@ export function AgentsView() {
teamActions.createTeamMutation.isPending ||
teamActions.updateTeamMutation.isPending ||
teamActions.deleteTeamMutation.isPending;
const runningAgentCount = agents.managedAgents.filter((agent) =>
isManagedAgentActive(agent),
).length;
const configuredGlobalModel = globalConfig.model?.trim();
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable
React.useEffect(() => {
@@ -100,15 +106,39 @@ export function AgentsView() {
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-7 sm:px-6 sm:py-8">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<PageHeader
action={
<div className="flex flex-wrap justify-end gap-2">
<Button
onClick={() => setIsAiDefaultsOpen(true)}
ref={aiDefaultsTriggerRef}
size="sm"
variant="outline"
>
{configuredGlobalModel
? `Global model: ${configuredGlobalModel}`
: "Set global model"}
</Button>
{runningAgentCount > 0 ? (
<Button
disabled={isActionPending}
onClick={() => {
void agents.handleBulkStopRunning();
}}
size="sm"
variant="outline"
>
<OctagonX />
Stop running agents
</Button>
) : null}
</div>
}
description="Set up and manage your agents."
title="Agents"
/>
<div className="flex flex-col gap-8">
<UnifiedAgentsSection
aiDefaultsSummary={aiDefaultsSummary}
onEditAiDefaults={() => {
void goSettings("agents");
}}
defaultModel={inheritedDefaults.model.value}
actionErrorMessage={agents.actionErrorMessage}
actionNoticeMessage={agents.actionNoticeMessage}
agents={agents.managedAgents}
@@ -121,9 +151,6 @@ export function AgentsView() {
isAgentsLoading={agents.managedAgentsQuery.isLoading}
startingAgentPubkey={agents.startingAgentPubkey}
startingPersonaIds={agents.startingPersonaIds}
onBulkStopRunning={() => {
void agents.handleBulkStopRunning();
}}
onOpenAgentProfile={(pubkey, options) => {
openProfilePanel?.(pubkey, options);
}}
@@ -211,6 +238,12 @@ export function AgentsView() {
</div>
</div>
<AgentAiDefaultsDialog
onOpenChange={setIsAiDefaultsOpen}
open={isAiDefaultsOpen}
returnFocusRef={aiDefaultsTriggerRef}
/>
{isCreateDialogOpen ? (
<AgentDialog
definitionError={
@@ -0,0 +1,230 @@
/**
* Settings card for global agent configuration defaults.
*
* Lets the user set env vars, provider, and model that apply to ALL local
* agents as the lowest-precedence user layer. Per-agent and persona configs
* always win on collision.
*
* Precedence: baked floor < GLOBAL (this card) < persona < per-agent.
*/
import { AlertCircle, Check, Loader } from "lucide-react";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
getGlobalAgentConfig,
setGlobalAgentConfig,
} from "@/shared/api/tauriGlobalAgentConfig";
import type { GlobalAgentConfig } from "@/shared/api/types";
import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig";
import { useAcpRuntimesQuery } from "@/features/agents/hooks";
import {
GlobalAgentConfigFields,
EMPTY_GLOBAL_CONFIG,
} from "@/features/agents/ui/GlobalAgentConfigFields";
import { Button } from "@/shared/ui/button";
type SaveState = "idle" | "saving" | "saved" | "error";
export type GlobalAgentConfigSaveResult = Awaited<
ReturnType<typeof setGlobalAgentConfig>
>;
type GlobalAgentConfigEditorProps = {
onDirtyChange?: (dirty: boolean) => void;
onSaveSuccess?: (result: GlobalAgentConfigSaveResult) => void;
onSavingChange?: (saving: boolean) => void;
secondaryAction?: React.ReactNode;
};
export function GlobalAgentConfigEditor({
onDirtyChange,
onSaveSuccess,
onSavingChange,
secondaryAction,
}: GlobalAgentConfigEditorProps) {
const [config, setConfig] =
React.useState<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const configRef = React.useRef(config);
const [dirty, setDirty] = React.useState(false);
const [saveState, setSaveState] = React.useState<SaveState>("idle");
const [saveError, setSaveError] = React.useState<string | null>(null);
const [restartedCount, setRestartedCount] = React.useState(0);
const [failedRestartCount, setFailedRestartCount] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(false);
const [configIsValid, setConfigIsValid] = React.useState(true);
const [isCustomProvider, setIsCustomProvider] = React.useState(false);
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const savedTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const queryClient = useQueryClient();
const [bakedEnv, setBakedEnv] = React.useState<BakedEnvEntry[]>([]);
React.useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
// Load on mount — seed the shared TanStack Query cache so any dialog that
// opens after this point reads the populated value on first render (no async
// race). The query is also backed by its own queryFn for first-consumer
// scenarios, but this eager seed eliminates the "settings card loaded, user
// opens Create Agent before the lazy query fires" window.
React.useEffect(() => {
let cancelled = false;
getGlobalAgentConfig()
.then((loaded) => {
if (!cancelled) {
configRef.current = loaded;
setConfig(loaded);
setIsLoading(false);
queryClient.setQueryData(globalAgentConfigQueryKey, loaded);
}
})
.catch(() => {
if (!cancelled) {
setIsLoading(false);
setLoadError(true);
}
});
return () => {
cancelled = true;
};
}, [queryClient]);
// Load baked build env once on mount. OSS builds return [] — the section
// stays hidden. Failures are silently swallowed (non-critical display data).
React.useEffect(() => {
getBakedBuildEnv()
.then(setBakedEnv)
.catch(() => {
// non-critical — leave bakedEnv empty
});
}, []);
// Resolve the buzz-agent runtime catalog entry for model discovery.
const runtimesQuery = useAcpRuntimesQuery();
const buzzAgentRuntime = React.useMemo(
() => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
[runtimesQuery.data],
);
function handleConfigChange(next: GlobalAgentConfig) {
configRef.current = next;
setConfig(next);
setDirty(true);
setSaveState("idle");
setSaveError(null);
}
async function handleSave() {
// Snapshot the config being submitted so we can detect edits that arrive
// during the IPC round-trip and avoid clobbering the user's newer input.
const submittedConfig = config;
onSavingChange?.(true);
setSaveState("saving");
setSaveError(null);
try {
const result = await setGlobalAgentConfig(submittedConfig);
// Apply the backend's canonical config ONLY if nothing changed during the
// IPC window. If the user edited, keep their newer value and leave dirty=true
// so they can save again. setDirty(false) runs inside the updater so both
// state updates batch into the same render (React 18 automatic batching).
const savedCurrentDraft = configRef.current === submittedConfig;
setConfig((current) => {
if (!savedCurrentDraft) {
// Mid-flight edit detected — do not overwrite newer user input.
return current;
}
configRef.current = result.config;
setDirty(false);
return result.config;
});
setRestartedCount(result.restarted_count);
setFailedRestartCount(result.failed_restart_count);
setSaveState("saved");
// Seed the shared TanStack Query cache with the canonical saved value so
// all open dialogs (and any that open afterward) see the new config
// synchronously — no second IPC round-trip needed.
queryClient.setQueryData(globalAgentConfigQueryKey, result.config);
if (savedCurrentDraft) onSaveSuccess?.(result);
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
// Partial restart failures require an explicit acknowledgment in the
// overlay, so keep their result visible instead of fading it away.
if (result.failed_restart_count === 0) {
savedTimerRef.current = setTimeout(() => setSaveState("idle"), 2500);
}
} catch (err) {
setSaveState("error");
setSaveError(typeof err === "string" ? err : "Failed to save.");
} finally {
onSavingChange?.(false);
}
}
return (
<div className="min-w-0 space-y-4">
{isLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader className="size-4 animate-spin" />
Loading…
</div>
) : loadError ? (
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="size-4" />
Failed to load agent defaults. Restart the app to try again.
</div>
) : (
<GlobalAgentConfigFields
bakedEnv={bakedEnv}
buzzAgentRuntime={buzzAgentRuntime}
config={config}
isCustomModelEditing={isCustomModelEditing}
isCustomProvider={isCustomProvider}
onConfigChange={handleConfigChange}
onCustomModelEditingChange={setIsCustomModelEditing}
onIsCustomProviderChange={setIsCustomProvider}
onValidityChange={setConfigIsValid}
/>
)}
{/* Save bar */}
{!isLoading && !loadError && (
<div className="mt-4 flex flex-wrap items-center gap-3">
{saveState === "saved" && (
<span className="flex min-w-0 items-center gap-1 text-sm text-green-600 dark:text-green-400">
<Check className="size-3.5 shrink-0" />
{restartedCount > 0
? `Saved. Restarted ${restartedCount} agent${restartedCount === 1 ? "" : "s"}.${failedRestartCount > 0 ? ` ${failedRestartCount} failed to restart — check the Agents tab.` : ""}`
: failedRestartCount > 0
? `Saved. ${failedRestartCount} agent${failedRestartCount === 1 ? "" : "s"} failed to restart — check the Agents tab.`
: "Saved."}
</span>
)}
{saveState === "error" && saveError && (
<span className="flex min-w-0 items-center gap-1 text-sm text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
{saveError}
</span>
)}
<div className="ml-auto flex items-center gap-3">
{secondaryAction}
<Button
disabled={!dirty || !configIsValid || saveState === "saving"}
onClick={() => void handleSave()}
size="sm"
>
{saveState === "saving" ? (
<Loader className="mr-1.5 size-3.5 animate-spin" />
) : null}
Save defaults
</Button>
</div>
</div>
)}
</div>
);
}
@@ -14,7 +14,10 @@ import type {
} from "@/shared/api/types";
import { EnvVarsEditor } from "@/features/agents/ui/EnvVarsEditor";
import type { InheritedEnvRow } from "@/features/agents/ui/EnvVarsEditor";
import { getBakedProviderInheritLabel } from "@/features/agents/ui/bakedEnvHelpers";
import {
getBakedProviderInheritLabel,
getGlobalModelFallback,
} from "@/features/agents/ui/bakedEnvHelpers";
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
@@ -57,6 +60,7 @@ export type GlobalAgentConfigFieldsProps = {
onConfigChange: (next: GlobalAgentConfig) => void;
onCustomModelEditingChange: (value: boolean) => void;
onIsCustomProviderChange: (value: boolean) => void;
onValidityChange?: (valid: boolean) => void;
};
export function GlobalAgentConfigFields({
@@ -68,15 +72,22 @@ export function GlobalAgentConfigFields({
onConfigChange,
onCustomModelEditingChange,
onIsCustomProviderChange,
onValidityChange,
}: GlobalAgentConfigFieldsProps) {
const bakedProvider = React.useMemo(
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null,
[bakedEnv],
);
const bakedModel = React.useMemo(
() => bakedEnv.find((e) => e.key === "BUZZ_AGENT_MODEL")?.value ?? null,
[bakedEnv],
const effectiveProvider = config.provider?.trim() || bakedProvider || "";
const fallbackModel = React.useMemo(
() => getGlobalModelFallback(bakedEnv, effectiveProvider, config.env_vars),
[bakedEnv, config.env_vars, effectiveProvider],
);
const modelIsValid =
(config.model?.trim().length ?? 0) > 0 || fallbackModel !== null;
React.useEffect(() => {
onValidityChange?.(modelIsValid);
}, [modelIsValid, onValidityChange]);
const bakedEffort = React.useMemo(
() =>
bakedEnv.find((e) => e.key === BUZZ_AGENT_THINKING_EFFORT)?.value ?? null,
@@ -229,12 +240,16 @@ export function GlobalAgentConfigFields({
{/* Model field */}
<div className="space-y-1.5 p-3">
<AgentModelField
allowDefaultModel={fallbackModel !== null}
defaultModelLabel={
fallbackModel ? `Default model (${fallbackModel})` : undefined
}
disabled={false}
discoveredModelOptions={discoveredModelOptions}
globalModel={bakedModel ?? undefined}
globalModel={fallbackModel ?? undefined}
id="global-agent-model"
isCustomModelEditing={isCustomModelEditing}
isRequired={false}
isRequired={fallbackModel === null}
model={config.model ?? ""}
modelDiscoveryLoading={modelDiscoveryLoading}
modelDiscoveryStatus={modelDiscoveryStatus}
@@ -1,11 +1,5 @@
import * as React from "react";
import {
ChevronDown,
ChevronRight,
Ellipsis,
OctagonX,
RefreshCw,
} from "lucide-react";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { formatAgentModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
@@ -16,7 +10,6 @@ import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelConte
import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,7 +17,6 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton";
import { SectionHeader } from "@/shared/ui/PageHeader";
import { AgentIdentityCard } from "./AgentIdentityCard";
import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl";
import { CreateIdentityCard } from "./CreateIdentityCard";
@@ -32,7 +24,7 @@ import { PersonaActionsMenu } from "./PersonaActionsMenu";
import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups";
type UnifiedAgentsSectionProps = {
aiDefaultsSummary: string;
defaultModel: string;
actionErrorMessage: string | null;
actionNoticeMessage: string | null;
agents: ManagedAgent[];
@@ -41,8 +33,6 @@ type UnifiedAgentsSectionProps = {
isAgentsLoading: boolean;
startingAgentPubkey: string | null;
startingPersonaIds: ReadonlySet<string>;
onBulkStopRunning: () => void;
onEditAiDefaults: () => void;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
@@ -76,16 +66,14 @@ const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} grid grid-cols-[repeat
export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
const {
actionErrorMessage,
aiDefaultsSummary,
actionNoticeMessage,
defaultModel,
agents,
agentsError,
isActionPending,
isAgentsLoading,
startingAgentPubkey,
startingPersonaIds,
onBulkStopRunning,
onEditAiDefaults,
onOpenAgentProfile,
onOpenPersonaProfile,
onStartAgent,
@@ -107,9 +95,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
onImportSnapshotFile,
} = props;
const runningCount = agents.filter((agent) =>
isManagedAgentActive(agent),
).length;
const { groups, ungrouped, unknown } = React.useMemo(
() => buildUnifiedGroups(personas, agents),
[personas, agents],
@@ -162,15 +147,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
</div>
) : null}
<AgentsListHeader
agentCount={agents.length}
aiDefaultsSummary={aiDefaultsSummary}
fileInputRef={fileInputRef}
handleFileChange={handleFileChange}
isActionPending={isActionPending}
runningCount={runningCount}
onBulkStopRunning={onBulkStopRunning}
onEditAiDefaults={onEditAiDefaults}
<input
accept=".agent.json,.agent.png"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
{isLoading ? <LoadingSkeleton /> : null}
@@ -196,6 +178,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
/>
}
agent={profileAgent}
defaultModel={defaultModel}
key={group.persona.id}
persona={group.persona}
startingAgentPubkey={startingAgentPubkey}
@@ -220,6 +203,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
<CollapsibleAgentGroup
agents={additionalPersonaAgents}
collapsed={collapsed}
defaultModel={defaultModel}
groupKey="__additional_persona_agents__"
label="Additional agent instances"
startingAgentPubkey={startingAgentPubkey}
@@ -232,6 +216,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
<CollapsibleAgentGroup
agents={unknown}
collapsed={collapsed}
defaultModel={defaultModel}
groupKey="__unknown__"
label="Unknown Agent"
startingAgentPubkey={startingAgentPubkey}
@@ -244,6 +229,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
<CollapsibleAgentGroup
agents={ungrouped}
collapsed={collapsed}
defaultModel={defaultModel}
groupKey="__ungrouped__"
label="Custom agents"
startingAgentPubkey={startingAgentPubkey}
@@ -276,6 +262,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
function AgentPersonaCard({
actions,
agent,
defaultModel,
persona,
startingAgentPubkey,
startingPersonaIds,
@@ -286,6 +273,7 @@ function AgentPersonaCard({
}: {
actions?: React.ReactNode;
agent: ManagedAgent | undefined;
defaultModel: string;
persona: AgentPersona;
startingAgentPubkey: string | null;
startingPersonaIds: ReadonlySet<string>;
@@ -298,7 +286,10 @@ function AgentPersonaCard({
onStartPersona: (persona: AgentPersona) => void;
}) {
const title = persona.displayName;
const modelLabel = formatAgentModelLabel(agent?.model ?? persona.model);
const explicitModel = agent?.model ?? persona.model;
const modelLabel = explicitModel?.trim()
? formatAgentModelLabel(explicitModel)
: formatDefaultModelLabel(defaultModel);
const isActive = agent ? isManagedAgentActive(agent) : false;
const profileQuery = useUserProfileQuery(agent?.pubkey);
const avatarUrl = agent
@@ -344,7 +335,7 @@ function AgentPersonaCard({
avatarUrl={avatarUrl}
dataTestId={`persona-agent-row-${persona.id}`}
label={title}
modelLabel={agent && isActive ? modelLabel : null}
modelLabel={modelLabel}
onClick={() => {
if (agent) {
onOpenAgentProfile(
@@ -369,11 +360,13 @@ function AgentPersonaCard({
function StandaloneAgentCard({
agent,
defaultModel,
startingAgentPubkey,
onOpenAgentProfile,
onStartAgent,
}: {
agent: ManagedAgent;
defaultModel: string;
startingAgentPubkey: string | null;
onOpenAgentProfile: (
pubkey: string,
@@ -412,7 +405,11 @@ function StandaloneAgentCard({
avatarUrl={profileQuery.data?.avatarUrl}
dataTestId={`managed-agent-${agent.pubkey}`}
label={title}
modelLabel={isActive ? formatAgentModelLabel(agent.model) : null}
modelLabel={
agent.model?.trim()
? formatAgentModelLabel(agent.model)
: formatDefaultModelLabel(defaultModel)
}
onClick={() => {
onOpenAgentProfile(
agent.pubkey,
@@ -431,6 +428,11 @@ function StandaloneAgentCard({
);
}
function formatDefaultModelLabel(defaultModel: string) {
const model = defaultModel.trim();
return model ? `Default model (${model})` : "Default model";
}
function firstAvatarUrl(
...candidates: Array<string | null | undefined>
): string | null {
@@ -441,85 +443,6 @@ function firstAvatarUrl(
return null;
}
function AgentsListHeader({
agentCount,
aiDefaultsSummary,
fileInputRef,
handleFileChange,
isActionPending,
runningCount,
onBulkStopRunning,
onEditAiDefaults,
}: {
agentCount: number;
aiDefaultsSummary: string;
fileInputRef: React.RefObject<HTMLInputElement | null>;
handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
isActionPending: boolean;
runningCount: number;
onBulkStopRunning: () => void;
onEditAiDefaults: () => void;
}) {
return (
<div className={AGENT_CARD_COLUMN_CLASS}>
<input
accept=".agent.json,.agent.png"
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
<SectionHeader
title="Agents"
description={
<>
<span>Agents in this community.</span>
<span className="mt-1 flex flex-wrap items-center gap-x-1 text-xs">
<span>AI defaults: {aiDefaultsSummary}</span>
<Button
onClick={onEditAiDefaults}
size="xs"
type="button"
variant="link"
>
Edit defaults
</Button>
</span>
</>
}
action={
agentCount > 0 ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Bulk actions"
className="h-7 w-7"
size="icon"
variant="ghost"
>
<Ellipsis className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
disabled={isActionPending || runningCount === 0}
onClick={onBulkStopRunning}
>
<OctagonX className="h-4 w-4" />
Stop all running ({runningCount})
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : undefined
}
/>
</div>
);
}
function NewAgentCard({
canChooseCatalog,
isPersonasPending,
@@ -595,6 +518,7 @@ function CollapsibleAgentGroup({
label,
agents,
collapsed,
defaultModel,
startingAgentPubkey,
onToggle,
onOpenAgentProfile,
@@ -604,6 +528,7 @@ function CollapsibleAgentGroup({
label: string;
agents: ManagedAgent[];
collapsed: ReadonlySet<string>;
defaultModel: string;
startingAgentPubkey: string | null;
onToggle: (key: string) => void;
onOpenAgentProfile: (
@@ -633,6 +558,7 @@ function CollapsibleAgentGroup({
{agents.map((agent) => (
<StandaloneAgentCard
agent={agent}
defaultModel={defaultModel}
key={agent.pubkey}
startingAgentPubkey={startingAgentPubkey}
onOpenAgentProfile={onOpenAgentProfile}
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
agentAiConfigurationModeSatisfied,
agentAiConfigurationPairForMode,
initialAgentAiConfigurationMode,
} from "./agentAiConfigurationPolicy.ts";
test("existing one-sided and complete overrides open in Customize", () => {
assert.equal(
initialAgentAiConfigurationMode({ provider: "anthropic" }),
"custom",
);
assert.equal(
initialAgentAiConfigurationMode({ model: "claude-opus" }),
"custom",
);
assert.equal(
initialAgentAiConfigurationMode({
provider: "anthropic",
model: "claude-opus",
}),
"custom",
);
assert.equal(initialAgentAiConfigurationMode({}), "defaults");
});
test("Customize requires a complete explicit pair", () => {
assert.equal(
agentAiConfigurationModeSatisfied("custom", {
provider: "anthropic",
model: "",
}),
false,
);
assert.equal(
agentAiConfigurationModeSatisfied("custom", {
provider: "",
model: "claude-opus",
}),
false,
);
assert.equal(
agentAiConfigurationModeSatisfied("custom", {
provider: "anthropic",
model: "claude-opus",
}),
true,
);
});
test("Defaults clears provider and model together", () => {
assert.deepEqual(
agentAiConfigurationPairForMode({
current: { provider: "anthropic", model: "claude-opus" },
inherited: { provider: "databricks_v2", model: "llama" },
mode: "defaults",
}),
{ provider: "", model: "" },
);
});
test("entering Customize pins unresolved fields from the inherited pair", () => {
assert.deepEqual(
agentAiConfigurationPairForMode({
current: { provider: "anthropic", model: "" },
inherited: { provider: "databricks_v2", model: "llama" },
mode: "custom",
}),
{ provider: "anthropic", model: "llama" },
);
});
@@ -0,0 +1,41 @@
export type AgentAiConfigurationMode = "defaults" | "custom";
export type AgentAiConfigurationPair = {
provider: string;
model: string;
};
export function initialAgentAiConfigurationMode(
pair: Partial<AgentAiConfigurationPair>,
): AgentAiConfigurationMode {
return pair.provider?.trim() || pair.model?.trim() ? "custom" : "defaults";
}
export function agentAiConfigurationPairForMode({
current,
inherited,
mode,
}: {
current: AgentAiConfigurationPair;
inherited: AgentAiConfigurationPair;
mode: AgentAiConfigurationMode;
}): AgentAiConfigurationPair {
if (mode === "defaults") {
return { provider: "", model: "" };
}
return {
provider: current.provider.trim() || inherited.provider,
model: current.model.trim() || inherited.model,
};
}
export function agentAiConfigurationModeSatisfied(
mode: AgentAiConfigurationMode,
pair: AgentAiConfigurationPair,
) {
return (
mode === "defaults" ||
(pair.provider.trim().length > 0 && pair.model.trim().length > 0)
);
}
@@ -33,7 +33,9 @@ test("edit-untouched: model and provider omitted when user changes nothing on au
// ── edit-model-only ────────────────────────────────────────────────────────────
//
// User opens a null-runtime builtin with auto-seed and picks a model.
// Transport-level coverage: the serializer remains permissive for legacy and
// non-dialog callers. The separately tested AI configuration mode policy blocks
// a model-only Customize submission in AgentDefinitionDialog.
// Expected: model persisted, runtime omitted (auto-seeded, not explicit).
test("edit-model-only: chosen model persists, runtime omitted on auto-seeded builtin", () => {
@@ -51,7 +53,9 @@ test("edit-model-only: chosen model persists, runtime omitted on auto-seeded bui
// ── edit-provider-only ─────────────────────────────────────────────────────────
//
// User opens a null-runtime builtin with auto-seed and picks a provider.
// Transport-level coverage: the serializer remains permissive for legacy and
// non-dialog callers. The separately tested AI configuration mode policy blocks
// a provider-only Customize submission in AgentDefinitionDialog.
// Expected: provider persisted, model and runtime omitted.
test("edit-provider-only: chosen provider persists, runtime omitted on auto-seeded builtin", () => {
@@ -58,6 +58,43 @@ export function getBakedModelInheritLabel(bakedModelId: string): string {
return `Inherit build default (${bakedModelId})`;
}
function providerModelEnvKey(provider: string): string | null {
switch (provider.trim().toLowerCase()) {
case "databricks":
case "databricks_v2":
case "databricks-v2":
return "DATABRICKS_MODEL";
case "anthropic":
return "ANTHROPIC_MODEL";
case "openai":
case "openai-compat":
return "OPENAI_COMPAT_MODEL";
default:
return null;
}
}
/** Resolve the concrete model beneath the structured global model override. */
export function getGlobalModelFallback(
bakedEnv: readonly BakedEnvEntry[] | undefined,
provider: string,
globalEnv: Readonly<Record<string, string>> = {},
): string | null {
const universal = bakedEnv?.find(
(entry) => entry.key === "BUZZ_AGENT_MODEL" && !entry.masked,
)?.value;
if (universal?.trim()) return universal.trim();
const providerKey = providerModelEnvKey(provider);
if (!providerKey) return provider === "relay-mesh" ? "auto" : null;
const globalProviderModel = globalEnv[providerKey]?.trim();
if (globalProviderModel) return globalProviderModel;
const providerModel = bakedEnv?.find(
(entry) => entry.key === providerKey && !entry.masked,
)?.value;
return providerModel?.trim() || null;
}
/**
* Global env keys are arbitrary user input. Count only ordinary configuration
* names in the collapsed summary so it never calls attention to credentials.
@@ -117,11 +154,27 @@ export function getInheritedAgentDefaults(
bakedEnv,
"BUZZ_AGENT_PROVIDER",
),
model: resolveInheritedDefault(
globalConfig.model,
bakedEnv,
"BUZZ_AGENT_MODEL",
),
model: (() => {
const structured = resolveInheritedDefault(
globalConfig.model,
bakedEnv,
"BUZZ_AGENT_MODEL",
);
if (structured.value) return structured;
const provider = resolveInheritedDefault(
globalConfig.provider,
bakedEnv,
"BUZZ_AGENT_PROVIDER",
);
const fallback = getGlobalModelFallback(
bakedEnv,
provider.value,
globalConfig.env_vars,
);
return fallback
? { source: "build", value: fallback }
: { source: null, value: "" };
})(),
effort: resolveInheritedDefault(
globalConfig.env_vars.BUZZ_AGENT_THINKING_EFFORT,
bakedEnv,
@@ -36,6 +36,8 @@ import {
countNonSecretInheritedEnvVars,
getBakedModelInheritLabel,
getAdvancedInheritedSummary,
getGlobalModelFallback,
getInheritedAgentDefaults,
getBakedProviderInheritLabel,
resolveInheritedDefault,
} from "./bakedEnvHelpers.ts";
@@ -1390,3 +1392,53 @@ test("localMode_globalEnvSatisfied_agentLocalKeyAbsent_silenced", () => {
"gate must be satisfied when global key is not shadowed by an explicit empty",
);
});
test("global model fallback resolves the selected provider model env", () => {
const bakedEnv = [
{
key: "DATABRICKS_MODEL",
value: "goose-claude-opus-4-8",
masked: false,
},
];
assert.equal(
getGlobalModelFallback(bakedEnv, "databricks_v2"),
"goose-claude-opus-4-8",
);
});
test("global model fallback gives saved provider env precedence over build env", () => {
const bakedEnv = [
{ key: "ANTHROPIC_MODEL", value: "build-model", masked: false },
];
assert.equal(
getGlobalModelFallback(bakedEnv, "anthropic", {
ANTHROPIC_MODEL: "saved-model",
}),
"saved-model",
);
});
test("global model fallback never uses another provider's model", () => {
const bakedEnv = [
{ key: "DATABRICKS_MODEL", value: "databricks-model", masked: false },
];
assert.equal(getGlobalModelFallback(bakedEnv, "anthropic"), null);
});
test("inherited defaults expose a provider-specific model fallback to agent dialogs", () => {
const defaults = getInheritedAgentDefaults(
{ env_vars: {}, provider: "databricks_v2", model: null },
[
{
key: "DATABRICKS_MODEL",
value: "goose-claude-opus-4-8",
masked: false,
},
],
);
assert.deepEqual(defaults.model, {
source: "build",
value: "goose-claude-opus-4-8",
});
});
@@ -48,6 +48,8 @@ export function AgentModelField({
disabled,
discoveredModelOptions,
globalModel,
allowDefaultModel = true,
defaultModelLabel,
id = "agent-model",
isCustomModelEditing,
isRequired,
@@ -60,8 +62,12 @@ export function AgentModelField({
}: {
disabled: boolean;
discoveredModelOptions: readonly PersonaModelOption[] | null;
/** Global model default; when set, the zero-value option reads `Inherit global default (<model>)`. */
/** Known lower-precedence model used when this field is left empty. */
globalModel?: string;
/** Hide the empty/default option when no valid lower-precedence model exists. */
allowDefaultModel?: boolean;
/** Source-aware label for the empty/default option. */
defaultModelLabel?: string;
/** DOM id for the model select. Defaults to `"agent-model"`. Override in
* contexts where multiple instances coexist on the same page (e.g. the
* global-config settings card) to avoid duplicate DOM ids. */
@@ -80,20 +86,23 @@ export function AgentModelField({
// Buzz shared compute always has an automatic routing choice, even when
// discovery is empty or returns only explicit live model ids.
const staticModelOptions: readonly PersonaModelOption[] = [
{
id: "",
label: isSharedCompute
const defaultOption: PersonaModelOption = {
id: "",
label:
defaultModelLabel ??
(isSharedCompute
? "Default (auto)"
: getDefaultLlmModelLabel(globalModel),
},
];
: getDefaultLlmModelLabel(globalModel)),
};
const discoveredWithoutDefault = (discoveredModelOptions ?? []).filter(
(option) => option.id.trim() !== "",
);
const effectiveModelOptions = isSharedCompute
? [...staticModelOptions, ...discoveredWithoutDefault]
: (discoveredModelOptions ?? staticModelOptions);
? [defaultOption, ...discoveredWithoutDefault]
: [
...(allowDefaultModel ? [defaultOption] : []),
...discoveredWithoutDefault,
];
// isModelCustom: true when the current model isn't in any known option set.
// We check discovered options (when available) or runtime-static options so
@@ -3,11 +3,7 @@ import * as React from "react";
import { useBakedBuildEnvQuery } from "../hooks";
import { useGlobalAgentConfig } from "../useGlobalAgentConfig";
import { BUZZ_AGENT_THINKING_EFFORT } from "./buzzAgentConfig";
import {
countNonSecretInheritedEnvVars,
getAdvancedInheritedSummary,
getInheritedAgentDefaults,
} from "./bakedEnvHelpers";
import { getInheritedAgentDefaults } from "./bakedEnvHelpers";
export function useAgentDialogDefaults({
inheritedEnvVars = {},
@@ -29,16 +25,7 @@ export function useAgentDialogDefaults({
}),
[globalConfig.env_vars, inheritedDefaults.effort.value, inheritedEnvVars],
);
const advancedInheritedSummary = getAdvancedInheritedSummary(
inheritedDefaults.effort,
countNonSecretInheritedEnvVars(
globalConfig.env_vars,
BUZZ_AGENT_THINKING_EFFORT,
),
);
return {
advancedInheritedSummary,
globalConfig,
inheritedDefaults,
inheritedEnvVars: effectiveInheritedEnvVars,
@@ -1,137 +1,7 @@
/**
* Settings card for global agent configuration defaults.
*
* Lets the user set env vars, provider, and model that apply to ALL local
* agents as the lowest-precedence user layer. Per-agent and persona configs
* always win on collision.
*
* Precedence: baked floor < GLOBAL (this card) < persona < per-agent.
*/
import { AlertCircle, Check, Loader } from "lucide-react";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
getGlobalAgentConfig,
setGlobalAgentConfig,
} from "@/shared/api/tauriGlobalAgentConfig";
import type { GlobalAgentConfig } from "@/shared/api/types";
import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri";
import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig";
import { useAcpRuntimesQuery } from "@/features/agents/hooks";
import {
GlobalAgentConfigFields,
EMPTY_GLOBAL_CONFIG,
} from "@/features/agents/ui/GlobalAgentConfigFields";
import { Button } from "@/shared/ui/button";
import { GlobalAgentConfigEditor } from "@/features/agents/ui/GlobalAgentConfigEditor";
import { SectionHeader } from "@/shared/ui/PageHeader";
type SaveState = "idle" | "saving" | "saved" | "error";
export function GlobalAgentConfigSettingsCard() {
const [config, setConfig] =
React.useState<GlobalAgentConfig>(EMPTY_GLOBAL_CONFIG);
const [dirty, setDirty] = React.useState(false);
const [saveState, setSaveState] = React.useState<SaveState>("idle");
const [saveError, setSaveError] = React.useState<string | null>(null);
const [restartedCount, setRestartedCount] = React.useState(0);
const [failedRestartCount, setFailedRestartCount] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(false);
const [isCustomProvider, setIsCustomProvider] = React.useState(false);
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const savedTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const queryClient = useQueryClient();
const [bakedEnv, setBakedEnv] = React.useState<BakedEnvEntry[]>([]);
// Load on mount — seed the shared TanStack Query cache so any dialog that
// opens after this point reads the populated value on first render (no async
// race). The query is also backed by its own queryFn for first-consumer
// scenarios, but this eager seed eliminates the "settings card loaded, user
// opens Create Agent before the lazy query fires" window.
React.useEffect(() => {
let cancelled = false;
getGlobalAgentConfig()
.then((loaded) => {
if (!cancelled) {
setConfig(loaded);
setIsLoading(false);
queryClient.setQueryData(globalAgentConfigQueryKey, loaded);
}
})
.catch(() => {
if (!cancelled) {
setIsLoading(false);
setLoadError(true);
}
});
return () => {
cancelled = true;
};
}, [queryClient]);
// Load baked build env once on mount. OSS builds return [] — the section
// stays hidden. Failures are silently swallowed (non-critical display data).
React.useEffect(() => {
getBakedBuildEnv()
.then(setBakedEnv)
.catch(() => {
// non-critical — leave bakedEnv empty
});
}, []);
// Resolve the buzz-agent runtime catalog entry for model discovery.
const runtimesQuery = useAcpRuntimesQuery();
const buzzAgentRuntime = React.useMemo(
() => (runtimesQuery.data ?? []).find((r) => r.id === "buzz-agent"),
[runtimesQuery.data],
);
function handleConfigChange(next: GlobalAgentConfig) {
setConfig(next);
setDirty(true);
setSaveState("idle");
setSaveError(null);
}
async function handleSave() {
// Snapshot the config being submitted so we can detect edits that arrive
// during the IPC round-trip and avoid clobbering the user's newer input.
const submittedConfig = config;
setSaveState("saving");
setSaveError(null);
try {
const result = await setGlobalAgentConfig(submittedConfig);
// Apply the backend's canonical config ONLY if nothing changed during the
// IPC window. If the user edited, keep their newer value and leave dirty=true
// so they can save again. setDirty(false) runs inside the updater so both
// state updates batch into the same render (React 18 automatic batching).
setConfig((current) => {
if (current !== submittedConfig) {
// Mid-flight edit detected — do not overwrite newer user input.
return current;
}
setDirty(false);
return result.config;
});
setRestartedCount(result.restarted_count);
setFailedRestartCount(result.failed_restart_count);
setSaveState("saved");
// Seed the shared TanStack Query cache with the canonical saved value so
// all open dialogs (and any that open afterward) see the new config
// synchronously — no second IPC round-trip needed.
queryClient.setQueryData(globalAgentConfigQueryKey, result.config);
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
savedTimerRef.current = setTimeout(() => setSaveState("idle"), 2500);
} catch (err) {
setSaveState("error");
setSaveError(typeof err === "string" ? err : "Failed to save.");
}
}
return (
<section
className="min-w-0 space-y-4"
@@ -141,61 +11,7 @@ export function GlobalAgentConfigSettingsCard() {
title="AI defaults"
description="Provider, model, effort, and environment settings inherited by local agents. Agent-specific settings always take priority."
/>
{isLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader className="size-4 animate-spin" />
Loading…
</div>
) : loadError ? (
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="size-4" />
Failed to load agent defaults. Restart the app to try again.
</div>
) : (
<GlobalAgentConfigFields
bakedEnv={bakedEnv}
buzzAgentRuntime={buzzAgentRuntime}
config={config}
isCustomModelEditing={isCustomModelEditing}
isCustomProvider={isCustomProvider}
onConfigChange={handleConfigChange}
onCustomModelEditingChange={setIsCustomModelEditing}
onIsCustomProviderChange={setIsCustomProvider}
/>
)}
{/* Save bar */}
{!isLoading && !loadError && (
<div className="mt-4 flex items-center gap-3">
<Button
disabled={!dirty || saveState === "saving"}
onClick={() => void handleSave()}
size="sm"
>
{saveState === "saving" ? (
<Loader className="mr-1.5 size-3.5 animate-spin" />
) : null}
Save defaults
</Button>
{saveState === "saved" && (
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
<Check className="size-3.5" />
{restartedCount > 0
? `Saved. Restarted ${restartedCount} agent${restartedCount === 1 ? "" : "s"}.${failedRestartCount > 0 ? ` ${failedRestartCount} failed to restart — check the Agents tab.` : ""}`
: failedRestartCount > 0
? `Saved. ${failedRestartCount} agent${failedRestartCount === 1 ? "" : "s"} failed to restart — check the Agents tab.`
: "Saved."}
</span>
)}
{saveState === "error" && saveError && (
<span className="flex items-center gap-1 text-sm text-destructive">
<AlertCircle className="size-3.5" />
{saveError}
</span>
)}
</div>
)}
<GlobalAgentConfigEditor />
</section>
);
}
@@ -715,8 +715,8 @@ export function renderSettingsSection(
case "agents":
return (
<div className="space-y-12">
<GlobalAgentConfigSettingsCard />
<PreventSleepSettingsCard />
<GlobalAgentConfigSettingsCard />
</div>
);
case "channel-templates":
@@ -277,7 +277,7 @@ export function SettingsView({
data-tauri-drag-region
/>
<div className="relative z-10 ml-px mt-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-tl-xl bg-background pt-11 shadow-[-1px_-1px_0_0_hsl(var(--sidebar-border)/0.45)]">
<section className="min-h-0 flex-1 overflow-y-auto px-5 pb-5 pt-4 sm:px-6">
<section className="min-h-0 flex-1 overflow-y-auto px-5 pb-12 pt-4 sm:px-6">
<div
className="mx-auto flex h-full w-full max-w-4xl flex-col gap-4"
data-testid={`settings-panel-${section}`}