diff --git a/backend/app/api/claude_code.py b/backend/app/api/claude_code.py
index 7189b83..5a4277c 100644
--- a/backend/app/api/claude_code.py
+++ b/backend/app/api/claude_code.py
@@ -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(
diff --git a/backend/app/schemas/claude_code.py b/backend/app/schemas/claude_code.py
index 1d0f8f4..e69ece8 100644
--- a/backend/app/schemas/claude_code.py
+++ b/backend/app/schemas/claude_code.py
@@ -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):
diff --git a/backend/app/services/claude_code_reader.py b/backend/app/services/claude_code_reader.py
index 311aa2a..c80c941 100644
--- a/backend/app/services/claude_code_reader.py
+++ b/backend/app/services/claude_code_reader.py
@@ -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()
diff --git a/compose.yml b/compose.yml
index bd755d4..95b68fb 100644
--- a/compose.yml
+++ b/compose.yml
@@ -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
diff --git a/frontend/src/app/claude-code/page.tsx b/frontend/src/app/claude-code/page.tsx
new file mode 100644
index 0000000..0f35ef9
--- /dev/null
+++ b/frontend/src/app/claude-code/page.tsx
@@ -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 (
+ setActiveOnly((value) => !value)}
+ >
+
+ Active only
+
+ }
+ contentClassName="space-y-6"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Session command center
+
+
+ Open a session to read the exact conversation, tool calls, and
+ thinking trail.
+
+
+
+
+
+
+
+
+ Sessions
+
+
+ {(stats?.session_count ?? 0).toLocaleString()}
+
+
+
+
+ Active
+
+
+ {(stats?.active_sessions ?? 0).toLocaleString()}
+
+
+
+
+ Tokens
+
+
+ {(stats?.total_tokens ?? 0).toLocaleString()}
+
+
+
+
+ Spend
+
+
+ {formatCost(stats?.total_cost_usd ?? 0)}
+
+
+
+
+
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder="Search sessions, projects, models, branches..."
+ className="pl-10"
+ />
+
+
+ Showing {filteredSessions.length.toLocaleString()} of{" "}
+ {sessions.length.toLocaleString()}
+
+
+
+
+
+
+
+
+ Session
+
+
+ Model
+
+
+ Usage
+
+
+ Last active
+
+
+ Open
+
+
+
+
+ {sessionsQuery.isLoading ? (
+ Array.from({ length: 5 }).map((_, index) => (
+
+
+
+
+
+ ))
+ ) : sessionsQuery.isError ? (
+
+
+
+
+ Claude Code sessions unavailable
+
+
+ The backend could not read local session data. Check the API
+ server and try again.
+
+
+
+ ) : filteredSessions.length === 0 ? (
+
+
+
+
+ No Claude Code sessions found
+
+
+ Sessions appear here after Claude Code writes local JSONL history
+ under your configured projects directory.
+
+
+
+ ) : (
+ filteredSessions.map((session) => (
+ openSession(session)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") openSession(session);
+ }}
+ >
+
+
+
+
+
+
+
+
+ {session.title || truncateText(session.session_id, 20)}
+
+ {session.is_active ? (
+
Active
+ ) : null}
+
+
+ {session.cwd ?? session.project_dir}
+
+
+
+
+
+
+ {(session.models.length > 0
+ ? session.models
+ : ["Unknown model"]
+ ).map((model) => (
+
+ {model}
+
+ ))}
+
+
+
+
+
+
+ {formatCost(session.cost_usd)}
+
+
+ {session.tokens.total.toLocaleString()} tokens ·{" "}
+ {session.message_count.toLocaleString()} turns
+
+
+
+
+
+
+ {formatRelativeTimestamp(session.last_message_at)}
+
+
+ {formatTimestamp(session.last_message_at)}
+
+
+
+ event.stopPropagation()}
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/claude-code/sessions/[id]/page.tsx b/frontend/src/app/claude-code/sessions/[id]/page.tsx
new file mode 100644
index 0000000..c84552a
--- /dev/null
+++ b/frontend/src/app/claude-code/sessions/[id]/page.tsx
@@ -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 (
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function ErrorState({ isNotFound }: { isNotFound: boolean }) {
+ return (
+
+
+
+
+ {isNotFound ? "Session not found" : "Session unavailable"}
+
+
+ {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."}
+
+
+
+
+ Back to Claude Code
+
+
+
+
+ );
+}
+
+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 (
+
+ {sessionQuery.isLoading ? (
+
+
+
+ ) : sessionQuery.isError || messagesQuery.isError ? (
+
+ ) : sessionQuery.data ? (
+ <>
+
+
+
+
+ {messagesQuery.isLoading ? (
+
+ ) : (
+ <>
+
+
+
+ Conversation
+
+
+ Showing {messages.length.toLocaleString()} of{" "}
+ {total.toLocaleString()} messages
+
+
+ {messagesQuery.hasNextPage ? (
+
messagesQuery.fetchNextPage()}
+ disabled={messagesQuery.isFetchingNextPage}
+ >
+ {messagesQuery.isFetchingNextPage ? (
+
+ ) : (
+
+ )}
+ Load more
+
+ ) : null}
+
+
+ {messagesQuery.hasNextPage ? (
+
+ messagesQuery.fetchNextPage()}
+ disabled={messagesQuery.isFetchingNextPage}
+ >
+ {messagesQuery.isFetchingNextPage ? (
+
+ ) : (
+
+ )}
+ Load more messages
+
+
+ ) : null}
+ >
+ )}
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/claude/SessionHeroHeader.tsx b/frontend/src/components/claude/SessionHeroHeader.tsx
new file mode 100644
index 0000000..6b61b43
--- /dev/null
+++ b/frontend/src/components/claude/SessionHeroHeader.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ Back to Claude Code
+
+
+
+
+ {session.is_active ? "Active" : "Complete"}
+
+ {session.git_branch ? (
+
+
+ {session.git_branch}
+
+ ) : null}
+
+ {model}
+
+
+
+ {title}
+
+
+ {session.cwd ?? session.project_dir}
+
+
+
+
+
+
+
+ Cost
+
+
+ {formatCost(session.cost_usd)}
+
+
+
+
+
+ Tokens
+
+
+ {session.tokens.total.toLocaleString()}
+
+
+
+
+
+ Turns
+
+
+ {session.message_count.toLocaleString()}
+
+
+
+
+
+ Last seen
+
+
+ {formatTimestamp(session.last_message_at)}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/claude/SessionMessageThread.tsx b/frontend/src/components/claude/SessionMessageThread.tsx
new file mode 100644
index 0000000..a5689d0
--- /dev/null
+++ b/frontend/src/components/claude/SessionMessageThread.tsx
@@ -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>({});
+ const [copiedId, setCopiedId] = useState(null);
+
+ const markCopied = (id: string) => {
+ setCopiedId(id);
+ window.setTimeout(() => setCopiedId(null), 1400);
+ };
+
+ if (messages.length === 0) {
+ return (
+
+
+
+ No conversation messages
+
+
+ This session was found, but there are no displayable user or assistant turns
+ in the selected page.
+
+
+ );
+ }
+
+ return (
+
+ {messages.map((message, index) => {
+ const isAssistant = message.role === "assistant";
+ const text = messageText(message);
+ const thinkingOpen = openThinking[message.uuid] ?? false;
+ const tokens = tokenTotal(message);
+
+ return (
+
+
+
+
+ {isAssistant ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {isAssistant ? "Assistant" : "User"}
+
+
+ #{index + 1}
+
+ {message.model ? (
+
+ {message.model}
+
+ ) : null}
+
+
+ {formatTimestamp(message.timestamp)}
+ {tokens !== null ? ` · ${tokens.toLocaleString()} tokens` : ""}
+
+
+
+
+ {text ? (
+ copyText(text, () => markCopied(message.uuid))}
+ >
+ {copiedId === message.uuid ? (
+
+ ) : (
+
+ )}
+ {copiedId === message.uuid ? "Copied" : "Copy"}
+
+ ) : null}
+
+
+
+ {message.thinking_blocks.length > 0 ? (
+
+
+ setOpenThinking((value) => ({
+ ...value,
+ [message.uuid]: !thinkingOpen,
+ }))
+ }
+ >
+
+
+ Thinking
+
+
+
+ {thinkingOpen ? (
+
+ {message.thinking_blocks.map((block, blockIndex) => (
+
+ {block.text}
+ {block.truncated ? " [truncated]" : ""}
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+ {message.text_blocks.map((block, blockIndex) => (
+
+ {block.text}
+ {block.truncated ? (
+
+ truncated
+
+ ) : null}
+
+ ))}
+
+ {message.tool_uses.length > 0 ? (
+
+ {message.tool_uses.map((tool) => (
+
+ ))}
+
+ ) : null}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/claude/SessionTimelineNav.tsx b/frontend/src/components/claude/SessionTimelineNav.tsx
new file mode 100644
index 0000000..f9c9561
--- /dev/null
+++ b/frontend/src/components/claude/SessionTimelineNav.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/components/claude/ToolCallBlock.tsx b/frontend/src/components/claude/ToolCallBlock.tsx
new file mode 100644
index 0000000..223eec8
--- /dev/null
+++ b/frontend/src/components/claude/ToolCallBlock.tsx
@@ -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 (
+
+
setOpen((value) => !value)}
+ >
+
+ {tool.tool_name.toLowerCase() === "bash" ? (
+
+ ) : tool.is_error ? (
+
+ ) : (
+
+ )}
+
+
+
+ {tool.tool_name}
+
+
+ {summary}
+
+
+
+
+
+ {open ? (
+
+
+
+
+ Input
+ {tool.input_truncated ? " · truncated" : ""}
+
+
copyToClipboard(inputJson, () => markCopied("input"))}
+ >
+ {copied === "input" ? (
+
+ ) : (
+
+ )}
+ {copied === "input" ? "Copied" : "Copy"}
+
+
+
+ {inputJson}
+
+
+
+ {tool.result ? (
+
+
+
+ Result
+ {tool.result_truncated ? " · truncated" : ""}
+
+
+ copyToClipboard(tool.result ?? "", () => markCopied("result"))
+ }
+ >
+ {copied === "result" ? (
+
+ ) : (
+
+ )}
+ {copied === "result" ? "Copied" : "Copy"}
+
+
+
+ {tool.result}
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/components/organisms/DashboardSidebar.tsx b/frontend/src/components/organisms/DashboardSidebar.tsx
index ee139e2..58b5b1b 100644
--- a/frontend/src/components/organisms/DashboardSidebar.tsx
+++ b/frontend/src/components/organisms/DashboardSidebar.tsx
@@ -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() {
+
+
AI Operations
+
+ }
+ tone="cyan"
+ active={isActive("/claude-code")}
+ />
+ {isAdmin ? (
+ }
+ tone="violet"
+ active={isActive("/agents")}
+ />
+ ) : null}
+ {isAdmin ? (
+ }
+ tone="emerald"
+ active={isActive("/gateways")}
+ />
+ ) : null}
+ }
+ tone="amber"
+ active={isActive("/settings/ai-providers")}
+ />
+
+
+
{isAdmin ? (
Skills
@@ -251,24 +299,6 @@ export function DashboardSidebar() {
tone="amber"
active={isActive("/settings")}
/>
- {isAdmin ? (
-
}
- tone="emerald"
- active={isActive("/gateways")}
- />
- ) : null}
- {isAdmin ? (
-
}
- tone="violet"
- active={isActive("/agents")}
- />
- ) : null}
diff --git a/frontend/src/lib/api/claude-code.ts b/frontend/src/lib/api/claude-code.ts
new file mode 100644
index 0000000..a9f3faa
--- /dev/null
+++ b/frontend/src/lib/api/claude-code.ts
@@ -0,0 +1,149 @@
+"use client";
+
+import { customFetch } from "@/api/mutator";
+
+type ApiResponse = {
+ 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;
+ 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 {
+ 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>(
+ `/api/v1/claude-code/sessions${query ? `?${query}` : ""}`,
+ { method: "GET" },
+ );
+ return response.data;
+}
+
+export async function getClaudeSession(
+ sessionId: string,
+): Promise {
+ const response = await customFetch>(
+ `/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 {
+ const params = new URLSearchParams({
+ limit: String(limit),
+ offset: String(offset),
+ });
+ const response = await customFetch>(
+ `/api/v1/claude-code/sessions/${encodeURIComponent(sessionId)}/messages?${params.toString()}`,
+ { method: "GET" },
+ );
+ return response.data;
+}