feat: add Claude Code session detail page with message thread and timeline navigation

- Implemented the ClaudeSessionDetailPage component to display session details, including messages and timeline navigation.
- Created SessionHeroHeader for displaying session metadata and status.
- Added SessionMessageThread to render individual messages and their details.
- Introduced SessionTimelineNav for quick navigation through messages.
- Developed ToolCallBlock to show tool usage details within messages.
- Integrated API functions for fetching session and message data.
This commit is contained in:
null 2026-05-24 16:31:18 -05:00
parent 1bf4e30e8c
commit 28e103452b
12 changed files with 1461 additions and 18 deletions

View File

@ -31,12 +31,15 @@ from app.schemas.claude_code import (
ClaudeSessionListResponse,
ClaudeSessionRead,
ClaudeSessionStatsRead,
CommandEntry,
FileEntry,
SessionMessagesResponse,
SessionMessage,
SessionTokensRead as ClaudeSessionTokensRead,
SessionTokenUsageRead,
TextBlock,
ThinkingBlock,
ToolAnalyticsResponse,
ToolUseBlock,
)
from app.services import claude_code_reader as reader
@ -213,6 +216,36 @@ async def list_projects(
]
# ── Tool Analytics ───────────────────────────────────────────────────────────
@router.get(
"/analytics/tools",
response_model=ToolAnalyticsResponse,
summary="Aggregate tool-use statistics across Claude Code sessions",
description=(
"Scans local JSONL session files and returns counts of each tool used, "
"top files read and written, and top Bash commands by binary name. "
"Duplicate streaming records are deduplicated by block id. "
"Use `days` to scope the analysis window and `project` to narrow by project."
),
)
async def get_tool_analytics(
project: str | None = Query(None, description="Filter by project directory name substring"),
days: int = Query(30, ge=1, le=365, description="Number of days to look back (uses file mtime)"),
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ToolAnalyticsResponse:
"""Return aggregated tool-use analytics from local Claude Code sessions."""
data = await asyncio.to_thread(reader.get_tool_analytics, project, days)
return ToolAnalyticsResponse(
tool_counts=data["tool_counts"],
top_files_read=[FileEntry(path=e["path"], count=e["count"]) for e in data["top_files_read"]],
top_files_written=[FileEntry(path=e["path"], count=e["count"]) for e in data["top_files_written"]],
top_commands=[CommandEntry(command=e["command"], count=e["count"]) for e in data["top_commands"]],
session_count=data["session_count"],
date_range_days=data["date_range_days"],
)
# ── Config Scanner ────────────────────────────────────────────────────────────
@router.get(

View File

@ -55,6 +55,27 @@ class SessionMessagesResponse(SQLModel):
has_more: bool
# ── Tool analytics ───────────────────────────────────────────────────────────
class FileEntry(SQLModel):
path: str
count: int
class CommandEntry(SQLModel):
command: str
count: int
class ToolAnalyticsResponse(SQLModel):
tool_counts: dict[str, int]
top_files_read: list[FileEntry]
top_files_written: list[FileEntry]
top_commands: list[CommandEntry]
session_count: int
date_range_days: int
# ── Session token totals (used in list/detail) ────────────────────────────────
class SessionTokensRead(SQLModel):

View File

@ -520,6 +520,150 @@ def get_session_messages(
return parsed[offset : offset + limit], total
# ---------------------------------------------------------------------------
# Tool analytics
# ---------------------------------------------------------------------------
# Tools that expose a readable file path in their input
_FILE_READ_TOOLS = {"Read"}
_FILE_WRITE_TOOLS = {"Edit", "Write", "NotebookEdit"}
def _bash_binary(command: str) -> str | None:
"""Extract the leading binary name from a shell command string."""
cmd = command.strip().lstrip("!").strip()
if not cmd:
return None
first = cmd.split()[0]
# Strip leading path separators and common shell prefixes
binary = first.lstrip("./").rsplit("/", 1)[-1]
return binary or None
def get_tool_analytics(
project_filter: str | None = None,
days: int = 30,
) -> dict[str, Any]:
"""Scan JSONL session files and return aggregated tool-use statistics.
Uses file mtime for the days filter (fast, no need to fully parse
every record). Deduplicates tool_use blocks by their block id so
streaming artefacts (duplicate JSONL records with the same message.id)
are not double-counted.
"""
root = _projects_dir()
if not root.exists():
return {
"tool_counts": {},
"top_files_read": [],
"top_files_written": [],
"top_commands": [],
"session_count": 0,
"date_range_days": days,
}
cutoff = datetime.utcnow() - timedelta(days=days)
jsonl_files = sorted(root.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
tool_counts: dict[str, int] = {}
files_read: dict[str, int] = {}
files_written: dict[str, int] = {}
bash_cmds: dict[str, int] = {}
session_count = 0
seen_sessions: set[str] = set()
for path in jsonl_files:
if project_filter and project_filter.lower() not in path.parent.name.lower():
continue
try:
mtime = datetime.utcfromtimestamp(path.stat().st_mtime)
except OSError:
continue
if mtime < cutoff:
continue
session_id = path.stem
if session_id in seen_sessions:
continue
seen_sessions.add(session_id)
session_had_tools = False
# Deduplicate tool_use blocks within this session by block id
seen_block_ids: set[str] = set()
try:
with open(path, encoding="utf-8", errors="replace") as fh:
for raw_line in fh:
raw_line = raw_line.strip()
if not raw_line:
continue
try:
rec = json.loads(raw_line)
except json.JSONDecodeError:
continue
if rec.get("isSidechain") or rec.get("type") != "assistant":
continue
content = (rec.get("message") or {}).get("content")
if not isinstance(content, list):
continue
for block in content:
if block.get("type") != "tool_use":
continue
bid = block.get("id", "")
if bid and bid in seen_block_ids:
continue
if bid:
seen_block_ids.add(bid)
name = block.get("name") or "unknown"
tool_counts[name] = tool_counts.get(name, 0) + 1
session_had_tools = True
inp = block.get("input") or {}
if name in _FILE_READ_TOOLS:
fp = inp.get("file_path", "").strip()
if fp:
files_read[fp] = files_read.get(fp, 0) + 1
elif name in _FILE_WRITE_TOOLS:
fp = inp.get("file_path", "").strip()
if fp:
files_written[fp] = files_written.get(fp, 0) + 1
elif name == "Bash":
binary = _bash_binary(inp.get("command", ""))
if binary:
bash_cmds[binary] = bash_cmds.get(binary, 0) + 1
except (OSError, PermissionError) as exc:
logger.debug("claude_code_reader.analytics_read_error path=%s error=%s", path, exc)
continue
if session_had_tools:
session_count += 1
def _top(counter: dict[str, int], key: str, n: int = 20) -> list[dict[str, Any]]:
return [
{key: k, "count": v}
for k, v in sorted(counter.items(), key=lambda x: x[1], reverse=True)[:n]
]
return {
"tool_counts": dict(sorted(tool_counts.items(), key=lambda x: x[1], reverse=True)),
"top_files_read": _top(files_read, "path"),
"top_files_written": _top(files_written, "path"),
"top_commands": _top(bash_cmds, "command"),
"session_count": session_count,
"date_range_days": days,
}
def list_projects() -> list[dict[str, Any]]:
"""Return discovered projects with aggregate stats."""
root = _projects_dir()

View File

@ -52,6 +52,8 @@ services:
CLAUDE_CREDENTIALS_PATH: /run/secrets/claude_credentials
# Codex CLI credentials — read-only mount for ChatGPT subscription usage auto-detection.
CODEX_CREDENTIALS_PATH: /run/secrets/codex_credentials
# Claude Code session JSONL files — read-only mount so the session viewer works.
CLAUDE_PROJECTS_PATH: /run/claude/projects
# AI provider API keys — seeded into provider_credentials on boot (optional).
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
@ -60,6 +62,7 @@ services:
volumes:
- ${CLAUDE_CREDENTIALS_FILE:-/home/kaspa/.claude/.credentials.json}:/run/secrets/claude_credentials:ro
- ${CODEX_CREDENTIALS_FILE:-/home/kaspa/.codex/auth.json}:/run/secrets/codex_credentials:ro
- ${CLAUDE_PROJECTS_DIR:-/home/kaspa/.claude/projects}:/run/claude/projects:ro
depends_on:
db:
condition: service_healthy

View File

@ -0,0 +1,320 @@
"use client";
export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
ArrowUpRight,
Bot,
Clock3,
Coins,
Filter,
MessagesSquare,
Search,
TerminalSquare,
} from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { listClaudeSessions, type ClaudeSession } from "@/lib/api/claude-code";
import { formatRelativeTimestamp, formatTimestamp, truncateText } from "@/lib/formatters";
function formatCost(value: number) {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
maximumFractionDigits: value < 1 ? 4 : 2,
}).format(value);
}
function sessionHref(session: ClaudeSession) {
return `/claude-code/sessions/${encodeURIComponent(session.session_id)}`;
}
export default function ClaudeCodePage() {
const { isSignedIn } = useAuth();
const router = useRouter();
const [search, setSearch] = useState("");
const [activeOnly, setActiveOnly] = useState(false);
const sessionsQuery = useQuery({
queryKey: ["claude-code", "sessions", activeOnly],
queryFn: () => listClaudeSessions({ activeOnly, limit: 300 }),
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
refetchOnMount: "always",
});
const sessions = useMemo(
() => sessionsQuery.data?.sessions ?? [],
[sessionsQuery.data?.sessions],
);
const stats = sessionsQuery.data?.stats;
const normalizedSearch = search.trim().toLowerCase();
const filteredSessions = useMemo(() => {
if (!normalizedSearch) return sessions;
return sessions.filter((session) => {
const haystack = [
session.title,
session.project_dir,
session.cwd,
session.git_branch,
...session.models,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(normalizedSearch);
});
}, [normalizedSearch, sessions]);
const openSession = (session: ClaudeSession) => {
router.push(sessionHref(session));
};
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to view local Claude Code sessions.",
forceRedirectUrl: "/claude-code",
signUpForceRedirectUrl: "/claude-code",
}}
title="Claude Code"
description="Inspect local agent sessions, costs, tools, and conversation history."
headerActions={
<Button
type="button"
variant={activeOnly ? "primary" : "outline"}
onClick={() => setActiveOnly((value) => !value)}
>
<Filter className="h-4 w-4" />
Active only
</Button>
}
contentClassName="space-y-6"
>
<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.
</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="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)]" />
</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)}
</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>
</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)}
</p>
<p className="text-xs text-[color:var(--text-muted)]">
{session.tokens.total.toLocaleString()} tokens ·{" "}
{session.message_count.toLocaleString()} turns
</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>
</DashboardPageLayout>
);
}

View File

@ -0,0 +1,180 @@
"use client";
export const dynamic = "force-dynamic";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Loader2, MessagesSquare } from "lucide-react";
import { ApiError } from "@/api/mutator";
import { SessionHeroHeader } from "@/components/claude/SessionHeroHeader";
import { SessionMessageThread } from "@/components/claude/SessionMessageThread";
import { SessionTimelineNav } from "@/components/claude/SessionTimelineNav";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import {
getClaudeSession,
getSessionMessages,
type SessionMessage,
} from "@/lib/api/claude-code";
const PAGE_SIZE = 200;
function LoadingState() {
return (
<div className="space-y-5">
<div className="h-48 animate-pulse rounded-2xl bg-[color:var(--surface-muted)]" />
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-40 animate-pulse rounded-2xl bg-[color:var(--surface-muted)]"
/>
))}
</div>
);
}
function ErrorState({ isNotFound }: { isNotFound: boolean }) {
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">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-rose-500/15 text-rose-300 ring-1 ring-rose-400/20">
<AlertTriangle className="h-6 w-6" />
</div>
<h1 className="mt-5 text-xl font-semibold text-[color:var(--text)]">
{isNotFound ? "Session not found" : "Session unavailable"}
</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."}
</p>
<Link href="/claude-code">
<Button className="mt-6">
<ArrowLeft className="h-4 w-4" />
Back to Claude Code
</Button>
</Link>
</div>
</div>
);
}
export default function ClaudeSessionDetailPage() {
const params = useParams<{ id: string }>();
const sessionId = decodeURIComponent(params.id);
const sessionQuery = useQuery({
queryKey: ["claude-code", "session", sessionId],
queryFn: () => getClaudeSession(sessionId),
enabled: Boolean(sessionId),
refetchOnMount: "always",
});
const messagesQuery = useInfiniteQuery({
queryKey: ["claude-code", "session", sessionId, "messages"],
queryFn: ({ pageParam }) =>
getSessionMessages({
sessionId,
limit: PAGE_SIZE,
offset: pageParam,
}),
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => {
if (!lastPage.has_more) return undefined;
return pages.reduce((total, page) => total + page.messages.length, 0);
},
enabled: Boolean(sessionId),
refetchOnMount: "always",
});
const messages: SessionMessage[] =
messagesQuery.data?.pages.flatMap((page) => page.messages) ?? [];
const total = messagesQuery.data?.pages[0]?.total ?? 0;
const error = sessionQuery.error ?? messagesQuery.error;
const isNotFound = error instanceof ApiError && error.status === 404;
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to view Claude Code session details.",
forceRedirectUrl: `/claude-code/sessions/${encodeURIComponent(sessionId)}`,
signUpForceRedirectUrl: `/claude-code/sessions/${encodeURIComponent(sessionId)}`,
}}
title="Claude Code session"
description="Conversation trace, thinking blocks, and tool activity."
contentClassName="p-0 md:p-0"
headerClassName="sr-only"
>
{sessionQuery.isLoading ? (
<div className="p-4 md:p-8">
<LoadingState />
</div>
) : sessionQuery.isError || messagesQuery.isError ? (
<ErrorState isNotFound={isNotFound} />
) : sessionQuery.data ? (
<>
<SessionHeroHeader session={sessionQuery.data} />
<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">
{messagesQuery.isLoading ? (
<LoadingState />
) : (
<>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-heading text-lg font-semibold text-[color:var(--text)]">
Conversation
</h2>
<p className="text-sm text-[color:var(--text-muted)]">
Showing {messages.length.toLocaleString()} of{" "}
{total.toLocaleString()} messages
</p>
</div>
{messagesQuery.hasNextPage ? (
<Button
type="button"
variant="outline"
onClick={() => messagesQuery.fetchNextPage()}
disabled={messagesQuery.isFetchingNextPage}
>
{messagesQuery.isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessagesSquare className="h-4 w-4" />
)}
Load more
</Button>
) : null}
</div>
<SessionMessageThread messages={messages} />
{messagesQuery.hasNextPage ? (
<div className="mt-6 flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => messagesQuery.fetchNextPage()}
disabled={messagesQuery.isFetchingNextPage}
>
{messagesQuery.isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessagesSquare className="h-4 w-4" />
)}
Load more messages
</Button>
</div>
) : null}
</>
)}
</main>
<SessionTimelineNav messages={messages} />
</div>
</div>
</>
) : null}
</DashboardPageLayout>
);
}

View File

@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import {
ArrowLeft,
Bot,
Clock3,
Coins,
GitBranch,
MessagesSquare,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { type ClaudeSession } from "@/lib/api/claude-code";
import { formatTimestamp, truncateText } from "@/lib/formatters";
type SessionHeroHeaderProps = {
session: ClaudeSession;
};
function formatCost(value: number) {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: "USD",
maximumFractionDigits: value < 1 ? 4 : 2,
}).format(value);
}
export function SessionHeroHeader({ session }: SessionHeroHeaderProps) {
const title = session.title?.trim() || truncateText(session.session_id, 18);
const model = session.models[0] ?? "Model unavailable";
return (
<section className="relative overflow-hidden border-b border-[color:var(--border)] bg-[color:var(--surface)]">
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#8b5cf6,#22c55e)]" />
<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">
<Button variant="ghost" size="sm" className="mb-4 px-2">
<ArrowLeft className="h-4 w-4" />
Back to Claude Code
</Button>
</Link>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={session.is_active ? "success" : "outline"}>
{session.is_active ? "Active" : "Complete"}
</Badge>
{session.git_branch ? (
<Badge variant="outline" className="normal-case tracking-normal">
<GitBranch className="mr-1 h-3 w-3" />
{session.git_branch}
</Badge>
) : null}
<Badge variant="accent" className="normal-case tracking-normal">
{model}
</Badge>
</div>
<h1 className="mt-4 max-w-4xl break-words font-heading text-3xl font-semibold tracking-tight text-[color:var(--text)] md:text-4xl">
{title}
</h1>
<p className="mt-2 max-w-4xl break-words text-sm text-[color:var(--text-muted)]">
{session.cwd ?? session.project_dir}
</p>
</div>
<div className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
<Coins className="h-3.5 w-3.5" />
Cost
</div>
<p className="mt-2 truncate text-lg font-semibold text-[color:var(--text)]">
{formatCost(session.cost_usd)}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
<Bot className="h-3.5 w-3.5" />
Tokens
</div>
<p className="mt-2 truncate text-lg font-semibold text-[color:var(--text)]">
{session.tokens.total.toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
<MessagesSquare className="h-3.5 w-3.5" />
Turns
</div>
<p className="mt-2 truncate text-lg font-semibold text-[color:var(--text)]">
{session.message_count.toLocaleString()}
</p>
</div>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
<Clock3 className="h-3.5 w-3.5" />
Last seen
</div>
<p className="mt-2 truncate text-sm font-semibold text-[color:var(--text)]">
{formatTimestamp(session.last_message_at)}
</p>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,204 @@
"use client";
import { useState } from "react";
import {
Bot,
Check,
ChevronDown,
Clipboard,
MessageSquare,
Sparkles,
UserRound,
} from "lucide-react";
import { ToolCallBlock } from "@/components/claude/ToolCallBlock";
import { type SessionMessage } from "@/lib/api/claude-code";
import { formatTimestamp } from "@/lib/formatters";
import { cn } from "@/lib/utils";
type SessionMessageThreadProps = {
messages: SessionMessage[];
};
async function copyText(value: string, onCopied: () => void) {
if (!value || typeof navigator === "undefined" || !navigator.clipboard) return;
await navigator.clipboard.writeText(value);
onCopied();
}
function messageText(message: SessionMessage) {
return message.text_blocks.map((block) => block.text).join("\n\n");
}
function tokenTotal(message: SessionMessage) {
if (!message.tokens) return null;
return (
message.tokens.input +
message.tokens.output +
message.tokens.cache_read +
message.tokens.cache_write
);
}
export function SessionMessageThread({ messages }: SessionMessageThreadProps) {
const [openThinking, setOpenThinking] = useState<Record<string, boolean>>({});
const [copiedId, setCopiedId] = useState<string | null>(null);
const markCopied = (id: string) => {
setCopiedId(id);
window.setTimeout(() => setCopiedId(null), 1400);
};
if (messages.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface)] p-10 text-center">
<MessageSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
<h2 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
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.
</p>
</div>
);
}
return (
<div className="space-y-5">
{messages.map((message, index) => {
const isAssistant = message.role === "assistant";
const text = messageText(message);
const thinkingOpen = openThinking[message.uuid] ?? false;
const tokens = tokenTotal(message);
return (
<article
key={message.uuid}
id={`message-${message.uuid}`}
className={cn(
"scroll-mt-32 rounded-2xl border shadow-sm",
isAssistant
? "border-cyan-400/15 bg-[linear-gradient(180deg,rgba(8,145,178,0.08),rgba(8,145,178,0.02))]"
: "border-[color:var(--border)] bg-[color:var(--surface)]",
)}
>
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-[color:var(--border)] px-4 py-3 md:px-5">
<div className="flex min-w-0 items-center gap-3">
<span
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1",
isAssistant
? "bg-cyan-500/15 text-cyan-300 ring-cyan-400/20"
: "bg-violet-500/15 text-violet-300 ring-violet-400/20",
)}
>
{isAssistant ? (
<Bot className="h-4 w-4" />
) : (
<UserRound className="h-4 w-4" />
)}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold text-[color:var(--text)]">
{isAssistant ? "Assistant" : "User"}
</h2>
<span className="rounded-full border border-[color:var(--border)] px-2 py-0.5 text-[11px] font-semibold text-[color:var(--text-muted)]">
#{index + 1}
</span>
{message.model ? (
<span className="max-w-[220px] truncate rounded-full bg-[color:var(--surface-muted)] px-2 py-0.5 font-mono text-[11px] text-[color:var(--text-muted)]">
{message.model}
</span>
) : null}
</div>
<p className="mt-0.5 text-xs text-[color:var(--text-muted)]">
{formatTimestamp(message.timestamp)}
{tokens !== null ? ` · ${tokens.toLocaleString()} tokens` : ""}
</p>
</div>
</div>
{text ? (
<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={() => copyText(text, () => markCopied(message.uuid))}
>
{copiedId === message.uuid ? (
<Check className="h-3.5 w-3.5" />
) : (
<Clipboard className="h-3.5 w-3.5" />
)}
{copiedId === message.uuid ? "Copied" : "Copy"}
</button>
) : null}
</header>
<div className="space-y-4 px-4 py-4 md:px-5">
{message.thinking_blocks.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-amber-400/20 bg-amber-500/10">
<button
type="button"
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-semibold text-amber-100 transition hover:bg-amber-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300"
aria-expanded={thinkingOpen}
onClick={() =>
setOpenThinking((value) => ({
...value,
[message.uuid]: !thinkingOpen,
}))
}
>
<span className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
Thinking
</span>
<ChevronDown
className={cn("h-4 w-4 transition", thinkingOpen && "rotate-180")}
/>
</button>
{thinkingOpen ? (
<div className="space-y-3 border-t border-amber-400/20 px-4 py-3">
{message.thinking_blocks.map((block, blockIndex) => (
<p
key={`${message.uuid}-thinking-${blockIndex}`}
className="whitespace-pre-wrap break-words text-sm italic leading-7 text-amber-50/90"
>
{block.text}
{block.truncated ? " [truncated]" : ""}
</p>
))}
</div>
) : null}
</div>
) : null}
{message.text_blocks.map((block, blockIndex) => (
<div
key={`${message.uuid}-text-${blockIndex}`}
className="prose prose-invert max-w-none whitespace-pre-wrap break-words text-[15px] leading-7 text-[color:var(--text)]"
>
{block.text}
{block.truncated ? (
<span className="ml-2 rounded-full bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--text-muted)]">
truncated
</span>
) : null}
</div>
))}
{message.tool_uses.length > 0 ? (
<div className="space-y-3">
{message.tool_uses.map((tool) => (
<ToolCallBlock key={tool.tool_use_id} tool={tool} />
))}
</div>
) : null}
</div>
</article>
);
})}
</div>
);
}

View File

@ -0,0 +1,81 @@
"use client";
import { Bot, Terminal, UserRound } from "lucide-react";
import { type SessionMessage } from "@/lib/api/claude-code";
import { formatTimestamp, truncateText } from "@/lib/formatters";
import { cn } from "@/lib/utils";
type SessionTimelineNavProps = {
messages: SessionMessage[];
};
export function SessionTimelineNav({ messages }: SessionTimelineNavProps) {
const visibleMessages = messages.slice(0, 80);
if (messages.length === 0) return null;
return (
<aside className="sticky top-6 hidden max-h-[calc(100vh-3rem)] w-72 shrink-0 overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm xl:block">
<div className="border-b border-[color:var(--border)] px-4 py-3">
<h2 className="text-sm font-semibold text-[color:var(--text)]">
Session map
</h2>
<p className="mt-0.5 text-xs text-[color:var(--text-muted)]">
Jump through turns and tool-heavy moments.
</p>
</div>
<nav className="max-h-[calc(100vh-9rem)] overflow-y-auto p-2">
{visibleMessages.map((message, index) => {
const isAssistant = message.role === "assistant";
const toolCount = message.tool_uses.length;
const title =
message.text_blocks[0]?.text ??
(toolCount > 0 ? `${toolCount} tool calls` : message.role);
return (
<a
key={message.uuid}
href={`#message-${message.uuid}`}
className="group flex items-start gap-3 rounded-xl px-3 py-2.5 transition hover:bg-[color:var(--surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
>
<span
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ring-1",
isAssistant
? "bg-cyan-500/15 text-cyan-300 ring-cyan-400/20"
: "bg-violet-500/15 text-violet-300 ring-violet-400/20",
)}
>
{toolCount > 0 ? (
<Terminal className="h-3.5 w-3.5" />
) : isAssistant ? (
<Bot className="h-3.5 w-3.5" />
) : (
<UserRound className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-[color:var(--text)]">
#{index + 1} {isAssistant ? "Assistant" : "User"}
{toolCount > 0 ? ` · ${toolCount} tools` : ""}
</span>
<span className="mt-0.5 block break-words text-xs leading-5 text-[color:var(--text-muted)]">
{truncateText(title, 72)}
</span>
<span className="mt-1 block text-[11px] text-[color:var(--text-quiet)]">
{formatTimestamp(message.timestamp)}
</span>
</span>
</a>
);
})}
{messages.length > visibleMessages.length ? (
<p className="px-3 py-3 text-xs text-[color:var(--text-muted)]">
Showing first {visibleMessages.length.toLocaleString()} timeline items.
</p>
) : null}
</nav>
</aside>
);
}

View File

@ -0,0 +1,168 @@
"use client";
import { useMemo, useState } from "react";
import {
Check,
ChevronDown,
Clipboard,
Terminal,
TriangleAlert,
Wrench,
} from "lucide-react";
import { type SessionToolUseBlock } from "@/lib/api/claude-code";
import { cn } from "@/lib/utils";
type ToolCallBlockProps = {
tool: SessionToolUseBlock;
};
function summarizeToolInput(tool: SessionToolUseBlock): string {
const input = tool.input;
const path =
typeof input.file_path === "string"
? input.file_path
: typeof input.path === "string"
? input.path
: null;
const command = typeof input.command === "string" ? input.command : null;
const pattern = typeof input.pattern === "string" ? input.pattern : null;
if (command) return command;
if (path) return path;
if (pattern) return pattern;
const keys = Object.keys(input);
if (keys.length === 0) return "No input";
return keys.slice(0, 4).join(", ");
}
async function copyToClipboard(value: string, onCopied: () => void) {
if (!value || typeof navigator === "undefined" || !navigator.clipboard) return;
await navigator.clipboard.writeText(value);
onCopied();
}
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 summary = summarizeToolInput(tool);
const markCopied = (kind: "input" | "result") => {
setCopied(kind);
window.setTimeout(() => setCopied(null), 1400);
};
return (
<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)]",
)}
>
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-[color:var(--surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<span
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ring-1",
tool.is_error
? "bg-rose-500/15 text-rose-300 ring-rose-400/20"
: "bg-cyan-500/15 text-cyan-300 ring-cyan-400/20",
)}
>
{tool.tool_name.toLowerCase() === "bash" ? (
<Terminal className="h-4 w-4" />
) : tool.is_error ? (
<TriangleAlert className="h-4 w-4" />
) : (
<Wrench className="h-4 w-4" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-[color:var(--text)]">
{tool.tool_name}
</span>
<span className="mt-0.5 block truncate font-mono text-xs text-[color:var(--text-muted)]">
{summary}
</span>
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-[color:var(--text-muted)] transition",
open && "rotate-180",
)}
/>
</button>
{open ? (
<div className="space-y-4 border-t border-[color:var(--border)] px-4 py-4">
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-[color:var(--text-muted)]">
Input
{tool.input_truncated ? " · truncated" : ""}
</p>
<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"))}
>
{copied === "input" ? (
<Check className="h-3.5 w-3.5" />
) : (
<Clipboard className="h-3.5 w-3.5" />
)}
{copied === "input" ? "Copied" : "Copy"}
</button>
</div>
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words rounded-lg border border-[color:var(--border)] bg-[color:var(--bg)] p-3 font-mono text-xs leading-relaxed text-[color:var(--text)]">
{inputJson}
</pre>
</div>
{tool.result ? (
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-[color:var(--text-muted)]">
Result
{tool.result_truncated ? " · truncated" : ""}
</p>
<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(tool.result ?? "", () => markCopied("result"))
}
>
{copied === "result" ? (
<Check className="h-3.5 w-3.5" />
) : (
<Clipboard className="h-3.5 w-3.5" />
)}
{copied === "result" ? "Copied" : "Copy"}
</button>
</div>
<pre
className={cn(
"max-h-[420px] overflow-auto whitespace-pre-wrap break-words rounded-lg border p-3 font-mono text-xs leading-relaxed",
tool.is_error
? "border-rose-400/30 bg-rose-950/20 text-rose-100"
: "border-[color:var(--border)] bg-[color:var(--bg)] text-[color:var(--text)]",
)}
>
{tool.result}
</pre>
</div>
) : null}
</div>
) : null}
</div>
);
}

View File

@ -13,11 +13,13 @@ import {
CircleDot,
Folder,
FolderGit,
KeyRound,
LayoutGrid,
Network,
Settings,
Store,
Tags,
TerminalSquare,
} from "lucide-react";
import { ApiError } from "@/api/mutator";
@ -71,6 +73,14 @@ function isNavActive(pathname: string, href: string) {
);
}
if (href === "/settings") {
return (
pathname === href ||
(pathname.startsWith("/settings/") &&
!pathname.startsWith("/settings/ai-providers"))
);
}
return pathname === href || pathname.startsWith(`${href}/`);
}
@ -212,6 +222,44 @@ export function DashboardSidebar() {
</div>
</div>
<div>
<p className={sectionHeaderClass}>AI Operations</p>
<div className="mt-2 space-y-1.5">
<NavItem
href="/claude-code"
label="Claude Code"
icon={<TerminalSquare className="h-4 w-4" />}
tone="cyan"
active={isActive("/claude-code")}
/>
{isAdmin ? (
<NavItem
href="/agents"
label="Agents"
icon={<Bot className="h-4 w-4" />}
tone="violet"
active={isActive("/agents")}
/>
) : null}
{isAdmin ? (
<NavItem
href="/gateways"
label="Gateways"
icon={<Network className="h-4 w-4" />}
tone="emerald"
active={isActive("/gateways")}
/>
) : null}
<NavItem
href="/settings/ai-providers"
label="AI Providers"
icon={<KeyRound className="h-4 w-4" />}
tone="amber"
active={isActive("/settings/ai-providers")}
/>
</div>
</div>
{isAdmin ? (
<div>
<p className={sectionHeaderClass}>Skills</p>
@ -251,24 +299,6 @@ export function DashboardSidebar() {
tone="amber"
active={isActive("/settings")}
/>
{isAdmin ? (
<NavItem
href="/gateways"
label="Gateways"
icon={<Network className="h-4 w-4" />}
tone="emerald"
active={isActive("/gateways")}
/>
) : null}
{isAdmin ? (
<NavItem
href="/agents"
label="Agents"
icon={<Bot className="h-4 w-4" />}
tone="violet"
active={isActive("/agents")}
/>
) : null}
</div>
</div>
</nav>

View File

@ -0,0 +1,149 @@
"use client";
import { customFetch } from "@/api/mutator";
type ApiResponse<T> = {
data: T;
status: number;
headers: Headers;
};
export type ClaudeSessionTokens = {
input: number;
output: number;
cache_read: number;
cache_write: number;
total: number;
};
export type ClaudeSession = {
session_id: string;
project_dir: string;
cwd: string | null;
title: string | null;
models: string[];
tokens: ClaudeSessionTokens;
cost_usd: number;
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 ClaudeSessionStats = {
session_count: number;
active_sessions: number;
total_tokens: number;
total_cost_usd: number;
models: string[];
};
export type ClaudeSessionListResponse = {
sessions: ClaudeSession[];
total: number;
stats: ClaudeSessionStats;
};
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;
messages: SessionMessage[];
total: number;
has_more: boolean;
};
export type ListClaudeSessionsParams = {
project?: string;
activeOnly?: boolean;
limit?: number;
};
export async function listClaudeSessions({
project,
activeOnly = false,
limit = 100,
}: ListClaudeSessionsParams = {}): Promise<ClaudeSessionListResponse> {
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<ClaudeSessionListResponse>>(
`/api/v1/claude-code/sessions${query ? `?${query}` : ""}`,
{ method: "GET" },
);
return response.data;
}
export async function getClaudeSession(
sessionId: string,
): Promise<ClaudeSession> {
const response = await customFetch<ApiResponse<ClaudeSession>>(
`/api/v1/claude-code/sessions/${encodeURIComponent(sessionId)}`,
{ method: "GET" },
);
return response.data;
}
export type GetSessionMessagesParams = {
sessionId: string;
limit?: number;
offset?: number;
};
export async function getSessionMessages({
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/claude-code/sessions/${encodeURIComponent(sessionId)}/messages?${params.toString()}`,
{ method: "GET" },
);
return response.data;
}