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";
|
import { Eyebrow } from "./ui";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-slot workspace layout with a section header.
|
* Two-pane layout with a section header.
|
||||||
* Use `Panel` for single-slot sections; use this when rendering a primary + secondary pane.
|
* Use `Panel` (from ui/) for single-slot sections. Use this when rendering a primary + secondary pane.
|
||||||
*/
|
*/
|
||||||
export function EntityWorkspace({
|
export function TwoPaneLayout({
|
||||||
title,
|
title,
|
||||||
eyebrow,
|
eyebrow,
|
||||||
primary,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { ConnectorEditor } from "../../components/connectors";
|
import { ConnectorEditor } from "../../components/connectors";
|
||||||
import { KanbanBoard } from "../../components/dashboard";
|
import { KanbanBoard } from "../../components/dashboard";
|
||||||
import { EntityWorkspace } from "../../components/EntityWorkspace";
|
import { TwoPaneLayout } from "../../components/TwoPaneLayout";
|
||||||
import { RunCenter } from "../../components/runs";
|
import { RunCenter } from "../../components/runs";
|
||||||
import {
|
import {
|
||||||
ActivityList,
|
ActivityList,
|
||||||
@@ -294,7 +294,7 @@ export function ExecutionSections({ section }: ExecutionSectionsProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{costsTab === "costs" ? (
|
{costsTab === "costs" ? (
|
||||||
<EntityWorkspace
|
<TwoPaneLayout
|
||||||
title="Costs"
|
title="Costs"
|
||||||
eyebrow={undefined}
|
eyebrow={undefined}
|
||||||
primary={
|
primary={
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { SectionId } from "@shared/types";
|
import type { SectionId } from "@shared/types";
|
||||||
import { Inbox, StandupView, PerformanceView } from "../../components/inbox";
|
import { Inbox, StandupView, PerformanceView } from "../../components/inbox";
|
||||||
import { Dashboard } from "../../components/dashboard";
|
import { Dashboard } from "../../components/dashboard";
|
||||||
import { EntityWorkspace } from "../../components/EntityWorkspace";
|
import { TwoPaneLayout } from "../../components/TwoPaneLayout";
|
||||||
import {
|
import {
|
||||||
SettingsPane,
|
SettingsPane,
|
||||||
ModelManagement,
|
ModelManagement,
|
||||||
@@ -230,7 +230,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "workspaces" && currentCompany ? (
|
{section === "workspaces" && currentCompany ? (
|
||||||
<EntityWorkspace
|
<TwoPaneLayout
|
||||||
title="Workspaces"
|
title="Workspaces"
|
||||||
eyebrow={undefined}
|
eyebrow={undefined}
|
||||||
primary={
|
primary={
|
||||||
|
|||||||
Reference in new issue
Block a user