Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 139 additions & 48 deletions src/app/(dashboard)/[owner]/[repo]/[checkpointId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
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,
Expand All @@ -35,10 +52,27 @@ export default function CheckpointDetailPage({
const { diff, isLoading: diffLoading } = useDiff(
owner,
repo,
checkpoint?.commit_hash || ""
checkpointId
);

const [activeTab, setActiveTab] = useState<Tab>("transcript");
const [activeTab, setActiveTab] = useState<Tab>("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 (
Expand Down Expand Up @@ -79,87 +113,144 @@ export default function CheckpointDetailPage({
]}
/>

<div className="rounded-xl border border-border bg-surface p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="font-mono text-base font-semibold text-foreground">
{checkpointId}
</h2>
<Badge>{checkpoint.branch}</Badge>
</div>
<span className="text-sm text-muted">
{/* Header */}
<div className="space-y-3">
<h1 className="text-lg font-semibold text-foreground leading-snug">
{pageTitle || checkpointId}
</h1>

<div className="flex flex-wrap items-center gap-2 text-sm text-muted">
{/* ID pill */}
<span className="inline-flex items-center rounded-md bg-surface-light border border-border px-2 py-0.5 font-mono text-xs text-muted">
{checkpointId.slice(0, 8)}
</span>

{/* Commit hash pill */}
<span className="inline-flex items-center rounded-md bg-surface-light border border-border px-2 py-0.5 font-mono text-xs text-muted">
{checkpoint.commit_hash.slice(0, 8)}
</span>

<span className="text-muted/60">·</span>

{/* Date */}
<span>
{formatDistanceToNow(new Date(checkpoint.created_at), {
addSuffix: true,
})}
</span>
</div>

<div className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-muted">
<span>
commit{" "}
<span className="font-mono text-foreground">
{checkpoint.commit_hash.slice(0, 8)}
</span>
<span className="text-muted/60">·</span>

{/* Branch */}
<span className="inline-flex items-center gap-1">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted"
>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
<span className="text-foreground">{checkpoint.branch}</span>
</span>

{totalTokens > 0 && (
<>
<span className="text-muted/60">·</span>
<span>{totalTokens.toLocaleString()} tokens</span>
</>
)}

{checkpoint.agent && (
<span>
agent{" "}
<span className="text-foreground">{checkpoint.agent}</span>
</span>
<>
<span className="text-muted/60">·</span>
<span className="inline-flex items-center rounded-full bg-accent-orange/15 px-2 py-0.5 text-xs font-medium text-accent-orange border border-accent-orange/30">
{checkpoint.agent}
</span>
<span className="font-mono text-xs text-accent-orange">
{checkpoint.agent_percent}%
</span>
</>
)}
<span>
attribution{" "}
<span className="font-mono text-accent-light">
{checkpoint.agent_percent}%
</span>
</span>
</div>
</div>

{/* Tabs */}
<div className="flex gap-1 border-b border-border">
<button
onClick={() => setActiveTab("transcript")}
onClick={() => setActiveTab("sessions")}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors cursor-pointer",
activeTab === "transcript"
"inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors cursor-pointer",
activeTab === "sessions"
? "border-b-2 border-accent text-foreground"
: "text-muted hover:text-foreground"
)}
>
Transcript
Sessions
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs",
activeTab === "sessions"
? "bg-accent/20 text-accent-light"
: "bg-surface-light text-muted"
)}
>
1
</span>
</button>
<button
onClick={() => setActiveTab("diff")}
onClick={() => setActiveTab("files")}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors cursor-pointer",
activeTab === "diff"
"inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors cursor-pointer",
activeTab === "files"
? "border-b-2 border-accent text-foreground"
: "text-muted hover:text-foreground"
)}
>
Diff
Files
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs",
activeTab === "files"
? "bg-accent/20 text-accent-light"
: "bg-surface-light text-muted"
)}
>
{fileCount}
</span>
</button>
</div>

{activeTab === "transcript" && (
sessionLoading ? (
{/* Tab content */}
{activeTab === "sessions" &&
(sessionLoading ? (
<div className="space-y-3">
<Skeleton className="h-20" />
<Skeleton className="h-32" />
<Skeleton className="h-20" />
</div>
) : (
<TranscriptViewer messages={messages || []} />
)
)}
<TranscriptViewer
messages={messages || []}
agentName={checkpoint.agent || undefined}
agentPercent={checkpoint.agent_percent}
/>
))}

{activeTab === "diff" && (
diffLoading ? (
{activeTab === "files" &&
(diffLoading ? (
<Skeleton className="h-64" />
) : (
<DiffViewer diff={diff || ""} />
)
)}
))}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 });
}
2 changes: 2 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
--border: #27272a;
--success: #22c55e;
--warning: #eab308;
--accent-orange: #d97706;
}

@theme inline {
Expand All @@ -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);
}
Expand Down
52 changes: 52 additions & 0 deletions src/components/ui/tool-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,55 @@ export function ToolBadge({ name, className }: ToolBadgeProps) {
</span>
);
}

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 (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-light px-2.5 py-1 text-xs text-muted",
className
)}
>
{unique.slice(0, 3).map((_, i) => (
<svg
key={i}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
))}
<span>{count} tools used</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</span>
);
}
Loading