feat: add tool analytics functionality and UI components

- Extend ClaudeSession type to include optional billing source.
- Introduce ToolAnalyticsResponse type for API response structure.
- Implement getToolAnalytics function to fetch tool analytics data.
- Create RankedAnalyticsList component for displaying ranked items.
- Develop ToolAnalyticsPanel component to manage and display tool analytics.
- Add ToolFrequencyChart component to visualize tool call frequency.
- Implement loading and empty states for better user experience.
This commit is contained in:
null 2026-05-24 16:43:58 -05:00
parent a8e560a586
commit b782511ee9
5 changed files with 793 additions and 215 deletions

View File

@ -4,7 +4,7 @@ export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
ArrowUpRight,
@ -12,18 +12,32 @@ import {
Clock3,
Coins,
Filter,
LineChart,
MessagesSquare,
Search,
TerminalSquare,
} from "lucide-react";
import { useAuth } from "@/auth/clerk";
import {
type AnalyticsRangeDays,
ToolAnalyticsPanel,
} from "@/components/claude/ToolAnalyticsPanel";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { listClaudeSessions, type ClaudeSession } from "@/lib/api/claude-code";
import { formatRelativeTimestamp, formatTimestamp, truncateText } from "@/lib/formatters";
import {
formatRelativeTimestamp,
formatTimestamp,
truncateText,
} from "@/lib/formatters";
type ClaudeCodeTab = "sessions" | "analytics";
const ANALYTICS_DAYS: AnalyticsRangeDays[] = [7, 30, 90];
function formatCost(value: number) {
return new Intl.NumberFormat(undefined, {
@ -40,13 +54,22 @@ function sessionHref(session: ClaudeSession) {
export default function ClaudeCodePage() {
const { isSignedIn } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const [search, setSearch] = useState("");
const [activeOnly, setActiveOnly] = useState(false);
const selectedTab: ClaudeCodeTab =
searchParams.get("tab") === "analytics" ? "analytics" : "sessions";
const rawDays = Number(searchParams.get("days"));
const selectedDays: AnalyticsRangeDays = ANALYTICS_DAYS.includes(
rawDays as AnalyticsRangeDays,
)
? (rawDays as AnalyticsRangeDays)
: 30;
const sessionsQuery = useQuery({
queryKey: ["claude-code", "sessions", activeOnly],
queryFn: () => listClaudeSessions({ activeOnly, limit: 300 }),
enabled: Boolean(isSignedIn),
enabled: Boolean(isSignedIn && selectedTab === "sessions"),
refetchInterval: 30_000,
refetchOnMount: "always",
});
@ -79,6 +102,23 @@ export default function ClaudeCodePage() {
router.push(sessionHref(session));
};
const updateCommandCenterUrl = (
tab: ClaudeCodeTab,
days: AnalyticsRangeDays = selectedDays,
) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", tab);
if (tab === "analytics") {
params.set("days", String(days));
} else {
params.delete("days");
}
const query = params.toString();
router.replace(`/claude-code${query ? `?${query}` : ""}`, {
scroll: false,
});
};
return (
<DashboardPageLayout
signedOut={{
@ -89,232 +129,269 @@ export default function ClaudeCodePage() {
title="Claude Code"
description="Inspect local agent sessions, costs, tools, and conversation history."
headerActions={
<Button
type="button"
variant={activeOnly ? "primary" : "outline"}
onClick={() => setActiveOnly((value) => !value)}
>
<Filter className="h-4 w-4" />
Active only
</Button>
selectedTab === "sessions" ? (
<Button
type="button"
variant={activeOnly ? "primary" : "outline"}
onClick={() => setActiveOnly((value) => !value)}
>
<Filter className="h-4 w-4" />
Active only
</Button>
) : undefined
}
contentClassName="space-y-6"
>
<section className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
<div className="relative border-b border-[color:var(--border)] px-5 py-6 md:px-6">
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#8b5cf6,#22c55e)]" />
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<TerminalSquare className="h-5 w-5" />
</span>
<div>
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
Session command center
</h2>
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
Open a session to read the exact conversation, tool calls, and
thinking trail.
</p>
<Tabs
value={selectedTab}
onValueChange={(value) => {
if (value === "sessions" || value === "analytics") {
updateCommandCenterUrl(value);
}
}}
className="space-y-5"
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<TabsList aria-label="Claude Code views">
<TabsTrigger value="sessions">
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
Sessions
</TabsTrigger>
<TabsTrigger value="analytics">
<LineChart className="mr-2 h-3.5 w-3.5" />
Tool Analytics
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="sessions" className="mt-0">
<section className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
<div className="relative border-b border-[color:var(--border)] px-5 py-6 md:px-6">
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#8b5cf6,#22c55e)]" />
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<TerminalSquare className="h-5 w-5" />
</span>
<div>
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
Session command center
</h2>
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
Open a session to read the exact conversation, tool
calls, and thinking trail.
</p>
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-2 gap-3 md:grid-cols-4 xl:min-w-[640px]">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Sessions
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.session_count ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Active
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.active_sessions ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Tokens
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.total_tokens ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Spend
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{formatCost(stats?.total_cost_usd ?? 0)}
</p>
</div>
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-2 gap-3 md:grid-cols-4 xl:min-w-[640px]">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Sessions
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.session_count ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Active
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.active_sessions ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Tokens
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.total_tokens ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Spend
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{formatCost(stats?.total_cost_usd ?? 0)}
</p>
<div className="flex flex-col gap-3 border-b border-[color:var(--border)] px-5 py-4 md:flex-row md:items-center md:px-6">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search sessions, projects, models, branches..."
className="pl-10"
/>
</div>
<p className="text-sm text-[color:var(--text-muted)]">
Showing {filteredSessions.length.toLocaleString()} of{" "}
{sessions.length.toLocaleString()}
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 border-b border-[color:var(--border)] px-5 py-4 md:flex-row md:items-center md:px-6">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search sessions, projects, models, branches..."
className="pl-10"
/>
</div>
<p className="text-sm text-[color:var(--text-muted)]">
Showing {filteredSessions.length.toLocaleString()} of{" "}
{sessions.length.toLocaleString()}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[color:var(--border)]">
<thead className="bg-[color:var(--surface-muted)]">
<tr>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Session
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Model
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Usage
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Last active
</th>
<th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Open
</th>
</tr>
</thead>
<tbody className="divide-y divide-[color:var(--border)]">
{sessionsQuery.isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<tr key={index}>
<td colSpan={5} className="px-5 py-4">
<div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
</td>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[color:var(--border)]">
<thead className="bg-[color:var(--surface-muted)]">
<tr>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Session
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Model
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Usage
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Last active
</th>
<th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Open
</th>
</tr>
))
) : sessionsQuery.isError ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<Bot className="mx-auto h-10 w-10 text-rose-300" />
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
Claude Code sessions unavailable
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
The backend could not read local session data. Check the API
server and try again.
</p>
</td>
</tr>
) : filteredSessions.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
No Claude Code sessions found
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
Sessions appear here after Claude Code writes local JSONL history
under your configured projects directory.
</p>
</td>
</tr>
) : (
filteredSessions.map((session) => (
<tr
key={session.session_id}
tabIndex={0}
role="link"
className="cursor-pointer transition hover:bg-[color:var(--surface-muted)] focus-visible:bg-[color:var(--surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--accent)]"
onClick={() => openSession(session)}
onKeyDown={(event) => {
if (event.key === "Enter") openSession(session);
}}
>
<td className="max-w-[420px] px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<TerminalSquare className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="break-words text-sm font-semibold text-[color:var(--text)]">
{session.title || truncateText(session.session_id, 20)}
</p>
{session.is_active ? (
<Badge variant="success">Active</Badge>
) : null}
</div>
<p className="mt-1 break-words text-xs text-[color:var(--text-muted)]">
{session.cwd ?? session.project_dir}
</p>
</div>
</div>
</td>
<td className="px-5 py-4">
<div className="max-w-[220px] space-y-1">
{(session.models.length > 0
? session.models
: ["Unknown model"]
).map((model) => (
<span
key={model}
className="block truncate rounded-full bg-[color:var(--surface-muted)] px-2.5 py-1 font-mono text-xs text-[color:var(--text-muted)]"
>
{model}
</span>
))}
</div>
</td>
<td className="px-5 py-4">
<div className="space-y-1 text-sm">
<p className="flex items-center gap-2 font-semibold text-[color:var(--text)]">
<Coins className="h-4 w-4 text-[color:var(--text-muted)]" />
{formatCost(session.cost_usd)}
</thead>
<tbody className="divide-y divide-[color:var(--border)]">
{sessionsQuery.isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<tr key={index}>
<td colSpan={5} className="px-5 py-4">
<div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
</td>
</tr>
))
) : sessionsQuery.isError ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<Bot className="mx-auto h-10 w-10 text-rose-300" />
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
Claude Code sessions unavailable
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
The backend could not read local session data. Check
the API server and try again.
</p>
<p className="text-xs text-[color:var(--text-muted)]">
{session.tokens.total.toLocaleString()} tokens ·{" "}
{session.message_count.toLocaleString()} turns
</td>
</tr>
) : filteredSessions.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
No Claude Code sessions found
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
Sessions appear here after Claude Code writes local
JSONL history under your configured projects
directory.
</p>
</div>
</td>
<td className="px-5 py-4">
<p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]">
<Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" />
{formatRelativeTimestamp(session.last_message_at)}
</p>
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
{formatTimestamp(session.last_message_at)}
</p>
</td>
<td className="px-5 py-4 text-right">
<Link
href={sessionHref(session)}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-[color:var(--border)] text-[color:var(--text-muted)] transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
aria-label={`Open ${session.title ?? session.session_id}`}
onClick={(event) => event.stopPropagation()}
</td>
</tr>
) : (
filteredSessions.map((session) => (
<tr
key={session.session_id}
tabIndex={0}
role="link"
className="cursor-pointer transition hover:bg-[color:var(--surface-muted)] focus-visible:bg-[color:var(--surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--accent)]"
onClick={() => openSession(session)}
onKeyDown={(event) => {
if (event.key === "Enter") openSession(session);
}}
>
<ArrowUpRight className="h-4 w-4" />
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
<td className="max-w-[420px] px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<TerminalSquare className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="break-words text-sm font-semibold text-[color:var(--text)]">
{session.title ||
truncateText(session.session_id, 20)}
</p>
{session.is_active ? (
<Badge variant="success">Active</Badge>
) : null}
</div>
<p className="mt-1 break-words text-xs text-[color:var(--text-muted)]">
{session.cwd ?? session.project_dir}
</p>
</div>
</div>
</td>
<td className="px-5 py-4">
<div className="max-w-[220px] space-y-1">
{(session.models.length > 0
? session.models
: ["Unknown model"]
).map((model) => (
<span
key={model}
className="block truncate rounded-full bg-[color:var(--surface-muted)] px-2.5 py-1 font-mono text-xs text-[color:var(--text-muted)]"
>
{model}
</span>
))}
</div>
</td>
<td className="px-5 py-4">
<div className="space-y-1 text-sm">
<p className="flex items-center gap-2 font-semibold text-[color:var(--text)]">
<Coins className="h-4 w-4 text-[color:var(--text-muted)]" />
{formatCost(session.cost_usd)}
</p>
<p className="text-xs text-[color:var(--text-muted)]">
{session.tokens.total.toLocaleString()} tokens ·{" "}
{session.message_count.toLocaleString()} turns
</p>
</div>
</td>
<td className="px-5 py-4">
<p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]">
<Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" />
{formatRelativeTimestamp(session.last_message_at)}
</p>
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
{formatTimestamp(session.last_message_at)}
</p>
</td>
<td className="px-5 py-4 text-right">
<Link
href={sessionHref(session)}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-[color:var(--border)] text-[color:var(--text-muted)] transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
aria-label={`Open ${session.title ?? session.session_id}`}
onClick={(event) => event.stopPropagation()}
>
<ArrowUpRight className="h-4 w-4" />
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</TabsContent>
<TabsContent value="analytics" className="mt-0">
<ToolAnalyticsPanel
days={selectedDays}
enabled={Boolean(isSignedIn && selectedTab === "analytics")}
onDaysChange={(days) => updateCommandCenterUrl("analytics", days)}
/>
</TabsContent>
</Tabs>
</DashboardPageLayout>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Check, Clipboard, FileCode2, Terminal } from "lucide-react";
import { cn } from "@/lib/utils";
export type RankedAnalyticsItem = {
label: string;
count: number;
};
type RankedAnalyticsListProps = {
title: string;
items: RankedAnalyticsItem[];
kind: "file" | "command";
};
async function copyValue(value: string, onCopied: () => void) {
if (!value || typeof navigator === "undefined" || !navigator.clipboard)
return;
await navigator.clipboard.writeText(value);
onCopied();
}
export function RankedAnalyticsList({
title,
items,
kind,
}: RankedAnalyticsListProps) {
const [copied, setCopied] = useState<string | null>(null);
const Icon = kind === "command" ? Terminal : FileCode2;
const markCopied = (value: string) => {
setCopied(value);
window.setTimeout(() => setCopied(null), 1400);
};
return (
<section
className="min-w-0 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm"
aria-labelledby={`${title.toLowerCase().replace(/\s+/g, "-")}-heading`}
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="min-w-0">
<h3
id={`${title.toLowerCase().replace(/\s+/g, "-")}-heading`}
className="truncate text-sm font-semibold text-[color:var(--text)]"
>
{title}
</h3>
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
Top {items.length.toLocaleString()} by count
</p>
</div>
<span
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1",
kind === "command"
? "bg-amber-500/15 text-amber-300 ring-amber-400/20"
: "bg-violet-500/15 text-violet-300 ring-violet-400/20",
)}
>
<Icon className="h-4 w-4" />
</span>
</div>
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-[color:var(--border-strong)] p-5 text-sm text-[color:var(--text-muted)]">
No entries in this range.
</div>
) : (
<ol className="space-y-2">
{items.map((item, index) => (
<li
key={`${item.label}-${index}`}
className="group flex min-w-0 items-center gap-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] px-3 py-2.5 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-muted)]"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[color:var(--surface-muted)] text-xs font-bold text-[color:var(--text-muted)]">
{index + 1}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-xs text-[color:var(--text)]"
title={item.label}
>
{item.label}
</span>
<span className="shrink-0 rounded-full bg-[color:var(--accent-soft)] px-2 py-1 text-xs font-semibold text-[color:var(--accent-strong)]">
{item.count.toLocaleString()}
</span>
<button
type="button"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[color:var(--border)] text-[color:var(--text-muted)] opacity-100 transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
aria-label={`Copy ${item.label}`}
onClick={() =>
copyValue(item.label, () => markCopied(item.label))
}
>
{copied === item.label ? (
<Check className="h-3.5 w-3.5" />
) : (
<Clipboard className="h-3.5 w-3.5" />
)}
</button>
</li>
))}
</ol>
)}
</section>
);
}

View File

@ -0,0 +1,252 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
BarChart3,
Loader2,
RotateCw,
Wrench,
} from "lucide-react";
import {
RankedAnalyticsList,
type RankedAnalyticsItem,
} from "@/components/claude/RankedAnalyticsList";
import { ToolFrequencyChart } from "@/components/claude/ToolFrequencyChart";
import { Button } from "@/components/ui/button";
import { getToolAnalytics } from "@/lib/api/claude-code";
import { cn } from "@/lib/utils";
export type AnalyticsRangeDays = 7 | 30 | 90;
type ToolAnalyticsPanelProps = {
days: AnalyticsRangeDays;
onDaysChange: (days: AnalyticsRangeDays) => void;
enabled?: boolean;
};
const RANGE_OPTIONS: AnalyticsRangeDays[] = [7, 30, 90];
function totalToolCalls(toolCounts: Record<string, number>) {
return Object.values(toolCounts).reduce((total, count) => total + count, 0);
}
function mostUsedTool(toolCounts: Record<string, number>) {
const [tool, count] =
Object.entries(toolCounts).sort((a, b) => b[1] - a[1])[0] ?? [];
return tool && typeof count === "number" ? { tool, count } : null;
}
function LoadingSkeleton() {
return (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-24 animate-pulse rounded-2xl bg-[color:var(--surface-muted)]"
/>
))}
</div>
<div className="h-80 animate-pulse rounded-2xl bg-[color:var(--surface-muted)]" />
<div className="grid gap-4 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-72 animate-pulse rounded-2xl bg-[color:var(--surface-muted)]"
/>
))}
</div>
</div>
);
}
function EmptyState() {
return (
<div className="rounded-2xl border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface)] p-10 text-center">
<Wrench className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<h2 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
No tool analytics yet
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[color:var(--text-muted)]">
Tool analytics appear after local Claude Code sessions include assistant
tool calls in the selected date range.
</p>
</div>
);
}
export function ToolAnalyticsPanel({
days,
onDaysChange,
enabled = true,
}: ToolAnalyticsPanelProps) {
const analyticsQuery = useQuery({
queryKey: ["claude-code", "tool-analytics", days],
queryFn: () => getToolAnalytics({ days }),
enabled,
refetchOnMount: "always",
});
const analytics = analyticsQuery.data;
const totalCalls = analytics ? totalToolCalls(analytics.tool_counts) : 0;
const topTool = useMemo(
() => (analytics ? mostUsedTool(analytics.tool_counts) : null),
[analytics],
);
const topFilesRead: RankedAnalyticsItem[] =
analytics?.top_files_read.map((item) => ({
label: item.path,
count: item.count,
})) ?? [];
const topFilesWritten: RankedAnalyticsItem[] =
analytics?.top_files_written.map((item) => ({
label: item.path,
count: item.count,
})) ?? [];
const topCommands: RankedAnalyticsItem[] =
analytics?.top_commands.map((item) => ({
label: item.command,
count: item.count,
})) ?? [];
return (
<div className="space-y-5">
<div className="flex flex-col gap-4 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
Tool Analytics
</h2>
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
{analyticsQuery.isFetching && !analyticsQuery.isLoading
? "Refreshing selected range..."
: `${days.toLocaleString()} day operating window`}
</p>
</div>
<div
className="inline-flex w-full rounded-full border border-[color:var(--border)] bg-[color:var(--bg)] p-1 md:w-auto"
aria-label="Select analytics date range"
role="group"
>
{RANGE_OPTIONS.map((option) => (
<button
key={option}
type="button"
className={cn(
"min-h-9 flex-1 rounded-full px-4 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] md:flex-none",
days === option
? "bg-[color:var(--accent)] text-[color:var(--primary-foreground)] shadow-sm"
: "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-muted)] hover:text-[color:var(--text)]",
)}
aria-pressed={days === option}
onClick={() => onDaysChange(option)}
>
{option}d
</button>
))}
</div>
</div>
{analyticsQuery.isLoading ? <LoadingSkeleton /> : null}
{analyticsQuery.isError ? (
<div className="rounded-2xl border border-rose-400/30 bg-rose-950/20 p-8 text-center">
<AlertTriangle className="mx-auto h-10 w-10 text-rose-300" />
<h2 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
Analytics unavailable
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[color:var(--text-muted)]">
The backend could not aggregate tool analytics for this range.
</p>
<Button
type="button"
className="mt-6"
onClick={() => analyticsQuery.refetch()}
disabled={analyticsQuery.isFetching}
>
{analyticsQuery.isFetching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCw className="h-4 w-4" />
)}
Retry
</Button>
</div>
) : null}
{analytics && !analyticsQuery.isError && totalCalls === 0 ? (
<EmptyState />
) : null}
{analytics && totalCalls > 0 ? (
<>
<section
className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"
aria-label="Analytics summary"
>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Tool calls
</p>
<p className="mt-2 text-2xl font-semibold text-[color:var(--text)]">
{totalCalls.toLocaleString()}
</p>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Sessions scanned
</p>
<p className="mt-2 text-2xl font-semibold text-[color:var(--text)]">
{analytics.session_count.toLocaleString()}
</p>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Most-used tool
</p>
<p className="mt-2 truncate text-2xl font-semibold text-[color:var(--text)]">
{topTool ? topTool.tool : "None"}
</p>
{topTool ? (
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
{topTool.count.toLocaleString()} calls
</p>
) : null}
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Date range
</p>
<p className="mt-2 flex items-center gap-2 text-2xl font-semibold text-[color:var(--text)]">
<BarChart3 className="h-5 w-5 text-[color:var(--text-muted)]" />
{analytics.date_range_days}d
</p>
</div>
</section>
<ToolFrequencyChart toolCounts={analytics.tool_counts} />
<div className="grid min-w-0 gap-4 xl:grid-cols-3">
<RankedAnalyticsList
title="Top Files Read"
kind="file"
items={topFilesRead}
/>
<RankedAnalyticsList
title="Top Files Written"
kind="file"
items={topFilesWritten}
/>
<RankedAnalyticsList
title="Top Commands"
kind="command"
items={topCommands}
/>
</div>
</>
) : null}
</div>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import { Activity, Terminal } from "lucide-react";
import { cn } from "@/lib/utils";
type ToolFrequencyChartProps = {
toolCounts: Record<string, number>;
};
const TOOL_ACCENTS = [
"from-cyan-400 to-blue-500",
"from-violet-400 to-fuchsia-500",
"from-emerald-400 to-teal-500",
"from-amber-300 to-orange-500",
"from-rose-300 to-pink-500",
"from-sky-300 to-indigo-500",
];
export function ToolFrequencyChart({ toolCounts }: ToolFrequencyChartProps) {
const entries = Object.entries(toolCounts)
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
if (entries.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface)] p-8 text-center">
<Activity className="mx-auto h-9 w-9 text-[color:var(--text-muted)]" />
<h3 className="mt-4 text-base font-semibold text-[color:var(--text)]">
No tool calls in this range
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
Tool activity appears after Claude Code sessions include assistant
tool calls.
</p>
</div>
);
}
return (
<section
className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5 shadow-sm md:p-6"
aria-labelledby="tool-frequency-heading"
>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2
id="tool-frequency-heading"
className="font-heading text-lg font-semibold text-[color:var(--text)]"
>
Tool frequency
</h2>
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
Ranked by call count across the selected window.
</p>
</div>
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<Terminal className="h-4 w-4" />
</span>
</div>
<div className="space-y-3" role="list" aria-label="Tool call counts">
{entries.map(([toolName, count], index) => {
const width = `${Math.max((count / maxCount) * 100, 4)}%`;
return (
<div
key={toolName}
role="listitem"
className="grid gap-2 md:grid-cols-[160px_1fr_80px] md:items-center"
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[color:var(--surface-muted)] text-xs font-bold text-[color:var(--text-muted)]">
{index + 1}
</span>
<span className="truncate text-sm font-semibold text-[color:var(--text)]">
{toolName}
</span>
</div>
<div className="h-9 rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-1">
<div
className={cn(
"h-full rounded-lg bg-gradient-to-r transition-[width] duration-500 motion-reduce:transition-none",
TOOL_ACCENTS[index % TOOL_ACCENTS.length],
)}
style={{ width }}
aria-hidden="true"
/>
</div>
<div className="text-left text-sm font-semibold text-[color:var(--text)] md:text-right">
{count.toLocaleString()}
</div>
</div>
);
})}
</div>
</section>
);
}

View File

@ -24,6 +24,7 @@ export type ClaudeSession = {
models: string[];
tokens: ClaudeSessionTokens;
cost_usd: number;
billing_source?: string;
message_count: number;
first_message_at: string | null;
last_message_at: string | null;
@ -47,6 +48,25 @@ export type ClaudeSessionListResponse = {
stats: ClaudeSessionStats;
};
export type ToolAnalyticsFileEntry = {
path: string;
count: number;
};
export type ToolAnalyticsCommandEntry = {
command: string;
count: number;
};
export type ToolAnalyticsResponse = {
tool_counts: Record<string, number>;
top_files_read: ToolAnalyticsFileEntry[];
top_files_written: ToolAnalyticsFileEntry[];
top_commands: ToolAnalyticsCommandEntry[];
session_count: number;
date_range_days: number;
};
export type SessionTextBlock = {
text: string;
truncated: boolean;
@ -116,6 +136,25 @@ export async function listClaudeSessions({
return response.data;
}
export type GetToolAnalyticsParams = {
days?: 7 | 30 | 90 | number;
project?: string;
};
export async function getToolAnalytics({
days = 30,
project,
}: GetToolAnalyticsParams = {}): Promise<ToolAnalyticsResponse> {
const params = new URLSearchParams({ days: String(days) });
if (project?.trim()) params.set("project", project.trim());
const response = await customFetch<ApiResponse<ToolAnalyticsResponse>>(
`/api/v1/claude-code/analytics/tools?${params.toString()}`,
{ method: "GET" },
);
return response.data;
}
export async function getClaudeSession(
sessionId: string,
): Promise<ClaudeSession> {