update
This commit is contained in:
1 parent
54bcc77ef7
commit
7a3db27ffa
33 files changed
+5906
-3089
No files matched your search
+124
-211
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
Reference in new issue
Block a user