mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
980
desktop/src/features/agents/ui/AgentEditMergedDialog.tsx
Normal file
980
desktop/src/features/agents/ui/AgentEditMergedDialog.tsx
Normal 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";
|
||||
}
|
||||
242
desktop/src/features/agents/ui/AgentEditMergedDialogDSection.tsx
Normal file
242
desktop/src/features/agents/ui/AgentEditMergedDialogDSection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
289
desktop/src/features/agents/ui/useAgentEditMergedSubmit.ts
Normal file
289
desktop/src/features/agents/ui/useAgentEditMergedSubmit.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user