forked from farhoodlabs/paperclip
c2709687b8
Add ILIKE-based issue search across title, identifier, description, and comments with relevance ranking. Add assigneeUserId filter and allow agents to return issues to creator. Show assigned issue count in sidebar badges. Add minCount param to live-runs endpoint. Add activity charts (run activity, priority, status, success rate) to dashboard. Improve active agents panel with recent run cards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link } 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 { heartbeatsApi } from "../api/heartbeats";
|
|
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 { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
|
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, companies } = useCompany();
|
|
const { openOnboarding } = useDialog();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
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 { data: runs } = useQuery({
|
|
queryKey: queryKeys.heartbeats(selectedCompanyId!),
|
|
queryFn: () => heartbeatsApi.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.identifier ?? i.id.slice(0, 8));
|
|
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 entityTitleMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title);
|
|
return map;
|
|
}, [issues]);
|
|
|
|
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">
|
|
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}
|
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
|
|
|
<ActiveAgentsPanel companyId={selectedCompanyId!} />
|
|
|
|
{data && (
|
|
<>
|
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-1 sm:gap-2">
|
|
<MetricCard
|
|
icon={Bot}
|
|
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
|
|
label="Agents Enabled"
|
|
to="/agents"
|
|
description={
|
|
<span>
|
|
{data.agents.running} running{", "}
|
|
{data.agents.paused} paused{", "}
|
|
{data.agents.error} errors
|
|
</span>
|
|
}
|
|
/>
|
|
<MetricCard
|
|
icon={CircleDot}
|
|
value={data.tasks.inProgress}
|
|
label="Tasks In Progress"
|
|
to="/issues"
|
|
description={
|
|
<span>
|
|
{data.tasks.open} open{", "}
|
|
{data.tasks.blocked} blocked
|
|
</span>
|
|
}
|
|
/>
|
|
<MetricCard
|
|
icon={DollarSign}
|
|
value={formatCents(data.costs.monthSpendCents)}
|
|
label="Month Spend"
|
|
to="/costs"
|
|
description={
|
|
<span>
|
|
{data.costs.monthBudgetCents > 0
|
|
? `${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget`
|
|
: "Unlimited budget"}
|
|
</span>
|
|
}
|
|
/>
|
|
<MetricCard
|
|
icon={ShieldCheck}
|
|
value={data.pendingApprovals}
|
|
label="Pending Approvals"
|
|
to="/approvals"
|
|
description={
|
|
<span>
|
|
{data.staleTasks} stale tasks
|
|
</span>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
|
<RunActivityChart runs={runs ?? []} />
|
|
</ChartCard>
|
|
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
|
<PriorityChart issues={issues ?? []} />
|
|
</ChartCard>
|
|
<ChartCard title="Issues by Status" subtitle="Last 14 days">
|
|
<IssueStatusChart issues={issues ?? []} />
|
|
</ChartCard>
|
|
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
|
<SuccessRateChart runs={runs ?? []} />
|
|
</ChartCard>
|
|
</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}
|
|
entityTitleMap={entityTitleMap}
|
|
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) => (
|
|
<Link
|
|
key={issue.id}
|
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
|
|
>
|
|
<div className="flex gap-3">
|
|
<div className="flex items-start gap-2 min-w-0 flex-1">
|
|
<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 sm:truncate">
|
|
<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;
|
|
})()}
|
|
</p>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">
|
|
{timeAgo(issue.updatedAt)}
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|