[W9-3a] Enrich agent detail page with sparkline + activity timeline (#529)

Backend: optional agent_slug filter on GET /usage/time-series + UsageService.get_time_series (AgentSpawnSessionTable.agent_slug column already exists — no migration).

Frontend: AgentActivityPanel on the agent detail page — a 7d per-agent token sparkline (recharts AreaChart) + a merged work-session/journal activity timeline. Work-sessions filter by the agent UUID (WorkSessionTable.agent_id is a UUID FK to agents.id), journals by slug. List grid left as-is (avoids 25-agent fan-out). Card last_active deferred (no live hook populates AgentMetrics).

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:33:55 +02:00
committed by GitHub
co-authored by Renn F
parent d9084eeb07
commit 9a364abb74
10 changed files with 352 additions and 11 deletions
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
const { mockSeries, mockSessions, mockJournals } = vi.hoisted(() => ({
mockSeries: vi.fn(),
mockSessions: vi.fn(),
mockJournals: vi.fn(),
}));
vi.mock("@/hooks/use-usage", () => ({
useUsageTimeSeries: mockSeries,
}));
vi.mock("@/hooks/use-work-sessions", () => ({
useWorkSessions: mockSessions,
}));
vi.mock("@/hooks/use-journals", () => ({
useAgentJournalEntries: mockJournals,
}));
import { AgentActivityPanel } from "../agent-activity-panel";
describe("AgentActivityPanel", () => {
beforeEach(() => {
mockSeries.mockReturnValue({ data: [], isLoading: false });
mockSessions.mockReturnValue({ data: [], isLoading: false });
mockJournals.mockReturnValue({ data: [], isLoading: false });
});
it("renders both card titles", () => {
render(
<AgentActivityPanel agentSlug="be-dev-1" agentUuid="uuid-1" />,
);
expect(screen.getByText("Token Activity")).toBeInTheDocument();
expect(screen.getByText("Recent Activity")).toBeInTheDocument();
});
it("shows empty states when there is no history", () => {
render(
<AgentActivityPanel agentSlug="be-dev-1" agentUuid="uuid-1" />,
);
expect(
screen.getByText("No token usage in the last 7 days"),
).toBeInTheDocument();
expect(screen.getByText("No recent activity")).toBeInTheDocument();
});
it("does not show the empty state while loading", () => {
mockSeries.mockReturnValue({ data: [], isLoading: true });
mockSessions.mockReturnValue({ data: [], isLoading: true });
render(
<AgentActivityPanel agentSlug="be-dev-1" agentUuid="uuid-1" />,
);
expect(
screen.queryByText("No token usage in the last 7 days"),
).not.toBeInTheDocument();
expect(
screen.queryByText("No recent activity"),
).not.toBeInTheDocument();
});
it("renders a work-session timeline entry", () => {
mockSessions.mockReturnValue({
data: [
{
id: "s1",
task_id: "abcdef12-3456-7890",
branch_name: "feature/backend/ABC12345",
status: "completed",
started_at: "2026-07-14T10:00:00Z",
has_pr: true,
},
],
isLoading: false,
});
render(
<AgentActivityPanel agentSlug="be-dev-1" agentUuid="uuid-1" />,
);
expect(screen.getByText("Task abcdef12")).toBeInTheDocument();
});
});
@@ -0,0 +1,200 @@
"use client";
import { useMemo } from "react";
import { formatDistanceToNow } from "date-fns";
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip as RTooltip,
} from "recharts";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { GitBranch, BookOpen } from "lucide-react";
import { useUsageTimeSeries } from "@/hooks/use-usage";
import { useWorkSessions } from "@/hooks/use-work-sessions";
import { useAgentJournalEntries } from "@/hooks/use-journals";
import { WorkSessionStatus, type UsageTimePoint } from "@/types";
interface AgentActivityPanelProps {
agentSlug: string;
// WorkSessionTable.agent_id is a UUID FK to agents.id, so work-sessions are
// filtered by the agent UUID — NOT the slug. Journals take the slug.
agentUuid?: string;
}
const SESSION_STATUS_LABEL: Record<WorkSessionStatus, string> = {
[WorkSessionStatus.ACTIVE]: "In progress",
[WorkSessionStatus.COMPLETED]: "Completed",
[WorkSessionStatus.ABANDONED]: "Abandoned",
};
function fmtK(n: number): string {
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
return String(n);
}
type TimelineItem = {
kind: "session" | "journal";
title: string;
subtitle: string;
timestamp: string;
};
function mergeTimeline(
sessions: { task_id: string; branch_name: string; status: WorkSessionStatus; started_at: string }[],
journals: { title: string; type: string; timestamp: string; task_id: string | null }[],
): TimelineItem[] {
const items: TimelineItem[] = [
...sessions.map((s) => ({
kind: "session" as const,
title: `Task ${s.task_id.slice(0, 8)}`,
subtitle: `${s.branch_name} · ${SESSION_STATUS_LABEL[s.status] ?? s.status}`,
timestamp: s.started_at,
})),
...journals.map((j) => ({
kind: "journal" as const,
title: j.title,
subtitle: j.task_id ? `Task ${j.task_id.slice(0, 8)} · ${j.type}` : j.type,
timestamp: j.timestamp,
})),
];
return items
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 8);
}
export function AgentActivityPanel({
agentSlug,
agentUuid,
}: AgentActivityPanelProps) {
const { data: series, isLoading: seriesLoading } = useUsageTimeSeries(
"7d",
agentSlug,
);
const { data: sessions, isLoading: sessionsLoading } = useWorkSessions(
agentUuid ? { agent_id: agentUuid } : undefined,
);
const { data: journals, isLoading: journalsLoading } = useAgentJournalEntries(
agentSlug,
{ limit: 5 },
);
const timeline = useMemo(
() => mergeTimeline(sessions ?? [], journals ?? []),
[sessions, journals],
);
const timelineLoading = sessionsLoading || journalsLoading;
const hasTokens = (series ?? []).some((p: UsageTimePoint) => p.total_tokens > 0);
return (
<div className="grid gap-4 md:grid-cols-2">
{/* Token activity sparkline */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Token Activity</CardTitle>
<CardDescription>Last 7 days</CardDescription>
</CardHeader>
<CardContent>
{seriesLoading ? (
<Skeleton className="h-20 w-full" />
) : hasTokens ? (
<ResponsiveContainer width="100%" height={80}>
<AreaChart
data={series}
margin={{ top: 4, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="agentSpark" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<RTooltip
formatter={(value) => [
fmtK(typeof value === "number" ? value : 0),
"Tokens",
]}
contentStyle={{ fontSize: 12 }}
labelFormatter={() => ""}
/>
<Area
type="monotone"
dataKey="total_tokens"
stroke="var(--chart-1)"
strokeWidth={2}
fill="url(#agentSpark)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-sm py-6 text-center">
No token usage in the last 7 days
</p>
)}
</CardContent>
</Card>
{/* Activity timeline */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Recent Activity</CardTitle>
<CardDescription>Work sessions &amp; journal entries</CardDescription>
</CardHeader>
<CardContent>
{timelineLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : timeline.length === 0 ? (
<p className="text-muted-foreground text-sm py-6 text-center">
No recent activity
</p>
) : (
<ol className="space-y-3">
{timeline.map((item, i) => (
<li key={`${item.kind}-${i}`} className="flex gap-3">
<div className="mt-0.5 shrink-0">
{item.kind === "session" ? (
<GitBranch className="h-4 w-4 text-muted-foreground" />
) : (
<BookOpen className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{item.title}</p>
<p className="text-muted-foreground text-xs truncate">
{item.subtitle}
</p>
</div>
<span className="text-muted-foreground text-xs whitespace-nowrap">
{formatDistanceToNow(new Date(item.timestamp), {
addSuffix: true,
})}
</span>
</li>
))}
</ol>
)}
</CardContent>
</Card>
</div>
);
}
+1
View File
@@ -5,5 +5,6 @@ export { AgentStatusCards } from "./agent-status-cards";
export { SpawnAgentDialog } from "./spawn-agent-dialog";
export { ResolveWaitDialog } from "./resolve-wait-dialog";
export { AgentStreamViewer } from "./stream-viewer";
export { AgentActivityPanel } from "./agent-activity-panel";
export { OrchestratorStatusCards } from "./orchestrator-status";
export { WaitingAgentsAlert } from "./waiting-agents-alert";