From 8c6767caf6305e9fa2b0114848d25eba76df5ff2 Mon Sep 17 00:00:00 2001 From: jcleira Date: Fri, 20 Feb 2026 06:27:59 +0100 Subject: [PATCH] Redesign Checkpoint Detail Page and Serve Diffs * Objective Redesign the checkpoint detail page UI and update diff retrieval to read from the checkpoint orphan branch instead of GitHub's commit API. * Why The previous UI lacked clear metadata presentation and session structure. Fetching diffs from GitHub's commit API fails for unpushed or amend-rebased commits. Reading diff.patch directly from the checkpoint orphan branch ensures diffs are always available. * How Overhaul the checkpoint header with ID and commit pills, a branch icon, token count, and agent attribution badge. Replace the chat-style transcript with a collapsible SessionAccordion showing session stats such as duration, steps, tools, and tokens, along with per-message avatars. Rename the Transcript and Diff tabs to Sessions and Files, and display badge counts for each tab. Introduce a ToolUsagePill component to replace the per-message ToolBadge implementation. Improve JSONL parsing by filtering message types, removing system tags, extracting content from supported fields, and skipping thinking blocks. Correct the session tree path from {shard}/{rest}/full.jsonl to {shard}/{rest}/0/full.jsonl. Replace getCommitDiff with getCheckpointDiff to read diff.patch from the orphan tree. Add a /checkpoints/[id]/diff API route and remove the /diff/[commitHash] route. Update the useDiff hook to accept checkpointId instead of commitHash. Add an accent-orange color for agent attribution styling and introduce a toolNames field to the Message type. --- .../[owner]/[repo]/[checkpointId]/page.tsx | 187 ++++++++---- .../[id]/diff}/route.ts | 10 +- src/app/globals.css | 2 + src/components/ui/tool-badge.tsx | 52 ++++ src/components/ui/transcript-viewer.tsx | 270 +++++++++++++++--- src/hooks/use-checkpoints.ts | 6 +- src/lib/github/diff.ts | 43 ++- src/lib/github/session.ts | 88 ++++-- src/types/checkpoint.ts | 1 + 9 files changed, 531 insertions(+), 128 deletions(-) rename src/app/api/github/repos/[owner]/[repo]/{diff/[commitHash] => checkpoints/[id]/diff}/route.ts (60%) diff --git a/src/app/(dashboard)/[owner]/[repo]/[checkpointId]/page.tsx b/src/app/(dashboard)/[owner]/[repo]/[checkpointId]/page.tsx index dccb24b..9dc05ef 100644 --- a/src/app/(dashboard)/[owner]/[repo]/[checkpointId]/page.tsx +++ b/src/app/(dashboard)/[owner]/[repo]/[checkpointId]/page.tsx @@ -1,20 +1,37 @@ "use client"; -import { use, useState } from "react"; +import { use, useState, useMemo } from "react"; import { useCheckpoint, useSession as useCheckpointSession, useDiff, } from "@/hooks/use-checkpoints"; import { Breadcrumb } from "@/components/ui/breadcrumb"; -import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { TranscriptViewer } from "@/components/ui/transcript-viewer"; import { DiffViewer } from "@/components/ui/diff-viewer"; import { formatDistanceToNow } from "date-fns"; import { cn } from "@/lib/utils"; -type Tab = "transcript" | "diff"; +type Tab = "sessions" | "files"; + +function countDiffFiles(diff: string | undefined): number { + if (!diff) return 0; + return (diff.match(/^diff --git/gm) || []).length; +} + +function extractToolCount(messages: { content: string }[]): number { + const tools = new Set(); + for (const msg of messages) { + const matches = msg.content.match(/\[Tool: (.+?)\]/g); + if (matches) { + for (const m of matches) { + tools.add(m.replace("[Tool: ", "").replace("]", "")); + } + } + } + return tools.size; +} export default function CheckpointDetailPage({ params, @@ -35,10 +52,27 @@ export default function CheckpointDetailPage({ const { diff, isLoading: diffLoading } = useDiff( owner, repo, - checkpoint?.commit_hash || "" + checkpointId ); - const [activeTab, setActiveTab] = useState("transcript"); + const [activeTab, setActiveTab] = useState("sessions"); + + const pageTitle = useMemo(() => { + if (!messages || messages.length === 0) return null; + const firstHuman = messages.find( + (m) => m.role === "human" || m.role === "user" + ); + if (!firstHuman) return null; + const text = firstHuman.content.trim(); + return text.length > 100 ? text.slice(0, 100) + "..." : text; + }, [messages]); + + const totalTokens = useMemo(() => { + if (!messages) return 0; + return messages.reduce((sum, m) => sum + (m.tokens || 0), 0); + }, [messages]); + + const fileCount = useMemo(() => countDiffFiles(diff), [diff]); if (cpLoading) { return ( @@ -79,87 +113,144 @@ export default function CheckpointDetailPage({ ]} /> -
-
-
-

- {checkpointId} -

- {checkpoint.branch} -
- + {/* Header */} +
+

+ {pageTitle || checkpointId} +

+ +
+ {/* ID pill */} + + {checkpointId.slice(0, 8)} + + + {/* Commit hash pill */} + + {checkpoint.commit_hash.slice(0, 8)} + + + · + + {/* Date */} + {formatDistanceToNow(new Date(checkpoint.created_at), { addSuffix: true, })} -
-
- - commit{" "} - - {checkpoint.commit_hash.slice(0, 8)} - + · + + {/* Branch */} + + + + + + + + {checkpoint.branch} + + {totalTokens > 0 && ( + <> + · + {totalTokens.toLocaleString()} tokens + + )} + {checkpoint.agent && ( - - agent{" "} - {checkpoint.agent} - + <> + · + + {checkpoint.agent} + + + {checkpoint.agent_percent}% + + )} - - attribution{" "} - - {checkpoint.agent_percent}% - -
+ {/* Tabs */}
- {activeTab === "transcript" && ( - sessionLoading ? ( + {/* Tab content */} + {activeTab === "sessions" && + (sessionLoading ? (
) : ( - - ) - )} + + ))} - {activeTab === "diff" && ( - diffLoading ? ( + {activeTab === "files" && + (diffLoading ? ( ) : ( - ) - )} + ))}
); } diff --git a/src/app/api/github/repos/[owner]/[repo]/diff/[commitHash]/route.ts b/src/app/api/github/repos/[owner]/[repo]/checkpoints/[id]/diff/route.ts similarity index 60% rename from src/app/api/github/repos/[owner]/[repo]/diff/[commitHash]/route.ts rename to src/app/api/github/repos/[owner]/[repo]/checkpoints/[id]/diff/route.ts index f8adee8..4ecd8eb 100644 --- a/src/app/api/github/repos/[owner]/[repo]/diff/[commitHash]/route.ts +++ b/src/app/api/github/repos/[owner]/[repo]/checkpoints/[id]/diff/route.ts @@ -1,21 +1,19 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { createOctokit } from "@/lib/github"; -import { getCommitDiff } from "@/lib/github/diff"; +import { getCheckpointDiff } from "@/lib/github/diff"; export async function GET( _request: Request, - { - params, - }: { params: Promise<{ owner: string; repo: string; commitHash: string }> } + { params }: { params: Promise<{ owner: string; repo: string; id: string }> } ) { const session = await auth(); if (!session?.accessToken) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const { owner, repo, commitHash } = await params; + const { owner, repo, id } = await params; const octokit = createOctokit(session.accessToken); - const diff = await getCommitDiff(octokit, owner, repo, commitHash); + const diff = await getCheckpointDiff(octokit, owner, repo, id); return NextResponse.json({ diff }); } diff --git a/src/app/globals.css b/src/app/globals.css index 7ab0db9..57679c3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -12,6 +12,7 @@ --border: #27272a; --success: #22c55e; --warning: #eab308; + --accent-orange: #d97706; } @theme inline { @@ -26,6 +27,7 @@ --color-border: var(--border); --color-success: var(--success); --color-warning: var(--warning); + --color-accent-orange: var(--accent-orange); --font-sans: var(--font-inter); --font-mono: var(--font-jetbrains-mono); } diff --git a/src/components/ui/tool-badge.tsx b/src/components/ui/tool-badge.tsx index c92dfc3..da3f42a 100644 --- a/src/components/ui/tool-badge.tsx +++ b/src/components/ui/tool-badge.tsx @@ -29,3 +29,55 @@ export function ToolBadge({ name, className }: ToolBadgeProps) { ); } + +interface ToolUsagePillProps { + toolNames: string[]; + className?: string; +} + +export function ToolUsagePill({ toolNames, className }: ToolUsagePillProps) { + if (toolNames.length === 0) return null; + + const unique = [...new Set(toolNames)]; + const count = unique.length; + + return ( + + {unique.slice(0, 3).map((_, i) => ( + + + + ))} + {count} tools used + + + + + ); +} diff --git a/src/components/ui/transcript-viewer.tsx b/src/components/ui/transcript-viewer.tsx index 31682bc..aa340b3 100644 --- a/src/components/ui/transcript-viewer.tsx +++ b/src/components/ui/transcript-viewer.tsx @@ -2,62 +2,184 @@ import { useState } from "react"; import { cn } from "@/lib/utils"; -import { ToolBadge } from "./tool-badge"; +import { ToolUsagePill } from "./tool-badge"; import type { Message } from "@/types/checkpoint"; interface TranscriptViewerProps { messages: Message[]; + agentName?: string; + agentPercent?: number; className?: string; } +interface SessionStats { + prompt: string; + steps: number; + toolCount: number; + toolNames: string[]; + totalTokens: number; + duration: string; + startTime: string; +} + const COLLAPSE_THRESHOLD = 500; -function MessageBubble({ message }: { message: Message }) { +function extractToolNames(content: string): string[] { + const matches = content.match(/\[Tool: (.+?)\]/g); + if (!matches) return []; + return matches.map((m) => m.replace("[Tool: ", "").replace("]", "")); +} + +function stripToolPatterns(content: string): string { + return content + .replace(/\[Tool: .+?\]/g, "") + .replace(/\[Tool Result: [\s\S]*?\]/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + +function computeSessionStats(messages: Message[]): SessionStats { + const humanMessages = messages.filter( + (m) => m.role === "human" || m.role === "user" + ); + const prompt = + humanMessages.length > 0 + ? humanMessages[0].content.slice(0, 80) + + (humanMessages[0].content.length > 80 ? "..." : "") + : "Session"; + + const allToolNames: string[] = []; + for (const msg of messages) { + allToolNames.push(...extractToolNames(msg.content)); + } + const uniqueTools = [...new Set(allToolNames)]; + + const totalTokens = messages.reduce((sum, m) => sum + (m.tokens || 0), 0); + + let duration = "—"; + if (messages.length >= 2) { + const first = new Date(messages[0].timestamp).getTime(); + const last = new Date(messages[messages.length - 1].timestamp).getTime(); + if (!isNaN(first) && !isNaN(last) && last > first) { + duration = formatDuration(last - first); + } + } + + let startTime = ""; + if (messages.length > 0) { + const d = new Date(messages[0].timestamp); + if (!isNaN(d.getTime())) { + startTime = d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + } + } + + return { + prompt, + steps: humanMessages.length, + toolCount: uniqueTools.length, + toolNames: allToolNames, + totalTokens, + duration, + startTime, + }; +} + +function AttributionBar({ + percent, + className, +}: { + percent: number; + className?: string; +}) { + return ( +
+
+
+
+ {percent}% AI +
+ ); +} + +function HumanAvatar() { + return ( +
+ + + + +
+ ); +} + +function AssistantAvatar() { + return ( +
+ A\ +
+ ); +} + +function MessageRow({ message }: { message: Message }) { const [expanded, setExpanded] = useState(false); const isHuman = message.role === "human" || message.role === "user"; - const isLong = message.content.length > COLLAPSE_THRESHOLD; + const toolNames = extractToolNames(message.content); + const cleanContent = isHuman + ? message.content + : stripToolPatterns(message.content); + const isLong = cleanContent.length > COLLAPSE_THRESHOLD; const displayContent = !expanded && isLong - ? message.content.slice(0, COLLAPSE_THRESHOLD) + "..." - : message.content; - - // Check for tool usage patterns - const toolMatch = message.content.match(/\[Tool: (.+?)\]/g); + ? cleanContent.slice(0, COLLAPSE_THRESHOLD) + "..." + : cleanContent; return ( -
-
+
+ {isHuman ? : } +
- - {isHuman ? "Human" : "Assistant"} + + {isHuman ? "You" : "Assistant"} - {message.tokens && ( + {message.tokens != null && message.tokens > 0 && ( {message.tokens.toLocaleString()} tokens )}
-
+
{displayContent}
- {toolMatch && ( -
- {toolMatch.map((match, idx) => { - const name = match.replace("[Tool: ", "").replace("]", ""); - return ; - })} + {!isHuman && toolNames.length > 0 && ( +
+
)} {isLong && ( @@ -73,13 +195,87 @@ function MessageBubble({ message }: { message: Message }) { ); } +function SessionAccordion({ + messages, + agentName, + agentPercent, +}: { + messages: Message[]; + agentName?: string; + agentPercent?: number; +}) { + const [open, setOpen] = useState(true); + const stats = computeSessionStats(messages); + + return ( +
+ + + {open && ( +
+ {messages.map((msg, idx) => ( + + ))} +
+ )} +
+ ); +} + export function TranscriptViewer({ messages, + agentName, + agentPercent, className, }: TranscriptViewerProps) { if (messages.length === 0) { return ( -
+

No transcript data available

); @@ -87,9 +283,11 @@ export function TranscriptViewer({ return (
- {messages.map((msg, idx) => ( - - ))} +
); } diff --git a/src/hooks/use-checkpoints.ts b/src/hooks/use-checkpoints.ts index 357d801..8f88b43 100644 --- a/src/hooks/use-checkpoints.ts +++ b/src/hooks/use-checkpoints.ts @@ -46,10 +46,10 @@ export function useSession(owner: string, repo: string, id: string) { }; } -export function useDiff(owner: string, repo: string, commitHash: string) { +export function useDiff(owner: string, repo: string, checkpointId: string) { const { data, error, isLoading } = useSWR<{ diff: string }>( - commitHash - ? `/api/github/repos/${owner}/${repo}/diff/${commitHash}` + checkpointId + ? `/api/github/repos/${owner}/${repo}/checkpoints/${checkpointId}/diff` : null, fetcher ); diff --git a/src/lib/github/diff.ts b/src/lib/github/diff.ts index 298ae79..cb40fd6 100644 --- a/src/lib/github/diff.ts +++ b/src/lib/github/diff.ts @@ -1,23 +1,48 @@ import { Octokit } from "@octokit/rest"; -export async function getCommitDiff( +const CHECKPOINT_BRANCH = "partio/checkpoints/v1"; + +export async function getCheckpointDiff( octokit: Octokit, owner: string, repo: string, - commitHash: string + checkpointId: string ): Promise { + const shard = checkpointId.slice(0, 2); + const rest = checkpointId.slice(2); + try { - const { data } = await octokit.repos.getCommit({ + const { data: ref } = await octokit.git.getRef({ + owner, + repo, + ref: `heads/${CHECKPOINT_BRANCH}`, + }); + + const { data: commit } = await octokit.git.getCommit({ + owner, + repo, + commit_sha: ref.object.sha, + }); + + const { data: tree } = await octokit.git.getTree({ + owner, + repo, + tree_sha: commit.tree.sha, + recursive: "1", + }); + + const diffPath = `${shard}/${rest}/0/diff.patch`; + const entry = (tree.tree || []).find((e) => e.path === diffPath); + + if (!entry?.sha) return ""; + + const { data: blob } = await octokit.git.getBlob({ owner, repo, - ref: commitHash, - mediaType: { - format: "diff", - }, + file_sha: entry.sha, }); - // When format is "diff", data is the raw diff string - return data as unknown as string; + return Buffer.from(blob.content, "base64").toString("utf-8"); } catch { return ""; } diff --git a/src/lib/github/session.ts b/src/lib/github/session.ts index 17351bf..1ddc184 100644 --- a/src/lib/github/session.ts +++ b/src/lib/github/session.ts @@ -32,7 +32,7 @@ export async function getSessionData( recursive: "1", }); - const sessionPath = `${shard}/${rest}/full.jsonl`; + const sessionPath = `${shard}/${rest}/0/full.jsonl`; const entry = (tree.tree || []).find((e) => e.path === sessionPath); if (!entry?.sha) return []; @@ -46,35 +46,71 @@ export async function getSessionData( const content = Buffer.from(blob.content, "base64").toString("utf-8"); const lines = content.split("\n").filter((line) => line.trim()); - return lines.map((line) => { - const parsed = JSON.parse(line); - return { - role: parsed.role || "unknown", - content: extractText(parsed.content), - timestamp: parsed.timestamp || "", - tokens: parsed.tokens, - } satisfies Message; - }); + const MESSAGE_TYPES = new Set(["user", "human", "assistant"]); + + return lines + .map((line) => JSON.parse(line)) + .filter((parsed: any) => MESSAGE_TYPES.has(parsed.type || parsed.role)) + .map((parsed: any) => { + return { + role: parsed.type || parsed.role || "unknown", + content: stripSystemTags(extractText(parsed)), + timestamp: parsed.timestamp || "", + tokens: parsed.tokens, + } satisfies Message; + }) + .filter((msg) => msg.content.trim() !== ""); } catch { return []; } } -function extractText(content: unknown): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .map((block) => { - if (typeof block === "string") return block; - if (block?.type === "text") return block.text || ""; - if (block?.type === "tool_use") - return `[Tool: ${block.name || "unknown"}]`; - if (block?.type === "tool_result") - return `[Tool Result: ${typeof block.content === "string" ? block.content.slice(0, 200) : "..."}]`; - return ""; - }) - .filter(Boolean) - .join("\n"); +function extractContentBlocks(blocks: any[]): string { + return blocks + .map((block: any) => { + if (typeof block === "string") return block; + if (block?.type === "text") return block.text || ""; + if (block?.type === "tool_use") + return `[Tool: ${block.name || "unknown"}]`; + if (block?.type === "tool_result") + return `[Tool Result: ${typeof block.content === "string" ? block.content.slice(0, 200) : "..."}]`; + // Skip thinking, redacted_thinking, and unknown block types + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +const SYSTEM_TAG_RE = + /<(local-command-caveat|command-name|command-message|command-args|local-command-stdout|system-reminder|user-prompt-submit-hook)>[\s\S]*?<\/\1>/g; + +function stripSystemTags(text: string): string { + return text.replace(SYSTEM_TAG_RE, ""); +} + +function extractText(entry: Record): string { + // 1. Try contentBlocks (top-level array of {type, text}) + if (Array.isArray(entry.contentBlocks)) { + return extractContentBlocks(entry.contentBlocks); + } + + // 2. Try message field + if (entry.message != null) { + if (typeof entry.message === "string") return entry.message; + const msg = entry.message as any; + if (typeof msg.content === "string") return msg.content; + if (Array.isArray(msg.content)) { + return extractContentBlocks(msg.content); + } } - return String(content ?? ""); + + // 3. Try content field + if (entry.content != null) { + if (typeof entry.content === "string") return entry.content; + if (Array.isArray(entry.content)) { + return extractContentBlocks(entry.content as any[]); + } + } + + return ""; } diff --git a/src/types/checkpoint.ts b/src/types/checkpoint.ts index 207e19f..67bebf8 100644 --- a/src/types/checkpoint.ts +++ b/src/types/checkpoint.ts @@ -20,6 +20,7 @@ export interface Message { content: string; timestamp: string; tokens?: number; + toolNames?: string[]; } export interface RepoWithCheckpoints {