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:
parent
1bf4e30e8c
commit
28e103452b
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue