refactor(agent-form): split EditFormBody into 5 semantic sections + add Cancel/dirty-state + always-visible Thinking Effort + binary OrgChart hierarchy

Previously the agent Edit modal was a 351-LOC wall of 17 stacked form
fields with no visual grouping, mixed header styles between the
skills heading and the field labels, a Save button that was always
enabled, no Cancel affordance, and a Thinking Effort field that
silently disappeared when the selected connector did not support it.

This commit restructures the edit surface into five named sections
— Identity, Runtime, Budget & scope, Behavior, Skills — each with a
consistent SectionHeader (11px uppercase muted, border-top divider).
Every section lives in its own ≤150-LOC file under
agent-form-fields/edit-sections/ so nothing stays god-sized:

  EditFormBody.tsx          199 LOC (orchestrator: isDirty, handlers)
  IdentitySection.tsx        74 LOC
  RuntimeSection.tsx        114 LOC
  ConstraintsSection.tsx     58 LOC
  SkillsSection.tsx          36 LOC
  EditActionBar.tsx          87 LOC
  SectionHeader.tsx          18 LOC

Behavioral changes:

- Save is disabled until the form is dirty (trimmed-string + numeric
  budget comparison against the persisted agent record). The board
  sees whether changes are captured without clicking Save to find out.
- Cancel button added, wired through AgentForm → AgentEditor →
  OrganizationSections, which clears selectedAgentId (same as the
  modal's close X — a proper bottom action avoids users reaching for
  the corner).
- Thinking Effort is always shown. When the connector provider does
  not accept the hint the Select is disabled with an inline note,
  so the option is discoverable instead of silently hidden.
- Budget shows a live BudgetBar + "Spent $X of $Y" line when the
  budget or spend is non-zero. The raw number input on its own left
  burn state invisible.
- Capabilities placeholder replaced with a concrete example
  ("writes React components, reviews PRs, ...") — the previous
  "What this agent does" was a circular prompt.
- OrgChart role bar went from three tiers (CEO 4px primary /
  manager 3px warning / IC 2px muted 30%) to binary (root 4px
  primary / everyone else 2px border). Orange-warning for managers
  read as an alert, not a rank; managerial position is already
  encoded in the tree's topology.

Also added i18n keys (zh + en):
  agent.form.sectionIdentity / sectionRuntime / sectionConstraints /
  sectionBehavior / effortNotSupported / budgetBurn; updated
  capabilitiesPlaceholder copy.

Covers J2 / J7 / J8 / S5 / S6 / S7 / S8.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 19:50:53 -04:00
1 parent 62d0964af3
commit 9981ee342b
13 files changed
+510 -261

No files matched your search

@@ -8,6 +8,7 @@ export function AgentEditor({
agents,
companyId,
onSaved,
onCancel,
onDeleted,
}: {
agent: AgentRecord;
@@ -16,6 +17,7 @@ export function AgentEditor({
agents: AgentRecord[];
companyId: string;
onSaved: () => Promise<void>;
onCancel: () => void;
onDeleted: () => Promise<void>;
}) {
return (
@@ -27,6 +29,7 @@ export function AgentEditor({
agents={agents}
agent={agent}
onSaved={onSaved}
onCancel={onCancel}
onDeleted={onDeleted}
/>
);
@@ -131,10 +131,15 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
const isActive = node.agent.status === "active" || isRunning;
const isError = node.agent.status === "error";
const isPending = node.agent.status === "pending_approval";
const isCEO = node.children.length > 0 && !node.agent.reportsTo;
// Binary hierarchy: the root (no reportsTo) gets a bold primary bar;
// everyone else gets a hairline neutral bar. Managerial rank is
// already communicated by tree position — duplicating it via a
// color (previously "warning" orange, which read as an alert
// rather than a rank) was misleading.
const isRoot = !node.agent.reportsTo;
return (
<div key={node.agent.id} data-org-card role="button" tabIndex={0} aria-label={t("org.agentAria").replace("{name}", node.agent.name).replace("{title}", node.agent.title || node.agent.role).replace("{status}", node.agent.status)} className={`absolute cursor-pointer select-none rounded-lg border bg-card shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${isRunning ? "border-[color:var(--primary)] shadow-[0_0_12px_color-mix(in_oklch,var(--success)_15%,transparent)]" : isError ? "border-[color:var(--destructive)] border-opacity-50" : isPending ? "border-[color:var(--destructive)] border-dashed" : "border-border hover:border-border"}`} style={{ left: node.x, top: node.y, width: CARD_W, minHeight: CARD_H }} onClick={() => onSelectAgent?.(node.agent.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectAgent?.(node.agent.id); } }}>
<div className={`rounded-t-[7px] ${isCEO ? "h-[4px] bg-primary" : node.children.length > 0 ? "h-[3px] bg-warning" : "h-[2px] bg-[color:var(--muted-foreground)] opacity-30"}`} />
<div className={`rounded-t-[7px] ${isRoot ? "h-[4px] bg-primary" : "h-[2px] bg-border"}`} />
<div className="flex items-center gap-3 px-4 py-3">
<div className="relative shrink-0">
<div className={`flex h-11 w-11 items-center justify-center rounded-full text-[13px] font-bold ${isActive ? "bg-success-muted text-success" : isError ? "bg-destructive-muted text-destructive" : "bg-muted text-muted-foreground"}`}>{initials}</div>
@@ -20,6 +20,7 @@ interface AgentFormProps {
agents: AgentRecord[];
agent?: AgentRecord;
onSaved: () => Promise<void>;
onCancel?: () => void;
onDeleted?: () => Promise<void>;
}
@@ -31,6 +32,7 @@ export function AgentForm({
agents,
agent,
onSaved,
onCancel,
onDeleted,
}: AgentFormProps) {
const form = useAgentForm({
@@ -105,6 +107,7 @@ export function AgentForm({
persistedConnectorIssue={persistedConnectorIssue}
form={form}
onSaved={onSaved}
onCancel={onCancel ?? (() => {})}
onDeleted={onDeleted}
/>
);
@@ -1,20 +1,20 @@
import { useEffect, useState } from "react";
import { Pencil, Trash } from "../../../ui/icons";
import { DEPARTMENT_OPTIONS } from "@shared/constants";
import { useMemo, useState } from "react";
import type {
AgentRecord,
ConnectorRecord,
DepartmentId,
WorkspaceRecord,
} from "@shared/types";
import { unwrap } from "../../../../lib/desktop";
import { buildBudgetPolicyInput } from "../../../../lib/budget-policy-input";
import { useAppStore } from "../../../../state/store";
import { useT } from "../../../../i18n";
import { ActionButton, Input, Select, TextArea } from "../../../ui";
import { SkillAgentToggles } from "../../../skills/SkillAgentToggles";
import { ConfirmDelete, HeartbeatSection } from "../../helpers";
import { HeartbeatSection } from "../../helpers";
import type { AgentFormState } from "../useAgentForm";
import { IdentitySection } from "./edit-sections/IdentitySection";
import { RuntimeSection } from "./edit-sections/RuntimeSection";
import { ConstraintsSection } from "./edit-sections/ConstraintsSection";
import { SkillsSection } from "./edit-sections/SkillsSection";
import { EditActionBar } from "./edit-sections/EditActionBar";
import { SectionHeader } from "./edit-sections/SectionHeader";
interface EditFormBodyProps {
agent: AgentRecord;
@@ -27,9 +27,22 @@ interface EditFormBodyProps {
persistedConnectorIssue: string | null;
form: AgentFormState;
onSaved: () => Promise<void>;
onCancel: () => void;
onDeleted?: () => Promise<void>;
}
/**
* Orchestrator for the agent Edit modal. Owns the save / wake / delete
* side effects, the save-error latch, and the dirty-state derivation;
* renders through five section components so each concern (identity,
* runtime, constraints, behavior, skills) lives in a ≤200 LOC file.
*
* "Dirty" is computed from the form state vs. the persisted agent record
* plus the persisted budget policy. We intentionally compare trimmed
* strings and numeric budget so that whitespace-only edits do not falsely
* enable Save. Root-agent guards against Delete / terminate live here
* and are threaded into the relevant sections.
*/
export function EditFormBody({
agent,
companyId,
@@ -40,97 +53,72 @@ export function EditFormBody({
persistedConnectorIssue,
form,
onSaved,
onCancel,
onDeleted,
}: EditFormBodyProps) {
const t = useT();
const {
name,
setName,
role,
setRole,
title,
setTitle,
department,
setDepartment,
status,
setStatus,
connectorId,
setConnectorId,
model,
setModel,
availableModels,
workspaceId,
setWorkspaceId,
budget,
setBudget,
capabilities,
setCapabilities,
reportsTo,
setReportsTo,
thinkingEffort,
setThinkingEffort,
existingMeta,
supportsEffort,
selectedDepartment,
agentPolicy,
} = form;
const [saveError, setSaveError] = useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const pendingApproval = agent.status === "pending_approval";
// Root agents (CEO / no reportsTo) must not be terminable or deletable from
// this form — their removal requires reassigning direct reports first, and
// we would rather the user see a disabled control + reason than discover
// the orphaning side-effect only after clicking through a confirm.
const isRootAgent = !agent.reportsTo;
const skills = useAppStore((s) => s.skills);
const setSkills = useAppStore((s) => s.setSkills);
useEffect(() => {
if (skills.length === 0 && companyId) {
window.agentCompany.listSkills(companyId).then((result) => {
if (result.ok) setSkills(result.data);
});
}
}, [companyId, skills.length, setSkills]);
const managerCandidates = agents.filter((a) => a.id !== agent.id);
const isDirty = useMemo(() => {
if (pendingApproval) return false;
const persistedEffort =
typeof form.existingMeta.thinkingEffort === "string"
? (form.existingMeta.thinkingEffort as string)
: "";
return (
form.name.trim() !== agent.name ||
form.role.trim() !== agent.role ||
form.title.trim() !== (agent.title ?? "") ||
(form.department || null) !== (agent.department ?? null) ||
form.status !== agent.status ||
form.connectorId !== agent.connectorId ||
(form.model || null) !== (agent.model ?? null) ||
(form.workspaceId || null) !== (agent.workspaceId ?? null) ||
(form.reportsTo || null) !== (agent.reportsTo ?? null) ||
Number(form.budget || 0) !== agent.budgetMonthlyUsd ||
form.capabilities.trim() !== (agent.capabilities ?? "") ||
form.thinkingEffort !== persistedEffort
);
}, [form, agent, pendingApproval]);
async function persistAgentDraft() {
const meta = { ...existingMeta };
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
const meta = { ...form.existingMeta };
if (form.thinkingEffort) meta.thinkingEffort = form.thinkingEffort;
else delete meta.thinkingEffort;
const budgetDollars = Number(budget || 0);
const budgetDollars = Number(form.budget || 0);
unwrap(
await window.agentCompany.saveAgent({
id: agent.id,
companyId,
name: name.trim(),
role: role.trim(),
title: title.trim(),
department: selectedDepartment,
status,
connectorId,
workspaceId: workspaceId || null,
reportsTo: reportsTo || null,
model: model || null,
name: form.name.trim(),
role: form.role.trim(),
title: form.title.trim(),
department: form.selectedDepartment,
status: form.status,
connectorId: form.connectorId,
workspaceId: form.workspaceId || null,
reportsTo: form.reportsTo || null,
model: form.model || null,
metadataJson: JSON.stringify(meta),
budgetMonthlyUsd: budgetDollars,
capabilities: capabilities.trim(),
capabilities: form.capabilities.trim(),
}),
);
if (budgetDollars > 0 && agent.id) {
const payload = buildBudgetPolicyInput({
policyId: agentPolicy?.id ?? null,
policyId: form.agentPolicy?.id ?? null,
companyId,
scopeType: "agent",
scopeId: agent.id,
amountDollars: String(budgetDollars),
warnPercent: String(agentPolicy?.warnPercent ?? 80),
hardStopEnabled: agentPolicy?.hardStopEnabled ?? true,
notifyEnabled: agentPolicy?.notifyEnabled ?? true,
warnPercent: String(form.agentPolicy?.warnPercent ?? 80),
hardStopEnabled: form.agentPolicy?.hardStopEnabled ?? true,
notifyEnabled: form.agentPolicy?.notifyEnabled ?? true,
});
if (!payload) {
throw new Error(t("agent.form.budgetPolicyInvalid"));
@@ -166,196 +154,46 @@ export function EditFormBody({
}
}
async function handleDelete() {
await window.agentCompany.deleteAgent({ id: agent.id, companyId });
if (onDeleted) await onDeleted();
}
return (
<div className="grid min-w-0 gap-4">
<Input
label={t("agent.name")}
value={name}
onChange={setName}
placeholder={t("agent.name")}
readOnly={pendingApproval}
disabled={pendingApproval}
/>
<Input
label={t("agent.role")}
value={role}
onChange={setRole}
placeholder={t("agent.role")}
readOnly={pendingApproval}
disabled={pendingApproval}
/>
<Input
label={t("agent.title")}
value={title}
onChange={setTitle}
placeholder={t("agent.title")}
readOnly={pendingApproval}
disabled={pendingApproval}
/>
<Select
label={t("comm.department")}
value={department}
onChange={(value) => setDepartment(value as DepartmentId | "")}
disabled={pendingApproval}
options={DEPARTMENT_OPTIONS.map((option) => ({
value: option.value,
label: option.value === "" ? t("comm.selectDepartment") : option.label,
}))}
/>
<Select
label={t("agent.status")}
value={status}
onChange={(v) => setStatus(v as AgentRecord["status"])}
disabled={pendingApproval}
options={
pendingApproval
? [{ value: "pending_approval", label: t("agent.pendingApproval") }]
: [
{ value: "idle", label: t("agent.form.statusIdle") },
{ value: "active", label: t("agent.form.statusActive") },
{ value: "running", label: t("agent.form.statusRunning") },
{ value: "paused", label: t("agent.form.statusPaused") },
{ value: "error", label: t("agent.form.statusError") },
...(isRootAgent ? [] : [{ value: "terminated", label: t("agent.form.statusTerminated") }]),
]
}
/>
{pendingApproval ? (
<p className="text-[12px] text-muted-foreground">{t("agent.pendingHireNote")}</p>
) : null}
{isRootAgent && !pendingApproval ? (
<p className="text-[12px] text-muted-foreground">{t("agent.form.rootTerminateBlocked")}</p>
) : null}
<Select
label={t("agent.connector")}
value={connectorId}
onChange={(v) => setConnectorId(v as ConnectorRecord["id"])}
disabled={pendingApproval}
options={connectorOptions}
/>
{selectedConnectorIssue ? (
<p className="text-[12px] text-warning">{selectedConnectorIssue}</p>
) : null}
{availableModels.length > 0 ? (
<Select
label={t("agent.model")}
value={model}
onChange={setModel}
disabled={pendingApproval}
options={availableModels.map((m) => ({ value: m.id, label: m.label }))}
/>
) : null}
{supportsEffort ? (
<Select
label={t("agent.thinkingEffort")}
value={thinkingEffort}
onChange={setThinkingEffort}
disabled={pendingApproval}
options={[
{ value: "", label: t("agent.form.effortDefault") },
{ value: "low", label: t("agent.form.effortLow") },
{ value: "medium", label: t("agent.form.effortMedium") },
{ value: "high", label: t("agent.form.effortHigh") },
]}
/>
) : null}
<Select
label={t("agent.workspace")}
value={workspaceId}
onChange={setWorkspaceId}
disabled={pendingApproval}
options={[
{ value: "", label: t("agent.noWorkspace") },
...workspaces.map((w) => ({ value: w.id, label: w.name })),
]}
/>
<Select
label={t("agent.reportsTo")}
value={reportsTo}
onChange={setReportsTo}
disabled={pendingApproval}
options={[
{ value: "", label: t("agent.noManager") },
...managerCandidates.map((a) => ({ value: a.id, label: a.name })),
]}
/>
<Input
label={t("agent.budget")}
value={budget}
onChange={setBudget}
placeholder={t("agent.form.budgetPlaceholder")}
type="number"
readOnly={pendingApproval}
disabled={pendingApproval}
/>
<TextArea
label={t("agent.capabilities")}
value={capabilities}
onChange={setCapabilities}
placeholder={t("agent.capabilities")}
readOnly={pendingApproval}
disabled={pendingApproval}
/>
<HeartbeatSection
<div className="grid min-w-0 gap-6">
<IdentitySection form={form} agent={agent} agents={agents} pendingApproval={pendingApproval} />
<RuntimeSection
form={form}
agent={agent}
companyId={companyId}
workspaces={workspaces}
connectorOptions={connectorOptions}
selectedConnectorIssue={selectedConnectorIssue}
pendingApproval={pendingApproval}
persistedConnectorIssue={selectedConnectorIssue ?? persistedConnectorIssue}
onSaved={onSaved}
onWakeNow={() => void handleWakeNow()}
isRootAgent={isRootAgent}
/>
<div className="mt-6 border-t border-border pt-4">
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-primary mb-3">
{t("agent.skills")}
</h3>
<SkillAgentToggles
mode="agent"
<ConstraintsSection form={form} agent={agent} pendingApproval={pendingApproval} />
<SectionHeader title={t("agent.form.sectionBehavior")}>
<HeartbeatSection
agent={agent}
companyId={companyId}
agentId={agent.id}
skills={skills}
pendingApproval={pendingApproval}
persistedConnectorIssue={selectedConnectorIssue ?? persistedConnectorIssue}
onSaved={onSaved}
onWakeNow={() => void handleWakeNow()}
/>
</div>
<div className="flex items-center gap-3">
<ActionButton
label={t("agent.saveChanges")}
tone="accent"
icon={Pencil}
disabled={pendingApproval || !!selectedConnectorIssue}
onClick={() => void handleSave()}
/>
{confirmingDelete ? (
<ConfirmDelete
entityLabel={agent.name}
onConfirm={() =>
void (async () => {
await window.agentCompany.deleteAgent({ id: agent.id, companyId });
if (onDeleted) await onDeleted();
})()
}
onCancel={() => setConfirmingDelete(false)}
/>
) : (
<ActionButton
label={t("agent.form.delete")}
tone="danger"
icon={Trash}
disabled={pendingApproval || isRootAgent}
onClick={() => setConfirmingDelete(true)}
/>
)}
</div>
{pendingApproval ? (
<p className="text-[12px] text-muted-foreground">
{t("agent.form.pendingReadOnly")}
</p>
) : null}
{isRootAgent && !pendingApproval ? (
<p className="text-[12px] text-muted-foreground">{t("agent.form.rootDeleteBlocked")}</p>
) : null}
{saveError ? <p className="text-[12px] text-destructive">{saveError}</p> : null}
</SectionHeader>
<SkillsSection companyId={companyId} agentId={agent.id} />
<EditActionBar
agent={agent}
pendingApproval={pendingApproval}
isRootAgent={isRootAgent}
isDirty={isDirty}
selectedConnectorIssue={selectedConnectorIssue}
saveError={saveError}
onSave={handleSave}
onCancel={onCancel}
onDelete={handleDelete}
/>
</div>
);
}
@@ -0,0 +1,58 @@
import type { AgentRecord } from "@shared/types";
import { Input, TextArea } from "../../../../ui";
import { BudgetBar } from "../../../helpers";
import { formatMoney } from "../../../../../lib/formatters";
import { useT } from "../../../../../i18n";
import type { AgentFormState } from "../../useAgentForm";
import { SectionHeader } from "./SectionHeader";
interface ConstraintsSectionProps {
form: AgentFormState;
agent: AgentRecord;
pendingApproval: boolean;
}
/**
* Budget + scope of work. Budget surfaces the live burn via BudgetBar so
* the board can see whether a proposed change (typing a new budget in the
* input) tracks reality. Capabilities gets a concrete placeholder so
* agents ship with useful, specific scope statements instead of the
* previous "What this agent does" circular prompt.
*/
export function ConstraintsSection({ form, agent, pendingApproval }: ConstraintsSectionProps) {
const t = useT();
const budgetValue = Number(form.budget || 0);
const showBurn = budgetValue > 0 || agent.spentMonthlyUsd > 0;
return (
<SectionHeader title={t("agent.form.sectionConstraints")}>
<Input
label={t("agent.budget")}
value={form.budget}
onChange={form.setBudget}
placeholder={t("agent.form.budgetPlaceholder")}
type="number"
readOnly={pendingApproval}
disabled={pendingApproval}
/>
{showBurn ? (
<div className="grid gap-1.5">
<BudgetBar spent={agent.spentMonthlyUsd} budget={budgetValue} />
<p className="text-[11px] text-muted-foreground">
{t("agent.form.budgetBurn")
.replace("{spent}", formatMoney(agent.spentMonthlyUsd))
.replace("{budget}", formatMoney(budgetValue))}
</p>
</div>
) : null}
<TextArea
label={t("agent.capabilities")}
value={form.capabilities}
onChange={form.setCapabilities}
placeholder={t("agent.form.capabilitiesPlaceholder")}
readOnly={pendingApproval}
disabled={pendingApproval}
/>
</SectionHeader>
);
}
@@ -0,0 +1,87 @@
import { useState } from "react";
import { Pencil, Trash } from "../../../../ui/icons";
import type { AgentRecord } from "@shared/types";
import { ActionButton } from "../../../../ui";
import { ConfirmDelete } from "../../../helpers";
import { useT } from "../../../../../i18n";
interface EditActionBarProps {
agent: AgentRecord;
pendingApproval: boolean;
isRootAgent: boolean;
isDirty: boolean;
selectedConnectorIssue: string | null;
saveError: string | null;
onSave: () => Promise<void> | void;
onCancel: () => void;
onDelete: () => Promise<void> | void;
}
/**
* Primary / secondary / destructive actions for the agent Edit modal.
*
* - Save is disabled until the form is dirty, so users see whether their
* changes have been captured without needing to click Save to find out.
* - Cancel closes the modal; the caller controls what "close" means
* (typically clearing the selectedAgentId state).
* - Delete is gated behind both an inline ConfirmDelete AND the
* `isRootAgent` guard; root agents (no reportsTo) must have their
* direct reports reassigned before they can be removed.
*/
export function EditActionBar({
agent,
pendingApproval,
isRootAgent,
isDirty,
selectedConnectorIssue,
saveError,
onSave,
onCancel,
onDelete,
}: EditActionBarProps) {
const t = useT();
const [confirmingDelete, setConfirmingDelete] = useState(false);
return (
<div className="border-t border-border pt-4">
<div className="flex flex-wrap items-center gap-3">
<ActionButton
label={t("agent.saveChanges")}
tone="accent"
icon={Pencil}
disabled={pendingApproval || !isDirty || !!selectedConnectorIssue}
onClick={() => void onSave()}
/>
<ActionButton
label={t("common.cancel")}
tone="default"
onClick={onCancel}
/>
<div className="ml-auto">
{confirmingDelete ? (
<ConfirmDelete
entityLabel={agent.name}
onConfirm={() => void onDelete()}
onCancel={() => setConfirmingDelete(false)}
/>
) : (
<ActionButton
label={t("agent.form.delete")}
tone="danger"
icon={Trash}
disabled={pendingApproval || isRootAgent}
onClick={() => setConfirmingDelete(true)}
/>
)}
</div>
</div>
{pendingApproval ? (
<p className="mt-3 text-[12px] text-muted-foreground">{t("agent.form.pendingReadOnly")}</p>
) : null}
{isRootAgent && !pendingApproval ? (
<p className="mt-3 text-[12px] text-muted-foreground">{t("agent.form.rootDeleteBlocked")}</p>
) : null}
{saveError ? <p className="mt-3 text-[12px] text-destructive">{saveError}</p> : null}
</div>
);
}
@@ -0,0 +1,74 @@
import { DEPARTMENT_OPTIONS } from "@shared/constants";
import type { AgentRecord, DepartmentId } from "@shared/types";
import { Input, Select } from "../../../../ui";
import { useT } from "../../../../../i18n";
import type { AgentFormState } from "../../useAgentForm";
import { SectionHeader } from "./SectionHeader";
interface IdentitySectionProps {
form: AgentFormState;
agent: AgentRecord;
agents: AgentRecord[];
pendingApproval: boolean;
}
/**
* Who this agent is and where they sit in the org: Name / Role / Title /
* Department / Reports To. "Assignments" (Reports To) lives here because it
* answers the same question identity fields do — who is this person — and
* keeping it with Name/Role lets the user fill the whole identity picture
* in one pass before moving to runtime config.
*/
export function IdentitySection({ form, agent, agents, pendingApproval }: IdentitySectionProps) {
const t = useT();
const managerCandidates = agents.filter((a) => a.id !== agent.id);
return (
<SectionHeader title={t("agent.form.sectionIdentity")}>
<Input
label={t("agent.name")}
value={form.name}
onChange={form.setName}
placeholder={t("agent.name")}
readOnly={pendingApproval}
disabled={pendingApproval}
/>
<Input
label={t("agent.role")}
value={form.role}
onChange={form.setRole}
placeholder={t("agent.role")}
readOnly={pendingApproval}
disabled={pendingApproval}
/>
<Input
label={t("agent.title")}
value={form.title}
onChange={form.setTitle}
placeholder={t("agent.title")}
readOnly={pendingApproval}
disabled={pendingApproval}
/>
<Select
label={t("comm.department")}
value={form.department}
onChange={(value) => form.setDepartment(value as DepartmentId | "")}
disabled={pendingApproval}
options={DEPARTMENT_OPTIONS.map((option) => ({
value: option.value,
label: option.value === "" ? t("comm.selectDepartment") : option.label,
}))}
/>
<Select
label={t("agent.reportsTo")}
value={form.reportsTo}
onChange={form.setReportsTo}
disabled={pendingApproval}
options={[
{ value: "", label: t("agent.noManager") },
...managerCandidates.map((a) => ({ value: a.id, label: a.name })),
]}
/>
</SectionHeader>
);
}
@@ -0,0 +1,114 @@
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
import { Select } from "../../../../ui";
import { useT } from "../../../../../i18n";
import type { AgentFormState } from "../../useAgentForm";
import { SectionHeader } from "./SectionHeader";
interface RuntimeSectionProps {
form: AgentFormState;
agent: AgentRecord;
workspaces: WorkspaceRecord[];
connectorOptions: Array<{ value: string; label: string }>;
selectedConnectorIssue: string | null;
pendingApproval: boolean;
isRootAgent: boolean;
}
/**
* How this agent runs: which connector, which model, thinking effort,
* which workspace, and what the current runtime status is. Status +
* connector + model cluster together because that is what the orchestrator
* reads when dispatching a run.
*
* Thinking Effort is always shown (instead of disappearing when the model
* does not support it) so users can see the option exists — it is
* disabled with an inline note when the selected connector's provider
* does not accept the hint.
*/
export function RuntimeSection({
form,
agent,
workspaces,
connectorOptions,
selectedConnectorIssue,
pendingApproval,
isRootAgent,
}: RuntimeSectionProps) {
const t = useT();
return (
<SectionHeader title={t("agent.form.sectionRuntime")}>
<Select
label={t("agent.status")}
value={form.status}
onChange={(v) => form.setStatus(v as AgentRecord["status"])}
disabled={pendingApproval}
options={
pendingApproval
? [{ value: "pending_approval", label: t("agent.pendingApproval") }]
: [
{ value: "idle", label: t("agent.form.statusIdle") },
{ value: "active", label: t("agent.form.statusActive") },
{ value: "running", label: t("agent.form.statusRunning") },
{ value: "paused", label: t("agent.form.statusPaused") },
{ value: "error", label: t("agent.form.statusError") },
...(isRootAgent
? []
: [{ value: "terminated", label: t("agent.form.statusTerminated") }]),
]
}
/>
{pendingApproval ? (
<p className="text-[12px] text-muted-foreground">{t("agent.pendingHireNote")}</p>
) : null}
{isRootAgent && !pendingApproval ? (
<p className="text-[12px] text-muted-foreground">{t("agent.form.rootTerminateBlocked")}</p>
) : null}
<Select
label={t("agent.connector")}
value={form.connectorId}
onChange={(v) => form.setConnectorId(v as ConnectorRecord["id"])}
disabled={pendingApproval}
options={connectorOptions}
/>
{selectedConnectorIssue ? (
<p className="text-[12px] text-warning">{selectedConnectorIssue}</p>
) : null}
{form.availableModels.length > 0 ? (
<Select
label={t("agent.model")}
value={form.model}
onChange={form.setModel}
disabled={pendingApproval}
options={form.availableModels.map((m) => ({ value: m.id, label: m.label }))}
/>
) : null}
<Select
label={t("agent.thinkingEffort")}
value={form.thinkingEffort}
onChange={form.setThinkingEffort}
disabled={pendingApproval || !form.supportsEffort}
options={[
{ value: "", label: t("agent.form.effortDefault") },
{ value: "low", label: t("agent.form.effortLow") },
{ value: "medium", label: t("agent.form.effortMedium") },
{ value: "high", label: t("agent.form.effortHigh") },
]}
/>
{!form.supportsEffort && !pendingApproval ? (
<p className="text-[11px] text-muted-foreground">{t("agent.form.effortNotSupported")}</p>
) : null}
<Select
label={t("agent.workspace")}
value={form.workspaceId}
onChange={form.setWorkspaceId}
disabled={pendingApproval}
options={[
{ value: "", label: t("agent.noWorkspace") },
...workspaces.map((w) => ({ value: w.id, label: w.name })),
]}
/>
<input type="hidden" data-agent-id={agent.id} />
</SectionHeader>
);
}
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
/**
* Consistent header for Edit-form sections. Single style (11px uppercase
* muted) used across Identity / Runtime / Constraints / Skills / Behavior
* so the form reads as one rhythm instead of a mix of bold banners + bare
* labels.
*/
export function SectionHeader({ title, children }: { title: string; children?: ReactNode }) {
return (
<section className="border-t border-border pt-4 first:border-t-0 first:pt-0">
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{title}
</h3>
<div className="grid gap-4">{children}</div>
</section>
);
}
@@ -0,0 +1,36 @@
import { useEffect } from "react";
import { SkillAgentToggles } from "../../../../skills/SkillAgentToggles";
import { useAppStore } from "../../../../../state/store";
import { useT } from "../../../../../i18n";
import { SectionHeader } from "./SectionHeader";
interface SkillsSectionProps {
companyId: string;
agentId: string;
}
/**
* Skills assignment for this agent. Pulls the company skill catalog via
* the store (fetched lazily if not already populated) and renders
* SkillAgentToggles inside the shared SectionHeader so it reads with the
* same rhythm as Identity / Runtime / Constraints.
*/
export function SkillsSection({ companyId, agentId }: SkillsSectionProps) {
const t = useT();
const skills = useAppStore((s) => s.skills);
const setSkills = useAppStore((s) => s.setSkills);
useEffect(() => {
if (skills.length === 0 && companyId) {
window.agentCompany.listSkills(companyId).then((result) => {
if (result.ok) setSkills(result.data);
});
}
}, [companyId, skills.length, setSkills]);
return (
<SectionHeader title={t("agent.skills")}>
<SkillAgentToggles mode="agent" companyId={companyId} agentId={agentId} skills={skills} />
</SectionHeader>
);
}
+7 -1
View File
@@ -1369,12 +1369,18 @@ export const en: Record<string, string> = {
"agent.form.titleHint": "Job title shown under the name",
"agent.form.budgetPlaceholder": "120",
"agent.form.budgetHint": "Spending cap per month; agent pauses when exceeded",
"agent.form.capabilitiesPlaceholder": "What this agent does",
"agent.form.capabilitiesPlaceholder": "e.g. writes React components, reviews PRs, triages bugs, drafts support replies",
"agent.form.capabilitiesHint": "Skills and domain knowledge injected into the agent's system prompt",
"agent.form.effortDefault": "Default",
"agent.form.effortLow": "Low",
"agent.form.effortMedium": "Medium",
"agent.form.effortHigh": "High",
"agent.form.effortNotSupported": "The selected connector's models do not accept a thinking-effort hint.",
"agent.form.budgetBurn": "Spent {spent} of {budget}",
"agent.form.sectionIdentity": "Identity",
"agent.form.sectionRuntime": "Runtime",
"agent.form.sectionConstraints": "Budget & scope",
"agent.form.sectionBehavior": "Behavior",
"agent.form.statusIdle": "Idle",
"agent.form.statusActive": "Active",
"agent.form.statusRunning": "Running",
+7 -1
View File
@@ -1369,12 +1369,18 @@ export const zh: Record<string, string> = {
"agent.form.titleHint": "显示在名称下方的职位",
"agent.form.budgetPlaceholder": "120",
"agent.form.budgetHint": "每月支出上限,超支后智能体自动暂停",
"agent.form.capabilitiesPlaceholder": "这位智能体擅长什么",
"agent.form.capabilitiesPlaceholder": "例如:撰写 React 组件、评审 PR、分类 bug、写客服工单回复",
"agent.form.capabilitiesHint": "注入智能体系统提示词的技能和领域知识",
"agent.form.effortDefault": "默认",
"agent.form.effortLow": "低",
"agent.form.effortMedium": "中",
"agent.form.effortHigh": "高",
"agent.form.effortNotSupported": "当前连接器的模型不接受思考力度设置。",
"agent.form.budgetBurn": "已用 {spent} / 共 {budget}",
"agent.form.sectionIdentity": "身份",
"agent.form.sectionRuntime": "运行时",
"agent.form.sectionConstraints": "预算与范围",
"agent.form.sectionBehavior": "行为",
"agent.form.statusIdle": "空闲",
"agent.form.statusActive": "活跃",
"agent.form.statusRunning": "运行中",
@@ -88,6 +88,7 @@ export function OrganizationSections({ section }: OrganizationSectionsProps) {
agents={companyAgents}
companyId={currentCompany.id}
onSaved={refresh}
onCancel={() => setSelectedAgentId(null)}
onDeleted={async () => { setSelectedAgentId(null); await refresh(); }}
/>
) : null}