refactor: migrate Claude Code components to Agent Sessions

- Updated session detail page to use agent session API and handle different sources.
- Refactored SessionHeroHeader to accept agent session data and provider label.
- Adjusted SessionMessageThread and SessionTimelineNav to utilize new agent session types.
- Modified ToolAnalyticsPanel and ToolFrequencyChart to reflect changes in session terminology.
- Created new agent-sessions API module to manage agent session data and analytics.
- Updated DashboardSidebar to rename Claude Code to Agent Sessions.
This commit is contained in:
null 2026-05-24 18:23:02 -05:00
parent fe6d9f219a
commit e26f3aa068
10 changed files with 843 additions and 289 deletions

View File

@ -7,6 +7,7 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
AlertTriangle,
ArrowUpRight, ArrowUpRight,
Bot, Bot,
Clock3, Clock3,
@ -28,16 +29,65 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { listClaudeSessions, type ClaudeSession } from "@/lib/api/claude-code"; import {
type AgentSession,
type AgentSessionSource,
type AgentSessionSourceCard,
listAgentSessions,
listAgentSessionSources,
} from "@/lib/api/agent-sessions";
import { import {
formatRelativeTimestamp, formatRelativeTimestamp,
formatTimestamp, formatTimestamp,
truncateText, truncateText,
} from "@/lib/formatters"; } from "@/lib/formatters";
import { cn } from "@/lib/utils";
type ClaudeCodeTab = "sessions" | "analytics"; type AgentSessionsTab = "sessions" | "analytics";
const ANALYTICS_DAYS: AnalyticsRangeDays[] = [7, 30, 90]; const ANALYTICS_DAYS: AnalyticsRangeDays[] = [7, 30, 90];
const SOURCE_ORDER: AgentSessionSource[] = [
"claude_code",
"codex_cli",
"openai_api",
];
const FALLBACK_SOURCES: AgentSessionSourceCard[] = [
{
source: "claude_code",
provider_label: "Claude Code",
source_status: "unavailable",
source_path: null,
session_count: 0,
last_activity_at: null,
last_scanned_at: null,
unavailable_reason: "Claude Code source status has not loaded yet.",
setup_hint: null,
},
{
source: "codex_cli",
provider_label: "Codex CLI",
source_status: "unavailable",
source_path: null,
session_count: 0,
last_activity_at: null,
last_scanned_at: null,
unavailable_reason: "Codex CLI source status has not loaded yet.",
setup_hint: null,
},
{
source: "openai_api",
provider_label: "OpenAI API",
source_status: "unavailable",
source_path: null,
session_count: 0,
last_activity_at: null,
last_scanned_at: null,
unavailable_reason:
"OpenAI API history needs an owned Pipeline event source.",
setup_hint: null,
},
];
function formatCost(value: number) { function formatCost(value: number) {
return new Intl.NumberFormat(undefined, { return new Intl.NumberFormat(undefined, {
@ -47,18 +97,128 @@ function formatCost(value: number) {
}).format(value); }).format(value);
} }
function sessionHref(session: ClaudeSession) { function normalizeSource(value: string | null): AgentSessionSource {
return `/claude-code/sessions/${encodeURIComponent(session.session_id)}`; if (value === "codex_cli" || value === "openai_api") return value;
return "claude_code";
} }
export default function ClaudeCodePage() { function providerLabel(
source: AgentSessionSource,
card?: AgentSessionSourceCard,
) {
if (card?.provider_label) return card.provider_label;
if (source === "codex_cli") return "Codex CLI";
if (source === "openai_api") return "OpenAI API";
return "Claude Code";
}
function sessionHref(session: AgentSession, source: AgentSessionSource) {
const params = new URLSearchParams({ source });
return `/claude-code/sessions/${encodeURIComponent(session.session_id)}?${params.toString()}`;
}
function sourceTone(source: AgentSessionSource) {
if (source === "codex_cli") return "emerald";
if (source === "openai_api") return "violet";
return "cyan";
}
function SourceIcon({ source }: { source: AgentSessionSource }) {
if (source === "openai_api") return <Bot className="h-4 w-4" />;
return <TerminalSquare className="h-4 w-4" />;
}
function SourceCard({
card,
selected,
onSelect,
}: {
card: AgentSessionSourceCard;
selected: boolean;
onSelect: () => void;
}) {
const available = card.source_status === "available";
const tone = sourceTone(card.source);
return (
<button
type="button"
className={cn(
"min-h-[124px] rounded-2xl border p-4 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]",
selected
? "border-[color:var(--accent)] bg-[color:var(--accent-soft)] shadow-sm"
: "border-[color:var(--border)] bg-[color:var(--surface)] hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-muted)]/45",
)}
onClick={onSelect}
aria-pressed={selected}
>
<div className="flex items-start justify-between gap-3">
<span
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl ring-1",
tone === "emerald" &&
"bg-emerald-500/15 text-emerald-300 ring-emerald-400/20",
tone === "violet" &&
"bg-violet-500/15 text-violet-300 ring-violet-400/20",
tone === "cyan" && "bg-cyan-500/15 text-cyan-300 ring-cyan-400/20",
)}
>
<SourceIcon source={card.source} />
</span>
<Badge variant={available ? "success" : "outline"}>
{available ? "Available" : "Unavailable"}
</Badge>
</div>
<h2 className="mt-3 text-sm font-semibold text-[color:var(--text)]">
{card.provider_label}
</h2>
<p className="mt-1 text-xs leading-5 text-[color:var(--text-muted)]">
{available
? `${card.session_count.toLocaleString()} sessions · ${formatRelativeTimestamp(card.last_activity_at)}`
: card.unavailable_reason}
</p>
{card.source_path ? (
<p className="mt-2 truncate font-mono text-[11px] text-[color:var(--text-quiet)]">
{card.source_path}
</p>
) : null}
</button>
);
}
function UnavailableSourcePanel({
card,
}: {
card: AgentSessionSourceCard | undefined;
}) {
return (
<div className="rounded-2xl border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface)] p-10 text-center">
<AlertTriangle className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<h2 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
{card?.provider_label ?? "Source"} is not available yet
</h2>
<p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-[color:var(--text-muted)]">
{card?.unavailable_reason ??
"This provider does not have readable local session history yet."}
</p>
{card?.setup_hint ? (
<p className="mx-auto mt-3 max-w-xl text-sm leading-6 text-[color:var(--text-muted)]">
{card.setup_hint}
</p>
) : null}
</div>
);
}
export default function AgentSessionsPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [activeOnly, setActiveOnly] = useState(false); const [activeOnly, setActiveOnly] = useState(false);
const selectedTab: ClaudeCodeTab = const selectedTab: AgentSessionsTab =
searchParams.get("tab") === "analytics" ? "analytics" : "sessions"; searchParams.get("tab") === "analytics" ? "analytics" : "sessions";
const selectedSource = normalizeSource(searchParams.get("source"));
const rawDays = Number(searchParams.get("days")); const rawDays = Number(searchParams.get("days"));
const selectedDays: AnalyticsRangeDays = ANALYTICS_DAYS.includes( const selectedDays: AnalyticsRangeDays = ANALYTICS_DAYS.includes(
rawDays as AnalyticsRangeDays, rawDays as AnalyticsRangeDays,
@ -66,10 +226,39 @@ export default function ClaudeCodePage() {
? (rawDays as AnalyticsRangeDays) ? (rawDays as AnalyticsRangeDays)
: 30; : 30;
const sourcesQuery = useQuery({
queryKey: ["agent-sessions", "sources"],
queryFn: listAgentSessionSources,
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
});
const sourceCards = useMemo(() => {
const cards = sourcesQuery.data?.sources ?? FALLBACK_SOURCES;
return [...cards].sort(
(left, right) =>
SOURCE_ORDER.indexOf(left.source) - SOURCE_ORDER.indexOf(right.source),
);
}, [sourcesQuery.data?.sources]);
const activeSourceCard = sourceCards.find(
(card) => card.source === selectedSource,
);
const selectedProviderLabel = providerLabel(selectedSource, activeSourceCard);
const sourceSupportsSessions = selectedSource !== "openai_api";
const sessionsQuery = useQuery({ const sessionsQuery = useQuery({
queryKey: ["claude-code", "sessions", activeOnly], queryKey: ["agent-sessions", selectedSource, "sessions", activeOnly],
queryFn: () => listClaudeSessions({ activeOnly, limit: 300 }), queryFn: () =>
enabled: Boolean(isSignedIn && selectedTab === "sessions"), listAgentSessions({
source: selectedSource,
activeOnly,
limit: 300,
}),
enabled: Boolean(
isSignedIn && selectedTab === "sessions" && sourceSupportsSessions,
),
refetchInterval: 30_000, refetchInterval: 30_000,
refetchOnMount: "always", refetchOnMount: "always",
}); });
@ -89,6 +278,7 @@ export default function ClaudeCodePage() {
session.project_dir, session.project_dir,
session.cwd, session.cwd,
session.git_branch, session.git_branch,
session.provider_label,
...session.models, ...session.models,
] ]
.filter(Boolean) .filter(Boolean)
@ -98,38 +288,41 @@ export default function ClaudeCodePage() {
}); });
}, [normalizedSearch, sessions]); }, [normalizedSearch, sessions]);
const openSession = (session: ClaudeSession) => { const openSession = (session: AgentSession) => {
router.push(sessionHref(session)); router.push(sessionHref(session, selectedSource));
}; };
const updateCommandCenterUrl = ( const updateCommandCenterUrl = ({
tab: ClaudeCodeTab, source = selectedSource,
days: AnalyticsRangeDays = selectedDays, tab = selectedTab,
) => { days = selectedDays,
}: {
source?: AgentSessionSource;
tab?: AgentSessionsTab;
days?: AnalyticsRangeDays;
}) => {
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set("source", source);
params.set("tab", tab); params.set("tab", tab);
if (tab === "analytics") { if (tab === "analytics") {
params.set("days", String(days)); params.set("days", String(days));
} else { } else {
params.delete("days"); params.delete("days");
} }
const query = params.toString(); router.replace(`/claude-code?${params.toString()}`, { scroll: false });
router.replace(`/claude-code${query ? `?${query}` : ""}`, {
scroll: false,
});
}; };
return ( return (
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to view local Claude Code sessions.", message: "Sign in to view local agent sessions.",
forceRedirectUrl: "/claude-code", forceRedirectUrl: "/claude-code",
signUpForceRedirectUrl: "/claude-code", signUpForceRedirectUrl: "/claude-code",
}} }}
title="Claude Code" title="Agent Sessions"
description="Inspect local agent sessions, costs, tools, and conversation history." description="Inspect local Claude, Codex, and future OpenAI agent traces from one operational view."
headerActions={ headerActions={
selectedTab === "sessions" ? ( selectedTab === "sessions" && sourceSupportsSessions ? (
<Button <Button
type="button" type="button"
variant={activeOnly ? "primary" : "outline"} variant={activeOnly ? "primary" : "outline"}
@ -142,17 +335,33 @@ export default function ClaudeCodePage() {
} }
contentClassName="space-y-6" contentClassName="space-y-6"
> >
<section
className="grid gap-3 md:grid-cols-3"
aria-label="Agent session sources"
>
{sourceCards.map((card) => (
<SourceCard
key={card.source}
card={card}
selected={card.source === selectedSource}
onSelect={() =>
updateCommandCenterUrl({ source: card.source, tab: "sessions" })
}
/>
))}
</section>
<Tabs <Tabs
value={selectedTab} value={selectedTab}
onValueChange={(value) => { onValueChange={(value) => {
if (value === "sessions" || value === "analytics") { if (value === "sessions" || value === "analytics") {
updateCommandCenterUrl(value); updateCommandCenterUrl({ tab: value });
} }
}} }}
className="space-y-5" className="space-y-5"
> >
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<TabsList aria-label="Claude Code views"> <TabsList aria-label="Agent session views">
<TabsTrigger value="sessions"> <TabsTrigger value="sessions">
<MessagesSquare className="mr-2 h-3.5 w-3.5" /> <MessagesSquare className="mr-2 h-3.5 w-3.5" />
Sessions Sessions
@ -162,234 +371,253 @@ export default function ClaudeCodePage() {
Tool Analytics Tool Analytics
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<p className="text-sm text-[color:var(--text-muted)]">
{selectedProviderLabel}
{activeSourceCard?.last_scanned_at
? ` · scanned ${formatRelativeTimestamp(activeSourceCard.last_scanned_at)}`
: ""}
</p>
</div> </div>
<TabsContent value="sessions" className="mt-0"> <TabsContent value="sessions" className="mt-0">
<section className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm"> {!sourceSupportsSessions ? (
<div className="relative border-b border-[color:var(--border)] px-5 py-6 md:px-6"> <UnavailableSourcePanel card={activeSourceCard} />
<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"> <section className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
<div className="min-w-0"> <div className="relative border-b border-[color:var(--border)] px-5 py-6 md:px-6">
<div className="flex items-center gap-3"> <div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#22c55e,#8b5cf6)]" />
<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"> <div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<TerminalSquare className="h-5 w-5" /> <div className="min-w-0">
</span> <div className="flex items-center gap-3">
<div> <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">
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]"> <TerminalSquare className="h-5 w-5" />
Session command center </span>
</h2> <div>
<p className="mt-1 text-sm text-[color:var(--text-muted)]"> <h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
Open a session to read the exact conversation, tool Trace Command Center
calls, and thinking trail. </h2>
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
Open a {selectedProviderLabel} session to inspect
conversation turns, tool calls, reasoning, and usage.
</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> </p>
</div> </div>
</div> </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="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="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3"> <div className="relative flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]"> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
Sessions <Input
</p> value={search}
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]"> onChange={(event) => setSearch(event.target.value)}
{(stats?.session_count ?? 0).toLocaleString()} placeholder="Search sessions, projects, models, branches..."
</p> className="pl-10"
</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>
<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="overflow-x-auto">
<div className="relative flex-1"> <table className="min-w-full divide-y divide-[color:var(--border)]">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" /> <thead className="bg-[color:var(--surface-muted)]">
<Input <tr>
value={search} <th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
onChange={(event) => setSearch(event.target.value)} Session
placeholder="Search sessions, projects, models, branches..." </th>
className="pl-10" <th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
/> Model
</div> </th>
<p className="text-sm text-[color:var(--text-muted)]"> <th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Showing {filteredSessions.length.toLocaleString()} of{" "} Usage
{sessions.length.toLocaleString()} </th>
</p> <th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
</div> Last active
</th>
<div className="overflow-x-auto"> <th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
<table className="min-w-full divide-y divide-[color:var(--border)]"> Open
<thead className="bg-[color:var(--surface-muted)]"> </th>
<tr> </tr>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]"> </thead>
Session <tbody className="divide-y divide-[color:var(--border)]">
</th> {sessionsQuery.isLoading ? (
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]"> Array.from({ length: 5 }).map((_, index) => (
Model <tr key={index}>
</th> <td colSpan={5} className="px-5 py-4">
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]"> <div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
Usage </td>
</th> </tr>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]"> ))
Last active ) : sessionsQuery.isError ? (
</th> <tr>
<th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]"> <td colSpan={5} className="px-5 py-16 text-center">
Open <AlertTriangle className="mx-auto h-10 w-10 text-rose-300" />
</th> <h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
</tr> {selectedProviderLabel} sessions unavailable
</thead> </h3>
<tbody className="divide-y divide-[color:var(--border)]"> <p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
{sessionsQuery.isLoading ? ( The backend could not read this source. Check the
Array.from({ length: 5 }).map((_, index) => ( API server and try again.
<tr key={index}> </p>
<td colSpan={5} className="px-5 py-4">
<div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
</td> </td>
</tr> </tr>
)) ) : filteredSessions.length === 0 ? (
) : sessionsQuery.isError ? ( <tr>
<tr> <td colSpan={5} className="px-5 py-16 text-center">
<td colSpan={5} className="px-5 py-16 text-center"> <MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<Bot className="mx-auto h-10 w-10 text-rose-300" /> <h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]"> No {selectedProviderLabel} sessions found
Claude Code sessions unavailable </h3>
</h3> <p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]"> {sessionsQuery.data?.unavailable_reason ??
The backend could not read local session data. Check activeSourceCard?.unavailable_reason ??
the API server and try again. "Sessions appear here after the local session source writes readable history."}
</p> </p>
</td> </td>
</tr> </tr>
) : filteredSessions.length === 0 ? ( ) : (
<tr> filteredSessions.map((session) => (
<td colSpan={5} className="px-5 py-16 text-center"> <tr
<MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" /> key={session.session_id}
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]"> tabIndex={0}
No Claude Code sessions found role="link"
</h3> 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)]"
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]"> onClick={() => openSession(session)}
Sessions appear here after Claude Code writes local onKeyDown={(event) => {
JSONL history under your configured projects if (event.key === "Enter") openSession(session);
directory. }}
</p> >
</td> <td className="max-w-[420px] px-5 py-4">
</tr> <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">
filteredSessions.map((session) => ( <TerminalSquare className="h-4 w-4" />
<tr </span>
key={session.session_id} <div className="min-w-0">
tabIndex={0} <div className="flex flex-wrap items-center gap-2">
role="link" <p className="break-words text-sm font-semibold text-[color:var(--text)]">
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)]" {session.title ||
onClick={() => openSession(session)} truncateText(session.session_id, 20)}
onKeyDown={(event) => { </p>
if (event.key === "Enter") openSession(session); {session.is_active ? (
}} <Badge variant="success">Active</Badge>
> ) : null}
<td className="max-w-[420px] px-5 py-4"> </div>
<div className="flex min-w-0 items-start gap-3"> <p className="mt-1 break-words text-xs text-[color:var(--text-muted)]">
<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"> {session.cwd ?? session.project_dir}
<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> </p>
{session.is_active ? (
<Badge variant="success">Active</Badge>
) : null}
</div> </div>
<p className="mt-1 break-words text-xs text-[color:var(--text-muted)]"> </div>
{session.cwd ?? session.project_dir} </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> </p>
</div> </div>
</div> </td>
</td> <td className="px-5 py-4">
<td className="px-5 py-4"> <p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]">
<div className="max-w-[220px] space-y-1"> <Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" />
{(session.models.length > 0 {formatRelativeTimestamp(session.last_message_at)}
? 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>
<p className="text-xs text-[color:var(--text-muted)]"> <p className="mt-1 text-xs text-[color:var(--text-muted)]">
{session.tokens.total.toLocaleString()} tokens ·{" "} {formatTimestamp(session.last_message_at)}
{session.message_count.toLocaleString()} turns
</p> </p>
</div> </td>
</td> <td className="px-5 py-4 text-right">
<td className="px-5 py-4"> <Link
<p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]"> href={sessionHref(session, selectedSource)}
<Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" /> 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)]"
{formatRelativeTimestamp(session.last_message_at)} aria-label={`Open ${session.title ?? session.session_id}`}
</p> onClick={(event) => event.stopPropagation()}
<p className="mt-1 text-xs text-[color:var(--text-muted)]"> >
{formatTimestamp(session.last_message_at)} <ArrowUpRight className="h-4 w-4" />
</p> </Link>
</td> </td>
<td className="px-5 py-4 text-right"> </tr>
<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)]" </tbody>
aria-label={`Open ${session.title ?? session.session_id}`} </table>
onClick={(event) => event.stopPropagation()} </div>
> </section>
<ArrowUpRight className="h-4 w-4" /> )}
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</TabsContent> </TabsContent>
<TabsContent value="analytics" className="mt-0"> <TabsContent value="analytics" className="mt-0">
<ToolAnalyticsPanel {selectedSource === "openai_api" ? (
days={selectedDays} <UnavailableSourcePanel card={activeSourceCard} />
enabled={Boolean(isSignedIn && selectedTab === "analytics")} ) : (
onDaysChange={(days) => updateCommandCenterUrl("analytics", days)} <ToolAnalyticsPanel
/> source={selectedSource}
providerLabel={selectedProviderLabel}
days={selectedDays}
enabled={Boolean(isSignedIn && selectedTab === "analytics")}
unavailableReason={activeSourceCard?.unavailable_reason}
onDaysChange={(days) =>
updateCommandCenterUrl({ tab: "analytics", days })
}
/>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</DashboardPageLayout> </DashboardPageLayout>

View File

@ -3,9 +3,14 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Loader2, MessagesSquare } from "lucide-react"; import {
AlertTriangle,
ArrowLeft,
Loader2,
MessagesSquare,
} from "lucide-react";
import { ApiError } from "@/api/mutator"; import { ApiError } from "@/api/mutator";
import { SessionHeroHeader } from "@/components/claude/SessionHeroHeader"; import { SessionHeroHeader } from "@/components/claude/SessionHeroHeader";
@ -14,10 +19,11 @@ import { SessionTimelineNav } from "@/components/claude/SessionTimelineNav";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
getClaudeSession, type AgentSessionSource,
getSessionMessages, getAgentSession,
getAgentSessionMessages,
type SessionMessage, type SessionMessage,
} from "@/lib/api/claude-code"; } from "@/lib/api/agent-sessions";
const PAGE_SIZE = 200; const PAGE_SIZE = 200;
@ -35,7 +41,13 @@ function LoadingState() {
); );
} }
function ErrorState({ isNotFound }: { isNotFound: boolean }) { function ErrorState({
isNotFound,
source,
}: {
isNotFound: boolean;
source: AgentSessionSource;
}) {
return ( return (
<div className="flex min-h-[520px] items-center justify-center px-4"> <div className="flex min-h-[520px] items-center justify-center px-4">
<div className="max-w-lg rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 text-center shadow-sm"> <div className="max-w-lg rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 text-center shadow-sm">
@ -47,13 +59,13 @@ function ErrorState({ isNotFound }: { isNotFound: boolean }) {
</h1> </h1>
<p className="mt-2 text-sm leading-6 text-[color:var(--text-muted)]"> <p className="mt-2 text-sm leading-6 text-[color:var(--text-muted)]">
{isNotFound {isNotFound
? "This Claude Code session could not be found in the local history." ? "This agent session could not be found in the local history."
: "The backend could not load this Claude Code session. Check the API server and try again."} : "The backend could not load this agent session. Check the API server and try again."}
</p> </p>
<Link href="/claude-code"> <Link href={`/claude-code?source=${encodeURIComponent(source)}`}>
<Button className="mt-6"> <Button className="mt-6">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to Claude Code Back to Agent Sessions
</Button> </Button>
</Link> </Link>
</div> </div>
@ -61,21 +73,36 @@ function ErrorState({ isNotFound }: { isNotFound: boolean }) {
); );
} }
function normalizeSource(value: string | null): AgentSessionSource {
if (value === "codex_cli" || value === "openai_api") return value;
return "claude_code";
}
function providerLabel(source: AgentSessionSource) {
if (source === "codex_cli") return "Codex CLI";
if (source === "openai_api") return "OpenAI API";
return "Claude Code";
}
export default function ClaudeSessionDetailPage() { export default function ClaudeSessionDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const searchParams = useSearchParams();
const sessionId = decodeURIComponent(params.id); const sessionId = decodeURIComponent(params.id);
const source = normalizeSource(searchParams.get("source"));
const label = providerLabel(source);
const sessionQuery = useQuery({ const sessionQuery = useQuery({
queryKey: ["claude-code", "session", sessionId], queryKey: ["agent-sessions", source, "session", sessionId],
queryFn: () => getClaudeSession(sessionId), queryFn: () => getAgentSession(source, sessionId),
enabled: Boolean(sessionId), enabled: Boolean(sessionId && source !== "openai_api"),
refetchOnMount: "always", refetchOnMount: "always",
}); });
const messagesQuery = useInfiniteQuery({ const messagesQuery = useInfiniteQuery({
queryKey: ["claude-code", "session", sessionId, "messages"], queryKey: ["agent-sessions", source, "session", sessionId, "messages"],
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
getSessionMessages({ getAgentSessionMessages({
source,
sessionId, sessionId,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset: pageParam, offset: pageParam,
@ -85,7 +112,7 @@ export default function ClaudeSessionDetailPage() {
if (!lastPage.has_more) return undefined; if (!lastPage.has_more) return undefined;
return pages.reduce((total, page) => total + page.messages.length, 0); return pages.reduce((total, page) => total + page.messages.length, 0);
}, },
enabled: Boolean(sessionId), enabled: Boolean(sessionId && source !== "openai_api"),
refetchOnMount: "always", refetchOnMount: "always",
}); });
@ -98,11 +125,11 @@ export default function ClaudeSessionDetailPage() {
return ( return (
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to view Claude Code session details.", message: "Sign in to view agent session details.",
forceRedirectUrl: `/claude-code/sessions/${encodeURIComponent(sessionId)}`, forceRedirectUrl: `/claude-code/sessions/${encodeURIComponent(sessionId)}?source=${encodeURIComponent(source)}`,
signUpForceRedirectUrl: `/claude-code/sessions/${encodeURIComponent(sessionId)}`, signUpForceRedirectUrl: `/claude-code/sessions/${encodeURIComponent(sessionId)}?source=${encodeURIComponent(source)}`,
}} }}
title="Claude Code session" title="Agent session"
description="Conversation trace, thinking blocks, and tool activity." description="Conversation trace, thinking blocks, and tool activity."
contentClassName="p-0 md:p-0" contentClassName="p-0 md:p-0"
headerClassName="sr-only" headerClassName="sr-only"
@ -112,10 +139,14 @@ export default function ClaudeSessionDetailPage() {
<LoadingState /> <LoadingState />
</div> </div>
) : sessionQuery.isError || messagesQuery.isError ? ( ) : sessionQuery.isError || messagesQuery.isError ? (
<ErrorState isNotFound={isNotFound} /> <ErrorState isNotFound={isNotFound} source={source} />
) : sessionQuery.data ? ( ) : sessionQuery.data ? (
<> <>
<SessionHeroHeader session={sessionQuery.data} /> <SessionHeroHeader
session={sessionQuery.data}
source={source}
providerLabel={label}
/>
<div className="px-4 py-6 md:px-8"> <div className="px-4 py-6 md:px-8">
<div className="mx-auto flex max-w-[1480px] items-start gap-6"> <div className="mx-auto flex max-w-[1480px] items-start gap-6">
<main className="min-w-0 flex-1"> <main className="min-w-0 flex-1">

View File

@ -12,11 +12,16 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { type ClaudeSession } from "@/lib/api/claude-code"; import {
type AgentSession,
type AgentSessionSource,
} from "@/lib/api/agent-sessions";
import { formatTimestamp, truncateText } from "@/lib/formatters"; import { formatTimestamp, truncateText } from "@/lib/formatters";
type SessionHeroHeaderProps = { type SessionHeroHeaderProps = {
session: ClaudeSession; session: AgentSession;
source: AgentSessionSource;
providerLabel: string;
}; };
function formatCost(value: number) { function formatCost(value: number) {
@ -27,9 +32,14 @@ function formatCost(value: number) {
}).format(value); }).format(value);
} }
export function SessionHeroHeader({ session }: SessionHeroHeaderProps) { export function SessionHeroHeader({
session,
source,
providerLabel,
}: SessionHeroHeaderProps) {
const title = session.title?.trim() || truncateText(session.session_id, 18); const title = session.title?.trim() || truncateText(session.session_id, 18);
const model = session.models[0] ?? "Model unavailable"; const model = session.models[0] ?? "Model unavailable";
const backHref = `/claude-code?source=${encodeURIComponent(source)}`;
return ( return (
<section className="relative overflow-hidden border-b border-[color:var(--border)] bg-[color:var(--surface)]"> <section className="relative overflow-hidden border-b border-[color:var(--border)] bg-[color:var(--surface)]">
@ -37,18 +47,24 @@ export function SessionHeroHeader({ session }: SessionHeroHeaderProps) {
<div className="px-4 py-5 md:px-8 md:py-7"> <div className="px-4 py-5 md:px-8 md:py-7">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<Link href="/claude-code"> <Link href={backHref}>
<Button variant="ghost" size="sm" className="mb-4 px-2"> <Button variant="ghost" size="sm" className="mb-4 px-2">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to Claude Code Back to Agent Sessions
</Button> </Button>
</Link> </Link>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="normal-case tracking-normal">
{providerLabel}
</Badge>
<Badge variant={session.is_active ? "success" : "outline"}> <Badge variant={session.is_active ? "success" : "outline"}>
{session.is_active ? "Active" : "Complete"} {session.is_active ? "Active" : "Complete"}
</Badge> </Badge>
{session.git_branch ? ( {session.git_branch ? (
<Badge variant="outline" className="normal-case tracking-normal"> <Badge
variant="outline"
className="normal-case tracking-normal"
>
<GitBranch className="mr-1 h-3 w-3" /> <GitBranch className="mr-1 h-3 w-3" />
{session.git_branch} {session.git_branch}
</Badge> </Badge>

View File

@ -12,7 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { ToolCallBlock } from "@/components/claude/ToolCallBlock"; import { ToolCallBlock } from "@/components/claude/ToolCallBlock";
import { type SessionMessage } from "@/lib/api/claude-code"; import { type SessionMessage } from "@/lib/api/agent-sessions";
import { formatTimestamp } from "@/lib/formatters"; import { formatTimestamp } from "@/lib/formatters";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -21,7 +21,8 @@ type SessionMessageThreadProps = {
}; };
async function copyText(value: string, onCopied: () => void) { async function copyText(value: string, onCopied: () => void) {
if (!value || typeof navigator === "undefined" || !navigator.clipboard) return; if (!value || typeof navigator === "undefined" || !navigator.clipboard)
return;
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
onCopied(); onCopied();
} }
@ -57,8 +58,8 @@ export function SessionMessageThread({ messages }: SessionMessageThreadProps) {
No conversation messages No conversation messages
</h2> </h2>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]"> <p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
This session was found, but there are no displayable user or assistant turns This session was found, but there are no displayable user or assistant
in the selected page. turns in the selected page.
</p> </p>
</div> </div>
); );
@ -115,7 +116,9 @@ export function SessionMessageThread({ messages }: SessionMessageThreadProps) {
</div> </div>
<p className="mt-0.5 text-xs text-[color:var(--text-muted)]"> <p className="mt-0.5 text-xs text-[color:var(--text-muted)]">
{formatTimestamp(message.timestamp)} {formatTimestamp(message.timestamp)}
{tokens !== null ? ` · ${tokens.toLocaleString()} tokens` : ""} {tokens !== null
? ` · ${tokens.toLocaleString()} tokens`
: ""}
</p> </p>
</div> </div>
</div> </div>
@ -155,7 +158,10 @@ export function SessionMessageThread({ messages }: SessionMessageThreadProps) {
Thinking Thinking
</span> </span>
<ChevronDown <ChevronDown
className={cn("h-4 w-4 transition", thinkingOpen && "rotate-180")} className={cn(
"h-4 w-4 transition",
thinkingOpen && "rotate-180",
)}
/> />
</button> </button>
{thinkingOpen ? ( {thinkingOpen ? (

View File

@ -2,7 +2,7 @@
import { Bot, Terminal, UserRound } from "lucide-react"; import { Bot, Terminal, UserRound } from "lucide-react";
import { type SessionMessage } from "@/lib/api/claude-code"; import { type SessionMessage } from "@/lib/api/agent-sessions";
import { formatTimestamp, truncateText } from "@/lib/formatters"; import { formatTimestamp, truncateText } from "@/lib/formatters";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -72,7 +72,8 @@ export function SessionTimelineNav({ messages }: SessionTimelineNavProps) {
})} })}
{messages.length > visibleMessages.length ? ( {messages.length > visibleMessages.length ? (
<p className="px-3 py-3 text-xs text-[color:var(--text-muted)]"> <p className="px-3 py-3 text-xs text-[color:var(--text-muted)]">
Showing first {visibleMessages.length.toLocaleString()} timeline items. Showing first {visibleMessages.length.toLocaleString()} timeline
items.
</p> </p>
) : null} ) : null}
</nav> </nav>

View File

@ -16,15 +16,21 @@ import {
} from "@/components/claude/RankedAnalyticsList"; } from "@/components/claude/RankedAnalyticsList";
import { ToolFrequencyChart } from "@/components/claude/ToolFrequencyChart"; import { ToolFrequencyChart } from "@/components/claude/ToolFrequencyChart";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getToolAnalytics } from "@/lib/api/claude-code"; import {
type AgentSessionSource,
getAgentToolAnalytics,
} from "@/lib/api/agent-sessions";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export type AnalyticsRangeDays = 7 | 30 | 90; export type AnalyticsRangeDays = 7 | 30 | 90;
type ToolAnalyticsPanelProps = { type ToolAnalyticsPanelProps = {
source: AgentSessionSource;
providerLabel: string;
days: AnalyticsRangeDays; days: AnalyticsRangeDays;
onDaysChange: (days: AnalyticsRangeDays) => void; onDaysChange: (days: AnalyticsRangeDays) => void;
enabled?: boolean; enabled?: boolean;
unavailableReason?: string | null;
}; };
const RANGE_OPTIONS: AnalyticsRangeDays[] = [7, 30, 90]; const RANGE_OPTIONS: AnalyticsRangeDays[] = [7, 30, 90];
@ -63,7 +69,7 @@ function LoadingSkeleton() {
); );
} }
function EmptyState() { function EmptyState({ providerLabel }: { providerLabel: string }) {
return ( return (
<div className="rounded-2xl border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface)] p-10 text-center"> <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)]" /> <Wrench className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
@ -71,21 +77,24 @@ function EmptyState() {
No tool analytics yet No tool analytics yet
</h2> </h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[color:var(--text-muted)]"> <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 analytics appear after local {providerLabel} sessions include
tool calls in the selected date range. assistant tool calls in the selected date range.
</p> </p>
</div> </div>
); );
} }
export function ToolAnalyticsPanel({ export function ToolAnalyticsPanel({
source,
providerLabel,
days, days,
onDaysChange, onDaysChange,
enabled = true, enabled = true,
unavailableReason,
}: ToolAnalyticsPanelProps) { }: ToolAnalyticsPanelProps) {
const analyticsQuery = useQuery({ const analyticsQuery = useQuery({
queryKey: ["claude-code", "tool-analytics", days], queryKey: ["agent-sessions", source, "tool-analytics", days],
queryFn: () => getToolAnalytics({ days }), queryFn: () => getAgentToolAnalytics({ source, days }),
enabled, enabled,
refetchOnMount: "always", refetchOnMount: "always",
}); });
@ -120,9 +129,11 @@ export function ToolAnalyticsPanel({
Tool Analytics Tool Analytics
</h2> </h2>
<p className="mt-1 text-sm text-[color:var(--text-muted)]"> <p className="mt-1 text-sm text-[color:var(--text-muted)]">
{analyticsQuery.isFetching && !analyticsQuery.isLoading {unavailableReason
? "Refreshing selected range..." ? unavailableReason
: `${days.toLocaleString()} day operating window`} : analyticsQuery.isFetching && !analyticsQuery.isLoading
? "Refreshing selected range..."
: `${providerLabel} · ${days.toLocaleString()} day operating window`}
</p> </p>
</div> </div>
<div <div
@ -177,7 +188,7 @@ export function ToolAnalyticsPanel({
) : null} ) : null}
{analytics && !analyticsQuery.isError && totalCalls === 0 ? ( {analytics && !analyticsQuery.isError && totalCalls === 0 ? (
<EmptyState /> <EmptyState providerLabel={providerLabel} />
) : null} ) : null}
{analytics && totalCalls > 0 ? ( {analytics && totalCalls > 0 ? (

View File

@ -10,7 +10,7 @@ import {
Wrench, Wrench,
} from "lucide-react"; } from "lucide-react";
import { type SessionToolUseBlock } from "@/lib/api/claude-code"; import { type SessionToolUseBlock } from "@/lib/api/agent-sessions";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type ToolCallBlockProps = { type ToolCallBlockProps = {
@ -38,7 +38,8 @@ function summarizeToolInput(tool: SessionToolUseBlock): string {
} }
async function copyToClipboard(value: string, onCopied: () => void) { async function copyToClipboard(value: string, onCopied: () => void) {
if (!value || typeof navigator === "undefined" || !navigator.clipboard) return; if (!value || typeof navigator === "undefined" || !navigator.clipboard)
return;
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
onCopied(); onCopied();
} }
@ -46,7 +47,10 @@ async function copyToClipboard(value: string, onCopied: () => void) {
export function ToolCallBlock({ tool }: ToolCallBlockProps) { export function ToolCallBlock({ tool }: ToolCallBlockProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [copied, setCopied] = useState<"input" | "result" | null>(null); const [copied, setCopied] = useState<"input" | "result" | null>(null);
const inputJson = useMemo(() => JSON.stringify(tool.input, null, 2), [tool.input]); const inputJson = useMemo(
() => JSON.stringify(tool.input, null, 2),
[tool.input],
);
const summary = summarizeToolInput(tool); const summary = summarizeToolInput(tool);
const markCopied = (kind: "input" | "result") => { const markCopied = (kind: "input" | "result") => {
@ -58,9 +62,7 @@ export function ToolCallBlock({ tool }: ToolCallBlockProps) {
<div <div
className={cn( className={cn(
"overflow-hidden rounded-xl border bg-[color:var(--surface)] shadow-sm", "overflow-hidden rounded-xl border bg-[color:var(--surface)] shadow-sm",
tool.is_error tool.is_error ? "border-rose-400/30" : "border-[color:var(--border)]",
? "border-rose-400/30"
: "border-[color:var(--border)]",
)} )}
> >
<button <button
@ -112,7 +114,9 @@ export function ToolCallBlock({ tool }: ToolCallBlockProps) {
<button <button
type="button" type="button"
className="inline-flex h-8 items-center gap-2 rounded-lg border border-[color:var(--border)] px-2.5 text-xs font-semibold 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)]" className="inline-flex h-8 items-center gap-2 rounded-lg border border-[color:var(--border)] px-2.5 text-xs font-semibold 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)]"
onClick={() => copyToClipboard(inputJson, () => markCopied("input"))} onClick={() =>
copyToClipboard(inputJson, () => markCopied("input"))
}
> >
{copied === "input" ? ( {copied === "input" ? (
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
@ -138,7 +142,9 @@ export function ToolCallBlock({ tool }: ToolCallBlockProps) {
type="button" type="button"
className="inline-flex h-8 items-center gap-2 rounded-lg border border-[color:var(--border)] px-2.5 text-xs font-semibold 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)]" className="inline-flex h-8 items-center gap-2 rounded-lg border border-[color:var(--border)] px-2.5 text-xs font-semibold 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)]"
onClick={() => onClick={() =>
copyToClipboard(tool.result ?? "", () => markCopied("result")) copyToClipboard(tool.result ?? "", () =>
markCopied("result"),
)
} }
> >
{copied === "result" ? ( {copied === "result" ? (

View File

@ -31,8 +31,8 @@ export function ToolFrequencyChart({ toolCounts }: ToolFrequencyChartProps) {
No tool calls in this range No tool calls in this range
</h3> </h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]"> <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 activity appears after agent sessions include assistant tool
tool calls. calls.
</p> </p>
</div> </div>
); );

View File

@ -227,7 +227,7 @@ export function DashboardSidebar() {
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-1.5">
<NavItem <NavItem
href="/claude-code" href="/claude-code"
label="Claude Code" label="Agent Sessions"
icon={<TerminalSquare className="h-4 w-4" />} icon={<TerminalSquare className="h-4 w-4" />}
tone="cyan" tone="cyan"
active={isActive("/claude-code")} active={isActive("/claude-code")}
@ -308,7 +308,8 @@ export function DashboardSidebar() {
<span <span
className={cn( className={cn(
"h-2.5 w-2.5 rounded-full shadow-[0_0_18px_currentColor]", "h-2.5 w-2.5 rounded-full shadow-[0_0_18px_currentColor]",
systemStatus === "operational" && "bg-emerald-500 text-emerald-500", systemStatus === "operational" &&
"bg-emerald-500 text-emerald-500",
systemStatus === "degraded" && "bg-rose-500 text-rose-500", systemStatus === "degraded" && "bg-rose-500 text-rose-500",
systemStatus === "unknown" && systemStatus === "unknown" &&
"bg-[color:var(--text-quiet)] text-[color:var(--text-quiet)]", "bg-[color:var(--text-quiet)] text-[color:var(--text-quiet)]",

View File

@ -0,0 +1,254 @@
"use client";
import { customFetch } from "@/api/mutator";
type ApiResponse<T> = {
data: T;
status: number;
headers: Headers;
};
export type AgentSessionSource = "claude_code" | "codex_cli" | "openai_api";
export type AgentSourceStatus = "available" | "unavailable" | "unsupported";
export type AgentSessionTokens = {
input: number;
output: number;
cache_read: number;
cache_write: number;
total: number;
};
export type AgentSession = {
session_id: string;
source?: AgentSessionSource;
provider_label?: string;
project_dir: string;
cwd: string | null;
title: string | null;
models: string[];
tokens: AgentSessionTokens;
cost_usd: number;
billing_source?: string;
message_count: number;
first_message_at: string | null;
last_message_at: string | null;
is_active: boolean;
entrypoints: string[];
git_branch: string | null;
version: string | null;
};
export type AgentSessionStats = {
session_count: number;
active_sessions: number;
total_tokens: number;
total_cost_usd: number;
models: string[];
};
export type AgentSessionListResponse = {
sessions: AgentSession[];
total: number;
stats: AgentSessionStats;
source?: AgentSessionSource;
provider_label?: string;
source_status?: AgentSourceStatus;
source_path?: string | null;
last_scanned_at?: string | null;
unavailable_reason?: string | null;
setup_hint?: string | null;
};
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;
source?: AgentSessionSource | null;
source_status?: AgentSourceStatus | null;
source_path?: string | null;
last_scanned_at?: string | null;
};
export type SessionTextBlock = {
text: string;
truncated: boolean;
};
export type SessionThinkingBlock = {
text: string;
truncated: boolean;
};
export type SessionToolUseBlock = {
tool_use_id: string;
tool_name: string;
input: Record<string, unknown>;
input_truncated: boolean;
result: string | null;
result_truncated: boolean;
is_error: boolean;
};
export type SessionTokenUsage = {
input: number;
output: number;
cache_read: number;
cache_write: number;
};
export type SessionMessage = {
uuid: string;
role: "user" | "assistant" | string;
timestamp: string | null;
text_blocks: SessionTextBlock[];
thinking_blocks: SessionThinkingBlock[];
tool_uses: SessionToolUseBlock[];
model: string | null;
tokens: SessionTokenUsage | null;
};
export type SessionMessagesResponse = {
session_id: string;
source?: AgentSessionSource;
source_status?: AgentSourceStatus;
source_path?: string | null;
last_scanned_at?: string | null;
messages: SessionMessage[];
total: number;
has_more: boolean;
};
export type AgentSessionSourceCard = {
source: AgentSessionSource;
provider_label: string;
source_status: AgentSourceStatus;
source_path: string | null;
session_count: number;
last_activity_at: string | null;
last_scanned_at: string | null;
unavailable_reason: string | null;
setup_hint: string | null;
};
export type AgentSessionSourcesResponse = {
sources: AgentSessionSourceCard[];
last_scanned_at: string;
};
export type ListAgentSessionsParams = {
source: AgentSessionSource;
project?: string;
activeOnly?: boolean;
limit?: number;
};
export type GetToolAnalyticsParams = {
source: AgentSessionSource;
days?: 7 | 30 | 90 | number;
project?: string;
};
export type GetSessionMessagesParams = {
source: AgentSessionSource;
sessionId: string;
limit?: number;
offset?: number;
};
function sourcePath(source: AgentSessionSource) {
if (source === "codex_cli") return "codex";
if (source === "claude_code") return "claude-code";
return null;
}
function supportedSourcePath(source: AgentSessionSource) {
const path = sourcePath(source);
if (!path) {
throw new Error("OpenAI API session history is not available yet.");
}
return path;
}
export async function listAgentSessionSources(): Promise<AgentSessionSourcesResponse> {
const response = await customFetch<ApiResponse<AgentSessionSourcesResponse>>(
"/api/v1/agent-sessions/sources",
{ method: "GET" },
);
return response.data;
}
export async function listAgentSessions({
source,
project,
activeOnly = false,
limit = 100,
}: ListAgentSessionsParams): Promise<AgentSessionListResponse> {
const params = new URLSearchParams();
if (project?.trim()) params.set("project", project.trim());
if (activeOnly) params.set("active_only", "true");
params.set("limit", String(limit));
const query = params.toString();
const response = await customFetch<ApiResponse<AgentSessionListResponse>>(
`/api/v1/${supportedSourcePath(source)}/sessions${query ? `?${query}` : ""}`,
{ method: "GET" },
);
return response.data;
}
export async function getAgentToolAnalytics({
source,
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/${supportedSourcePath(source)}/analytics/tools?${params.toString()}`,
{ method: "GET" },
);
return response.data;
}
export async function getAgentSession(
source: AgentSessionSource,
sessionId: string,
): Promise<AgentSession> {
const response = await customFetch<ApiResponse<AgentSession>>(
`/api/v1/${supportedSourcePath(source)}/sessions/${encodeURIComponent(sessionId)}`,
{ method: "GET" },
);
return response.data;
}
export async function getAgentSessionMessages({
source,
sessionId,
limit = 200,
offset = 0,
}: GetSessionMessagesParams): Promise<SessionMessagesResponse> {
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
const response = await customFetch<ApiResponse<SessionMessagesResponse>>(
`/api/v1/${supportedSourcePath(source)}/sessions/${encodeURIComponent(sessionId)}/messages?${params.toString()}`,
{ method: "GET" },
);
return response.data;
}