Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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