update
This commit is contained in:
1 parent
41eafc7c44
commit
54bcc77ef7
19 files changed
+11420
-64
No files matched your search
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user