mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): one edit surface for agent instances and definitions
Merges the two-dialog routing into a single unified path: - UserProfilePanel.handleEditAgent always opens AgentInstanceEditDialog, removing the resolvedPersona-preference branch that made the auto-restart toggle unreachable for definition-backed agents (routing bug fix). - EditAgentAdvancedFields gains an optional Instance name pool field that renders when the agent has a linked persona. Comma-separated names map to the persona's namePool; the field is hidden for agents without a definition. - useNamePoolEdit (new hook) owns name pool state for the instance dialog: seeds once per open session from the linked persona, resets on close, and calls updatePersonaMutation only when the value actually changed. Built-in personas are silently skipped. - AgentInstanceEditDialog wires the hook, passes name pool props to EditAgentAdvancedFields, guards canSubmit on isPending, and calls saveIfChanged after the auto-restart setter in handleSubmit. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import { Dialog } from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
|
||||
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
|
||||
import { useNamePoolEdit } from "./useNamePoolEdit";
|
||||
import {
|
||||
ADVANCED_FIELDS_MOTION_TRANSITION,
|
||||
AUTO_PROVIDER_DROPDOWN_VALUE,
|
||||
@@ -153,6 +154,7 @@ export function AgentInstanceEditDialog({
|
||||
[agent.personaId, personasQuery.data],
|
||||
);
|
||||
const inheritedEnvVars = linkedPersona?.envVars ?? {};
|
||||
const namePoolEdit = useNamePoolEdit({ open, linkedPersona });
|
||||
const [respondTo, setRespondTo] = React.useState<RespondToMode>(
|
||||
agent.respondTo,
|
||||
);
|
||||
@@ -266,10 +268,9 @@ export function AgentInstanceEditDialog({
|
||||
return runtimeSupportsLlmProviderSelection(matched?.id ?? "");
|
||||
}, [runtimes, originalAgentCommand]);
|
||||
|
||||
// The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime
|
||||
// (that is what runs once the override is cleared, not the current override).
|
||||
// Falls back to dual-match (command path, then id) when no persona or its runtime is unset.
|
||||
// This single prospective id feeds BOTH the block-save gate and submit so they always agree.
|
||||
// The runtime id active after submit. Inheriting resolves from the linked
|
||||
// persona's runtime; falls back to dual-match when no persona exists.
|
||||
// Feeds both the block-save gate and submit so they always agree.
|
||||
const prospectiveRuntimeId = React.useMemo(() => {
|
||||
if (!inheritHarness) {
|
||||
return selectedRuntime?.id ?? selectedRuntimeId;
|
||||
@@ -394,11 +395,8 @@ export function AgentInstanceEditDialog({
|
||||
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
|
||||
|
||||
// Merge global env as the base layer so credential keys satisfied via global
|
||||
// config (e.g. ANTHROPIC_API_KEY) are available to model discovery. Use
|
||||
// `inheritedSubmission.envVars` (the same snapshot the credential gate
|
||||
// validates) rather than raw `envVars`, so an inherit-transition that layers
|
||||
// in persona env vars is reflected in discovery. Agent-local env takes
|
||||
// precedence, matching the agent → global → file spawn-path precedence.
|
||||
// config are available to model discovery. Agent-local env takes precedence,
|
||||
// matching the agent → global → file spawn-path precedence.
|
||||
const envVarsForDiscovery = React.useMemo(
|
||||
() => ({ ...globalConfig.env_vars, ...inheritedSubmission.envVars }),
|
||||
[globalConfig.env_vars, inheritedSubmission.envVars],
|
||||
@@ -421,11 +419,8 @@ export function AgentInstanceEditDialog({
|
||||
selectedRuntime,
|
||||
});
|
||||
|
||||
// D2: derive advancedRequiredEnvKeys for EnvVarsEditor display.
|
||||
// The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating.
|
||||
// D2/D3: the top-level API key owns display, while the readiness gate keeps
|
||||
// the complete required-key list. The effective snapshot covers persona
|
||||
// inheritance during an instance inherit transition.
|
||||
// Derive advancedRequiredEnvKeys for EnvVarsEditor display; the top-level
|
||||
// API key owns display while the readiness gate keeps the full required-key list.
|
||||
const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider);
|
||||
const personaSatisfied =
|
||||
providerApiKeyEnvVar != null &&
|
||||
@@ -610,6 +605,7 @@ export function AgentInstanceEditDialog({
|
||||
}) &&
|
||||
providerValid &&
|
||||
!updateMutation.isPending &&
|
||||
!namePoolEdit.isPending &&
|
||||
!isAvatarUploadPending;
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -732,6 +728,8 @@ export function AgentInstanceEditDialog({
|
||||
autoRestartOnConfigChange,
|
||||
);
|
||||
}
|
||||
// Save name pool changes to the linked definition (handled inside the hook).
|
||||
await namePoolEdit.saveIfChanged();
|
||||
showAgentProfileSyncWarning(result.agent.name, result.profileSyncError);
|
||||
handleOpenChange(false);
|
||||
onUpdated?.(result.agent);
|
||||
@@ -1194,6 +1192,7 @@ export function AgentInstanceEditDialog({
|
||||
linkedPersona={linkedPersona}
|
||||
model={inheritedSubmission.model ?? ""}
|
||||
modelTuningRuntimeId={prospectiveRuntimeId}
|
||||
namePoolText={namePoolEdit.namePoolText}
|
||||
parallelism={parallelism}
|
||||
provider={effectiveProvider}
|
||||
requiredEnvKeys={advancedRequiredEnvKeys}
|
||||
@@ -1205,6 +1204,7 @@ export function AgentInstanceEditDialog({
|
||||
onAutoRestartChange={setAutoRestartOnConfigChange}
|
||||
onEnvVarsChange={setEnvVars}
|
||||
onInheritHarnessChange={setInheritHarness}
|
||||
onNamePoolTextChange={namePoolEdit.setNamePoolText}
|
||||
onParallelismChange={setParallelism}
|
||||
onSystemPromptChange={setSystemPrompt}
|
||||
/>
|
||||
|
||||
@@ -43,6 +43,7 @@ export function EditAgentAdvancedFields({
|
||||
linkedPersona,
|
||||
model,
|
||||
modelTuningRuntimeId,
|
||||
namePoolText,
|
||||
parallelism,
|
||||
provider,
|
||||
requiredEnvKeys,
|
||||
@@ -53,6 +54,7 @@ export function EditAgentAdvancedFields({
|
||||
onAgentArgsChange,
|
||||
onEnvVarsChange,
|
||||
onInheritHarnessChange,
|
||||
onNamePoolTextChange,
|
||||
onParallelismChange,
|
||||
onAutoRestartChange,
|
||||
onSystemPromptChange,
|
||||
@@ -77,6 +79,12 @@ export function EditAgentAdvancedFields({
|
||||
* EditAgentDialog — the resolved runtime, not the "inherit"/"custom" sentinel.
|
||||
*/
|
||||
modelTuningRuntimeId: string;
|
||||
/**
|
||||
* Comma-separated name pool text from the linked definition. When provided
|
||||
* (i.e. linkedPersona is non-null), the name pool field is shown so the user
|
||||
* can edit instance names without needing to open the definition dialog.
|
||||
*/
|
||||
namePoolText?: string;
|
||||
parallelism: string;
|
||||
/** Active LLM provider id — forwarded to BuzzAgentModelTuningFields for effort filtering. */
|
||||
provider?: string;
|
||||
@@ -104,6 +112,8 @@ export function EditAgentAdvancedFields({
|
||||
onAgentArgsChange: (value: string) => void;
|
||||
onEnvVarsChange: (value: EnvVarsValue) => void;
|
||||
onInheritHarnessChange: (value: boolean) => void;
|
||||
/** Called when the user edits the name pool text field. Only relevant when linkedPersona is non-null. */
|
||||
onNamePoolTextChange?: (value: string) => void;
|
||||
onParallelismChange: (value: string) => void;
|
||||
onAutoRestartChange: (value: boolean) => void;
|
||||
onSystemPromptChange: (value: string) => void;
|
||||
@@ -324,6 +334,44 @@ export function EditAgentAdvancedFields({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instance name pool — only shown when there is a linked definition.
|
||||
Names are drawn from this pool when spawning new instances; the
|
||||
user can edit the pool here without opening the definition dialog. */}
|
||||
{linkedPersona != null &&
|
||||
namePoolText !== undefined &&
|
||||
onNamePoolTextChange ? (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-agent-name-pool"
|
||||
>
|
||||
Instance name pool
|
||||
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="words"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
disabled={disabled}
|
||||
id="edit-agent-name-pool"
|
||||
onChange={(event) => onNamePoolTextChange(event.target.value)}
|
||||
placeholder="Birch, Compass, Ridge, Thistle"
|
||||
spellCheck={false}
|
||||
value={namePoolText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Env vars */}
|
||||
<EnvVarsEditor
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from "react";
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import type { UpdatePersonaInput } from "@/shared/api/types";
|
||||
import { useUpdatePersonaMutation } from "@/features/agents/hooks";
|
||||
import {
|
||||
formatPersonaNamePoolText,
|
||||
parsePersonaNamePoolText,
|
||||
} from "./personaDialogState";
|
||||
|
||||
/**
|
||||
* Manages name pool state for AgentInstanceEditDialog.
|
||||
*
|
||||
* When a linked persona exists the dialog exposes its name pool so users can
|
||||
* edit instance names without navigating to the definition dialog. This hook
|
||||
* owns the text state, seeds it once per open session from the persona, and
|
||||
* exposes a `saveIfChanged` function the dialog calls on submit.
|
||||
*/
|
||||
export function useNamePoolEdit({
|
||||
open,
|
||||
linkedPersona,
|
||||
}: {
|
||||
open: boolean;
|
||||
linkedPersona: AgentPersona | null;
|
||||
}) {
|
||||
const updatePersonaMutation = useUpdatePersonaMutation();
|
||||
const [namePoolText, setNamePoolText] = React.useState("");
|
||||
const seededRef = React.useRef(false);
|
||||
|
||||
// Reset seed guard when the dialog closes.
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
seededRef.current = false;
|
||||
setNamePoolText("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Seed once per open session when the linked persona resolves.
|
||||
React.useEffect(() => {
|
||||
if (!open || seededRef.current || linkedPersona === null) {
|
||||
return;
|
||||
}
|
||||
setNamePoolText(formatPersonaNamePoolText(linkedPersona.namePool));
|
||||
seededRef.current = true;
|
||||
}, [open, linkedPersona]);
|
||||
|
||||
/**
|
||||
* Saves the name pool to the linked definition if it has changed.
|
||||
* No-op for built-in personas or when the text is unchanged.
|
||||
*/
|
||||
async function saveIfChanged() {
|
||||
if (
|
||||
linkedPersona == null ||
|
||||
linkedPersona.isBuiltIn ||
|
||||
namePoolText === formatPersonaNamePoolText(linkedPersona.namePool)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const input: UpdatePersonaInput = {
|
||||
id: linkedPersona.id,
|
||||
displayName: linkedPersona.displayName,
|
||||
avatarUrl: linkedPersona.avatarUrl ?? undefined,
|
||||
systemPrompt: linkedPersona.systemPrompt,
|
||||
runtime: linkedPersona.runtime ?? undefined,
|
||||
model: linkedPersona.model ?? undefined,
|
||||
provider: linkedPersona.provider ?? undefined,
|
||||
namePool: parsePersonaNamePoolText(namePoolText),
|
||||
envVars: linkedPersona.envVars,
|
||||
};
|
||||
await updatePersonaMutation.mutateAsync(input);
|
||||
}
|
||||
|
||||
return {
|
||||
namePoolText,
|
||||
setNamePoolText,
|
||||
isPending: updatePersonaMutation.isPending,
|
||||
saveIfChanged,
|
||||
};
|
||||
}
|
||||
@@ -402,12 +402,8 @@ export function UserProfilePanel({
|
||||
});
|
||||
|
||||
const handleEditAgent = React.useCallback(() => {
|
||||
if (resolvedPersona) {
|
||||
setPersonaDialogState(editPersonaDialogState(resolvedPersona));
|
||||
return;
|
||||
}
|
||||
setEditAgentOpen(true);
|
||||
}, [resolvedPersona]);
|
||||
}, []);
|
||||
|
||||
const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } =
|
||||
useProfileAgentDeletion({
|
||||
|
||||
Reference in New Issue
Block a user