From b782511ee9a5d55d4797c5db48997bdf8e6e6982 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 24 May 2026 16:43:58 -0500 Subject: [PATCH] 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. --- frontend/src/app/claude-code/page.tsx | 507 ++++++++++-------- .../components/claude/RankedAnalyticsList.tsx | 111 ++++ .../components/claude/ToolAnalyticsPanel.tsx | 252 +++++++++ .../components/claude/ToolFrequencyChart.tsx | 99 ++++ frontend/src/lib/api/claude-code.ts | 39 ++ 5 files changed, 793 insertions(+), 215 deletions(-) create mode 100644 frontend/src/components/claude/RankedAnalyticsList.tsx create mode 100644 frontend/src/components/claude/ToolAnalyticsPanel.tsx create mode 100644 frontend/src/components/claude/ToolFrequencyChart.tsx diff --git a/frontend/src/app/claude-code/page.tsx b/frontend/src/app/claude-code/page.tsx index 0f35ef9..83443be 100644 --- a/frontend/src/app/claude-code/page.tsx +++ b/frontend/src/app/claude-code/page.tsx @@ -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 ( setActiveOnly((value) => !value)} - > - - Active only - + selectedTab === "sessions" ? ( + + ) : undefined } contentClassName="space-y-6" > -
-
-
-
-
-
- - - -
-

- Session command center -

-

- Open a session to read the exact conversation, tool calls, and - thinking trail. -

+ { + if (value === "sessions" || value === "analytics") { + updateCommandCenterUrl(value); + } + }} + className="space-y-5" + > +
+ + + + Sessions + + + + Tool Analytics + + +
+ + +
+
+
+
+
+
+ + + +
+

+ Session command center +

+

+ Open a session to read the exact conversation, tool + calls, and thinking trail. +

+
+
+
+ +
+
+

+ Sessions +

+

+ {(stats?.session_count ?? 0).toLocaleString()} +

+
+
+

+ Active +

+

+ {(stats?.active_sessions ?? 0).toLocaleString()} +

+
+
+

+ Tokens +

+

+ {(stats?.total_tokens ?? 0).toLocaleString()} +

+
+
+

+ Spend +

+

+ {formatCost(stats?.total_cost_usd ?? 0)} +

+
-
-
-

- Sessions -

-

- {(stats?.session_count ?? 0).toLocaleString()} -

-
-
-

- Active -

-

- {(stats?.active_sessions ?? 0).toLocaleString()} -

-
-
-

- Tokens -

-

- {(stats?.total_tokens ?? 0).toLocaleString()} -

-
-
-

- Spend -

-

- {formatCost(stats?.total_cost_usd ?? 0)} -

+
+
+ + setSearch(event.target.value)} + placeholder="Search sessions, projects, models, branches..." + className="pl-10" + />
+

+ Showing {filteredSessions.length.toLocaleString()} of{" "} + {sessions.length.toLocaleString()} +

-
-
-
-
- - setSearch(event.target.value)} - placeholder="Search sessions, projects, models, branches..." - className="pl-10" - /> -
-

- Showing {filteredSessions.length.toLocaleString()} of{" "} - {sessions.length.toLocaleString()} -

-
- -
- - - - - - - - - - - - {sessionsQuery.isLoading ? ( - Array.from({ length: 5 }).map((_, index) => ( - - +
+
- Session - - Model - - Usage - - Last active - - Open -
-
-
+ + + + + + + - )) - ) : sessionsQuery.isError ? ( - - - - ) : filteredSessions.length === 0 ? ( - - - - ) : ( - filteredSessions.map((session) => ( - openSession(session)} - onKeyDown={(event) => { - if (event.key === "Enter") openSession(session); - }} - > - - - + + {sessionsQuery.isLoading ? ( + Array.from({ length: 5 }).map((_, index) => ( + + + + )) + ) : sessionsQuery.isError ? ( + + + + ) : filteredSessions.length === 0 ? ( + + - - + + ) : ( + filteredSessions.map((session) => ( + openSession(session)} + onKeyDown={(event) => { + if (event.key === "Enter") openSession(session); + }} > - - - - - )) - )} - -
+ Session + + Model + + Usage + + Last active + + Open +
- -

- Claude Code sessions unavailable -

-

- The backend could not read local session data. Check the API - server and try again. -

-
- -

- No Claude Code sessions found -

-

- Sessions appear here after Claude Code writes local JSONL history - under your configured projects directory. -

-
-
- - - -
-
-

- {session.title || truncateText(session.session_id, 20)} -

- {session.is_active ? ( - Active - ) : null} -
-

- {session.cwd ?? session.project_dir} -

-
-
-
-
- {(session.models.length > 0 - ? session.models - : ["Unknown model"] - ).map((model) => ( - - {model} - - ))} -
-
-
-

- - {formatCost(session.cost_usd)} +

+
+
+ +

+ Claude Code sessions unavailable +

+

+ The backend could not read local session data. Check + the API server and try again.

-

- {session.tokens.total.toLocaleString()} tokens ·{" "} - {session.message_count.toLocaleString()} turns +

+ +

+ No Claude Code sessions found +

+

+ Sessions appear here after Claude Code writes local + JSONL history under your configured projects + directory.

- -
-

- - {formatRelativeTimestamp(session.last_message_at)} -

-

- {formatTimestamp(session.last_message_at)} -

-
- event.stopPropagation()} +
-
-
+ +
+ + + +
+
+

+ {session.title || + truncateText(session.session_id, 20)} +

+ {session.is_active ? ( + Active + ) : null} +
+

+ {session.cwd ?? session.project_dir} +

+
+
+ + +
+ {(session.models.length > 0 + ? session.models + : ["Unknown model"] + ).map((model) => ( + + {model} + + ))} +
+ + +
+

+ + {formatCost(session.cost_usd)} +

+

+ {session.tokens.total.toLocaleString()} tokens ·{" "} + {session.message_count.toLocaleString()} turns +

+
+ + +

+ + {formatRelativeTimestamp(session.last_message_at)} +

+

+ {formatTimestamp(session.last_message_at)} +

+ + + event.stopPropagation()} + > + + + + + )) + )} + + +
+
+ + + + updateCommandCenterUrl("analytics", days)} + /> + +
); } diff --git a/frontend/src/components/claude/RankedAnalyticsList.tsx b/frontend/src/components/claude/RankedAnalyticsList.tsx new file mode 100644 index 0000000..2d5d182 --- /dev/null +++ b/frontend/src/components/claude/RankedAnalyticsList.tsx @@ -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(null); + const Icon = kind === "command" ? Terminal : FileCode2; + + const markCopied = (value: string) => { + setCopied(value); + window.setTimeout(() => setCopied(null), 1400); + }; + + return ( +
+
+
+

+ {title} +

+

+ Top {items.length.toLocaleString()} by count +

+
+ + + +
+ + {items.length === 0 ? ( +
+ No entries in this range. +
+ ) : ( +
    + {items.map((item, index) => ( +
  1. + + {index + 1} + + + {item.label} + + + {item.count.toLocaleString()} + + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/claude/ToolAnalyticsPanel.tsx b/frontend/src/components/claude/ToolAnalyticsPanel.tsx new file mode 100644 index 0000000..9e013ec --- /dev/null +++ b/frontend/src/components/claude/ToolAnalyticsPanel.tsx @@ -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) { + return Object.values(toolCounts).reduce((total, count) => total + count, 0); +} + +function mostUsedTool(toolCounts: Record) { + 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 ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+
+ ); +} + +function EmptyState() { + return ( +
+ +

+ No tool analytics yet +

+

+ Tool analytics appear after local Claude Code sessions include assistant + tool calls in the selected date range. +

+
+ ); +} + +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 ( +
+
+
+

+ Tool Analytics +

+

+ {analyticsQuery.isFetching && !analyticsQuery.isLoading + ? "Refreshing selected range..." + : `${days.toLocaleString()} day operating window`} +

+
+
+ {RANGE_OPTIONS.map((option) => ( + + ))} +
+
+ + {analyticsQuery.isLoading ? : null} + + {analyticsQuery.isError ? ( +
+ +

+ Analytics unavailable +

+

+ The backend could not aggregate tool analytics for this range. +

+ +
+ ) : null} + + {analytics && !analyticsQuery.isError && totalCalls === 0 ? ( + + ) : null} + + {analytics && totalCalls > 0 ? ( + <> +
+
+

+ Tool calls +

+

+ {totalCalls.toLocaleString()} +

+
+
+

+ Sessions scanned +

+

+ {analytics.session_count.toLocaleString()} +

+
+
+

+ Most-used tool +

+

+ {topTool ? topTool.tool : "None"} +

+ {topTool ? ( +

+ {topTool.count.toLocaleString()} calls +

+ ) : null} +
+
+

+ Date range +

+

+ + {analytics.date_range_days}d +

+
+
+ + + +
+ + + +
+ + ) : null} +
+ ); +} diff --git a/frontend/src/components/claude/ToolFrequencyChart.tsx b/frontend/src/components/claude/ToolFrequencyChart.tsx new file mode 100644 index 0000000..0bec344 --- /dev/null +++ b/frontend/src/components/claude/ToolFrequencyChart.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Activity, Terminal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +type ToolFrequencyChartProps = { + toolCounts: Record; +}; + +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 ( +
+ +

+ No tool calls in this range +

+

+ Tool activity appears after Claude Code sessions include assistant + tool calls. +

+
+ ); + } + + return ( +
+
+
+

+ Tool frequency +

+

+ Ranked by call count across the selected window. +

+
+ + + +
+ +
+ {entries.map(([toolName, count], index) => { + const width = `${Math.max((count / maxCount) * 100, 4)}%`; + return ( +
+
+ + {index + 1} + + + {toolName} + +
+
+ +
+ {count.toLocaleString()} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/lib/api/claude-code.ts b/frontend/src/lib/api/claude-code.ts index a9f3faa..2aa5578 100644 --- a/frontend/src/lib/api/claude-code.ts +++ b/frontend/src/lib/api/claude-code.ts @@ -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; + 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 { + const params = new URLSearchParams({ days: String(days) }); + if (project?.trim()) params.set("project", project.trim()); + + const response = await customFetch>( + `/api/v1/claude-code/analytics/tools?${params.toString()}`, + { method: "GET" }, + ); + return response.data; +} + export async function getClaudeSession( sessionId: string, ): Promise {