mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1d6t84ajeg9skp2609l2k6axgcme8x7g7u7luj352r03hcwreg7lqnxcsex
npub1s654z6utm59mkg4tjdqw69tlen05y2hrrwajlck4n2ar0jyx7hfsmp60kf
parent
0722346b1f
commit
2910ea45de
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user