nexus/ui/src/pages/Dashboard.tsx
Forgotten 39f8d38528 UI: mobile responsive layout, streamline agent budget display, and xs avatar size
Make agents list force list view on mobile with condensed trailing
info. Add mobile bottom bar for config save/cancel and live run
indicator on agent detail. Make MetricCard, PageTabBar, Dashboard
tasks, and ActivityRow responsive for small screens. Add xs avatar
size for inline text flow. Remove redundant budget displays from
agent overview, properties panel, costs tab, and config form.
Add attachment activity verb labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:29:13 -06:00

299 lines
11 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { dashboardApi } from "../api/dashboard";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { MetricCard } from "../components/MetricCard";
import { EmptyState } from "../components/EmptyState";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { ActivityRow } from "../components/ActivityRow";
import { Identity } from "../components/Identity";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import type { Agent, Issue } from "@paperclip/shared";
function getRecentIssues(issues: Issue[]): Issue[] {
return [...issues]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
export function Dashboard() {
const { selectedCompanyId, selectedCompany, companies } = useCompany();
const { openOnboarding } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
const seenActivityIdsRef = useRef<Set<string>>(new Set());
const hydratedActivityRef = useRef(false);
const activityAnimationTimersRef = useRef<number[]>([]);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
useEffect(() => {
setBreadcrumbs([{ label: "Dashboard" }]);
}, [setBreadcrumbs]);
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.dashboard(selectedCompanyId!),
queryFn: () => dashboardApi.summary(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: activity } = useQuery({
queryKey: queryKeys.activity(selectedCompanyId!),
queryFn: () => activityApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: issues } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const recentIssues = issues ? getRecentIssues(issues) : [];
const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]);
useEffect(() => {
for (const timer of activityAnimationTimersRef.current) {
window.clearTimeout(timer);
}
activityAnimationTimersRef.current = [];
seenActivityIdsRef.current = new Set();
hydratedActivityRef.current = false;
setAnimatedActivityIds(new Set());
}, [selectedCompanyId]);
useEffect(() => {
if (recentActivity.length === 0) return;
const seen = seenActivityIdsRef.current;
const currentIds = recentActivity.map((event) => event.id);
if (!hydratedActivityRef.current) {
for (const id of currentIds) seen.add(id);
hydratedActivityRef.current = true;
return;
}
const newIds = currentIds.filter((id) => !seen.has(id));
if (newIds.length === 0) {
for (const id of currentIds) seen.add(id);
return;
}
setAnimatedActivityIds((prev) => {
const next = new Set(prev);
for (const id of newIds) next.add(id);
return next;
});
for (const id of newIds) seen.add(id);
const timer = window.setTimeout(() => {
setAnimatedActivityIds((prev) => {
const next = new Set(prev);
for (const id of newIds) next.delete(id);
return next;
});
activityAnimationTimersRef.current = activityAnimationTimersRef.current.filter((t) => t !== timer);
}, 980);
activityAnimationTimersRef.current.push(timer);
}, [recentActivity]);
useEffect(() => {
return () => {
for (const timer of activityAnimationTimersRef.current) {
window.clearTimeout(timer);
}
};
}, []);
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const entityNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title);
for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name);
for (const p of projects ?? []) map.set(`project:${p.id}`, p.name);
return map;
}, [issues, agents, projects]);
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
if (!selectedCompanyId) {
if (companies.length === 0) {
return (
<EmptyState
icon={LayoutDashboard}
message="Welcome to Paperclip. Set up your first company and agent to get started."
action="Get Started"
onAction={openOnboarding}
/>
);
}
return (
<EmptyState icon={LayoutDashboard} message="Create or select a company to view the dashboard." />
);
}
return (
<div className="space-y-6">
{selectedCompany && (
<p className="text-sm text-muted-foreground">{selectedCompany.name}</p>
)}
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{data && (
<>
<div className="grid grid-cols-2 xl:grid-cols-4 gap-2 sm:gap-4">
<MetricCard
icon={Bot}
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
label="Agents Enabled"
onClick={() => navigate("/agents")}
description={
<span>
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.running} running</span>
{", "}
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.paused} paused</span>
{", "}
<span className="cursor-pointer" onClick={() => navigate("/agents")}>{data.agents.error} errors</span>
</span>
}
/>
<MetricCard
icon={CircleDot}
value={data.tasks.inProgress}
label="Tasks In Progress"
onClick={() => navigate("/issues")}
description={
<span>
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.open} open</span>
{", "}
<span className="cursor-pointer" onClick={() => navigate("/issues")}>{data.tasks.blocked} blocked</span>
</span>
}
/>
<MetricCard
icon={DollarSign}
value={formatCents(data.costs.monthSpendCents)}
label="Month Spend"
onClick={() => navigate("/costs")}
description={
<span className="cursor-pointer" onClick={() => navigate("/costs")}>
{data.costs.monthUtilizationPercent}% of {formatCents(data.costs.monthBudgetCents)} budget
</span>
}
/>
<MetricCard
icon={ShieldCheck}
value={data.pendingApprovals}
label="Pending Approvals"
onClick={() => navigate("/approvals")}
description={
<span className="cursor-pointer" onClick={() => navigate("/issues")}>
{data.staleTasks} stale tasks
</span>
}
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
{/* Recent Activity */}
{recentActivity.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Activity
</h3>
<div className="border border-border divide-y divide-border">
{recentActivity.map((event) => (
<ActivityRow
key={event.id}
event={event}
agentMap={agentMap}
entityNameMap={entityNameMap}
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}
/>
))}
</div>
</div>
)}
{/* Recent Tasks */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Recent Tasks
</h3>
{recentIssues.length === 0 ? (
<div className="border border-border p-4">
<p className="text-sm text-muted-foreground">No tasks yet.</p>
</div>
) : (
<div className="border border-border divide-y divide-border">
{recentIssues.slice(0, 10).map((issue) => (
<div
key={issue.id}
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate(`/issues/${issue.id}`)}
>
<div className="flex items-start gap-2">
<div className="flex items-center gap-2 shrink-0 mt-0.5">
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
</div>
<p className="min-w-0 flex-1">
<span>{issue.title}</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline"><Identity name={name} size="sm" className="ml-2 inline-flex" /></span>
: null;
})()}
<span className="text-xs text-muted-foreground ml-2">
{timeAgo(issue.updatedAt)}
</span>
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
<ActiveAgentsPanel companyId={selectedCompanyId!} />
</>
)}
</div>
);
}