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 { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowUpRight,
Bot,
Clock3,
@ -28,16 +29,65 @@ 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 {
type AgentSession,
type AgentSessionSource,
type AgentSessionSourceCard,
listAgentSessions,
listAgentSessionSources,
} from "@/lib/api/agent-sessions";
import {
formatRelativeTimestamp,
formatTimestamp,
truncateText,
} from "@/lib/formatters";
import { cn } from "@/lib/utils";
type ClaudeCodeTab = "sessions" | "analytics";
type AgentSessionsTab = "sessions" | "analytics";
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) {
return new Intl.NumberFormat(undefined, {
@ -47,18 +97,128 @@ function formatCost(value: number) {
}).format(value);
}
function sessionHref(session: ClaudeSession) {
return `/claude-code/sessions/${encodeURIComponent(session.session_id)}`;
function normalizeSource(value: string | null): AgentSessionSource {
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 router = useRouter();
const searchParams = useSearchParams();
const [search, setSearch] = useState("");
const [activeOnly, setActiveOnly] = useState(false);
const selectedTab: ClaudeCodeTab =
const selectedTab: AgentSessionsTab =
searchParams.get("tab") === "analytics" ? "analytics" : "sessions";
const selectedSource = normalizeSource(searchParams.get("source"));
const rawDays = Number(searchParams.get("days"));
const selectedDays: AnalyticsRangeDays = ANALYTICS_DAYS.includes(
rawDays as AnalyticsRangeDays,
@ -66,10 +226,39 @@ export default function ClaudeCodePage() {
? (rawDays as AnalyticsRangeDays)
: 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({
queryKey: ["claude-code", "sessions", activeOnly],
queryFn: () => listClaudeSessions({ activeOnly, limit: 300 }),
enabled: Boolean(isSignedIn && selectedTab === "sessions"),
queryKey: ["agent-sessions", selectedSource, "sessions", activeOnly],
queryFn: () =>
listAgentSessions({
source: selectedSource,
activeOnly,
limit: 300,
}),
enabled: Boolean(
isSignedIn && selectedTab === "sessions" && sourceSupportsSessions,
),
refetchInterval: 30_000,
refetchOnMount: "always",
});
@ -89,6 +278,7 @@ export default function ClaudeCodePage() {
session.project_dir,
session.cwd,
session.git_branch,
session.provider_label,
...session.models,
]
.filter(Boolean)
@ -98,38 +288,41 @@ export default function ClaudeCodePage() {
});
}, [normalizedSearch, sessions]);
const openSession = (session: ClaudeSession) => {
router.push(sessionHref(session));
const openSession = (session: AgentSession) => {
router.push(sessionHref(session, selectedSource));
};
const updateCommandCenterUrl = (
tab: ClaudeCodeTab,
days: AnalyticsRangeDays = selectedDays,
) => {
const updateCommandCenterUrl = ({
source = selectedSource,
tab = selectedTab,
days = selectedDays,
}: {
source?: AgentSessionSource;
tab?: AgentSessionsTab;
days?: AnalyticsRangeDays;
}) => {
const params = new URLSearchParams(searchParams.toString());
params.set("source", source);
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,
});
router.replace(`/claude-code?${params.toString()}`, { scroll: false });
};
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to view local Claude Code sessions.",
message: "Sign in to view local agent sessions.",
forceRedirectUrl: "/claude-code",
signUpForceRedirectUrl: "/claude-code",
}}
title="Claude Code"
description="Inspect local agent sessions, costs, tools, and conversation history."
title="Agent Sessions"
description="Inspect local Claude, Codex, and future OpenAI agent traces from one operational view."
headerActions={
selectedTab === "sessions" ? (
selectedTab === "sessions" && sourceSupportsSessions ? (
<Button
type="button"
variant={activeOnly ? "primary" : "outline"}
@ -142,17 +335,33 @@ export default function ClaudeCodePage() {
}
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
value={selectedTab}
onValueChange={(value) => {
if (value === "sessions" || value === "analytics") {
updateCommandCenterUrl(value);
updateCommandCenterUrl({ tab: 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">
<TabsList aria-label="Agent session views">
<TabsTrigger value="sessions">
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
Sessions
@ -162,12 +371,21 @@ export default function ClaudeCodePage() {
Tool Analytics
</TabsTrigger>
</TabsList>
<p className="text-sm text-[color:var(--text-muted)]">
{selectedProviderLabel}
{activeSourceCard?.last_scanned_at
? ` · scanned ${formatRelativeTimestamp(activeSourceCard.last_scanned_at)}`
: ""}
</p>
</div>
<TabsContent value="sessions" className="mt-0">
{!sourceSupportsSessions ? (
<UnavailableSourcePanel card={activeSourceCard} />
) : (
<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="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#22c55e,#8b5cf6)]" />
<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">
@ -176,11 +394,11 @@ export default function ClaudeCodePage() {
</span>
<div>
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
Session command center
Trace 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.
Open a {selectedProviderLabel} session to inspect
conversation turns, tool calls, reasoning, and usage.
</p>
</div>
</div>
@ -272,13 +490,13 @@ export default function ClaudeCodePage() {
) : sessionsQuery.isError ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<Bot className="mx-auto h-10 w-10 text-rose-300" />
<AlertTriangle 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
{selectedProviderLabel} 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.
The backend could not read this source. Check the
API server and try again.
</p>
</td>
</tr>
@ -287,12 +505,12 @@ export default function ClaudeCodePage() {
<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
No {selectedProviderLabel} 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.
{sessionsQuery.data?.unavailable_reason ??
activeSourceCard?.unavailable_reason ??
"Sessions appear here after the local session source writes readable history."}
</p>
</td>
</tr>
@ -367,7 +585,7 @@ export default function ClaudeCodePage() {
</td>
<td className="px-5 py-4 text-right">
<Link
href={sessionHref(session)}
href={sessionHref(session, selectedSource)}
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()}
@ -382,14 +600,24 @@ export default function ClaudeCodePage() {
</table>
</div>
</section>
)}
</TabsContent>
<TabsContent value="analytics" className="mt-0">
{selectedSource === "openai_api" ? (
<UnavailableSourcePanel card={activeSourceCard} />
) : (
<ToolAnalyticsPanel
source={selectedSource}
providerLabel={selectedProviderLabel}
days={selectedDays}
enabled={Boolean(isSignedIn && selectedTab === "analytics")}
onDaysChange={(days) => updateCommandCenterUrl("analytics", days)}
unavailableReason={activeSourceCard?.unavailable_reason}
onDaysChange={(days) =>
updateCommandCenterUrl({ tab: "analytics", days })
}
/>
)}
</TabsContent>
</Tabs>
</DashboardPageLayout>

View File

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

View File

@ -12,11 +12,16 @@ import {
import { Badge } from "@/components/ui/badge";
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";
type SessionHeroHeaderProps = {
session: ClaudeSession;
session: AgentSession;
source: AgentSessionSource;
providerLabel: string;
};
function formatCost(value: number) {
@ -27,9 +32,14 @@ function formatCost(value: number) {
}).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 model = session.models[0] ?? "Model unavailable";
const backHref = `/claude-code?source=${encodeURIComponent(source)}`;
return (
<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="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<Link href="/claude-code">
<Link href={backHref}>
<Button variant="ghost" size="sm" className="mb-4 px-2">
<ArrowLeft className="h-4 w-4" />
Back to Claude Code
Back to Agent Sessions
</Button>
</Link>
<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"}>
{session.is_active ? "Active" : "Complete"}
</Badge>
{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" />
{session.git_branch}
</Badge>

View File

@ -12,7 +12,7 @@ import {
} from "lucide-react";
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 { cn } from "@/lib/utils";
@ -21,7 +21,8 @@ type SessionMessageThreadProps = {
};
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);
onCopied();
}
@ -57,8 +58,8 @@ export function SessionMessageThread({ messages }: SessionMessageThreadProps) {
No conversation messages
</h2>
<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
in the selected page.
This session was found, but there are no displayable user or assistant
turns in the selected page.
</p>
</div>
);
@ -115,7 +116,9 @@ export function SessionMessageThread({ messages }: SessionMessageThreadProps) {
</div>
<p className="mt-0.5 text-xs text-[color:var(--text-muted)]">
{formatTimestamp(message.timestamp)}
{tokens !== null ? ` · ${tokens.toLocaleString()} tokens` : ""}
{tokens !== null
? ` · ${tokens.toLocaleString()} tokens`
: ""}
</p>
</div>
</div>
@ -155,7 +158,10 @@ export function SessionMessageThread({ messages }: SessionMessageThreadProps) {
Thinking
</span>
<ChevronDown
className={cn("h-4 w-4 transition", thinkingOpen && "rotate-180")}
className={cn(
"h-4 w-4 transition",
thinkingOpen && "rotate-180",
)}
/>
</button>
{thinkingOpen ? (

View File

@ -2,7 +2,7 @@
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 { cn } from "@/lib/utils";
@ -72,7 +72,8 @@ export function SessionTimelineNav({ messages }: SessionTimelineNavProps) {
})}
{messages.length > visibleMessages.length ? (
<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>
) : null}
</nav>

View File

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

View File

@ -10,7 +10,7 @@ import {
Wrench,
} from "lucide-react";
import { type SessionToolUseBlock } from "@/lib/api/claude-code";
import { type SessionToolUseBlock } from "@/lib/api/agent-sessions";
import { cn } from "@/lib/utils";
type ToolCallBlockProps = {
@ -38,7 +38,8 @@ function summarizeToolInput(tool: SessionToolUseBlock): string {
}
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);
onCopied();
}
@ -46,7 +47,10 @@ async function copyToClipboard(value: string, onCopied: () => void) {
export function ToolCallBlock({ tool }: ToolCallBlockProps) {
const [open, setOpen] = useState(false);
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 markCopied = (kind: "input" | "result") => {
@ -58,9 +62,7 @@ export function ToolCallBlock({ tool }: ToolCallBlockProps) {
<div
className={cn(
"overflow-hidden rounded-xl border bg-[color:var(--surface)] shadow-sm",
tool.is_error
? "border-rose-400/30"
: "border-[color:var(--border)]",
tool.is_error ? "border-rose-400/30" : "border-[color:var(--border)]",
)}
>
<button
@ -112,7 +114,9 @@ export function ToolCallBlock({ tool }: ToolCallBlockProps) {
<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)]"
onClick={() => copyToClipboard(inputJson, () => markCopied("input"))}
onClick={() =>
copyToClipboard(inputJson, () => markCopied("input"))
}
>
{copied === "input" ? (
<Check className="h-3.5 w-3.5" />
@ -138,7 +142,9 @@ export function ToolCallBlock({ tool }: ToolCallBlockProps) {
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)]"
onClick={() =>
copyToClipboard(tool.result ?? "", () => markCopied("result"))
copyToClipboard(tool.result ?? "", () =>
markCopied("result"),
)
}
>
{copied === "result" ? (

View File

@ -31,8 +31,8 @@ export function ToolFrequencyChart({ toolCounts }: ToolFrequencyChartProps) {
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.
Tool activity appears after agent sessions include assistant tool
calls.
</p>
</div>
);

View File

@ -227,7 +227,7 @@ export function DashboardSidebar() {
<div className="mt-2 space-y-1.5">
<NavItem
href="/claude-code"
label="Claude Code"
label="Agent Sessions"
icon={<TerminalSquare className="h-4 w-4" />}
tone="cyan"
active={isActive("/claude-code")}
@ -308,7 +308,8 @@ export function DashboardSidebar() {
<span
className={cn(
"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 === "unknown" &&
"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;
}