mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user