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
1,668 changes: 1,637 additions & 31 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@
},
"dependencies": {
"@octokit/rest": "^21.1.1",
"@types/react-syntax-highlighter": "^15.5.13",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.34.1",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.25",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1",
"swr": "^2.3.3"
},
"devDependencies": {
Expand Down
44 changes: 42 additions & 2 deletions src/app/(dashboard)/[owner]/[repo]/[checkpointId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
"use client";

import { use, useState, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
import {
useCheckpoint,
useSession as useCheckpointSession,
useDiff,
usePlan,
} from "@/hooks/use-checkpoints";
import { Breadcrumb } from "@/components/ui/breadcrumb";
import { Skeleton } from "@/components/ui/skeleton";
import { TranscriptViewer } from "@/components/ui/transcript-viewer";
import { DiffViewer } from "@/components/ui/diff-viewer";
import { PlanViewer } from "@/components/ui/plan-viewer";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";

type Tab = "sessions" | "files";
type Tab = "sessions" | "files" | "plan";

function countDiffFiles(diff: string | undefined): number {
if (!diff) return 0;
Expand All @@ -39,6 +43,8 @@ export default function CheckpointDetailPage({
params: Promise<{ owner: string; repo: string; checkpointId: string }>;
}) {
const { owner, repo, checkpointId } = use(params);
const searchParams = useSearchParams();
const { data: session } = useSession();
const { checkpoint, isLoading: cpLoading } = useCheckpoint(
owner,
repo,
Expand All @@ -54,8 +60,16 @@ export default function CheckpointDetailPage({
repo,
checkpointId
);
const { plan, isLoading: planLoading } = usePlan(
owner,
repo,
checkpointId
);

const [activeTab, setActiveTab] = useState<Tab>("sessions");
const initialTab = searchParams.get("tab");
const [activeTab, setActiveTab] = useState<Tab>(
initialTab === "plan" || initialTab === "files" ? initialTab : "sessions"
);

const pageTitle = useMemo(() => {
if (!messages || messages.length === 0) return null;
Expand Down Expand Up @@ -227,6 +241,19 @@ export default function CheckpointDetailPage({
{fileCount}
</span>
</button>
{checkpoint.plan_slug && (
<button
onClick={() => setActiveTab("plan")}
className={cn(
"inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors cursor-pointer",
activeTab === "plan"
? "border-b-2 border-accent text-foreground"
: "text-muted hover:text-foreground"
)}
>
Plan
</button>
)}
</div>

{/* Tab content */}
Expand All @@ -242,6 +269,8 @@ export default function CheckpointDetailPage({
messages={messages || []}
agentName={checkpoint.agent || undefined}
agentPercent={checkpoint.agent_percent}
userName={session?.user?.login || undefined}
userImage={session?.user?.image || undefined}
/>
))}

Expand All @@ -251,6 +280,17 @@ export default function CheckpointDetailPage({
) : (
<DiffViewer diff={diff || ""} />
))}

{activeTab === "plan" &&
(planLoading ? (
<Skeleton className="h-64" />
) : plan ? (
<PlanViewer plan={plan} />
) : (
<div className="rounded-xl border border-border bg-surface p-8 text-center">
<p className="text-sm text-muted">No plan data available</p>
</div>
))}
</div>
);
}
9 changes: 9 additions & 0 deletions src/app/(dashboard)/[owner]/[repo]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ export default function RepoDetailPage({
{cp.agent && (
<span className="text-xs text-muted">{cp.agent}</span>
)}
{cp.plan_slug && (
<Link
href={`/${owner}/${repo}/${cp.id}?tab=plan`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center rounded-md bg-accent/10 border border-accent/20 px-1.5 py-0.5 text-xs text-accent-light hover:bg-accent/20 transition-colors"
>
Plan
</Link>
)}
</div>
<div className="flex items-center gap-4">
<span className="font-mono text-sm text-accent-light">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { createOctokit } from "@/lib/github";
import { getCheckpointPlan } from "@/lib/github/plan";

export async function GET(
_request: Request,
{ 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, id } = await params;
const octokit = createOctokit(session.accessToken);
const plan = await getCheckpointPlan(octokit, owner, repo, id);
return NextResponse.json({ plan });
}
158 changes: 158 additions & 0 deletions src/components/ui/plan-viewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"use client";

import { useState, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Button } from "@/components/ui/button";

interface PlanViewerProps {
plan: string;
}

export function PlanViewer({ plan }: PlanViewerProps) {
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(plan);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [plan]);

const handleDownload = useCallback(() => {
const blob = new Blob([plan], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "plan.md";
a.click();
URL.revokeObjectURL(url);
}, [plan]);

return (
<div className="rounded-xl border border-border bg-surface">
{/* Sticky toolbar */}
<div className="sticky top-0 z-10 flex items-center justify-end gap-2 border-b border-border bg-surface/80 backdrop-blur px-4 py-2 rounded-t-xl">
<Button variant="ghost" size="sm" onClick={handleCopy}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button variant="ghost" size="sm" onClick={handleDownload}>
Download
</Button>
</div>

{/* Markdown content */}
<div className="p-5">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-2xl font-semibold text-foreground mt-6 mb-3 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-xl font-semibold text-foreground mt-5 mb-2">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-lg font-semibold text-foreground mt-4 mb-2">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-base font-semibold text-foreground mt-3 mb-1">
{children}
</h4>
),
p: ({ children }) => (
<p className="text-sm text-foreground/90 leading-relaxed mb-3">
{children}
</p>
),
ul: ({ children }) => (
<ul className="list-disc list-inside text-sm text-foreground/90 mb-3 space-y-1">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside text-sm text-foreground/90 mb-3 space-y-1">
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
a: ({ href, children }) => (
<a
href={href}
className="text-accent-light hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
code: ({ className, children }) => {
const match = className?.match(/language-(\w+)/);
if (match) {
return (
<SyntaxHighlighter
style={oneDark}
language={match[1]}
customStyle={{
margin: 0,
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
);
}
return (
<code className="rounded bg-surface-light border border-border px-1.5 py-0.5 text-xs font-mono text-foreground/90">
{children}
</code>
);
},
pre: ({ children }) => (
<div className="mb-3">{children}</div>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-accent/50 pl-4 text-sm text-muted italic mb-3">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-3">
<table className="w-full text-sm border-collapse border border-border">
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-surface-light">{children}</thead>
),
th: ({ children }) => (
<th className="border border-border px-3 py-2 text-left font-semibold text-foreground">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-border px-3 py-2 text-foreground/90">
{children}
</td>
),
hr: () => <hr className="border-border my-4" />,
strong: ({ children }) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
}}
>
{plan}
</ReactMarkdown>
</div>
</div>
);
}
Loading