From 16d10c0814f5870f80fbebf6e1ca84ad2235a7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 01:54:20 -0400 Subject: [PATCH] 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 --- src/renderer/components/dashboard.tsx | 1065 +------------ .../components/dashboard/ActivityFeed.tsx | 75 + .../components/dashboard/Dashboard.tsx | 239 +++ .../dashboard/DepartmentOverview.tsx | 127 ++ .../components/dashboard/GoalProgress.tsx | 47 + .../components/dashboard/HealthCard.tsx | 109 ++ .../components/dashboard/KanbanBoard.tsx | 111 ++ .../components/dashboard/MetricsGrid.tsx | 49 + .../components/dashboard/NeedsAttention.tsx | 147 ++ .../components/dashboard/QuickActions.tsx | 79 + .../components/dashboard/SprintProgress.tsx | 69 + .../components/dashboard/TaskPipeline.tsx | 36 + src/renderer/components/organization.tsx | 1405 +---------------- .../components/organization/AgentCreator.tsx | 179 +++ .../components/organization/AgentEditor.tsx | 229 +++ .../components/organization/AgentRoster.tsx | 64 + .../components/organization/GoalCreator.tsx | 74 + .../components/organization/GoalEditor.tsx | 111 ++ .../components/organization/GoalList.tsx | 54 + .../components/organization/OrgChart.tsx | 132 ++ .../organization/ProjectCreator.tsx | 78 + .../components/organization/ProjectEditor.tsx | 113 ++ .../components/organization/ProjectList.tsx | 65 + .../components/organization/helpers.tsx | 128 ++ .../components/organization/orgTreeLayout.ts | 101 ++ 25 files changed, 2438 insertions(+), 2448 deletions(-) create mode 100644 src/renderer/components/dashboard/ActivityFeed.tsx create mode 100644 src/renderer/components/dashboard/Dashboard.tsx create mode 100644 src/renderer/components/dashboard/DepartmentOverview.tsx create mode 100644 src/renderer/components/dashboard/GoalProgress.tsx create mode 100644 src/renderer/components/dashboard/HealthCard.tsx create mode 100644 src/renderer/components/dashboard/KanbanBoard.tsx create mode 100644 src/renderer/components/dashboard/MetricsGrid.tsx create mode 100644 src/renderer/components/dashboard/NeedsAttention.tsx create mode 100644 src/renderer/components/dashboard/QuickActions.tsx create mode 100644 src/renderer/components/dashboard/SprintProgress.tsx create mode 100644 src/renderer/components/dashboard/TaskPipeline.tsx create mode 100644 src/renderer/components/organization/AgentCreator.tsx create mode 100644 src/renderer/components/organization/AgentEditor.tsx create mode 100644 src/renderer/components/organization/AgentRoster.tsx create mode 100644 src/renderer/components/organization/GoalCreator.tsx create mode 100644 src/renderer/components/organization/GoalEditor.tsx create mode 100644 src/renderer/components/organization/GoalList.tsx create mode 100644 src/renderer/components/organization/OrgChart.tsx create mode 100644 src/renderer/components/organization/ProjectCreator.tsx create mode 100644 src/renderer/components/organization/ProjectEditor.tsx create mode 100644 src/renderer/components/organization/ProjectList.tsx create mode 100644 src/renderer/components/organization/helpers.tsx create mode 100644 src/renderer/components/organization/orgTreeLayout.ts diff --git a/src/renderer/components/dashboard.tsx b/src/renderer/components/dashboard.tsx index 1e4510b5..52813b01 100644 --- a/src/renderer/components/dashboard.tsx +++ b/src/renderer/components/dashboard.tsx @@ -1,1053 +1,12 @@ -import { useEffect, useMemo, useState } from "react"; -import { motion } from "framer-motion"; -import { - ArrowRight, - Briefcase, - Buildings, - CalendarCheck, - ChartLineUp, - CheckCircle, - Clock, - Cpu, - CurrencyCircleDollar, - Heartbeat, - Lightning, - Robot, - Rocket, - ShieldCheck, - Target, - TreeStructure, - TrendUp, - Users, - Warning, - XCircle, -} 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, getAgentInitials, timeAgo } from "../lib/formatters"; -import { StatusPill } from "./ui"; -import { useT } from "../i18n"; - -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; -} - -function computeHealthScore(props: Pick): 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)]" }; -} - -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 ( -
-
- - - - -
- {score} -
-
-
-
Company Health
-
{label}
-
-
- ); -} - -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 ( - - ); -} - -const statusGlow: Record = { - 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 ( - - ); -} - -function WorkforceGrid({ agents, onSelectAgent }: { agents: AgentRecord[]; onSelectAgent: (id: string) => void }) { - if (agents.length === 0) { - return ( -
- -
No agents hired yet
-
Create a CEO agent to start building your company
-
- ); - } - - return ( -
- {agents.map((agent) => ( - onSelectAgent(agent.id)} /> - ))} -
- ); -} - -const actionIcons: Record = { - created: CheckCircle, - updated: Lightning, - deleted: XCircle, - started: Heartbeat, - completed: CheckCircle, - failed: Warning, - approved: ShieldCheck, - rejected: XCircle, -}; - -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 ( -
-
No activity yet. Start by creating agents and tasks.
-
- ); - } - - return ( -
- {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 ( - -
- -
-
-
- {actorName} - {" "} - {item.action} - {" "} - {item.entityType} - {item.detail ? ( - — {item.detail.slice(0, 60)}{item.detail.length > 60 ? "..." : ""} - ) : null} -
-
{timeAgo(item.createdAt)}
-
-
- ); - })} -
- ); -} - -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 ( -
- {stages.map((stage, i) => ( -
-
- {stage.count} - {stage.label} -
- {i < stages.length - 1 ? ( - - ) : null} -
- ))} -
- ); -} - -const DEPARTMENT_INFO: Record = { - 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" }, -}; - -function DepartmentOverview({ - agents, - tasks, - runs, - onNavigate, -}: { - agents: AgentRecord[]; - tasks: TaskRecord[]; - runs: RunRecord[]; - onNavigate: (section: SectionId) => void; -}) { - const departments = useMemo(() => { - const depts = new Map(); - 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 ( -
- {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 ( - - ); - })} -
- ); -} - -function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) { - const [nowMs, setNowMs] = useState(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 ( -
-
-
- - {sprint.name} - -
-
- - {daysLeft} days left -
-
- -
- -
- -
- {sprint.completedPoints}/{sprint.velocityPoints} points ({progress}%) -
- {sprintTasks.done} done - {sprintTasks.active} active - {sprintTasks.total} total -
-
- - {sprint.goal && ( -
"{sprint.goal}"
- )} -
- ); -} - -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 ( -
- {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 ( - - ); - })} -
- ); -} - -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 ( -
- {/* Health + Metrics */} -
- {/* Health Ring */} -
- -
-
-
{activeAgents.length}
-
Agents
-
-
-
-
{readyConnectors.length}/{connectors.length}
-
Connectors
-
-
-
-
{activeGoals.length}
-
Goals
-
-
-
- - {/* Metric Cards */} -
- 0 ? "success" : "default"} - onClick={() => onNavigate("runs")} - subtitle={liveRuns.length > 0 ? `${liveRuns.filter((r) => r.status === "running").length} ${t("dash.running")}` : t("dash.allQuiet")} - /> - 0 ? "accent" : "default"} - onClick={() => onNavigate("tasks")} - subtitle={`${tasks.filter((t) => t.status === "todo").length} ${t("dash.queued")}`} - /> - 0 ? "warn" : "default"} - onClick={() => onNavigate("approvals")} - subtitle={pendingApprovals.length > 0 ? t("dash.needsAttention") : t("dash.allClear")} - /> - 0 ? "danger" : "default"} - onClick={() => onNavigate("runs")} - /> - onNavigate("costs")} - /> - p.status === "in_progress").length} - icon={Target} - onClick={() => onNavigate("projects")} - subtitle={`${projects.length} ${t("dash.total")}`} - /> - 0 ? "warn" : unreadMessages.length > 0 ? "accent" : "default"} - onClick={() => onNavigate("communication")} - subtitle={urgentMessages.length > 0 ? `${urgentMessages.length} urgent` : unreadMessages.length > 0 ? "Needs triage" : "All read"} - /> - 0 ? "warn" : "default"} - onClick={() => onNavigate("social")} - subtitle={blockedBrowserActions.length > 0 ? "Blocked or failed" : "Nominal"} - /> - 0 ? "warn" : "default"} - onClick={() => onNavigate("automation")} - subtitle={stalledWorkflows.length > 0 ? "Paused or failed" : "Healthy"} - /> -
-
- - {/* Department Overview */} -
-
-
{t("dash.departments")}
- -
- -
- - {/* Sprint + Goals */} -
- {activeSprint ? ( -
-
-
{t("dash.currentSprint")}
- -
- -
- ) : null} - -
-
-
{t("dash.goalProgress")}
- -
- -
-
- - {/* Task Pipeline */} -
-
-
{t("dash.taskPipeline")}
- -
-
- -
-
- - {/* Workforce + Activity */} -
- {/* Workforce */} -
-
-
{t("dash.workforce")}
- -
- onNavigate("agents", id)} /> -
- - {/* Activity Feed */} -
-
-
{t("dash.recentActivity")}
- -
-
- -
-
-
- - {/* Connector Health */} -
-
{t("dash.infrastructure")}
-
- {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 ( - - ); - })} -
-
- - {/* Needs Attention */} - {(pendingApprovals.length > 0 || failedRuns.length > 0 || urgentMessages.length > 0 || blockedBrowserActions.length > 0 || stalledWorkflows.length > 0) ? ( -
-
{t("dash.needsAttentionSection")}
-
- {pendingApprovals.slice(0, 5).map((approval) => { - const requester = agents.find((a) => a.id === approval.requestedByAgentId); - return ( - - ); - })} - {failedRuns.slice(0, 3).map((run) => { - const agent = agents.find((a) => a.id === run.agentId); - return ( - - ); - })} - {urgentMessages.slice(0, 3).map((message) => { - const sender = agents.find((agent) => agent.id === message.fromAgentId); - return ( - - ); - })} - {blockedBrowserActions.slice(0, 3).map((action) => ( - - ))} - {stalledWorkflows.slice(0, 3).map((workflow) => ( - - ))} -
-
- ) : null} -
- ); -} - -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 = { - 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 = {}; - 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 ( -
- -
No tasks yet
-
Tasks created by agents will appear here on the board
-
- ); - } - - return ( -
- {KANBAN_COLUMNS.map((col) => { - const colTasks = tasksByStatus[col.status] ?? []; - return ( -
-
- - {col.label} - - {colTasks.length} - -
-
- {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 ( - - ); - })} -
-
- ); - })} -
- ); -} +export { Dashboard } from "./dashboard/Dashboard"; +export type { DashboardProps } from "./dashboard/Dashboard"; +export { KanbanBoard } from "./dashboard/KanbanBoard"; +export { HealthRing, computeHealthScore } from "./dashboard/HealthCard"; +export { MetricCard } from "./dashboard/MetricsGrid"; +export { ActivityFeed } from "./dashboard/ActivityFeed"; +export { WorkforceGrid } from "./dashboard/QuickActions"; +export { DepartmentOverview } from "./dashboard/DepartmentOverview"; +export { TaskPipeline } from "./dashboard/TaskPipeline"; +export { SprintProgress } from "./dashboard/SprintProgress"; +export { GoalProgress } from "./dashboard/GoalProgress"; +export { NeedsAttention } from "./dashboard/NeedsAttention"; diff --git a/src/renderer/components/dashboard/ActivityFeed.tsx b/src/renderer/components/dashboard/ActivityFeed.tsx new file mode 100644 index 00000000..45de1783 --- /dev/null +++ b/src/renderer/components/dashboard/ActivityFeed.tsx @@ -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 = { + 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 ( +
+
No activity yet. Start by creating agents and tasks.
+
+ ); + } + + return ( +
+ {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 ( + +
+ +
+
+
+ {actorName} + {" "} + {item.action} + {" "} + {item.entityType} + {item.detail ? ( + — {item.detail.slice(0, 60)}{item.detail.length > 60 ? "..." : ""} + ) : null} +
+
{timeAgo(item.createdAt)}
+
+
+ ); + })} +
+ ); +} diff --git a/src/renderer/components/dashboard/Dashboard.tsx b/src/renderer/components/dashboard/Dashboard.tsx new file mode 100644 index 00000000..5c8bdecf --- /dev/null +++ b/src/renderer/components/dashboard/Dashboard.tsx @@ -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 ( +
+ {/* Health + Metrics */} +
+
+ +
+
+
{activeAgents.length}
+
Agents
+
+
+
+
{readyConnectors.length}/{connectors.length}
+
Connectors
+
+
+
+
{activeGoals.length}
+
Goals
+
+
+
+ +
+ 0 ? "success" : "default"} onClick={() => onNavigate("runs")} subtitle={liveRuns.length > 0 ? `${liveRuns.filter((r) => r.status === "running").length} ${t("dash.running")}` : t("dash.allQuiet")} /> + 0 ? "accent" : "default"} onClick={() => onNavigate("tasks")} subtitle={`${tasks.filter((t) => t.status === "todo").length} ${t("dash.queued")}`} /> + 0 ? "warn" : "default"} onClick={() => onNavigate("approvals")} subtitle={pendingApprovals.length > 0 ? t("dash.needsAttention") : t("dash.allClear")} /> + 0 ? "danger" : "default"} onClick={() => onNavigate("runs")} /> + onNavigate("costs")} /> + p.status === "in_progress").length} icon={Target} onClick={() => onNavigate("projects")} subtitle={`${projects.length} ${t("dash.total")}`} /> + 0 ? "warn" : unreadMessages.length > 0 ? "accent" : "default"} onClick={() => onNavigate("communication")} subtitle={urgentMessages.length > 0 ? `${urgentMessages.length} urgent` : unreadMessages.length > 0 ? "Needs triage" : "All read"} /> + 0 ? "warn" : "default"} onClick={() => onNavigate("social")} subtitle={blockedBrowserActions.length > 0 ? "Blocked or failed" : "Nominal"} /> + 0 ? "warn" : "default"} onClick={() => onNavigate("automation")} subtitle={stalledWorkflows.length > 0 ? "Paused or failed" : "Healthy"} /> +
+
+ + {/* Department Overview */} +
+
+
{t("dash.departments")}
+ +
+ +
+ + {/* Sprint + Goals */} +
+ {activeSprint ? ( +
+
+
{t("dash.currentSprint")}
+ +
+ +
+ ) : null} +
+
+
{t("dash.goalProgress")}
+ +
+ +
+
+ + {/* Task Pipeline */} +
+
+
{t("dash.taskPipeline")}
+ +
+
+ +
+
+ + {/* Workforce + Activity */} +
+
+
+
{t("dash.workforce")}
+ +
+ onNavigate("agents", id)} /> +
+
+
+
{t("dash.recentActivity")}
+ +
+
+ +
+
+
+ + {/* Connector Health */} +
+
{t("dash.infrastructure")}
+
+ {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 ( + + ); + })} +
+
+ + {/* Needs Attention */} + +
+ ); +} diff --git a/src/renderer/components/dashboard/DepartmentOverview.tsx b/src/renderer/components/dashboard/DepartmentOverview.tsx new file mode 100644 index 00000000..248ceade --- /dev/null +++ b/src/renderer/components/dashboard/DepartmentOverview.tsx @@ -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 = { + 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(); + 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 ( +
+ {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 ( + + ); + })} +
+ ); +} diff --git a/src/renderer/components/dashboard/GoalProgress.tsx b/src/renderer/components/dashboard/GoalProgress.tsx new file mode 100644 index 00000000..7d42724d --- /dev/null +++ b/src/renderer/components/dashboard/GoalProgress.tsx @@ -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 ( +
+ {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 ( + + ); + })} +
+ ); +} diff --git a/src/renderer/components/dashboard/HealthCard.tsx b/src/renderer/components/dashboard/HealthCard.tsx new file mode 100644 index 00000000..1fb45ccd --- /dev/null +++ b/src/renderer/components/dashboard/HealthCard.tsx @@ -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 ( +
+
+ + + + +
+ {score} +
+
+
+
Company Health
+
{label}
+
+
+ ); +} diff --git a/src/renderer/components/dashboard/KanbanBoard.tsx b/src/renderer/components/dashboard/KanbanBoard.tsx new file mode 100644 index 00000000..10a77102 --- /dev/null +++ b/src/renderer/components/dashboard/KanbanBoard.tsx @@ -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 = { + 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 = {}; + 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 ( +
+ +
No tasks yet
+
Tasks created by agents will appear here on the board
+
+ ); + } + + return ( +
+ {KANBAN_COLUMNS.map((col) => { + const colTasks = tasksByStatus[col.status] ?? []; + return ( +
+
+ + {col.label} + + {colTasks.length} + +
+
+ {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 ( + + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/src/renderer/components/dashboard/MetricsGrid.tsx b/src/renderer/components/dashboard/MetricsGrid.tsx new file mode 100644 index 00000000..e0b88d6b --- /dev/null +++ b/src/renderer/components/dashboard/MetricsGrid.tsx @@ -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 ( + + ); +} diff --git a/src/renderer/components/dashboard/NeedsAttention.tsx b/src/renderer/components/dashboard/NeedsAttention.tsx new file mode 100644 index 00000000..79be31c3 --- /dev/null +++ b/src/renderer/components/dashboard/NeedsAttention.tsx @@ -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 ( +
+
{t("dash.needsAttentionSection")}
+
+ {pendingApprovals.slice(0, 5).map((approval) => { + const requester = agents.find((a) => a.id === approval.requestedByAgentId); + return ( + + ); + })} + {failedRuns.slice(0, 3).map((run) => { + const agent = agents.find((a) => a.id === run.agentId); + return ( + + ); + })} + {urgentMessages.slice(0, 3).map((message) => { + const sender = agents.find((agent) => agent.id === message.fromAgentId); + return ( + + ); + })} + {blockedBrowserActions.slice(0, 3).map((action) => ( + + ))} + {stalledWorkflows.slice(0, 3).map((workflow) => ( + + ))} +
+
+ ); +} diff --git a/src/renderer/components/dashboard/QuickActions.tsx b/src/renderer/components/dashboard/QuickActions.tsx new file mode 100644 index 00000000..0b776a78 --- /dev/null +++ b/src/renderer/components/dashboard/QuickActions.tsx @@ -0,0 +1,79 @@ +import { Robot } from "@phosphor-icons/react"; +import type { AgentRecord } from "@shared/types"; +import { getAgentInitials } from "../../lib/formatters"; + +const statusGlow: Record = { + 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 ( + + ); +} + +export function WorkforceGrid({ agents, onSelectAgent }: { agents: AgentRecord[]; onSelectAgent: (id: string) => void }) { + if (agents.length === 0) { + return ( +
+ +
No agents hired yet
+
Create a CEO agent to start building your company
+
+ ); + } + + return ( +
+ {agents.map((agent) => ( + onSelectAgent(agent.id)} /> + ))} +
+ ); +} diff --git a/src/renderer/components/dashboard/SprintProgress.tsx b/src/renderer/components/dashboard/SprintProgress.tsx new file mode 100644 index 00000000..685a9e48 --- /dev/null +++ b/src/renderer/components/dashboard/SprintProgress.tsx @@ -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(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 ( +
+
+
+ + {sprint.name} + +
+
+ + {daysLeft} days left +
+
+ +
+ +
+ +
+ {sprint.completedPoints}/{sprint.velocityPoints} points ({progress}%) +
+ {sprintTasks.done} done + {sprintTasks.active} active + {sprintTasks.total} total +
+
+ + {sprint.goal && ( +
"{sprint.goal}"
+ )} +
+ ); +} diff --git a/src/renderer/components/dashboard/TaskPipeline.tsx b/src/renderer/components/dashboard/TaskPipeline.tsx new file mode 100644 index 00000000..518d9e27 --- /dev/null +++ b/src/renderer/components/dashboard/TaskPipeline.tsx @@ -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 ( +
+ {stages.map((stage, i) => ( +
+
+ {stage.count} + {stage.label} +
+ {i < stages.length - 1 ? ( + + ) : null} +
+ ))} +
+ ); +} diff --git a/src/renderer/components/organization.tsx b/src/renderer/components/organization.tsx index 179722ef..fac2b701 100644 --- a/src/renderer/components/organization.tsx +++ b/src/renderer/components/organization.tsx @@ -1,1395 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - CheckCircle, - Heartbeat, - Kanban, - Lightning, - Pencil, - Robot, - Target, - Trash, - XCircle, -} from "@phosphor-icons/react"; -import type { - AgentRecord, - ConnectorRecord, - GoalRecord, - ProjectRecord, - WorkspaceRecord, -} from "@shared/types"; -import { getConnectorExecutionReadinessIssue, isConnectorExecutionReady } from "@shared/connector-policy"; -import { formatMoney, formatTime, getAgentInitials } from "../lib/formatters"; -import { unwrap } from "../lib/desktop"; -import { - ActionButton, - EmptyState, - Input, - ListFrame, - ListRow, - Select, - StatusPill, - TextArea, -} from "./ui"; -import { useT } from "../i18n"; - -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 ( -
-
-
-
- - {formatMoney(spent)} / {formatMoney(budget)} - -
- ); -} - -function ConfirmDelete({ - entityLabel, - onConfirm, - onCancel, -}: { - entityLabel: string; - onConfirm: () => void; - onCancel: () => void; -}) { - return ( -
- Delete {entityLabel}? - - -
- ); -} - -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 ; - } - return ( - - {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 ( - onSelect(agent.id)} - className={`cursor-pointer transition-colors duration-100 ${isSelected ? "bg-[color:var(--accent-soft)]" : "hover:bg-[color:var(--panel-soft)]"}`} - > -
-
- {isSelected && ( - - )} -
-
{agent.name}
-
- {agent.role} · {agent.title || "No title"} -
-
-
- -
-
- Connector: {connector?.label ?? agent.connectorId} - {workspace && Workspace: {workspace.name}} -
-
- -
-
- ); - })} -
- ); -} - -export function AgentCreator({ - companyId, - connectors, - workspaces, - agents, - onSaved, -}: { - companyId: string; - connectors: ConnectorRecord[]; - workspaces: WorkspaceRecord[]; - agents: AgentRecord[]; - onSaved: () => Promise; -}) { - 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>([]); - 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(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 ( -
- - - - ({ value: m.id, label: m.label }))} - /> - ) : null} - {supportsEffort ? ( - ({ value: workspace.id, label: workspace.name })), - ]} - /> - -