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()} +

+
+ +
+ + + + + + + + + + + + {sessionsQuery.isLoading ? ( + Array.from({ length: 5 }).map((_, index) => ( + + + + )) + ) : sessionsQuery.isError ? ( + + + + ) : filteredSessions.length === 0 ? ( + + + + ) : ( + filteredSessions.map((session) => ( + openSession(session)} + onKeyDown={(event) => { + if (event.key === "Enter") openSession(session); + }} + > + + + + + + + )) + )} + +
+ Session + + Model + + Usage + + Last active + + Open +
+
+
+ +

+ Claude Code sessions unavailable +

+

+ The backend could not read local session data. Check the API + server and try again. +

+
+ +

+ No Claude Code sessions found +

+

+ Sessions appear here after Claude Code writes local JSONL history + under your configured projects directory. +

+
+
+ + + +
+
+

+ {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."} +

+ + + +
+
+ ); +} + +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 ? ( + + ) : null} +
+ + {messagesQuery.hasNextPage ? ( +
+ +
+ ) : 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 ( +
+
+
+
+
+ + + +
+ + {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 ? ( + + ) : null} +
+ +
+ {message.thinking_blocks.length > 0 ? ( +
+ + {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 ( +
+ + + {open ? ( +
+
+
+

+ Input + {tool.input_truncated ? " · truncated" : ""} +

+ +
+
+              {inputJson}
+            
+
+ + {tool.result ? ( +
+
+

+ Result + {tool.result_truncated ? " · truncated" : ""} +

+ +
+
+                {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; +}