feat(01-03): decompose organization.tsx and dashboard.tsx into focused sub-components
- Split organization.tsx (1395 lines) into 12 files: AgentRoster, AgentCreator, AgentEditor, GoalList, GoalCreator, GoalEditor, ProjectList, ProjectCreator, ProjectEditor, OrgChart, helpers, orgTreeLayout - Split dashboard.tsx (1053 lines) into 11 files: Dashboard, HealthCard, MetricsGrid, ActivityFeed, QuickActions, DepartmentOverview, TaskPipeline, SprintProgress, GoalProgress, KanbanBoard, NeedsAttention - All sub-component files under 300 lines (max: 239 lines) - Original files replaced with backward-compatible re-exports - TypeScript compilation passes
This commit is contained in:
1 parent
ccfdbe73a3
commit
16d10c0814
25 files changed
+2438
-2448
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
CheckCircle,
|
||||
Heartbeat,
|
||||
Lightning,
|
||||
ShieldCheck,
|
||||
Warning,
|
||||
XCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ActivityRecord, AgentRecord } from "@shared/types";
|
||||
import { timeAgo } from "../../lib/formatters";
|
||||
|
||||
const actionIcons: Record<string, React.ElementType> = {
|
||||
created: CheckCircle,
|
||||
updated: Lightning,
|
||||
deleted: XCircle,
|
||||
started: Heartbeat,
|
||||
completed: CheckCircle,
|
||||
failed: Warning,
|
||||
approved: ShieldCheck,
|
||||
rejected: XCircle,
|
||||
};
|
||||
|
||||
export function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityRecord[]; agents: AgentRecord[]; limit?: number }) {
|
||||
const recentActivity = useMemo(() => {
|
||||
return [...activity]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, limit);
|
||||
}, [activity, limit]);
|
||||
|
||||
if (recentActivity.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center py-8 text-center">
|
||||
<div className="text-[13px] text-[color:var(--muted)]">No activity yet. Start by creating agents and tasks.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{recentActivity.map((item, index) => {
|
||||
const Icon = actionIcons[item.action] ?? Lightning;
|
||||
const agent = agents.find((a) => a.id === item.actor);
|
||||
const actorName = agent?.name ?? (item.actor === "board" ? "Board" : item.actor.slice(0, 8));
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={index === 0 ? { opacity: 0, y: -8 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-start gap-3 rounded-[6px] px-3 py-2 transition hover:bg-[color:var(--panel-soft)]"
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<Icon size={14} className="text-[color:var(--muted)]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] leading-relaxed text-[color:var(--text)]">
|
||||
<span className="font-medium">{actorName}</span>
|
||||
{" "}
|
||||
<span className="text-[color:var(--muted)]">{item.action}</span>
|
||||
{" "}
|
||||
<span className="text-[color:var(--muted-strong)]">{item.entityType}</span>
|
||||
{item.detail ? (
|
||||
<span className="text-[color:var(--muted)]"> — {item.detail.slice(0, 60)}{item.detail.length > 60 ? "..." : ""}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-[color:var(--muted)]">{timeAgo(item.createdAt)}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Briefcase,
|
||||
Cpu,
|
||||
CurrencyCircleDollar,
|
||||
Heartbeat,
|
||||
Lightning,
|
||||
ShieldCheck,
|
||||
Target,
|
||||
TreeStructure,
|
||||
Users,
|
||||
Warning,
|
||||
} from "@phosphor-icons/react";
|
||||
import type {
|
||||
ActivityRecord,
|
||||
AgentRecord,
|
||||
AgentMessageRecord,
|
||||
ApprovalRecord,
|
||||
BrowserActionRecord,
|
||||
ConnectorRecord,
|
||||
CostRecord,
|
||||
GoalRecord,
|
||||
MeetingRecord,
|
||||
ProjectRecord,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
SprintRecord,
|
||||
TaskRecord,
|
||||
WorkflowPipelineRecord,
|
||||
} from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { formatMoney } from "../../lib/formatters";
|
||||
import { StatusPill } from "../ui";
|
||||
import { useT } from "../../i18n";
|
||||
import { computeHealthScore, HealthRing } from "./HealthCard";
|
||||
import { MetricCard } from "./MetricsGrid";
|
||||
import { ActivityFeed } from "./ActivityFeed";
|
||||
import { WorkforceGrid } from "./QuickActions";
|
||||
import { DepartmentOverview } from "./DepartmentOverview";
|
||||
import { TaskPipeline } from "./TaskPipeline";
|
||||
import { SprintProgress } from "./SprintProgress";
|
||||
import { GoalProgress } from "./GoalProgress";
|
||||
import { NeedsAttention } from "./NeedsAttention";
|
||||
|
||||
export interface DashboardProps {
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
runs: RunRecord[];
|
||||
approvals: ApprovalRecord[];
|
||||
goals: GoalRecord[];
|
||||
projects: ProjectRecord[];
|
||||
activity: ActivityRecord[];
|
||||
costs: CostRecord[];
|
||||
connectors: ConnectorRecord[];
|
||||
messages?: AgentMessageRecord[];
|
||||
browserActions?: BrowserActionRecord[];
|
||||
workflows?: WorkflowPipelineRecord[];
|
||||
meetings?: MeetingRecord[];
|
||||
sprints?: SprintRecord[];
|
||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||
}
|
||||
|
||||
export function Dashboard(props: DashboardProps) {
|
||||
const t = useT();
|
||||
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, messages = [], browserActions = [], workflows = [], sprints = [], onNavigate } = props;
|
||||
|
||||
const healthScore = useMemo(
|
||||
() => computeHealthScore({ agents, tasks, runs, approvals, connectors, messages, browserActions, workflows }),
|
||||
[agents, tasks, runs, approvals, connectors, messages, browserActions, workflows],
|
||||
);
|
||||
|
||||
const liveRuns = useMemo(() => runs.filter((r) => r.status === "running" || r.status === "queued"), [runs]);
|
||||
const failedRuns = useMemo(() => runs.filter((r) => r.status === "failed" || r.status === "timed_out" || r.status === "interrupted"), [runs]);
|
||||
const pendingApprovals = useMemo(() => approvals.filter((a) => a.state === "pending"), [approvals]);
|
||||
const activeTasks = useMemo(() => tasks.filter((t) => t.status === "in_progress" || t.status === "in_review"), [tasks]);
|
||||
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
|
||||
const totalSpend = useMemo(() => costs.reduce((sum, c) => sum + (c.amountUsd ?? 0), 0), [costs]);
|
||||
const activeAgents = useMemo(() => agents.filter((a) => a.status !== "terminated" && a.status !== "pending_approval"), [agents]);
|
||||
const readyConnectors = useMemo(() => connectors.filter((connector) => isConnectorExecutionReady(connector)), [connectors]);
|
||||
const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]);
|
||||
const unreadMessages = useMemo(() => messages.filter((message) => !message.readAt), [messages]);
|
||||
const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
|
||||
const blockedBrowserActions = useMemo(() => browserActions.filter((action) => action.status === "approval_required" || action.status === "failed"), [browserActions]);
|
||||
const stalledWorkflows = useMemo(() => workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed"), [workflows]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Health + Metrics */}
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<div className="shrink-0 rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
|
||||
<HealthRing score={healthScore} />
|
||||
<div className="mt-3 flex gap-3">
|
||||
<div className="text-center">
|
||||
<div className="text-[16px] font-bold text-[color:var(--text)]">{activeAgents.length}</div>
|
||||
<div className="text-[10px] text-[color:var(--muted)]">Agents</div>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-[color:var(--line)]" />
|
||||
<div className="text-center">
|
||||
<div className="text-[16px] font-bold text-[color:var(--text)]">{readyConnectors.length}/{connectors.length}</div>
|
||||
<div className="text-[10px] text-[color:var(--muted)]">Connectors</div>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-[color:var(--line)]" />
|
||||
<div className="text-center">
|
||||
<div className="text-[16px] font-bold text-[color:var(--text)]">{activeGoals.length}</div>
|
||||
<div className="text-[10px] text-[color:var(--muted)]">Goals</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-2 gap-2 lg:grid-cols-3">
|
||||
<MetricCard label={t("dash.liveRuns")} value={liveRuns.length} icon={Heartbeat} tone={liveRuns.length > 0 ? "success" : "default"} onClick={() => onNavigate("runs")} subtitle={liveRuns.length > 0 ? `${liveRuns.filter((r) => r.status === "running").length} ${t("dash.running")}` : t("dash.allQuiet")} />
|
||||
<MetricCard label={t("dash.activeTasks")} value={activeTasks.length} icon={Briefcase} tone={activeTasks.length > 0 ? "accent" : "default"} onClick={() => onNavigate("tasks")} subtitle={`${tasks.filter((t) => t.status === "todo").length} ${t("dash.queued")}`} />
|
||||
<MetricCard label={t("dash.approvals")} value={pendingApprovals.length} icon={ShieldCheck} tone={pendingApprovals.length > 0 ? "warn" : "default"} onClick={() => onNavigate("approvals")} subtitle={pendingApprovals.length > 0 ? t("dash.needsAttention") : t("dash.allClear")} />
|
||||
<MetricCard label={t("dash.failedRuns")} value={failedRuns.length} icon={Warning} tone={failedRuns.length > 0 ? "danger" : "default"} onClick={() => onNavigate("runs")} />
|
||||
<MetricCard label={t("dash.totalSpend")} value={formatMoney(totalSpend)} icon={CurrencyCircleDollar} onClick={() => onNavigate("costs")} />
|
||||
<MetricCard label={t("dash.projects")} value={projects.filter((p) => p.status === "in_progress").length} icon={Target} onClick={() => onNavigate("projects")} subtitle={`${projects.length} ${t("dash.total")}`} />
|
||||
<MetricCard label={t("dash.unreadMessages")} value={unreadMessages.length} icon={Users} tone={urgentMessages.length > 0 ? "warn" : unreadMessages.length > 0 ? "accent" : "default"} onClick={() => onNavigate("communication")} subtitle={urgentMessages.length > 0 ? `${urgentMessages.length} urgent` : unreadMessages.length > 0 ? "Needs triage" : "All read"} />
|
||||
<MetricCard label={t("dash.browserOps")} value={blockedBrowserActions.length} icon={Cpu} tone={blockedBrowserActions.length > 0 ? "warn" : "default"} onClick={() => onNavigate("social")} subtitle={blockedBrowserActions.length > 0 ? "Blocked or failed" : "Nominal"} />
|
||||
<MetricCard label={t("dash.workflowStalls")} value={stalledWorkflows.length} icon={Lightning} tone={stalledWorkflows.length > 0 ? "warn" : "default"} onClick={() => onNavigate("automation")} subtitle={stalledWorkflows.length > 0 ? "Paused or failed" : "Healthy"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Department Overview */}
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("dash.departments")}</div>
|
||||
<button type="button" onClick={() => onNavigate("orgchart")} className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline">
|
||||
{t("dash.orgChart")} <TreeStructure size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} />
|
||||
</section>
|
||||
|
||||
{/* Sprint + Goals */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{activeSprint ? (
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("dash.currentSprint")}</div>
|
||||
<button type="button" onClick={() => onNavigate("sprints")} className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline">
|
||||
Manage <ArrowRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<SprintProgress sprint={activeSprint} tasks={tasks} />
|
||||
</section>
|
||||
) : null}
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("dash.goalProgress")}</div>
|
||||
<button type="button" onClick={() => onNavigate("goals")} className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline">
|
||||
All goals <ArrowRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<GoalProgress goals={goals} tasks={tasks} onNavigate={onNavigate} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Task Pipeline */}
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("dash.taskPipeline")}</div>
|
||||
<button type="button" onClick={() => onNavigate("tasks")} className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline">
|
||||
View all <ArrowRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||
<TaskPipeline tasks={tasks} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Workforce + Activity */}
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
<section className="lg:col-span-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("dash.workforce")}</div>
|
||||
<button type="button" onClick={() => onNavigate("orgchart")} className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline">
|
||||
{t("dash.orgChart")} <TreeStructure size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<WorkforceGrid agents={agents} onSelectAgent={(id) => onNavigate("agents", id)} />
|
||||
</section>
|
||||
<section className="lg:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("dash.recentActivity")}</div>
|
||||
<button type="button" onClick={() => onNavigate("activity")} className="flex items-center gap-1 text-[11px] font-medium text-[color:var(--accent)] transition hover:underline">
|
||||
View all <ArrowRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] py-2">
|
||||
<ActivityFeed activity={activity} agents={agents} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Connector Health */}
|
||||
<section>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{t("dash.infrastructure")}</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{connectors.map((connector) => {
|
||||
const isReady = isConnectorExecutionReady(connector);
|
||||
const isError = connector.status === "error" || connector.status === "auth_required";
|
||||
const agentsUsing = agents.filter((a) => a.connectorId === connector.id).length;
|
||||
return (
|
||||
<button
|
||||
key={connector.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("connectors", connector.id)}
|
||||
className={`focus-ring flex items-center gap-2.5 rounded-[8px] border px-3 py-2.5 text-left transition duration-150 hover:bg-[color:var(--panel-soft)] ${
|
||||
isError ? "border-[color:var(--danger)] border-opacity-30" : "border-[color:var(--line)]"
|
||||
}`}
|
||||
>
|
||||
<Cpu size={16} className={isReady ? "text-[color:var(--success)]" : isError ? "text-[color:var(--danger)]" : "text-[color:var(--muted)]"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-medium text-[color:var(--text)]">{connector.label}</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-[color:var(--muted)]">
|
||||
<StatusPill status={connector.status} />
|
||||
{agentsUsing > 0 ? <span>{agentsUsing} agent{agentsUsing > 1 ? "s" : ""}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Needs Attention */}
|
||||
<NeedsAttention
|
||||
agents={agents}
|
||||
approvals={approvals}
|
||||
runs={runs}
|
||||
messages={messages}
|
||||
browserActions={browserActions}
|
||||
workflows={workflows}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Buildings,
|
||||
ChartLineUp,
|
||||
Cpu,
|
||||
CurrencyCircleDollar,
|
||||
Heartbeat,
|
||||
Lightning,
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Target,
|
||||
TrendUp,
|
||||
Users,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { AgentRecord, RunRecord, SectionId, TaskRecord } from "@shared/types";
|
||||
|
||||
const DEPARTMENT_INFO: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
||||
executive: { label: "Executive", icon: Buildings, color: "var(--accent)" },
|
||||
engineering: { label: "Engineering", icon: Cpu, color: "#6366f1" },
|
||||
product: { label: "Product", icon: Rocket, color: "#8b5cf6" },
|
||||
design: { label: "Design", icon: Target, color: "#ec4899" },
|
||||
marketing: { label: "Marketing", icon: TrendUp, color: "#f59e0b" },
|
||||
sales: { label: "Sales", icon: ChartLineUp, color: "#10b981" },
|
||||
hr: { label: "People", icon: Users, color: "#06b6d4" },
|
||||
finance: { label: "Finance", icon: CurrencyCircleDollar, color: "#14b8a6" },
|
||||
legal: { label: "Legal", icon: ShieldCheck, color: "#64748b" },
|
||||
operations: { label: "Operations", icon: Lightning, color: "#f97316" },
|
||||
customer_support: { label: "Support", icon: Heartbeat, color: "#3b82f6" },
|
||||
research: { label: "Research", icon: Target, color: "#a855f7" },
|
||||
};
|
||||
|
||||
export function DepartmentOverview({
|
||||
agents,
|
||||
tasks,
|
||||
runs,
|
||||
onNavigate,
|
||||
}: {
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
runs: RunRecord[];
|
||||
onNavigate: (section: SectionId) => void;
|
||||
}) {
|
||||
const departments = useMemo(() => {
|
||||
const depts = new Map<string, { agents: AgentRecord[]; tasks: TaskRecord[]; activeRuns: number }>();
|
||||
for (const agent of agents) {
|
||||
if (agent.status === "terminated") continue;
|
||||
const dept = agent.department ?? "unassigned";
|
||||
if (!depts.has(dept)) depts.set(dept, { agents: [], tasks: [], activeRuns: 0 });
|
||||
depts.get(dept)!.agents.push(agent);
|
||||
}
|
||||
for (const task of tasks) {
|
||||
if (task.assigneeAgentId) {
|
||||
const agent = agents.find((a) => a.id === task.assigneeAgentId);
|
||||
if (agent?.department) {
|
||||
const dept = depts.get(agent.department);
|
||||
if (dept) dept.tasks.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (run.status === "running") {
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
if (agent?.department) {
|
||||
const dept = depts.get(agent.department);
|
||||
if (dept) dept.activeRuns++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(depts.entries())
|
||||
.filter(([key]) => key !== "unassigned")
|
||||
.sort((a, b) => b[1].agents.length - a[1].agents.length);
|
||||
}, [agents, tasks, runs]);
|
||||
|
||||
if (departments.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{departments.map(([deptId, data]) => {
|
||||
const info = DEPARTMENT_INFO[deptId] ?? { label: deptId, icon: Buildings, color: "var(--muted)" };
|
||||
const Icon = info.icon;
|
||||
const activeTasks = data.tasks.filter((t) => t.status === "in_progress" || t.status === "in_review").length;
|
||||
const doneTasks = data.tasks.filter((t) => t.status === "done").length;
|
||||
const blockedTasks = data.tasks.filter((t) => t.status === "blocked").length;
|
||||
const runningAgents = data.agents.filter((a) => a.status === "running").length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={deptId}
|
||||
type="button"
|
||||
onClick={() => onNavigate("agents")}
|
||||
className="focus-ring group relative overflow-hidden rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-3 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
|
||||
>
|
||||
<div className="absolute left-0 top-0 h-full w-[3px]" style={{ backgroundColor: info.color }} />
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<Icon size={14} style={{ color: info.color }} />
|
||||
<span className="text-[11px] font-semibold text-[color:var(--text)]">{info.label}</span>
|
||||
{runningAgents > 0 && (
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[color:var(--success)]" />
|
||||
<span className="text-[9px] text-[color:var(--success)]">{runningAgents}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 pl-1">
|
||||
<div>
|
||||
<div className="text-[14px] font-bold text-[color:var(--text)]">{data.agents.length}</div>
|
||||
<div className="text-[8px] uppercase tracking-[0.1em] text-[color:var(--muted)]">People</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-bold text-[color:var(--accent)]">{activeTasks}</div>
|
||||
<div className="text-[8px] uppercase tracking-[0.1em] text-[color:var(--muted)]">Active</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-[14px] font-bold ${blockedTasks > 0 ? "text-[color:var(--danger)]" : "text-[color:var(--success)]"}`}>
|
||||
{blockedTasks > 0 ? blockedTasks : doneTasks}
|
||||
</div>
|
||||
<div className="text-[8px] uppercase tracking-[0.1em] text-[color:var(--muted)]">
|
||||
{blockedTasks > 0 ? "Blocked" : "Done"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Target } from "@phosphor-icons/react";
|
||||
import type { GoalRecord, SectionId, TaskRecord } from "@shared/types";
|
||||
|
||||
export function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks: TaskRecord[]; onNavigate: (section: SectionId) => void }) {
|
||||
const activeGoals = useMemo(() => goals.filter((g) => g.status === "active"), [goals]);
|
||||
|
||||
if (activeGoals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{activeGoals.slice(0, 5).map((goal) => {
|
||||
const goalTasks = tasks.filter((t) => t.goalId === goal.id);
|
||||
const done = goalTasks.filter((t) => t.status === "done").length;
|
||||
const total = goalTasks.length;
|
||||
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={goal.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("goals")}
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 text-left transition hover:border-[color:var(--line-strong)]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={14} className="text-[color:var(--accent)]" />
|
||||
<span className="text-[12px] font-medium text-[color:var(--text)]">{goal.title}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-[color:var(--accent)]">{pct}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-[color:var(--accent)]"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-[color:var(--muted)]">{done}/{total} tasks complete</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type {
|
||||
AgentRecord,
|
||||
AgentMessageRecord,
|
||||
ApprovalRecord,
|
||||
BrowserActionRecord,
|
||||
ConnectorRecord,
|
||||
RunRecord,
|
||||
TaskRecord,
|
||||
WorkflowPipelineRecord,
|
||||
} from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
|
||||
interface HealthInputProps {
|
||||
agents: AgentRecord[];
|
||||
tasks: TaskRecord[];
|
||||
runs: RunRecord[];
|
||||
approvals: ApprovalRecord[];
|
||||
connectors: ConnectorRecord[];
|
||||
messages?: AgentMessageRecord[];
|
||||
browserActions?: BrowserActionRecord[];
|
||||
workflows?: WorkflowPipelineRecord[];
|
||||
}
|
||||
|
||||
export function computeHealthScore(props: HealthInputProps): number {
|
||||
let score = 100;
|
||||
const { agents, tasks, runs, approvals, connectors, messages = [], browserActions = [], workflows = [] } = props;
|
||||
|
||||
const failedRuns = runs.filter((r) => r.status === "failed" || r.status === "timed_out");
|
||||
score -= Math.min(failedRuns.length * 5, 25);
|
||||
|
||||
const blockedTasks = tasks.filter((t) => t.status === "blocked");
|
||||
score -= Math.min(blockedTasks.length * 8, 20);
|
||||
|
||||
const pendingApprovals = approvals.filter((a) => a.state === "pending");
|
||||
score -= Math.min(pendingApprovals.length * 5, 15);
|
||||
|
||||
const errorAgents = agents.filter((a) => a.status === "error");
|
||||
score -= Math.min(errorAgents.length * 10, 20);
|
||||
|
||||
const readyConnectors = connectors.filter((connector) => isConnectorExecutionReady(connector));
|
||||
if (readyConnectors.length === 0 && connectors.length > 0) score -= 15;
|
||||
|
||||
const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd);
|
||||
score -= Math.min(overBudget.length * 5, 10);
|
||||
|
||||
const urgentMessages = messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident"));
|
||||
score -= Math.min(urgentMessages.length * 4, 12);
|
||||
|
||||
const blockedBrowserActions = browserActions.filter((action) => action.status === "approval_required" || action.status === "failed");
|
||||
score -= Math.min(blockedBrowserActions.length * 4, 12);
|
||||
|
||||
const stalledWorkflows = workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed");
|
||||
score -= Math.min(stalledWorkflows.length * 5, 15);
|
||||
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
function healthLabel(score: number): { label: string; tone: string } {
|
||||
if (score >= 90) return { label: "Excellent", tone: "text-[color:var(--success)]" };
|
||||
if (score >= 70) return { label: "Good", tone: "text-[color:var(--success)]" };
|
||||
if (score >= 50) return { label: "Fair", tone: "text-[color:var(--warn)]" };
|
||||
if (score >= 30) return { label: "Poor", tone: "text-[color:var(--warn)]" };
|
||||
return { label: "Critical", tone: "text-[color:var(--danger)]" };
|
||||
}
|
||||
|
||||
export function HealthRing({ score }: { score: number }) {
|
||||
const radius = 38;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (score / 100) * circumference;
|
||||
const { label, tone } = healthLabel(score);
|
||||
|
||||
const strokeColor = score >= 70
|
||||
? "var(--success)"
|
||||
: score >= 50
|
||||
? "var(--warn)"
|
||||
: "var(--danger)";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative h-[90px] w-[90px] shrink-0">
|
||||
<svg viewBox="0 0 88 88" className="h-full w-full -rotate-90">
|
||||
<circle cx="44" cy="44" r={radius} fill="none" stroke="var(--line)" strokeWidth="6" />
|
||||
<motion.circle
|
||||
cx="44"
|
||||
cy="44"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className={`text-[22px] font-bold ${tone}`}>{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Company Health</div>
|
||||
<div className={`text-[16px] font-semibold ${tone}`}>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useMemo } from "react";
|
||||
import { Briefcase, Robot } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, RunRecord, TaskRecord } from "@shared/types";
|
||||
|
||||
interface KanbanProps {
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
runs: RunRecord[];
|
||||
onSelectTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; label: string; tone: string; dotColor: string }> = [
|
||||
{ status: "backlog", label: "Backlog", tone: "text-[color:var(--muted)]", dotColor: "bg-[color:var(--muted)]" },
|
||||
{ status: "todo", label: "Todo", tone: "text-[color:var(--warn)]", dotColor: "bg-[color:var(--warn)]" },
|
||||
{ status: "in_progress", label: "In Progress", tone: "text-[color:var(--accent)]", dotColor: "bg-[color:var(--accent)]" },
|
||||
{ status: "in_review", label: "In Review", tone: "text-[color:var(--warn)]", dotColor: "bg-[color:var(--warn)]" },
|
||||
{ status: "done", label: "Done", tone: "text-[color:var(--success)]", dotColor: "bg-[color:var(--success)]" },
|
||||
{ status: "blocked", label: "Blocked", tone: "text-[color:var(--danger)]", dotColor: "bg-[color:var(--danger)]" },
|
||||
];
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: "border-l-[color:var(--danger)]",
|
||||
high: "border-l-[color:var(--warn)]",
|
||||
medium: "border-l-[color:var(--accent)]",
|
||||
low: "border-l-[color:var(--muted)]",
|
||||
};
|
||||
|
||||
export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps) {
|
||||
const tasksByStatus = useMemo(() => {
|
||||
const grouped: Record<string, TaskRecord[]> = {};
|
||||
for (const col of KANBAN_COLUMNS) grouped[col.status] = [];
|
||||
for (const task of tasks) {
|
||||
if (task.status === "cancelled") continue;
|
||||
const bucket = grouped[task.status];
|
||||
if (bucket) bucket.push(task);
|
||||
else grouped.backlog?.push(task);
|
||||
}
|
||||
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
for (const col of Object.values(grouped)) {
|
||||
col.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9));
|
||||
}
|
||||
return grouped;
|
||||
}, [tasks]);
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
|
||||
<Briefcase size={32} className="mb-3 text-[color:var(--muted)]" />
|
||||
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No tasks yet</div>
|
||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">Tasks created by agents will appear here on the board</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{KANBAN_COLUMNS.map((col) => {
|
||||
const colTasks = tasksByStatus[col.status] ?? [];
|
||||
return (
|
||||
<div key={col.status} className="flex w-[220px] shrink-0 flex-col">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<span className={`h-2 w-2 rounded-full ${col.dotColor}`} />
|
||||
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span>
|
||||
<span className="ml-auto rounded-full bg-[color:var(--panel-soft)] px-1.5 py-0.5 text-[10px] font-bold text-[color:var(--muted)]">
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5 rounded-[8px] bg-[color:var(--panel-soft)] p-1.5" style={{ minHeight: 120 }}>
|
||||
{colTasks.map((task) => {
|
||||
const assignee = agents.find((a) => a.id === task.assigneeAgentId);
|
||||
const activeRun = runs.find((r) => r.id === task.activeRunId);
|
||||
const isRunning = activeRun?.status === "running";
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
onClick={() => onSelectTask(task.id)}
|
||||
className={`focus-ring rounded-[6px] border border-l-[3px] border-[color:var(--line)] bg-[color:var(--panel)] px-3 py-2.5 text-left transition hover:shadow-sm ${priorityColors[task.priority] ?? ""}`}
|
||||
>
|
||||
<div className="text-[12px] font-medium leading-snug text-[color:var(--text)]">{task.title}</div>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
{assignee ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[color:var(--muted)]">
|
||||
<Robot size={10} />
|
||||
{assignee.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] italic text-[color:var(--muted)]">Unassigned</span>
|
||||
)}
|
||||
{isRunning ? (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[color:var(--success)]">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[color:var(--success)]" />
|
||||
Running
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{task.priority === "critical" || task.priority === "high" ? (
|
||||
<div className={`mt-1 text-[9px] font-bold uppercase tracking-[0.12em] ${task.priority === "critical" ? "text-[color:var(--danger)]" : "text-[color:var(--warn)]"}`}>
|
||||
{task.priority}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type React from "react";
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
tone = "default",
|
||||
onClick,
|
||||
subtitle,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: React.ElementType;
|
||||
tone?: "default" | "success" | "warn" | "danger" | "accent";
|
||||
onClick?: () => void;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
const toneMap = {
|
||||
default: "text-[color:var(--text)]",
|
||||
success: "text-[color:var(--success)]",
|
||||
warn: "text-[color:var(--warn)]",
|
||||
danger: "text-[color:var(--danger)]",
|
||||
accent: "text-[color:var(--accent)]",
|
||||
};
|
||||
const bgMap = {
|
||||
default: "bg-[color:var(--panel-soft)]",
|
||||
success: "bg-[color:var(--success-soft)]",
|
||||
warn: "bg-[color:var(--warn-soft)]",
|
||||
danger: "bg-[color:var(--danger-soft)]",
|
||||
accent: "bg-[color:var(--accent-soft)]",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="focus-ring flex items-center gap-3.5 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3.5 text-left transition duration-150 hover:border-[color:var(--line-strong)] hover:shadow-sm"
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-[8px] ${bgMap[tone]}`}>
|
||||
<Icon size={20} weight="fill" className={toneMap[tone]} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.06em] text-[color:var(--muted)]">{label}</div>
|
||||
<div className={`text-[20px] font-bold tracking-[-0.02em] leading-tight ${toneMap[tone]}`}>{value}</div>
|
||||
{subtitle ? <div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">{subtitle}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Cpu,
|
||||
Lightning,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "@phosphor-icons/react";
|
||||
import type {
|
||||
AgentRecord,
|
||||
AgentMessageRecord,
|
||||
ApprovalRecord,
|
||||
BrowserActionRecord,
|
||||
RunRecord,
|
||||
SectionId,
|
||||
WorkflowPipelineRecord,
|
||||
} from "@shared/types";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
export function NeedsAttention({
|
||||
agents,
|
||||
approvals,
|
||||
runs,
|
||||
messages,
|
||||
browserActions,
|
||||
workflows,
|
||||
onNavigate,
|
||||
}: {
|
||||
agents: AgentRecord[];
|
||||
approvals: ApprovalRecord[];
|
||||
runs: RunRecord[];
|
||||
messages: AgentMessageRecord[];
|
||||
browserActions: BrowserActionRecord[];
|
||||
workflows: WorkflowPipelineRecord[];
|
||||
onNavigate: (section: SectionId, entityId?: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const pendingApprovals = useMemo(() => approvals.filter((a) => a.state === "pending"), [approvals]);
|
||||
const failedRuns = useMemo(() => runs.filter((r) => r.status === "failed" || r.status === "timed_out" || r.status === "interrupted"), [runs]);
|
||||
const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
|
||||
const blockedBrowserActions = useMemo(() => browserActions.filter((action) => action.status === "approval_required" || action.status === "failed"), [browserActions]);
|
||||
const stalledWorkflows = useMemo(() => workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed"), [workflows]);
|
||||
|
||||
if (pendingApprovals.length === 0 && failedRuns.length === 0 && urgentMessages.length === 0 && blockedBrowserActions.length === 0 && stalledWorkflows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--warn)]">{t("dash.needsAttentionSection")}</div>
|
||||
<div className="space-y-1.5">
|
||||
{pendingApprovals.slice(0, 5).map((approval) => {
|
||||
const requester = agents.find((a) => a.id === approval.requestedByAgentId);
|
||||
return (
|
||||
<button
|
||||
key={approval.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("approvals", approval.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<ShieldCheck size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{approval.payloadSummary}</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
|
||||
<span className="uppercase tracking-[0.1em]">{approval.type.replaceAll("_", " ")}</span>
|
||||
{requester ? <span>by {requester.name}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{failedRuns.slice(0, 3).map((run) => {
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
return (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("runs", run.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--danger)] border-opacity-20 bg-[color:var(--danger-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<XCircle size={18} className="shrink-0 text-[color:var(--danger)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{run.summary || "Run failed"}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
|
||||
{agent?.name} — {run.errorMessage?.slice(0, 80) ?? "Unknown error"}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{urgentMessages.slice(0, 3).map((message) => {
|
||||
const sender = agents.find((agent) => agent.id === message.fromAgentId);
|
||||
return (
|
||||
<button
|
||||
key={message.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("communication", message.id)}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Users size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{message.subject}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
|
||||
{sender?.name ?? "Unknown"} — {message.channel} {message.priority === "urgent" ? "· urgent" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{blockedBrowserActions.slice(0, 3).map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("social")}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Cpu size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{action.resultSummary || action.actionType}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Browser action {action.status.replaceAll("_", " ")}</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||
</button>
|
||||
))}
|
||||
{stalledWorkflows.slice(0, 3).map((workflow) => (
|
||||
<button
|
||||
key={workflow.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate("automation")}
|
||||
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
|
||||
>
|
||||
<Lightning size={18} className="shrink-0 text-[color:var(--warn)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{workflow.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Workflow {workflow.status}</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Robot } from "@phosphor-icons/react";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import { getAgentInitials } from "../../lib/formatters";
|
||||
|
||||
const statusGlow: Record<string, string> = {
|
||||
running: "ring-2 ring-[color:var(--success)] ring-opacity-50",
|
||||
active: "ring-1 ring-[color:var(--success)] ring-opacity-30",
|
||||
idle: "",
|
||||
paused: "opacity-60",
|
||||
error: "ring-2 ring-[color:var(--danger)] ring-opacity-50",
|
||||
pending_approval: "ring-1 ring-[color:var(--warn)] ring-opacity-40",
|
||||
terminated: "opacity-40",
|
||||
};
|
||||
|
||||
function AgentCard({ agent, onClick }: { agent: AgentRecord; onClick: () => void }) {
|
||||
const initials = getAgentInitials(agent.name);
|
||||
const isActive = agent.status === "running" || agent.status === "active";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`focus-ring group relative flex flex-col items-center gap-2 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3 py-3 text-center transition duration-150 hover:border-[color:var(--line-strong)] hover:shadow-sm ${statusGlow[agent.status] ?? ""}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={`flex h-11 w-11 items-center justify-center rounded-full text-[14px] font-bold ${
|
||||
isActive ? "bg-[color:var(--success-soft)] text-[color:var(--success)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
|
||||
}`}>
|
||||
{initials}
|
||||
</div>
|
||||
{agent.status === "running" ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[color:var(--success)] opacity-40" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[color:var(--success)]" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-[color:var(--panel)]"
|
||||
style={{
|
||||
backgroundColor: agent.status === "error"
|
||||
? "var(--danger)"
|
||||
: agent.status === "active"
|
||||
? "var(--success)"
|
||||
: agent.status === "pending_approval"
|
||||
? "var(--warn)"
|
||||
: agent.status === "paused" || agent.status === "terminated"
|
||||
? "var(--muted)"
|
||||
: "var(--muted)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
<div className="truncate text-[12px] font-semibold text-[color:var(--text)]">{agent.name}</div>
|
||||
<div className="truncate text-[10px] text-[color:var(--muted)]">{agent.title || agent.role}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkforceGrid({ agents, onSelectAgent }: { agents: AgentRecord[]; onSelectAgent: (id: string) => void }) {
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-10 text-center">
|
||||
<Robot size={32} className="mb-3 text-[color:var(--muted)]" />
|
||||
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No agents hired yet</div>
|
||||
<div className="mt-1 text-[12px] text-[color:var(--muted)]">Create a CEO agent to start building your company</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} onClick={() => onSelectAgent(agent.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { CalendarCheck, Clock } from "@phosphor-icons/react";
|
||||
import type { SprintRecord, TaskRecord } from "@shared/types";
|
||||
import { StatusPill } from "../ui";
|
||||
|
||||
export function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
|
||||
const [nowMs, setNowMs] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNowMs(Date.now());
|
||||
}, []);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (sprint.velocityPoints === 0) return 0;
|
||||
return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100));
|
||||
}, [sprint]);
|
||||
|
||||
const daysLeft = useMemo(() => {
|
||||
const end = new Date(sprint.endDate).getTime();
|
||||
const now = nowMs ?? end;
|
||||
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
|
||||
}, [sprint, nowMs]);
|
||||
|
||||
const sprintTasks = useMemo(() => {
|
||||
const done = tasks.filter((t) => t.status === "done").length;
|
||||
const active = tasks.filter((t) => t.status === "in_progress" || t.status === "in_review").length;
|
||||
const total = tasks.length;
|
||||
return { done, active, total };
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck size={16} className="text-[color:var(--accent)]" />
|
||||
<span className="text-[12px] font-semibold text-[color:var(--text)]">{sprint.name}</span>
|
||||
<StatusPill status={sprint.status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px] text-[color:var(--muted)]">
|
||||
<Clock size={12} />
|
||||
{daysLeft} days left
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-[color:var(--accent)]"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between text-[10px] text-[color:var(--muted)]">
|
||||
<span>{sprint.completedPoints}/{sprint.velocityPoints} points ({progress}%)</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[color:var(--success)]">{sprintTasks.done} done</span>
|
||||
<span className="text-[color:var(--accent)]">{sprintTasks.active} active</span>
|
||||
<span>{sprintTasks.total} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sprint.goal && (
|
||||
<div className="mt-2 text-[11px] italic text-[color:var(--muted)]">"{sprint.goal}"</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMemo } from "react";
|
||||
import { ArrowRight } from "@phosphor-icons/react";
|
||||
import type { TaskRecord } from "@shared/types";
|
||||
|
||||
export function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
|
||||
const stages = useMemo(() => {
|
||||
const counts = { backlog: 0, todo: 0, in_progress: 0, in_review: 0, done: 0, blocked: 0 };
|
||||
for (const t of tasks) {
|
||||
if (t.status in counts) counts[t.status as keyof typeof counts]++;
|
||||
}
|
||||
return [
|
||||
{ label: "Backlog", count: counts.backlog, tone: "text-[color:var(--muted)]", bg: "bg-[color:var(--panel-soft)]" },
|
||||
{ label: "Todo", count: counts.todo, tone: "text-[color:var(--warn)]", bg: "bg-[color:var(--warn-soft)]" },
|
||||
{ label: "In Progress", count: counts.in_progress, tone: "text-[color:var(--accent)]", bg: "bg-[color:var(--accent-soft)]" },
|
||||
{ label: "In Review", count: counts.in_review, tone: "text-[color:var(--warn)]", bg: "bg-[color:var(--warn-soft)]" },
|
||||
{ label: "Done", count: counts.done, tone: "text-[color:var(--success)]", bg: "bg-[color:var(--success-soft)]" },
|
||||
{ label: "Blocked", count: counts.blocked, tone: "text-[color:var(--danger)]", bg: "bg-[color:var(--danger-soft)]" },
|
||||
];
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{stages.map((stage, i) => (
|
||||
<div key={stage.label} className="flex items-center gap-1.5">
|
||||
<div className={`flex items-center gap-2 rounded-[8px] px-3 py-2 ${stage.bg}`}>
|
||||
<span className={`text-[18px] font-bold ${stage.tone}`}>{stage.count}</span>
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.06em] text-[color:var(--muted)]">{stage.label}</span>
|
||||
</div>
|
||||
{i < stages.length - 1 ? (
|
||||
<ArrowRight size={12} className="text-[color:var(--muted)]" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Robot } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
|
||||
export function AgentCreator({
|
||||
companyId,
|
||||
connectors,
|
||||
workspaces,
|
||||
agents,
|
||||
onSaved,
|
||||
}: {
|
||||
companyId: string;
|
||||
connectors: ConnectorRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
agents: AgentRecord[];
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const executionReadyConnectors = useMemo(
|
||||
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
|
||||
[connectors],
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [connectorId, setConnectorId] = useState(executionReadyConnectors[0]?.id ?? "");
|
||||
const [model, setModel] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [workspaceId, setWorkspaceId] = useState(workspaces[0]?.id ?? "");
|
||||
const [budget, setBudget] = useState("120");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
const [reportsTo, setReportsTo] = useState("");
|
||||
const [thinkingEffort, setThinkingEffort] = useState("");
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local";
|
||||
const selectedConnector = executionReadyConnectors.find((connector) => connector.id === connectorId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
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("");
|
||||
}
|
||||
}, [connectorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectorId && executionReadyConnectors[0]) {
|
||||
setConnectorId(executionReadyConnectors[0].id);
|
||||
return;
|
||||
}
|
||||
if (connectorId && !executionReadyConnectors.some((connector) => connector.id === connectorId)) {
|
||||
setConnectorId(executionReadyConnectors[0]?.id ?? "");
|
||||
}
|
||||
}, [executionReadyConnectors, connectorId]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" />
|
||||
<Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" />
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" />
|
||||
<Select
|
||||
label="Connector"
|
||||
value={connectorId}
|
||||
onChange={(value) => setConnectorId(value as ConnectorRecord["id"])}
|
||||
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.label }))}
|
||||
/>
|
||||
{executionReadyConnectors.length === 0 ? (
|
||||
<p className="text-[12px] text-[color:var(--warn)]">
|
||||
No execution-ready connector is available. Fix connector health before requesting hires.
|
||||
</p>
|
||||
) : null}
|
||||
{availableModels.length > 0 ? (
|
||||
<Select
|
||||
label="Model"
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={availableModels.map((m) => ({ value: m.id, label: m.label }))}
|
||||
/>
|
||||
) : null}
|
||||
{supportsEffort ? (
|
||||
<Select
|
||||
label="Thinking effort"
|
||||
value={thinkingEffort}
|
||||
onChange={setThinkingEffort}
|
||||
options={[
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
label="Workspace"
|
||||
value={workspaceId}
|
||||
onChange={setWorkspaceId}
|
||||
options={[
|
||||
{ value: "", label: "No workspace" },
|
||||
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Reports to"
|
||||
value={reportsTo}
|
||||
onChange={setReportsTo}
|
||||
options={[
|
||||
{ value: "", label: "No manager" },
|
||||
...agents.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" />
|
||||
<TextArea
|
||||
label="Capabilities"
|
||||
value={capabilities}
|
||||
onChange={setCapabilities}
|
||||
placeholder="What this agent is expected to do and when it becomes relevant."
|
||||
/>
|
||||
<p className="text-[12px] text-[color:var(--muted)]">
|
||||
New agents enter the hiring pipeline first. Approval activates the agent and keeps onboarding, budgets, and org history linked to the same record.
|
||||
</p>
|
||||
<ActionButton
|
||||
label="Submit hire request"
|
||||
tone="accent"
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
setRequestError(null);
|
||||
if (!selectedConnector) {
|
||||
setRequestError("Select an execution-ready connector before requesting a hire.");
|
||||
return;
|
||||
}
|
||||
const meta: Record<string, string> = {};
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
unwrap(await window.agentCompany.requestHire({
|
||||
companyId,
|
||||
requestedByAgentId: null,
|
||||
name,
|
||||
role,
|
||||
title,
|
||||
department: null,
|
||||
connectorId: connectorId as ConnectorRecord["id"],
|
||||
workspaceId: workspaceId || null,
|
||||
reportsTo: reportsTo || null,
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
capabilities,
|
||||
budgetMonthlyUsd: Number(budget || 0),
|
||||
}));
|
||||
setName("");
|
||||
setRole("");
|
||||
setTitle("");
|
||||
setCapabilities("");
|
||||
setReportsTo("");
|
||||
setRequestError(null);
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setRequestError(error instanceof Error ? error.message : "Hire request failed.");
|
||||
}
|
||||
})()
|
||||
}
|
||||
icon={Robot}
|
||||
disabled={!selectedConnector}
|
||||
/>
|
||||
{requestError ? <p className="text-[12px] text-[color:var(--danger)]">{requestError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Trash } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
import { ConfirmDelete, HeartbeatSection } from "./helpers";
|
||||
|
||||
export function AgentEditor({
|
||||
agent,
|
||||
connectors,
|
||||
workspaces,
|
||||
agents,
|
||||
companyId,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
agent: AgentRecord;
|
||||
connectors: ConnectorRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
agents: AgentRecord[];
|
||||
companyId: string;
|
||||
onSaved: () => Promise<void>;
|
||||
onDeleted: () => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState(agent.name);
|
||||
const [role, setRole] = useState(agent.role);
|
||||
const [title, setTitle] = useState(agent.title);
|
||||
const [status, setStatus] = useState<AgentRecord["status"]>(agent.status);
|
||||
const [connectorId, setConnectorId] = useState<ConnectorRecord["id"]>(agent.connectorId);
|
||||
const [model, setModel] = useState(agent.model ?? "");
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [workspaceId, setWorkspaceId] = useState(agent.workspaceId ?? "");
|
||||
const [budget, setBudget] = useState(String(agent.budgetMonthlyUsd));
|
||||
const [capabilities, setCapabilities] = useState(agent.capabilities);
|
||||
const [reportsTo, setReportsTo] = useState(agent.reportsTo ?? "");
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const existingMeta = useMemo(() => { try { return JSON.parse(agent.metadataJson || "{}"); } catch { return {}; } }, [agent.metadataJson]);
|
||||
const [thinkingEffort, setThinkingEffort] = useState<string>(existingMeta.thinkingEffort ?? "");
|
||||
const supportsEffort = connectorId === "claude_local" || connectorId === "codex_local";
|
||||
const pendingApproval = agent.status === "pending_approval";
|
||||
const executionReadyConnectors = useMemo(
|
||||
() => connectors.filter((connector) => isConnectorExecutionReady(connector)),
|
||||
[connectors],
|
||||
);
|
||||
const selectedConnectorRecord = connectors.find((connector) => connector.id === connectorId) ?? null;
|
||||
const selectedConnectorIssue = selectedConnectorRecord ? getConnectorExecutionReadinessIssue(selectedConnectorRecord) : "Select a connector.";
|
||||
const persistedConnectorRecord = connectors.find((connector) => connector.id === agent.connectorId) ?? null;
|
||||
const persistedConnectorIssue = persistedConnectorRecord ? getConnectorExecutionReadinessIssue(persistedConnectorRecord) : "Current connector is unavailable.";
|
||||
const connectorOptions = useMemo(() => {
|
||||
const readyOptions = executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.label }));
|
||||
if (!selectedConnectorRecord || isConnectorExecutionReady(selectedConnectorRecord)) {
|
||||
return readyOptions;
|
||||
}
|
||||
return [
|
||||
{ value: selectedConnectorRecord.id, label: `${selectedConnectorRecord.label} (current, blocked)` },
|
||||
...readyOptions.filter((option) => option.value !== selectedConnectorRecord.id),
|
||||
];
|
||||
}, [executionReadyConnectors, selectedConnectorRecord]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const result = await window.agentCompany.listConnectorModels(connectorId);
|
||||
if (result.ok) {
|
||||
setAvailableModels(result.data);
|
||||
if (connectorId !== agent.connectorId) {
|
||||
setModel(result.data[0]?.id ?? "");
|
||||
}
|
||||
}
|
||||
})();
|
||||
if (connectorId !== "claude_local" && connectorId !== "codex_local") {
|
||||
setThinkingEffort("");
|
||||
}
|
||||
}, [connectorId, agent.connectorId]);
|
||||
|
||||
const managerCandidates = agents.filter((a) => a.id !== agent.id);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Input label="Role" value={role} onChange={setRole} placeholder="Role" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v as AgentRecord["status"])}
|
||||
disabled={pendingApproval}
|
||||
options={pendingApproval
|
||||
? [{ value: "pending_approval", label: "Pending Approval" }]
|
||||
: [
|
||||
{ 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-[color:var(--muted)]">
|
||||
Pending hires must be approved or rejected from Hiring or Approvals. Status changes are intentionally locked here.
|
||||
</p>
|
||||
) : null}
|
||||
<Select
|
||||
label="Connector"
|
||||
value={connectorId}
|
||||
onChange={(v) => setConnectorId(v as ConnectorRecord["id"])}
|
||||
disabled={pendingApproval}
|
||||
options={connectorOptions}
|
||||
/>
|
||||
{selectedConnectorIssue ? (
|
||||
<p className="text-[12px] text-[color:var(--warn)]">
|
||||
{selectedConnectorIssue}
|
||||
</p>
|
||||
) : null}
|
||||
{availableModels.length > 0 ? (
|
||||
<Select
|
||||
label="Model"
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={pendingApproval}
|
||||
options={availableModels.map((m) => ({ value: m.id, label: m.label }))}
|
||||
/>
|
||||
) : null}
|
||||
{supportsEffort ? (
|
||||
<Select
|
||||
label="Thinking effort"
|
||||
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="Workspace"
|
||||
value={workspaceId}
|
||||
onChange={setWorkspaceId}
|
||||
disabled={pendingApproval}
|
||||
options={[
|
||||
{ value: "", label: "No workspace" },
|
||||
...workspaces.map((w) => ({ value: w.id, label: w.name })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Reports to"
|
||||
value={reportsTo}
|
||||
onChange={setReportsTo}
|
||||
disabled={pendingApproval}
|
||||
options={[
|
||||
{ value: "", label: "No manager" },
|
||||
...managerCandidates.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
<TextArea label="Capabilities" value={capabilities} onChange={setCapabilities} placeholder="Capabilities" readOnly={pendingApproval} disabled={pendingApproval} />
|
||||
|
||||
<HeartbeatSection agent={agent} companyId={companyId} pendingApproval={pendingApproval} persistedConnectorIssue={persistedConnectorIssue} onSaved={onSaved} />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionButton
|
||||
label="Save changes"
|
||||
tone="accent"
|
||||
icon={Pencil}
|
||||
disabled={pendingApproval || !!selectedConnectorIssue}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
setSaveError(null);
|
||||
const meta = { ...existingMeta };
|
||||
if (thinkingEffort) meta.thinkingEffort = thinkingEffort;
|
||||
else delete meta.thinkingEffort;
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveAgent({
|
||||
id: agent.id,
|
||||
companyId,
|
||||
name,
|
||||
role,
|
||||
title,
|
||||
status,
|
||||
connectorId,
|
||||
workspaceId: workspaceId || null,
|
||||
reportsTo: reportsTo || null,
|
||||
model: model || null,
|
||||
metadataJson: JSON.stringify(meta),
|
||||
budgetMonthlyUsd: Number(budget || 0),
|
||||
capabilities,
|
||||
}));
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "Failed to save agent.");
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
{confirmingDelete ? (
|
||||
<ConfirmDelete
|
||||
entityLabel={agent.name}
|
||||
onConfirm={() =>
|
||||
void (async () => {
|
||||
await window.agentCompany.deleteAgent({ id: agent.id, companyId });
|
||||
await onDeleted();
|
||||
})()
|
||||
}
|
||||
onCancel={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Delete"
|
||||
tone="danger"
|
||||
icon={Trash}
|
||||
disabled={pendingApproval}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{pendingApproval ? (
|
||||
<p className="text-[12px] text-[color:var(--muted)]">
|
||||
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-[color:var(--danger)]">{saveError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CheckCircle } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { EmptyState, ListFrame, ListRow, StatusPill } from "../ui";
|
||||
import { useT } from "../../i18n";
|
||||
import { BudgetBar } from "./helpers";
|
||||
|
||||
export function AgentRoster({
|
||||
agents,
|
||||
connectors,
|
||||
workspaces,
|
||||
selectedAgentId,
|
||||
onSelect,
|
||||
}: {
|
||||
agents: AgentRecord[];
|
||||
connectors: ConnectorRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
selectedAgentId: string | null;
|
||||
onSelect: (agentId: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
if (agents.length === 0) {
|
||||
return <EmptyState title={t("org.noAgentsYet")} />;
|
||||
}
|
||||
return (
|
||||
<ListFrame>
|
||||
{agents.map((agent) => {
|
||||
const connector = connectors.find((c) => c.id === agent.connectorId);
|
||||
const workspace = agent.workspaceId
|
||||
? workspaces.find((w) => w.id === agent.workspaceId)
|
||||
: null;
|
||||
const isSelected = agent.id === selectedAgentId;
|
||||
return (
|
||||
<ListRow
|
||||
key={agent.id}
|
||||
onClick={() => onSelect(agent.id)}
|
||||
className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && (
|
||||
<CheckCircle size={16} weight="fill" className="text-[color:var(--accent)]" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-[15px] text-[color:var(--text)]">{agent.name}</div>
|
||||
<div className="text-[12px] text-[color:var(--muted)]">
|
||||
{agent.role} · {agent.title || "No title"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={agent.status} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[12px] text-[color:var(--muted)]">
|
||||
<span>Connector: {connector?.label ?? agent.connectorId}</span>
|
||||
{workspace && <span>Workspace: {workspace.name}</span>}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<BudgetBar spent={agent.spentMonthlyUsd} budget={agent.budgetMonthlyUsd} />
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { Target } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, GoalRecord } from "@shared/types";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
|
||||
export function GoalCreator({
|
||||
companyId,
|
||||
agents,
|
||||
goals,
|
||||
onSaved,
|
||||
}: {
|
||||
companyId: string;
|
||||
agents: AgentRecord[];
|
||||
goals: GoalRecord[];
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [ownerAgentId, setOwnerAgentId] = useState(agents[0]?.id ?? "");
|
||||
const [parentId, setParentId] = useState("");
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Input label="Goal title" value={title} onChange={setTitle} placeholder="Goal title" />
|
||||
<TextArea
|
||||
label="Success criteria"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="What must become true for this goal to count as achieved?"
|
||||
/>
|
||||
<Select
|
||||
label="Owner agent"
|
||||
value={ownerAgentId}
|
||||
onChange={setOwnerAgentId}
|
||||
options={[
|
||||
{ value: "", label: "No owner" },
|
||||
...agents.map((agent) => ({ value: agent.id, label: agent.name })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Parent goal"
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
options={[
|
||||
{ value: "", label: "No parent goal" },
|
||||
...goals.map((g) => ({ value: g.id, label: g.title })),
|
||||
]}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Create goal"
|
||||
tone="accent"
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
await window.agentCompany.saveGoal({
|
||||
companyId,
|
||||
title,
|
||||
description,
|
||||
ownerAgentId: ownerAgentId || null,
|
||||
parentId: parentId || null,
|
||||
status: "active",
|
||||
});
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
void error;
|
||||
}
|
||||
})()
|
||||
}
|
||||
icon={Target}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from "react";
|
||||
import { Pencil, Trash } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, GoalRecord } from "@shared/types";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
import { ConfirmDelete } from "./helpers";
|
||||
|
||||
export function GoalEditor({
|
||||
goal,
|
||||
goals,
|
||||
agents,
|
||||
companyId,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
goal: GoalRecord;
|
||||
goals: GoalRecord[];
|
||||
agents: AgentRecord[];
|
||||
companyId: string;
|
||||
onSaved: () => Promise<void>;
|
||||
onDeleted: () => Promise<void>;
|
||||
}) {
|
||||
const [title, setTitle] = useState(goal.title);
|
||||
const [description, setDescription] = useState(goal.description);
|
||||
const [status, setStatus] = useState<GoalRecord["status"]>(goal.status);
|
||||
const [parentId, setParentId] = useState(goal.parentId ?? "");
|
||||
const [ownerAgentId, setOwnerAgentId] = useState(goal.ownerAgentId ?? "");
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
const parentCandidates = goals.filter((g) => g.id !== goal.id);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Input label="Goal title" value={title} onChange={setTitle} placeholder="Goal title" />
|
||||
<TextArea label="Description" value={description} onChange={setDescription} placeholder="Description" />
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v as GoalRecord["status"])}
|
||||
options={[
|
||||
{ value: "planned", label: "Planned" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "achieved", label: "Achieved" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Parent goal"
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
options={[
|
||||
{ value: "", label: "No parent goal" },
|
||||
...parentCandidates.map((g) => ({ value: g.id, label: g.title })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Owner agent"
|
||||
value={ownerAgentId}
|
||||
onChange={setOwnerAgentId}
|
||||
options={[
|
||||
{ value: "", label: "No owner" },
|
||||
...agents.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<ActionButton
|
||||
label="Save changes"
|
||||
tone="accent"
|
||||
icon={Pencil}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
await window.agentCompany.saveGoal({
|
||||
id: goal.id,
|
||||
companyId,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
parentId: parentId || null,
|
||||
ownerAgentId: ownerAgentId || null,
|
||||
});
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
void error;
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
{confirmingDelete ? (
|
||||
<ConfirmDelete
|
||||
entityLabel={goal.title}
|
||||
onConfirm={() =>
|
||||
void (async () => {
|
||||
await window.agentCompany.deleteGoal({ id: goal.id, companyId });
|
||||
await onDeleted();
|
||||
})()
|
||||
}
|
||||
onCancel={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Delete"
|
||||
tone="danger"
|
||||
icon={Trash}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CheckCircle } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, GoalRecord } from "@shared/types";
|
||||
import { EmptyState, ListFrame, ListRow, StatusPill } from "../ui";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
export function GoalList({
|
||||
goals,
|
||||
agents,
|
||||
selectedGoalId,
|
||||
onSelect,
|
||||
}: {
|
||||
goals: GoalRecord[];
|
||||
agents: AgentRecord[];
|
||||
selectedGoalId: string | null;
|
||||
onSelect: (goalId: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
if (goals.length === 0) {
|
||||
return <EmptyState title={t("org.noGoalsYet")} />;
|
||||
}
|
||||
return (
|
||||
<ListFrame>
|
||||
{goals.map((goal) => {
|
||||
const isSelected = goal.id === selectedGoalId;
|
||||
const parentGoal = goal.parentId ? goals.find((g) => g.id === goal.parentId) : null;
|
||||
const ownerAgent = goal.ownerAgentId
|
||||
? agents.find((a) => a.id === goal.ownerAgentId)
|
||||
: null;
|
||||
return (
|
||||
<ListRow
|
||||
key={goal.id}
|
||||
onClick={() => onSelect(goal.id)}
|
||||
className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && (
|
||||
<CheckCircle size={16} weight="fill" className="text-[color:var(--accent)]" />
|
||||
)}
|
||||
<div className="text-[15px] text-[color:var(--text)]">{goal.title}</div>
|
||||
</div>
|
||||
<StatusPill status={goal.status} />
|
||||
</div>
|
||||
<div className="text-[13px] text-[color:var(--muted)]">{goal.description}</div>
|
||||
<div className="mt-1 flex gap-4 text-[12px] text-[color:var(--muted)]">
|
||||
{parentGoal && <span>Parent: {parentGoal.title}</span>}
|
||||
{ownerAgent && <span>Owner: {ownerAgent.name}</span>}
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Heartbeat } from "@phosphor-icons/react";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import { getAgentInitials } from "../../lib/formatters";
|
||||
import { EmptyState } from "../ui";
|
||||
import { useT } from "../../i18n";
|
||||
import { buildOrgTree, layoutForest, flattenLayout, collectEdges, statusDotColor, adapterLabels, CARD_W, CARD_H, CHART_PADDING } from "./orgTreeLayout";
|
||||
|
||||
export function OrgChart({ agents, onSelectAgent }: { agents: AgentRecord[]; onSelectAgent?: (agentId: string) => void }) {
|
||||
const t = useT();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||
|
||||
const orgTree = useMemo(() => buildOrgTree(agents), [agents]);
|
||||
const layout = useMemo(() => layoutForest(orgTree), [orgTree]);
|
||||
const allNodes = useMemo(() => flattenLayout(layout), [layout]);
|
||||
const edges = useMemo(() => collectEdges(layout), [layout]);
|
||||
const bounds = useMemo(() => {
|
||||
if (allNodes.length === 0) return { width: 800, height: 600 };
|
||||
let maxX = 0, maxY = 0;
|
||||
for (const n of allNodes) { maxX = Math.max(maxX, n.x + CARD_W); maxY = Math.max(maxY, n.y + CARD_H); }
|
||||
return { width: maxX + CHART_PADDING, height: maxY + CHART_PADDING };
|
||||
}, [allNodes]);
|
||||
|
||||
const hasInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return;
|
||||
hasInitialized.current = true;
|
||||
const cW = containerRef.current.clientWidth;
|
||||
const cH = containerRef.current.clientHeight;
|
||||
const fitZoom = Math.min((cW - 40) / bounds.width, (cH - 40) / bounds.height, 1);
|
||||
setZoom(fitZoom);
|
||||
setPan({ x: (cW - bounds.width * fitZoom) / 2, y: (cH - bounds.height * fitZoom) / 2 });
|
||||
}, [allNodes, bounds]);
|
||||
|
||||
const fitToScreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const cW = containerRef.current.clientWidth;
|
||||
const cH = containerRef.current.clientHeight;
|
||||
const fitZoom = Math.min((cW - 40) / bounds.width, (cH - 40) / bounds.height, 1);
|
||||
setZoom(fitZoom);
|
||||
setPan({ x: (cW - bounds.width * fitZoom) / 2, y: (cH - bounds.height * fitZoom) / 2 });
|
||||
}, [bounds]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || (e.target as HTMLElement).closest("[data-org-card]")) return;
|
||||
setDragging(true);
|
||||
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
|
||||
}, [pan]);
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
setPan({ x: dragStart.current.panX + (e.clientX - dragStart.current.x), y: dragStart.current.panY + (e.clientY - dragStart.current.y) });
|
||||
}, [dragging]);
|
||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
||||
const nz = Math.min(Math.max(zoom * (e.deltaY < 0 ? 1.1 : 0.9), 0.2), 2);
|
||||
const s = nz / zoom;
|
||||
setPan({ x: mx - s * (mx - pan.x), y: my - s * (my - pan.y) });
|
||||
setZoom(nz);
|
||||
}, [zoom, pan]);
|
||||
|
||||
if (agents.length === 0) return <EmptyState title={t("org.noAgentsToDisplay")} />;
|
||||
|
||||
const zoomIn = () => { const nz = Math.min(zoom * 1.2, 2); const c = containerRef.current; if (c) { const cx = c.clientWidth / 2; const cy = c.clientHeight / 2; const s = nz / zoom; setPan({ x: cx - s * (cx - pan.x), y: cy - s * (cy - pan.y) }); } setZoom(nz); };
|
||||
const zoomOut = () => { const nz = Math.max(zoom * 0.8, 0.2); const c = containerRef.current; if (c) { const cx = c.clientWidth / 2; const cy = c.clientHeight / 2; const s = nz / zoom; setPan({ x: cx - s * (cx - pan.x), y: cy - s * (cy - pan.y) }); } setZoom(nz); };
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-[480px] w-full overflow-hidden rounded-[10px] border border-[color:var(--line)] bg-[color:var(--bg)]" style={{ cursor: dragging ? "grabbing" : "grab" }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel}>
|
||||
<div className="absolute right-3 top-3 z-10 flex flex-col gap-1">
|
||||
{[{ label: "+", ariaLabel: "Zoom in", action: zoomIn }, { label: "\u2212", ariaLabel: "Zoom out", action: zoomOut }, { label: "Fit", ariaLabel: "Fit chart to screen", action: fitToScreen }].map((btn) => (
|
||||
<button key={btn.ariaLabel} className="flex h-7 w-7 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[color:var(--panel)] text-[12px] text-[color:var(--text)] transition hover:bg-[color:var(--panel-soft)]" onClick={btn.action} aria-label={btn.ariaLabel}>{btn.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<svg className="pointer-events-none absolute inset-0" style={{ width: "100%", height: "100%" }}>
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||
{edges.map(({ parent, child }) => {
|
||||
const x1 = parent.x + CARD_W / 2, y1 = parent.y + CARD_H, x2 = child.x + CARD_W / 2, y2 = child.y;
|
||||
const midY = (y1 + y2) / 2;
|
||||
const isChildRunning = child.agent.status === "running";
|
||||
return <path key={`${parent.agent.id}-${child.agent.id}`} d={`M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`} fill="none" stroke={isChildRunning ? "var(--success)" : "var(--line-strong)"} strokeWidth={isChildRunning ? 2 : 1.5} strokeDasharray={isChildRunning ? "6 3" : "none"} opacity={isChildRunning ? 0.8 : 0.5} />;
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
<div className="absolute inset-0" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: "0 0" }}>
|
||||
{allNodes.map((node) => {
|
||||
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
|
||||
const connectorLabel = adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
|
||||
const initials = getAgentInitials(node.agent.name);
|
||||
const isRunning = node.agent.status === "running";
|
||||
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;
|
||||
return (
|
||||
<div key={node.agent.id} data-org-card className={`absolute cursor-pointer select-none rounded-[10px] border bg-[color:var(--panel)] shadow-sm transition-all duration-200 hover:shadow-lg ${isRunning ? "border-[color:var(--success)] shadow-[0_0_12px_rgba(74,222,128,0.15)]" : isError ? "border-[color:var(--danger)] border-opacity-50" : isPending ? "border-[color:var(--warn)] border-dashed" : "border-[color:var(--line)] hover:border-[color:var(--line-strong)]"}`} style={{ left: node.x, top: node.y, width: CARD_W, minHeight: CARD_H }} onClick={() => onSelectAgent?.(node.agent.id)}>
|
||||
<div className={`h-[3px] rounded-t-[9px] ${isCEO ? "bg-[color:var(--accent)]" : node.children.length > 0 ? "bg-[color:var(--warn)]" : "bg-[color:var(--muted)] opacity-30"}`} />
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="relative shrink-0">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-full text-[13px] font-bold ${isActive ? "bg-[color:var(--success-soft)] text-[color:var(--success)]" : isError ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"}`}>{initials}</div>
|
||||
{isRunning ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-40" style={{ backgroundColor: dotColor }} />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full border-2 border-[color:var(--panel)]" style={{ backgroundColor: dotColor }} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-[color:var(--panel)]" style={{ backgroundColor: dotColor }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start">
|
||||
<span className="text-[13px] font-semibold leading-tight text-[color:var(--text)]">{node.agent.name}</span>
|
||||
<span className="mt-0.5 text-[11px] leading-tight text-[color:var(--muted)]">{node.agent.title || node.agent.role}</span>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span className="rounded bg-[color:var(--panel-soft)] px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{connectorLabel}</span>
|
||||
{node.agent.heartbeatEnabled ? <Heartbeat size={10} className="text-[color:var(--success)]" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { Kanban } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, GoalRecord } from "@shared/types";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
|
||||
export function ProjectCreator({
|
||||
companyId,
|
||||
goals,
|
||||
agents,
|
||||
onSaved,
|
||||
}: {
|
||||
companyId: string;
|
||||
goals: GoalRecord[];
|
||||
agents: AgentRecord[];
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [goalId, setGoalId] = useState(goals[0]?.id ?? "");
|
||||
const [leadAgentId, setLeadAgentId] = useState(agents[0]?.id ?? "");
|
||||
const [targetDate, setTargetDate] = useState("");
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Input label="Project name" value={name} onChange={setName} placeholder="Project name" />
|
||||
<TextArea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="What delivery lane does this project own?"
|
||||
/>
|
||||
<Select
|
||||
label="Linked goal"
|
||||
value={goalId}
|
||||
onChange={setGoalId}
|
||||
options={[
|
||||
{ value: "", label: "No linked goal" },
|
||||
...goals.map((goal) => ({ value: goal.id, label: goal.title })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Lead agent"
|
||||
value={leadAgentId}
|
||||
onChange={setLeadAgentId}
|
||||
options={[
|
||||
{ value: "", label: "No lead" },
|
||||
...agents.map((agent) => ({ value: agent.id, label: agent.name })),
|
||||
]}
|
||||
/>
|
||||
<Input label="Target date" value={targetDate} onChange={setTargetDate} placeholder="YYYY-MM-DD" />
|
||||
<ActionButton
|
||||
label="Create project"
|
||||
tone="accent"
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
await window.agentCompany.saveProject({
|
||||
companyId,
|
||||
goalId: goalId || null,
|
||||
name,
|
||||
description,
|
||||
leadAgentId: leadAgentId || null,
|
||||
targetDate: targetDate || null,
|
||||
status: "planned",
|
||||
});
|
||||
setName("");
|
||||
setDescription("");
|
||||
setTargetDate("");
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
void error;
|
||||
}
|
||||
})()
|
||||
}
|
||||
icon={Kanban}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { Pencil, Trash } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, GoalRecord, ProjectRecord } from "@shared/types";
|
||||
import { ActionButton, Input, Select, TextArea } from "../ui";
|
||||
import { ConfirmDelete } from "./helpers";
|
||||
|
||||
export function ProjectEditor({
|
||||
project,
|
||||
goals,
|
||||
agents,
|
||||
companyId,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
goals: GoalRecord[];
|
||||
agents: AgentRecord[];
|
||||
companyId: string;
|
||||
onSaved: () => Promise<void>;
|
||||
onDeleted: () => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [description, setDescription] = useState(project.description);
|
||||
const [status, setStatus] = useState<ProjectRecord["status"]>(project.status);
|
||||
const [goalId, setGoalId] = useState(project.goalId ?? "");
|
||||
const [leadAgentId, setLeadAgentId] = useState(project.leadAgentId ?? "");
|
||||
const [targetDate, setTargetDate] = useState(project.targetDate ?? "");
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Input label="Project name" value={name} onChange={setName} placeholder="Project name" />
|
||||
<TextArea label="Description" value={description} onChange={setDescription} placeholder="Description" />
|
||||
<Select
|
||||
label="Status"
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v as ProjectRecord["status"])}
|
||||
options={[
|
||||
{ value: "backlog", label: "Backlog" },
|
||||
{ value: "planned", label: "Planned" },
|
||||
{ value: "in_progress", label: "In Progress" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Linked goal"
|
||||
value={goalId}
|
||||
onChange={setGoalId}
|
||||
options={[
|
||||
{ value: "", label: "No linked goal" },
|
||||
...goals.map((g) => ({ value: g.id, label: g.title })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Lead agent"
|
||||
value={leadAgentId}
|
||||
onChange={setLeadAgentId}
|
||||
options={[
|
||||
{ value: "", label: "No lead" },
|
||||
...agents.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
<Input label="Target date" value={targetDate} onChange={setTargetDate} placeholder="YYYY-MM-DD" />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionButton
|
||||
label="Save changes"
|
||||
tone="accent"
|
||||
icon={Pencil}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
try {
|
||||
await window.agentCompany.saveProject({
|
||||
id: project.id,
|
||||
companyId,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
goalId: goalId || null,
|
||||
leadAgentId: leadAgentId || null,
|
||||
targetDate: targetDate || null,
|
||||
});
|
||||
await onSaved();
|
||||
} catch (error) {
|
||||
void error;
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
{confirmingDelete ? (
|
||||
<ConfirmDelete
|
||||
entityLabel={project.name}
|
||||
onConfirm={() =>
|
||||
void (async () => {
|
||||
await window.agentCompany.deleteProject({ id: project.id, companyId });
|
||||
await onDeleted();
|
||||
})()
|
||||
}
|
||||
onCancel={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Delete"
|
||||
tone="danger"
|
||||
icon={Trash}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CheckCircle } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, GoalRecord, ProjectRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { EmptyState, ListFrame, ListRow, StatusPill } from "../ui";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
export function ProjectList({
|
||||
projects,
|
||||
goals,
|
||||
agents,
|
||||
workspaces,
|
||||
selectedProjectId,
|
||||
onSelect,
|
||||
}: {
|
||||
projects: ProjectRecord[];
|
||||
goals: GoalRecord[];
|
||||
agents: AgentRecord[];
|
||||
workspaces: WorkspaceRecord[];
|
||||
selectedProjectId: string | null;
|
||||
onSelect: (projectId: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
if (projects.length === 0) {
|
||||
return <EmptyState title={t("org.noProjectsYet")} />;
|
||||
}
|
||||
return (
|
||||
<ListFrame>
|
||||
{projects.map((project) => {
|
||||
const isSelected = project.id === selectedProjectId;
|
||||
const wsCount = workspaces.filter((w) => w.projectId === project.id).length;
|
||||
return (
|
||||
<ListRow
|
||||
key={project.id}
|
||||
onClick={() => onSelect(project.id)}
|
||||
className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && (
|
||||
<CheckCircle size={16} weight="fill" className="text-[color:var(--accent)]" />
|
||||
)}
|
||||
<div className="text-[15px] text-[color:var(--text)]">{project.name}</div>
|
||||
</div>
|
||||
<StatusPill status={project.status} />
|
||||
</div>
|
||||
<div className="mb-2 text-[13px] text-[color:var(--muted)]">{project.description}</div>
|
||||
<div className="flex flex-wrap gap-4 text-[12px] text-[color:var(--muted)]">
|
||||
<span>
|
||||
Goal: {goals.find((g) => g.id === project.goalId)?.title ?? "None"}
|
||||
</span>
|
||||
<span>
|
||||
Lead: {agents.find((a) => a.id === project.leadAgentId)?.name ?? "Unassigned"}
|
||||
</span>
|
||||
{project.targetDate && <span>Target: {project.targetDate}</span>}
|
||||
{wsCount > 0 && (
|
||||
<span>
|
||||
{wsCount} workspace{wsCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Heartbeat, Lightning, Trash, XCircle } from "@phosphor-icons/react";
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
import { ActionButton, Select } from "../ui";
|
||||
import { formatMoney, formatTime } from "../../lib/formatters";
|
||||
|
||||
export function BudgetBar({ spent, budget }: { spent: number; budget: number }) {
|
||||
const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
|
||||
const overBudget = spent > budget && budget > 0;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[color:var(--line)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${overBudget ? "bg-[color:var(--danger)]" : "bg-[color:var(--success)]"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[11px] ${overBudget ? "text-[color:var(--danger)]" : "text-[color:var(--muted)]"}`}>
|
||||
{formatMoney(spent)} / {formatMoney(budget)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDelete({
|
||||
entityLabel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
entityLabel: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[8px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3">
|
||||
<span className="text-[13px] text-[color:var(--danger)]">Delete {entityLabel}?</span>
|
||||
<ActionButton label="Yes, delete" tone="danger" onClick={onConfirm} icon={Trash} />
|
||||
<ActionButton label="Cancel" onClick={onCancel} icon={XCircle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVALS = [
|
||||
{ value: "30", label: "30 seconds" },
|
||||
{ value: "60", label: "1 minute" },
|
||||
{ value: "120", label: "2 minutes" },
|
||||
{ value: "300", label: "5 minutes" },
|
||||
{ value: "600", label: "10 minutes" },
|
||||
{ value: "1800", label: "30 minutes" },
|
||||
{ value: "3600", label: "1 hour" },
|
||||
];
|
||||
|
||||
export function HeartbeatSection({
|
||||
agent,
|
||||
companyId,
|
||||
pendingApproval,
|
||||
persistedConnectorIssue,
|
||||
onSaved,
|
||||
}: {
|
||||
agent: AgentRecord;
|
||||
companyId: string;
|
||||
pendingApproval: boolean;
|
||||
persistedConnectorIssue: string | null;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[color:var(--line)] p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Heartbeat size={16} className="text-[color:var(--accent)]" />
|
||||
<span className="text-[13px] font-semibold text-[color:var(--text)]">Continuous Execution</span>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agent.heartbeatEnabled}
|
||||
disabled={pendingApproval}
|
||||
onChange={(e) =>
|
||||
void (async () => {
|
||||
await window.agentCompany.setHeartbeat({ agentId: agent.id, companyId, enabled: e.target.checked, intervalSec: agent.heartbeatIntervalSec || 120 });
|
||||
await onSaved();
|
||||
})()
|
||||
}
|
||||
className="h-4 w-4 rounded border-[color:var(--line)] accent-[color:var(--accent)]"
|
||||
/>
|
||||
<span className="text-[13px] text-[color:var(--text)]">Enable heartbeat</span>
|
||||
</label>
|
||||
{agent.heartbeatEnabled && (
|
||||
<span className="text-[11px] text-[color:var(--muted)]">
|
||||
Every {agent.heartbeatIntervalSec}s{agent.lastHeartbeatAt ? ` · Last: ${formatTime(agent.lastHeartbeatAt)}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{agent.heartbeatEnabled && (
|
||||
<Select
|
||||
label="Interval"
|
||||
value={String(agent.heartbeatIntervalSec)}
|
||||
onChange={(v) =>
|
||||
void (async () => {
|
||||
await window.agentCompany.setHeartbeat({ agentId: agent.id, companyId, enabled: true, intervalSec: Number(v) });
|
||||
await onSaved();
|
||||
})()
|
||||
}
|
||||
disabled={pendingApproval}
|
||||
options={HEARTBEAT_INTERVALS}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<ActionButton
|
||||
label="Wake now"
|
||||
tone="accent"
|
||||
icon={Lightning}
|
||||
disabled={pendingApproval || !!persistedConnectorIssue}
|
||||
onClick={() =>
|
||||
void (async () => {
|
||||
await window.agentCompany.triggerHeartbeat({ agentId: agent.id, companyId, trigger: "manual" });
|
||||
await onSaved();
|
||||
})()
|
||||
}
|
||||
/>
|
||||
{persistedConnectorIssue ? (
|
||||
<p className="mt-2 text-[12px] text-[color:var(--warn)]">
|
||||
{persistedConnectorIssue} Save a ready connector before waking this agent again.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { AgentRecord } from "@shared/types";
|
||||
|
||||
export const CARD_W = 220;
|
||||
export const CARD_H = 110;
|
||||
export const GAP_X = 32;
|
||||
export const GAP_Y = 80;
|
||||
export const CHART_PADDING = 60;
|
||||
|
||||
export interface OrgTreeNode {
|
||||
agent: AgentRecord;
|
||||
children: OrgTreeNode[];
|
||||
}
|
||||
|
||||
export interface LayoutNode {
|
||||
agent: AgentRecord;
|
||||
x: number;
|
||||
y: number;
|
||||
children: LayoutNode[];
|
||||
}
|
||||
|
||||
export function buildOrgTree(agents: AgentRecord[]): OrgTreeNode[] {
|
||||
const agentIds = new Set(agents.map((a) => a.id));
|
||||
const childrenMap = new Map<string, AgentRecord[]>();
|
||||
const roots: AgentRecord[] = [];
|
||||
for (const agent of agents) {
|
||||
if (!agent.reportsTo || !agentIds.has(agent.reportsTo)) {
|
||||
roots.push(agent);
|
||||
} else {
|
||||
const list = childrenMap.get(agent.reportsTo) ?? [];
|
||||
list.push(agent);
|
||||
childrenMap.set(agent.reportsTo, list);
|
||||
}
|
||||
}
|
||||
function toNode(agent: AgentRecord): OrgTreeNode {
|
||||
const kids = childrenMap.get(agent.id) ?? [];
|
||||
return { agent, children: kids.map(toNode) };
|
||||
}
|
||||
return roots.map(toNode);
|
||||
}
|
||||
|
||||
export function subtreeWidth(node: OrgTreeNode): number {
|
||||
if (node.children.length === 0) return CARD_W;
|
||||
const childrenW = node.children.reduce((sum, c) => sum + subtreeWidth(c), 0);
|
||||
const gaps = (node.children.length - 1) * GAP_X;
|
||||
return Math.max(CARD_W, childrenW + gaps);
|
||||
}
|
||||
|
||||
function layoutTree(node: OrgTreeNode, x: number, y: number): LayoutNode {
|
||||
const totalW = subtreeWidth(node);
|
||||
const layoutChildren: LayoutNode[] = [];
|
||||
if (node.children.length > 0) {
|
||||
const childrenW = node.children.reduce((sum, c) => sum + subtreeWidth(c), 0);
|
||||
const gaps = (node.children.length - 1) * GAP_X;
|
||||
let cx = x + (totalW - childrenW - gaps) / 2;
|
||||
for (const child of node.children) {
|
||||
const cw = subtreeWidth(child);
|
||||
layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y));
|
||||
cx += cw + GAP_X;
|
||||
}
|
||||
}
|
||||
return { agent: node.agent, x: x + (totalW - CARD_W) / 2, y, children: layoutChildren };
|
||||
}
|
||||
|
||||
export function layoutForest(roots: OrgTreeNode[]): LayoutNode[] {
|
||||
let x = CHART_PADDING;
|
||||
return roots.map((root) => {
|
||||
const w = subtreeWidth(root);
|
||||
const node = layoutTree(root, x, CHART_PADDING);
|
||||
x += w + GAP_X;
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
export function flattenLayout(nodes: LayoutNode[]): LayoutNode[] {
|
||||
const result: LayoutNode[] = [];
|
||||
function walk(n: LayoutNode) { result.push(n); n.children.forEach(walk); }
|
||||
nodes.forEach(walk);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> {
|
||||
const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = [];
|
||||
function walk(n: LayoutNode) { for (const c of n.children) { edges.push({ parent: n, child: c }); walk(c); } }
|
||||
nodes.forEach(walk);
|
||||
return edges;
|
||||
}
|
||||
|
||||
export const statusDotColor: Record<string, string> = {
|
||||
running: "#22d3ee",
|
||||
active: "#4ade80",
|
||||
paused: "#facc15",
|
||||
idle: "#facc15",
|
||||
error: "#f87171",
|
||||
terminated: "#a3a3a3",
|
||||
};
|
||||
|
||||
export const adapterLabels: Record<string, string> = {
|
||||
claude_local: "Claude",
|
||||
codex_local: "Codex",
|
||||
gemini_local: "Gemini",
|
||||
};
|
||||
Reference in new issue
Block a user