refactor(renderer): rename EntityWorkspace to TwoPaneLayout for clarity
This commit is contained in:
1 parent
933fcf27d0
commit
61aba41e77
8 files changed
+952
-7
No files matched your search
+3
-3
@@ -2,10 +2,10 @@ import type { ReactNode } from "react";
|
||||
import { Eyebrow } from "./ui";
|
||||
|
||||
/**
|
||||
* Two-slot workspace layout with a section header.
|
||||
* Use `Panel` for single-slot sections; use this when rendering a primary + secondary pane.
|
||||
* Two-pane layout with a section header.
|
||||
* Use `Panel` (from ui/) for single-slot sections. Use this when rendering a primary + secondary pane.
|
||||
*/
|
||||
export function EntityWorkspace({
|
||||
export function TwoPaneLayout({
|
||||
title,
|
||||
eyebrow,
|
||||
primary,
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ConnectorRecord,
|
||||
WorkspaceRecord,
|
||||
} from "@shared/types";
|
||||
import {
|
||||
getConnectorExecutionReadinessIssue,
|
||||
isConnectorExecutionReady,
|
||||
} from "@shared/connector-policy";
|
||||
import { useAgentForm, type AgentFormMode } from "./useAgentForm";
|
||||
import { CreateFormBody } from "./agent-form-fields/CreateFormBody";
|
||||
import { EditFormBody } from "./agent-form-fields/EditFormBody";
|
||||
|
||||
interface AgentFormProps {
|
||||
mode: AgentFormMode;
|
||||
companyId: string;
|
||||
connectors: ConnectorRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
agents: AgentRecord[];
|
||||
agent?: AgentRecord;
|
||||
onSaved: () => Promise<void>;
|
||||
onDeleted?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function AgentForm({
|
||||
mode,
|
||||
companyId,
|
||||
connectors,
|
||||
workspaces,
|
||||
agents,
|
||||
agent,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: AgentFormProps) {
|
||||
const form = useAgentForm({
|
||||
mode,
|
||||
agent: agent ?? null,
|
||||
connectors,
|
||||
workspaces,
|
||||
companyId,
|
||||
});
|
||||
const { executionReadyConnectors, selectedConnectorRecord } = form;
|
||||
|
||||
const selectedConnectorIssue =
|
||||
mode === "edit"
|
||||
? selectedConnectorRecord
|
||||
? getConnectorExecutionReadinessIssue(selectedConnectorRecord)
|
||||
: "Select a connector."
|
||||
: null;
|
||||
const persistedConnectorRecord =
|
||||
mode === "edit" && agent
|
||||
? connectors.find((connector) => connector.id === agent.connectorId) ?? null
|
||||
: null;
|
||||
const persistedConnectorIssue =
|
||||
mode === "edit"
|
||||
? persistedConnectorRecord
|
||||
? getConnectorExecutionReadinessIssue(persistedConnectorRecord)
|
||||
: "Current connector is unavailable."
|
||||
: null;
|
||||
|
||||
const connectorOptions = useMemo(() => {
|
||||
const getDisplayLabel = (c: ConnectorRecord) =>
|
||||
c.id.startsWith("claude_compat") && c.notes ? c.notes : c.label;
|
||||
const readyOptions = executionReadyConnectors.map((connector) => ({
|
||||
value: connector.id,
|
||||
label: getDisplayLabel(connector),
|
||||
}));
|
||||
if (mode !== "edit") return readyOptions;
|
||||
if (!selectedConnectorRecord || isConnectorExecutionReady(selectedConnectorRecord)) {
|
||||
return readyOptions;
|
||||
}
|
||||
return [
|
||||
{
|
||||
value: selectedConnectorRecord.id,
|
||||
label: `${getDisplayLabel(selectedConnectorRecord)} (current, blocked)`,
|
||||
},
|
||||
...readyOptions.filter((option) => option.value !== selectedConnectorRecord.id),
|
||||
];
|
||||
}, [executionReadyConnectors, selectedConnectorRecord, mode]);
|
||||
|
||||
if (mode === "create") {
|
||||
return (
|
||||
<CreateFormBody
|
||||
companyId={companyId}
|
||||
agents={agents}
|
||||
workspaces={workspaces}
|
||||
connectorOptions={connectorOptions}
|
||||
form={form}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!agent) return null;
|
||||
return (
|
||||
<EditFormBody
|
||||
agent={agent}
|
||||
companyId={companyId}
|
||||
agents={agents}
|
||||
workspaces={workspaces}
|
||||
connectors={connectors}
|
||||
connectorOptions={connectorOptions}
|
||||
selectedConnectorIssue={selectedConnectorIssue}
|
||||
persistedConnectorIssue={persistedConnectorIssue}
|
||||
form={form}
|
||||
onSaved={onSaved}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Robot } from "@phosphor-icons/react";
|
||||
import { DEPARTMENT_OPTIONS } from "@shared/constants";
|
||||
import type {
|
||||
AgentRecord,
|
||||
ConnectorRecord,
|
||||
DepartmentId,
|
||||
WorkspaceRecord,
|
||||
} from "@shared/types";
|
||||
import { unwrap } from "../../../../lib/desktop";
|
||||
import { useT } from "../../../../i18n";
|
||||
import { ActionButton, Input, Select, TextArea } from "../../../ui";
|
||||
import type { AgentFormState } from "../useAgentForm";
|
||||
|
||||
interface CreateFormBodyProps {
|
||||
companyId: string;
|
||||
agents: AgentRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
connectorOptions: Array<{ value: string; label: string }>;
|
||||
form: AgentFormState;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function CreateFormBody({
|
||||
companyId,
|
||||
agents,
|
||||
workspaces,
|
||||
connectorOptions,
|
||||
form,
|
||||
onSaved,
|
||||
}: CreateFormBodyProps) {
|
||||
const t = useT();
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
role,
|
||||
setRole,
|
||||
title,
|
||||
setTitle,
|
||||
department,
|
||||
setDepartment,
|
||||
connectorId,
|
||||
setConnectorId,
|
||||
model,
|
||||
setModel,
|
||||
availableModels,
|
||||
workspaceId,
|
||||
setWorkspaceId,
|
||||
budget,
|
||||
setBudget,
|
||||
capabilities,
|
||||
setCapabilities,
|
||||
reportsTo,
|
||||
setReportsTo,
|
||||
thinkingEffort,
|
||||
setThinkingEffort,
|
||||
supportsEffort,
|
||||
executionReadyConnectors,
|
||||
selectedConnectorRecord,
|
||||
selectedDepartment,
|
||||
} = form;
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
|
||||
async function handleCreate() {
|
||||
try {
|
||||
setRequestError(null);
|
||||
if (!selectedConnectorRecord) {
|
||||
setRequestError(t("org.selectConnectorFirst"));
|
||||
return;
|
||||
}
|
||||
const meta: Record<string, string> = {};
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
unwrap(
|
||||
await window.agentCompany.requestHire({
|
||||
companyId,
|
||||
requestedByAgentId: null,
|
||||
name: name.trim(),
|
||||
role: role.trim(),
|
||||
title: title.trim(),
|
||||
department: selectedDepartment,
|
||||
connectorId: connectorId as ConnectorRecord["id"],
|
||||
workspaceId: workspaceId || null,
|
||||
reportsTo: reportsTo || null,
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
capabilities: capabilities.trim(),
|
||||
budgetMonthlyUsd: Number(budget || 0),
|
||||
}),
|
||||
);
|
||||
setName("");
|
||||
setRole("");
|
||||
setTitle("");
|
||||
setDepartment("");
|
||||
setCapabilities("");
|
||||
setReportsTo("");
|
||||
setRequestError(null);
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setRequestError(
|
||||
error instanceof Error ? error.message : t("org.hireRequestFailed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<Input
|
||||
label={t("org.agentName")}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder={t("org.agentName")}
|
||||
hint="Display name shown in org chart and messages"
|
||||
/>
|
||||
<Input
|
||||
label={t("agent.role")}
|
||||
value={role}
|
||||
onChange={setRole}
|
||||
placeholder="e.g. Engineer, PM, CEO"
|
||||
hint="Determines which tasks the agent can handle"
|
||||
/>
|
||||
<Input
|
||||
label={t("agent.title")}
|
||||
value={title}
|
||||
onChange={setTitle}
|
||||
placeholder={t("agent.title")}
|
||||
hint="Job title shown under the name"
|
||||
/>
|
||||
<Select
|
||||
label={t("org.connector")}
|
||||
value={connectorId}
|
||||
onChange={(value) => setConnectorId(value as ConnectorRecord["id"])}
|
||||
options={connectorOptions}
|
||||
/>
|
||||
{executionReadyConnectors.length === 0 ? (
|
||||
<p className="text-[12px] text-warning">{t("org.noConnectorAvailable")}</p>
|
||||
) : null}
|
||||
{availableModels.length > 0 ? (
|
||||
<Select
|
||||
label={t("org.model")}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={availableModels.map((m) => ({ value: m.id, label: m.label }))}
|
||||
/>
|
||||
) : null}
|
||||
{supportsEffort ? (
|
||||
<Select
|
||||
label={t("org.thinkingEffort")}
|
||||
value={thinkingEffort}
|
||||
onChange={setThinkingEffort}
|
||||
options={[
|
||||
{ value: "", label: t("org.default") },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
>
|
||||
<path
|
||||
d="M3 1l4 4-4 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
{t("org.advancedSettings")}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showAdvanced ? (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="grid gap-4 overflow-hidden"
|
||||
>
|
||||
<Select
|
||||
label={t("comm.department")}
|
||||
value={department}
|
||||
onChange={(value) => setDepartment(value as DepartmentId | "")}
|
||||
options={DEPARTMENT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label:
|
||||
option.value === "" ? t("comm.selectDepartment") : option.label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label={t("agent.workspace")}
|
||||
value={workspaceId}
|
||||
onChange={setWorkspaceId}
|
||||
options={[
|
||||
{ value: "", label: t("agent.noWorkspace") },
|
||||
...workspaces.map((workspace) => ({
|
||||
value: workspace.id,
|
||||
label: workspace.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label={t("agent.reportsTo")}
|
||||
value={reportsTo}
|
||||
onChange={setReportsTo}
|
||||
options={[
|
||||
{ value: "", label: t("agent.noManager") },
|
||||
...agents.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label={t("org.monthlyBudget")}
|
||||
value={budget}
|
||||
onChange={setBudget}
|
||||
placeholder="120"
|
||||
type="number"
|
||||
hint="Spending cap per month; agent pauses when exceeded"
|
||||
/>
|
||||
<TextArea
|
||||
label={t("agent.capabilities")}
|
||||
value={capabilities}
|
||||
onChange={setCapabilities}
|
||||
placeholder="What this agent does"
|
||||
hint="Skills and domain knowledge injected into the agent's system prompt"
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<p className="text-[12px] text-muted-foreground">{t("org.hireRequestNote")}</p>
|
||||
<ActionButton
|
||||
label={t("org.submitHireRequest")}
|
||||
tone="accent"
|
||||
onClick={() => void handleCreate()}
|
||||
icon={Robot}
|
||||
disabled={!selectedConnectorRecord}
|
||||
/>
|
||||
{requestError ? (
|
||||
<p className="text-[12px] text-destructive">{requestError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pencil, Trash } from "@phosphor-icons/react";
|
||||
import { DEPARTMENT_OPTIONS } from "@shared/constants";
|
||||
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 type { AgentFormState } from "../useAgentForm";
|
||||
|
||||
interface EditFormBodyProps {
|
||||
agent: AgentRecord;
|
||||
companyId: string;
|
||||
agents: AgentRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
connectors: ConnectorRecord[];
|
||||
connectorOptions: Array<{ value: string; label: string }>;
|
||||
selectedConnectorIssue: string | null;
|
||||
persistedConnectorIssue: string | null;
|
||||
form: AgentFormState;
|
||||
onSaved: () => Promise<void>;
|
||||
onDeleted?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function EditFormBody({
|
||||
agent,
|
||||
companyId,
|
||||
agents,
|
||||
workspaces,
|
||||
connectorOptions,
|
||||
selectedConnectorIssue,
|
||||
persistedConnectorIssue,
|
||||
form,
|
||||
onSaved,
|
||||
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";
|
||||
|
||||
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);
|
||||
|
||||
async function persistAgentDraft() {
|
||||
const meta = { ...existingMeta };
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
else delete meta.thinkingEffort;
|
||||
|
||||
const budgetDollars = Number(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,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
budgetMonthlyUsd: budgetDollars,
|
||||
capabilities: capabilities.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
if (budgetDollars > 0 && agent.id) {
|
||||
const payload = buildBudgetPolicyInput({
|
||||
policyId: 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,
|
||||
});
|
||||
if (!payload) {
|
||||
throw new Error("Agent budget policy is invalid.");
|
||||
}
|
||||
unwrap(await window.agentCompany.saveBudgetPolicy(payload));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWakeNow() {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await persistAgentDraft();
|
||||
unwrap(
|
||||
await window.agentCompany.triggerHeartbeat({
|
||||
agentId: agent.id,
|
||||
companyId,
|
||||
trigger: "manual",
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "Failed to wake agent.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await persistAgentDraft();
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "Failed to save agent.");
|
||||
}
|
||||
}
|
||||
|
||||
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: "Idle" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
{ value: "error", label: "Error" },
|
||||
{ value: "terminated", label: "Terminated" },
|
||||
]
|
||||
}
|
||||
/>
|
||||
{pendingApproval ? (
|
||||
<p className="text-[12px] text-muted-foreground">{t("agent.pendingHireNote")}</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: "Default" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
]}
|
||||
/>
|
||||
) : 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="120"
|
||||
type="number"
|
||||
readOnly={pendingApproval}
|
||||
disabled={pendingApproval}
|
||||
/>
|
||||
<TextArea
|
||||
label={t("agent.capabilities")}
|
||||
value={capabilities}
|
||||
onChange={setCapabilities}
|
||||
placeholder={t("agent.capabilities")}
|
||||
readOnly={pendingApproval}
|
||||
disabled={pendingApproval}
|
||||
/>
|
||||
|
||||
<HeartbeatSection
|
||||
agent={agent}
|
||||
companyId={companyId}
|
||||
pendingApproval={pendingApproval}
|
||||
persistedConnectorIssue={selectedConnectorIssue ?? persistedConnectorIssue}
|
||||
onSaved={onSaved}
|
||||
onWakeNow={() => void handleWakeNow()}
|
||||
/>
|
||||
|
||||
<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"
|
||||
companyId={companyId}
|
||||
agentId={agent.id}
|
||||
skills={skills}
|
||||
/>
|
||||
</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="Delete"
|
||||
tone="danger"
|
||||
icon={Trash}
|
||||
disabled={pendingApproval}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{pendingApproval ? (
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Pending hires are read-only here. Use Hiring or Approvals to approve or reject
|
||||
the request before editing the agent profile.
|
||||
</p>
|
||||
) : null}
|
||||
{saveError ? <p className="text-[12px] text-destructive">{saveError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { AgentForm } from "./AgentForm";
|
||||
export { useAgentForm } from "./useAgentForm";
|
||||
export type { AgentFormMode, AgentFormState } from "./useAgentForm";
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
AgentRecord,
|
||||
BudgetPolicyRecord,
|
||||
ConnectorRecord,
|
||||
DepartmentId,
|
||||
} from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { safeJson } from "../../../lib/safe-json";
|
||||
import { getBudgetPolicyAmountDollars } from "../../../lib/budget-policy-input";
|
||||
|
||||
export type AgentFormMode = "create" | "edit";
|
||||
|
||||
export interface UseAgentFormArgs {
|
||||
mode: AgentFormMode;
|
||||
agent: AgentRecord | null;
|
||||
connectors: ConnectorRecord[];
|
||||
workspaces: { id: string; name: string }[];
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
export interface AgentFormState {
|
||||
name: string;
|
||||
setName: (value: string) => void;
|
||||
role: string;
|
||||
setRole: (value: string) => void;
|
||||
title: string;
|
||||
setTitle: (value: string) => void;
|
||||
department: DepartmentId | "";
|
||||
setDepartment: (value: DepartmentId | "") => void;
|
||||
status: AgentRecord["status"];
|
||||
setStatus: (value: AgentRecord["status"]) => void;
|
||||
connectorId: ConnectorRecord["id"];
|
||||
setConnectorId: (value: ConnectorRecord["id"]) => void;
|
||||
model: string;
|
||||
setModel: (value: string) => void;
|
||||
availableModels: Array<{ id: string; label: string }>;
|
||||
workspaceId: string;
|
||||
setWorkspaceId: (value: string) => void;
|
||||
budget: string;
|
||||
setBudget: (value: string) => void;
|
||||
capabilities: string;
|
||||
setCapabilities: (value: string) => void;
|
||||
reportsTo: string;
|
||||
setReportsTo: (value: string) => void;
|
||||
thinkingEffort: string;
|
||||
setThinkingEffort: (value: string) => void;
|
||||
existingMeta: Record<string, unknown>;
|
||||
supportsEffort: boolean;
|
||||
executionReadyConnectors: ConnectorRecord[];
|
||||
selectedConnectorRecord: ConnectorRecord | null;
|
||||
selectedDepartment: DepartmentId | null;
|
||||
agentPolicy: BudgetPolicyRecord | null;
|
||||
}
|
||||
|
||||
export function useAgentForm({
|
||||
mode,
|
||||
agent,
|
||||
connectors,
|
||||
workspaces,
|
||||
companyId,
|
||||
}: UseAgentFormArgs): AgentFormState {
|
||||
const executionReadyConnectors = useMemo(
|
||||
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
|
||||
[connectors],
|
||||
);
|
||||
|
||||
const initialConnectorId: ConnectorRecord["id"] =
|
||||
agent?.connectorId ?? executionReadyConnectors[0]?.id ?? ("" as ConnectorRecord["id"]);
|
||||
const existingMeta = useMemo(
|
||||
() => safeJson<Record<string, unknown>>(agent?.metadataJson ?? null, {}),
|
||||
[agent?.metadataJson],
|
||||
);
|
||||
|
||||
const [name, setName] = useState(agent?.name ?? "");
|
||||
const [role, setRole] = useState(agent?.role ?? "");
|
||||
const [title, setTitle] = useState(agent?.title ?? "");
|
||||
const [department, setDepartment] = useState<DepartmentId | "">(agent?.department ?? "");
|
||||
const [status, setStatus] = useState<AgentRecord["status"]>(agent?.status ?? "idle");
|
||||
const [connectorId, setConnectorId] = useState<ConnectorRecord["id"]>(initialConnectorId);
|
||||
const [model, setModel] = useState(agent?.model ?? "");
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [workspaceId, setWorkspaceId] = useState(
|
||||
agent ? agent.workspaceId ?? "" : workspaces[0]?.id ?? "",
|
||||
);
|
||||
const [budget, setBudget] = useState(agent ? String(agent.budgetMonthlyUsd) : "120");
|
||||
const [capabilities, setCapabilities] = useState(agent?.capabilities ?? "");
|
||||
const [reportsTo, setReportsTo] = useState(agent?.reportsTo ?? "");
|
||||
const [thinkingEffort, setThinkingEffort] = useState<string>(
|
||||
typeof existingMeta.thinkingEffort === "string" ? existingMeta.thinkingEffort : "",
|
||||
);
|
||||
|
||||
const supportsEffort =
|
||||
connectorId === "claude_local" ||
|
||||
connectorId === "codex_local" ||
|
||||
(mode === "edit" && typeof connectorId === "string" && connectorId.startsWith("claude_compat"));
|
||||
|
||||
const selectedConnectorRecord =
|
||||
(mode === "edit" ? connectors : executionReadyConnectors).find(
|
||||
(connector) => connector.id === connectorId,
|
||||
) ?? null;
|
||||
|
||||
const persistedConnectorId = agent?.connectorId ?? null;
|
||||
useEffect(() => {
|
||||
if (mode === "create") {
|
||||
if (!connectorId) {
|
||||
setAvailableModels([]);
|
||||
setModel("");
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listConnectorModels(connectorId);
|
||||
if (result.ok) {
|
||||
setAvailableModels(result.data);
|
||||
setModel(result.data[0]?.id ?? "");
|
||||
}
|
||||
})();
|
||||
if (connectorId !== "claude_local" && connectorId !== "codex_local") {
|
||||
setThinkingEffort("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof connectorId === "string" && connectorId.startsWith("claude_compat")) {
|
||||
const compat = connectors.find((c) => c.id === connectorId);
|
||||
const raw = compat?.model ?? "";
|
||||
const models = raw
|
||||
.split(",")
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean)
|
||||
.map((m) => ({ id: m, label: m }));
|
||||
setAvailableModels(models);
|
||||
if (persistedConnectorId && connectorId !== persistedConnectorId && models.length > 0) {
|
||||
setModel(models[0].id);
|
||||
}
|
||||
} else {
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listConnectorModels(connectorId);
|
||||
if (result.ok) {
|
||||
setAvailableModels(result.data);
|
||||
if (persistedConnectorId && connectorId !== persistedConnectorId) {
|
||||
setModel(result.data[0]?.id ?? "");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
if (
|
||||
connectorId !== "claude_local" &&
|
||||
!(typeof connectorId === "string" && connectorId.startsWith("claude_compat")) &&
|
||||
connectorId !== "codex_local"
|
||||
) {
|
||||
setThinkingEffort("");
|
||||
}
|
||||
}, [connectorId, mode, persistedConnectorId, connectors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "create") return;
|
||||
if (!connectorId && executionReadyConnectors[0]) {
|
||||
setConnectorId(executionReadyConnectors[0].id);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
connectorId &&
|
||||
!executionReadyConnectors.some((connector) => connector.id === connectorId)
|
||||
) {
|
||||
setConnectorId(executionReadyConnectors[0]?.id ?? ("" as ConnectorRecord["id"]));
|
||||
}
|
||||
}, [executionReadyConnectors, connectorId, mode]);
|
||||
|
||||
const [agentPolicy, setAgentPolicy] = useState<BudgetPolicyRecord | null>(null);
|
||||
const agentIdForPolicy = agent?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !agentIdForPolicy) return;
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listBudgetPolicies(companyId);
|
||||
if (result.ok) {
|
||||
const policy = result.data
|
||||
.filter((p) => p.scopeType === "agent" && p.scopeId === agentIdForPolicy)
|
||||
.sort((left, right) => {
|
||||
if (left.isActive !== right.isActive) return left.isActive ? -1 : 1;
|
||||
return right.updatedAt.localeCompare(left.updatedAt);
|
||||
})[0];
|
||||
if (policy) {
|
||||
setAgentPolicy(policy);
|
||||
setBudget(getBudgetPolicyAmountDollars(policy));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [companyId, agentIdForPolicy, mode]);
|
||||
|
||||
const selectedDepartment: DepartmentId | null = department === "" ? null : department;
|
||||
|
||||
return {
|
||||
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,
|
||||
executionReadyConnectors,
|
||||
selectedConnectorRecord,
|
||||
selectedDepartment,
|
||||
agentPolicy,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user