feat(desktop): Phase 1 R2 — merged agent edit surface (one form, one coordinator)

Build the merged edit surface the spec requires. One dialog (AgentEditMergedDialog)
handles all R1–R7 contexts via a single AgentFormModel, rendering D+I+L sections
together on one form and submitting every save through the Artifact 3 coordinator.

Key changes:

- AgentEditMergedDialog: replaces refetchStores+handleSubmit inline block with
  useAgentEditMergedSubmit hook; moves hook call before canSubmit so isSaving
  is in scope. For instance-with-definition context, renders both D-section
  (definition fields: name, prompt, runtime, model, provider) and I-section
  (instance fields: name, respond-to, harness, env overlay, parallelism) on one
  form. All saves go through emitAgentFormDiff → runAgentSaveCoordinator
  (observed-state settlement for D+I+L together).

- AgentEditMergedDialogDSection: removes !showInst guards from runtime, model,
  and provider fields. These are D-fields and must render for all definition
  contexts, including instance-with-definition (writes to the definition via
  coordinator). Removes the now-unused showInst prop.

- AgentEditMergedDialogInstanceSection: gates model, provider, and API key
  fields on !showDef. When a definition is present, model/provider are D-fields
  shown in the D-section; the I-section renders instance-specific fields only.

- agentFormModel.ts: adds instanceEnvVars field (row 8 contract: per-instance
  env overlay that does not wholesale-replace the definition env).

- agentDialogRouting.test.mjs: adds tests verifying hasDefinitionContext/
  hasInstanceContext predicates and that the D/I section components are exported
  as functions (structural guards for the merged surface).

AgentInstanceEditDialog has no production consumer on any R1–R7 route. The
mode='instance-edit' path in AgentDialog.tsx is unreachable from all production
edit surfaces (UserProfilePanel, AgentsView, AgentManagementDialogs all use
AgentEditDialog with ctx, not AgentDialog with mode='instance-edit').

File-size gate: AgentEditMergedDialog.tsx split count = 981 (< 1000 ceiling).
Full suite: 4562 tests, 0 failures.

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-08 14:20:00 -04:00
parent 6f0d07f638
commit 45d76bbb90
8 changed files with 2283 additions and 324 deletions

View File

@@ -367,7 +367,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => {
BOB,
);
assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer");
assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`);
assert.equal(personas[0].isActive, false);
});
@@ -388,7 +388,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => {
ALICE,
);
assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer");
assert.equal(personas[0].id, `catalog:${BOB}:reviewer`);
assert.equal(personas[0].isActive, false);
});

View File

@@ -1,83 +1,46 @@
/**
* AgentEditDialog.tsx — Merged agent edit surface (Phase 1).
* AgentEditDialog.tsx — Merged agent edit surface entry point (Phase 1 Round 2).
*
* Collapses R1–R7 from the Phase 0 spec Artifact 2 onto a single dialog.
* R8 (duplicate = create seed) and R9 (start-on-launch quick toggle) are
* kept as separate paths and are not affected here.
* Collapses R1–R7 onto a single dialog: AgentEditMergedDialog.
*
* Architecture:
* instance-with-definition or instance-only:
* → AgentInstanceEditDialog (all I/L fields, correct edit-agent-dialog
* testid, linked-runtime awareness, auto-restart setter, saved-while-
* stopped affordance).
* All contexts — instance-with-definition, instance-only, definition-only —
* render the ONE merged surface. AgentEditMergedDialog seeds from
* AgentFormModel, renders D+I+L sections per context, and submits every
* save through the Artifact 3 coordinator (observed-state settlement for D,
* I, and per-policy writes).
*
* definition-only (zero-instance definitions — R5 library card, R6 review):
* → AgentDefinitionDialog, wired through the Artifact 3 coordinator.
* Team-managed definitions render fields disabled with a "Managed by
* team" note (D-fields structurally unemittable per the spec).
*
* The Artifact 3 coordinator is invoked for the definition-only path only.
* Instance paths use AgentInstanceEditDialog's own well-tested save path.
*
* S5 "Instances" vocabulary rename is deferred to Phase 3. Built-in agents
* are fully editable (Artifact 1 corrected matrix).
* AgentDefinitionDialog survives for create flows (R8 duplicate seed, import)
* only — it is NOT a consumer of any R1–R7 edit route.
* AgentInstanceEditDialog has no production consumer on any R1–R7 route.
*/
import * as React from "react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import {
managedAgentsQueryKey,
personasQueryKey,
useAcpRuntimesQuery,
useStartManagedAgentMutation,
useUpdatePersonaMutation,
} from "@/features/agents/hooks";
import { useUpdatePersonaAndPublishMutation } from "@/features/agents/lib/usePersonaCatalogRelay";
import { runAgentSaveCoordinator } from "./agentSaveCoordinator";
import type { ManagedAgent } from "@/shared/api/types";
import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
import type { AgentEditContext } from "./agentFormModel";
export type { AgentEditContext };
import { isDefinitionReadOnly } from "./agentFormModel";
import type {
AgentPersona,
CreatePersonaInput,
ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
import type { AgentDefinitionSubmitOptions } from "./AgentDefinitionDialog";
import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning";
import { AgentDefinitionDialog } from "./AgentDefinitionDialog";
import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog";
import { AgentRunLocationProvider } from "./AgentRunLocationContext";
import { editPersonaDialogState } from "./personaDialogState";
import { AgentEditMergedDialog } from "./AgentEditMergedDialog";
// ── Types ─────────────────────────────────────────────────────────────────────
export type AgentEditDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Edit context: which entity or entities to edit.
*/
/** Edit context: which entity or entities to edit. */
ctx: AgentEditContext;
/**
* Optional field to focus when the dialog opens from a card deep-link
* (instance paths only — AgentInstanceEditDialog honors this).
* Optional field to focus when the dialog opens from a card deep-link.
*/
initialFocus?: EditAgentFocusTarget;
onUpdated?: (agent: ManagedAgent) => void;
/**
* Optional pre-save validator (R6 origin permission check).
* Fires before the coordinator's step 0 on definition-only paths.
* Return a non-null string to abort with an error toast; return null to proceed.
*/
onValidate?: () => string | null;
/**
* Optional initial-values override for the definition fields. When provided,
* these values are merged into the seeded form (used in R6 review mode to
* pre-fill agent-requested changes for user approval).
* Optional initial-values override for definition fields (R6 review mode).
*/
initialValueOverrides?: Partial<{
displayName: string;
@@ -99,226 +62,31 @@ export function AgentEditDialog({
initialValueOverrides,
initialFocus,
}: AgentEditDialogProps) {
// ── Instance paths: delegate entirely to AgentInstanceEditDialog ──────────
//
// AgentInstanceEditDialog renders the full I+L field set (respondTo/allowlist,
// parallelism, env vars, harness pin, auto-restart, start-on-launch, instance
// name) and owns a well-tested save path. Route all instance-present contexts
// here so no I/L field is accidentally omitted.
//
// Wrap in AgentRunLocationProvider so RespondToField's access warning can
// name the machine the agent actually runs on (mirrors AgentDialog's instance-
// edit arm — backend → run location for the remote-backend warning path).
if (ctx.kind === "instance-with-definition" || ctx.kind === "instance-only") {
// All contexts route to the merged surface. The run-location provider wraps
// instance-present paths so RespondToField's remote-backend warning can name
// the machine the agent actually runs on.
const inst = ctx.kind !== "definition-only" ? ctx.instance : null;
const runLocation = inst ? runLocationForBackend(inst.backend) : null;
const inner = (
<AgentEditMergedDialog
ctx={ctx}
initialFocus={initialFocus}
initialValueOverrides={initialValueOverrides}
onOpenChange={onOpenChange}
onUpdated={onUpdated}
onValidate={onValidate}
open={open}
/>
);
if (inst) {
return (
<AgentRunLocationProvider
runLocation={runLocationForBackend(ctx.instance.backend)}
>
<AgentInstanceEditDialog
agent={ctx.instance}
initialFocus={initialFocus}
open={open}
onOpenChange={onOpenChange}
onUpdated={onUpdated}
// R4 back-door deleted: avatar lives on the merged surface (definition
// section is visible in the profile panel when a definition exists).
onEditLinkedPersona={undefined}
/>
<AgentRunLocationProvider runLocation={runLocation}>
{inner}
</AgentRunLocationProvider>
);
}
// ── Definition-only path: AgentDefinitionDialog + Artifact 3 coordinator ──
return (
<AgentEditDefinitionOnlyDialog
ctx={ctx}
open={open}
onOpenChange={onOpenChange}
onUpdated={onUpdated}
onValidate={onValidate}
initialValueOverrides={initialValueOverrides}
/>
);
}
// ── Definition-only edit: coordinator-wired AgentDefinitionDialog ─────────────
//
// Separated into its own component so React hook ordering is stable across
// the instance/definition-only branch above. All hooks run unconditionally here.
function AgentEditDefinitionOnlyDialog({
ctx,
open,
onOpenChange,
// onUpdated is intentionally omitted: definition-only has no ManagedAgent to
// return. Instance paths surface onUpdated via AgentInstanceEditDialog.
onValidate,
initialValueOverrides,
}: Omit<AgentEditDialogProps, "initialFocus" | "ctx"> & {
ctx: Extract<AgentEditContext, { kind: "definition-only" }>;
}) {
const queryClient = useQueryClient();
const updatePersonaMutation = useUpdatePersonaMutation();
const startMutation = useStartManagedAgentMutation();
const runtimesQuery = useAcpRuntimesQuery({ enabled: open });
// Community id is required for publish-aware mutations; null → non-catalog edit.
const updatePersonaAndPublishMutation =
useUpdatePersonaAndPublishMutation(null);
const [isSaving, setIsSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState<Error | null>(null);
// Reset error on open
React.useEffect(() => {
if (open) setSaveError(null);
}, [open]);
const def = ctx.definition;
const runtimes = runtimesQuery.data ?? [];
const runtimeCatalogStatus = runtimesQuery.isLoading
? ("loading" as const)
: runtimesQuery.isError
? ("error" as const)
: ("ready" as const);
// Team-managed: D-fields render disabled (structurally unemittable per spec).
const defReadOnly = isDefinitionReadOnly(ctx);
// ── Settlement helper ──────────────────────────────────────────────────────
async function refetchStores(): Promise<{
persona: AgentPersona | null;
agent: ManagedAgent | null;
}> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
const personas =
queryClient.getQueryData<AgentPersona[]>(personasQueryKey) ?? [];
return {
persona: personas.find((p) => p.id === def.id) ?? null,
agent: null, // definition-only: no instance to settle
};
}
// ── onSubmit ── called by AgentDefinitionDialog when the user clicks Save ─
async function handleSubmit(
input: CreatePersonaInput | UpdatePersonaInput,
options: AgentDefinitionSubmitOptions,
): Promise<unknown> {
// AgentEditDialog is always in edit mode — the input will always be an
// UpdatePersonaInput. If for any reason a CreatePersonaInput arrives
// (e.g. dialog misconfiguration), bail out safely.
if (!("id" in input)) {
console.error(
"[AgentEditDialog] Received CreatePersonaInput in edit mode — ignoring.",
);
return undefined;
}
// Pre-save validation (e.g. R6 origin-permission check).
if (onValidate) {
const validationError = onValidate();
if (validationError) {
toast.error(validationError);
return undefined;
}
}
// Team-managed: D-fields are structurally unemittable; form renders read-only.
// Guard here too so a misconfigured call path cannot bypass the UI gate.
if (defReadOnly) {
return undefined;
}
setSaveError(null);
setIsSaving(true);
try {
const personaInput: UpdatePersonaInput = input;
const success = await runAgentSaveCoordinator({
ctx,
personaInput,
agentInput: null,
policySets: [],
publishCatalogUpdates: options.publishCatalogUpdates,
runtimes: runtimes.length > 0 ? runtimes : undefined,
updatePersona: (p) => updatePersonaMutation.mutateAsync(p),
updatePersonaAndPublish: (p) =>
updatePersonaAndPublishMutation.mutateAsync(p),
updateManagedAgent: (_a) => {
throw new Error("No instance in definition-only context");
},
setAutoRestart: (_pubkey, _value) => Promise.resolve(),
setStartOnAppLaunch: (_pubkey, _value) => Promise.resolve(),
refetchStores,
onDone: () => onOpenChange(false),
onSavedWhileStopped: (agent) => {
// definition-only: no instance, but preserve the affordance contract
const savedName = agent.name;
toast(`${savedName} saved while stopped.`, {
action: {
label: "Start now",
onClick: () => {
startMutation.mutate(agent.pubkey, {
onSuccess: () => toast.success(`${savedName} started.`),
onError: (err) =>
toast.error(
err instanceof Error
? `${savedName} failed to start: ${err.message}`
: `${savedName} failed to start.`,
),
});
},
},
});
},
});
if (!success) {
setSaveError(
new Error("Some changes may not have persisted. Reopen to retry."),
);
}
// definition-only: no ManagedAgent to surface; onUpdated is not called.
// Instance paths call onUpdated via AgentInstanceEditDialog directly.
} finally {
setIsSaving(false);
}
return undefined;
}
// ── Build initial values for AgentDefinitionDialog ─────────────────────────
const baseDialogState = editPersonaDialogState(def);
const dialogState =
baseDialogState && initialValueOverrides
? {
...baseDialogState,
initialValues: {
...baseDialogState.initialValues,
...initialValueOverrides,
},
}
: baseDialogState;
return (
<AgentDefinitionDialog
description={dialogState?.description ?? ""}
error={saveError}
initialValues={dialogState?.initialValues ?? null}
isPending={isSaving}
definitionReadOnly={defReadOnly}
onOpenChange={onOpenChange}
onSubmit={handleSubmit}
open={open}
publishCatalogUpdatesOnSave={def.shared && !defReadOnly}
runtimes={runtimes}
runtimeCatalogStatus={runtimeCatalogStatus}
submitLabel={dialogState?.submitLabel ?? "Save changes"}
title={dialogState?.title ?? `Edit ${def.displayName}`}
/>
);
return inner;
}

View File

@@ -0,0 +1,980 @@
/**
* AgentEditMergedDialog — single merged edit surface for R1–R7.
*
* instance-with-definition → D + I + L sections on one form
* instance-only → I + L sections
* definition-only → D sections only
*
* Submit: emitAgentFormDiff → runAgentSaveCoordinator (observed-state settlement).
* Submit logic extracted to useAgentEditMergedSubmit; section JSX to
* AgentEditMergedDialogDSection / AgentEditMergedDialogInstanceSection.
*/
import * as React from "react";
import {
useAcpRuntimesQuery,
useBakedBuildEnvKeysQuery,
usePersonasQuery,
useStartManagedAgentMutation,
useUpdateManagedAgentMutation,
useUpdatePersonaMutation,
} from "@/features/agents/hooks";
import { useUpdatePersonaAndPublishMutation } from "@/features/agents/lib/usePersonaCatalogRelay";
import type { ManagedAgent, RespondToMode } from "@/shared/api/types";
import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
import { Button } from "@/shared/ui/button";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { Dialog } from "@/shared/ui/dialog";
import {
seedAgentFormModel,
hasDefinitionContext,
hasInstanceContext,
isDefinitionReadOnly,
editContextDefinition,
editContextInstance,
type AgentEditContext,
} from "./agentFormModel";
import { useAgentEditMergedSubmit } from "./useAgentEditMergedSubmit";
import { AgentEditMergedDSection } from "./AgentEditMergedDialogDSection";
import { AgentEditMergedInstanceSection } from "./AgentEditMergedDialogInstanceSection";
import {
ADD_CUSTOM_HARNESS_OPTION,
runtimeDropdownAction,
usePendingHarnessSelection,
} from "./addCustomHarness";
import {
AUTO_PROVIDER_DROPDOWN_VALUE,
BLOCK_BUILD_HIDDEN_PROVIDER_IDS,
CUSTOM_PROVIDER_DROPDOWN_VALUE,
formatRuntimeOptionLabel,
getDefaultPersonaRuntime,
getPersonaProviderOptions,
isMissingRequiredDropdownField,
NO_RUNTIME_DROPDOWN_VALUE,
buildPersonaRuntimeDropdownOptions,
runtimeSupportsLlmProviderSelection,
shouldClearKnownModelForSelectionScope,
sortPersonaRuntimes,
type PersonaDropdownOption,
} from "./agentConfigOptions";
import {
selectionOnModelDropdownChange,
selectionOnProviderDropdownChange,
selectionOnRuntimeChange,
type RuntimeModelProviderSelection,
} from "./runtimeModelProviderSelection";
import {
MODEL_DISCOVERY_LOADING_VALUE,
usePersonaModelDiscovery,
} from "./usePersonaModelDiscovery";
import { useAgentDialogDefaults } from "./useAgentDialogDefaults";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { useRequiredCredentialState } from "./useRequiredCredentialState";
import { getBakedProviderInheritLabel } from "./bakedEnvHelpers";
import { getProviderApiKeyEnvVar } from "./agentConfigOptions";
import { resolveModelFieldStatusMessage } from "./agentConfigControls";
import { useAgentAccessOwnerOnlyQuery } from "@/features/agents/useAgentAccessOwnerOnly";
import {
resolveInheritedRuntimeSubmission,
isEditAgentProviderSaveValid,
} from "./personaRuntimeModel";
import type { EnvVarsValue } from "./EnvVarsEditor";
import { formatPersonaNamePoolText } from "./personaDialogState";
import { useRuntimeFileConfigQuery } from "../hooks";
// ── Types / Component ────────────────────────────────────────────────────────
export type AgentEditMergedDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
ctx: AgentEditContext;
/** Optional field to focus when the dialog opens from a card deep-link. */
initialFocus?: EditAgentFocusTarget;
onUpdated?: (agent: ManagedAgent) => void;
/**
* Optional pre-save validator (R6 origin permission check).
* Return a non-null string to abort with an error toast; return null to proceed.
*/
onValidate?: () => string | null;
/**
* Optional initial-values override for definition fields (R6 review mode).
*/
initialValueOverrides?: Partial<{
displayName: string;
systemPrompt: string;
runtime: string | undefined;
provider: string | undefined;
model: string | undefined;
}>;
};
export function AgentEditMergedDialog({
open,
onOpenChange,
ctx,
initialFocus,
onUpdated,
onValidate,
initialValueOverrides,
}: AgentEditMergedDialogProps) {
const def = editContextDefinition(ctx);
const inst = editContextInstance(ctx);
const showDef = hasDefinitionContext(ctx);
const showInst = hasInstanceContext(ctx);
const defReadOnly = isDefinitionReadOnly(ctx);
// ── Queries / mutations ───────────────────────────────────────────────────
const runtimesQuery = useAcpRuntimesQuery({ enabled: open });
const runtimes = runtimesQuery.data ?? [];
const runtimeCatalogStatus = runtimesQuery.isLoading
? ("loading" as const)
: runtimesQuery.isError
? ("error" as const)
: ("ready" as const);
const updatePersonaMutation = useUpdatePersonaMutation();
const updatePersonaAndPublishMutation =
useUpdatePersonaAndPublishMutation(null);
const updateMutation = useUpdateManagedAgentMutation();
const startMutation = useStartManagedAgentMutation();
const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open });
const { data: agentAccessOwnerOnly } = useAgentAccessOwnerOnlyQuery({
enabled: open && showInst,
});
const personasQuery = usePersonasQuery();
const linkedPersona = React.useMemo(
() =>
inst?.personaId
? (personasQuery.data?.find((p) => p.id === inst.personaId) ?? null)
: null,
[inst?.personaId, personasQuery.data],
);
// ── Form state — seeded from AgentFormModel ───────────────────────────────
// D-fields
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const [systemPrompt, setSystemPrompt] = React.useState("");
const [namePoolText, setNamePoolText] = React.useState("");
// Runtime/model/provider
const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom");
const [model, setModel] = React.useState("");
const [provider, setProvider] = React.useState("");
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
const [isCustomProviderEditing, setIsCustomProviderEditing] =
React.useState(false);
const runtimeTouched = React.useRef(false);
// I-fields
const [instanceName, setInstanceName] = React.useState("");
const [respondTo, setRespondTo] = React.useState<RespondToMode>(
inst?.respondTo ?? "anyone",
);
const [respondToAllowlist, setRespondToAllowlist] = React.useState<string[]>(
inst?.respondToAllowlist ?? [],
);
const [parallelism, setParallelism] = React.useState(
String(inst?.parallelism ?? 1),
);
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(
def?.envVars ?? inst?.envVars ?? {},
);
const [instanceEnvVars, setInstanceEnvVars] = React.useState<EnvVarsValue>(
inst?.envVars ?? {},
);
// Harness-pin (I-field)
const [agentCommand, setAgentCommand] = React.useState(
inst?.agentCommand ?? "",
);
const [inheritHarness, setInheritHarness] = React.useState(
inst != null && inst.personaId != null && inst.agentCommandOverride == null,
);
const [agentArgs, setAgentArgs] = React.useState(
inst?.agentArgs.join(",") ?? "",
);
const [acpCommand, setAcpCommand] = React.useState(inst?.acpCommand ?? "");
const [originalAgentCommand, setOriginalAgentCommand] = React.useState(
inst?.agentCommand ?? "",
);
// L-fields
const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] =
React.useState(inst?.autoRestartOnConfigChange ?? false);
// UI state
const [showAdvancedFields, setShowAdvancedFields] = React.useState(false);
const [isAvatarUploadPending, setIsAvatarUploadPending] =
React.useState(false);
const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false);
const aiDefaultsTriggerRef = React.useRef<HTMLButtonElement>(null);
const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false);
// ── Seed form on open ─────────────────────────────────────────────────────
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only re-seed on open/entity-switch
React.useEffect(() => {
if (!open) return;
const seed = seedAgentFormModel(ctx);
setDisplayName(seed.displayName);
setAvatarUrl(seed.avatarUrl);
setSystemPrompt(seed.systemPrompt);
setNamePoolText(formatPersonaNamePoolText(seed.namePool ?? []));
setModel(seed.model ?? "");
setProvider(seed.provider ?? "");
setIsCustomModelEditing(false);
setIsCustomProviderEditing(false);
setRespondTo((seed.respondTo ?? "anyone") as RespondToMode);
setRespondToAllowlist(seed.respondToAllowlist);
setParallelism(String(seed.parallelism ?? 1));
setEnvVars(seed.envVars);
setInstanceEnvVars(seed.instanceEnvVars ?? {});
setInstanceName(seed.instanceName ?? "");
setAutoRestartOnConfigChange(seed.autoRestartOnConfigChange ?? false);
setShowAdvancedFields(false);
setIsAvatarUploadPending(false);
runtimeTouched.current = false;
if (inst) {
setAgentCommand(inst.agentCommand);
setOriginalAgentCommand(inst.agentCommand);
setInheritHarness(
inst.personaId != null && inst.agentCommandOverride == null,
);
setAgentArgs(inst.agentArgs.join(","));
setAcpCommand(inst.acpCommand);
const matched =
runtimes.find((r) => r.command?.trim() === inst.agentCommand.trim()) ??
runtimes.find((r) => r.id === inst.agentCommand.trim());
setSelectedRuntimeId(matched ? matched.id : "custom");
} else if (def) {
// definition-only: seed runtime from definition
const defRuntimeId = def.runtime?.trim() ?? "";
setSelectedRuntimeId(defRuntimeId || "custom");
}
// Apply any caller-supplied initial value overrides (R6 review mode).
if (initialValueOverrides) {
if (initialValueOverrides.displayName !== undefined)
setDisplayName(initialValueOverrides.displayName);
if (initialValueOverrides.systemPrompt !== undefined)
setSystemPrompt(initialValueOverrides.systemPrompt);
if (initialValueOverrides.model !== undefined)
setModel(initialValueOverrides.model ?? "");
if (initialValueOverrides.provider !== undefined)
setProvider(initialValueOverrides.provider ?? "");
if (initialValueOverrides.runtime !== undefined)
setSelectedRuntimeId(initialValueOverrides.runtime ?? "custom");
}
}, [open, inst?.pubkey, def?.id]);
// Re-derive runtime id when catalog loads
React.useEffect(() => {
if (!open || runtimeTouched.current || runtimes.length === 0 || !inst)
return;
const matched =
runtimes.find((r) => r.command?.trim() === inst.agentCommand.trim()) ??
runtimes.find((r) => r.id === inst.agentCommand.trim());
if (matched) setSelectedRuntimeId(matched.id);
}, [open, runtimes, inst?.agentCommand, inst]);
// ── Runtime / model / provider machinery ──────────────────────────────────
const sortedRuntimes = React.useMemo(
() => sortPersonaRuntimes(runtimes),
[runtimes],
);
const selectedRuntime = runtimes.find((r) => r.id === selectedRuntimeId);
const runtimeDropdownValue = selectedRuntimeId || NO_RUNTIME_DROPDOWN_VALUE;
// Instance-path runtime dropdown options
const instanceRuntimeDropdownOptions: PersonaDropdownOption[] =
React.useMemo(() => {
const options: PersonaDropdownOption[] = [
...sortedRuntimes.map((r) => ({
label: formatRuntimeOptionLabel(r),
value: r.id,
})),
{ label: "Custom command", value: "custom" },
];
if (
selectedRuntimeId &&
selectedRuntimeId !== "custom" &&
!options.some((o) => o.value === selectedRuntimeId)
) {
options.push({
label: `${selectedRuntimeId} (current)`,
value: selectedRuntimeId,
});
}
options.push(ADD_CUSTOM_HARNESS_OPTION);
return options;
}, [sortedRuntimes, selectedRuntimeId]);
// Definition path: uses buildPersonaRuntimeDropdownOptions (supports loading/blank)
const {
blankRuntimeOptionLabel: defBlankLabel,
runtimeDropdownOptions: defRuntimeDropdownOptions,
} = React.useMemo(() => {
const result = buildPersonaRuntimeDropdownOptions({
defaultRuntimeId: getDefaultPersonaRuntime(runtimes)?.id,
isCreateMode: false,
runtime: selectedRuntimeId === "custom" ? "" : selectedRuntimeId,
runtimes,
runtimesLoading: runtimeCatalogStatus === "loading",
});
result.runtimeDropdownOptions.push(ADD_CUSTOM_HARNESS_OPTION);
return result;
}, [runtimes, runtimeCatalogStatus, selectedRuntimeId]);
const originalRuntimeSupportsProvider = React.useMemo(() => {
if (!inst) return false;
const originalCommand = originalAgentCommand.trim();
const matched =
runtimes.find((r) => r.command?.trim() === originalCommand) ??
runtimes.find((r) => r.id === originalCommand);
return runtimeSupportsLlmProviderSelection(matched?.id ?? "");
}, [runtimes, originalAgentCommand, inst]);
// Prospective runtime after submit (for gate + discovery)
const prospectiveRuntimeId = React.useMemo(() => {
if (!inst) return selectedRuntimeId || "";
if (!inheritHarness) return selectedRuntime?.id ?? selectedRuntimeId;
const personaRuntimeId = linkedPersona?.runtime?.trim();
if (personaRuntimeId) {
return (
runtimes.find((r) => r.id === personaRuntimeId)?.id ?? personaRuntimeId
);
}
return (
runtimes.find((r) => r.command?.trim() === inst.agentCommand.trim())
?.id ??
runtimes.find((r) => r.id === inst.agentCommand.trim())?.id ??
getDefaultPersonaRuntime(runtimes)?.id ??
""
);
}, [
inheritHarness,
linkedPersona?.runtime,
runtimes,
inst?.agentCommand,
selectedRuntime?.id,
selectedRuntimeId,
inst,
]);
const llmProviderFieldVisible = runtimeSupportsLlmProviderSelection(
showInst ? prospectiveRuntimeId : selectedRuntimeId,
);
const prospectiveRuntime = runtimes.find(
(r) => r.id === prospectiveRuntimeId,
);
// Resolve inherited env / provider for credential gate
const inheritedEnvVars = linkedPersona?.envVars ?? {};
const inheritedSubmission = React.useMemo(() => {
if (!inst)
return { provider: provider || null, model: model || null, envVars };
return resolveInheritedRuntimeSubmission({
inheritHarness,
agentWasHarnessPinned: inst.agentCommandOverride != null,
provider,
personaProvider: linkedPersona?.provider ?? "",
model,
personaModel: linkedPersona?.model ?? null,
envVars: showDef ? envVars : instanceEnvVars,
personaEnvVars: inheritedEnvVars,
});
}, [
inheritHarness,
inst,
linkedPersona,
provider,
model,
envVars,
instanceEnvVars,
inheritedEnvVars,
showDef,
]);
const {
globalConfig,
inheritedDefaults: {
provider: inheritedProviderDefault,
model: inheritedModelDefault,
},
inheritedEnvVars: inheritedEnvVarsForAdvanced,
} = useAgentDialogDefaults({ inheritedEnvVars, open });
const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } =
useRequiredCredentialState({
open: open && showInst,
prospectiveRuntimeId,
provider: inheritedSubmission.provider ?? "",
globalProvider: inheritedProviderDefault.value,
envVars: inheritedSubmission.envVars,
globalEnvVars: globalConfig.env_vars,
personaEnvVars: inheritHarness ? inheritedEnvVars : undefined,
});
// runtimeFileConfig is used by localModeGate (definition-only). For the
// merged surface, the credential gate drives Save readiness on instance paths.
useRuntimeFileConfigQuery(
showInst ? prospectiveRuntimeId : selectedRuntimeId,
{ enabled: open },
);
// Model discovery
const effectiveProvider =
(inheritedSubmission.provider ?? "").trim() ||
inheritedProviderDefault.value;
const providerForDiscovery = llmProviderFieldVisible ? effectiveProvider : "";
const envVarsForDiscovery = React.useMemo(
() => ({ ...globalConfig.env_vars, ...inheritedSubmission.envVars }),
[globalConfig.env_vars, inheritedSubmission.envVars],
);
const {
discoveredModelOptions,
modelDiscoveryLoading,
modelDiscoveryStatus,
} = usePersonaModelDiscovery({
envVars: envVarsForDiscovery,
isCustomProviderEditing,
modelFieldVisible: true,
open,
provider: providerForDiscovery,
selectedRuntime: showInst ? prospectiveRuntime : selectedRuntime,
});
const hideProviderIds = React.useMemo(
() =>
(bakedEnvKeys ?? []).includes("BUZZ_AGENT_PROVIDER")
? BLOCK_BUILD_HIDDEN_PROVIDER_IDS
: new Set<string>(),
[bakedEnvKeys],
);
const providerOptions = getPersonaProviderOptions(
provider.trim(),
showInst ? (selectedRuntime?.id ?? "") : selectedRuntimeId,
inheritedProviderDefault.source === "global"
? inheritedProviderDefault.value
: "",
hideProviderIds,
);
const providerSelectValue = isCustomProviderEditing
? CUSTOM_PROVIDER_DROPDOWN_VALUE
: provider.trim() || AUTO_PROVIDER_DROPDOWN_VALUE;
const providerDropdownOptions: PersonaDropdownOption[] = [
...providerOptions.map((opt) => ({
label:
opt.id === "" && inheritedProviderDefault.source === "build"
? getBakedProviderInheritLabel(
inheritedProviderDefault.value,
providerOptions,
)
: opt.label,
value: opt.id || AUTO_PROVIDER_DROPDOWN_VALUE,
})),
{ label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE },
];
const {
isRelayMesh,
options: effectiveModelOptions,
selectValue: modelSelectValue,
showCustomInput: showCustomModelInput,
} = relayMeshModelPickerState({
discoveredOptions: discoveredModelOptions,
fallbackOptions: [
{ id: "", label: getDefaultLlmModelLabel(inheritedModelDefault.value) },
],
isCustomEditing: isCustomModelEditing,
model,
provider: providerForDiscovery,
});
const modelDropdownOptions = buildModelDropdownOptions({
allowCustom: !isRelayMesh,
globalModel: isRelayMesh ? undefined : inheritedModelDefault.value,
globalModelLabel: isRelayMesh
? undefined
: getDefaultLlmModelLabel(inheritedModelDefault.value),
loading: modelDiscoveryLoading && discoveredModelOptions === null,
loadingValue: MODEL_DISCOVERY_LOADING_VALUE,
options: effectiveModelOptions,
});
const modelStatusMessage = resolveModelFieldStatusMessage({
discoveredModelOptions,
loading: modelDiscoveryLoading,
status: modelDiscoveryStatus,
});
const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider);
const personaSatisfied =
providerApiKeyEnvVar != null &&
!(providerApiKeyEnvVar in (showInst ? instanceEnvVars : envVars)) &&
(inheritedEnvVars[providerApiKeyEnvVar] ?? "").length > 0;
const apiKeyFieldState = useProviderApiKeyFieldState({
bakedEnvKeys,
effectiveEnvVars: inheritedSubmission.envVars,
envVars: showInst ? instanceEnvVars : envVars,
fileSatisfiedEnvKeys,
globalEnvVars: globalConfig.env_vars,
personaSatisfied,
provider: effectiveProvider,
requiredEnvKeys,
});
const {
advancedRequiredEnvKeys,
inheritedLabel: apiKeyInheritedLabel,
isInherited: apiKeyIsInherited,
isRequired: apiKeyIsRequired,
secretEnvVar: topLevelSecretEnvVar,
value: apiKeyValue,
} = apiKeyFieldState;
// Clear model when provider scope changes
React.useEffect(() => {
if (
!open ||
isCustomModelEditing ||
!shouldClearKnownModelForSelectionScope({
model,
provider: providerForDiscovery,
runtime: showInst ? prospectiveRuntimeId : selectedRuntimeId,
})
)
return;
setModel("");
setIsCustomModelEditing(false);
}, [
isCustomModelEditing,
model,
open,
providerForDiscovery,
selectedRuntimeId,
prospectiveRuntimeId,
showInst,
]);
// ── Submit validity ────────────────────────────────────────────────────────
const modelRequired = React.useMemo(
() => isMissingRequiredDropdownField(undefined, model),
[model],
);
const providerRequired = React.useMemo(
() => isMissingRequiredDropdownField(undefined, provider),
[provider],
);
const providerValid = showInst
? isEditAgentProviderSaveValid({
llmProviderFieldVisible,
currentProvider: provider,
originalProvider: inst?.provider ?? null,
globalProvider: inheritedProviderDefault.value,
originalRuntimeSupportsProvider,
})
: true;
const parsedParallelism = parseInt(parallelism, 10);
// ── Submit hook (must come before canSubmit so isSaving is available) ─────
const {
isSaving,
saveError,
handleSubmit: _handleSubmit,
} = useAgentEditMergedSubmit({
ctx,
displayName,
avatarUrl,
systemPrompt,
namePoolText,
model,
provider,
respondTo,
respondToAllowlist,
parallelism,
parsedParallelism,
envVars,
instanceEnvVars,
instanceName,
autoRestartOnConfigChange,
selectedRuntimeId,
inheritHarness,
agentCommand,
agentArgs,
acpCommand,
showInst,
defReadOnly,
inheritedSubmissionProvider: inheritedSubmission.provider ?? null,
inheritedSubmissionEnvVars: inheritedSubmission.envVars as Record<
string,
string
>,
linkedPersonaEnvVars: linkedPersona?.envVars,
runtimes,
updatePersona: (p) => updatePersonaMutation.mutateAsync(p),
updatePersonaAndPublish: (p) =>
updatePersonaAndPublishMutation.mutateAsync(p),
updateManagedAgent: (input) => updateMutation.mutateAsync(input),
startMutate: (pubkey, cbs) => startMutation.mutate(pubkey, cbs),
onValidate,
onOpenChange,
onUpdated,
});
const canSubmit =
!isSaving &&
!isAvatarUploadPending &&
displayName.trim().length > 0 &&
(showInst
? providerValid &&
!requiredEnvKeyMissing &&
(parsedParallelism > 0 || parallelism === "") &&
(selectedRuntimeId !== "custom" ||
inheritHarness ||
agentCommand.trim().length > 0)
: true);
// ── selection helpers ──────────────────────────────────────────────────────
const selection: RuntimeModelProviderSelection = {
provider,
model,
isCustomProviderEditing,
isCustomModelEditing,
envVars: showInst ? instanceEnvVars : envVars,
};
function applySelection(next: RuntimeModelProviderSelection) {
setProvider(next.provider);
setModel(next.model);
setIsCustomProviderEditing(next.isCustomProviderEditing);
setIsCustomModelEditing(next.isCustomModelEditing);
if (showInst) {
setInstanceEnvVars(next.envVars);
} else {
setEnvVars(next.envVars);
}
}
function handleRuntimeDropdownChange(nextValue: string) {
const action = runtimeDropdownAction(nextValue);
if (action.kind === "add-custom-harness") {
setIsAddHarnessOpen(true);
return;
}
const nextRuntimeId = action.runtimeId;
const previousRuntimeId = selectedRuntimeId;
if (showInst) {
runtimeTouched.current = true;
const nextRuntime = runtimes.find((r) => r.id === nextRuntimeId);
const resolvedRuntimeId = nextRuntimeId || "custom";
setSelectedRuntimeId(resolvedRuntimeId);
if (resolvedRuntimeId === "custom" || nextRuntime?.command) {
setInheritHarness(false);
}
if (nextRuntime?.command) {
setAgentCommand(nextRuntime.command);
setAgentArgs(nextRuntime.defaultArgs.join(","));
}
} else {
setSelectedRuntimeId(nextRuntimeId || "custom");
}
applySelection(
selectionOnRuntimeChange(selection, {
previousRuntime: previousRuntimeId,
nextRuntime: nextRuntimeId,
nextRuntimeCanChooseProvider:
runtimeSupportsLlmProviderSelection(nextRuntimeId),
lockedRuntimeReset: "full",
}),
);
}
const selectSavedHarness = usePendingHarnessSelection(
runtimes,
handleRuntimeDropdownChange,
open,
);
function handleProviderDropdownChange(nextValue: string) {
const nextProvider =
nextValue === AUTO_PROVIDER_DROPDOWN_VALUE ? "" : nextValue;
if (nextProvider === "relay-mesh" && selectedRuntimeId !== "buzz-agent") {
handleRuntimeDropdownChange("buzz-agent");
}
const nextSelection = selectionOnProviderDropdownChange(selection, {
runtime:
nextProvider === "relay-mesh"
? "buzz-agent"
: (selectedRuntime?.id ?? selectedRuntimeId),
nextValue,
clearModelWhenApiKeyMissing: !showInst,
});
applySelection({
...nextSelection,
model: nextProvider === "relay-mesh" ? "auto" : nextSelection.model,
});
}
function handleModelDropdownChange(nextValue: string) {
applySelection(
selectionOnModelDropdownChange(selection, {
nextValue,
clearKnownModelOnCustomEntry: !showInst,
isModelCustom: false,
}),
);
}
// ── Focus target (R2) ──────────────────────────────────────────────────────
const normalizedFieldFocusFiredRef = React.useRef(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — reset guard on open/context-switch; pure ref mutation is correct
React.useEffect(() => {
normalizedFieldFocusFiredRef.current = false;
}, [open, inst?.pubkey, def?.id]);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — llmProviderFieldVisible is the availability signal; inst?.pubkey handles agent-switch
React.useEffect(() => {
if (!open || !initialFocus) return;
if (initialFocus.type !== "normalized_field") return;
if (normalizedFieldFocusFiredRef.current) return;
const targetId =
initialFocus.field === "provider"
? "edit-agent-llm-provider"
: "edit-agent-model";
const el = document.getElementById(targetId);
if (!(el instanceof HTMLElement)) return;
normalizedFieldFocusFiredRef.current = true;
const id = requestAnimationFrame(() => {
el.scrollIntoView({ block: "nearest" });
el.focus();
});
return () => cancelAnimationFrame(id);
}, [open, initialFocus, inst?.pubkey, llmProviderFieldVisible]);
// ── Render ─────────────────────────────────────────────────────────────────
function handleSubmit() {
void _handleSubmit(canSubmit);
}
const previewLabel = displayName.trim() || "Agent name";
const previewAvatarUrl = avatarUrl.trim() || null;
const dialogTitle = inst
? `Edit ${inst.name}`
: def
? `Edit ${def.displayName}`
: "Edit agent";
return (
<Dialog
onOpenChange={(next) => {
if (!isSaving) onOpenChange(next);
}}
open={open}
>
<ChooserDialogContent
className="max-w-3xl border-0"
contentClassName="pt-3"
data-testid="edit-agent-dialog"
footerClassName="border-t-0 pt-0"
headerClassName="pb-2"
title={dialogTitle}
footer={
<div className="flex w-full items-center justify-end gap-2">
<Button
disabled={isSaving || isAvatarUploadPending}
onClick={() => onOpenChange(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="edit-agent-dialog-submit"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
type="button"
>
{isSaving ? "Saving..." : "Save changes"}
</Button>
</div>
}
>
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
{/* ── Identity column: avatar + preview ────────────────────── */}
<div className="flex flex-col items-center gap-2">
<AgentCreationPreview
avatarUrl={previewAvatarUrl}
disabled={isSaving || isAvatarUploadPending || defReadOnly}
label={previewLabel}
onClearAvatar={() => setAvatarUrl("")}
onUploadPendingChange={setIsAvatarUploadPending}
onSelectAvatar={setAvatarUrl}
/>
</div>
{/* ── Main form column ──────────────────────────────────────── */}
<div className="space-y-5">
{/* Team-managed notice */}
{defReadOnly && showDef ? (
<p
className="text-sm text-muted-foreground"
data-testid="team-managed-notice"
>
This agent is managed by a team. Its configuration cannot be
edited here.
</p>
) : null}
{/* Built-in notice */}
{def?.isBuiltIn && !defReadOnly ? (
<p
className="text-sm text-muted-foreground"
data-testid="builtin-notice"
>
Built-in agent — fully editable.
</p>
) : null}
{/* ── D-section: Identity + Behavior + Runtime ──────────── */}
{showDef ? (
<AgentEditMergedDSection
defReadOnly={defReadOnly}
isSaving={isSaving}
displayName={displayName}
onDisplayNameChange={setDisplayName}
systemPrompt={systemPrompt}
onSystemPromptChange={setSystemPrompt}
runtimeCatalogStatus={runtimeCatalogStatus}
runtimeDropdownValue={runtimeDropdownValue}
defRuntimeDropdownOptions={defRuntimeDropdownOptions}
defBlankLabel={defBlankLabel}
onRuntimeChange={handleRuntimeDropdownChange}
llmProviderFieldVisible={llmProviderFieldVisible}
providerSelectValue={providerSelectValue}
providerDropdownOptions={providerDropdownOptions}
onProviderChange={handleProviderDropdownChange}
isCustomProviderEditing={isCustomProviderEditing}
provider={provider}
onProviderTextChange={setProvider}
modelSelectValue={modelSelectValue}
modelDropdownOptions={modelDropdownOptions}
onModelChange={handleModelDropdownChange}
modelDiscoveryLoading={modelDiscoveryLoading}
showCustomModelInput={showCustomModelInput}
model={model}
onModelTextChange={setModel}
modelStatusMessage={modelStatusMessage}
/>
) : null}
{/* ── I-section: Instance + Runtime (when instance present) ── */}
{showInst && inst ? (
<AgentEditMergedInstanceSection
inst={inst}
showDef={showDef}
defReadOnly={defReadOnly}
isSaving={isSaving}
runtimeCatalogStatus={runtimeCatalogStatus}
instanceName={instanceName}
onInstanceNameChange={setInstanceName}
respondTo={respondTo}
respondToAllowlist={respondToAllowlist}
onRespondToChange={setRespondTo}
onAllowlistChange={setRespondToAllowlist}
agentAccessOwnerOnly={agentAccessOwnerOnly}
selectedRuntimeId={selectedRuntimeId}
runtimeDropdownValue={runtimeDropdownValue}
instanceRuntimeDropdownOptions={instanceRuntimeDropdownOptions}
onRuntimeChange={handleRuntimeDropdownChange}
selectedRuntime={selectedRuntime}
inheritHarness={inheritHarness}
agentCommand={agentCommand}
onAgentCommandChange={setAgentCommand}
isAddHarnessOpen={isAddHarnessOpen}
onAddHarnessOpenChange={setIsAddHarnessOpen}
onSelectSavedHarness={selectSavedHarness}
llmProviderFieldVisible={llmProviderFieldVisible}
providerSelectValue={providerSelectValue}
providerDropdownOptions={providerDropdownOptions}
onProviderChange={handleProviderDropdownChange}
isCustomProviderEditing={isCustomProviderEditing}
provider={provider}
onProviderTextChange={setProvider}
providerRequired={providerRequired}
topLevelSecretEnvVar={topLevelSecretEnvVar}
apiKeyValue={apiKeyValue}
apiKeyIsInherited={apiKeyIsInherited}
apiKeyInheritedLabel={apiKeyInheritedLabel}
apiKeyIsRequired={apiKeyIsRequired}
effectiveProvider={effectiveProvider}
onApiKeyChange={(key, value) => {
setInstanceEnvVars((prev) => ({ ...prev, [key]: value }));
}}
modelSelectValue={modelSelectValue}
modelDropdownOptions={modelDropdownOptions}
onModelChange={handleModelDropdownChange}
modelDiscoveryLoading={modelDiscoveryLoading}
showCustomModelInput={showCustomModelInput}
model={model}
onModelTextChange={setModel}
modelStatusMessage={modelStatusMessage}
modelRequired={modelRequired}
inheritedSubmission={inheritedSubmission}
inheritedModelDefault={inheritedModelDefault}
inheritedProviderDefault={inheritedProviderDefault}
aiDefaultsOpen={aiDefaultsOpen}
onAiDefaultsOpenChange={setAiDefaultsOpen}
aiDefaultsTriggerRef={aiDefaultsTriggerRef}
showAdvancedFields={showAdvancedFields}
onShowAdvancedFieldsChange={setShowAdvancedFields}
acpCommand={acpCommand}
agentArgs={agentArgs}
autoRestartOnConfigChange={autoRestartOnConfigChange}
instanceEnvVars={instanceEnvVars}
fileSatisfiedEnvKeys={fileSatisfiedEnvKeys}
advancedRequiredEnvKeys={advancedRequiredEnvKeys}
initialFocus={initialFocus}
inheritedEnvVarsForAdvanced={inheritedEnvVarsForAdvanced}
linkedPersona={linkedPersona}
prospectiveRuntimeId={prospectiveRuntimeId}
prospectiveRuntime={prospectiveRuntime}
parallelism={parallelism}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAutoRestartChange={setAutoRestartOnConfigChange}
onEnvVarsChange={setInstanceEnvVars}
onInheritHarnessChange={setInheritHarness}
onParallelismChange={setParallelism}
systemPrompt={systemPrompt}
onSystemPromptChange={setSystemPrompt}
/>
) : null}
{/* Save error */}
{saveError ? (
<p className="text-sm text-destructive">{saveError.message}</p>
) : null}
</div>
</div>
</ChooserDialogContent>
</Dialog>
);
}
// ── Local helpers ─────────────────────────────────────────────────────────────
function getDefaultLlmModelLabel(model: string): string {
return model ? `Default (${model})` : "Default model";
}

View File

@@ -0,0 +1,242 @@
/**
* AgentEditMergedDialogDSection.tsx — Definition-field section for the merged edit surface.
*
* Renders agent name, system prompt, and (for definition-only contexts)
* runtime, LLM provider, and model. Team-managed fields render read-only.
*
* Extracted from AgentEditMergedDialog to satisfy the desktop file-size gate.
*/
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import {
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
type PersonaDropdownOption,
} from "./agentConfigOptions";
import { AgentHarnessField } from "./AgentHarnessField";
import { PersonaDropdownField } from "./PersonaDropdownField";
// ── Props ─────────────────────────────────────────────────────────────────────
export type AgentEditMergedDSectionProps = {
/** When true definition fields are team-managed and rendered read-only. */
defReadOnly: boolean;
isSaving: boolean;
// Identity
displayName: string;
onDisplayNameChange: (value: string) => void;
// Behavior
systemPrompt: string;
onSystemPromptChange: (value: string) => void;
// Runtime (definition-only — hidden when showInst)
runtimeCatalogStatus: "loading" | "error" | "ready";
runtimeDropdownValue: string;
defRuntimeDropdownOptions: PersonaDropdownOption[];
defBlankLabel: string;
onRuntimeChange: (value: string) => void;
// LLM provider (definition-only — hidden when showInst)
llmProviderFieldVisible: boolean;
providerSelectValue: string;
providerDropdownOptions: PersonaDropdownOption[];
onProviderChange: (value: string) => void;
isCustomProviderEditing: boolean;
provider: string;
onProviderTextChange: (value: string) => void;
// Model (definition-only — hidden when showInst)
modelSelectValue: string;
modelDropdownOptions: PersonaDropdownOption[];
onModelChange: (value: string) => void;
modelDiscoveryLoading: boolean;
showCustomModelInput: boolean;
model: string;
onModelTextChange: (value: string) => void;
modelStatusMessage: string | null;
};
// ── Component ─────────────────────────────────────────────────────────────────
export function AgentEditMergedDSection({
defReadOnly,
isSaving,
displayName,
onDisplayNameChange,
systemPrompt,
onSystemPromptChange,
runtimeCatalogStatus,
runtimeDropdownValue,
defRuntimeDropdownOptions,
defBlankLabel,
onRuntimeChange,
llmProviderFieldVisible,
providerSelectValue,
providerDropdownOptions,
onProviderChange,
isCustomProviderEditing,
provider,
onProviderTextChange,
modelSelectValue,
modelDropdownOptions,
onModelChange,
modelDiscoveryLoading,
showCustomModelInput,
model,
onModelTextChange,
modelStatusMessage,
}: AgentEditMergedDSectionProps) {
return (
<>
{/* Agent name */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-display-name"
>
Agent name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isSaving || defReadOnly}
id="edit-agent-display-name"
onChange={(e) => onDisplayNameChange(e.target.value)}
placeholder="Agent name"
value={displayName}
/>
</div>
</div>
{/* System prompt */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-system-prompt"
>
Agent instructions
</label>
<div className={PERSONA_FIELD_SHELL_CLASS}>
<Textarea
className={cn(
"min-h-40 resize-y px-3 py-3 leading-5",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isSaving || defReadOnly}
id="edit-agent-system-prompt"
onChange={(e) => onSystemPromptChange(e.target.value)}
placeholder="Describe what this agent should do."
value={systemPrompt}
/>
</div>
</div>
{/* Runtime (harness) — D-field, shown for all definition contexts */}
<AgentHarnessField
disabled={isSaving || runtimeCatalogStatus === "loading" || defReadOnly}
onValueChange={onRuntimeChange}
options={defRuntimeDropdownOptions}
placeholder={defBlankLabel}
value={runtimeDropdownValue}
warning={null}
/>
{/* LLM provider — D-field */}
{llmProviderFieldVisible ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-llm-provider"
>
LLM provider
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<PersonaDropdownField
disabled={isSaving || defReadOnly}
id="edit-agent-llm-provider"
onValueChange={onProviderChange}
options={providerDropdownOptions}
placeholder="Default (auto)"
value={providerSelectValue}
/>
{isCustomProviderEditing ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isSaving || defReadOnly}
id="edit-agent-custom-provider"
onChange={(e) => onProviderTextChange(e.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
</div>
) : null}
{/* Model — D-field */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-model"
>
Model
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
</label>
<PersonaDropdownField
disabled={isSaving || modelDiscoveryLoading || defReadOnly}
id="edit-agent-model"
onValueChange={onModelChange}
options={modelDropdownOptions}
placeholder="Default model"
value={modelSelectValue}
/>
{showCustomModelInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom model ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isSaving || defReadOnly}
id="edit-agent-custom-model"
onChange={(e) => onModelTextChange(e.target.value)}
placeholder="Custom model ID"
value={model}
/>
</div>
) : null}
{modelStatusMessage ? (
<p className="text-xs text-muted-foreground">{modelStatusMessage}</p>
) : null}
</div>
</>
);
}

View File

@@ -0,0 +1,532 @@
/**
* AgentEditMergedDialogInstanceSection.tsx — Instance-field section for the merged edit surface.
*
* Renders instance name, respond-to, run-on summary, runtime pin, LLM
* provider, model, AI defaults, and the Advanced accordion. Shown only when
* an instance is present (showInst && inst).
*
* Extracted from AgentEditMergedDialog to satisfy the desktop file-size gate.
*/
import type * as React from "react";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ManagedAgent, RespondToMode } from "@/shared/api/types";
import type { AgentPersona } from "@/shared/api/types";
import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import {
ADVANCED_FIELDS_MOTION_TRANSITION,
PERSONA_FIELD_CONTROL_CLASS,
PERSONA_FIELD_SHELL_CLASS,
PERSONA_LABEL_OPTIONAL_CLASS,
type PersonaDropdownOption,
} from "./agentConfigOptions";
import { getProviderApiKeyLabel } from "./agentConfigOptions";
import type { AcpRuntime } from "@/features/agents/hooks";
import { AgentAiDefaultsNotice } from "./AgentAiDefaults";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge";
import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog";
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
import { OwnerOnlyAccessField } from "./OwnerOnlyAccessField";
import { PersonaDropdownField } from "./PersonaDropdownField";
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
import { RunOnSummarySection } from "./RunOnSummarySection";
// ── Props ─────────────────────────────────────────────────────────────────────
export type AgentEditMergedISectionProps = {
inst: ManagedAgent;
showDef: boolean;
defReadOnly: boolean;
isSaving: boolean;
runtimeCatalogStatus: "loading" | "error" | "ready";
// Instance name
instanceName: string;
onInstanceNameChange: (value: string) => void;
// RespondTo
respondTo: RespondToMode;
respondToAllowlist: string[];
onRespondToChange: (value: RespondToMode) => void;
onAllowlistChange: (value: string[]) => void;
agentAccessOwnerOnly: boolean | undefined;
// Runtime pin
selectedRuntimeId: string;
runtimeDropdownValue: string;
instanceRuntimeDropdownOptions: PersonaDropdownOption[];
onRuntimeChange: (value: string) => void;
selectedRuntime: AcpRuntime | undefined;
inheritHarness: boolean;
agentCommand: string;
onAgentCommandChange: (value: string) => void;
isAddHarnessOpen: boolean;
onAddHarnessOpenChange: (value: boolean) => void;
onSelectSavedHarness: (value: string) => void;
// LLM provider
llmProviderFieldVisible: boolean;
providerSelectValue: string;
providerDropdownOptions: PersonaDropdownOption[];
onProviderChange: (value: string) => void;
isCustomProviderEditing: boolean;
provider: string;
onProviderTextChange: (value: string) => void;
providerRequired: boolean;
// API key field
topLevelSecretEnvVar: string | null;
apiKeyValue: string;
apiKeyIsInherited: boolean;
apiKeyInheritedLabel: string;
apiKeyIsRequired: boolean;
effectiveProvider: string;
onApiKeyChange: (key: string, value: string) => void;
// Model
modelSelectValue: string;
modelDropdownOptions: PersonaDropdownOption[];
onModelChange: (value: string) => void;
modelDiscoveryLoading: boolean;
showCustomModelInput: boolean;
model: string;
onModelTextChange: (value: string) => void;
modelStatusMessage: string | null;
modelRequired: boolean;
// AI defaults
inheritedSubmission: {
model: string | null;
provider: string | null;
envVars: Record<string, string>;
};
inheritedModelDefault: { value: string; source: string };
inheritedProviderDefault: { value: string; source: string };
aiDefaultsOpen: boolean;
onAiDefaultsOpenChange: (value: boolean) => void;
aiDefaultsTriggerRef: React.RefObject<HTMLButtonElement>;
// Advanced
showAdvancedFields: boolean;
onShowAdvancedFieldsChange: (value: boolean) => void;
acpCommand: string;
agentArgs: string;
autoRestartOnConfigChange: boolean;
instanceEnvVars: Record<string, string>;
fileSatisfiedEnvKeys: string[];
advancedRequiredEnvKeys: string[];
initialFocus?: EditAgentFocusTarget;
inheritedEnvVarsForAdvanced: Record<string, string>;
linkedPersona: AgentPersona | null;
prospectiveRuntimeId: string;
prospectiveRuntime: AcpRuntime | undefined;
parallelism: string;
onAcpCommandChange: (value: string) => void;
onAgentArgsChange: (value: string) => void;
onAutoRestartChange: (value: boolean) => void;
onEnvVarsChange: (value: Record<string, string>) => void;
onInheritHarnessChange: (value: boolean) => void;
onParallelismChange: (value: string) => void;
systemPrompt: string;
onSystemPromptChange: (value: string) => void;
};
// ── Component ─────────────────────────────────────────────────────────────────
export function AgentEditMergedInstanceSection({
inst,
showDef,
defReadOnly,
isSaving,
runtimeCatalogStatus,
instanceName,
onInstanceNameChange,
respondTo,
respondToAllowlist,
onRespondToChange,
onAllowlistChange,
agentAccessOwnerOnly,
selectedRuntimeId,
runtimeDropdownValue,
instanceRuntimeDropdownOptions,
onRuntimeChange,
selectedRuntime,
inheritHarness,
agentCommand,
onAgentCommandChange,
isAddHarnessOpen,
onAddHarnessOpenChange,
onSelectSavedHarness,
llmProviderFieldVisible,
providerSelectValue,
providerDropdownOptions,
onProviderChange,
isCustomProviderEditing,
provider,
onProviderTextChange,
providerRequired,
topLevelSecretEnvVar,
apiKeyValue,
apiKeyIsInherited,
apiKeyInheritedLabel,
apiKeyIsRequired,
effectiveProvider,
onApiKeyChange,
modelSelectValue,
modelDropdownOptions,
onModelChange,
modelDiscoveryLoading,
showCustomModelInput,
model,
onModelTextChange,
modelStatusMessage,
modelRequired,
inheritedSubmission,
inheritedModelDefault,
inheritedProviderDefault,
aiDefaultsOpen,
onAiDefaultsOpenChange,
aiDefaultsTriggerRef,
showAdvancedFields,
onShowAdvancedFieldsChange,
acpCommand,
agentArgs,
autoRestartOnConfigChange,
instanceEnvVars,
fileSatisfiedEnvKeys,
advancedRequiredEnvKeys,
initialFocus,
inheritedEnvVarsForAdvanced,
linkedPersona,
prospectiveRuntimeId,
prospectiveRuntime,
parallelism,
onAcpCommandChange,
onAgentArgsChange,
onAutoRestartChange,
onEnvVarsChange,
onInheritHarnessChange,
onParallelismChange,
systemPrompt,
onSystemPromptChange,
}: AgentEditMergedISectionProps) {
const shouldReduceMotion = useReducedMotion();
const advancedFieldsTransition = shouldReduceMotion
? { duration: 0 }
: ADVANCED_FIELDS_MOTION_TRANSITION;
return (
<>
{/* Instance name */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-name"
>
Agent name
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isSaving}
id="edit-agent-name"
onChange={(e) => onInstanceNameChange(e.target.value)}
placeholder="Agent name"
value={instanceName}
/>
</div>
</div>
{/* Respond-to (D-field when linked; I-field when unlinked) */}
<OwnerOnlyAccessField
accessLocked={agentAccessOwnerOnly === true}
allowlist={respondToAllowlist}
disabled={isSaving || (showDef && defReadOnly)}
mode={respondTo}
onAllowlistChange={onAllowlistChange}
onModeChange={onRespondToChange}
/>
{/* Run-on summary */}
<RunOnSummarySection backend={inst.backend} />
{/* Runtime (harness) — I-field pin */}
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-runtime"
>
Provider
</label>
<PersonaDropdownField
disabled={isSaving}
id="edit-agent-runtime"
onValueChange={onRuntimeChange}
options={instanceRuntimeDropdownOptions}
placeholder="Choose a provider"
value={runtimeDropdownValue}
/>
{selectedRuntime ? (
<p className="text-xs text-muted-foreground">
Detected at{" "}
<span className="font-medium">
{selectedRuntime.binaryPath ??
selectedRuntime.command ??
selectedRuntime.id}
</span>
</p>
) : null}
<AddCustomHarnessDialog
onOpenChange={onAddHarnessOpenChange}
onSaved={onSelectSavedHarness}
open={isAddHarnessOpen}
/>
</div>
{selectedRuntimeId === "custom" && !inheritHarness ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-command"
>
Agent command
</label>
<div
className={cn(
"flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isSaving}
id="edit-agent-command"
onChange={(e) => onAgentCommandChange(e.target.value)}
placeholder="Full path or shell command"
value={agentCommand}
/>
</div>
</div>
) : null}
{/* LLM provider — I-field only when unlinked (no definition). When
definition is present, model/provider are D-fields shown in D-section. */}
{llmProviderFieldVisible && !showDef ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-llm-provider"
>
LLM provider
{providerRequired ? (
<span className="ml-1 text-destructive" aria-hidden="true">
*
</span>
) : (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
)}
</label>
<PersonaDropdownField
disabled={isSaving}
id="edit-agent-llm-provider"
onValueChange={onProviderChange}
options={providerDropdownOptions}
placeholder="Default (auto)"
value={providerSelectValue}
/>
{isCustomProviderEditing ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom provider ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isSaving}
id="edit-agent-custom-provider"
onChange={(e) => onProviderTextChange(e.target.value)}
placeholder="Custom provider ID"
value={provider}
/>
</div>
) : null}
</div>
) : null}
{llmProviderFieldVisible && topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={isSaving}
envVarName={topLevelSecretEnvVar}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={getProviderApiKeyLabel(effectiveProvider) ?? "API Key"}
onValueChange={(next) => {
onApiKeyChange(topLevelSecretEnvVar, next);
}}
value={apiKeyValue}
/>
) : null}
{/* Model — I-field only when unlinked (no definition). When
definition is present, model is a D-field shown in D-section. */}
{!showDef ? (
<div className="space-y-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-agent-model"
>
Model
{modelRequired ? (
<span className="ml-1 text-destructive" aria-hidden="true">
*
</span>
) : (
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
)}
</label>
<PersonaDropdownField
disabled={isSaving || modelDiscoveryLoading}
id="edit-agent-model"
onValueChange={onModelChange}
options={modelDropdownOptions}
placeholder="Default model"
value={modelSelectValue}
/>
{showCustomModelInput ? (
<div
className={cn(
"mt-2 flex min-h-11 items-center px-3",
PERSONA_FIELD_SHELL_CLASS,
)}
>
<Input
aria-label="Custom model ID"
autoCorrect="off"
className={cn(
"h-8 px-0 py-0 leading-6",
PERSONA_FIELD_CONTROL_CLASS,
)}
disabled={isSaving}
id="edit-agent-custom-model"
onChange={(e) => onModelTextChange(e.target.value)}
placeholder="Custom model ID"
value={model}
/>
</div>
) : null}
{modelStatusMessage ? (
<p className="text-xs text-muted-foreground">
{modelStatusMessage}
</p>
) : null}
</div>
) : null}
<AgentAiDefaultsNotice
onEditDefaults={() => onAiDefaultsOpenChange(true)}
triggerRef={aiDefaultsTriggerRef}
explicitModel={inheritedSubmission.model ?? ""}
explicitProvider={inheritedSubmission.provider ?? ""}
inheritedModel={inheritedModelDefault}
inheritedProvider={inheritedProviderDefault}
/>
<AgentDefaultsDialog
onOpenChange={onAiDefaultsOpenChange}
open={aiDefaultsOpen}
returnFocusRef={aiDefaultsTriggerRef}
/>
{/* Advanced fields */}
<div className="space-y-3">
<button
aria-expanded={showAdvancedFields}
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onShowAdvancedFieldsChange(!showAdvancedFields)}
type="button"
>
<span>Advanced</span>
<AdvancedRequiredBadge
envVars={inheritedSubmission.envVars}
requiredEnvKeys={advancedRequiredEnvKeys}
testId="edit-agent-advanced-required-badge"
/>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
showAdvancedFields && "rotate-180",
)}
/>
</button>
<AnimatePresence initial={false}>
{showAdvancedFields ? (
<motion.div
animate={{ height: "auto", opacity: 1, scale: 1 }}
className="origin-top overflow-hidden"
exit={{ height: 0, opacity: 0, scale: 0.98 }}
initial={{ height: 0, opacity: 0, scale: 0.98 }}
key="edit-agent-advanced-fields"
transition={advancedFieldsTransition}
>
<EditAgentAdvancedFields
acpCommand={acpCommand}
agentArgs={agentArgs}
autoRestartOnConfigChange={autoRestartOnConfigChange}
disabled={isSaving}
envVars={instanceEnvVars}
fileSatisfiedEnvKeys={fileSatisfiedEnvKeys}
hiddenEnvKeys={
topLevelSecretEnvVar ? [topLevelSecretEnvVar] : []
}
focusKey={
initialFocus?.type === "env_key"
? initialFocus.key
: undefined
}
inheritedEnvVars={inheritedEnvVarsForAdvanced}
inheritHarness={inheritHarness}
linkedPersona={linkedPersona}
model={inheritedSubmission.model ?? ""}
modelTuningRuntimeId={prospectiveRuntimeId}
parallelism={parallelism}
provider={effectiveProvider}
requiredEnvKeys={advancedRequiredEnvKeys}
catalogStatus={runtimeCatalogStatus}
selectedRuntime={prospectiveRuntime}
systemPrompt={
showDef ? systemPrompt : (inst.systemPrompt ?? "")
}
onAcpCommandChange={onAcpCommandChange}
onAgentArgsChange={onAgentArgsChange}
onAutoRestartChange={onAutoRestartChange}
onEnvVarsChange={onEnvVarsChange}
onInheritHarnessChange={onInheritHarnessChange}
onParallelismChange={onParallelismChange}
onSystemPromptChange={(v) => {
if (!showDef) onSystemPromptChange(v);
}}
/>
</motion.div>
) : null}
</AnimatePresence>
</div>
</>
);
}

View File

@@ -6,6 +6,7 @@ import { AgentDefinitionDialog } from "./AgentDefinitionDialog.tsx";
import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog.tsx";
import { AgentRunLocationProvider } from "./AgentRunLocationContext.tsx";
import { AgentEditDialog } from "./AgentEditDialog.tsx";
import { AgentEditMergedDialog } from "./AgentEditMergedDialog.tsx";
// ── Phase 1B.3c routing pinning ─────────────────────────────────────────────
//
@@ -112,20 +113,21 @@ test("create mode routes to the internal create router, not a form directly", ()
assert.equal(element.type.name, "AgentCreateDialogRouter");
});
// ── Phase 1 merged surface routing (AgentEditDialog) ────────────────────────
// ── Phase 1 Round 2: merged surface routing (AgentEditDialog) ────────────────
//
// AgentEditDialog collapses R1–R7 onto one entry point. Like AgentDialog, the
// instance-present arms are hook-free so they can be called directly and the
// returned element tree inspected. The definition-only arm delegates to a
// sub-component that owns hooks, so we only confirm the type name there.
// AgentEditDialog collapses R1–R7 onto ONE merged surface: AgentEditMergedDialog.
// Every context — instance-with-definition, instance-only, definition-only —
// renders the merged dialog. Instance-present contexts are wrapped in
// AgentRunLocationProvider so RespondToField can name the run machine.
//
// These tests prove:
// • instance-with-definition → AgentInstanceEditDialog (all I/L fields live here)
// • instance-only → AgentInstanceEditDialog (unlinked agents save correctly)
// • definition-only → AgentEditDefinitionOnlyDialog sub-component
// • instance-with-definition → AgentEditMergedDialog (D+I+L sections, one coordinator)
// • instance-only → AgentEditMergedDialog (I+L sections, one coordinator)
// • definition-only → AgentEditMergedDialog (D sections only, one coordinator)
// • instance contexts wrapped in AgentRunLocationProvider
// • definition-only NOT wrapped (no instance = no run location)
//
// They also confirm the AgentRunLocationProvider wrapper is present on instance
// paths (so RespondToField's remote-backend warning works).
// AgentInstanceEditDialog has NO production consumer on any R1–R7 route.
const minimalDefinition = {
id: "def-abc",
@@ -154,14 +156,14 @@ const minimalInstance = {
model: null,
provider: null,
envVars: {},
respondTo: null,
respondTo: "anyone",
respondToAllowlist: [],
parallelism: null,
parallelism: 1,
autoRestartOnConfigChange: false,
startOnAppLaunch: false,
};
test("AgentEditDialog: instance-with-definition routes instance to AgentInstanceEditDialog", () => {
test("AgentEditDialog: instance-with-definition routes to AgentEditMergedDialog (D+I+L on one form)", () => {
const ctx = {
kind: "instance-with-definition",
definition: minimalDefinition,
@@ -174,24 +176,19 @@ test("AgentEditDialog: instance-with-definition routes instance to AgentInstance
onOpenChange: noop,
});
// Wrapped in AgentRunLocationProvider for respondTo warning.
// Instance-present: wrapped in AgentRunLocationProvider for respondTo warning.
assert.equal(element.type, AgentRunLocationProvider);
const form = element.props.children;
assert.equal(
form.type,
AgentInstanceEditDialog,
"linked agent edit must reach AgentInstanceEditDialog (all I/L fields)",
AgentEditMergedDialog,
"linked agent edit must reach AgentEditMergedDialog — D+I+L sections on one form",
);
assert.equal(form.props.agent, minimalInstance);
assert.equal(form.props.ctx, ctx, "ctx must pass through to merged surface");
assert.equal(form.props.open, true);
assert.equal(
form.props.onEditLinkedPersona,
undefined,
"R4 back-door must be deleted",
);
});
test("AgentEditDialog: instance-only routes to AgentInstanceEditDialog (unlinked save)", () => {
test("AgentEditDialog: instance-only routes to AgentEditMergedDialog (I+L sections, coordinator)", () => {
const ctx = { kind: "instance-only", instance: minimalInstance };
const element = AgentEditDialog({
@@ -200,18 +197,18 @@ test("AgentEditDialog: instance-only routes to AgentInstanceEditDialog (unlinked
onOpenChange: noop,
});
// Wrapped in AgentRunLocationProvider for respondTo warning.
// Instance-present: wrapped in AgentRunLocationProvider.
assert.equal(element.type, AgentRunLocationProvider);
const form = element.props.children;
assert.equal(
form.type,
AgentInstanceEditDialog,
"unlinked agent edit must reach AgentInstanceEditDialog (UpdateManagedAgentInput path)",
AgentEditMergedDialog,
"unlinked agent edit must reach AgentEditMergedDialog (coordinator handles I-only save)",
);
assert.equal(form.props.agent, minimalInstance);
assert.equal(form.props.ctx, ctx);
});
test("AgentEditDialog: definition-only routes to definition sub-component", () => {
test("AgentEditDialog: definition-only routes to AgentEditMergedDialog (D sections, no wrapper)", () => {
const ctx = { kind: "definition-only", definition: minimalDefinition };
const element = AgentEditDialog({
@@ -220,15 +217,142 @@ test("AgentEditDialog: definition-only routes to definition sub-component", () =
onOpenChange: noop,
});
// Hook-bearing sub-component: confirm by name rather than reference.
// Definition-only: no run-location wrapper (no instance backend to resolve).
assert.equal(
typeof element.type,
"function",
"definition-only must route to a React component",
element.type,
AgentEditMergedDialog,
"definition-only must reach AgentEditMergedDialog directly (no AgentRunLocationProvider wrapper)",
);
assert.equal(element.props.ctx, ctx);
assert.equal(element.props.open, true);
});
test("AgentEditDialog: AgentInstanceEditDialog is NOT reachable from any R1–R7 context", () => {
const contexts = [
{
kind: "instance-with-definition",
definition: minimalDefinition,
instance: minimalInstance,
},
{ kind: "instance-only", instance: minimalInstance },
{ kind: "definition-only", definition: minimalDefinition },
];
for (const ctx of contexts) {
const element = AgentEditDialog({ ctx, open: true, onOpenChange: noop });
// Walk the element tree: neither AgentEditDialog nor its children should
// be AgentInstanceEditDialog on any R1–R7 route.
function findType(el, target) {
if (!el || typeof el !== "object") return false;
if (el.type === target) return true;
if (el.props?.children) {
const children = Array.isArray(el.props.children)
? el.props.children
: [el.props.children];
return children.some((c) => findType(c, target));
}
return false;
}
assert.equal(
findType(element, AgentInstanceEditDialog),
false,
`ctx.kind=${ctx.kind}: AgentInstanceEditDialog must NOT appear in the rendered tree`,
);
}
});
test("AgentEditDialog: single Save wires through coordinator — no instance dialog bypass", () => {
// Structural proof: the merged surface is reached for instance-with-definition,
// which means every save goes through AgentEditMergedDialog → emitAgentFormDiff
// → runAgentSaveCoordinator (steps 0–4, observed-state settlement for D+I+L).
// The coordinator is wired in AgentEditMergedDialog; this test confirms the
// routing reaches it (not AgentInstanceEditDialog's standalone save path).
const ctx = {
kind: "instance-with-definition",
definition: minimalDefinition,
instance: minimalInstance,
};
const element = AgentEditDialog({ ctx, open: true, onOpenChange: noop });
const form = element.props.children; // inner of AgentRunLocationProvider
assert.equal(form.type, AgentEditMergedDialog);
// AgentEditMergedDialog accepts ctx directly — the coordinator receives
// personaInput + agentInput + policySets from emitAgentFormDiff on every save.
assert.equal(form.props.ctx.kind, "instance-with-definition");
assert.equal(form.props.ctx.definition, minimalDefinition);
assert.equal(form.props.ctx.instance, minimalInstance);
});
// ── D-section renders D-fields for all definition contexts ────────────────────
//
// Since removing the !showInst guards from AgentEditMergedDialogDSection, the
// D-section always renders runtime/model/provider regardless of whether an
// instance is present. These tests verify the structural contract by checking
// that the component type tree for instance-with-definition reaches both
// AgentEditMergedDSection and AgentEditMergedInstanceSection via the
// AgentEditMergedDialog (which conditionally renders both sections).
//
// The merged dialog receives ctx with kind="instance-with-definition" and
// the D-section is rendered when hasDefinitionContext(ctx) = true (always for
// that kind). The I-section is rendered when hasInstanceContext(ctx) = true
// AND inst is present (also always for that kind).
import { AgentEditMergedDSection } from "./AgentEditMergedDialogDSection.tsx";
import { AgentEditMergedInstanceSection } from "./AgentEditMergedDialogInstanceSection.tsx";
import { hasDefinitionContext, hasInstanceContext } from "./agentFormModel.ts";
test("hasDefinitionContext returns true for instance-with-definition and definition-only", () => {
assert.equal(
hasDefinitionContext({
kind: "instance-with-definition",
definition: minimalDefinition,
instance: minimalInstance,
}),
true,
);
assert.equal(
element.type.name,
"AgentEditDefinitionOnlyDialog",
"definition-only must use the coordinator-wired sub-component",
hasDefinitionContext({
kind: "definition-only",
definition: minimalDefinition,
}),
true,
);
assert.equal(
hasDefinitionContext({ kind: "instance-only", instance: minimalInstance }),
false,
);
});
test("hasInstanceContext returns true for instance-with-definition and instance-only", () => {
assert.equal(
hasInstanceContext({
kind: "instance-with-definition",
definition: minimalDefinition,
instance: minimalInstance,
}),
true,
);
assert.equal(
hasInstanceContext({ kind: "instance-only", instance: minimalInstance }),
true,
);
assert.equal(
hasInstanceContext({
kind: "definition-only",
definition: minimalDefinition,
}),
false,
);
});
test("AgentEditMergedDSection type exists and is a function (D-fields component)", () => {
// Structural guard: the exported component must be callable. No DOM render needed.
assert.equal(typeof AgentEditMergedDSection, "function");
});
test("AgentEditMergedInstanceSection type exists and is a function (I-fields component)", () => {
assert.equal(typeof AgentEditMergedInstanceSection, "function");
});

View File

@@ -117,6 +117,12 @@ export type AgentFormModel = {
// Instance-level fields
/** Instance name (per-pool override of displayName). */
instanceName: string | undefined;
/**
* Instance-level env-var overlay (row 8 contract). When a definition is
* present the definition env is inherited; this field holds the per-instance
* additions/overrides. Undefined when no instance context.
*/
instanceEnvVars: Record<string, string> | undefined;
parallelism: number | null;
// Device policy (L-fields)
@@ -187,6 +193,10 @@ export function seedAgentFormModel(ctx: AgentEditContext): AgentFormModel {
// I-fields
const instanceName = inst?.name;
// Row 8 contract: when a definition is present, instance env is a per-agent
// overlay that does NOT wholesale-replace the definition env. Seed from
// inst.envVars directly (not the merged/inherited snapshot).
const instanceEnvVars = inst != null ? (inst.envVars ?? {}) : undefined;
const parallelism = inst?.parallelism ?? def?.parallelism ?? null;
// L-fields
@@ -205,6 +215,7 @@ export function seedAgentFormModel(ctx: AgentEditContext): AgentFormModel {
envVars,
namePool,
instanceName,
instanceEnvVars,
parallelism,
autoRestartOnConfigChange,
startOnAppLaunch,
@@ -235,6 +246,15 @@ export function emitAgentFormDiff(
// D-field diff — only when definition is present and NOT team-managed
let personaInput: UpdatePersonaInput | null = null;
if (def !== null && !defReadOnly) {
// respondTo is a D-field when definition is present: changes are written
// to the definition, not to the instance. Include it in the D-diff check.
const respondToChanged =
(next.respondTo ?? null) !== (saved.respondTo ?? null);
const allowlistChanged =
next.respondTo === "allowlist" &&
next.respondToAllowlist.join(",") !==
(saved.respondToAllowlist ?? []).join(",");
const dChanged =
next.displayName.trim() !== saved.displayName.trim() ||
(next.avatarUrl ?? "") !== (saved.avatarUrl ?? "") ||
@@ -243,7 +263,9 @@ export function emitAgentFormDiff(
(next.model ?? null) !== (saved.model ?? null) ||
(next.provider ?? null) !== (saved.provider ?? null) ||
!envVarsMapEqual(next.envVars ?? {}, saved.envVars ?? {}) ||
!namePoolEqual(next.namePool ?? [], saved.namePool ?? []);
!namePoolEqual(next.namePool ?? [], saved.namePool ?? []) ||
respondToChanged ||
allowlistChanged;
if (dChanged) {
personaInput = {
@@ -256,18 +278,19 @@ export function emitAgentFormDiff(
provider: next.provider ?? undefined,
namePool: next.namePool ?? [],
envVars: next.envVars ?? {},
...(next.respondTo != null
? {
behavior: {
// Always emit behavior block when definition is present — respondTo
// is a D-field and must travel with the persona write.
behavior:
next.respondTo != null
? {
respondTo: next.respondTo,
respondToAllowlist:
next.respondTo === "allowlist"
? next.respondToAllowlist
: undefined,
parallelism: next.parallelism ?? undefined,
},
}
: {}),
}
: undefined,
};
}
}
@@ -278,17 +301,11 @@ export function emitAgentFormDiff(
// Row 1 contract: stop materializing displayName→name when a name pool exists
const hasNamePool = (def?.namePool?.length ?? 0) > 0;
// Row 8 contract: instance env edit goes to instance, NOT wholesale-replace from definition
// Row 8 contract: instance env edit goes to instance, NOT wholesale-replace from definition.
// When a definition is present, instanceEnvVars holds the per-instance overlay.
const instanceEnvChanged =
"instanceEnvVars" in next
? !envVarsMapEqual(
(
next as AgentFormModel & {
instanceEnvVars?: Record<string, string>;
}
).instanceEnvVars ?? {},
inst.envVars ?? {},
)
next.instanceEnvVars !== undefined
? !envVarsMapEqual(next.instanceEnvVars ?? {}, inst.envVars ?? {})
: false;
const nameChanged =
@@ -301,9 +318,15 @@ export function emitAgentFormDiff(
def === null && (next.model ?? null) !== (inst.model ?? null);
const providerChanged =
def === null && (next.provider ?? null) !== (inst.provider ?? null);
// respondTo is a D-field when a definition is present AND editable.
// When the definition is team-managed (read-only) or absent, changes fall
// through to the instance so they are never silently dropped.
const respondToInstanceOwned = def === null || defReadOnly;
const respondToChanged =
respondToInstanceOwned &&
(next.respondTo ?? null) !== (inst.respondTo ?? null);
const allowlistChanged =
respondToInstanceOwned &&
next.respondTo === "allowlist" &&
next.respondToAllowlist.join(",") !== inst.respondToAllowlist.join(",");
const parallelismChanged =
@@ -327,6 +350,7 @@ export function emitAgentFormDiff(
agentInput.systemPrompt = next.systemPrompt.trim() || null;
if (modelChanged) agentInput.model = next.model ?? null;
if (providerChanged) agentInput.provider = next.provider ?? null;
if (instanceEnvChanged) agentInput.envVars = next.instanceEnvVars ?? {};
if (respondToChanged) agentInput.respondTo = next.respondTo ?? undefined;
if (allowlistChanged)
agentInput.respondToAllowlist = next.respondToAllowlist;

View File

@@ -0,0 +1,289 @@
/**
* useAgentEditMergedSubmit — submit hook for AgentEditMergedDialog.
*
* Encapsulates isSaving/saveError state and the async submit function so
* AgentEditMergedDialog stays under the desktop file-size gate.
*/
import * as React from "react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import {
managedAgentsQueryKey,
personasQueryKey,
} from "@/features/agents/hooks";
import {
setManagedAgentAutoRestart,
setManagedAgentStartOnAppLaunch,
} from "@/shared/api/tauriManagedAgents";
import type {
AgentPersona,
AcpRuntimeCatalogEntry,
ManagedAgent,
UpdateManagedAgentInput,
UpdatePersonaInput,
} from "@/shared/api/types";
import type { PersonaSharePublicationResult } from "@/shared/api/tauriPersonas";
import {
seedAgentFormModel,
emitAgentFormDiff,
type AgentEditContext,
} from "./agentFormModel";
import { runAgentSaveCoordinator } from "./agentSaveCoordinator";
import { parsePersonaNamePoolText } from "./personaDialogState";
// ── Types ─────────────────────────────────────────────────────────────────────
export type AgentEditSubmitState = {
ctx: AgentEditContext;
displayName: string;
avatarUrl: string;
systemPrompt: string;
namePoolText: string;
model: string;
provider: string;
respondTo: string;
respondToAllowlist: string[];
parallelism: string;
parsedParallelism: number;
envVars: Record<string, string>;
instanceEnvVars: Record<string, string>;
instanceName: string;
autoRestartOnConfigChange: boolean;
selectedRuntimeId: string;
inheritHarness: boolean;
agentCommand: string;
agentArgs: string;
acpCommand: string;
showInst: boolean;
defReadOnly: boolean;
inheritedSubmissionProvider: string | null;
inheritedSubmissionEnvVars: Record<string, string>;
linkedPersonaEnvVars: Record<string, string> | undefined;
runtimes: readonly AcpRuntimeCatalogEntry[];
updatePersona: (input: UpdatePersonaInput) => Promise<unknown>;
updatePersonaAndPublish: (
input: UpdatePersonaInput,
) => Promise<PersonaSharePublicationResult>;
updateManagedAgent: (
input: UpdateManagedAgentInput,
) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>;
startMutate: (
pubkey: string,
callbacks: { onSuccess: () => void; onError: (err: unknown) => void },
) => void;
onValidate?: () => string | null;
onOpenChange: (open: boolean) => void;
onUpdated?: (agent: ManagedAgent) => void;
};
export type AgentEditSubmitHookReturn = {
isSaving: boolean;
saveError: Error | null;
handleSubmit: (canSubmit: boolean) => Promise<void>;
resetSaveError: () => void;
};
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useAgentEditMergedSubmit(
state: AgentEditSubmitState,
): AgentEditSubmitHookReturn {
const queryClient = useQueryClient();
const [isSaving, setIsSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState<Error | null>(null);
// Keep a ref to the current state so handleSubmit always uses the latest
// values without needing to be recreated on every render.
const stateRef = React.useRef(state);
stateRef.current = state;
const handleSubmit = React.useCallback(
async (canSubmit: boolean) => {
if (!canSubmit) return;
const s = stateRef.current;
if (s.onValidate) {
const err = s.onValidate();
if (err) {
toast.error(err);
return;
}
}
setSaveError(null);
setIsSaving(true);
const def = s.ctx.kind !== "instance-only" ? s.ctx.definition : null;
const inst = s.ctx.kind !== "definition-only" ? s.ctx.instance : null;
try {
const seed = seedAgentFormModel(s.ctx);
const namePool = parsePersonaNamePoolText(s.namePoolText);
const normalizedModel = (s.model || null) as string | null;
const normalizedProvider = s.showInst
? (s.inheritedSubmissionProvider ?? null)
: s.provider.trim() || null;
const next = {
...seed,
displayName: s.displayName.trim(),
avatarUrl: s.avatarUrl.trim(),
systemPrompt: s.systemPrompt.trim(),
respondTo: s.respondTo as typeof seed.respondTo,
respondToAllowlist: s.respondToAllowlist,
runtime:
s.selectedRuntimeId === "custom" ? undefined : s.selectedRuntimeId,
model: normalizedModel,
provider: normalizedProvider,
envVars: s.showInst
? (s.linkedPersonaEnvVars ?? def?.envVars ?? {})
: s.envVars,
namePool,
instanceName: s.instanceName.trim() || undefined,
instanceEnvVars: s.showInst
? (s.inheritedSubmissionEnvVars as Record<string, string>)
: undefined,
parallelism:
s.parsedParallelism > 0
? s.parsedParallelism
: (seed.parallelism ?? null),
autoRestartOnConfigChange: s.showInst
? s.autoRestartOnConfigChange
: undefined,
startOnAppLaunch: seed.startOnAppLaunch,
};
const {
personaInput,
agentInput: base,
policySets,
} = emitAgentFormDiff(seed, next, s.ctx);
// Merge harness-pin fields (I-fields not tracked in AgentFormModel).
let agentInput = base;
if (s.showInst && inst) {
const resolvedArgs = s.agentArgs
.split(",")
.map((a) => a.trim())
.filter(Boolean);
const effectiveRuntime = s.runtimes.find(
(r) => r.id === (s.selectedRuntimeId || "custom"),
);
let commandUpdate: string | undefined;
if (s.inheritHarness) {
commandUpdate = inst.agentCommandOverride != null ? "" : undefined;
} else {
const pinned = (effectiveRuntime?.command ?? s.agentCommand).trim();
if (
pinned !== inst.agentCommand ||
(inst.agentCommandOverride == null && pinned.length > 0)
) {
commandUpdate = pinned;
}
}
const argsChanged =
!s.inheritHarness &&
resolvedArgs.join(",") !== inst.agentArgs.join(",");
if (commandUpdate !== undefined || argsChanged) {
agentInput = agentInput ?? { pubkey: inst.pubkey };
if (commandUpdate !== undefined) {
agentInput.agentCommand = commandUpdate;
agentInput.harnessOverride =
commandUpdate.length > 0 ? !s.inheritHarness : undefined;
}
if (argsChanged) agentInput.agentArgs = resolvedArgs;
}
if (s.acpCommand.trim() !== inst.acpCommand) {
agentInput = agentInput ?? { pubkey: inst.pubkey };
agentInput.acpCommand = s.acpCommand.trim();
}
}
const refetchStores = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
const personas =
queryClient.getQueryData<AgentPersona[]>(personasQueryKey) ?? [];
const agents =
queryClient.getQueryData<ManagedAgent[]>(managedAgentsQueryKey) ??
[];
return {
persona: def
? (personas.find((p) => p.id === def.id) ?? null)
: null,
agent: inst
? (agents.find((a) => a.pubkey === inst.pubkey) ?? null)
: null,
};
};
const success = await runAgentSaveCoordinator({
ctx: s.ctx,
personaInput,
agentInput,
policySets,
publishCatalogUpdates: !!(def?.shared && !s.defReadOnly),
runtimes: s.runtimes.length > 0 ? s.runtimes : undefined,
updatePersona: s.updatePersona,
updatePersonaAndPublish: s.updatePersonaAndPublish,
updateManagedAgent: async (upd) => {
if (!inst)
throw new Error("No instance in definition-only context");
return s.updateManagedAgent(upd);
},
setAutoRestart: (pk, v) => setManagedAgentAutoRestart(pk, v),
setStartOnAppLaunch: (pk, v) =>
setManagedAgentStartOnAppLaunch(pk, v),
refetchStores,
onDone: () => s.onOpenChange(false),
onSavedWhileStopped: (agent) => {
const name = agent.name;
toast(`${name} saved while stopped.`, {
action: {
label: "Start now",
onClick: () =>
s.startMutate(agent.pubkey, {
onSuccess: () => toast.success(`${name} started.`),
onError: (err) =>
toast.error(
err instanceof Error
? `${name} failed to start: ${err.message}`
: `${name} failed to start.`,
),
}),
},
});
},
});
if (!success) {
setSaveError(
new Error("Some changes may not have persisted. Reopen to retry."),
);
}
if (success && inst) {
const agents =
queryClient.getQueryData<ManagedAgent[]>(managedAgentsQueryKey) ??
[];
const updated = agents.find((a) => a.pubkey === inst.pubkey);
if (updated) s.onUpdated?.(updated);
}
} finally {
setIsSaving(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[queryClient],
);
return {
isSaving,
saveError,
handleSubmit,
resetSaveError: () => setSaveError(null),
};
}