This commit is contained in:
ZacharyZhang-NY committed 2026-03-17 12:31:06 -04:00
1 parent 54bcc77ef7
commit 7a3db27ffa
33 files changed
+5906 -3089

No files matched your search

+124 -211
View File
@@ -3,7 +3,6 @@ import { AnimatePresence, motion } from "framer-motion";
import {
ArrowClockwise,
Bell,
BookOpen,
Brain,
CalendarBlank,
CaretUpDown,
@@ -39,8 +38,6 @@ import {
X,
} from "@phosphor-icons/react";
import type {
AgentMessageRecord,
AutomationRuleRecord,
CompanyRecord,
CompanyMetrics,
DesktopEvent,
@@ -51,7 +48,6 @@ import type {
SocialAccountRecord,
BrowserActionRecord,
StandupReport,
WorkflowPipelineRecord,
} from "@shared/types";
import type { SidebarBadges } from "@shared/contracts";
import { CompanyOnboarding } from "./components/CompanyOnboarding";
@@ -72,6 +68,7 @@ import {
TaskInspector,
TaskQueue,
WorkspaceCreator,
WorkspaceInspector,
WorkspaceList,
} from "./components/execution";
import {
@@ -90,6 +87,7 @@ import { SocialAccountsPanel } from "./components/social";
import { AutomationPanel } from "./components/automation";
import { CommunicationHub } from "./components/communication";
import { HiringPipeline } from "./components/hiring";
import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "./components/company-operations";
import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui";
import { unwrap } from "./lib/desktop";
import { formatMoney, formatTime } from "./lib/formatters";
@@ -175,12 +173,15 @@ export function App() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null);
const [selectedCommunicationEntityId, setSelectedCommunicationEntityId] = useState<string | null>(null);
const [taskViewMode, setTaskViewMode] = useState<"list" | "kanban">("list");
const logBufferRef = useRef<Record<string, string>>({});
const [, setLogRevision] = useState(0);
const [badges, setBadges] = useState<SidebarBadges | null>(null);
const [commandPaletteSearch, setCommandPaletteSearch] = useState("");
const [showOnboarding, setShowOnboarding] = useState(false);
const [initialOnboardingLocked, setInitialOnboardingLocked] = useState(false);
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [companyMetrics, setCompanyMetrics] = useState<CompanyMetrics | null>(null);
const [standupReport, setStandupReport] = useState<StandupReport | null>(null);
@@ -191,23 +192,46 @@ export function App() {
try {
const next = unwrap<ProfileSnapshot>(await window.agentCompany.loadSnapshot());
setSnapshot(next);
// Tasks are opened explicitly by clicking; don't auto-select.
if (next.companies.length === 0) {
setInitialOnboardingLocked(true);
}
if (!selectedConnectorId && next.connectors[0]) setSelectedConnectorId(next.connectors[0].id);
if (!selectedApprovalId && next.approvals[0]) setSelectedApprovalId(next.approvals[0].id);
if (!selectedRunId && next.runs[0]) setSelectedRunId(next.runs[0].id);
if (next.currentCompanyId) {
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
if (badgeResult.ok) setBadges(badgeResult.data);
setSocialAccounts(next.socialAccounts?.filter(a => a.companyId === next.currentCompanyId) ?? []);
const browserActionResult = await window.agentCompany.listBrowserActions({ companyId: next.currentCompanyId, limit: 50 });
if (browserActionResult.ok) {
setBrowserActions(browserActionResult.data);
}
}
setError(null);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
}
}, [selectedApprovalId, selectedConnectorId, selectedRunId]);
}, [selectedConnectorId, selectedRunId]);
// Stable ref so the subscribe callback always calls the latest refresh
// without tearing down the subscription when refresh identity changes.
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
// Debounce timer ref for coalescing rapid real-time events
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedRefresh = useCallback(() => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void refreshRef.current();
}, 500);
}, []);
useEffect(() => {
void refresh();
void refreshRef.current();
const unsubscribe = window.agentCompany.subscribe((event: DesktopEvent) => {
if (event.type === "run-log") {
logBufferRef.current = {
@@ -220,13 +244,18 @@ export function App() {
if (event.type === "shortcut") {
if (event.action === "toggle-command-palette") setCommandPaletteOpen((current) => !current);
if (event.action === "toggle-console") setConsoleOpen((current) => !current);
if (event.action === "refresh") void refresh();
if (event.action === "refresh") void refreshRef.current();
return;
}
void refresh();
// All data-mutating events trigger a debounced snapshot reload:
// domain-changed, run-status, new-message, automation-fired, heartbeat-status
debouncedRefresh();
});
return unsubscribe;
}, [refresh]);
return () => {
unsubscribe();
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [debouncedRefresh]);
useEffect(() => {
if (!selectedRunId) return;
@@ -272,13 +301,9 @@ export function App() {
if (section === "social") {
setSocialAccounts(snapshot.socialAccounts?.filter(a => a.companyId === companyId) ?? []);
void window.agentCompany.listBrowserActions({ companyId, limit: 50 }).then(result => {
if (result.ok) setBrowserActions(result.data);
});
}
}, [section, snapshot?.currentCompanyId]);
}, [section, snapshot]);
// Global Escape key handler for command palette
useEffect(() => {
if (!commandPaletteOpen) return;
function onKey(event: KeyboardEvent) {
@@ -302,11 +327,10 @@ export function App() {
const selectedTask = selectedTaskId ? (tasks.find((task) => task.id === selectedTaskId) ?? null) : null;
const selectedConnector = snapshot?.connectors.find((connector) => connector.id === selectedConnectorId) ?? snapshot?.connectors[0] ?? null;
const approvals = useMemo(() => snapshot?.approvals.filter((approval) => !currentCompany || approval.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedApproval = approvals.find((approval) => approval.id === selectedApprovalId) ?? approvals[0] ?? null;
const selectedApproval = selectedApprovalId ? (approvals.find((approval) => approval.id === selectedApprovalId) ?? null) : null;
const runs = useMemo(() => snapshot?.runs.filter((run) => !currentCompany || run.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null;
// Derived selections for organization entities
const companyAgents = useMemo(() => snapshot?.agents.filter((a) => !currentCompany || a.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedAgent = companyAgents.find((a) => a.id === selectedAgentId) ?? null;
const companyGoals = useMemo(() => snapshot?.goals.filter((g) => !currentCompany || g.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
@@ -314,32 +338,24 @@ export function App() {
const companyProjects = useMemo(() => snapshot?.projects.filter((p) => !currentCompany || p.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedProject = companyProjects.find((p) => p.id === selectedProjectId) ?? null;
// Sidebar badge mapping
const activeInboxCount = useMemo(() => inboxItems.filter((item) => !item.dismissed).length, [inboxItems]);
const activeInboxCount = inboxItems.length;
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => badges ? {
inbox: activeInboxCount > 0 ? { count: activeInboxCount, tone: "warn" } : undefined,
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
costs: badges.budgetWarnings > 0 ? { count: badges.budgetWarnings, tone: "danger" } : undefined,
communication: badges.unreadMessages > 0 ? { count: badges.unreadMessages, tone: "warn" } : undefined,
} : {}, [badges, activeInboxCount]);
const liveRuns = useMemo(() => runs.filter((run) => run.status === "running" || run.status === "queued"), [runs]);
const failedRuns = useMemo(() => runs.filter((run) => ["failed", "timed_out", "interrupted"].includes(run.status)), [runs]);
const pendingApprovals = useMemo(() => approvals.filter((approval) => approval.state === "pending"), [approvals]);
const tasksInProgress = useMemo(() => tasks.filter((task) => task.status === "in_progress" || task.status === "in_review"), [tasks]);
const companyWorkspaces = useMemo(() => snapshot?.workspaces.filter((workspace) => !currentCompany || workspace.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const selectedWorkspace = companyWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? companyWorkspaces[0] ?? null;
const companyCosts = useMemo(() => snapshot?.costs.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const filteredActivity = useMemo(() => snapshot?.activity.filter((entry) => !currentCompany || entry.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companySecrets = useMemo(() => snapshot?.secrets.filter((secret) => !currentCompany || secret.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const totalSpend = useMemo(() => companyCosts.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0), [companyCosts]);
const overviewRows = useMemo(() => [
{ label: "Live runs", value: String(liveRuns.length), tone: liveRuns.length > 0 ? "text-[color:var(--success)]" : "" },
{ label: "Pending approvals", value: String(pendingApprovals.length), tone: pendingApprovals.length > 0 ? "text-[color:var(--warn)]" : "" },
{ label: "Active tasks", value: String(tasksInProgress.length), tone: "" },
{ label: "Known spend", value: formatMoney(totalSpend), tone: "" },
], [liveRuns, pendingApprovals, tasksInProgress, totalSpend]);
// Command palette entity search
const companyDocuments = useMemo(() => snapshot?.documents?.filter((d) => !currentCompany || d.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const paletteQuery = commandPaletteSearch.trim().toLowerCase();
const paletteResults = useMemo(() => {
const filteredNavItems = paletteQuery
@@ -354,6 +370,18 @@ export function App() {
}, [paletteQuery, companyAgents, tasks, companyGoals, companyProjects]);
const { filteredNavItems, matchedAgents, matchedTasks, matchedGoals, matchedProjects, hasEntityResults } = paletteResults;
const navigateTo = useCallback((targetSection: SectionId, entityId?: string) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
else if (targetSection === "communication") setSelectedCommunicationEntityId(entityId);
}
}, []);
const setCompany = useCallback(async (companyId: string) => {
if (companyId === "__new__") {
setShowOnboarding(true);
@@ -419,8 +447,12 @@ export function App() {
);
}
if (snapshot.companies.length === 0 || showOnboarding) {
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => { setShowOnboarding(false); await refresh(); }} />;
if (snapshot.companies.length === 0 || showOnboarding || initialOnboardingLocked) {
return <CompanyOnboarding connectors={snapshot.connectors} onDone={async () => {
setShowOnboarding(false);
setInitialOnboardingLocked(false);
await refresh();
}} />;
}
return (
@@ -560,7 +592,7 @@ export function App() {
className={`focus-ring mt-1 flex w-full items-center rounded-[6px] py-[7px] text-[color:var(--muted)] transition duration-150 hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)] ${sidebarCollapsed ? "justify-center p-2" : "gap-2.5 px-3"}`}
onClick={() => setSidebarCollapsed((c) => !c)}
>
<SidebarSimple size={sidebarCollapsed ? 18 : 17} className={sidebarCollapsed ? "" : ""} />
<SidebarSimple size={sidebarCollapsed ? 18 : 17} />
{!sidebarCollapsed ? <span className="text-[13px] font-medium tracking-[0.005em]">Collapse</span> : null}
</button>
</div>
@@ -626,67 +658,33 @@ export function App() {
activity={filteredActivity}
costs={companyCosts}
connectors={snapshot.connectors}
meetings={snapshot.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id)}
sprints={snapshot.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id)}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
meetings={companyMeetings}
sprints={companySprints}
messages={snapshot.agentMessages?.filter((message) => !currentCompany || message.companyId === currentCompany.id) ?? []}
browserActions={browserActions}
workflows={snapshot.workflows?.filter((workflow) => !currentCompany || workflow.companyId === currentCompany.id) ?? []}
onNavigate={navigateTo}
/>
) : null}
{section === "inbox" ? (
<Inbox
items={inboxItems}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
onDismiss={(_itemId) => { /* no-op for now */ }}
onNavigate={navigateTo}
/>
) : null}
{section === "standup" ? (
<StandupView
report={standupReport}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
onNavigate={navigateTo}
/>
) : null}
{section === "performance" ? (
<PerformanceView
metrics={companyMetrics}
onNavigate={(targetSection, entityId) => {
setSection(targetSection);
if (entityId) {
if (targetSection === "agents") setSelectedAgentId(entityId);
else if (targetSection === "tasks") setSelectedTaskId(entityId);
else if (targetSection === "approvals") setSelectedApprovalId(entityId);
else if (targetSection === "goals") setSelectedGoalId(entityId);
else if (targetSection === "projects") setSelectedProjectId(entityId);
}
}}
onNavigate={navigateTo}
/>
) : null}
@@ -717,7 +715,7 @@ export function App() {
</Panel>
<Panel
title={selectedConnector.label}
eyebrow={undefined}
action={<ActionButton label="Run health check" onClick={() => void quickConnectorCheck()} icon={Wrench} tone="accent" />}
>
<ConnectorEditor connector={selectedConnector} onSaved={refresh} />
@@ -762,7 +760,6 @@ export function App() {
{section === "hiring" && currentCompany ? (
<HiringPipeline
companyId={currentCompany.id}
agents={companyAgents}
approvals={(snapshot?.approvals ?? []).filter(a => a.companyId === currentCompany!.id)}
onRefresh={refresh}
@@ -906,7 +903,7 @@ export function App() {
tasks={tasks}
companyId={currentCompany!.id}
onDecide={async (state, note) => {
unwrap(await window.agentCompany.decideApproval({ approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
unwrap(await window.agentCompany.decideApproval({ companyId: currentCompany!.id, approvalId: selectedApproval.id, state, decisionNote: note ?? `${state} from desktop control surface.` }));
await refresh();
}}
onDeleted={async () => { setSelectedApprovalId(null); await refresh(); }}
@@ -965,16 +962,19 @@ export function App() {
}}
onLogin={async (accountId) => {
await window.agentCompany.triggerBrowserLogin(accountId);
await refresh();
}}
onNavigate={(s, id) => {
setSection(s);
if (id) {
if (s === "agents") setSelectedAgentId(id);
else if (s === "tasks") setSelectedTaskId(id);
else if (s === "approvals") setSelectedApprovalId(id);
else if (s === "goals") setSelectedGoalId(id);
else if (s === "projects") setSelectedProjectId(id);
}
onOpenPath={async (path) => {
await window.agentCompany.openPath(path);
}}
onCancelAction={async (actionId) => {
await window.agentCompany.cancelBrowserAction({ runId: actionId });
await refresh();
}}
onNavigate={(sectionName, entityId) => {
setSection(sectionName);
if (sectionName === "tasks") setSelectedTaskId(entityId);
if (sectionName === "approvals") setSelectedApprovalId(entityId);
}}
/>
)}
@@ -983,7 +983,6 @@ export function App() {
<AutomationPanel
rules={snapshot.automationRules?.filter(r => r.companyId === currentCompany.id) ?? []}
workflows={snapshot.workflows?.filter(w => w.companyId === currentCompany.id) ?? []}
agents={companyAgents}
companyId={currentCompany.id}
onRefresh={refresh}
/>
@@ -992,137 +991,36 @@ export function App() {
{section === "communication" && currentCompany ? (
<CommunicationHub
messages={snapshot.agentMessages?.filter(m => m.companyId === currentCompany.id) ?? []}
comments={snapshot.comments?.filter((comment) => comment.companyId === currentCompany.id) ?? []}
tasks={tasks}
agents={companyAgents}
companyId={currentCompany.id}
selectedEntityId={selectedCommunicationEntityId}
onRefresh={refresh}
/>
) : null}
{section === "documents" && currentCompany ? (
<Panel title="Company Documents" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length} documents</span>}>
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<FileText size={32} className="mx-auto mb-3 opacity-40" />
<p>No documents yet. Agents will create PRDs, specs, and reports as they work.</p>
</div>
) : (
<ListFrame>
{(snapshot.documents?.filter(d => d.companyId === currentCompany.id) ?? []).map(doc => (
<ListRow key={doc.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<FileText size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{doc.title}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{doc.type.replace(/_/g, " ")}</span>
<span>v{doc.version}</span>
{doc.authorAgentId && <span>by {companyAgents.find(a => a.id === doc.authorAgentId)?.name ?? "Unknown"}</span>}
</div>
</div>
<StatusPill status={doc.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
<DocumentCenter
companyId={currentCompany.id}
documents={companyDocuments}
agents={companyAgents}
projects={companyProjects}
goals={companyGoals}
onRefresh={refresh}
/>
) : null}
{section === "knowledge" && currentCompany ? (
<Panel title="Knowledge Base" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length} entries</span>}>
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<Brain size={32} className="mx-auto mb-3 opacity-40" />
<p>No knowledge entries yet. Agents learn and document lessons as they work.</p>
</div>
) : (
<ListFrame>
{(snapshot.knowledgeBase?.filter(k => k.companyId === currentCompany.id) ?? []).map(entry => (
<ListRow key={entry.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Brain size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{entry.topic}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{entry.category.replace(/_/g, " ")}</span>
<span className="mx-2">-</span>
<span>{entry.content.slice(0, 100)}{entry.content.length > 100 ? "..." : ""}</span>
</div>
</div>
<StatusPill status={entry.importance} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
<KnowledgeBaseManager companyId={currentCompany.id} entries={companyKnowledge} onRefresh={refresh} />
) : null}
{section === "meetings" && currentCompany ? (
<Panel title="Meetings" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length} meetings</span>}>
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<CalendarBlank size={32} className="mx-auto mb-3 opacity-40" />
<p>No meetings scheduled. Agents will schedule standups, reviews, and planning sessions.</p>
</div>
) : (
<ListFrame>
{(snapshot.meetings?.filter(m => m.companyId === currentCompany.id) ?? []).map(meeting => (
<ListRow key={meeting.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CalendarBlank size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{meeting.title}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span className="uppercase tracking-wider">{meeting.type.replace(/_/g, " ")}</span>
<span>{new Date(meeting.scheduledAt).toLocaleString()}</span>
<span>{meeting.durationMinutes}min</span>
</div>
</div>
<StatusPill status={meeting.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
<MeetingsManager companyId={currentCompany.id} meetings={companyMeetings} agents={companyAgents} onRefresh={refresh} />
) : null}
{section === "sprints" && currentCompany ? (
<Panel title="Sprint Board" action={<span className="text-[11px] text-[color:var(--muted)]">{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length} sprints</span>}>
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).length === 0 ? (
<div className="py-10 text-center text-[13px] text-[color:var(--muted)]">
<Rows size={32} className="mx-auto mb-3 opacity-40" />
<p>No sprints yet. Start a sprint to organize work into focused cycles.</p>
</div>
) : (
<ListFrame>
{(snapshot.sprints?.filter(s => s.companyId === currentCompany.id) ?? []).map(sprint => (
<ListRow key={sprint.id}>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Rows size={14} className="text-[color:var(--accent)]" />
<span className="text-[13px] font-medium text-[color:var(--text)]">{sprint.name}</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-[color:var(--muted)]">
<span>{sprint.startDate} - {sprint.endDate}</span>
<span>{sprint.completedPoints}/{sprint.velocityPoints} pts</span>
</div>
</div>
<StatusPill status={sprint.status} />
</div>
</ListRow>
))}
</ListFrame>
)}
</Panel>
<SprintManager companyId={currentCompany.id} sprints={companySprints} onRefresh={refresh} />
) : null}
{section === "workspaces" ? (
@@ -1135,15 +1033,30 @@ export function App() {
workspaces={companyWorkspaces}
projects={companyProjects}
companyId={currentCompany!.id}
selectedWorkspaceId={selectedWorkspace?.id ?? null}
onSelect={setSelectedWorkspaceId}
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)}
onDeleted={refresh}
/>
</Panel>
}
secondary={
<Panel title="Add workspace">
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} />
</Panel>
<div className="space-y-6">
<Panel title="Workspace control center">
<WorkspaceInspector
workspace={selectedWorkspace}
agents={companyAgents}
tasks={tasks}
runs={runs}
documents={companyDocuments}
project={selectedWorkspace ? (companyProjects.find((project) => project.id === selectedWorkspace.projectId) ?? null) : null}
onOpen={(path) => window.agentCompany.openPath(path).then(() => undefined)}
/>
</Panel>
<Panel title="Add workspace">
<WorkspaceCreator companyId={currentCompany!.id} projects={companyProjects} onSaved={refresh} />
</Panel>
</div>
}
/>
) : null}
+66 -63
View File
@@ -20,6 +20,7 @@ import {
} from "@phosphor-icons/react";
import type { CompanyTemplate, ProfileSnapshot } from "@shared/types";
import { unwrap } from "../lib/desktop";
import { getAgentInitials } from "../lib/formatters";
import { ActionButton, Input, TextArea } from "./ui";
type Step = "choose" | "template" | "company" | "goal" | "launch";
@@ -56,7 +57,6 @@ export function CompanyOnboarding({
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);
@@ -75,7 +75,6 @@ export function CompanyOnboarding({
}
}
// Template launch
const launchFromTemplate = useCallback(async () => {
if (loading || !selectedTemplate) return;
if (!companyName.trim()) {
@@ -98,7 +97,6 @@ export function CompanyOnboarding({
}),
);
// Create workspace for the company if path provided
if (workspacePath) {
unwrap(
await window.agentCompany.saveWorkspace({
@@ -122,7 +120,6 @@ export function CompanyOnboarding({
}
}, [loading, selectedTemplate, companyName, hasReadyConnector, workspacePath, bestConnector, onDone]);
// Custom company creation (existing flow)
const createCompany = useCallback(async () => {
if (loading) return;
if (!companyName.trim()) {
@@ -166,66 +163,77 @@ export function CompanyOnboarding({
}
}, [loading, companyName, hasReadyConnector, autoApproveHires, workspacePath]);
const bootstrapInitialCompany = useCallback(async (companyId: string, seedGoal?: { title?: string; description?: string }) => {
if (seedGoal?.title?.trim()) {
unwrap(
await window.agentCompany.saveGoal({
companyId,
title: seedGoal.title.trim(),
description: seedGoal.description?.trim() || `Achieve: ${seedGoal.title.trim()}`,
status: "active",
parentId: null,
ownerAgentId: null,
}),
);
}
if (bestConnector) {
const ceoId = unwrap(
await window.agentCompany.saveAgent({
companyId,
name: "CEO",
role: "CEO",
title: "Chief Executive Officer",
reportsTo: null,
connectorId: bestConnector.id,
workspaceId: null,
model: null,
capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
status: "idle",
}),
);
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId, enabled: true, intervalSec: 120 }));
setStep("launch");
setTimeout(async () => {
try {
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId, trigger: "assignment" });
} catch {
// Non-fatal. The CEO will wake on the next scheduled heartbeat.
}
}, 1500);
}
await onDone();
}, [bestConnector, onDone]);
const createGoalAndLaunch = useCallback(async () => {
if (loading || !createdCompanyId) return;
setLoading(true);
setError(null);
try {
if (goalTitle.trim()) {
unwrap(
await window.agentCompany.saveGoal({
companyId: createdCompanyId,
title: goalTitle.trim(),
description: goalDescription.trim() || `Achieve: ${goalTitle.trim()}`,
status: "active",
parentId: null,
ownerAgentId: null,
}),
);
}
if (bestConnector) {
const ceoId = unwrap(
await window.agentCompany.saveAgent({
companyId: createdCompanyId,
name: "CEO",
role: "CEO",
title: "Chief Executive Officer",
reportsTo: null,
connectorId: bestConnector.id,
workspaceId: null,
model: null,
capabilities: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
status: "idle",
}),
);
unwrap(await window.agentCompany.setHeartbeat({ agentId: ceoId, companyId: createdCompanyId, enabled: true, intervalSec: 120 }));
setStep("launch");
setTimeout(async () => {
try {
await window.agentCompany.triggerHeartbeat({ agentId: ceoId, companyId: createdCompanyId, trigger: "assignment" });
} catch { /* Non-fatal */ }
}, 1500);
}
await onDone();
await bootstrapInitialCompany(createdCompanyId, {
title: goalTitle,
description: goalDescription,
});
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to set up the company.");
setLoading(false);
}
}, [loading, createdCompanyId, goalTitle, goalDescription, bestConnector, onDone]);
}, [loading, createdCompanyId, goalTitle, goalDescription, bootstrapInitialCompany]);
const skipGoalAndFinish = useCallback(async () => {
if (loading) return;
if (loading || !createdCompanyId) return;
setLoading(true);
setError(null);
try {
await onDone();
await bootstrapInitialCompany(createdCompanyId);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to continue.");
} finally {
setLoading(false);
}
}, [loading, onDone]);
}, [loading, createdCompanyId, bootstrapInitialCompany]);
const stepLabels = step === "choose" || step === "template"
? [
@@ -242,14 +250,13 @@ export function CompanyOnboarding({
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)_520px]">
{/* Left panel — branding */}
<div className="flex items-center px-8 py-12 md:px-16">
<div className="grid h-[100dvh] overflow-hidden grid-cols-1 bg-[color:var(--bg)] md:grid-cols-[minmax(0,1fr)_520px]">
<div className="flex min-h-0 items-center justify-center overflow-y-auto px-8 py-12 md:px-16">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.16, 1, 0.3, 1] }}
className="max-w-[440px]"
className="mx-auto w-full max-w-[440px]"
>
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">AgentCompany Desktop</div>
<h1 className="text-[38px] leading-[0.98] tracking-[-0.03em] text-[color:var(--text)] md:text-[48px]">
@@ -259,7 +266,7 @@ export function CompanyOnboarding({
Set up a fully autonomous AI-powered organization. Your agents will hire, delegate, execute, review, and coordinate — just like a real company.
</p>
{/* Step indicators */}
<div className="mt-10 flex items-center gap-3">
{stepLabels.map((s, i) => (
<div key={s.label} className="flex items-center gap-2">
@@ -278,7 +285,7 @@ export function CompanyOnboarding({
))}
</div>
{/* Detected connectors */}
<div className="mt-10">
<div className="mb-3 text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Detected agents</div>
<div className="space-y-2.5">
@@ -313,10 +320,9 @@ export function CompanyOnboarding({
</motion.div>
</div>
{/* 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">
<div className="min-h-0 overflow-y-auto border-l border-[color:var(--line)] bg-[color:var(--bg-subtle)]">
<div className="flex min-h-full items-center px-6 py-10 md:px-10">
<AnimatePresence mode="wait">
{/* ── Step: Choose Mode ────────────────────────── */}
{step === "choose" ? (
<motion.div
key="choose"
@@ -331,7 +337,7 @@ export function CompanyOnboarding({
<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">
@@ -378,7 +384,7 @@ export function CompanyOnboarding({
<div className="h-px flex-1 bg-[color:var(--line)]" />
</div>
{/* Custom option */}
<button
type="button"
onClick={() => setStep("company")}
@@ -395,7 +401,6 @@ export function CompanyOnboarding({
</button>
</motion.div>
) : step === "template" && selectedTemplate ? (
/* ── Step: Template Configuration ─────────────── */
<motion.div
key="template"
initial={{ opacity: 0, y: 18 }}
@@ -421,7 +426,7 @@ export function CompanyOnboarding({
</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">
@@ -430,7 +435,7 @@ export function CompanyOnboarding({
<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()}
{getAgentInitials(agent.name)}
</div>
<span className="font-medium text-[color:var(--text)]">{agent.name}</span>
<span className="text-[color:var(--muted)]">— {agent.title}</span>
@@ -449,7 +454,7 @@ export function CompanyOnboarding({
</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)]">
@@ -492,7 +497,6 @@ export function CompanyOnboarding({
</div>
</motion.div>
) : step === "company" ? (
/* ── Step: Custom Company ─────────────────────── */
<motion.div
key="company"
initial={{ opacity: 0, y: 18 }}
@@ -568,7 +572,6 @@ export function CompanyOnboarding({
</div>
</motion.div>
) : step === "goal" ? (
/* ── Step: Goal ───────────────────────────────── */
<motion.div
key="goal"
initial={{ opacity: 0, y: 18 }}
@@ -639,7 +642,6 @@ export function CompanyOnboarding({
</div>
</motion.div>
) : (
/* ── Step: Launch Success ─────────────────────── */
<motion.div
key="launch"
initial={{ opacity: 0, scale: 0.95 }}
@@ -677,6 +679,7 @@ export function CompanyOnboarding({
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
+118 -156
View File
@@ -17,7 +17,6 @@ import {
X,
} from "@phosphor-icons/react"
import type {
AgentRecord,
AutomationAction,
AutomationRuleRecord,
AutomationStatus,
@@ -27,14 +26,12 @@ import type {
WorkflowStatus,
WorkflowStep,
} from "@shared/types"
import { timeAgo } from "../lib/formatters"
import { DEPARTMENT_OPTIONS } from "@shared/constants"
import { ActionButton, Select } from "./ui"
/* ------------------------------------------------------------------ */
/* Constants & Config */
/* ------------------------------------------------------------------ */
const ACCENT = "#d97745"
const LINE = "rgba(15,23,42,0.08)"
const ACCENT = "var(--accent)"
const LINE = "var(--line)"
const triggerConfig: Record<AutomationTrigger, { label: string; description: string; icon: typeof Lightning }> = {
task_status_changed: { label: "Task Status Changed", description: "When a task transitions to a new status", icon: ArrowsClockwise },
@@ -70,21 +67,7 @@ const actionConfig: Record<AutomationAction, { label: string; description: strin
run_task: { label: "Run Task", description: "Immediately execute the task with an agent" },
}
const departmentOptions: Array<{ value: string; label: string }> = [
{ value: "", label: "Any department" },
{ value: "executive", label: "Executive" },
{ value: "engineering", label: "Engineering" },
{ value: "product", label: "Product" },
{ value: "marketing", label: "Marketing" },
{ value: "sales", label: "Sales" },
{ value: "hr", label: "Human Resources" },
{ value: "finance", label: "Finance" },
{ value: "legal", label: "Legal" },
{ value: "operations", label: "Operations" },
{ value: "customer_support", label: "Customer Support" },
{ value: "research", label: "Research" },
{ value: "design", label: "Design" },
]
const departmentOptions = DEPARTMENT_OPTIONS
const statusColors: Record<AutomationStatus, { bg: string; text: string; dot: string }> = {
active: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" },
@@ -109,25 +92,6 @@ const stepStatusConfig: Record<WorkflowStep["status"], { bg: string; border: str
skipped: { bg: "var(--panel-soft)", border: "var(--line)", text: "var(--muted)" },
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function timeAgo(dateStr: string | null): string {
if (!dateStr) return "never"
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`
}
function safeJsonParse(json: string): unknown {
try {
return JSON.parse(json)
@@ -145,10 +109,6 @@ function safeJsonStringify(value: string): string {
}
}
/* ------------------------------------------------------------------ */
/* Automation Log Entry Type */
/* ------------------------------------------------------------------ */
interface AutomationLogEntry {
ruleId: string
ruleName: string
@@ -157,10 +117,6 @@ interface AutomationLogEntry {
executedAt: string
}
/* ------------------------------------------------------------------ */
/* Automation Rule Card */
/* ------------------------------------------------------------------ */
function RuleCard({
rule,
onToggle,
@@ -187,7 +143,6 @@ function RuleCard({
style={{ borderColor: LINE }}
>
<div className="flex items-center gap-3 px-4 py-3.5">
{/* Trigger icon */}
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
@@ -195,7 +150,6 @@ function RuleCard({
<TriggerIcon size={20} weight="fill" />
</div>
{/* Main info */}
<button
type="button"
onClick={onEdit}
@@ -217,18 +171,15 @@ function RuleCard({
</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[color:var(--muted)]">
{/* Trigger label */}
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]">
<Lightning size={10} weight="fill" />
{triggerInfo?.label ?? rule.trigger}
</span>
<ArrowRight size={10} className="text-[color:var(--muted)]" />
{/* Action label */}
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]">
<Gear size={10} />
{actionInfo?.label ?? rule.action}
</span>
{/* Department flow */}
{rule.sourceDepartment || rule.targetDepartment ? (
<>
<span className="text-[color:var(--line-strong)]">|</span>
@@ -244,7 +195,6 @@ function RuleCard({
</>
) : null}
</div>
{/* Execution stats */}
<div className="mt-1 flex items-center gap-3 text-[10px] text-[color:var(--muted)]">
<span>{rule.executionCount} execution{rule.executionCount !== 1 ? "s" : ""}</span>
<span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" />
@@ -254,7 +204,6 @@ function RuleCard({
</div>
</button>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
@@ -280,10 +229,6 @@ function RuleCard({
)
}
/* ------------------------------------------------------------------ */
/* Rule Creation / Edit Modal */
/* ------------------------------------------------------------------ */
interface RuleFormState {
name: string
description: string
@@ -356,7 +301,6 @@ function RuleFormModal({
setError("Rule name is required")
return
}
// Validate JSON fields
try { JSON.parse(form.conditionsJson) } catch {
setError("Conditions must be valid JSON")
return
@@ -428,7 +372,6 @@ function RuleFormModal({
style={{ borderColor: LINE, maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
<div className="flex items-center gap-3">
<div
@@ -451,17 +394,14 @@ function RuleFormModal({
</button>
</div>
{/* Body */}
<div className="overflow-y-auto px-6 py-5">
<div className="space-y-5">
{/* Error */}
{error ? (
<div className="rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
{error}
</div>
) : null}
{/* Name */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Rule Name
@@ -476,7 +416,6 @@ function RuleFormModal({
/>
</div>
{/* Description */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Description
@@ -491,7 +430,6 @@ function RuleFormModal({
/>
</div>
{/* Trigger */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Trigger
@@ -508,7 +446,6 @@ function RuleFormModal({
) : null}
</div>
{/* Conditions */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Conditions (JSON)
@@ -524,7 +461,6 @@ function RuleFormModal({
/>
</div>
{/* Action */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Action
@@ -541,7 +477,6 @@ function RuleFormModal({
) : null}
</div>
{/* Action Config */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Action Configuration (JSON)
@@ -557,7 +492,6 @@ function RuleFormModal({
/>
</div>
{/* Department selectors */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -581,7 +515,6 @@ function RuleFormModal({
</div>
</div>
{/* Priority slider */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<label className="text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -607,7 +540,6 @@ function RuleFormModal({
</div>
</div>
{/* Footer */}
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
<button
type="button"
@@ -630,10 +562,6 @@ function RuleFormModal({
)
}
/* ------------------------------------------------------------------ */
/* Delete Confirmation Dialog */
/* ------------------------------------------------------------------ */
function DeleteConfirmDialog({
open,
entityName,
@@ -693,10 +621,6 @@ function DeleteConfirmDialog({
)
}
/* ------------------------------------------------------------------ */
/* Activity Log */
/* ------------------------------------------------------------------ */
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
if (entries.length === 0) {
return (
@@ -743,10 +667,6 @@ function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
)
}
/* ------------------------------------------------------------------ */
/* AutomationRulesPanel */
/* ------------------------------------------------------------------ */
function AutomationRulesPanel({
rules,
companyId,
@@ -763,7 +683,6 @@ function AutomationRulesPanel({
const [activeTab, setActiveTab] = useState<"rules" | "activity">("rules")
const [filterStatus, setFilterStatus] = useState<AutomationStatus | "all">("all")
// Fetch activity log
useEffect(() => {
void window.agentCompany.getAutomationLog(companyId).then((result) => {
if (result.ok) setActivityLog(result.data)
@@ -782,29 +701,62 @@ function AutomationRulesPanel({
error: rules.filter((r) => r.status === "error").length,
}), [rules])
const [actionError, setActionError] = useState<string | null>(null)
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => {
setActionError(null)
const newStatus = rule.status === "active" ? "paused" : "active"
await window.agentCompany.toggleAutomationRule({
id: rule.id,
companyId,
status: newStatus as "active" | "paused",
})
onRefresh()
try {
const result = await window.agentCompany.toggleAutomationRule({
id: rule.id,
companyId,
status: newStatus as "active" | "paused",
})
if (!result.ok) {
setActionError(result.error.message)
return
}
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to toggle rule")
}
}, [companyId, onRefresh])
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
await window.agentCompany.deleteAutomationRule({
id: deleteTarget.id,
companyId,
})
setDeleteTarget(null)
onRefresh()
setActionError(null)
try {
const result = await window.agentCompany.deleteAutomationRule({
id: deleteTarget.id,
companyId,
})
if (!result.ok) {
setActionError(result.error.message)
setDeleteTarget(null)
return
}
setDeleteTarget(null)
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to delete rule")
setDeleteTarget(null)
}
}, [deleteTarget, companyId, onRefresh])
return (
<div className="space-y-5">
{/* Header */}
{actionError ? (
<div className="flex items-center justify-between rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
<span>{actionError}</span>
<button
type="button"
onClick={() => setActionError(null)}
className="ml-2 rounded p-0.5 hover:bg-[color:var(--danger)] hover:text-white"
>
<X size={12} />
</button>
</div>
) : null}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
@@ -828,7 +780,6 @@ function AutomationRulesPanel({
/>
</div>
{/* Stats bar */}
{rules.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
<div className="rounded-[10px] border bg-[color:var(--panel)] px-4 py-3" style={{ borderColor: LINE }}>
@@ -850,7 +801,6 @@ function AutomationRulesPanel({
</div>
) : null}
{/* Tabs */}
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1">
<button
type="button"
@@ -876,10 +826,8 @@ function AutomationRulesPanel({
</button>
</div>
{/* Rules content */}
{activeTab === "rules" ? (
<>
{/* Filter */}
{rules.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{(["all", "active", "paused", "error"] as const).map((status) => (
@@ -900,7 +848,6 @@ function AutomationRulesPanel({
</div>
) : null}
{/* Rule list */}
{filteredRules.length === 0 && rules.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}>
<Lightning size={36} className="mb-3 text-[color:var(--muted)]" />
@@ -941,7 +888,6 @@ function AutomationRulesPanel({
<AutomationActivityLog entries={activityLog} />
)}
{/* Create / Edit modal */}
<AnimatePresence>
{showCreateModal ? (
<RuleFormModal
@@ -963,7 +909,6 @@ function AutomationRulesPanel({
) : null}
</AnimatePresence>
{/* Delete confirmation */}
<AnimatePresence>
{deleteTarget ? (
<DeleteConfirmDialog
@@ -978,10 +923,6 @@ function AutomationRulesPanel({
)
}
/* ------------------------------------------------------------------ */
/* Workflow Step Timeline */
/* ------------------------------------------------------------------ */
function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
if (steps.length === 0) {
return (
@@ -993,7 +934,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
return (
<div className="relative pl-5">
{/* Vertical line */}
<div
className="absolute left-[9px] top-2 w-px"
style={{
@@ -1009,7 +949,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
return (
<div key={step.id} className="relative flex items-start gap-3">
{/* Dot */}
<div
className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2"
style={{
@@ -1018,7 +957,6 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
}}
/>
{/* Content */}
<div
className="flex-1 rounded-[8px] border px-3 py-2.5"
style={{ borderColor: step.status === "running" ? ACCENT : LINE }}
@@ -1069,18 +1007,18 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
)
}
/* ------------------------------------------------------------------ */
/* Workflow Card */
/* ------------------------------------------------------------------ */
function WorkflowCard({
workflow,
isRunning,
onDelete,
onEdit,
onRun,
}: {
workflow: WorkflowPipelineRecord
isRunning: boolean
onDelete: () => void
onEdit: () => void
onRun: () => void
}) {
const [expanded, setExpanded] = useState(false)
const colors = workflowStatusColors[workflow.status]
@@ -1102,7 +1040,6 @@ function WorkflowCard({
style={{ borderColor: LINE }}
>
<div className="flex items-center gap-3 px-4 py-3.5">
{/* Icon */}
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]"
style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}
@@ -1110,7 +1047,6 @@ function WorkflowCard({
<TriggerIcon size={20} weight="fill" />
</div>
{/* Main info */}
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
@@ -1149,7 +1085,6 @@ function WorkflowCard({
</div>
</button>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<motion.div
animate={{ rotate: expanded ? 90 : 0 }}
@@ -1164,6 +1099,16 @@ function WorkflowCard({
<CaretRight size={14} />
</button>
</motion.div>
<button
type="button"
onClick={onRun}
aria-label="Run workflow"
title="Run workflow"
disabled={isRunning}
className="rounded-[6px] p-2 text-[color:var(--muted)] transition hover:bg-[color:var(--success-soft)] hover:text-[color:var(--success)] disabled:pointer-events-none disabled:opacity-50"
>
{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}
</button>
<button
type="button"
onClick={onEdit}
@@ -1185,7 +1130,6 @@ function WorkflowCard({
</div>
</div>
{/* Expanded: step timeline */}
<AnimatePresence>
{expanded ? (
<motion.div
@@ -1210,10 +1154,6 @@ function WorkflowCard({
)
}
/* ------------------------------------------------------------------ */
/* Workflow Creation Modal */
/* ------------------------------------------------------------------ */
interface WorkflowFormState {
name: string
description: string
@@ -1342,7 +1282,6 @@ function WorkflowFormModal({
style={{ borderColor: LINE, maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
<div className="flex items-center gap-3">
<div
@@ -1365,7 +1304,6 @@ function WorkflowFormModal({
</button>
</div>
{/* Body */}
<div className="overflow-y-auto px-6 py-5">
<div className="space-y-5">
{error ? (
@@ -1374,7 +1312,6 @@ function WorkflowFormModal({
</div>
) : null}
{/* Name */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Workflow Name
@@ -1389,7 +1326,6 @@ function WorkflowFormModal({
/>
</div>
{/* Description */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Description
@@ -1404,7 +1340,6 @@ function WorkflowFormModal({
/>
</div>
{/* Trigger + Status */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
@@ -1428,7 +1363,6 @@ function WorkflowFormModal({
</div>
</div>
{/* Trigger Config */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Trigger Configuration (JSON)
@@ -1444,7 +1378,6 @@ function WorkflowFormModal({
/>
</div>
{/* Steps */}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">
Pipeline Steps (JSON Array)
@@ -1474,7 +1407,6 @@ function WorkflowFormModal({
</div>
</div>
{/* Footer */}
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
<button
type="button"
@@ -1497,10 +1429,6 @@ function WorkflowFormModal({
)
}
/* ------------------------------------------------------------------ */
/* WorkflowsPanel */
/* ------------------------------------------------------------------ */
function WorkflowsPanel({
workflows,
companyId,
@@ -1513,17 +1441,50 @@ function WorkflowsPanel({
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowPipelineRecord | null>(null)
const [deleteTarget, setDeleteTarget] = useState<WorkflowPipelineRecord | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [runningId, setRunningId] = useState<string | null>(null)
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
await window.agentCompany.deleteWorkflow({
id: deleteTarget.id,
companyId,
})
setDeleteTarget(null)
onRefresh()
setActionError(null)
try {
const result = await window.agentCompany.deleteWorkflow({
id: deleteTarget.id,
companyId,
})
if (!result.ok) {
setActionError(result.error.message)
setDeleteTarget(null)
return
}
setDeleteTarget(null)
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to delete workflow")
setDeleteTarget(null)
}
}, [deleteTarget, companyId, onRefresh])
const handleRun = useCallback(async (workflow: WorkflowPipelineRecord) => {
setActionError(null)
setRunningId(workflow.id)
try {
const result = await window.agentCompany.runWorkflow({
id: workflow.id,
companyId,
})
if (!result.ok) {
setActionError(result.error.message)
return
}
onRefresh()
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to run workflow")
} finally {
setRunningId(null)
}
}, [companyId, onRefresh])
const counts = useMemo(() => ({
total: workflows.length,
active: workflows.filter((w) => w.status === "active").length,
@@ -1532,7 +1493,18 @@ function WorkflowsPanel({
return (
<div className="space-y-5">
{/* Header */}
{actionError ? (
<div className="flex items-center justify-between rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">
<span>{actionError}</span>
<button
type="button"
onClick={() => setActionError(null)}
className="ml-2 rounded p-0.5 hover:bg-[color:var(--danger)] hover:text-white"
>
<X size={12} />
</button>
</div>
) : null}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
@@ -1556,7 +1528,6 @@ function WorkflowsPanel({
/>
</div>
{/* Workflow list */}
{workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}>
<ArrowsClockwise size={36} className="mb-3 text-[color:var(--muted)]" />
@@ -1580,15 +1551,16 @@ function WorkflowsPanel({
<WorkflowCard
key={wf.id}
workflow={wf}
isRunning={runningId === wf.id}
onDelete={() => setDeleteTarget(wf)}
onEdit={() => setEditingWorkflow(wf)}
onRun={() => void handleRun(wf)}
/>
))}
</AnimatePresence>
</div>
)}
{/* Create / Edit modal */}
<AnimatePresence>
{showCreateModal ? (
<WorkflowFormModal
@@ -1610,7 +1582,6 @@ function WorkflowsPanel({
) : null}
</AnimatePresence>
{/* Delete confirmation */}
<AnimatePresence>
{deleteTarget ? (
<DeleteConfirmDialog
@@ -1625,14 +1596,9 @@ function WorkflowsPanel({
)
}
/* ------------------------------------------------------------------ */
/* Main Automation Panel (exported) */
/* ------------------------------------------------------------------ */
interface AutomationPanelProps {
rules: AutomationRuleRecord[]
workflows: WorkflowPipelineRecord[]
agents: AgentRecord[]
companyId: string
onRefresh: () => void
}
@@ -1640,7 +1606,6 @@ interface AutomationPanelProps {
export function AutomationPanel({
rules,
workflows,
agents,
companyId,
onRefresh,
}: AutomationPanelProps) {
@@ -1648,7 +1613,6 @@ export function AutomationPanel({
return (
<div className="space-y-6">
{/* Top banner */}
<div
className="rounded-[12px] border px-5 py-4"
style={{
@@ -1674,7 +1638,6 @@ export function AutomationPanel({
</div>
</div>
{/* Section tabs */}
<div className="flex gap-1 rounded-[10px] bg-[color:var(--panel-soft)] p-1">
<button
type="button"
@@ -1702,7 +1665,6 @@ export function AutomationPanel({
</button>
</div>
{/* Content */}
<AnimatePresence mode="wait">
<motion.div
key={activeSection}
+316 -204
View File
@@ -5,7 +5,6 @@ import {
EnvelopeSimple,
At,
Hash,
UserCircle,
PaperPlaneTilt,
MagnifyingGlass,
Funnel,
@@ -19,21 +18,20 @@ import {
Plus,
X,
CaretDown,
Robot,
Tray,
} from "@phosphor-icons/react"
import type {
AgentMessageRecord,
AgentRecord,
CommentRecord,
DepartmentId,
MessageChannel,
MessagePriority,
TaskRecord,
} from "@shared/types"
import { ActionButton, Modal, Select, Input, TextArea } from "./ui"
/* ------------------------------------------------------------------ */
/* Constants & Config */
/* ------------------------------------------------------------------ */
import { getAgentInitials, formatMessageTime } from "../lib/formatters"
import { DEPARTMENT_COLORS } from "@shared/constants"
import { Modal, Select, Input, TextArea } from "./ui"
const TERRACOTTA = "#d97745"
const TERRACOTTA_SOFT = "#d9774518"
@@ -52,83 +50,14 @@ const priorityConfig: Record<MessagePriority, { label: string; color: string; bg
low: { label: "Low", color: "var(--muted)", bg: "var(--panel-soft)" },
}
const departmentColors: Record<string, string> = {
executive: "#8b5cf6",
engineering: "#3b82f6",
product: "#f59e0b",
marketing: "#ec4899",
sales: "#10b981",
hr: "#6366f1",
finance: "#14b8a6",
legal: "#a78bfa",
operations: "#f97316",
customer_support: "#06b6d4",
research: "#8b5cf6",
design: "#f43f5e",
}
function getDepartmentColor(department: DepartmentId | null | undefined): string {
if (!department) return "var(--muted)"
return departmentColors[department] ?? "var(--muted)"
return DEPARTMENT_COLORS[department] ?? "var(--muted)"
}
function getAgentInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase()
}
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`
}
function formatMessageTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const isToday =
d.getDate() === now.getDate() &&
d.getMonth() === now.getMonth() &&
d.getFullYear() === now.getFullYear()
if (isToday) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
const isYesterday =
d.getDate() === yesterday.getDate() &&
d.getMonth() === yesterday.getMonth() &&
d.getFullYear() === yesterday.getFullYear()
if (isYesterday) {
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
}
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
/* ------------------------------------------------------------------ */
/* Channel Derivation */
/* ------------------------------------------------------------------ */
interface ChannelDescriptor {
key: string
kind: "message"
channel: MessageChannel
label: string
targetId: string | null
@@ -136,6 +65,13 @@ interface ChannelDescriptor {
agentId: string | null
}
interface TaskDiscussionDescriptor {
key: string
kind: "task"
taskId: string
label: string
}
function deriveChannels(
messages: AgentMessageRecord[],
agents: AgentRecord[],
@@ -143,6 +79,37 @@ function deriveChannels(
const channelMap = new Map<string, ChannelDescriptor>()
const agentMap = new Map(agents.map((a) => [a.id, a]))
// Always create company-wide channel
channelMap.set("company-all", {
key: "company-all",
kind: "message",
channel: "company",
label: "#company-wide",
targetId: null,
agentId: null,
})
// Create permanent department channels for all departments that have agents
const activeDepts = new Set<string>()
for (const agent of agents) {
if (agent.department && agent.status !== "terminated") {
activeDepts.add(agent.department)
}
}
for (const dept of activeDepts) {
const key = `department-${dept}`
const deptLabel = dept.replace(/_/g, "-")
channelMap.set(key, {
key,
kind: "message",
channel: "department",
label: `#${deptLabel}`,
targetId: dept,
agentId: null,
})
}
// Add channels derived from existing messages (DMs, projects, incidents)
for (const msg of messages) {
let key: string
let label: string
@@ -166,7 +133,7 @@ function deriveChannels(
case "department": {
const dept = msg.channelTargetId ?? "general"
key = `department-${dept}`
label = `#${dept.replace("_", "-")}`
label = `#${dept.replace(/_/g, "-")}`
break
}
case "company": {
@@ -191,6 +158,7 @@ function deriveChannels(
if (!channelMap.has(key)) {
channelMap.set(key, {
key,
kind: "message",
channel: msg.channel,
label,
targetId: msg.channelTargetId,
@@ -199,17 +167,6 @@ function deriveChannels(
}
}
// Ensure at least one company-wide channel exists
if (!channelMap.has("company-all")) {
channelMap.set("company-all", {
key: "company-all",
channel: "company",
label: "#company-wide",
targetId: null,
agentId: null,
})
}
const order: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
return Array.from(channelMap.values()).sort((a, b) => {
const ai = order.indexOf(a.channel)
@@ -219,6 +176,25 @@ function deriveChannels(
})
}
function deriveTaskDiscussionChannels(
comments: CommentRecord[],
tasks: TaskRecord[],
): TaskDiscussionDescriptor[] {
const taskMap = new Map(tasks.map((task) => [task.id, task]))
const discussedTaskIds = new Set(comments.map((comment) => comment.taskId))
return Array.from(discussedTaskIds)
.map((taskId) => {
const task = taskMap.get(taskId)
return {
key: `task-${taskId}`,
kind: "task" as const,
taskId,
label: task?.title ?? `Task ${taskId.slice(0, 8)}`,
}
})
.sort((a, b) => a.label.localeCompare(b.label))
}
function getChannelMessages(
messages: AgentMessageRecord[],
channel: ChannelDescriptor,
@@ -244,10 +220,6 @@ function getChannelMessages(
})
}
/* ------------------------------------------------------------------ */
/* Agent Avatar */
/* ------------------------------------------------------------------ */
function AgentAvatar({
agent,
size = "md",
@@ -269,10 +241,6 @@ function AgentAvatar({
)
}
/* ------------------------------------------------------------------ */
/* Channel Sidebar Item */
/* ------------------------------------------------------------------ */
function ChannelItem({
descriptor,
active,
@@ -346,36 +314,23 @@ function ChannelItem({
)
}
/* ------------------------------------------------------------------ */
/* Message Bubble */
/* ------------------------------------------------------------------ */
function MessageBubble({
message,
agent,
isThread,
replyCount,
onReply,
onMarkRead,
}: {
message: AgentMessageRecord
agent: AgentRecord | undefined
isThread: boolean
replyCount: number
onReply: () => void
onMarkRead: () => void
}) {
const isUnread = !message.readAt
const isUrgent = message.priority === "urgent"
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
useEffect(() => {
if (isUnread) {
const timer = setTimeout(onMarkRead, 1500)
return () => clearTimeout(timer)
}
}, [isUnread, onMarkRead])
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
@@ -466,14 +421,12 @@ function MessageBubble({
)}
</div>
{/* Subject */}
{message.subject ? (
<div className="mt-0.5 text-[13px] font-medium text-[color:var(--text)]">
{message.subject}
</div>
) : null}
{/* Body */}
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
{message.body}
</div>
@@ -510,9 +463,102 @@ function MessageBubble({
)
}
/* ------------------------------------------------------------------ */
/* Attachment List */
/* ------------------------------------------------------------------ */
function TaskCommentBubble({
comment,
agent,
}: {
comment: CommentRecord
agent: AgentRecord | undefined
}) {
const isBoard = !comment.authorAgentId
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted)"
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18 }}
className="group relative flex gap-3 rounded-[10px] border-l-[3px] bg-[color:var(--panel)] px-4 py-3"
style={{ borderLeftColor: isBoard ? TERRACOTTA : deptColor }}
>
<AgentAvatar agent={agent} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold" style={{ color: isBoard ? TERRACOTTA : deptColor }}>
{comment.authorName}
</span>
{agent?.title ? <span className="text-[10px] text-[color:var(--muted)]">{agent.title}</span> : null}
<span className="text-[10px] text-[color:var(--muted)]">{formatMessageTime(comment.createdAt)}</span>
</div>
<div className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-[color:var(--muted-strong)]">
{comment.body}
</div>
</div>
</motion.div>
)
}
function TaskDiscussionComposer({
companyId,
taskId,
onSent,
}: {
companyId: string
taskId: string
onSent: () => void
}) {
const [body, setBody] = useState("")
const [sending, setSending] = useState(false)
const handleSend = useCallback(async () => {
if (!body.trim()) return
setSending(true)
try {
await window.agentCompany.addComment({
companyId,
taskId,
authorName: "Board",
body: body.trim(),
})
setBody("")
onSent()
} finally {
setSending(false)
}
}, [body, companyId, taskId, onSent])
return (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
<div className="flex items-end gap-2">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Reply in task discussion..."
rows={2}
className="min-h-[40px] min-w-0 flex-1 resize-none rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel-soft)] px-3 py-2 text-[12px] text-[color:var(--text)] placeholder:text-[color:var(--muted)] focus:border-transparent focus:outline-none focus:ring-2"
style={{ focusRingColor: TERRACOTTA } as React.CSSProperties}
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={!body.trim() || sending}
className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-[8px] text-white transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none"
style={{ backgroundColor: TERRACOTTA }}
aria-label="Send task comment"
>
{sending ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : (
<PaperPlaneTilt size={18} weight="fill" />
)}
</button>
</div>
</div>
)
}
function AttachmentList({ json }: { json: string }) {
let items: Array<{ type: string; id: string; title: string }> = []
@@ -538,10 +584,6 @@ function AttachmentList({ json }: { json: string }) {
)
}
/* ------------------------------------------------------------------ */
/* Message Composer */
/* ------------------------------------------------------------------ */
interface MessageComposerProps {
agents: AgentRecord[]
companyId: string
@@ -595,7 +637,7 @@ function MessageComposer({
setSubject("")
onSent()
} catch (err) {
console.error("[MessageComposer] send failed:", err)
void err
} finally {
setSending(false)
}
@@ -615,7 +657,6 @@ function MessageComposer({
return (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-3">
{/* Reply indicator */}
<AnimatePresence>
{replyTo ? (
<motion.div
@@ -640,7 +681,6 @@ function MessageComposer({
) : null}
</AnimatePresence>
{/* Sender selector row */}
<div className="mb-2 flex items-center gap-2">
<AgentAvatar agent={fromAgent} size="sm" />
<select
@@ -665,7 +705,6 @@ function MessageComposer({
) : null}
</div>
{/* Body input */}
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
@@ -702,10 +741,6 @@ function MessageComposer({
)
}
/* ------------------------------------------------------------------ */
/* Compose New Message Modal */
/* ------------------------------------------------------------------ */
interface ComposeModalProps {
open: boolean
onClose: () => void
@@ -748,14 +783,13 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
parentMessageId: null,
attachmentsJson: "[]",
})
// Reset form
setSubject("")
setBody("")
setPriority("normal")
onSent()
onClose()
} catch (err) {
console.error("[ComposeModal] send failed:", err)
void err
} finally {
setSending(false)
}
@@ -764,7 +798,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
return (
<Modal open={open} title="Compose Message" onClose={onClose} width="560px">
<div className="space-y-4">
{/* From */}
<Select
label="From Agent"
value={fromAgentId}
@@ -775,7 +808,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
}))}
/>
{/* Channel type */}
<Select
label="Channel"
value={channel}
@@ -789,7 +821,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
]}
/>
{/* Channel target */}
{channel === "direct" ? (
<Select
label="To Agent"
@@ -827,7 +858,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
/>
) : null}
{/* Subject */}
<Input
label="Subject"
value={subject}
@@ -835,7 +865,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
placeholder="Message subject"
/>
{/* Body */}
<TextArea
label="Message"
value={body}
@@ -844,7 +873,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
rows={5}
/>
{/* Priority */}
<Select
label="Priority"
value={priority}
@@ -856,7 +884,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
]}
/>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
@@ -888,10 +915,6 @@ function ComposeModal({ open, onClose, agents, companyId, onSent }: ComposeModal
)
}
/* ------------------------------------------------------------------ */
/* Search & Filter Bar */
/* ------------------------------------------------------------------ */
interface FilterState {
search: string
channelType: MessageChannel | "all"
@@ -1030,27 +1053,29 @@ function FilterBar({
)
}
/* ------------------------------------------------------------------ */
/* Main: CommunicationHub */
/* ------------------------------------------------------------------ */
interface CommunicationHubProps {
messages: AgentMessageRecord[]
comments: CommentRecord[]
tasks: TaskRecord[]
agents: AgentRecord[]
companyId: string
selectedEntityId?: string | null
onRefresh: () => void
}
export function CommunicationHub({
messages,
comments,
tasks,
agents,
companyId,
selectedEntityId,
onRefresh,
}: CommunicationHubProps) {
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [showCompose, setShowCompose] = useState(false)
const [showSidebar, setShowSidebar] = useState(true)
const [showCompose, setShowCompose] = useState(false)
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
const [filters, setFilters] = useState<FilterState>({
search: "",
channelType: "all",
@@ -1062,7 +1087,9 @@ export function CommunicationHub({
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents])
// Derive channels from messages
const channels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
const messageChannels = useMemo(() => deriveChannels(messages, agents), [messages, agents])
const taskDiscussionChannels = useMemo(() => deriveTaskDiscussionChannels(comments, tasks), [comments, tasks])
const channels = useMemo(() => [...messageChannels, ...taskDiscussionChannels], [messageChannels, taskDiscussionChannels])
// Auto-select first channel
useEffect(() => {
@@ -1071,6 +1098,27 @@ export function CommunicationHub({
}
}, [channels, selectedChannelKey])
useEffect(() => {
if (!selectedEntityId) return
const matchingMessage = messages.find((message) => message.id === selectedEntityId)
if (matchingMessage) {
const matchingChannel = messageChannels.find((channel) => getChannelMessages(messages, channel).some((message) => message.id === selectedEntityId))
if (matchingChannel) {
setSelectedChannelKey(matchingChannel.key)
}
return
}
const matchingComment = comments.find((comment) => comment.id === selectedEntityId)
if (matchingComment) {
setSelectedChannelKey(`task-${matchingComment.taskId}`)
return
}
const matchingTaskDiscussion = taskDiscussionChannels.find((discussion) => discussion.taskId === selectedEntityId)
if (matchingTaskDiscussion) {
setSelectedChannelKey(matchingTaskDiscussion.key)
}
}, [selectedEntityId, messages, comments, messageChannels, taskDiscussionChannels])
const activeChannel = useMemo(
() => channels.find((c) => c.key === selectedChannelKey) ?? null,
[channels, selectedChannelKey],
@@ -1078,16 +1126,32 @@ export function CommunicationHub({
// Messages for the selected channel
const channelMessages = useMemo(() => {
if (!activeChannel) return []
if (!activeChannel || activeChannel.kind !== "message") return []
return getChannelMessages(messages, activeChannel).sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)
}, [messages, activeChannel])
const taskDiscussionComments = useMemo(() => {
if (!activeChannel || activeChannel.kind !== "task") return []
return comments
.filter((comment) => comment.taskId === activeChannel.taskId)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
}, [comments, activeChannel])
const activeTask = useMemo(() => {
if (!activeChannel || activeChannel.kind !== "task") return null
return tasks.find((task) => task.id === activeChannel.taskId) ?? null
}, [tasks, activeChannel])
// Apply filters to visible messages
const filteredMessages = useMemo(() => {
let result = channelMessages
if (filters.channelType !== "all") {
result = result.filter((m) => m.channel === filters.channelType)
}
if (filters.search) {
const q = filters.search.toLowerCase()
result = result.filter(
@@ -1127,6 +1191,10 @@ export function CommunicationHub({
const unreadByChannel = useMemo(() => {
const counts = new Map<string, { unread: number; hasUrgent: boolean }>()
for (const ch of channels) {
if (ch.kind !== "message") {
counts.set(ch.key, { unread: 0, hasUrgent: false })
continue
}
const msgs = getChannelMessages(messages, ch)
const unread = msgs.filter((m) => !m.readAt).length
const hasUrgent = msgs.some((m) => !m.readAt && m.priority === "urgent")
@@ -1139,22 +1207,6 @@ export function CommunicationHub({
return messages.filter((m) => !m.readAt).length
}, [messages])
// Mark message read
const handleMarkRead = useCallback(
(messageId: string) => {
void window.agentCompany.markMessageRead({ messageId, companyId })
},
[companyId],
)
// After sending, refresh and scroll
const handleSent = useCallback(() => {
onRefresh()
setTimeout(() => {
messageEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, 200)
}, [onRefresh])
// Scroll to bottom when channel changes
useEffect(() => {
messageEndRef.current?.scrollIntoView({ behavior: "auto" })
@@ -1165,7 +1217,7 @@ export function CommunicationHub({
const groups: Array<{ type: MessageChannel; label: string; items: ChannelDescriptor[] }> = []
const typeOrder: MessageChannel[] = ["company", "department", "project", "incident", "direct"]
for (const type of typeOrder) {
const items = channels.filter((c) => c.channel === type)
const items = channels.filter((c): c is ChannelDescriptor => c.kind === "message" && c.channel === type)
if (items.length > 0) {
groups.push({
type,
@@ -1200,12 +1252,15 @@ export function CommunicationHub({
</p>
</div>
</div>
<ActionButton
label="Compose"
<button
type="button"
onClick={() => setShowCompose(true)}
icon={Plus}
tone="accent"
/>
className="inline-flex items-center gap-2 rounded-[8px] px-3 py-2 text-[11px] font-semibold text-white transition hover:brightness-90"
style={{ backgroundColor: TERRACOTTA }}
>
<Plus size={13} weight="bold" />
New message
</button>
</div>
{/* Main layout: sidebar + messages */}
@@ -1213,7 +1268,6 @@ export function CommunicationHub({
className="flex overflow-hidden rounded-[12px] border border-[color:var(--line)]"
style={{ height: "calc(100vh - 220px)", minHeight: "480px" }}
>
{/* ── Sidebar ────────────────────────────────────────── */}
<AnimatePresence initial={false}>
{showSidebar ? (
<motion.div
@@ -1258,7 +1312,6 @@ export function CommunicationHub({
agents={agents}
onClick={() => {
setSelectedChannelKey(ch.key)
setReplyTo(null)
}}
/>
)
@@ -1266,9 +1319,34 @@ export function CommunicationHub({
</div>
))}
{taskDiscussionChannels.length > 0 ? (
<div className="mb-3">
<div className="mb-1 px-2 text-[9px] font-bold uppercase tracking-[0.1em] text-[color:var(--muted)]">
Task Discussions
</div>
{taskDiscussionChannels.map((discussion) => (
<button
key={discussion.key}
type="button"
onClick={() => setSelectedChannelKey(discussion.key)}
className={`group flex w-full items-center gap-2.5 rounded-[8px] px-3 py-2 text-left transition duration-150 ${
selectedChannelKey === discussion.key ? "bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]/50"
}`}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px]" style={{ backgroundColor: `${TERRACOTTA}18` }}>
<Hash size={14} weight={selectedChannelKey === discussion.key ? "fill" : "regular"} style={{ color: TERRACOTTA }} />
</div>
<span className={`min-w-0 flex-1 truncate text-[12px] ${selectedChannelKey === discussion.key ? "font-semibold text-[color:var(--text)]" : "font-medium text-[color:var(--muted-strong)]"}`}>
{discussion.label}
</span>
</button>
))}
</div>
) : null}
{channels.length === 0 ? (
<div className="px-3 py-6 text-center text-[11px] text-[color:var(--muted)]">
No channels yet. Send a message to create one.
No active departments yet.
</div>
) : null}
</div>
@@ -1284,7 +1362,6 @@ export function CommunicationHub({
) : null}
</AnimatePresence>
{/* ── Main content ─────────────────────────────────── */}
<div className="flex min-w-0 flex-1 flex-col bg-[color:var(--bg)]">
{/* Channel header */}
<div className="flex items-center gap-3 border-b border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
@@ -1301,7 +1378,7 @@ export function CommunicationHub({
{activeChannel ? (
<>
{(() => {
{activeChannel.kind === "message" ? (() => {
const Icon = channelConfig[activeChannel.channel].icon
return (
<Icon
@@ -1312,19 +1389,20 @@ export function CommunicationHub({
}}
/>
)
})()}
})() : (
<Hash size={16} weight="fill" style={{ color: TERRACOTTA }} />
)}
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-[color:var(--text)]">
{activeChannel.label}
</div>
<div className="text-[10px] text-[color:var(--muted)]">
{channelMessages.length} message{channelMessages.length !== 1 ? "s" : ""}
{(() => {
const stats = unreadByChannel.get(activeChannel.key)
return stats?.unread
? ` (${stats.unread} unread)`
: ""
})()}
{activeChannel.kind === "message"
? `${channelMessages.length} message${channelMessages.length !== 1 ? "s" : ""}${(() => {
const stats = unreadByChannel.get(activeChannel.key)
return stats?.unread ? ` (${stats.unread} unread)` : ""
})()}`
: `${taskDiscussionComments.length} comment${taskDiscussionComments.length !== 1 ? "s" : ""}${activeTask ? ` · ${activeTask.status.replaceAll("_", " ")}` : ""}`}
</div>
</div>
</>
@@ -1352,9 +1430,32 @@ export function CommunicationHub({
Select a channel to view messages
</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
Or compose a new message to get started
Monitor internal agent communications across departments
</div>
</div>
) : activeChannel.kind === "task" ? (
taskDiscussionComments.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
<div className="text-[14px] font-medium text-[color:var(--muted-strong)]">
No discussion yet
</div>
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
Comments for this task will appear here as agents and the board coordinate.
</div>
</div>
) : (
<div className="space-y-1">
{taskDiscussionComments.map((comment) => (
<TaskCommentBubble
key={comment.id}
comment={comment}
agent={comment.authorAgentId ? (agentMap.get(comment.authorAgentId) ?? undefined) : undefined}
/>
))}
<div ref={messageEndRef} />
</div>
)
) : filteredMessages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<Tray size={36} className="mb-3 text-[color:var(--muted)]" />
@@ -1366,7 +1467,7 @@ export function CommunicationHub({
<div className="mt-1 text-[12px] text-[color:var(--muted)]">
{filters.search || filters.unreadOnly || filters.priority !== "all"
? "Try adjusting your search or filters"
: "Be the first to send a message"}
: "Messages will appear here as agents work and communicate"}
</div>
</div>
) : (
@@ -1381,7 +1482,6 @@ export function CommunicationHub({
isThread={false}
replyCount={replies.length}
onReply={() => setReplyTo(msg)}
onMarkRead={() => handleMarkRead(msg.id)}
/>
{/* Thread replies */}
{replies.length > 0 ? (
@@ -1394,7 +1494,6 @@ export function CommunicationHub({
isThread={true}
replyCount={0}
onReply={() => setReplyTo(reply)}
onMarkRead={() => handleMarkRead(reply.id)}
/>
))}
</div>
@@ -1407,31 +1506,44 @@ export function CommunicationHub({
)}
</div>
{/* Composer */}
{activeChannel ? (
<MessageComposer
agents={agents}
companyId={companyId}
channel={activeChannel.channel}
channelTargetId={activeChannel.targetId}
replyTo={replyTo}
onSent={handleSent}
onCancelReply={() => setReplyTo(null)}
/>
) : null}
activeChannel.kind === "message" ? (
<MessageComposer
agents={agents}
companyId={companyId}
channel={activeChannel.channel}
channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId}
replyTo={replyTo}
onSent={() => {
setReplyTo(null)
onRefresh()
}}
onCancelReply={() => setReplyTo(null)}
/>
) : (
<TaskDiscussionComposer
companyId={companyId}
taskId={activeChannel.taskId}
onSent={onRefresh}
/>
)
) : (
<div className="border-t border-[color:var(--line)] bg-[color:var(--panel)] px-4 py-2.5">
<div className="flex items-center gap-2 text-[11px] text-[color:var(--muted)]">
<ChatCircle size={14} />
<span>Select a channel to read and send messages.</span>
</div>
</div>
)}
</div>
</div>
{/* Compose modal */}
<ComposeModal
open={showCompose}
onClose={() => setShowCompose(false)}
agents={agents}
companyId={companyId}
onSent={() => {
onRefresh()
setShowCompose(false)
}}
onSent={onRefresh}
/>
</div>
)
@@ -0,0 +1,716 @@
import { useMemo, useState } from "react";
import {
CalendarBlank,
FileText,
FloppyDisk,
Lightbulb,
NotePencil,
Plus,
Rows,
Trash,
} from "@phosphor-icons/react";
import type {
AgentRecord,
DocumentRecord,
GoalRecord,
KnowledgeEntryRecord,
MeetingRecord,
ProjectRecord,
SprintRecord,
} from "@shared/types";
import { ActionButton, ConfirmDialog, InlineNotice, Input, ListFrame, ListRow, Modal, Select, StatusPill, TextArea } from "./ui";
const documentTypeOptions = [
"prd",
"technical_spec",
"design_doc",
"test_plan",
"post_mortem",
"meeting_notes",
"budget_proposal",
"hiring_requisition",
"performance_review_doc",
"sop",
"knowledge_article",
"project_brief",
"sprint_report",
"incident_report",
"onboarding_guide",
"architecture_decision",
"status_report",
"contract",
"proposal",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
const documentStatusOptions = ["draft", "in_review", "approved", "archived", "superseded"].map((value) => ({
value,
label: value.replaceAll("_", " "),
}));
const knowledgeCategoryOptions = [
"lesson_learned",
"best_practice",
"decision",
"process",
"technical",
"business",
"onboarding",
"incident",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
const importanceOptions = ["critical", "high", "medium", "low"].map((value) => ({ value, label: value }));
const meetingTypeOptions = [
"standup",
"sprint_planning",
"sprint_review",
"retrospective",
"one_on_one",
"all_hands",
"department_sync",
"incident_review",
"hiring_committee",
"budget_review",
"design_review",
"architecture_review",
].map((value) => ({ value, label: value.replaceAll("_", " ") }));
const meetingStatusOptions = ["scheduled", "in_progress", "completed", "cancelled"].map((value) => ({ value, label: value.replaceAll("_", " ") }));
const sprintStatusOptions = ["planning", "active", "review", "completed", "cancelled"].map((value) => ({ value, label: value }));
function splitTags(value: string) {
return JSON.stringify(value.split(",").map((item) => item.trim()).filter(Boolean));
}
function parseTags(value: string) {
try {
const tags = JSON.parse(value) as string[];
return tags.join(", ");
} catch {
return "";
}
}
function parseParticipantIds(value: string) {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function formatParticipantIds(raw: string, agents: AgentRecord[]) {
try {
const ids = JSON.parse(raw) as string[];
return ids
.map((id) => agents.find((agent) => agent.id === id)?.name ?? id.slice(0, 8))
.join(", ");
} catch {
return "";
}
}
function DocumentEditor({
companyId,
agents,
projects,
goals,
document,
onSaved,
}: {
companyId: string;
agents: AgentRecord[];
projects: ProjectRecord[];
goals: GoalRecord[];
document: DocumentRecord | null;
onSaved: () => Promise<void>;
}) {
const [title, setTitle] = useState(document?.title ?? "");
const [type, setType] = useState(document?.type ?? "technical_spec");
const [content, setContent] = useState(document?.content ?? "");
const [status, setStatus] = useState(document?.status ?? "draft");
const [reviewerAgentId, setReviewerAgentId] = useState(document?.reviewerAgentId ?? "");
const [projectId, setProjectId] = useState(document?.projectId ?? "");
const [goalId, setGoalId] = useState(document?.goalId ?? "");
const [tags, setTags] = useState(parseTags(document?.tagsJson ?? "[]"));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!title.trim()) {
setError("Document title is required.");
return;
}
setSaving(true);
setError(null);
try {
const result = await window.agentCompany.saveDocument({
id: document?.id ?? null,
companyId,
type: type as Parameters<typeof window.agentCompany.saveDocument>[0]["type"],
title: title.trim(),
content,
authorAgentId: document?.authorAgentId ?? null,
reviewerAgentId: reviewerAgentId || null,
projectId: projectId || null,
goalId: goalId || null,
parentDocId: document?.parentDocId ?? null,
version: document?.version ?? 1,
status: status as Parameters<typeof window.agentCompany.saveDocument>[0]["status"],
tagsJson: splitTags(tags),
});
if (!result.ok) {
setError(result.error.message);
return;
}
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Title" value={title} onChange={setTitle} placeholder="Architecture review summary" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Type" value={type} onChange={(value) => setType(value as typeof type)} options={documentTypeOptions} />
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={documentStatusOptions} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Select
label="Reviewer"
value={reviewerAgentId}
onChange={setReviewerAgentId}
options={[{ value: "", label: "None" }, ...agents.map((agent) => ({ value: agent.id, label: `${agent.name} (${agent.role})` }))]}
/>
<Input label="Tags" value={tags} onChange={setTags} placeholder="architecture, security, release" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Select label="Project" value={projectId} onChange={setProjectId} options={[{ value: "", label: "None" }, ...projects.map((project) => ({ value: project.id, label: project.name }))]} />
<Select label="Goal" value={goalId} onChange={setGoalId} options={[{ value: "", label: "None" }, ...goals.map((goal) => ({ value: goal.id, label: goal.title }))]} />
</div>
<TextArea label="Content" value={content} onChange={setContent} rows={12} placeholder="Write the document content here." />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : document ? "Save document" : "Create document"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function KnowledgeEditor({
companyId,
entry,
onSaved,
}: {
companyId: string;
entry: KnowledgeEntryRecord | null;
onSaved: () => Promise<void>;
}) {
const [topic, setTopic] = useState(entry?.topic ?? "");
const [category, setCategory] = useState(entry?.category ?? "technical");
const [importance, setImportance] = useState(entry?.importance ?? "medium");
const [content, setContent] = useState(entry?.content ?? "");
const [tags, setTags] = useState(parseTags(entry?.tagsJson ?? "[]"));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!topic.trim() || !content.trim()) {
setError("Topic and content are required.");
return;
}
setSaving(true);
setError(null);
try {
const result = await window.agentCompany.saveKnowledgeEntry({
id: entry?.id ?? null,
companyId,
category: category as Parameters<typeof window.agentCompany.saveKnowledgeEntry>[0]["category"],
topic: topic.trim(),
content: content.trim(),
authorAgentId: entry?.authorAgentId ?? null,
importance: importance as Parameters<typeof window.agentCompany.saveKnowledgeEntry>[0]["importance"],
tagsJson: splitTags(tags),
referencedEntityType: entry?.referencedEntityType ?? null,
referencedEntityId: entry?.referencedEntityId ?? null,
});
if (!result.ok) {
setError(result.error.message);
return;
}
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Topic" value={topic} onChange={setTopic} placeholder="What did the team learn?" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Category" value={category} onChange={(value) => setCategory(value as typeof category)} options={knowledgeCategoryOptions} />
<Select label="Importance" value={importance} onChange={(value) => setImportance(value as typeof importance)} options={importanceOptions} />
</div>
<Input label="Tags" value={tags} onChange={setTags} placeholder="incident, auth, onboarding" />
<TextArea label="Content" value={content} onChange={setContent} rows={10} placeholder="Capture the lesson, decision, or process in full." />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : entry ? "Save entry" : "Create entry"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function MeetingEditor({
companyId,
agents,
meeting,
onSaved,
}: {
companyId: string;
agents: AgentRecord[];
meeting: MeetingRecord | null;
onSaved: () => Promise<void>;
}) {
const [title, setTitle] = useState(meeting?.title ?? "");
const [type, setType] = useState(meeting?.type ?? "department_sync");
const [status, setStatus] = useState(meeting?.status ?? "scheduled");
const [scheduledAt, setScheduledAt] = useState(meeting?.scheduledAt?.slice(0, 16) ?? new Date().toISOString().slice(0, 16));
const [durationMinutes, setDurationMinutes] = useState(String(meeting?.durationMinutes ?? 30));
const [agenda, setAgenda] = useState(parseTags(meeting?.agendaJson ?? "[]"));
const [notes, setNotes] = useState(parseTags(meeting?.notesJson ?? "[]"));
const [decisions, setDecisions] = useState(parseTags(meeting?.decisionsJson ?? "[]"));
const [participants, setParticipants] = useState(formatParticipantIds(meeting?.participantAgentIds ?? "[]", agents));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!title.trim()) {
setError("Meeting title is required.");
return;
}
setSaving(true);
setError(null);
try {
const participantIds = parseParticipantIds(participants)
.map((value) => agents.find((agent) => agent.name === value || agent.id === value)?.id ?? value)
.filter(Boolean);
const result = await window.agentCompany.saveMeeting({
id: meeting?.id ?? null,
companyId,
type: type as Parameters<typeof window.agentCompany.saveMeeting>[0]["type"],
title: title.trim(),
organizerAgentId: meeting?.organizerAgentId ?? null,
participantAgentIds: JSON.stringify(participantIds),
scheduledAt: new Date(scheduledAt).toISOString(),
durationMinutes: Number.parseInt(durationMinutes, 10) || 30,
agendaJson: splitTags(agenda),
notesJson: splitTags(notes),
decisionsJson: splitTags(decisions),
actionItemsJson: meeting?.actionItemsJson ?? "[]",
status: status as Parameters<typeof window.agentCompany.saveMeeting>[0]["status"],
});
if (!result.ok) {
setError(result.error.message);
return;
}
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Title" value={title} onChange={setTitle} placeholder="Sprint review" />
<div className="grid gap-4 md:grid-cols-2">
<Select label="Type" value={type} onChange={(value) => setType(value as typeof type)} options={meetingTypeOptions} />
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={meetingStatusOptions} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Input label="Scheduled At" value={scheduledAt} onChange={setScheduledAt} type="datetime-local" />
<Input label="Duration (minutes)" value={durationMinutes} onChange={setDurationMinutes} type="number" />
</div>
<Input label="Participants" value={participants} onChange={setParticipants} placeholder="Comma-separated agent names or ids" />
<Input label="Agenda" value={agenda} onChange={setAgenda} placeholder="Planning, blockers, decisions" />
<Input label="Notes" value={notes} onChange={setNotes} placeholder="Key notes" />
<Input label="Decisions" value={decisions} onChange={setDecisions} placeholder="Decision 1, Decision 2" />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : meeting ? "Save meeting" : "Create meeting"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function SprintEditor({
companyId,
sprint,
onSaved,
}: {
companyId: string;
sprint: SprintRecord | null;
onSaved: () => Promise<void>;
}) {
const [name, setName] = useState(sprint?.name ?? "");
const [goal, setGoal] = useState(sprint?.goal ?? "");
const [startDate, setStartDate] = useState(sprint?.startDate ?? new Date().toISOString().slice(0, 10));
const [endDate, setEndDate] = useState(sprint?.endDate ?? new Date(Date.now() + 14 * 86_400_000).toISOString().slice(0, 10));
const [status, setStatus] = useState(sprint?.status ?? "planning");
const [velocityPoints, setVelocityPoints] = useState(String(sprint?.velocityPoints ?? 20));
const [completedPoints, setCompletedPoints] = useState(String(sprint?.completedPoints ?? 0));
const [retrospectiveNotes, setRetrospectiveNotes] = useState(sprint?.retrospectiveNotes ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
if (!name.trim()) {
setError("Sprint name is required.");
return;
}
setSaving(true);
setError(null);
try {
const result = await window.agentCompany.saveSprint({
id: sprint?.id ?? null,
companyId,
name: name.trim(),
goal: goal.trim(),
startDate,
endDate,
status: status as Parameters<typeof window.agentCompany.saveSprint>[0]["status"],
retrospectiveNotes,
velocityPoints: Number.parseInt(velocityPoints, 10) || 0,
completedPoints: Number.parseInt(completedPoints, 10) || 0,
});
if (!result.ok) {
setError(result.error.message);
return;
}
await onSaved();
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{error ? <InlineNotice message={error} /> : null}
<Input label="Sprint name" value={name} onChange={setName} placeholder="Sprint 12" />
<TextArea label="Goal" value={goal} onChange={setGoal} rows={3} placeholder="What this sprint is meant to achieve." />
<div className="grid gap-4 md:grid-cols-2">
<Input label="Start date" value={startDate} onChange={setStartDate} type="date" />
<Input label="End date" value={endDate} onChange={setEndDate} type="date" />
</div>
<div className="grid gap-4 md:grid-cols-3">
<Select label="Status" value={status} onChange={(value) => setStatus(value as typeof status)} options={sprintStatusOptions} />
<Input label="Velocity points" value={velocityPoints} onChange={setVelocityPoints} type="number" />
<Input label="Completed points" value={completedPoints} onChange={setCompletedPoints} type="number" />
</div>
<TextArea label="Retrospective notes" value={retrospectiveNotes} onChange={setRetrospectiveNotes} rows={5} placeholder="What should the team retain, change, or stop?" />
<div className="flex justify-end">
<ActionButton label={saving ? "Saving..." : sprint ? "Save sprint" : "Create sprint"} icon={FloppyDisk} tone="accent" onClick={() => void save()} loading={saving} />
</div>
</div>
);
}
function SectionHeader({
title,
count,
action,
}: {
title: string;
count: number;
action: () => void;
}) {
return (
<div className="mb-4 flex items-center justify-between">
<div>
<div className="text-[16px] font-semibold text-[color:var(--text)]">{title}</div>
<div className="text-[12px] text-[color:var(--muted)]">{count} item{count === 1 ? "" : "s"}</div>
</div>
<ActionButton label="New" icon={Plus} tone="accent" onClick={action} />
</div>
);
}
export function DocumentCenter({
companyId,
documents,
agents,
projects,
goals,
onRefresh,
}: {
companyId: string;
documents: DocumentRecord[];
agents: AgentRecord[];
projects: ProjectRecord[];
goals: GoalRecord[];
onRefresh: () => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeDocument = useMemo(() => documents.find((document) => document.id === editingId) ?? null, [documents, editingId]);
return (
<div>
<SectionHeader title="Document Center" count={documents.length} action={() => setEditingId("__new__")} />
<ListFrame>
{documents.map((document) => (
<ListRow key={document.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(document.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
<FileText size={15} className="text-[color:var(--accent)]" />
<span className="truncate">{document.title}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
{document.type.replaceAll("_", " ")} · v{document.version} · {document.updatedAt.slice(0, 10)}
</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={document.status} />
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(document.id)}>
<NotePencil size={14} />
</button>
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(document.id)}>
<Trash size={14} />
</button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeDocument ? activeDocument.title : "New document"} onClose={() => setEditingId(null)} width="760px">
<DocumentEditor
companyId={companyId}
agents={agents}
projects={projects}
goals={goals}
document={editingId === "__new__" ? null : activeDocument}
onSaved={async () => {
setEditingId(null);
await onRefresh();
}}
/>
</Modal>
<ConfirmDialog
open={deleteId !== null}
title="Delete document"
message="This removes the document from the company knowledge surface."
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (!deleteId) return;
void window.agentCompany.deleteDocument({ id: deleteId, companyId }).then(() => onRefresh());
setDeleteId(null);
}}
/>
</div>
);
}
export function KnowledgeBaseManager({
companyId,
entries,
onRefresh,
}: {
companyId: string;
entries: KnowledgeEntryRecord[];
onRefresh: () => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeEntry = useMemo(() => entries.find((entry) => entry.id === editingId) ?? null, [entries, editingId]);
return (
<div>
<SectionHeader title="Knowledge Base" count={entries.length} action={() => setEditingId("__new__")} />
<ListFrame>
{entries.map((entry) => (
<ListRow key={entry.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(entry.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
<Lightbulb size={15} className="text-[color:var(--accent)]" />
<span className="truncate">{entry.topic}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
{entry.category.replaceAll("_", " ")} · {entry.updatedAt.slice(0, 10)}
</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={entry.importance} />
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(entry.id)}>
<NotePencil size={14} />
</button>
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(entry.id)}>
<Trash size={14} />
</button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeEntry ? activeEntry.topic : "New knowledge entry"} onClose={() => setEditingId(null)} width="760px">
<KnowledgeEditor
companyId={companyId}
entry={editingId === "__new__" ? null : activeEntry}
onSaved={async () => {
setEditingId(null);
await onRefresh();
}}
/>
</Modal>
<ConfirmDialog
open={deleteId !== null}
title="Delete knowledge entry"
message="This removes the entry from the shared company knowledge base."
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (!deleteId) return;
void window.agentCompany.deleteKnowledgeEntry({ id: deleteId, companyId }).then(() => onRefresh());
setDeleteId(null);
}}
/>
</div>
);
}
export function MeetingsManager({
companyId,
meetings,
agents,
onRefresh,
}: {
companyId: string;
meetings: MeetingRecord[];
agents: AgentRecord[];
onRefresh: () => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeMeeting = useMemo(() => meetings.find((meeting) => meeting.id === editingId) ?? null, [meetings, editingId]);
return (
<div>
<SectionHeader title="Meetings" count={meetings.length} action={() => setEditingId("__new__")} />
<ListFrame>
{meetings.map((meeting) => (
<ListRow key={meeting.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(meeting.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
<CalendarBlank size={15} className="text-[color:var(--accent)]" />
<span className="truncate">{meeting.title}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
{meeting.type.replaceAll("_", " ")} · {new Date(meeting.scheduledAt).toLocaleString()} · {meeting.durationMinutes} min
</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={meeting.status} />
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(meeting.id)}>
<NotePencil size={14} />
</button>
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(meeting.id)}>
<Trash size={14} />
</button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeMeeting ? activeMeeting.title : "New meeting"} onClose={() => setEditingId(null)} width="760px">
<MeetingEditor
companyId={companyId}
agents={agents}
meeting={editingId === "__new__" ? null : activeMeeting}
onSaved={async () => {
setEditingId(null);
await onRefresh();
}}
/>
</Modal>
<ConfirmDialog
open={deleteId !== null}
title="Delete meeting"
message="This removes the meeting and its operational context from the company timeline."
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (!deleteId) return;
void window.agentCompany.deleteMeeting({ id: deleteId, companyId }).then(() => onRefresh());
setDeleteId(null);
}}
/>
</div>
);
}
export function SprintManager({
companyId,
sprints,
onRefresh,
}: {
companyId: string;
sprints: SprintRecord[];
onRefresh: () => Promise<void>;
}) {
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const activeSprint = useMemo(() => sprints.find((sprint) => sprint.id === editingId) ?? null, [sprints, editingId]);
return (
<div>
<SectionHeader title="Sprint Board" count={sprints.length} action={() => setEditingId("__new__")} />
<ListFrame>
{sprints.map((sprint) => (
<ListRow key={sprint.id}>
<div className="flex items-start justify-between gap-3">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setEditingId(sprint.id)}>
<div className="flex items-center gap-2 text-[13px] font-medium text-[color:var(--text)]">
<Rows size={15} className="text-[color:var(--accent)]" />
<span className="truncate">{sprint.name}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">
{sprint.startDate} → {sprint.endDate} · {sprint.completedPoints}/{sprint.velocityPoints} pts
</div>
</button>
<div className="flex items-center gap-2">
<StatusPill status={sprint.status} />
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--text)]" onClick={() => setEditingId(sprint.id)}>
<NotePencil size={14} />
</button>
<button type="button" className="rounded p-1 text-[color:var(--muted)] hover:text-[color:var(--danger)]" onClick={() => setDeleteId(sprint.id)}>
<Trash size={14} />
</button>
</div>
</div>
</ListRow>
))}
</ListFrame>
<Modal open={editingId !== null} title={activeSprint ? activeSprint.name : "New sprint"} onClose={() => setEditingId(null)} width="720px">
<SprintEditor
companyId={companyId}
sprint={editingId === "__new__" ? null : activeSprint}
onSaved={async () => {
setEditingId(null);
await onRefresh();
}}
/>
</Modal>
<ConfirmDialog
open={deleteId !== null}
title="Delete sprint"
message="This removes the sprint board entry. Historical run and task records remain."
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (!deleteId) return;
void window.agentCompany.deleteSprint({ id: deleteId, companyId }).then(() => onRefresh());
setDeleteId(null);
}}
/>
</div>
);
}
+117 -103
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import {
ArrowRight,
@@ -25,10 +25,11 @@ import {
import type {
ActivityRecord,
AgentRecord,
AgentMessageRecord,
ApprovalRecord,
BrowserActionRecord,
ConnectorRecord,
CostRecord,
DepartmentId,
GoalRecord,
MeetingRecord,
ProjectRecord,
@@ -36,14 +37,11 @@ import type {
SectionId,
SprintRecord,
TaskRecord,
WorkflowPipelineRecord,
} from "@shared/types";
import { formatMoney } from "../lib/formatters";
import { formatMoney, getAgentInitials, timeAgo } from "../lib/formatters";
import { StatusPill } from "./ui";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface DashboardProps {
agents: AgentRecord[];
tasks: TaskRecord[];
@@ -54,43 +52,45 @@ interface DashboardProps {
activity: ActivityRecord[];
costs: CostRecord[];
connectors: ConnectorRecord[];
messages?: AgentMessageRecord[];
browserActions?: BrowserActionRecord[];
workflows?: WorkflowPipelineRecord[];
meetings?: MeetingRecord[];
sprints?: SprintRecord[];
onNavigate: (section: SectionId, entityId?: string) => void;
}
/* ------------------------------------------------------------------ */
/* Health Score Computation */
/* ------------------------------------------------------------------ */
function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "runs" | "approvals" | "connectors">): number {
function computeHealthScore(props: Pick<DashboardProps, "agents" | "tasks" | "runs" | "approvals" | "connectors" | "messages" | "browserActions" | "workflows">): number {
let score = 100;
const { agents, tasks, runs, approvals, connectors } = props;
const { agents, tasks, runs, approvals, connectors, messages = [], browserActions = [], workflows = [] } = props;
// Penalize for failed runs (recent)
const failedRuns = runs.filter((r) => r.status === "failed" || r.status === "timed_out");
score -= Math.min(failedRuns.length * 5, 25);
// Penalize for blocked tasks
const blockedTasks = tasks.filter((t) => t.status === "blocked");
score -= Math.min(blockedTasks.length * 8, 20);
// Penalize for pending approvals (bottleneck)
const pendingApprovals = approvals.filter((a) => a.state === "pending");
score -= Math.min(pendingApprovals.length * 5, 15);
// Penalize for agents in error state
const errorAgents = agents.filter((a) => a.status === "error");
score -= Math.min(errorAgents.length * 10, 20);
// Penalize for no ready connectors
const readyConnectors = connectors.filter((c) => c.status === "ready");
if (readyConnectors.length === 0 && connectors.length > 0) score -= 15;
// Penalize for overbudget agents
const overBudget = agents.filter((a) => a.budgetMonthlyUsd > 0 && a.spentMonthlyUsd >= a.budgetMonthlyUsd);
score -= Math.min(overBudget.length * 5, 10);
const urgentMessages = messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident"));
score -= Math.min(urgentMessages.length * 4, 12);
const blockedBrowserActions = browserActions.filter((action) => action.status === "approval_required" || action.status === "failed");
score -= Math.min(blockedBrowserActions.length * 4, 12);
const stalledWorkflows = workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed");
score -= Math.min(stalledWorkflows.length * 5, 15);
return Math.max(0, Math.min(100, score));
}
@@ -102,10 +102,6 @@ function healthLabel(score: number): { label: string; tone: string } {
return { label: "Critical", tone: "text-[color:var(--danger)]" };
}
/* ------------------------------------------------------------------ */
/* Health Ring */
/* ------------------------------------------------------------------ */
function HealthRing({ score }: { score: number }) {
const radius = 38;
const circumference = 2 * Math.PI * radius;
@@ -149,10 +145,6 @@ function HealthRing({ score }: { score: number }) {
);
}
/* ------------------------------------------------------------------ */
/* Metric Card */
/* ------------------------------------------------------------------ */
function MetricCard({
label,
value,
@@ -201,10 +193,6 @@ function MetricCard({
);
}
/* ------------------------------------------------------------------ */
/* Agent Workforce Grid */
/* ------------------------------------------------------------------ */
const statusGlow: Record<string, string> = {
running: "ring-2 ring-[color:var(--success)] ring-opacity-50",
active: "ring-1 ring-[color:var(--success)] ring-opacity-30",
@@ -216,12 +204,7 @@ const statusGlow: Record<string, string> = {
};
function AgentCard({ agent, onClick }: { agent: AgentRecord; onClick: () => void }) {
const initials = agent.name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
const initials = getAgentInitials(agent.name);
const isActive = agent.status === "running" || agent.status === "active";
@@ -287,10 +270,6 @@ function WorkforceGrid({ agents, onSelectAgent }: { agents: AgentRecord[]; onSel
);
}
/* ------------------------------------------------------------------ */
/* Activity Stream */
/* ------------------------------------------------------------------ */
const actionIcons: Record<string, React.ElementType> = {
created: CheckCircle,
updated: Lightning,
@@ -302,20 +281,6 @@ const actionIcons: Record<string, React.ElementType> = {
rejected: XCircle,
};
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`;
}
function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityRecord[]; agents: AgentRecord[]; limit?: number }) {
const recentActivity = useMemo(() => {
return [...activity]
@@ -368,10 +333,6 @@ function ActivityFeed({ activity, agents, limit = 15 }: { activity: ActivityReco
);
}
/* ------------------------------------------------------------------ */
/* Workflow Pipeline (visual task flow) */
/* ------------------------------------------------------------------ */
function TaskPipeline({ tasks }: { tasks: TaskRecord[] }) {
const stages = useMemo(() => {
const counts = { backlog: 0, todo: 0, in_progress: 0, in_review: 0, done: 0, blocked: 0 };
@@ -405,10 +366,6 @@ 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" },
@@ -485,7 +442,6 @@ function DepartmentOverview({
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">
@@ -524,11 +480,13 @@ function DepartmentOverview({
);
}
/* ------------------------------------------------------------------ */
/* Sprint Progress */
/* ------------------------------------------------------------------ */
function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRecord[] }) {
const [nowMs, setNowMs] = useState<number | null>(null);
useEffect(() => {
setNowMs(Date.now());
}, []);
const progress = useMemo(() => {
if (sprint.velocityPoints === 0) return 0;
return Math.min(100, Math.round((sprint.completedPoints / sprint.velocityPoints) * 100));
@@ -536,9 +494,9 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
const daysLeft = useMemo(() => {
const end = new Date(sprint.endDate).getTime();
const now = Date.now();
const now = nowMs ?? end;
return Math.max(0, Math.ceil((end - now) / (1000 * 60 * 60 * 24)));
}, [sprint]);
}, [sprint, nowMs]);
const sprintTasks = useMemo(() => {
const done = tasks.filter((t) => t.status === "done").length;
@@ -561,7 +519,6 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
</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)]"
@@ -587,10 +544,6 @@ function SprintProgress({ sprint, tasks }: { sprint: SprintRecord; tasks: TaskRe
);
}
/* ------------------------------------------------------------------ */
/* 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]);
@@ -634,16 +587,12 @@ function GoalProgress({ goals, tasks, onNavigate }: { goals: GoalRecord[]; tasks
);
}
/* ------------------------------------------------------------------ */
/* Main Dashboard */
/* ------------------------------------------------------------------ */
export function Dashboard(props: DashboardProps) {
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, meetings = [], sprints = [], onNavigate } = props;
const { agents, tasks, runs, approvals, goals, projects, activity, costs, connectors, messages = [], browserActions = [], workflows = [], sprints = [], onNavigate } = props;
const healthScore = useMemo(
() => computeHealthScore({ agents, tasks, runs, approvals, connectors }),
[agents, tasks, runs, approvals, connectors],
() => computeHealthScore({ agents, tasks, runs, approvals, connectors, messages, browserActions, workflows }),
[agents, tasks, runs, approvals, connectors, messages, browserActions, workflows],
);
const liveRuns = useMemo(() => runs.filter((r) => r.status === "running" || r.status === "queued"), [runs]);
@@ -655,16 +604,14 @@ export function Dashboard(props: DashboardProps) {
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]);
const unreadMessages = useMemo(() => messages.filter((message) => !message.readAt), [messages]);
const urgentMessages = useMemo(() => messages.filter((message) => !message.readAt && (message.priority === "urgent" || message.channel === "incident")), [messages]);
const blockedBrowserActions = useMemo(() => browserActions.filter((action) => action.status === "approval_required" || action.status === "failed"), [browserActions]);
const stalledWorkflows = useMemo(() => workflows.filter((workflow) => workflow.status === "paused" || workflow.status === "failed"), [workflows]);
return (
<div className="space-y-8">
{/* Row 1: Health + Metrics */}
{/* Health + Metrics */}
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Health Ring */}
<div className="shrink-0 rounded-[12px] border border-[color:var(--line)] bg-[color:var(--panel)] p-5">
@@ -733,10 +680,34 @@ export function Dashboard(props: DashboardProps) {
onClick={() => onNavigate("projects")}
subtitle={`${projects.length} total`}
/>
<MetricCard
label="Unread Messages"
value={unreadMessages.length}
icon={Users}
tone={urgentMessages.length > 0 ? "warn" : unreadMessages.length > 0 ? "accent" : "default"}
onClick={() => onNavigate("communication")}
subtitle={urgentMessages.length > 0 ? `${urgentMessages.length} urgent` : unreadMessages.length > 0 ? "Needs triage" : "All read"}
/>
<MetricCard
label="Browser Ops"
value={blockedBrowserActions.length}
icon={Cpu}
tone={blockedBrowserActions.length > 0 ? "warn" : "default"}
onClick={() => onNavigate("social")}
subtitle={blockedBrowserActions.length > 0 ? "Blocked or failed" : "Nominal"}
/>
<MetricCard
label="Workflow Stalls"
value={stalledWorkflows.length}
icon={Lightning}
tone={stalledWorkflows.length > 0 ? "warn" : "default"}
onClick={() => onNavigate("automation")}
subtitle={stalledWorkflows.length > 0 ? "Paused or failed" : "Healthy"}
/>
</div>
</div>
{/* Row 2: Department Overview */}
{/* 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>
@@ -751,7 +722,7 @@ export function Dashboard(props: DashboardProps) {
<DepartmentOverview agents={agents} tasks={tasks} runs={runs} onNavigate={onNavigate} />
</section>
{/* Row 3: Sprint + Goals side by side */}
{/* Sprint + Goals */}
<div className="grid gap-6 lg:grid-cols-2">
{activeSprint ? (
<section>
@@ -784,7 +755,7 @@ export function Dashboard(props: DashboardProps) {
</section>
</div>
{/* Row 4: Task Pipeline */}
{/* 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>
@@ -801,7 +772,7 @@ export function Dashboard(props: DashboardProps) {
</div>
</section>
{/* Row 5: Workforce + Activity */}
{/* Workforce + Activity */}
<div className="grid gap-6 lg:grid-cols-5">
{/* Workforce */}
<section className="lg:col-span-3">
@@ -836,7 +807,7 @@ export function Dashboard(props: DashboardProps) {
</section>
</div>
{/* Row 4: Connector health (compact) */}
{/* Connector Health */}
<section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Infrastructure</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
@@ -867,8 +838,8 @@ export function Dashboard(props: DashboardProps) {
</div>
</section>
{/* Row 5: Quick Actions / Needs Attention */}
{(pendingApprovals.length > 0 || failedRuns.length > 0) ? (
{/* Needs Attention */}
{(pendingApprovals.length > 0 || failedRuns.length > 0 || urgentMessages.length > 0 || blockedBrowserActions.length > 0 || stalledWorkflows.length > 0) ? (
<section>
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--warn)]">Needs Attention</div>
<div className="space-y-1.5">
@@ -913,6 +884,56 @@ export function Dashboard(props: DashboardProps) {
</button>
);
})}
{urgentMessages.slice(0, 3).map((message) => {
const sender = agents.find((agent) => agent.id === message.fromAgentId);
return (
<button
key={message.id}
type="button"
onClick={() => onNavigate("communication")}
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
>
<Users size={18} className="shrink-0 text-[color:var(--warn)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{message.subject}</div>
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">
{sender?.name ?? "Unknown"} — {message.channel} {message.priority === "urgent" ? "· urgent" : ""}
</div>
</div>
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
</button>
);
})}
{blockedBrowserActions.slice(0, 3).map((action) => (
<button
key={action.id}
type="button"
onClick={() => onNavigate("social")}
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
>
<Cpu size={18} className="shrink-0 text-[color:var(--warn)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{action.resultSummary || action.actionType}</div>
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Browser action {action.status.replaceAll("_", " ")}</div>
</div>
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
</button>
))}
{stalledWorkflows.slice(0, 3).map((workflow) => (
<button
key={workflow.id}
type="button"
onClick={() => onNavigate("automation")}
className="focus-ring flex w-full items-center gap-3 rounded-[8px] border border-[color:var(--warn)] border-opacity-20 bg-[color:var(--warn-soft)] px-4 py-3 text-left transition hover:border-opacity-40"
>
<Lightning size={18} className="shrink-0 text-[color:var(--warn)]" />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">{workflow.name}</div>
<div className="mt-0.5 text-[11px] text-[color:var(--muted)]">Workflow {workflow.status}</div>
</div>
<ArrowRight size={14} className="shrink-0 text-[color:var(--muted)]" />
</button>
))}
</div>
</section>
) : null}
@@ -920,10 +941,6 @@ export function Dashboard(props: DashboardProps) {
);
}
/* ------------------------------------------------------------------ */
/* Kanban Board */
/* ------------------------------------------------------------------ */
interface KanbanProps {
tasks: TaskRecord[];
agents: AgentRecord[];
@@ -957,7 +974,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
if (bucket) bucket.push(task);
else grouped.backlog?.push(task);
}
// Sort each column by priority
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
for (const col of Object.values(grouped)) {
col.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9));
@@ -981,7 +997,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
const colTasks = tasksByStatus[col.status] ?? [];
return (
<div key={col.status} className="flex w-[220px] shrink-0 flex-col">
{/* Column header */}
<div className="mb-2 flex items-center gap-2 px-1">
<span className={`h-2 w-2 rounded-full ${col.dotColor}`} />
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span>
@@ -989,7 +1004,6 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
{colTasks.length}
</span>
</div>
{/* Cards */}
<div className="flex flex-1 flex-col gap-1.5 rounded-[8px] bg-[color:var(--panel-soft)] p-1.5" style={{ minHeight: 120 }}>
{colTasks.map((task) => {
const assignee = agents.find((a) => a.id === task.assigneeAgentId);
+137 -83
View File
@@ -28,6 +28,7 @@ import type {
ActivityRecord,
AgentRecord,
ApprovalRecord,
DocumentRecord,
GoalRecord,
ProfileSnapshot,
ProjectRecord,
@@ -39,76 +40,12 @@ import type {
} from "@shared/types";
import { formatMoney, formatTime } from "../lib/formatters";
import { unwrap } from "../lib/desktop";
import { ActionButton, EmptyState, Field, Input, ListFrame, ListRow, Select, StatusPill, TextArea, useFocusTrap } from "./ui";
import { ActionButton, ConfirmDialog, EmptyState, Field, InlineNotice, Input, ListFrame, ListRow, Select, StatusPill, TextArea } from "./ui";
function describeActionError(error: unknown) {
return error instanceof Error ? error.message : "The action could not be completed.";
}
function InlineNotice({
message,
tone = "danger",
}: {
message: string;
tone?: "danger" | "success" | "warn";
}) {
const toneClass = tone === "success"
? "border-[color:var(--success)] bg-[color:var(--success-soft)] text-[color:var(--success)]"
: tone === "warn"
? "border-[color:var(--warn)] bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
: "border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
return (
<div className={`rounded-[10px] border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
{message}
</div>
);
}
/* ─── Confirm Dialog ─────────────────────────────────────────────────── */
export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, onCancel }: {
open: boolean;
title: string;
message: string;
errorMessage?: string | null;
onConfirm: () => void;
onCancel: () => void;
}) {
const trapRef = useFocusTrap(open);
useEffect(() => {
if (!open) return;
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") onCancel();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--text)]/45" onClick={onCancel}>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="w-full max-w-[400px] rounded-[12px] bg-[color:var(--panel)] border border-[color:var(--line)] p-6 shadow-lg"
onClick={e => e.stopPropagation()}
>
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-[color:var(--text)] mb-2">{title}</div>
<div className="text-[13px] text-[color:var(--muted)] mb-6">{message}</div>
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
<div className="flex gap-3 justify-end">
<ActionButton label="Cancel" onClick={onCancel} />
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
</div>
</div>
</div>
);
}
/* ─── Entity-type icon lookup ────────────────────────────────────────── */
const ENTITY_ICONS: Record<string, typeof Robot> = {
agent: Robot,
@@ -127,7 +64,6 @@ const ENTITY_TYPE_OPTIONS = [
"all", "agent", "task", "run", "approval", "connector", "workspace", "secret", "goal", "project", "company",
] as const;
/* ─── Workspace state pills ──────────────────────────────────────────── */
function WorkspaceStatePill({ state }: { state: string }) {
const colorMap: Record<string, string> = {
@@ -144,7 +80,6 @@ function WorkspaceStatePill({ state }: { state: string }) {
);
}
/* ─── Priority indicator ─────────────────────────────────────────────── */
function PriorityDot({ priority }: { priority: string }) {
const color = priority === "critical"
@@ -157,7 +92,6 @@ function PriorityDot({ priority }: { priority: string }) {
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />;
}
/* ─── Task Queue ─────────────────────────────────────────────────────── */
export function TaskQueue({
tasks,
@@ -204,7 +138,6 @@ export function TaskQueue({
);
}
/* ─── Task Inspector ─────────────────────────────────────────────────── */
export function TaskInspector({
task,
@@ -571,7 +504,6 @@ export function TaskInspector({
);
}
/* ─── Task Comments ──────────────────────────────────────────────────── */
function TaskComments({
taskId,
@@ -666,7 +598,6 @@ function TaskComments({
);
}
/* ─── Task Creator ───────────────────────────────────────────────────── */
export function TaskCreator({
companyId,
@@ -741,7 +672,6 @@ export function TaskCreator({
);
}
/* ─── Pending Review List ────────────────────────────────────────────── */
export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agents: AgentRecord[] }) {
const reviewTasks = tasks.filter((t) => t.status === "in_review");
@@ -770,7 +700,6 @@ export function PendingReviewList({ tasks, agents }: { tasks: TaskRecord[]; agen
);
}
/* ─── Approval List ──────────────────────────────────────────────────── */
export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { approvals: ApprovalRecord[]; selectedApprovalId: string | null; onSelect: (id: string) => void }) {
if (approvals.length === 0) {
@@ -801,7 +730,6 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
);
}
/* ─── Approval Inspector ─────────────────────────────────────────────── */
export function ApprovalInspector({
approval,
@@ -915,7 +843,6 @@ export function ApprovalInspector({
{/* Hire agent payload details */}
{approval.type === "hire_agent" ? (() => {
// Find the agent that was proposed for hiring (look for pending_approval agents)
const proposedAgent = agents.find(
(a) => a.status === "pending_approval" && a.reportsTo === approval.requestedByAgentId
) ?? agents.find(
@@ -982,7 +909,6 @@ export function ApprovalInspector({
);
}
/* ─── Cost List ──────────────────────────────────────────────────────── */
export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["costs"]; agents: ProfileSnapshot["agents"]; runs: ProfileSnapshot["runs"] }) {
const [connectorFilter, setConnectorFilter] = useState("all");
@@ -1035,7 +961,6 @@ export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["cost
);
}
/* ─── Budget Summary ─────────────────────────────────────────────────── */
export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agents"]; costs: ProfileSnapshot["costs"] }) {
const knownSpend = costs.reduce((sum, entry) => sum + (entry.amountUsd ?? 0), 0);
@@ -1101,7 +1026,6 @@ export function BudgetSummary({ agents, costs }: { agents: ProfileSnapshot["agen
);
}
/* ─── Activity List ──────────────────────────────────────────────────── */
export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
const [search, setSearch] = useState("");
@@ -1187,18 +1111,21 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
);
}
/* ─── Workspace List ─────────────────────────────────────────────────── */
export function WorkspaceList({
workspaces,
projects,
companyId,
selectedWorkspaceId,
onSelect,
onOpen,
onDeleted,
}: {
workspaces: WorkspaceRecord[];
projects: ProjectRecord[];
companyId: string;
selectedWorkspaceId: string | null;
onSelect: (workspaceId: string) => void;
onOpen: (path: string) => Promise<void>;
onDeleted: () => Promise<void>;
}) {
@@ -1238,7 +1165,8 @@ export function WorkspaceList({
const linkedProject = projects.find((p) => p.id === workspace.projectId) ?? null;
return (
<ListRow key={workspace.id}>
<div className="mb-2 flex items-center justify-between">
<div className={`mb-2 rounded-[8px] p-1 ${selectedWorkspaceId === workspace.id ? "bg-[color:var(--accent-soft)]" : ""}`}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<div className="text-[14px] text-[color:var(--text)]">{workspace.name}</div>
@@ -1248,6 +1176,7 @@ export function WorkspaceList({
</div>
<div className="flex items-center gap-2">
{workspace.isPrimary ? <StatusPill status="ready" /> : null}
<ActionButton label="Inspect" onClick={() => onSelect(workspace.id)} icon={FolderOpen} />
<ActionButton label="Reveal" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
<ActionButton label="Delete" onClick={() => setConfirmDeleteId(workspace.id)} icon={Trash} tone="danger" />
</div>
@@ -1256,6 +1185,7 @@ export function WorkspaceList({
{workspace.repoUrl || "No remote URL"} {workspace.repoRef ? `· ${workspace.repoRef}` : ""}
{linkedProject ? <span> · Project: <span className="text-[color:var(--text)]">{linkedProject.name}</span></span> : null}
</div>
</div>
</ListRow>
);
})}
@@ -1264,7 +1194,133 @@ export function WorkspaceList({
);
}
/* ─── Workspace Creator ──────────────────────────────────────────────── */
export function WorkspaceInspector({
workspace,
agents,
tasks,
runs,
documents,
project,
onOpen,
}: {
workspace: WorkspaceRecord | null;
agents: AgentRecord[];
tasks: TaskRecord[];
runs: RunRecord[];
documents: DocumentRecord[];
project: ProjectRecord | null;
onOpen: (path: string) => Promise<void>;
}) {
if (!workspace) {
return <EmptyState title="Select a workspace" detail="Choose a workspace to inspect the agents, tasks, runs, and documents bound to it." />;
}
const workspaceAgents = agents.filter((agent) => agent.workspaceId === workspace.id);
const workspaceTasks = tasks.filter((task) => task.workspaceId === workspace.id);
const workspaceRuns = runs.filter((run) => run.workspaceId === workspace.id);
const workspaceDocuments = documents.filter((document) => document.projectId && project && document.projectId === project.id);
return (
<div className="space-y-5">
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="text-[15px] font-semibold text-[color:var(--text)]">{workspace.name}</div>
<WorkspaceStatePill state={workspace.state} />
</div>
<div className="mono mt-1 text-[11px] text-[color:var(--muted)]">{workspace.localPath}</div>
<div className="mt-2 text-[12px] text-[color:var(--muted)]">
{project ? <>Project: <span className="text-[color:var(--text)]">{project.name}</span></> : "No linked project"}
{workspace.repoUrl ? <span> · Repo: <span className="text-[color:var(--text)]">{workspace.repoUrl}</span></span> : null}
{workspace.repoRef ? <span> · Ref: <span className="text-[color:var(--text)]">{workspace.repoRef}</span></span> : null}
</div>
</div>
<ActionButton label="Reveal in Finder" onClick={() => void onOpen(workspace.localPath)} icon={ArrowSquareOut} />
</div>
</div>
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Agents</div>
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceAgents.length}</div>
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Tasks</div>
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceTasks.length}</div>
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Runs</div>
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceRuns.length}</div>
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="text-[11px] uppercase tracking-[0.08em] text-[color:var(--muted)]">Docs</div>
<div className="mt-2 text-[24px] font-semibold text-[color:var(--text)]">{workspaceDocuments.length}</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Assigned Agents</div>
{workspaceAgents.length === 0 ? (
<div className="text-[12px] text-[color:var(--muted)]">No agents are currently bound to this workspace.</div>
) : (
<div className="space-y-2">
{workspaceAgents.map((agent) => (
<div key={agent.id} className="flex items-center justify-between rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
<div>
<div className="text-[12px] font-medium text-[color:var(--text)]">{agent.name}</div>
<div className="text-[11px] text-[color:var(--muted)]">{agent.role}</div>
</div>
<StatusPill status={agent.status} />
</div>
))}
</div>
)}
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Open Tasks</div>
{workspaceTasks.length === 0 ? (
<div className="text-[12px] text-[color:var(--muted)]">No tasks target this workspace yet.</div>
) : (
<div className="space-y-2">
{workspaceTasks.slice(0, 6).map((task) => (
<div key={task.id} className="rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
<div className="flex items-center gap-2">
<PriorityDot priority={task.priority} />
<span className="text-[12px] font-medium text-[color:var(--text)]">{task.title}</span>
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">{task.status.replaceAll("_", " ")}</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-[10px] border border-[color:var(--line)] bg-[color:var(--panel)] p-4">
<div className="mb-3 text-[12px] font-semibold text-[color:var(--text)]">Recent Runs</div>
{workspaceRuns.length === 0 ? (
<div className="text-[12px] text-[color:var(--muted)]">No runs have executed in this workspace.</div>
) : (
<div className="space-y-2">
{workspaceRuns.slice(0, 6).map((run) => (
<div key={run.id} className="rounded-[8px] bg-[color:var(--panel-soft)] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[12px] font-medium text-[color:var(--text)]">{run.summary || `Run ${run.id.slice(0, 8)}`}</span>
<StatusPill status={run.status} />
</div>
<div className="mt-1 text-[11px] text-[color:var(--muted)]">{formatTime(run.startedAt)} → {formatTime(run.finishedAt)}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId: string; projects: ProjectRecord[]; onSaved: () => Promise<void> }) {
const [name, setName] = useState("");
@@ -1332,7 +1388,6 @@ export function WorkspaceCreator({ companyId, projects, onSaved }: { companyId:
);
}
/* ─── Settings Pane ──────────────────────────────────────────────────── */
export function SettingsPane({
snapshot,
@@ -1569,7 +1624,6 @@ export function SettingsPane({
);
}
/* ─── Secret Creator ─────────────────────────────────────────────────── */
export function SecretCreator({ companyId, onSaved }: { companyId: string; onSaved: () => Promise<void> }) {
const [name, setName] = useState("");
+58 -63
View File
@@ -6,10 +6,10 @@ import {
X,
} from "@phosphor-icons/react";
import type { AgentRecord, ApprovalRecord } from "@shared/types";
import { getAgentInitials } from "../lib/formatters";
import { ActionButton, EmptyState, Panel, StatusPill } from "./ui";
interface HiringPipelineProps {
companyId: string;
agents: AgentRecord[];
approvals: ApprovalRecord[];
onRefresh: () => Promise<void>;
@@ -19,7 +19,7 @@ interface HiringPipelineProps {
type HiringStage = "requisition" | "pending_approval" | "approved" | "onboarding" | "active" | "rejected";
interface HiringCandidate {
agent: AgentRecord;
agent: AgentRecord | null;
approval: ApprovalRecord | null;
stage: HiringStage;
}
@@ -33,7 +33,6 @@ function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | null): Hi
return "approved";
}
if (agent.status === "idle" && approval?.state === "approved") {
// Recently approved — check if they have any tasks yet
return "onboarding";
}
return "active";
@@ -52,27 +51,28 @@ function stageLabel(stage: HiringStage): string {
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";
case "requisition": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]";
case "pending_approval": return "bg-[color:var(--warn-soft)] text-[color:var(--warn)]";
case "approved": return "bg-[color:var(--success-soft)] text-[color:var(--success)]";
case "onboarding": return "bg-[color:var(--accent-soft)] text-[color:var(--accent)]";
case "active": return "bg-[color:var(--success-soft)] text-[color:var(--success)]";
case "rejected": return "bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
}
}
const STAGE_ORDER: HiringStage[] = ["requisition", "pending_approval", "approved", "onboarding", "active", "rejected"];
export function HiringPipeline({ companyId: _companyId, agents, approvals, onRefresh, onNavigateToAgent }: HiringPipelineProps) {
export function HiringPipeline({ 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;
const approval = hireApprovals.find(a => {
if (a.relatedAgentId === agent.id) return true;
if (!a.payloadJson) return false;
try { return JSON.parse(a.payloadJson).agentId === agent.id; } catch { return false; }
}) ?? null;
return {
agent,
approval,
@@ -80,7 +80,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
};
});
// 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);
});
@@ -88,7 +87,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
const allCandidates = [
...candidates,
...pendingHireApprovals.map(a => ({
agent: null as unknown as AgentRecord,
agent: null,
approval: a,
stage: "pending_approval" as HiringStage,
})),
@@ -98,7 +97,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
? 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;
@@ -109,7 +107,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
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) => (
@@ -117,16 +114,16 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
<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"
filterStage === stage ? "ring-1 ring-[color:var(--accent)]/40 bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]"
} ${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]">
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-[color:var(--line)] text-[10px]">
{stageCounts[stage]}
</span>
</button>
{idx < STAGE_ORDER.length - 2 && (
<ArrowRight size={12} className="text-zinc-600 flex-shrink-0" />
<ArrowRight size={12} className="text-[color:var(--muted)] flex-shrink-0" />
)}
</div>
))}
@@ -134,11 +131,11 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
<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"
filterStage === "rejected" ? "ring-1 ring-[color:var(--danger)]/40 bg-[color:var(--panel-soft)]" : "hover:bg-[color:var(--panel-soft)]"
} ${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]">
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-[color:var(--line)] text-[10px]">
{stageCounts.rejected}
</span>
</button>
@@ -146,10 +143,9 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
</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">
<div className="divide-y divide-[color:var(--line)]">
{allCandidates
.filter(c => c.stage === "requisition" || c.stage === "pending_approval")
.map((candidate) => {
@@ -157,32 +153,32 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
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 className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-[color:var(--warn-soft)] flex items-center justify-center">
<UserPlus size={16} className="text-[color:var(--warn)]" />
</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="font-medium text-sm text-[color:var(--text)]">{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">
<div className="text-xs text-[color:var(--muted)] mt-0.5">
{ag ? `${ag.role} — ${ag.title}` : a?.payloadSummary}
</div>
{ag?.department && (
<div className="text-xs text-zinc-500 mt-0.5">
<div className="text-xs text-[color:var(--muted)] 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>
<div className="text-xs text-[color:var(--muted)] mt-1">{a.payloadSummary}</div>
)}
{a?.impactSummary && (
<div className="text-xs text-zinc-500">{a.impactSummary}</div>
<div className="text-xs text-[color:var(--muted)]">{a.impactSummary}</div>
)}
{ag?.capabilities && (
<div className="text-xs text-zinc-600 mt-1 line-clamp-2">{ag.capabilities}</div>
<div className="text-xs text-[color:var(--muted)] mt-1 line-clamp-2">{ag.capabilities}</div>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
@@ -194,6 +190,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
tone="accent"
onClick={async () => {
await window.agentCompany.decideApproval({
companyId: a.companyId,
approvalId: a.id,
state: "approved",
decisionNote: "Hire approved via hiring pipeline",
@@ -207,6 +204,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
tone="danger"
onClick={async () => {
await window.agentCompany.decideApproval({
companyId: a.companyId,
approvalId: a.id,
state: "rejected",
decisionNote: "Hire rejected via hiring pipeline",
@@ -224,7 +222,6 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
</Panel>
)}
{/* Full Pipeline List */}
<Panel
title={filterStage === "all" ? "All Agents" : `${stageLabel(filterStage)} (${filteredCandidates.length})`}
action={
@@ -239,7 +236,7 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
detail="When agents propose new hires via the API, they will appear here for your review."
/>
) : (
<div className="divide-y divide-zinc-800/60">
<div className="divide-y divide-[color:var(--line)]">
{filteredCandidates.map((candidate) => {
const ag = candidate.agent;
if (!ag) return null;
@@ -250,32 +247,32 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
return (
<div
key={ag.id}
className="px-4 py-3 flex items-center gap-3 hover:bg-zinc-800/40 cursor-pointer transition-colors"
className="px-4 py-3 flex items-center gap-3 hover:bg-[color:var(--panel-soft)] 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 className="flex-shrink-0 h-8 w-8 rounded-full bg-[color:var(--panel-soft)] flex items-center justify-center text-xs font-bold text-[color:var(--muted-strong)]">
{getAgentInitials(ag.name)}
</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="font-medium text-sm text-[color:var(--text)] 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">
<div className="text-xs text-[color:var(--muted)] 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">
<div className="text-xs text-[color:var(--muted)]">
{manager ? `Reports to ${manager.name}` : "Top level"}
</div>
{requestedBy && (
<div className="text-[10px] text-zinc-600">Proposed by {requestedBy.name}</div>
<div className="text-[10px] text-[color:var(--muted)]">Proposed by {requestedBy.name}</div>
)}
<div className="text-xs text-zinc-500">${ag.budgetMonthlyUsd}/mo</div>
<div className="text-xs text-[color:var(--muted)]">${ag.budgetMonthlyUsd}/mo</div>
</div>
</div>
);
@@ -284,31 +281,29 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
)}
</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 className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Total Agents</div>
<div className="text-2xl font-bold text-[color:var(--text)] 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 className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Pending Hires</div>
<div className="text-2xl font-bold text-[color:var(--warn)] 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">
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Departments</div>
<div className="text-2xl font-bold text-[color:var(--text)] 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">
<div className="rounded-lg px-4 py-3 border border-[color:var(--line)] bg-[color:var(--panel)]">
<div className="text-[10px] uppercase text-[color:var(--muted)] tracking-wider">Monthly Payroll</div>
<div className="text-2xl font-bold text-[color:var(--text)] 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 => {
@@ -317,15 +312,15 @@ export function HiringPipeline({ companyId: _companyId, agents, approvals, onRef
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 key={dept} className="bg-[color:var(--panel-soft)] rounded-md px-3 py-2">
<div className="text-xs font-medium text-[color:var(--text)] 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>
<span className="text-sm font-bold text-[color:var(--text)]">{active}</span>
<span className="text-[10px] text-[color:var(--muted)]">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>
<span className="text-sm font-bold text-[color:var(--warn)]">+{pending}</span>
<span className="text-[10px] text-[color:var(--warn)]">pending</span>
</>
)}
</div>
+19 -71
View File
@@ -10,19 +10,14 @@ import {
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 */
/* ------------------------------------------------------------------ */
import type { InboxItem, InboxItemType, SectionId, StandupReport, CompanyMetrics } from "@shared/types";
import { formatMoney, timeAgo, getAgentInitials, formatDuration } from "../lib/formatters";
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" },
@@ -40,49 +35,28 @@ const severityColors: Record<string, string> = {
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) {
export function Inbox({ items, onNavigate }: 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]);
if (filter === "all") return items;
return items.filter(i => i.type === filter);
}, [items, filter]);
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 counts = useMemo(() => ({
all: items.length,
pending_approval: items.filter(i => i.type === "pending_approval").length,
failed_run: items.filter(i => i.type === "failed_run").length,
budget_warning: items.filter(i => i.type === "budget_warning").length,
blocked_task: items.filter(i => i.type === "blocked_task").length,
review_needed: items.filter(i => i.type === "review_needed").length,
agent_message: items.filter(i => i.type === "agent_message").length,
}), [items]);
const handleItemClick = (item: InboxItem) => {
const sectionMap: Record<string, SectionId> = {
@@ -90,6 +64,7 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
run: "tasks",
agent: "agents",
task: "tasks",
message: "communication",
};
const section = sectionMap[item.entityType] ?? "overview";
onNavigate(section, item.entityId);
@@ -102,11 +77,11 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
{ 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 },
{ key: "agent_message", label: "Messages", count: counts.agent_message },
];
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)]">
@@ -121,7 +96,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
</div>
</div>
{/* Filter Bar */}
<div className="flex flex-wrap items-center gap-1.5">
{filterButtons.map(btn => (
<button
@@ -146,7 +120,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
))}
</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)]" />
@@ -209,9 +182,6 @@ export function Inbox({ items, onNavigate, onDismiss }: InboxProps) {
);
}
/* ------------------------------------------------------------------ */
/* Standup Report View */
/* ------------------------------------------------------------------ */
interface StandupProps {
report: StandupReport | null;
@@ -231,7 +201,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
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)]">
@@ -270,7 +239,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</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>
@@ -285,7 +253,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</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>
@@ -300,7 +267,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</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">
@@ -317,7 +283,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
: 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()}
{getAgentInitials(ar.agentName)}
</div>
{ar.status === "running" ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
@@ -350,9 +316,6 @@ export function StandupView({ report, onNavigate }: StandupProps) {
);
}
/* ------------------------------------------------------------------ */
/* Performance Metrics View */
/* ------------------------------------------------------------------ */
interface PerformanceProps {
metrics: CompanyMetrics | null;
@@ -372,7 +335,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
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"
@@ -398,7 +360,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
/>
</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">
@@ -422,7 +383,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
</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)]">
@@ -449,7 +409,7 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
<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()}
{getAgentInitials(am.agentName)}
</div>
<span className="text-[12px] font-medium text-[color:var(--text)]">{am.agentName}</span>
</div>
@@ -477,9 +437,6 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function CompactMetric({ label, value, tone = "default", subtitle }: {
label: string;
@@ -526,12 +483,3 @@ function RunProgressBar({ succeeded, failed, total }: { succeeded: number; faile
</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`;
}
+7 -51
View File
@@ -17,7 +17,7 @@ import type {
ProjectRecord,
WorkspaceRecord,
} from "@shared/types";
import { formatMoney, formatTime } from "../lib/formatters";
import { formatMoney, formatTime, getAgentInitials } from "../lib/formatters";
import { unwrap } from "../lib/desktop";
import {
ActionButton,
@@ -30,10 +30,6 @@ import {
TextArea,
} from "./ui";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function BudgetBar({ spent, budget }: { spent: number; budget: number }) {
const pct = budget > 0 ? Math.min((spent / budget) * 100, 100) : 0;
const overBudget = spent > budget && budget > 0;
@@ -70,10 +66,6 @@ function ConfirmDelete({
);
}
/* ------------------------------------------------------------------ */
/* AgentRoster */
/* ------------------------------------------------------------------ */
export function AgentRoster({
agents,
connectors,
@@ -132,10 +124,6 @@ export function AgentRoster({
);
}
/* ------------------------------------------------------------------ */
/* AgentCreator */
/* ------------------------------------------------------------------ */
export function AgentCreator({
companyId,
connectors,
@@ -261,7 +249,7 @@ export function AgentCreator({
setReportsTo("");
await onSaved();
} catch (error) {
console.error("Failed to create agent:", error);
void error;
}
})()
}
@@ -271,10 +259,6 @@ export function AgentCreator({
);
}
/* ------------------------------------------------------------------ */
/* AgentEditor */
/* ------------------------------------------------------------------ */
export function AgentEditor({
agent,
connectors,
@@ -532,10 +516,6 @@ export function AgentEditor({
);
}
/* ------------------------------------------------------------------ */
/* GoalList */
/* ------------------------------------------------------------------ */
export function GoalList({
goals,
agents,
@@ -585,10 +565,6 @@ export function GoalList({
);
}
/* ------------------------------------------------------------------ */
/* GoalCreator */
/* ------------------------------------------------------------------ */
export function GoalCreator({
companyId,
agents,
@@ -649,7 +625,7 @@ export function GoalCreator({
setDescription("");
await onSaved();
} catch (error) {
console.error("Failed to create goal:", error);
void error;
}
})()
}
@@ -659,10 +635,6 @@ export function GoalCreator({
);
}
/* ------------------------------------------------------------------ */
/* GoalEditor */
/* ------------------------------------------------------------------ */
export function GoalEditor({
goal,
goals,
@@ -741,7 +713,7 @@ export function GoalEditor({
});
await onSaved();
} catch (error) {
console.error("Failed to save goal:", error);
void error;
}
})()
}
@@ -770,10 +742,6 @@ export function GoalEditor({
);
}
/* ------------------------------------------------------------------ */
/* ProjectList */
/* ------------------------------------------------------------------ */
export function ProjectList({
projects,
goals,
@@ -834,10 +802,6 @@ export function ProjectList({
);
}
/* ------------------------------------------------------------------ */
/* ProjectCreator */
/* ------------------------------------------------------------------ */
export function ProjectCreator({
companyId,
goals,
@@ -902,7 +866,7 @@ export function ProjectCreator({
setTargetDate("");
await onSaved();
} catch (error) {
console.error("Failed to create project:", error);
void error;
}
})()
}
@@ -912,10 +876,6 @@ export function ProjectCreator({
);
}
/* ------------------------------------------------------------------ */
/* ProjectEditor */
/* ------------------------------------------------------------------ */
export function ProjectEditor({
project,
goals,
@@ -995,7 +955,7 @@ export function ProjectEditor({
});
await onSaved();
} catch (error) {
console.error("Failed to save project:", error);
void error;
}
})()
}
@@ -1024,10 +984,6 @@ export function ProjectEditor({
);
}
/* ------------------------------------------------------------------ */
/* OrgChart – tree layout with pan & zoom */
/* ------------------------------------------------------------------ */
const CARD_W = 220;
const CARD_H = 110;
const GAP_X = 32;
@@ -1276,7 +1232,7 @@ export function OrgChart({
{allNodes.map((node) => {
const dotColor = statusDotColor[node.agent.status] ?? "#a3a3a3";
const connectorLabel = adapterLabels[node.agent.connectorId] ?? node.agent.connectorId;
const initials = node.agent.name.split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase();
const initials = getAgentInitials(node.agent.name);
const isRunning = node.agent.status === "running";
const isActive = node.agent.status === "active" || isRunning;
const isError = node.agent.status === "error";
+73 -54
View File
@@ -1,9 +1,10 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowRight,
Browser,
ArrowSquareOut,
Camera,
ClockCounterClockwise,
ChatCircle,
Eye,
Globe,
@@ -17,16 +18,12 @@ import {
SignIn,
Trash,
UserPlus,
Warning,
X,
} from "@phosphor-icons/react";
import type { BrowserActionRecord, SectionId, SocialAccountRecord, SocialPlatform } from "@shared/types";
import type { BrowserActionRecord, SocialAccountRecord, SocialPlatform } from "@shared/types";
import { timeAgo } from "../lib/formatters";
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" },
@@ -49,27 +46,9 @@ const actionTypeLabels: Record<string, { label: string; icon: React.ElementType
navigate: { label: "Navigate", icon: Globe },
search: { label: "Search", icon: MagnifyingGlass },
dm: { label: "Direct Message", icon: ChatCircle },
custom_script: { label: "Custom Script", icon: Browser },
custom_script: { label: "Legacy Action", 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,
@@ -86,7 +65,6 @@ function AccountCard({
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 }}
@@ -94,7 +72,6 @@ function AccountCard({
{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>
@@ -117,7 +94,6 @@ function AccountCard({
</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
@@ -160,10 +136,6 @@ function AccountCard({
);
}
/* ------------------------------------------------------------------ */
/* Add Account Dialog */
/* ------------------------------------------------------------------ */
function AddAccountDialog({
onAdd,
onClose,
@@ -199,7 +171,6 @@ function AddAccountDialog({
</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">
@@ -235,7 +206,6 @@ function AddAccountDialog({
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
@@ -279,11 +249,19 @@ function AddAccountDialog({
);
}
/* ------------------------------------------------------------------ */
/* Browser Action History */
/* ------------------------------------------------------------------ */
function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; agents: Array<{ id: string; name: string }> }) {
function ActionHistory({
actions,
agents,
onOpenPath,
onCancelAction,
onNavigate,
}: {
actions: BrowserActionRecord[];
agents: Array<{ id: string; name: string }>;
onOpenPath: (path: string) => Promise<void>;
onCancelAction: (actionId: string) => Promise<void>;
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
}) {
if (actions.length === 0) {
return (
<div className="py-8 text-center text-[13px] text-[color:var(--muted)]">
@@ -329,6 +307,46 @@ function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; ag
{action.errorMessage ? (
<div className="mt-0.5 truncate text-[11px] text-[color:var(--danger)]">{action.errorMessage}</div>
) : null}
<div className="mt-1 flex flex-wrap items-center gap-2">
{action.taskId ? (
<button
type="button"
onClick={() => onNavigate("tasks", action.taskId!)}
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
Open task
</button>
) : null}
{action.approvalId ? (
<button
type="button"
onClick={() => onNavigate("approvals", action.approvalId!)}
className="rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
Open approval
</button>
) : null}
{action.screenshotPath ? (
<button
type="button"
onClick={() => void onOpenPath(action.screenshotPath!)}
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--muted-strong)] transition hover:text-[color:var(--text)]"
>
<ArrowSquareOut size={10} />
Screenshot
</button>
) : null}
{["queued", "running", "approval_required"].includes(action.status) ? (
<button
type="button"
onClick={() => void onCancelAction(action.id)}
className="inline-flex items-center gap-1 rounded-[5px] bg-[color:var(--danger-soft)] px-2 py-1 text-[10px] font-medium text-[color:var(--danger)] transition hover:brightness-95"
>
<ClockCounterClockwise size={10} />
Cancel
</button>
) : null}
</div>
</div>
<div className="shrink-0 text-[10px] text-[color:var(--muted)]">
{timeAgo(action.createdAt)}
@@ -349,10 +367,6 @@ function ActionHistory({ actions, agents }: { actions: BrowserActionRecord[]; ag
);
}
/* ------------------------------------------------------------------ */
/* Main Social Accounts Panel */
/* ------------------------------------------------------------------ */
interface SocialPanelProps {
accounts: SocialAccountRecord[];
actions: BrowserActionRecord[];
@@ -367,7 +381,9 @@ interface SocialPanelProps {
}) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onLogin: (accountId: string) => Promise<void>;
onNavigate: (section: SectionId, entityId?: string) => void;
onOpenPath: (path: string) => Promise<void>;
onCancelAction: (actionId: string) => Promise<void>;
onNavigate: (section: "tasks" | "approvals", entityId: string) => void;
}
export function SocialAccountsPanel({
@@ -378,6 +394,8 @@ export function SocialAccountsPanel({
onSave,
onDelete,
onLogin,
onOpenPath,
onCancelAction,
onNavigate,
}: SocialPanelProps) {
const [showAddDialog, setShowAddDialog] = useState(false);
@@ -390,7 +408,6 @@ export function SocialAccountsPanel({
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)]">
@@ -411,7 +428,6 @@ export function SocialAccountsPanel({
/>
</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" />
@@ -420,13 +436,12 @@ export function SocialAccountsPanel({
<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.
using 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"
@@ -452,7 +467,6 @@ export function SocialAccountsPanel({
</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">
@@ -478,16 +492,21 @@ export function SocialAccountsPanel({
account={account}
onLogin={() => void onLogin(account.id)}
onDelete={() => void onDelete(account.id)}
onClick={() => {/* future: account detail view */}}
onClick={() => {}}
/>
))}
</div>
)
) : (
<ActionHistory actions={actions} agents={agents} />
<ActionHistory
actions={actions}
agents={agents}
onOpenPath={onOpenPath}
onCancelAction={onCancelAction}
onNavigate={onNavigate}
/>
)}
{/* Add Account Dialog */}
<AnimatePresence>
{showAddDialog ? (
<AddAccountDialog
+61 -5
View File
@@ -353,6 +353,67 @@ export function StatusPill({ status }: { status: string }) {
);
}
export function InlineNotice({
message,
tone = "danger",
}: {
message: string;
tone?: "danger" | "success" | "warn";
}) {
const toneClass = tone === "success"
? "border-[color:var(--success)] bg-[color:var(--success-soft)] text-[color:var(--success)]"
: tone === "warn"
? "border-[color:var(--warn)] bg-[color:var(--warn-soft)] text-[color:var(--warn)]"
: "border-[color:var(--danger)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]";
return (
<div className={`rounded-[10px] border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
{message}
</div>
);
}
export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, onCancel }: {
open: boolean;
title: string;
message: string;
errorMessage?: string | null;
onConfirm: () => void;
onCancel: () => void;
}) {
const trapRef = useFocusTrap(open);
useEffect(() => {
if (!open) return;
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") onCancel();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--text)]/45" onClick={onCancel}>
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="w-full max-w-[400px] rounded-[12px] bg-[color:var(--panel)] border border-[color:var(--line)] p-6 shadow-lg"
onClick={e => e.stopPropagation()}
>
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-[color:var(--text)] mb-2">{title}</div>
<div className="text-[13px] text-[color:var(--muted)] mb-6">{message}</div>
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
<div className="flex gap-3 justify-end">
<ActionButton label="Cancel" onClick={onCancel} />
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
</div>
</div>
</div>
);
}
export function EmptyState({
title,
detail,
@@ -404,8 +465,6 @@ export function ListRow({
return <div className={`border-b border-[color:var(--line)] px-5 py-4 last:border-b-0 ${className}`}>{children}</div>;
}
/* ── Focus Trap Hook ────────────────────────────────────────────────── */
export function useFocusTrap(active: boolean) {
const ref = useRef<HTMLDivElement>(null);
@@ -415,7 +474,6 @@ export function useFocusTrap(active: boolean) {
const container = ref.current;
const previouslyFocused = document.activeElement as HTMLElement | null;
// Focus first focusable element
const focusable = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
@@ -454,8 +512,6 @@ export function useFocusTrap(active: boolean) {
return ref;
}
/* ── Reduced Motion Hook ────────────────────────────────────────────── */
export function useReducedMotion() {
const query = typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
// Return static value — Electron doesn't change this mid-session
+57
View File
@@ -11,3 +11,60 @@ export function formatTime(value: string | null | undefined) {
if (!value) return "Not yet";
return new Date(value).toLocaleString();
}
export function timeAgo(dateStr: string | null): string {
if (!dateStr) return "never";
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`;
}
export function getAgentInitials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
export 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`;
}
export function formatMessageTime(dateStr: string): string {
const d = new Date(dateStr);
const now = new Date();
const isToday =
d.getDate() === now.getDate() &&
d.getMonth() === now.getMonth() &&
d.getFullYear() === now.getFullYear();
if (isToday) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday =
d.getDate() === yesterday.getDate() &&
d.getMonth() === yesterday.getMonth() &&
d.getFullYear() === yesterday.getFullYear();
if (isYesterday) {
return `Yesterday ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
}
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}