This commit is contained in:
ZacharyZhang-NY committed 2026-03-16 13:07:14 -04:00
1 parent 41eafc7c44
commit 54bcc77ef7
19 files changed
+11420 -64

No files matched your search

+333 -2
View File
@@ -2,21 +2,33 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
ArrowClockwise,
Bell,
BookOpen,
Brain,
CalendarBlank,
CaretUpDown,
ChartBar,
ChatCircle,
Check,
Command,
Cpu,
CurrencyCircleDollar,
FileText,
FolderOpen,
Gear,
Heartbeat,
Kanban,
Lightning,
ListChecks,
MagnifyingGlass,
Megaphone,
Plus,
Pulse,
Robot,
Rows,
ShieldCheck,
Trash,
UserPlus,
SquaresFour,
Target,
TreeStructure,
@@ -27,14 +39,23 @@ import {
X,
} from "@phosphor-icons/react";
import type {
AgentMessageRecord,
AutomationRuleRecord,
CompanyRecord,
CompanyMetrics,
DesktopEvent,
InboxItem,
ProfileSnapshot,
RunRecord,
SectionId,
SocialAccountRecord,
BrowserActionRecord,
StandupReport,
WorkflowPipelineRecord,
} from "@shared/types";
import type { SidebarBadges } from "@shared/contracts";
import { CompanyOnboarding } from "./components/CompanyOnboarding";
import { Inbox, StandupView, PerformanceView } from "./components/inbox";
import { ConnectorEditor } from "./components/connectors";
import { Dashboard, KanbanBoard } from "./components/dashboard";
import { EntityWorkspace } from "./components/EntityWorkspace";
@@ -65,6 +86,10 @@ import {
ProjectEditor,
ProjectList,
} from "./components/organization";
import { SocialAccountsPanel } from "./components/social";
import { AutomationPanel } from "./components/automation";
import { CommunicationHub } from "./components/communication";
import { HiringPipeline } from "./components/hiring";
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
import { unwrap } from "./lib/desktop";
import { formatMoney, formatTime } from "./lib/formatters";
@@ -77,6 +102,9 @@ const navGroups: NavGroup[] = [
label: "Overview",
items: [
{ id: "overview", label: "Dashboard", icon: SquaresFour },
{ id: "inbox", label: "Inbox", icon: Bell },
{ id: "standup", label: "Standup", icon: Heartbeat },
{ id: "performance", label: "Performance", icon: ChartBar },
],
},
{
@@ -84,17 +112,35 @@ const navGroups: NavGroup[] = [
items: [
{ id: "agents", label: "Agents", icon: Robot },
{ id: "orgchart", label: "Org Chart", icon: TreeStructure },
{ id: "hiring", label: "Hiring", icon: UserPlus },
{ id: "communication", label: "Messages", icon: ChatCircle },
{ id: "goals", label: "Goals", icon: Target },
{ id: "projects", label: "Projects", icon: Kanban },
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "sprints", label: "Sprints", icon: Rows },
],
},
{
label: "Automation",
items: [
{ id: "automation", label: "Automation", icon: Lightning },
{ id: "approvals", label: "Approvals", icon: ShieldCheck },
],
},
{
label: "Knowledge",
items: [
{ id: "documents", label: "Documents", icon: FileText },
{ id: "knowledge", label: "Knowledge", icon: Brain },
{ id: "meetings", label: "Meetings", icon: CalendarBlank },
],
},
{
label: "Operations",
items: [
{ id: "approvals", label: "Approvals", icon: ShieldCheck },
{ id: "costs", label: "Costs", icon: CurrencyCircleDollar },
{ id: "activity", label: "Activity", icon: Pulse },
{ id: "social", label: "Social Media", icon: Megaphone },
],
},
{
@@ -135,6 +181,11 @@ export function App() {
const [badges, setBadges] = useState<SidebarBadges | null>(null);
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
const [showOnboarding, setShowOnboarding] = useState(false);
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [companyMetrics, setCompanyMetrics] = useState<CompanyMetrics | null>(null);
const [standupReport, setStandupReport] = useState<StandupReport | null>(null);
const [socialAccounts, setSocialAccounts] = useState<SocialAccountRecord[]>([]);
const [browserActions, setBrowserActions] = useState<BrowserActionRecord[]>([]);
const refresh = useCallback(async () => {
try {
@@ -147,6 +198,7 @@ export function App() {
if (next.currentCompanyId) {
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
if (badgeResult.ok) setBadges(badgeResult.data);
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
}
setError(null);
} catch (nextError) {
@@ -196,6 +248,36 @@ export function App() {
root.style.colorScheme = snapshot.theme === "system" ? "light dark" : snapshot.theme;
}, [snapshot]);
useEffect(() => {
if (!snapshot?.currentCompanyId) return;
const companyId = snapshot.currentCompanyId;
if (section === "inbox") {
void window.agentCompany.getInbox(companyId).then((result) => {
if (result.ok) setInboxItems(result.data);
});
}
if (section === "performance") {
void window.agentCompany.getCompanyMetrics({ companyId, days: 30 }).then((result) => {
if (result.ok) setCompanyMetrics(result.data);
});
}
if (section === "standup") {
void window.agentCompany.getStandupReport(companyId).then((result) => {
if (result.ok) setStandupReport(result.data);
});
}
if (section === "social") {
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
void window.agentCompany.listBrowserActions({ companyId, limit: 50 }).then(result => {
if (result.ok) setBrowserActions(result.data);
});
}
}, [section, snapshot?.currentCompanyId]);
// Global Escape key handler for command palette
useEffect(() => {
if (!commandPaletteOpen) return;
@@ -233,11 +315,13 @@ export function App() {
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
// Sidebar badge mapping
const activeInboxCount = useMemo(() => inboxItems.filter((item) => !item.dismissed).length, [inboxItems]);
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
} : {}, [badges]);
} : {}, [badges, activeInboxCount]);
const liveRuns = useMemo(() => runs.filter((run) => run.status === "running" || run.status === "queued"), [runs]);
const failedRuns = useMemo(() => runs.filter((run) => ["failed", "timed_out", "interrupted"].includes(run.status)), [runs]);
@@ -542,6 +626,57 @@ export function App() {
activity={filteredActivity}
costs={companyCosts}
connectors={snapshot.connectors}
meetings={snapshot.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id)}
sprints={snapshot.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id)}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
/>
) : null}
{section === "inbox" ? (
<Inbox
items={inboxItems}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
onDismiss={(_itemId) => { /* no-op for now */ }}
/>
) : null}
{section === "standup" ? (
<StandupView
report={standupReport}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
/>
) : null}
{section === "performance" ? (
<PerformanceView
metrics={companyMetrics}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
@@ -625,6 +760,16 @@ export function App() {
<OrgChart agents={companyAgents} onSelectAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }} />
) : null}
{section === "hiring" && currentCompany ? (
<HiringPipeline
companyId={currentCompany.id}
agents={companyAgents}
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
onRefresh={refresh}
onNavigateToAgent={(id) => { setSelectedAgentId(id); setSection("agents"); }}
/>
) : null}
{section === "goals" ? (
<>
<Panel title="Goal stack" action={<ActionButton label="New goal" icon={Target} tone="accent" onClick={() => setSelectedGoalId("__new__")} />}>
@@ -794,6 +939,192 @@ export function App() {
</Panel>
) : null}
{section === "social" && snapshot?.currentCompanyId && (
<SocialAccountsPanel
accounts={socialAccounts}
actions={browserActions}
agents={companyAgents.map(a => ({ id: a.id, name: a.name }))}
companyId={snapshot.currentCompanyId}
onSave={async (input) => {
await window.agentCompany.saveSocialAccount({
companyId: input.companyId,
platform: input.platform,
accountName: input.accountName,
displayName: input.displayName,
requireApproval: input.requireApproval,
profileUrl: "",
status: "login_required",
metadataJson: "{}",
});
await refresh();
}}
onDelete={async (id) => {
if (!snapshot?.currentCompanyId) return;
await window.agentCompany.deleteSocialAccount({ id, companyId: snapshot.currentCompanyId });
await refresh();
}}
onLogin={async (accountId) => {
await window.agentCompany.triggerBrowserLogin(accountId);
}}
onNavigate={(s, id) => {
setSection(s);
if (id) {
if (s === "agents") setSelectedAgentId(id);
else if (s === "tasks") setSelectedTaskId(id);
else if (s === "approvals") setSelectedApprovalId(id);
else if (s === "goals") setSelectedGoalId(id);
else if (s === "projects") setSelectedProjectId(id);
}
}}
/>
)}
{section === "automation" && currentCompany ? (
<AutomationPanel
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
agents={companyAgents}
companyId={currentCompany.id}
onRefresh={refresh}
/>
) : null}
{section === "communication" && currentCompany ? (
<CommunicationHub
messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []}
agents={companyAgents}
companyId={currentCompany.id}
onRefresh={refresh}
/>
) : null}
{section === "documents" && currentCompany ? (
<Panel title="Company Documents" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length} documents</span>}>
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<FileText size={32} className="mx-auto mb-3 opacity-40" />
<p>No documents yet. Agents will create PRDs, specs, and reports as they work.</p>
</div>
) : (
<ListFrame>
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).map(doc => (
<ListRow key={doc.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<FileText size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{doc.title}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{doc.type.replace(/_/g, " ")}</span>
<span>v{doc.version}</span>
{doc.authorAgentId && <span>by {companyAgents.find(a => a.id === doc.authorAgentId)?.name ?? "Unknown"}</span>}
</div>
</div>
<StatusPill status={doc.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
) : null}
{section === "knowledge" && currentCompany ? (
<Panel title="Knowledge Base" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length} entries</span>}>
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<Brain size={32} className="mx-auto mb-3 opacity-40" />
<p>No knowledge entries yet. Agents learn and document lessons as they work.</p>
</div>
) : (
<ListFrame>
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).map(entry => (
<ListRow key={entry.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Brain size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{entry.topic}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{entry.category.replace(/_/g, " ")}</span>
<span className="mx-2">-</span>
<span>{entry.content.slice(0, 100)}{entry.content.length > 100 ? "..." : ""}</span>
</div>
</div>
<StatusPill status={entry.importance} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
) : null}
{section === "meetings" && currentCompany ? (
<Panel title="Meetings" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length} meetings</span>}>
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<CalendarBlank size={32} className="mx-auto mb-3 opacity-40" />
<p>No meetings scheduled. Agents will schedule standups, reviews, and planning sessions.</p>
</div>
) : (
<ListFrame>
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).map(meeting => (
<ListRow key={meeting.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CalendarBlank size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{meeting.title}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{meeting.type.replace(/_/g, " ")}</span>
<span>{new Date(meeting.scheduledAt).toLocaleString()}</span>
<span>{meeting.durationMinutes}min</span>
</div>
</div>
<StatusPill status={meeting.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
) : null}
{section === "sprints" && currentCompany ? (
<Panel title="Sprint Board" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length} sprints</span>}>
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<Rows size={32} className="mx-auto mb-3 opacity-40" />
<p>No sprints yet. Start a sprint to organize work into focused cycles.</p>
</div>
) : (
<ListFrame>
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).map(sprint => (
<ListRow key={sprint.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Rows size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{sprint.name}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span>{sprint.startDate} - {sprint.endDate}</span>
<span>{sprint.completedPoints}/{sprint.velocityPoints} pts</span>
</div>
</div>
<StatusPill status={sprint.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
) : null}
{section === "workspaces" ? (
<EntityWorkspace
title="Workspaces"
+286 -34
View File
@@ -1,21 +1,36 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Buildings,
CheckCircle,
Code,
Cpu,
Flask,
FolderOpen,
Lightning,
Megaphone,
Robot,
Rocket,
Stack,
Target,
TreeStructure,
Users,
WarningCircle,
XCircle,
} from "@phosphor-icons/react";
import type { ProfileSnapshot } from "@shared/types";
import type { CompanyTemplate, ProfileSnapshot } from "@shared/types";
import { unwrap } from "../lib/desktop";
import { ActionButton, Input, TextArea } from "./ui";
type Step = "company" | "goal" | "launch";
type Step = "choose" | "template" | "company" | "goal" | "launch";
const templateIcons: Record<string, React.ElementType> = {
Cpu: Cpu,
Megaphone: Megaphone,
Flask: Flask,
Stack: Stack,
Code: Code,
};
export function CompanyOnboarding({
connectors,
@@ -24,7 +39,7 @@ export function CompanyOnboarding({
connectors: ProfileSnapshot["connectors"];
onDone: () => Promise<void>;
}) {
const [step, setStep] = useState<Step>("company");
const [step, setStep] = useState<Step>("choose");
const [companyName, setCompanyName] = useState("");
const [workspacePath, setWorkspacePath] = useState("");
const [autoApproveHires, setAutoApproveHires] = useState(true);
@@ -33,10 +48,20 @@ export function CompanyOnboarding({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [templates, setTemplates] = useState<CompanyTemplate[]>([]);
const hasReadyConnector = connectors.some((c) => c.status === "ready" || c.status === "degraded");
const readyConnectors = connectors.filter((c) => c.status === "ready" || c.status === "degraded");
const bestConnector = readyConnectors[0] ?? null;
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) ?? null;
// Load templates
useEffect(() => {
window.agentCompany.listCompanyTemplates().then((result) => {
if (result.ok) setTemplates(result.data);
});
}, []);
async function pickDirectory() {
setError(null);
@@ -50,6 +75,54 @@ export function CompanyOnboarding({
}
}
// Template launch
const launchFromTemplate = useCallback(async () => {
if (loading || !selectedTemplate) return;
if (!companyName.trim()) {
setError("Enter a company name.");
return;
}
if (!hasReadyConnector) {
setError("At least one connector must be ready.");
return;
}
setLoading(true);
setError(null);
try {
const companyId = unwrap(
await window.agentCompany.applyCompanyTemplate({
templateId: selectedTemplate.id,
companyName: companyName.trim(),
companyDescription: "",
connectorId: bestConnector?.id,
}),
);
// Create workspace for the company if path provided
if (workspacePath) {
unwrap(
await window.agentCompany.saveWorkspace({
companyId,
name: "Default Workspace",
localPath: workspacePath,
repoUrl: "",
repoRef: "",
isPrimary: true,
}),
);
}
setCreatedCompanyId(companyId);
setStep("launch");
await onDone();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to create the company.");
} finally {
setLoading(false);
}
}, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]);
// Custom company creation (existing flow)
const createCompany = useCallback(async () => {
if (loading) return;
if (!companyName.trim()) {
@@ -98,7 +171,6 @@ export function CompanyOnboarding({
setLoading(true);
setError(null);
try {
// Create goal
if (goalTitle.trim()) {
unwrap(
await window.agentCompany.saveGoal({
@@ -111,8 +183,6 @@ export function CompanyOnboarding({
}),
);
}
// Create CEO agent automatically
if (bestConnector) {
const ceoId = unwrap(
await window.agentCompany.saveAgent({
@@ -129,22 +199,14 @@ export function CompanyOnboarding({
status: "idle",
}),
);
// Enable heartbeat on CEO for autonomous operation
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId: createdCompanyId, enabled: true, intervalSec: 120 }));
setStep("launch");
// Auto-trigger the CEO's first run after a brief delay for UI
setTimeout(async () => {
try {
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId: createdCompanyId, trigger: "assignment" });
} catch {
// Non-fatal — CEO will wake on next heartbeat cycle
}
} catch { /* Non-fatal */ }
}, 1500);
}
await onDone();
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
@@ -165,10 +227,22 @@ export function CompanyOnboarding({
}
}, [loading, onDone]);
const stepIndex = step === "company" ? 0 : step === "goal" ? 1 : 2;
const stepLabels = step === "choose" || step === "template"
? [
{ label: "Choose Setup", icon: Lightning },
{ label: "Configure", icon: Buildings },
{ label: "Launch", icon: Rocket },
]
: [
{ label: "Create Company", icon: Buildings },
{ label: "Set Mission", icon: Target },
{ label: "Launch", icon: Rocket },
];
const stepIndex = step === "choose" ? 0 : step === "template" ? 1 : step === "company" ? 0 : step === "goal" ? 1 : 2;
return (
<div className="grid min-h-[100dvh] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_480px]">
<div className="grid min-h-[100dvh] grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]">
{/* Left panel — branding */}
<div className="flex items-center px-8 py-12 md:px-16">
<motion.div
@@ -187,11 +261,7 @@ export function CompanyOnboarding({
{/* Step indicators */}
<div className="mt-10 flex items-center gap-3">
{[
{ label: "Create Company", icon: Buildings },
{ label: "Set Mission", icon: Target },
{ label: "Launch", icon: Rocket },
].map((s, i) => (
{stepLabels.map((s, i) => (
<div key={s.label} className="flex items-center gap-2">
{i > 0 ? <div className={`h-px w-6 ${i <= stepIndex ? "bg-[color:var(--accent)]" : "bg-[color:var(--line)]"}`} /> : null}
<div className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] font-medium ${
@@ -246,7 +316,183 @@ export function CompanyOnboarding({
{/* Right panel — form steps */}
<div className="flex items-center border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)] px-6 py-10 md:px-10">
<AnimatePresence mode="wait">
{step === "company" ? (
{/* ── Step: Choose Mode ────────────────────────── */}
{step === "choose" ? (
<motion.div
key="choose"
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -18 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="w-full space-y-5"
>
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">How would you like to start?</div>
<div className="text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Choose your setup</div>
</div>
{/* Template cards */}
<div className="space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--accent)]">Quick Start — Choose a template</div>
<div className="grid grid-cols-1 gap-2">
{templates.map((template) => {
const Icon = templateIcons[template.icon] ?? Cpu;
return (
<button
key={template.id}
type="button"
onClick={() => {
setSelectedTemplateId(template.id);
setCompanyName(template.name);
setStep("template");
}}
disabled={!hasReadyConnector}
className="focus-ring flex items-start gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 text-left transition hover:border-[color:var(--accent)] hover:shadow-sm disabled:opacity-50"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-[color:var(--accent-soft)]">
<Icon size={18} className="text-[color:var(--accent)]" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-[color:var(--text)]">{template.name}</div>
<div className="mt-0.5 text-[11px] leading-relaxed text-[color:var(--muted)]">{template.description}</div>
<div className="mt-1.5 flex gap-3 text-[10px] text-[color:var(--muted)]">
<span className="flex items-center gap-1">
<Users size={10} />
{template.agents.length} agent{template.agents.length !== 1 ? "s" : ""}
</span>
<span className="flex items-center gap-1">
<Target size={10} />
{template.goals.length} goal{template.goals.length !== 1 ? "s" : ""}
</span>
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-[color:var(--line)]" />
<span className="text-[10px] font-medium text-[color:var(--muted)]">OR</span>
<div className="h-px flex-1 bg-[color:var(--line)]" />
</div>
{/* Custom option */}
<button
type="button"
onClick={() => setStep("company")}
disabled={!hasReadyConnector}
className="focus-ring flex w-full items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-4 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm disabled:opacity-50"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-[color:var(--panel-soft)]">
<Buildings size={18} className="text-[color:var(--muted-strong)]" />
</div>
<div>
<div className="text-[13px] font-semibold text-[color:var(--text)]">Custom Setup</div>
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Create a company from scratch. Set your own name, goals, and let the CEO build the team.</div>
</div>
</button>
</motion.div>
) : step === "template" && selectedTemplate ? (
/* ── Step: Template Configuration ─────────────── */
<motion.div
key="template"
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -18 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="surface w-full rounded-[12px] p-7"
>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Template: {selectedTemplate.name}</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Configure & Launch</div>
<div className="mb-6 h-px bg-[color:var(--line)]" />
<div className="space-y-4">
<Input label="Company name" value={companyName} onChange={setCompanyName} placeholder="e.g. Acme AI Labs" />
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Workspace folder <span className="font-normal text-[color:var(--muted)]">(optional)</span>
</label>
<div className="flex gap-3">
<Input ariaLabel="Workspace folder" value={workspacePath} onChange={setWorkspacePath} placeholder="/Users/you/project" />
<ActionButton label="Choose" onClick={() => void pickDirectory()} icon={FolderOpen} />
</div>
</div>
</div>
{/* Team preview */}
<div className="mt-5 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">Team that will be created</div>
<div className="space-y-1.5">
{selectedTemplate.agents.map((agent, i) => (
<div key={i} className="flex items-center gap-2 text-[12px]">
<div className={`flex h-6 w-6 items-center justify-center rounded-full text-[8px] font-bold ${
agent.reportsToIndex === null ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" : "bg-[color:var(--panel)] text-[color:var(--muted-strong)]"
}`}>
{agent.name.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
</div>
<span className="font-medium text-[color:var(--text)]">{agent.name}</span>
<span className="text-[color:var(--muted)]">— {agent.title}</span>
</div>
))}
</div>
<div className="mt-3 flex gap-3 text-[11px] text-[color:var(--muted)]">
<span className="flex items-center gap-1">
<TreeStructure size={12} />
{selectedTemplate.agents.length} agents
</span>
<span className="flex items-center gap-1">
<Target size={12} />
{selectedTemplate.goals.length} goals
</span>
</div>
</div>
{/* What happens */}
<div className="mt-4 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
<div className="space-y-2 text-[12px] text-[color:var(--muted)]">
<div className="flex items-start gap-2">
<Users size={14} className="mt-0.5 shrink-0 text-[color:var(--accent)]" />
<span>All <strong className="text-[color:var(--text)]">{selectedTemplate.agents.length} agents</strong> are created with roles and budgets</span>
</div>
<div className="flex items-start gap-2">
<Target size={14} className="mt-0.5 shrink-0 text-[color:var(--accent)]" />
<span>Goals are set and assigned to the <strong className="text-[color:var(--text)]">{selectedTemplate.agents[0]?.role ?? "CEO"}</strong></span>
</div>
<div className="flex items-start gap-2">
<Lightning size={14} className="mt-0.5 shrink-0 text-[color:var(--accent)]" />
<span>The team leader starts working <strong className="text-[color:var(--text)]">immediately</strong></span>
</div>
</div>
</div>
{error ? (
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]" role="alert">
{error}
</div>
) : null}
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={() => { setStep("choose"); setSelectedTemplateId(null); }}
className="text-[12px] font-medium text-[color:var(--muted)] transition hover:text-[color:var(--text)]"
>
Back
</button>
<ActionButton
label={loading ? "Launching..." : "Launch Company"}
onClick={() => void launchFromTemplate()}
icon={Rocket}
tone="accent"
loading={loading}
disabled={!companyName.trim() || !hasReadyConnector}
/>
</div>
</motion.div>
) : step === "company" ? (
/* ── Step: Custom Company ─────────────────────── */
<motion.div
key="company"
initial={{ opacity: 0, y: 18 }}
@@ -255,7 +501,7 @@ export function CompanyOnboarding({
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="surface w-full rounded-[12px] p-7"
>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Step 1 of 3</div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Custom Setup — Step 1 of 3</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Create your company</div>
<div className="mb-6 h-px bg-[color:var(--line)]" />
<div className="space-y-4">
@@ -281,8 +527,6 @@ export function CompanyOnboarding({
The directory where your agents will work. You can add more later.
</div>
</div>
{/* Auto-approve toggle */}
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<label className="flex items-center gap-3">
<input
@@ -305,7 +549,14 @@ export function CompanyOnboarding({
{error}
</div>
) : null}
<div className="mt-8 flex items-center justify-end gap-4">
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={() => setStep("choose")}
className="text-[12px] font-medium text-[color:var(--muted)] transition hover:text-[color:var(--text)]"
>
Back
</button>
<ActionButton
label={loading ? "Creating..." : "Continue"}
onClick={() => void createCompany()}
@@ -317,6 +568,7 @@ export function CompanyOnboarding({
</div>
</motion.div>
) : step === "goal" ? (
/* ── Step: Goal ───────────────────────────────── */
<motion.div
key="goal"
initial={{ opacity: 0, y: 18 }}
@@ -325,7 +577,7 @@ export function CompanyOnboarding({
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="surface w-full rounded-[12px] p-7"
>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Step 2 of 3</div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Custom Setup — Step 2 of 3</div>
<div className="mb-1 text-[18px] font-semibold tracking-[-0.01em] text-[color:var(--text)]">Set your company's mission</div>
<div className="mb-2 text-[13px] text-[color:var(--muted)]">
What should your AI company accomplish? The CEO agent will use this to build a team and start working.
@@ -346,8 +598,6 @@ export function CompanyOnboarding({
rows={4}
/>
</div>
{/* Preview: what will happen */}
<div className="mt-5 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">What happens next</div>
<div className="space-y-2 text-[12px] text-[color:var(--muted)]">
@@ -365,7 +615,6 @@ export function CompanyOnboarding({
</div>
</div>
</div>
{error ? (
<div className="mt-5 rounded-[10px] border border-[color:var(--danger)] bg-[color:var(--danger-soft)] px-4 py-3 text-[13px] text-[color:var(--danger)]" role="alert">
{error}
@@ -390,6 +639,7 @@ export function CompanyOnboarding({
</div>
</motion.div>
) : (
/* ── Step: Launch Success ─────────────────────── */
<motion.div
key="launch"
initial={{ opacity: 0, scale: 0.95 }}
@@ -409,7 +659,9 @@ export function CompanyOnboarding({
{companyName} is live!
</h2>
<p className="mx-auto mt-3 max-w-[340px] text-[14px] leading-relaxed text-[color:var(--muted)]">
Your CEO agent is starting up. Watch the dashboard as your company comes to life.
{selectedTemplate
? `Your ${selectedTemplate.agents.length}-agent team is starting up. Watch the dashboard as your company comes to life.`
: "Your CEO agent is starting up. Watch the dashboard as your company comes to life."}
</p>
<motion.div
initial={{ opacity: 0 }}
@@ -419,7 +671,7 @@ export function CompanyOnboarding({
>
<div className="flex items-center justify-center gap-2 text-[12px] text-[color:var(--muted)]">
<span className="h-2 w-2 animate-pulse rounded-full bg-[color:var(--success)]" />
CEO agent initializing...
{selectedTemplate ? "Team initializing..." : "CEO agent initializing..."}
</div>
</motion.div>
</motion.div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+299 -3
View File
@@ -3,15 +3,22 @@ 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";
@@ -21,10 +28,13 @@ import type {
ApprovalRecord,
ConnectorRecord,
CostRecord,
DepartmentId,
GoalRecord,
MeetingRecord,
ProjectRecord,
RunRecord,
SectionId,
SprintRecord,
TaskRecord,
} from "@shared/types";
import { formatMoney } from "../lib/formatters";
@@ -44,6 +54,8 @@ interface DashboardProps {
activity: ActivityRecord[];
costs: CostRecord[];
connectors: ConnectorRecord[];
meetings?: MeetingRecord[];
sprints?: SprintRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
}
@@ -393,12 +405,241 @@ function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
);
}
/* ------------------------------------------------------------------ */
/* Department Overview */
/* ------------------------------------------------------------------ */
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" },
};
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"
>
{/* Accent bar */}
<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>
);
}
/* ------------------------------------------------------------------ */
/* Sprint Progress */
/* ------------------------------------------------------------------ */
function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
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 = Date.now();
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
}, [sprint]);
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>
{/* Progress bar */}
<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>
);
}
/* ------------------------------------------------------------------ */
/* Goal Progress Bars */
/* ------------------------------------------------------------------ */
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>
);
}
/* ------------------------------------------------------------------ */
/* Main Dashboard */
/* ------------------------------------------------------------------ */
export function Dashboard(props: DashboardProps) {
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, onNavigate } = props;
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, meetings = [], sprints = [], onNavigate } = props;
const healthScore = useMemo(
() => computeHealthScore({ agents, tasks, runs, approvals, connectors }),
@@ -413,6 +654,13 @@ export function Dashboard(props: DashboardProps) {
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((c) => c.status === "ready"), [connectors]);
const activeSprint = useMemo(() => sprints.find((s) => s.status === "active"), [sprints]);
const completionRate = useMemo(() => {
const total = runs.length;
if (total === 0) return 0;
const succeeded = runs.filter((r) => r.status === "succeeded").length;
return Math.round((succeeded / total) * 100);
}, [runs]);
return (
<div className="space-y-8">
@@ -488,7 +736,55 @@ export function Dashboard(props: DashboardProps) {
</div>
</div>
{/* Row 2: Task Pipeline */}
{/* Row 2: 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)]">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"
>
Org chart <TreeStructure size={12} />
</button>
</div>
<DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} />
</section>
{/* Row 3: Sprint + Goals side by side */}
<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)]">Current Sprint</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)]">Goal Progress</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>
{/* Row 4: 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)]">Task Pipeline</div>
@@ -505,7 +801,7 @@ export function Dashboard(props: DashboardProps) {
</div>
</section>
{/* Row 3: Workforce + Activity */}
{/* Row 5: Workforce + Activity */}
<div className="grid gap-6 lg:grid-cols-5">
{/* Workforce */}
<section className="lg:col-span-3">
+339
View File
@@ -0,0 +1,339 @@
import { useState } from "react";
import {
ArrowRight,
Check,
UserPlus,
X,
} from "@phosphor-icons/react";
import type { AgentRecord, ApprovalRecord } from "@shared/types";
import { ActionButton, EmptyState, Panel, StatusPill } from "./ui";
interface HiringPipelineProps {
companyId: string;
agents: AgentRecord[];
approvals: ApprovalRecord[];
onRefresh: () => Promise<void>;
onNavigateToAgent: (agentId: string) => void;
}
type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected";
interface HiringCandidate {
agent: AgentRecord;
approval: ApprovalRecord | null;
stage: HiringStage;
}
function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | null): HiringStage {
if (agent.status === "pending_approval") {
if (!approval) return "requisition";
if (approval.state === "pending") return "pending_approval";
if (approval.state === "rejected") return "rejected";
if (approval.state === "revision_requested") return "requisition";
return "approved";
}
if (agent.status === "idle" && approval?.state === "approved") {
// Recently approved — check if they have any tasks yet
return "onboarding";
}
return "active";
}
function stageLabel(stage: HiringStage): string {
switch (stage) {
case "requisition": return "Requisition";
case "pending_approval": return "Pending Approval";
case "approved": return "Approved";
case "onboarding": return "Onboarding";
case "active": return "Active";
case "rejected": return "Rejected";
}
}
function stageColor(stage: HiringStage): string {
switch (stage) {
case "requisition": return "bg-amber-500/20 text-amber-400";
case "pending_approval": return "bg-yellow-500/20 text-yellow-400";
case "approved": return "bg-green-500/20 text-green-400";
case "onboarding": return "bg-blue-500/20 text-blue-400";
case "active": return "bg-emerald-500/20 text-emerald-400";
case "rejected": return "bg-red-500/20 text-red-400";
}
}
const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"];
export function HiringPipeline({ companyId: _companyId, agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) {
const [filterStage, setFilterStage] = useState<HiringStage | "all">("all");
const hireApprovals = approvals.filter(a => a.type === "hire_agent");
const candidates: HiringCandidate[] = agents.map(agent => {
const approval = hireApprovals.find(a =>
a.relatedAgentId === agent.id ||
(a.payloadJson && (() => { try { const p = JSON.parse(a.payloadJson); return p.agentId === agent.id; } catch { return false; } })())
) ?? null;
return {
agent,
approval,
stage: getHiringStage(agent, approval),
};
});
// Also include hire approvals that don't yet have an agent (pre-creation stage)
const pendingHireApprovals = hireApprovals.filter(a => {
return a.state === "pending" && !candidates.some(c => c.approval?.id === a.id);
});
const allCandidates = [
...candidates,
...pendingHireApprovals.map(a => ({
agent: null as unknown as AgentRecord,
approval: a,
stage: "pending_approval" as HiringStage,
})),
].sort((a, b) => STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage));
const filteredCandidates = filterStage === "all"
? allCandidates
: allCandidates.filter(c => c.stage === filterStage);
// Stage counts for the pipeline overview
const stageCounts = STAGE_ORDER.reduce((acc, stage) => {
acc[stage] = allCandidates.filter(c => c.stage === stage).length;
return acc;
}, {} as Record<HiringStage, number>);
const pendingCount = stageCounts.requisition + stageCounts.pending_approval;
const activeHires = allCandidates.filter(c => c.stage !== "active" && c.stage !== "rejected").length;
return (
<div className="space-y-4">
{/* Pipeline Overview */}
<Panel title="Hiring Pipeline" eyebrow={`${activeHires} active hiring processes`}>
<div className="flex items-center gap-1 px-4 py-3 overflow-x-auto">
{STAGE_ORDER.filter(s => s !== "rejected").map((stage, idx) => (
<div key={stage} className="flex items-center gap-1">
<button
onClick={() => setFilterStage(filterStage === stage ? "all" : stage)}
className={`px-3 py-2 rounded-md text-xs font-medium transition-colors whitespace-nowrap ${
filterStage === stage ? "ring-1 ring-orange-400/40 bg-zinc-700" : "hover:bg-zinc-700/60"
} ${stageColor(stage)}`}
>
{stageLabel(stage)}
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]">
{stageCounts[stage]}
</span>
</button>
{idx < STAGE_ORDER.length - 2 && (
<ArrowRight size={12} className="text-zinc-600 flex-shrink-0" />
)}
</div>
))}
{stageCounts.rejected > 0 && (
<button
onClick={() => setFilterStage(filterStage === "rejected" ? "all" : "rejected")}
className={`ml-4 px-3 py-2 rounded-md text-xs font-medium transition-colors ${
filterStage === "rejected" ? "ring-1 ring-red-400/40 bg-zinc-700" : "hover:bg-zinc-700/60"
} ${stageColor("rejected")}`}
>
Rejected
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-zinc-800 text-[10px]">
{stageCounts.rejected}
</span>
</button>
)}
</div>
</Panel>
{/* Pending Approvals Section */}
{pendingCount > 0 && (
<Panel title="Awaiting Board Decision" eyebrow={`${pendingCount} hire requests need your review`}>
<div className="divide-y divide-zinc-800/60">
{allCandidates
.filter(c => c.stage === "requisition" || c.stage === "pending_approval")
.map((candidate) => {
const a = candidate.approval;
const ag = candidate.agent;
return (
<div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-amber-500/15 flex items-center justify-center">
<UserPlus size={16} className="text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-zinc-200">{ag?.name ?? "Proposed Agent"}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
{stageLabel(candidate.stage)}
</span>
</div>
<div className="text-xs text-zinc-400 mt-0.5">
{ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary}
</div>
{ag?.department && (
<div className="text-xs text-zinc-500 mt-0.5">
Dept: {ag.department} | Budget: ${ag.budgetMonthlyUsd}/mo | Connector: {ag.connectorId}
</div>
)}
{a?.payloadSummary && (
<div className="text-xs text-zinc-500 mt-1">{a.payloadSummary}</div>
)}
{a?.impactSummary && (
<div className="text-xs text-zinc-500">{a.impactSummary}</div>
)}
{ag?.capabilities && (
<div className="text-xs text-zinc-600 mt-1 line-clamp-2">{ag.capabilities}</div>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{a && a.state === "pending" && (
<>
<ActionButton
label="Approve"
icon={Check}
tone="accent"
onClick={async () => {
await window.agentCompany.decideApproval({
approvalId: a.id,
state: "approved",
decisionNote: "Hire approved via hiring pipeline",
});
await onRefresh();
}}
/>
<ActionButton
label="Reject"
icon={X}
tone="danger"
onClick={async () => {
await window.agentCompany.decideApproval({
approvalId: a.id,
state: "rejected",
decisionNote: "Hire rejected via hiring pipeline",
});
await onRefresh();
}}
/>
</>
)}
</div>
</div>
);
})}
</div>
</Panel>
)}
{/* Full Pipeline List */}
<Panel
title={filterStage === "all" ? "All Agents" : `${stageLabel(filterStage)} (${filteredCandidates.length})`}
action={
filterStage !== "all" ? (
<ActionButton label="Show all" icon={X} onClick={() => setFilterStage("all")} />
) : undefined
}
>
{filteredCandidates.length === 0 ? (
<EmptyState
title="No hiring activity"
detail="When agents propose new hires via the API, they will appear here for your review."
/>
) : (
<div className="divide-y divide-zinc-800/60">
{filteredCandidates.map((candidate) => {
const ag = candidate.agent;
if (!ag) return null;
const manager = agents.find(a => a.id === ag.reportsTo);
const requestedBy = candidate.approval?.requestedByAgentId
? agents.find(a => a.id === candidate.approval!.requestedByAgentId)
: null;
return (
<div
key={ag.id}
className="px-4 py-3 flex items-center gap-3 hover:bg-zinc-800/40 cursor-pointer transition-colors"
onClick={() => onNavigateToAgent(ag.id)}
>
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-zinc-700/60 flex items-center justify-center text-xs font-bold text-zinc-300">
{ag.name.split(" ").map(n => n[0]).join("").slice(0, 2)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-zinc-200 truncate">{ag.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${stageColor(candidate.stage)}`}>
{stageLabel(candidate.stage)}
</span>
<StatusPill status={ag.status} />
</div>
<div className="text-xs text-zinc-400 mt-0.5">
{ag.role} — {ag.title}{ag.department ? ` | ${ag.department}` : ""}
</div>
</div>
<div className="flex-shrink-0 text-right">
<div className="text-xs text-zinc-500">
{manager ? `Reports to ${manager.name}` : "Top level"}
</div>
{requestedBy && (
<div className="text-[10px] text-zinc-600">Proposed by {requestedBy.name}</div>
)}
<div className="text-xs text-zinc-500">${ag.budgetMonthlyUsd}/mo</div>
</div>
</div>
);
})}
</div>
)}
</Panel>
{/* Hiring Statistics */}
<div className="grid grid-cols-4 gap-3">
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Total Agents</div>
<div className="text-2xl font-bold text-zinc-200 mt-1">{agents.filter(a => a.status !== "terminated").length}</div>
</div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Pending Hires</div>
<div className="text-2xl font-bold text-amber-400 mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
</div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Departments</div>
<div className="text-2xl font-bold text-zinc-200 mt-1">
{new Set(agents.filter(a => a.department && a.status !== "terminated").map(a => a.department)).size}
</div>
</div>
<div className="bg-zinc-900/60 rounded-lg px-4 py-3 border border-zinc-800/40">
<div className="text-[10px] uppercase text-zinc-500 tracking-wider">Monthly Payroll</div>
<div className="text-2xl font-bold text-zinc-200 mt-1">
${agents.filter(a => a.status !== "terminated" && a.status !== "pending_approval").reduce((s, a) => s + a.budgetMonthlyUsd, 0).toLocaleString()}
</div>
</div>
</div>
{/* Department Headcount */}
<Panel title="Department Headcount">
<div className="grid grid-cols-3 gap-2 p-4">
{(["executive", "engineering", "product", "design", "marketing", "sales", "hr", "finance", "legal", "customer_support", "operations", "research"] as const).map(dept => {
const deptAgents = agents.filter(a => a.department === dept && a.status !== "terminated");
const pending = deptAgents.filter(a => a.status === "pending_approval").length;
const active = deptAgents.filter(a => a.status !== "pending_approval").length;
if (active === 0 && pending === 0) return null;
return (
<div key={dept} className="bg-zinc-800/40 rounded-md px-3 py-2">
<div className="text-xs font-medium text-zinc-300 capitalize">{dept.replace("_", " ")}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-sm font-bold text-zinc-200">{active}</span>
<span className="text-[10px] text-zinc-500">active</span>
{pending > 0 && (
<>
<span className="text-sm font-bold text-amber-400">+{pending}</span>
<span className="text-[10px] text-amber-400/70">pending</span>
</>
)}
</div>
</div>
);
})}
</div>
</Panel>
</div>
);
}
+537
View File
@@ -0,0 +1,537 @@
import { useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Bell,
ShieldCheck,
Warning,
XCircle,
CurrencyCircleDollar,
ArrowRight,
CheckCircle,
Clock,
Eye,
FunnelSimple,
Robot,
Heartbeat,
Target,
ChatCircle,
Tray,
} from "@phosphor-icons/react";
import type { InboxItem, InboxItemType, SectionId, StandupReport, AgentMetrics, CompanyMetrics } from "@shared/types";
import { formatMoney } from "../lib/formatters";
/* ------------------------------------------------------------------ */
/* Inbox Panel */
/* ------------------------------------------------------------------ */
const typeConfig: Record<InboxItemType, { icon: React.ElementType; color: string; bg: string; label: string }> = {
pending_approval: { icon: ShieldCheck, color: "var(--warn)", bg: "var(--warn-soft)", label: "Approval" },
failed_run: { icon: XCircle, color: "var(--danger)", bg: "var(--danger-soft)", label: "Failed Run" },
budget_warning: { icon: CurrencyCircleDollar, color: "var(--warn)", bg: "var(--warn-soft)", label: "Budget" },
blocked_task: { icon: Warning, color: "var(--danger)", bg: "var(--danger-soft)", label: "Blocked" },
review_needed: { icon: Eye, color: "var(--accent)", bg: "var(--accent-soft)", label: "Review" },
agent_message: { icon: ChatCircle, color: "var(--accent)", bg: "var(--accent-soft)", label: "Message" },
};
const severityColors: 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(--line)]",
};
function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = Math.max(0, now - then);
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
interface InboxProps {
items: InboxItem[];
onNavigate: (section: SectionId, entityId?: string) => void;
onDismiss: (itemId: string) => void;
}
export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
const [filter, setFilter] = useState<InboxItemType | "all">("all");
const [showDismissed, setShowDismissed] = useState(false);
const filteredItems = useMemo(() => {
let result = items;
if (!showDismissed) result = result.filter(i => !i.dismissed);
if (filter !== "all") result = result.filter(i => i.type === filter);
return result;
}, [items, filter, showDismissed]);
const counts = useMemo(() => {
const active = items.filter(i => !i.dismissed);
return {
all: active.length,
pending_approval: active.filter(i => i.type === "pending_approval").length,
failed_run: active.filter(i => i.type === "failed_run").length,
budget_warning: active.filter(i => i.type === "budget_warning").length,
blocked_task: active.filter(i => i.type === "blocked_task").length,
review_needed: active.filter(i => i.type === "review_needed").length,
agent_message: active.filter(i => i.type === "agent_message").length,
};
}, [items]);
const handleItemClick = (item: InboxItem) => {
const sectionMap: Record<string, SectionId> = {
approval: "approvals",
run: "tasks",
agent: "agents",
task: "tasks",
};
const section = sectionMap[item.entityType] ?? "overview";
onNavigate(section, item.entityId);
};
const filterButtons: Array<{ key: InboxItemType | "all"; label: string; count: number }> = [
{ key: "all", label: "All", count: counts.all },
{ key: "pending_approval", label: "Approvals", count: counts.pending_approval },
{ key: "review_needed", label: "Reviews", count: counts.review_needed },
{ key: "failed_run", label: "Failed", count: counts.failed_run },
{ key: "blocked_task", label: "Blocked", count: counts.blocked_task },
{ key: "budget_warning", label: "Budget", count: counts.budget_warning },
];
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
<Bell size={20} weight="fill" className="text-[color:var(--accent)]" />
</div>
<div>
<h2 className="text-[16px] font-bold text-[color:var(--text)]">Inbox</h2>
<p className="text-[12px] text-[color:var(--muted)]">
{counts.all} item{counts.all !== 1 ? "s" : ""} need{counts.all === 1 ? "s" : ""} your attention
</p>
</div>
</div>
</div>
{/* Filter Bar */}
<div className="flex flex-wrap items-center gap-1.5">
{filterButtons.map(btn => (
<button
key={btn.key}
type="button"
onClick={() => setFilter(btn.key)}
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[11px] font-medium transition ${
filter === btn.key
? "bg-[color:var(--accent)] text-white"
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)] hover:bg-[color:var(--line)]"
}`}
>
{btn.label}
{btn.count > 0 ? (
<span className={`rounded-full px-1.5 py-0.5 text-[9px] font-bold ${
filter === btn.key ? "bg-white/20 text-white" : "bg-[color:var(--line)] text-[color:var(--muted)]"
}`}>
{btn.count}
</span>
) : null}
</button>
))}
</div>
{/* Items */}
{filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
<Tray size={32} className="mb-3 text-[color:var(--muted)]" />
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">All clear!</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">No items need your attention right now</div>
</div>
) : (
<div className="space-y-1.5">
<AnimatePresence>
{filteredItems.map((item) => {
const config = typeConfig[item.type];
const Icon = config.icon;
return (
<motion.div
key={item.id}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 20 }}
className={`group flex items-center gap-3 rounded-[8px] border border-l-[3px] border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm ${severityColors[item.severity] ?? ""}`}
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px]"
style={{ backgroundColor: config.bg }}
>
<Icon size={18} weight="fill" style={{ color: config.color }} />
</div>
<button
type="button"
onClick={() => handleItemClick(item)}
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-[color:var(--text)]">{item.title}</span>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
style={{ backgroundColor: config.bg, color: config.color }}
>
{config.label}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
{item.agentName ? (
<span className="flex items-center gap-1">
<Robot size={10} />
{item.agentName}
</span>
) : null}
<span>{item.subtitle.slice(0, 80)}</span>
<span className="shrink-0">{timeAgo(item.createdAt)}</span>
</div>
</button>
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)] opacity-0 transition group-hover:opacity-100" />
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Standup Report View */
/* ------------------------------------------------------------------ */
interface StandupProps {
report: StandupReport | null;
onNavigate: (section: SectionId, entityId?: string) => void;
}
export function StandupView({ report, onNavigate }: StandupProps) {
if (!report) {
return (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
<Clock size={32} className="mb-3 text-[color:var(--muted)]" />
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No standup data</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">Company needs active agents to generate standup reports</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
<Heartbeat size={20} weight="fill" className="text-[color:var(--accent)]" />
</div>
<div>
<h2 className="text-[16px] font-bold text-[color:var(--text)]">Daily Standup</h2>
<p className="text-[12px] text-[color:var(--muted)]">
Generated {new Date(report.generatedAt).toLocaleString()}
</p>
</div>
</div>
<div className="text-[14px] font-medium text-[color:var(--text)]">{report.summary}</div>
<div className="mt-3 flex gap-4">
<div className="text-center">
<div className="text-[18px] font-bold text-[color:var(--success)]">{report.agentReports.filter(r => r.tasksCompleted24h > 0).length}</div>
<div className="text-[10px] text-[color:var(--muted)]">Productive</div>
</div>
<div className="h-10 w-px bg-[color:var(--line)]" />
<div className="text-center">
<div className="text-[18px] font-bold text-[color:var(--danger)]">{report.blockers.length}</div>
<div className="text-[10px] text-[color:var(--muted)]">Blockers</div>
</div>
<div className="h-10 w-px bg-[color:var(--line)]" />
<div className="text-center">
<div className="text-[18px] font-bold text-[color:var(--warn)]">{report.pendingApprovals}</div>
<div className="text-[10px] text-[color:var(--muted)]">Approvals</div>
</div>
<div className="h-10 w-px bg-[color:var(--line)]" />
<div className="text-center">
<div className={`text-[18px] font-bold ${report.budgetUtilization > 80 ? "text-[color:var(--danger)]" : "text-[color:var(--text)]"}`}>
{report.budgetUtilization}%
</div>
<div className="text-[10px] text-[color:var(--muted)]">Budget Used</div>
</div>
</div>
</div>
{/* Company Highlights */}
{report.companyHighlights.length > 0 ? (
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Highlights</div>
<div className="space-y-1.5">
{report.companyHighlights.map((h, i) => (
<div key={i} className="flex items-center gap-2 rounded-[6px] bg-[color:var(--success-soft)] px-3 py-2 text-[12px] text-[color:var(--success)]">
<CheckCircle size={14} weight="fill" />
{h}
</div>
))}
</div>
</section>
) : null}
{/* Blockers */}
{report.blockers.length > 0 ? (
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--danger)]">Blockers</div>
<div className="space-y-1.5">
{report.blockers.map((b, i) => (
<div key={i} className="flex items-center gap-2 rounded-[6px] bg-[color:var(--danger-soft)] px-3 py-2 text-[12px] text-[color:var(--danger)]">
<Warning size={14} weight="fill" />
{b}
</div>
))}
</div>
</section>
) : null}
{/* Agent Reports */}
<section>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Status</div>
<div className="space-y-2">
{report.agentReports.map((ar) => (
<button
key={ar.agentId}
type="button"
onClick={() => onNavigate("agents", ar.agentId)}
className="flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 text-left transition hover:border-[color:var(--line-strong)] hover:shadow-sm"
>
<div className="relative">
<div className={`flex h-10 w-10 items-center justify-center rounded-full text-[12px] font-bold ${
ar.status === "running" ? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
: ar.status === "error" ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
: "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)]"
}`}>
{ar.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
</div>
{ar.status === "running" ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
<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 w-2 rounded-full bg-[color:var(--success)]" />
</span>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-[color:var(--text)]">{ar.agentName}</span>
<span className="text-[10px] text-[color:var(--muted)]">{ar.role}</span>
</div>
<div className="mt-1 flex flex-wrap gap-3 text-[11px] text-[color:var(--muted)]">
{ar.tasksInProgress > 0 ? <span className="text-[color:var(--accent)]">{ar.tasksInProgress} in progress</span> : null}
{ar.tasksCompleted24h > 0 ? <span className="text-[color:var(--success)]">{ar.tasksCompleted24h} done</span> : null}
{ar.tasksBlocked > 0 ? <span className="text-[color:var(--danger)]">{ar.tasksBlocked} blocked</span> : null}
{ar.costLast24h > 0 ? <span>${ar.costLast24h.toFixed(2)}</span> : null}
</div>
{ar.highlights.length > 0 ? (
<div className="mt-1 text-[10px] text-[color:var(--muted)]">{ar.highlights.join(" · ")}</div>
) : null}
</div>
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
</button>
))}
</div>
</section>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Performance Metrics View */
/* ------------------------------------------------------------------ */
interface PerformanceProps {
metrics: CompanyMetrics | null;
onNavigate: (section: SectionId, entityId?: string) => void;
}
export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
if (!metrics) {
return (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
<Target size={32} className="mb-3 text-[color:var(--muted)]" />
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No metrics yet</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">Metrics will appear after agents start working</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Company Overview Metrics */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<CompactMetric
label="Success Rate"
value={`${metrics.overallSuccessRate}%`}
tone={metrics.overallSuccessRate >= 80 ? "success" : metrics.overallSuccessRate >= 50 ? "warn" : "danger"}
/>
<CompactMetric
label="Tasks Done"
value={metrics.completedTasks}
tone="success"
subtitle={`of ${metrics.totalTasks} total`}
/>
<CompactMetric
label="Throughput"
value={`${metrics.throughputTasksPerDay.toFixed(1)}`}
tone="accent"
subtitle="tasks/day"
/>
<CompactMetric
label="Avg Cost/Task"
value={formatMoney(metrics.avgCostPerTask)}
tone="default"
/>
</div>
{/* Run Stats */}
<div className="rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Run Activity (30d)</div>
<div className="flex items-center gap-6">
<div className="flex-1">
<RunProgressBar
succeeded={metrics.successfulRuns}
failed={metrics.failedRuns}
total={metrics.totalRuns}
/>
</div>
<div className="flex gap-4 text-[12px]">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-[color:var(--success)]" />
<span className="text-[color:var(--muted)]">{metrics.successfulRuns} succeeded</span>
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-[color:var(--danger)]" />
<span className="text-[color:var(--muted)]">{metrics.failedRuns} failed</span>
</span>
</div>
</div>
</div>
{/* Agent Performance Table */}
<section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Agent Performance</div>
<div className="overflow-x-auto rounded-[10px] border border-[color:var(--line)]">
<table className="w-full text-left">
<thead>
<tr className="border-b border-[color:var(--line)] bg-[color:var(--panel-soft)]">
<th className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)]">Agent</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Tasks Done</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Success %</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Runs</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Avg Duration</th>
<th className="px-3 py-2.5 text-[11px] font-semibold uppercase tracking-[0.06em] text-[color:var(--muted)] text-right">Cost</th>
</tr>
</thead>
<tbody>
{metrics.agentMetrics
.sort((a, b) => b.tasksCompleted - a.tasksCompleted)
.map((am) => (
<tr
key={am.agentId}
className="border-b border-[color:var(--line)] bg-[color:var(--panel)] transition hover:bg-[color:var(--panel-soft)] cursor-pointer"
onClick={() => onNavigate("agents", am.agentId)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[color:var(--panel-soft)] text-[10px] font-bold text-[color:var(--muted-strong)]">
{am.agentName.split(/\s+/).slice(0, 2).map(w => w[0]).join("").toUpperCase()}
</div>
<span className="text-[12px] font-medium text-[color:var(--text)]">{am.agentName}</span>
</div>
</td>
<td className="px-3 py-2.5 text-right text-[12px] font-semibold text-[color:var(--success)]">{am.tasksCompleted}</td>
<td className="px-3 py-2.5 text-right">
<span className={`text-[12px] font-semibold ${
am.successRate >= 80 ? "text-[color:var(--success)]"
: am.successRate >= 50 ? "text-[color:var(--warn)]"
: "text-[color:var(--danger)]"
}`}>
{am.successRate}%
</span>
</td>
<td className="px-3 py-2.5 text-right text-[12px] text-[color:var(--muted)]">{am.runsTotal}</td>
<td className="px-3 py-2.5 text-right text-[12px] text-[color:var(--muted)]">{formatDuration(am.avgRunDurationSec)}</td>
<td className="px-3 py-2.5 text-right text-[12px] text-[color:var(--muted)]">{formatMoney(am.totalCostUsd)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function CompactMetric({ label, value, tone = "default", subtitle }: {
label: string;
value: string | number;
tone?: "default" | "success" | "warn" | "danger" | "accent";
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)]",
};
return (
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{label}</div>
<div className={`text-[22px] font-bold ${toneMap[tone]}`}>{value}</div>
{subtitle ? <div className="text-[10px] text-[color:var(--muted)]">{subtitle}</div> : null}
</div>
);
}
function RunProgressBar({ succeeded, failed, total }: { succeeded: number; failed: number; total: number }) {
if (total === 0) return <div className="h-3 rounded-full bg-[color:var(--panel-soft)]" />;
const successPct = (succeeded / total) * 100;
const failPct = (failed / total) * 100;
return (
<div className="flex h-3 overflow-hidden rounded-full bg-[color:var(--panel-soft)]">
<motion.div
className="h-full bg-[color:var(--success)]"
initial={{ width: 0 }}
animate={{ width: `${successPct}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
<motion.div
className="h-full bg-[color:var(--danger)]"
initial={{ width: 0 }}
animate={{ width: `${failPct}%` }}
transition={{ duration: 0.8, ease: "easeOut", delay: 0.2 }}
/>
</div>
);
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
if (minutes < 60) return `${minutes}m ${secs}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
+501
View File
@@ -0,0 +1,501 @@
import { useCallback, useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowRight,
Browser,
Camera,
ChatCircle,
Eye,
Globe,
Heart,
MagnifyingGlass,
Megaphone,
PaperPlaneTilt,
Plus,
Robot,
ShieldCheck,
SignIn,
Trash,
UserPlus,
Warning,
X,
} from "@phosphor-icons/react";
import type { BrowserActionRecord, SectionId, SocialAccountRecord, SocialPlatform } from "@shared/types";
import { ActionButton, Input } from "./ui";
/* ------------------------------------------------------------------ */
/* Platform Config */
/* ------------------------------------------------------------------ */
const platformConfig: Record<SocialPlatform, { label: string; color: string; bgColor: string; icon: string }> = {
twitter: { label: "Twitter / X", color: "#1DA1F2", bgColor: "#1DA1F210", icon: "𝕏" },
linkedin: { label: "LinkedIn", color: "#0A66C2", bgColor: "#0A66C210", icon: "in" },
instagram: { label: "Instagram", color: "#E4405F", bgColor: "#E4405F10", icon: "IG" },
facebook: { label: "Facebook", color: "#1877F2", bgColor: "#1877F210", icon: "fb" },
reddit: { label: "Reddit", color: "#FF4500", bgColor: "#FF450010", icon: "r/" },
youtube: { label: "YouTube", color: "#FF0000", bgColor: "#FF000010", icon: "YT" },
tiktok: { label: "TikTok", color: "#000000", bgColor: "#00000010", icon: "TT" },
bluesky: { label: "Bluesky", color: "#0085FF", bgColor: "#0085FF10", icon: "🦋" },
other: { label: "Other", color: "#6B7280", bgColor: "#6B728010", icon: "🌐" },
};
const actionTypeLabels: Record<string, { label: string; icon: React.ElementType }> = {
post: { label: "Post", icon: PaperPlaneTilt },
reply: { label: "Reply", icon: ChatCircle },
like: { label: "Like", icon: Heart },
follow: { label: "Follow", icon: UserPlus },
browse_feed: { label: "Browse Feed", icon: Eye },
screenshot: { label: "Screenshot", icon: Camera },
navigate: { label: "Navigate", icon: Globe },
search: { label: "Search", icon: MagnifyingGlass },
dm: { label: "Direct Message", icon: ChatCircle },
custom_script: { label: "Custom Script", icon: Browser },
};
function timeAgo(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = Math.max(0, now - then);
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/* ------------------------------------------------------------------ */
/* Social Account Card */
/* ------------------------------------------------------------------ */
function AccountCard({
account,
onLogin,
onDelete,
onClick,
}: {
account: SocialAccountRecord;
onLogin: () => void;
onDelete: () => void;
onClick: () => void;
}) {
const platform = platformConfig[account.platform] ?? platformConfig.other;
const isLoggedIn = account.status === "logged_in" || account.status === "active";
return (
<div className="group flex items-center gap-3 rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3 transition hover:border-[color:var(--line-strong)] hover:shadow-sm">
{/* Platform icon */}
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px] text-[14px] font-bold"
style={{ backgroundColor: platform.bgColor, color: platform.color }}
>
{platform.icon}
</div>
{/* Info */}
<button type="button" onClick={onClick} className="min-w-0 flex-1 text-left">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-[color:var(--text)]">{account.displayName || account.accountName}</span>
<span
className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]"
style={{ backgroundColor: platform.bgColor, color: platform.color }}
>
{platform.label}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
<span>@{account.accountName}</span>
{account.requireApproval ? (
<span className="flex items-center gap-1 text-[color:var(--warn)]">
<ShieldCheck size={10} />
Approval required
</span>
) : null}
{account.lastUsedAt ? <span>Last used {timeAgo(account.lastUsedAt)}</span> : null}
</div>
</button>
{/* Status + Actions */}
<div className="flex items-center gap-2">
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
isLoggedIn
? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
: account.status === "login_required"
? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
: account.status === "suspended"
? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
: "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
}`}>
<span className={`h-1.5 w-1.5 rounded-full ${
isLoggedIn ? "bg-[color:var(--success)]"
: account.status === "login_required" ? "bg-[color:var(--warn)]"
: account.status === "suspended" ? "bg-[color:var(--danger)]"
: "bg-[color:var(--muted)]"
}`} />
{account.status === "logged_in" ? "Logged In" : account.status === "active" ? "Active" : account.status.replace("_", " ")}
</span>
{account.status === "login_required" ? (
<button
type="button"
onClick={onLogin}
className="flex items-center gap-1 rounded-[6px] bg-[color:var(--accent)] px-2.5 py-1.5 text-[10px] font-semibold text-white transition hover:opacity-90"
>
<SignIn size={12} />
Login
</button>
) : null}
<button
type="button"
onClick={onDelete}
className="rounded-[6px] p-1.5 text-[color:var(--muted)] opacity-0 transition hover:bg-[color:var(--danger-soft)] hover:text-[color:var(--danger)] group-hover:opacity-100"
>
<Trash size={14} />
</button>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Add Account Dialog */
/* ------------------------------------------------------------------ */
function AddAccountDialog({
onAdd,
onClose,
}: {
onAdd: (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => void;
onClose: () => void;
}) {
const [platform, setPlatform] = useState<SocialPlatform>("twitter");
const [accountName, setAccountName] = useState("");
const [displayName, setDisplayName] = useState("");
const [requireApproval, setRequireApproval] = useState(true);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="w-full max-w-[420px] rounded-[14px] border border-[color:var(--line)] bg-[color:var(--panel)] p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-5 flex items-center justify-between">
<h3 className="text-[16px] font-bold text-[color:var(--text)]">Add Social Account</h3>
<button type="button" onClick={onClose} className="rounded-[6px] p-1 text-[color:var(--muted)] hover:bg-[color:var(--panel-soft)]">
<X size={18} />
</button>
</div>
<div className="space-y-4">
{/* Platform selector */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Platform</label>
<div className="grid grid-cols-3 gap-1.5">
{(Object.entries(platformConfig) as [SocialPlatform, typeof platformConfig.twitter][]).map(([key, config]) => (
<button
key={key}
type="button"
onClick={() => setPlatform(key)}
className={`flex items-center gap-2 rounded-[8px] border px-3 py-2 text-[11px] font-medium transition ${
platform === key
? "border-[color:var(--accent)] bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
: "border-[color:var(--line)] text-[color:var(--muted)] hover:border-[color:var(--line-strong)]"
}`}
>
<span style={{ color: config.color }}>{config.icon}</span>
{config.label}
</button>
))}
</div>
</div>
<Input
label="Username / Handle"
value={accountName}
onChange={setAccountName}
placeholder={platform === "twitter" ? "elonmusk" : "your-username"}
/>
<Input
label="Display Name (optional)"
value={displayName}
onChange={setDisplayName}
placeholder="Your display name"
/>
{/* Approval toggle */}
<div className="rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-4 py-3">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={requireApproval}
onChange={(e) => setRequireApproval(e.target.checked)}
className="h-4 w-4 rounded border-[color:var(--line)] accent-[color:var(--accent)]"
/>
<div>
<div className="text-[12px] font-medium text-[color:var(--text)]">Require Board Approval for Posts</div>
<div className="mt-0.5 text-[10px] text-[color:var(--muted)]">
Agents must get approval before publishing content to this account
</div>
</div>
</label>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-[8px] px-4 py-2 text-[12px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)]"
>
Cancel
</button>
<ActionButton
label="Add Account"
onClick={() => {
if (accountName.trim()) {
onAdd(platform, accountName.trim(), displayName.trim(), requireApproval);
}
}}
icon={Plus}
tone="accent"
disabled={!accountName.trim()}
/>
</div>
</motion.div>
</motion.div>
);
}
/* ------------------------------------------------------------------ */
/* Browser Action History */
/* ------------------------------------------------------------------ */
function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; agents: Array<{ id: string; name: string }> }) {
if (actions.length === 0) {
return (
<div className="py-8 text-center text-[13px] text-[color:var(--muted)]">
No browser actions yet. Agents will create actions when working on social media tasks.
</div>
);
}
return (
<div className="space-y-1.5">
{actions.slice(0, 30).map((action) => {
const config = actionTypeLabels[action.actionType] ?? actionTypeLabels.custom_script;
const Icon = config.icon;
const agent = agents.find((a) => a.id === action.agentId);
const isSuccess = action.status === "succeeded";
const isFailed = action.status === "failed";
const isRunning = action.status === "running";
return (
<div
key={action.id}
className="flex items-center gap-3 rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5 text-[12px]"
>
<Icon size={16} className={
isSuccess ? "text-[color:var(--success)]"
: isFailed ? "text-[color:var(--danger)]"
: isRunning ? "text-[color:var(--accent)]"
: "text-[color:var(--muted)]"
} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-[color:var(--text)]">{config.label}</span>
{agent ? (
<span className="flex items-center gap-1 text-[color:var(--muted)]">
<Robot size={10} />
{agent.name}
</span>
) : null}
</div>
{action.resultSummary ? (
<div className="mt-0.5 truncate text-[11px] text-[color:var(--muted)]">{action.resultSummary}</div>
) : null}
{action.errorMessage ? (
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
) : null}
</div>
<div className="shrink-0 text-[10px] text-[color:var(--muted)]">
{timeAgo(action.createdAt)}
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${
isSuccess ? "bg-[color:var(--success-soft)] text-[color:var(--success)]"
: isFailed ? "bg-[color:var(--danger-soft)] text-[color:var(--danger)]"
: isRunning ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
: action.status === "approval_required" ? "bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
: "bg-[color:var(--panel-soft)] text-[color:var(--muted)]"
}`}>
{action.status.replace("_", " ")}
</span>
</div>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main Social Accounts Panel */
/* ------------------------------------------------------------------ */
interface SocialPanelProps {
accounts: SocialAccountRecord[];
actions: BrowserActionRecord[];
agents: Array<{ id: string; name: string }>;
companyId: string;
onSave: (input: {
companyId: string;
platform: SocialPlatform;
accountName: string;
displayName: string;
requireApproval: boolean;
}) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onLogin: (accountId: string) => Promise<void>;
onNavigate: (section: SectionId, entityId?: string) => void;
}
export function SocialAccountsPanel({
accounts,
actions,
agents,
companyId,
onSave,
onDelete,
onLogin,
onNavigate,
}: SocialPanelProps) {
const [showAddDialog, setShowAddDialog] = useState(false);
const [activeTab, setActiveTab] = useState<"accounts" | "history">("accounts");
const handleAdd = useCallback(async (platform: SocialPlatform, accountName: string, displayName: string, requireApproval: boolean) => {
await onSave({ companyId, platform, accountName, displayName, requireApproval });
setShowAddDialog(false);
}, [companyId, onSave]);
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] bg-[color:var(--accent-soft)]">
<Megaphone size={20} weight="fill" className="text-[color:var(--accent)]" />
</div>
<div>
<h2 className="text-[16px] font-bold text-[color:var(--text)]">Social Media</h2>
<p className="text-[12px] text-[color:var(--muted)]">
{accounts.length} account{accounts.length !== 1 ? "s" : ""} connected
</p>
</div>
</div>
<ActionButton
label="Add Account"
onClick={() => setShowAddDialog(true)}
icon={Plus}
tone="accent"
/>
</div>
{/* Info banner */}
<div className="rounded-[10px] border border-[color:var(--accent)] border-opacity-20 bg-[color:var(--accent-soft)] px-4 py-3">
<div className="flex items-start gap-2 text-[12px] text-[color:var(--accent)]">
<Browser size={16} className="mt-0.5 shrink-0" />
<div>
<div className="font-medium">How it works</div>
<div className="mt-1 text-[11px] opacity-80">
Add your social media accounts here. Click "Login" to open a browser where you can sign in.
Your session is saved locally so marketing agents can post content, reply to users, and browse feeds
using Playwright browser automation. All actions are logged and can require Board approval.
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
<button
type="button"
onClick={() => setActiveTab("accounts")}
className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${
activeTab === "accounts"
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
}`}
>
Accounts ({accounts.length})
</button>
<button
type="button"
onClick={() => setActiveTab("history")}
className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${
activeTab === "history"
? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm"
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
}`}
>
Action History ({actions.length})
</button>
</div>
{/* Content */}
{activeTab === "accounts" ? (
accounts.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed border-[color:var(--line)] py-16 text-center">
<Megaphone size={32} className="mb-3 text-[color:var(--muted)]" />
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No social accounts</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
Add your first social media account to enable marketing automation
</div>
<div className="mt-4">
<ActionButton
label="Add Account"
onClick={() => setShowAddDialog(true)}
icon={Plus}
tone="accent"
/>
</div>
</div>
) : (
<div className="space-y-2">
{accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
onLogin={() => void onLogin(account.id)}
onDelete={() => void onDelete(account.id)}
onClick={() => {/* future: account detail view */}}
/>
))}
</div>
)
) : (
<ActionHistory actions={actions} agents={agents} />
)}
{/* Add Account Dialog */}
<AnimatePresence>
{showAddDialog ? (
<AddAccountDialog
onAdd={(platform, accountName, displayName, requireApproval) => void handleAdd(platform, accountName, displayName, requireApproval)}
onClose={() => setShowAddDialog(false)}
/>
) : null}
</AnimatePresence>
</div>
);
}