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:
parent
fe6d9f219a
commit
e26f3aa068
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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" ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)]",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue