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,234 +371,253 @@ 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">
<section className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
<div className="relative border-b border-[color:var(--border)] px-5 py-6 md:px-6">
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#8b5cf6,#22c55e)]" />
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<TerminalSquare className="h-5 w-5" />
</span>
<div>
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
Session command center
</h2>
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
Open a session to read the exact conversation, tool
calls, and thinking trail.
{!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,#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">
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<TerminalSquare className="h-5 w-5" />
</span>
<div>
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
Trace Command Center
</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>
</div>
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-2 gap-3 md:grid-cols-4 xl:min-w-[640px]">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Sessions
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.session_count ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Active
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.active_sessions ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Tokens
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{(stats?.total_tokens ?? 0).toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
Spend
</p>
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
{formatCost(stats?.total_cost_usd ?? 0)}
</p>
</div>
<div className="flex flex-col gap-3 border-b border-[color:var(--border)] px-5 py-4 md:flex-row md:items-center md:px-6">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search sessions, projects, models, branches..."
className="pl-10"
/>
</div>
<p className="text-sm text-[color:var(--text-muted)]">
Showing {filteredSessions.length.toLocaleString()} of{" "}
{sessions.length.toLocaleString()}
</p>
</div>
</div>
<div className="flex flex-col gap-3 border-b border-[color:var(--border)] px-5 py-4 md:flex-row md:items-center md:px-6">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search sessions, projects, models, branches..."
className="pl-10"
/>
</div>
<p className="text-sm text-[color:var(--text-muted)]">
Showing {filteredSessions.length.toLocaleString()} of{" "}
{sessions.length.toLocaleString()}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[color:var(--border)]">
<thead className="bg-[color:var(--surface-muted)]">
<tr>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Session
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Model
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Usage
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Last active
</th>
<th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Open
</th>
</tr>
</thead>
<tbody className="divide-y divide-[color:var(--border)]">
{sessionsQuery.isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<tr key={index}>
<td colSpan={5} className="px-5 py-4">
<div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[color:var(--border)]">
<thead className="bg-[color:var(--surface-muted)]">
<tr>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Session
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Model
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Usage
</th>
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Last active
</th>
<th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
Open
</th>
</tr>
</thead>
<tbody className="divide-y divide-[color:var(--border)]">
{sessionsQuery.isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<tr key={index}>
<td colSpan={5} className="px-5 py-4">
<div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
</td>
</tr>
))
) : sessionsQuery.isError ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<AlertTriangle className="mx-auto h-10 w-10 text-rose-300" />
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
{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 this source. Check the
API server and try again.
</p>
</td>
</tr>
))
) : sessionsQuery.isError ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<Bot className="mx-auto h-10 w-10 text-rose-300" />
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
Claude Code sessions unavailable
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
The backend could not read local session data. Check
the API server and try again.
</p>
</td>
</tr>
) : filteredSessions.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
No Claude Code sessions found
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
Sessions appear here after Claude Code writes local
JSONL history under your configured projects
directory.
</p>
</td>
</tr>
) : (
filteredSessions.map((session) => (
<tr
key={session.session_id}
tabIndex={0}
role="link"
className="cursor-pointer transition hover:bg-[color:var(--surface-muted)] focus-visible:bg-[color:var(--surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--accent)]"
onClick={() => openSession(session)}
onKeyDown={(event) => {
if (event.key === "Enter") openSession(session);
}}
>
<td className="max-w-[420px] px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<TerminalSquare className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="break-words text-sm font-semibold text-[color:var(--text)]">
{session.title ||
truncateText(session.session_id, 20)}
) : filteredSessions.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-16 text-center">
<MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
No {selectedProviderLabel} sessions found
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
{sessionsQuery.data?.unavailable_reason ??
activeSourceCard?.unavailable_reason ??
"Sessions appear here after the local session source writes readable history."}
</p>
</td>
</tr>
) : (
filteredSessions.map((session) => (
<tr
key={session.session_id}
tabIndex={0}
role="link"
className="cursor-pointer transition hover:bg-[color:var(--surface-muted)] focus-visible:bg-[color:var(--surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--accent)]"
onClick={() => openSession(session)}
onKeyDown={(event) => {
if (event.key === "Enter") openSession(session);
}}
>
<td className="max-w-[420px] px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
<TerminalSquare className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="break-words text-sm font-semibold text-[color:var(--text)]">
{session.title ||
truncateText(session.session_id, 20)}
</p>
{session.is_active ? (
<Badge variant="success">Active</Badge>
) : null}
</div>
<p className="mt-1 break-words text-xs text-[color:var(--text-muted)]">
{session.cwd ?? session.project_dir}
</p>
{session.is_active ? (
<Badge variant="success">Active</Badge>
) : null}
</div>
<p className="mt-1 break-words text-xs text-[color:var(--text-muted)]">
{session.cwd ?? session.project_dir}
</div>
</td>
<td className="px-5 py-4">
<div className="max-w-[220px] space-y-1">
{(session.models.length > 0
? session.models
: ["Unknown model"]
).map((model) => (
<span
key={model}
className="block truncate rounded-full bg-[color:var(--surface-muted)] px-2.5 py-1 font-mono text-xs text-[color:var(--text-muted)]"
>
{model}
</span>
))}
</div>
</td>
<td className="px-5 py-4">
<div className="space-y-1 text-sm">
<p className="flex items-center gap-2 font-semibold text-[color:var(--text)]">
<Coins className="h-4 w-4 text-[color:var(--text-muted)]" />
{formatCost(session.cost_usd)}
</p>
<p className="text-xs text-[color:var(--text-muted)]">
{session.tokens.total.toLocaleString()} tokens ·{" "}
{session.message_count.toLocaleString()} turns
</p>
</div>
</div>
</td>
<td className="px-5 py-4">
<div className="max-w-[220px] space-y-1">
{(session.models.length > 0
? session.models
: ["Unknown model"]
).map((model) => (
<span
key={model}
className="block truncate rounded-full bg-[color:var(--surface-muted)] px-2.5 py-1 font-mono text-xs text-[color:var(--text-muted)]"
>
{model}
</span>
))}
</div>
</td>
<td className="px-5 py-4">
<div className="space-y-1 text-sm">
<p className="flex items-center gap-2 font-semibold text-[color:var(--text)]">
<Coins className="h-4 w-4 text-[color:var(--text-muted)]" />
{formatCost(session.cost_usd)}
</td>
<td className="px-5 py-4">
<p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]">
<Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" />
{formatRelativeTimestamp(session.last_message_at)}
</p>
<p className="text-xs text-[color:var(--text-muted)]">
{session.tokens.total.toLocaleString()} tokens ·{" "}
{session.message_count.toLocaleString()} turns
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
{formatTimestamp(session.last_message_at)}
</p>
</div>
</td>
<td className="px-5 py-4">
<p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]">
<Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" />
{formatRelativeTimestamp(session.last_message_at)}
</p>
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
{formatTimestamp(session.last_message_at)}
</p>
</td>
<td className="px-5 py-4 text-right">
<Link
href={sessionHref(session)}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-[color:var(--border)] text-[color:var(--text-muted)] transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
aria-label={`Open ${session.title ?? session.session_id}`}
onClick={(event) => event.stopPropagation()}
>
<ArrowUpRight className="h-4 w-4" />
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</td>
<td className="px-5 py-4 text-right">
<Link
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()}
>
<ArrowUpRight className="h-4 w-4" />
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)}
</TabsContent>
<TabsContent value="analytics" className="mt-0">
<ToolAnalyticsPanel
days={selectedDays}
enabled={Boolean(isSignedIn && selectedTab === "analytics")}
onDaysChange={(days) => updateCommandCenterUrl("analytics", days)}
/>
{selectedSource === "openai_api" ? (
<UnavailableSourcePanel card={activeSourceCard} />
) : (
<ToolAnalyticsPanel
source={selectedSource}
providerLabel={selectedProviderLabel}
days={selectedDays}
enabled={Boolean(isSignedIn && selectedTab === "analytics")}
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
? "Refreshing selected range..."
: `${days.toLocaleString()} day operating window`}
{unavailableReason
? unavailableReason
: analyticsQuery.isFetching && !analyticsQuery.isLoading
? "Refreshing selected range..."
: `${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;
}