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 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ coverage
# Claude Code
.claude/scheduled_tasks.lock
.claude/settings.local.json
.gstack/
12 changes: 12 additions & 0 deletions packages/cli/src/__tests__/pull-request.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const GITHUB_ORIGIN = "git@github.com:owner/repo.git";
const PR_JSON = JSON.stringify({
number: 7,
title: "Add the thing",
body: "This PR adds the thing.\n\nDetails here.",
url: "https://github.com/owner/repo/pull/7",
state: "OPEN",
isDraft: false,
Expand Down Expand Up @@ -263,6 +264,7 @@ describe("pull-request API", () => {
expect(pullRequest).toEqual({
number: 7,
title: "Add the thing",
body: "This PR adds the thing.\n\nDetails here.",
html_url: "https://github.com/owner/repo/pull/7",
state: "open",
draft: false,
Expand All @@ -279,6 +281,16 @@ describe("pull-request API", () => {
});
});

it("coerces a null gh body to an empty string instead of dropping the PR", async () => {
const prNoBody = JSON.stringify({ ...JSON.parse(PR_JSON), body: null });
await writeFakeGh({ pr: prNoBody, restPr: REST_PR_JSON });
const runId = insertRun(GITHUB_ORIGIN);
const res = await request(await start(), `/api/runs/${runId}/pull-request`);
expect(res.status).toBe(200);
const { pullRequest } = JSON.parse(res.body) as PullRequestResponse;
expect(pullRequest?.body).toBe("");
});

it("returns null when gh finds no PR for the branch", async () => {
await writeFakeGh({});
const runId = insertRun(GITHUB_ORIGIN);
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/github/pull-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const GhAuthorSchema = z
const GhPullRequestSchema = z.object({
number: z.number(),
title: z.string(),
// gh emits null (not "") for a PR with no description, same as mergedAt; coerce below.
body: z.string().nullable(),
url: z.string(),
state: z.enum(["OPEN", "CLOSED", "MERGED"]),
isDraft: z.boolean(),
Expand All @@ -43,6 +45,7 @@ const GhPullRequestSchema = z.object({
const PR_FIELDS = [
"number",
"title",
"body",
"url",
"state",
"isDraft",
Expand Down Expand Up @@ -127,6 +130,7 @@ export async function getPullRequest(
return {
number: pr.number,
title: pr.title,
body: pr.body ?? "",
html_url: pr.url,
// REST `state` is open|closed; merged implies closed.
state: pr.state === "OPEN" ? "open" : "closed",
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/pull-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export type GitHubUser = z.infer<typeof GitHubUserSchema>;
export const PullRequestSchema = z.object({
number: z.number().int().positive(),
title: z.string(),
/** Raw markdown body of the PR description; empty string when none was provided. */
body: z.string(),
html_url: z.string(),
state: z.enum(["open", "closed"]),
draft: z.boolean(),
Expand Down
2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"react-dom": "^19.2.3",
"react-hotkeys-hook": "^5.3.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
Expand Down
52 changes: 23 additions & 29 deletions packages/web/src/app/runs.$runId.index.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,44 @@
import { createFileRoute } from "@tanstack/react-router";
import { useMemo } from "react";
import { PrologueSection } from "@/components/prologue/prologue-section";
import { OverviewSidebar } from "@/components/pull-request/overview-sidebar";
import { useChapters } from "@/lib/use-chapters";
import { countViewedChapters, useViewStateData } from "@/lib/use-view-state";
import { usePullRequest } from "@/lib/use-pull-request";
import { ChaptersIndexPage } from "@/routes/chapters-index-page";

export const Route = createFileRoute("/runs/$runId/")({
component: ChaptersRoute,
});

// Both columns pin below the sticky tab bar and scroll independently. The
// `--content-top`/`--main-height` vars are set by the pull-request layout.
const COLUMN_CLASS =
"scrollbar-thin min-w-0 @4xl:sticky @4xl:top-[var(--content-top)] @4xl:max-h-[calc(var(--main-height)_-_var(--content-top))] @4xl:overflow-y-auto @4xl:pb-6";

function ChaptersRoute() {
const { runId } = Route.useParams();
const { data, isLoading } = useChapters(runId);
const { chapterIdSet } = useViewStateData(runId);
const { data: prData } = usePullRequest(runId);

const chapters = data?.chapters;
const viewedCount = useMemo(
() => countViewedChapters(chapters, chapterIdSet),
[chapters, chapterIdSet],
);
const prologue = data?.prologue ?? null;
const pullRequest = prData?.pullRequest ?? null;

const prologue = data?.prologue;
const hasPrologue = prologue !== null;
const hasDescription = Boolean(pullRequest?.user && pullRequest.body.trim().length > 0);
Comment thread
dastratakos marked this conversation as resolved.

if (!prologue) {
return (
<ChaptersIndexPage
chapters={chapters}
runId={runId}
viewedCount={viewedCount}
isLoading={isLoading}
/>
);
// Without prologue or PR description there's nothing for the left column —
// the chapters list spans the full width.
if (!hasPrologue && !hasDescription) {
return <ChaptersIndexPage chapters={chapters} runId={runId} isLoading={isLoading} />;
}

return (
<div className="@container">
<div className="grid grid-cols-1 gap-6 @4xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<div className="scrollbar-thin min-w-0 @4xl:sticky @4xl:top-[var(--content-top)] @4xl:max-h-[calc(var(--main-height)_-_var(--content-top))] @4xl:overflow-y-auto @4xl:pr-4 @4xl:pb-6">
<PrologueSection prologue={prologue} />
<div className="@container h-full">
<div className="grid h-full grid-cols-1 gap-6 @4xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<div className={COLUMN_CLASS}>
<OverviewSidebar prologue={prologue} pullRequest={pullRequest} />
</div>
<div className="min-w-0">
<ChaptersIndexPage
chapters={chapters}
runId={runId}
viewedCount={viewedCount}
isLoading={isLoading}
/>
<div className={COLUMN_CLASS}>
<ChaptersIndexPage chapters={chapters} runId={runId} isLoading={isLoading} />
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";

/**
* Sticky header row shared by the overview sidebar (Prologue/Description tabs)
* and the chapters column, so both columns' headers align and pin while their
* content scrolls independently.
*/
export function OverviewColumnHeader({ children }: { children: ReactNode }) {
return (
<div className="sticky top-0 z-10 bg-background pb-3">
<div className="flex h-7 items-center justify-between">{children}</div>
</div>
);
}
107 changes: 107 additions & 0 deletions packages/web/src/components/pull-request/overview-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Prologue } from "@stagereview/types/prologue";
import type { GitHubPullRequest } from "@stagereview/types/pull-request";
import { useCallback, useState } from "react";
import { PrologueSection } from "@/components/prologue/prologue-section";
import { OverviewColumnHeader } from "@/components/pull-request/overview-column-header";
import { PullRequestBodyCard } from "@/components/pull-request/pull-request-body-card";
import { CopyMarkdownButton } from "@/components/shared/copy-markdown-button";
import { formatPrologueAsMarkdown } from "@/lib/format-prologue-markdown";
import { cn } from "@/lib/utils";

const SIDEBAR_TAB = {
PROLOGUE: "prologue",
DESCRIPTION: "description",
} as const;
type SidebarTab = (typeof SIDEBAR_TAB)[keyof typeof SIDEBAR_TAB];

const TAB_CLASS =
"cursor-pointer rounded-md px-2.5 py-1 font-medium text-[11px] uppercase tracking-wider transition-colors";

interface OverviewSidebarProps {
prologue: Prologue | null;
pullRequest: GitHubPullRequest | null;
}

/**
* Left overview column: a Prologue tab (the imported run's structured prologue)
* and a Description tab (the detected PR's markdown body). Each tab is shown
* only when its content exists; the route renders this only when at least one
* does. Mirrors hosted Stage's PrologueSidebar.
*/
export function OverviewSidebar({ prologue, pullRequest }: OverviewSidebarProps) {
const hasPrologue = prologue !== null;
const hasDescription = Boolean(pullRequest?.user && pullRequest.body.trim().length > 0);
// Default to whichever content exists at mount (the sidebar only renders once at
// least one does), preferring the prologue. Capturing the initial tab this way —
// rather than hardcoding Prologue — means a later-arriving tab can't yank the user
// off the one they're reading: if the PR description loads before the prologue, the
// view stays on Description instead of jumping to Prologue when it arrives.
const [activeTab, setActiveTab] = useState<SidebarTab>(() =>
prologue !== null ? SIDEBAR_TAB.PROLOGUE : SIDEBAR_TAB.DESCRIPTION,
);

// Recover to the available tab if the active one ever has no content.
const resolvedTab: SidebarTab =
activeTab === SIDEBAR_TAB.DESCRIPTION && !hasDescription
? SIDEBAR_TAB.PROLOGUE
: activeTab === SIDEBAR_TAB.PROLOGUE && !hasPrologue
? SIDEBAR_TAB.DESCRIPTION
: activeTab;
Comment thread
cursor[bot] marked this conversation as resolved.

const copyPrologue = useCallback(
() => (prologue ? formatPrologueAsMarkdown(prologue) : null),
[prologue],
);

return (
<div>
<OverviewColumnHeader>
<div className="flex items-center gap-1" role="tablist" aria-label="Overview tabs">
{hasPrologue && (
<button
type="button"
role="tab"
aria-selected={resolvedTab === SIDEBAR_TAB.PROLOGUE}
onClick={() => setActiveTab(SIDEBAR_TAB.PROLOGUE)}
className={cn(
TAB_CLASS,
resolvedTab === SIDEBAR_TAB.PROLOGUE
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Prologue
</button>
)}
{hasDescription && (
<button
type="button"
role="tab"
aria-selected={resolvedTab === SIDEBAR_TAB.DESCRIPTION}
onClick={() => setActiveTab(SIDEBAR_TAB.DESCRIPTION)}
className={cn(
TAB_CLASS,
resolvedTab === SIDEBAR_TAB.DESCRIPTION
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Description
</button>
)}
</div>
{resolvedTab === SIDEBAR_TAB.PROLOGUE && prologue && (
<CopyMarkdownButton getMarkdown={copyPrologue} label="prologue" />
)}
</OverviewColumnHeader>

{resolvedTab === SIDEBAR_TAB.PROLOGUE && prologue ? (
<PrologueSection prologue={prologue} />
) : pullRequest ? (
<section className="rounded-lg border bg-card p-4">
<PullRequestBodyCard pullRequest={pullRequest} />
</section>
) : null}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { GitHubPullRequest } from "@stagereview/types/pull-request";
import { MessageSquare } from "lucide-react";
import { UserName } from "@/components/shared/user-name";
import { getUserDisplay } from "@/components/shared/user-utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Markdown } from "@/components/ui/markdown";
import { formatTimeAgo } from "@/lib/format";

/**
* The PR description rendered as a GitHub-style comment: author avatar/header
* plus the markdown body. Mirrors hosted Stage's PullRequestBodyCard, minus the
* write/reaction affordances the CLI doesn't carry.
*/
export function PullRequestBodyCard({ pullRequest }: { pullRequest: GitHubPullRequest }) {
const user = pullRequest.user;
if (!user) return null;

const { profileUrl } = getUserDisplay(user);
const hasBody = pullRequest.body.trim().length > 0;

return (
<div className="flex items-start gap-3">
<a href={profileUrl} target="_blank" rel="noopener noreferrer" className="shrink-0">
<Avatar className="size-8">
<AvatarImage src={user.avatar_url} alt={user.login} />
<AvatarFallback className="text-xs">{user.login[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</a>
<div className="min-w-0 flex-1">
<p className="mb-1 text-muted-foreground text-sm">
<span className="mr-1.5 inline-flex size-6 items-center justify-center rounded-full bg-muted align-middle text-muted-foreground">
<MessageSquare className="size-3" />
</span>
<UserName user={user} /> commented{" "}
<a
href={pullRequest.html_url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<time
dateTime={pullRequest.created_at}
title={new Date(pullRequest.created_at).toLocaleString()}
>
{formatTimeAgo(pullRequest.created_at)}
</time>
</a>
</p>
{hasBody ? (
<Markdown content={pullRequest.body} allowHtml />
) : (
<p className="text-muted-foreground text-sm italic">No description provided.</p>
)}
</div>
</div>
);
}
40 changes: 40 additions & 0 deletions packages/web/src/components/shared/copy-markdown-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Copy } from "lucide-react";
import { useCallback } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/sonner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";

interface CopyMarkdownButtonProps {
/** Produces the Markdown to copy. Returning null/empty skips the copy. */
getMarkdown: () => string | null;
/** Lowercase noun for the toast, e.g. "prologue" → "Copied prologue to clipboard". */
label: string;
}

export function CopyMarkdownButton({ getMarkdown, label }: CopyMarkdownButtonProps) {
const handleCopy = useCallback(() => {
const markdown = getMarkdown();
if (!markdown) return;
navigator.clipboard.writeText(markdown).then(
() => toast.success(`Copied ${label} to clipboard`),
() => toast.error("Failed to copy to clipboard"),
);
}, [getMarkdown, label]);

return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={`Copy ${label}`}
className="size-6 cursor-pointer rounded-md text-muted-foreground"
onClick={handleCopy}
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Copy {label} as Markdown</TooltipContent>
</Tooltip>
);
}
Loading
Loading