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:
Duncan
2026-08-05 17:03:12 -04:00
co-authored by Will Pfleger
parent ed4b3e7afa
commit 9ad809d2f7
4 changed files with 141 additions and 19 deletions
@@ -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({