diff --git a/DESIGN.md b/DESIGN.md
index c0d30965..60bd789f 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -310,7 +310,10 @@ such detail into a token. New layout spacing must use the scale.
identity ties. Recent first is one flat activity-ordered list labeling each
row with its repository. Rows retain compact tree density,
truncation, ages and metadata.
- Show only current heads with changes. The current stream uses `--accent-bg`,
+ Show only current heads with changes; closed and merged GitHub pull requests
+ are not current. A pull request row shows a pull request (or draft) icon
+ labeled for assistive technology, `#N title`, and “GitHub · @author” first in
+ its metadata. The current stream uses `--accent-bg`,
accent text and `aria-current="page"`. Unopened arrivals show a small accent
dot labeled “New changes”; collapsed repositories carry their streams' dots.
Repository collapse state persists per browser.
@@ -323,6 +326,13 @@ such detail into a token. New layout spacing must use the scale.
`aria-current="true"` with accent text on `--selected` for the open review's
repository. A mark (“Open latest in {repo}”) opens that repository's most
active stream; the rail stays collapsed.
+- A GitHub pull request review shows a banner on `--panel` above the review
+ notices: a state icon, muted `#N` and the semibold title, a status `Badge`
+ (Open success, Draft neutral, Merged accent, Closed error), `@author`, muted
+ monospace head → base, an outline-muted “Open on GitHub” `LinkButton`, and a
+ “Show description” disclosure, collapsed by default and persisted per
+ browser. The description is `MarkdownText`, bounded by `--reading-width`, and
+ scrolls within the banner rather than pushing the diff away.
- A newer head for the viewed stream shows a one-line `--accent-bg` notice below
the header with an outline-muted “View latest” action. Never swap the open review.
- Unopened arrivals are marked only by the column's and rail's accent dots and
@@ -433,6 +443,8 @@ with a semantic variant instead of overriding it.
| Need | Component (`apps/web/src/components`) |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| Navigation styled as a button | `link-button` (keeps link semantics; `external` opens a new tab) |
+| Untrusted Markdown prose | `markdown` (`MarkdownText`: no raw HTML, images as links) |
| Action, link-styled trigger | `ui/button` (variants: default, outline, outline-muted, outline-check, ghost, shell, rail, subtle, destructive, destructive-solid, link) |
| Icon-only action | `icon-button` (label is name and tooltip) |
| Primary action plus menu | `split-button` |
diff --git a/apps/web/package.json b/apps/web/package.json
index da630f6b..1c0a94de 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -22,6 +22,8 @@
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
+ "react-markdown": "catalog:",
+ "remark-gfm": "catalog:",
"shadcn": "catalog:",
"tw-animate-css": "catalog:"
},
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 82f74c85..8aaea01d 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -6,6 +6,7 @@ import { FileExplorerNav } from "./FileExplorerNav.tsx";
import { Header } from "./Header.tsx";
import { Navigation } from "./Navigation.tsx";
import { NewerSnapshotNotice } from "./ProjectsNav.tsx";
+import { PullRequestBanner } from "./PullRequestBanner.tsx";
import { CopyDialog } from "./review.tsx";
import { capabilityEnabled, useSession } from "./session-context.tsx";
import { useSidebarState } from "./sidebar-context.tsx";
@@ -22,6 +23,7 @@ export function App() {
+
diff --git a/apps/web/src/ContextIdentity.tsx b/apps/web/src/ContextIdentity.tsx
index edd5e09e..a61b5d44 100644
--- a/apps/web/src/ContextIdentity.tsx
+++ b/apps/web/src/ContextIdentity.tsx
@@ -32,7 +32,7 @@ import {
type ProjectGroup,
type ProjectStream,
} from "./project-nav.ts";
-import { NewMark, useNow } from "./ProjectsNav.tsx";
+import { NewMark, StreamIcon, useNow } from "./ProjectsNav.tsx";
import { useCatalog } from "./session-context.tsx";
import { useShellPolicy } from "./shell-policy.ts";
@@ -204,7 +204,7 @@ function BranchItem({
return (
-
+
{streamTitle(context)}
{unseen && }
{typeof context.changedFileCount === "number" && (
diff --git a/apps/web/src/ProjectsNav.tsx b/apps/web/src/ProjectsNav.tsx
index 0850800d..f960afa2 100644
--- a/apps/web/src/ProjectsNav.tsx
+++ b/apps/web/src/ProjectsNav.tsx
@@ -3,6 +3,8 @@ import type { ApiContext } from "@diffx/api";
import {
FolderGit2Icon,
GitBranchIcon,
+ GitPullRequestDraftIcon,
+ GitPullRequestIcon,
HistoryIcon,
ListIcon,
PanelLeftCloseIcon,
@@ -40,6 +42,7 @@ import {
latestStream,
newerHead,
projectGroups,
+ pullRequestOf,
recentStreams,
relativeAge,
repoMonogram,
@@ -471,10 +474,7 @@ function StreamRow({
onClick={(event) => onOpen(event, context)}
>
-
+
{streamTitle(context)}
{fresh && !current && }
{typeof context.changedFileCount === "number" && (
@@ -497,6 +497,27 @@ function StreamRow({
);
}
+/** Pull requests carry their GitHub state; other streams show a branch. */
+export function StreamIcon({
+ context,
+ muted = false,
+}: {
+ context: ApiContext;
+ muted?: boolean;
+}) {
+ const pull = pullRequestOf(context);
+ const className = muted ? "text-muted" : undefined;
+ if (!pull) return ;
+ const Icon = pull.draft ? GitPullRequestDraftIcon : GitPullRequestIcon;
+ return (
+
+ );
+}
+
export function NewMark() {
return ;
}
diff --git a/apps/web/src/PullRequestBanner.tsx b/apps/web/src/PullRequestBanner.tsx
new file mode 100644
index 00000000..debc3e82
--- /dev/null
+++ b/apps/web/src/PullRequestBanner.tsx
@@ -0,0 +1,107 @@
+import type { ApiPullRequest } from "@diffx/api";
+import {
+ ExternalLinkIcon,
+ GitMergeIcon,
+ GitPullRequestClosedIcon,
+ GitPullRequestDraftIcon,
+ GitPullRequestIcon,
+ type LucideIcon,
+} from "lucide-react";
+import { useId } from "react";
+import { useSavedOpen } from "@/components/collapsible-section";
+import { DisclosureIcon } from "@/components/disclosure";
+import { IconButton } from "@/components/icon-button";
+import { LinkButton } from "@/components/link-button";
+import { MarkdownText } from "@/components/markdown";
+import { Badge } from "@/components/ui/badge";
+import { pullRequestOf } from "./project-nav.ts";
+import { useSession } from "./session-context.tsx";
+
+type Status = {
+ label: string;
+ tone: "accent" | "error" | "neutral" | "success";
+ Icon: LucideIcon;
+};
+
+/** State as GitHub names it; draft applies only while open. */
+export function pullRequestStatus(pull: ApiPullRequest): Status {
+ if (pull.state === "merged")
+ return { label: "Merged", tone: "accent", Icon: GitMergeIcon };
+ if (pull.state === "closed")
+ return { label: "Closed", tone: "error", Icon: GitPullRequestClosedIcon };
+ if (pull.draft)
+ return { label: "Draft", tone: "neutral", Icon: GitPullRequestDraftIcon };
+ return { label: "Open", tone: "success", Icon: GitPullRequestIcon };
+}
+
+/**
+ * The open review's GitHub pull request above the diff: number, title, state,
+ * author and branches, with the description collapsed by default.
+ */
+export function PullRequestBanner() {
+ const session = useSession();
+ const pull = pullRequestOf(session);
+ const [open, setOpen] = useSavedOpen("pull-request-description", false);
+ const descriptionId = useId();
+ if (!pull) return null;
+ const status = pullRequestStatus(pull);
+ const body = pull.body.trim();
+ return (
+
+
+
+
+
+ #{pull.number}
+ {" "}
+ {pull.title}
+
+
+ {status.label}
+
+
+ Author @{pull.author.login}
+
+
+ Merging
+ {pull.headRef}
+ →
+ into
+ {pull.baseRef}
+
+
+ Open on GitHub
+
+
+ {body && (
+ setOpen((value) => !value)}
+ >
+
+
+ )}
+
+ {body && (
+
+
+ {body}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/account-github.tsx b/apps/web/src/account-github.tsx
new file mode 100644
index 00000000..4aea3e79
--- /dev/null
+++ b/apps/web/src/account-github.tsx
@@ -0,0 +1,379 @@
+import {
+ api,
+ errorDetail,
+ type ApiGitHubConnection,
+ type ApiGitHubRepository,
+} from "@diffx/api";
+import { ExternalLinkIcon, LockIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { ErrorState } from "@/components/error-state";
+import { LinkButton } from "@/components/link-button";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+
+const prose = "mb-4 leading-copy text-secondary";
+
+/** Outcomes the OAuth callback reports through `/settings?github=`. */
+const callbackMessages: Record = {
+ connected: "GitHub connected. Choose repositories to follow.",
+ cancelled: "GitHub authorization was cancelled.",
+ failed: "GitHub authorization failed. Try connecting again.",
+ signin: "Sign in, then connect GitHub again from this browser.",
+};
+
+/** Reads and clears the callback outcome so a reload does not repeat it. */
+function takeCallbackMessage() {
+ const url = new URL(window.location.href);
+ const outcome = url.searchParams.get("github");
+ if (outcome === null) return "";
+ url.searchParams.delete("github");
+ window.history.replaceState(window.history.state, "", url);
+ return callbackMessages[outcome] ?? "";
+}
+
+type Connection =
+ | { status: "loading" }
+ | { status: "unconfigured" }
+ | { status: "error"; message: string }
+ | { status: "ready"; connection: ApiGitHubConnection };
+
+/**
+ * Connect GitHub and follow repositories whose pull requests appear as
+ * reviews. Absent when the server has no GitHub App configured.
+ */
+export function AccountGitHub() {
+ const [connection, setConnection] = useState({
+ status: "loading",
+ });
+ const [attempt, setAttempt] = useState(0);
+ const [callback] = useState(takeCallbackMessage);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const [disconnecting, setDisconnecting] = useState(false);
+ useEffect(() => {
+ const controller = new AbortController();
+ void api
+ .GET("/api/v2/github/connection", { signal: controller.signal })
+ .then(({ data, error, response }) => {
+ if (controller.signal.aborted) return;
+ if (response.status === 404) {
+ setConnection({ status: "unconfigured" });
+ return;
+ }
+ if (!data)
+ throw new Error(
+ errorDetail(error, "Unable to load GitHub status. Try again."),
+ );
+ setConnection({ status: "ready", connection: data });
+ })
+ .catch((failure: unknown) => {
+ if (!controller.signal.aborted)
+ setConnection({
+ status: "error",
+ message: errorDetail(
+ failure,
+ "Unable to load GitHub status. Try again.",
+ ),
+ });
+ });
+ return () => controller.abort();
+ }, [attempt]);
+ if (connection.status === "unconfigured" || connection.status === "loading")
+ return null;
+ async function connect() {
+ setBusy(true);
+ setError("");
+ try {
+ const { data, error } = await api.POST("/api/v2/github/connection");
+ if (!data)
+ throw new Error(
+ errorDetail(error, "Unable to connect GitHub. Try again."),
+ );
+ window.location.assign(data.authorizeUrl);
+ } catch (failure: unknown) {
+ setError(errorDetail(failure, "Unable to connect GitHub. Try again."));
+ setBusy(false);
+ }
+ }
+ async function disconnect() {
+ setBusy(true);
+ setError("");
+ try {
+ const { response, error } = await api.DELETE("/api/v2/github/connection");
+ if (!response.ok)
+ throw new Error(
+ errorDetail(error, "Unable to disconnect GitHub. Try again."),
+ );
+ setDisconnecting(false);
+ setAttempt((value) => value + 1);
+ } catch (failure: unknown) {
+ setError(errorDetail(failure, "Unable to disconnect GitHub. Try again."));
+ } finally {
+ setBusy(false);
+ }
+ }
+ const current = connection.status === "ready" ? connection.connection : null;
+ const active = current?.connected === true && current.state === "active";
+ return (
+
+
+ GitHub
+
+ {callback && (
+
+ {callback}
+
+ )}
+ {connection.status === "error" && (
+ setAttempt((value) => value + 1)}
+ />
+ )}
+ {current && !current.connected && (
+
+ Connect GitHub to review pull requests from repositories you follow.
+ Each pull request appears with your other reviews of its repository.
+
+ )}
+ {current?.connected && !active && (
+
+ GitHub authorization for @{current.login} {" "}
+ {current.state === "revoked" ? "was revoked" : "expired"}. Connect
+ again to keep receiving pull requests.
+
+ )}
+ {active && (
+
+ Connected as @{current.login} . Pull requests of
+ followed repositories update from GitHub events.
+
+ )}
+ {error && }
+ {current && (
+
+ {!active && (
+ void connect()}
+ >
+ {current.connected ? "Connect GitHub again" : "Connect GitHub"}
+
+ )}
+
+ Install on repositories
+
+
+ {current.connected && (
+ {
+ setError("");
+ setDisconnecting(true);
+ }}
+ >
+ Disconnect
+
+ )}
+
+ )}
+ {
+ if (!open) setDisconnecting(false);
+ }}
+ title="Disconnect GitHub?"
+ description="You stop following every repository. Existing pull request reviews stay until they expire."
+ error={error}
+ confirmLabel="Disconnect GitHub"
+ busyLabel="Disconnecting…"
+ busy={busy}
+ onConfirm={() => void disconnect()}
+ />
+ {active && }
+
+ );
+}
+
+function FollowedRepositories() {
+ const [repositories, setRepositories] = useState([]);
+ const [loaded, setLoaded] = useState(false);
+ const [attempt, setAttempt] = useState(0);
+ const [error, setError] = useState("");
+ const [pending, setPending] = useState(null);
+ const [resyncing, setResyncing] = useState(false);
+ const [status, setStatus] = useState("");
+ useEffect(() => {
+ const controller = new AbortController();
+ setError("");
+ void api
+ .GET("/api/v2/github/repositories", { signal: controller.signal })
+ .then(({ data, error }) => {
+ if (controller.signal.aborted) return;
+ if (!data)
+ throw new Error(
+ errorDetail(error, "Unable to load repositories. Try again."),
+ );
+ setRepositories(data.repositories);
+ setLoaded(true);
+ })
+ .catch((failure: unknown) => {
+ if (!controller.signal.aborted)
+ setError(
+ errorDetail(failure, "Unable to load repositories. Try again."),
+ );
+ });
+ return () => controller.abort();
+ }, [attempt]);
+ async function setFollowed(repository: ApiGitHubRepository, follow: boolean) {
+ setPending(repository.id);
+ setError("");
+ setStatus("");
+ try {
+ const params = { params: { path: { repositoryId: repository.id } } };
+ const { response, error } = follow
+ ? await api.PUT("/api/v2/github/follows/{repositoryId}", params)
+ : await api.DELETE("/api/v2/github/follows/{repositoryId}", params);
+ if (!response.ok)
+ throw new Error(
+ errorDetail(
+ error,
+ `Unable to ${follow ? "follow" : "unfollow"} ${repository.fullName}. Try again.`,
+ ),
+ );
+ setRepositories((value) =>
+ value.flatMap((item) =>
+ item.id !== repository.id
+ ? [item]
+ : !follow && item.lost
+ ? []
+ : [{ ...item, followed: follow, lost: false }],
+ ),
+ );
+ setStatus(
+ follow
+ ? `Following ${repository.fullName}. Its open pull requests appear shortly.`
+ : `Stopped following ${repository.fullName}.`,
+ );
+ } catch (failure: unknown) {
+ setError(errorDetail(failure, "Unable to update. Try again."));
+ } finally {
+ setPending(null);
+ }
+ }
+ async function resync() {
+ setResyncing(true);
+ setError("");
+ setStatus("");
+ try {
+ const { response, error } = await api.POST("/api/v2/github/resync");
+ if (!response.ok)
+ throw new Error(errorDetail(error, "Unable to resync. Try again."));
+ setStatus("Refreshing pull requests of followed repositories.");
+ } catch (failure: unknown) {
+ setError(errorDetail(failure, "Unable to resync. Try again."));
+ } finally {
+ setResyncing(false);
+ }
+ }
+ const following = repositories.some((repository) => repository.followed);
+ return (
+
+
+ Repositories
+
+
+ Follow repositories to review their open pull requests, drafts included.
+ Closed and merged pull requests leave the Reviews column.
+
+ {error && (
+ setAttempt((value) => value + 1) })}
+ />
+ )}
+ {!loaded && !error && (
+
+ Loading repositories…
+
+ )}
+ {loaded && repositories.length === 0 && (
+
+ No repositories yet. Install the app on repositories you can read,
+ then reload this page.
+
+ )}
+ {loaded && repositories.length > 0 && (
+
+ {repositories.map((repository) => {
+ const id = `github-repository-${repository.id}`;
+ return (
+
+
+ void setFollowed(repository, checked)
+ }
+ />
+
+ {repository.fullName}
+
+ {repository.private && (
+
+ )}
+ {repository.lost && (
+ Access lost
+ )}
+
+ );
+ })}
+
+ )}
+ {status && (
+
+ {status}
+
+ )}
+ {following && (
+ void resync()}
+ >
+ Resync pull requests
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/account.tsx b/apps/web/src/account.tsx
index f0a6e1b3..91976fb6 100644
--- a/apps/web/src/account.tsx
+++ b/apps/web/src/account.tsx
@@ -6,6 +6,7 @@ import { IconButton } from "@/components/icon-button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { navigate, useIdentity } from "./identity-context.tsx";
+import { AccountGitHub } from "./account-github.tsx";
import { AccountTokens } from "./account-tokens.tsx";
/** Rail placement on the panel, or the compact header on the shell canvas. */
@@ -174,6 +175,7 @@ export function AccountSettings() {
Signed in as {state.bootstrap.user.username} .
+
&
+ VariantProps & { external?: boolean }) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/markdown.tsx b/apps/web/src/components/markdown.tsx
new file mode 100644
index 00000000..775f3c8b
--- /dev/null
+++ b/apps/web/src/components/markdown.tsx
@@ -0,0 +1,133 @@
+import Markdown, { type Components } from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { Checkbox } from "@/components/ui/checkbox";
+import { cn } from "@/lib/utils";
+
+// Untrusted prose (pull request descriptions) renders as React elements only:
+// raw HTML is dropped, unsafe URL schemes are removed by react-markdown, and
+// images become links so viewing a review never loads remote content.
+const components: Components = {
+ h1: ({ children }) => (
+ {children}
+ ),
+ h2: ({ children }) => (
+ {children}
+ ),
+ h3: ({ children }) => (
+ {children}
+ ),
+ h4: ({ children }) => (
+ {children}
+ ),
+ h5: ({ children }) => (
+ {children}
+ ),
+ h6: ({ children }) => (
+ {children}
+ ),
+ p: ({ children }) => {children}
,
+ ul: ({ children }) => (
+
+ ),
+ ol: ({ start, children }) => (
+
+ {children}
+
+ ),
+ li: ({ className, children }) => (
+
+ {children}
+
+ ),
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ img: ({ src, alt }) =>
+ typeof src === "string" && src ? (
+
+ {alt ? `Image: ${alt}` : "Image"}
+
+ ) : null,
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ code: ({ className, children }) => (
+
+ {children}
+
+ ),
+ pre: ({ children }) => (
+
+ {children}
+
+ ),
+ hr: () => ,
+ table: ({ children }) => (
+
+ ),
+ th: ({ children }) => (
+
+ {children}
+
+ ),
+ td: ({ children }) => (
+ {children}
+ ),
+ input: ({ checked }) => (
+
+ ),
+};
+
+/** Renders untrusted GitHub-flavored markdown as review prose. */
+export function MarkdownText({
+ children,
+ className,
+}: {
+ children: string;
+ className?: string;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/gallery/Gallery.tsx b/apps/web/src/gallery/Gallery.tsx
index 797f829e..2309e8a3 100644
--- a/apps/web/src/gallery/Gallery.tsx
+++ b/apps/web/src/gallery/Gallery.tsx
@@ -8,6 +8,8 @@ import {
Trash2Icon,
} from "lucide-react";
import { useState, type ReactNode } from "react";
+import { LinkButton } from "@/components/link-button";
+import { MarkdownText } from "@/components/markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -150,6 +152,25 @@ const layouts = [
{ value: "unified", label: "Unified" },
] as const;
+const galleryMarkdown = [
+ "## Summary",
+ "",
+ "Adds **search** with `useSearch()` and [docs](#markdown).",
+ "",
+ "- [x] Index files",
+ "- [ ] Rank results",
+ "",
+ "> Follow-up: cache queries.",
+ "",
+ "```ts",
+ "const hits = search(query);",
+ "```",
+ "",
+ "| Case | Result |",
+ "| --- | --- |",
+ "| empty | none |",
+].join("\n");
+
function Section({
id,
title,
@@ -248,6 +269,9 @@ function Components() {
+
+ Link button
+
Status row ·{" "}
@@ -551,6 +575,12 @@ function Components() {
+
+
+
+ {galleryMarkdown}
+
+
);
}
diff --git a/apps/web/src/project-nav.ts b/apps/web/src/project-nav.ts
index b9d81309..d0be195f 100644
--- a/apps/web/src/project-nav.ts
+++ b/apps/web/src/project-nav.ts
@@ -30,10 +30,22 @@ export function projectKey(context: ApiContext) {
return context.repositoryId ?? `name:${context.name}`;
}
+/** The GitHub pull request a stream reviews; the server alone authors it. */
+export function pullRequestOf(context: ApiContext) {
+ return context.observation?.pullRequest;
+}
+
+/** Closed and merged pull requests leave current work; their reviews remain. */
+function currentWork(context: ApiContext) {
+ const state = pullRequestOf(context)?.state;
+ return state !== "closed" && state !== "merged";
+}
+
export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) {
const groups = new Map();
for (const context of heads) {
- if (context.stale || !contextHasChanges(context)) continue;
+ if (context.stale || !contextHasChanges(context) || !currentWork(context))
+ continue;
const id = projectKey(context);
const group = groups.get(id) ?? {
id,
@@ -126,6 +138,8 @@ export function streamMatches(stream: ProjectStream, query: string) {
const { context } = stream;
const text = [
streamTitle(context),
+ context.branch ?? "",
+ pullRequestOf(context)?.author.login ?? "",
streamWorktree(context),
streamHost(context),
...streamSessions(context),
@@ -147,6 +161,8 @@ export function repoMonogram(name: string) {
}
export function streamTitle(context: ApiContext) {
+ const pull = pullRequestOf(context);
+ if (pull) return `#${pull.number} ${pull.title}`;
return context.branch ?? context.observation?.branch ?? "Detached HEAD";
}
@@ -162,10 +178,14 @@ export function streamMeta(
)[0]?.harness ??
context.observation?.agentSession?.harness ??
"";
+ const pull = pullRequestOf(context);
return [
+ pull ? "GitHub" : "",
+ pull?.draft ? "Draft" : "",
+ pull ? `@${pull.author.login}` : "",
worktree,
harness,
- multipleHosts ? streamHost(context) : "",
+ multipleHosts && !pull ? streamHost(context) : "",
relativeAge(contextActivity(context), now),
]
.filter(Boolean)
diff --git a/apps/web/src/review.css b/apps/web/src/review.css
index e7b9fae0..7f4ecbf9 100644
--- a/apps/web/src/review.css
+++ b/apps/web/src/review.css
@@ -145,6 +145,11 @@
padding: var(--space-2) var(--page-gutter);
border-bottom: 1px solid var(--border-muted);
}
+/* A long description scrolls within the banner and never pushes the diff away. */
+.pull-request-description {
+ max-height: min(40dvh, calc(6 * var(--space-16)));
+ overflow-y: auto;
+}
.comments-panel {
flex: 1 0 calc(4 * var(--space-12));
overflow: auto;
diff --git a/apps/web/test/browser/identity.spec.ts b/apps/web/test/browser/identity.spec.ts
index 6eca8a25..579eb231 100644
--- a/apps/web/test/browser/identity.spec.ts
+++ b/apps/web/test/browser/identity.spec.ts
@@ -326,3 +326,170 @@ test("logout failure stays recoverable and success disposes account UI", async (
await page.getByRole("button", { name: "Sign out", exact: true }).click();
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
});
+
+test("GitHub settings stay absent until the server configures the integration", async ({
+ page,
+}) => {
+ await remote(page);
+ await page.route("**/api/v2/github/connection", (route) =>
+ route.fulfill({
+ status: 404,
+ json: { detail: "GitHub integration is not configured" },
+ }),
+ );
+ await page.goto("/settings");
+ await expect(page.getByRole("heading", { name: "API tokens" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "GitHub" })).toHaveCount(0);
+});
+
+test("connecting GitHub starts authorization with CSRF and reports the callback once", async ({
+ page,
+}) => {
+ await remote(page);
+ await page.route("**/api/v2/github/connection", (route) => {
+ if (route.request().method() === "POST") {
+ expect(route.request().headers()["x-csrf-token"]).toBe("session-csrf");
+ return route.fulfill({
+ json: { authorizeUrl: "https://github.test/login/oauth/authorize" },
+ });
+ }
+ return route.fulfill({
+ json: {
+ connected: false,
+ installUrl: "https://github.com/apps/diffx/installations/new",
+ },
+ });
+ });
+ await page.route("https://github.test/**", (route) =>
+ route.fulfill({ contentType: "text/html", body: "Authorize " }),
+ );
+ await page.goto("/settings?github=cancelled");
+ await expect(
+ page.getByText("GitHub authorization was cancelled."),
+ ).toBeVisible();
+ expect(new URL(page.url()).search).toBe("");
+ await expect(
+ page.getByRole("link", { name: "Install on repositories" }),
+ ).toHaveAttribute("href", "https://github.com/apps/diffx/installations/new");
+ await page
+ .getByRole("button", { name: "Connect GitHub", exact: true })
+ .click();
+ await expect(page.getByRole("heading", { name: "Authorize" })).toBeVisible();
+ expect(page.url()).toBe("https://github.test/login/oauth/authorize");
+});
+
+test("followed repositories toggle with CSRF, resync, and lost access stays listed", async ({
+ page,
+}) => {
+ await remote(page);
+ await page.route("**/api/v2/github/connection", (route) =>
+ route.fulfill({
+ json: {
+ connected: true,
+ login: "alice-gh",
+ state: "active",
+ installUrl: "https://github.com/apps/diffx/installations/new",
+ },
+ }),
+ );
+ await page.route("**/api/v2/github/repositories", (route) =>
+ route.fulfill({
+ json: {
+ repositories: [
+ {
+ id: 10,
+ fullName: "octo/app",
+ private: true,
+ followed: false,
+ lost: false,
+ },
+ {
+ id: 11,
+ fullName: "octo/gone",
+ private: false,
+ followed: true,
+ lost: true,
+ },
+ ],
+ },
+ }),
+ );
+ const follows: string[] = [];
+ await page.route("**/api/v2/github/follows/*", (route) => {
+ expect(route.request().headers()["x-csrf-token"]).toBe("session-csrf");
+ follows.push(
+ `${route.request().method()} ${new URL(route.request().url()).pathname}`,
+ );
+ return route.fulfill({ status: 204 });
+ });
+ let resynced = false;
+ await page.route("**/api/v2/github/resync", (route) => {
+ expect(route.request().headers()["x-csrf-token"]).toBe("session-csrf");
+ resynced = true;
+ return route.fulfill({ status: 204 });
+ });
+ await page.goto("/settings");
+ await expect(page.getByText("Connected as @alice-gh.")).toBeVisible();
+ const app = page.getByRole("checkbox", { name: "octo/app" });
+ await expect(app).not.toBeChecked();
+ await expect(page.getByRole("img", { name: "Private" })).toBeVisible();
+ await expect(page.getByText("Access lost")).toBeVisible();
+ await app.click();
+ await expect(app).toBeChecked();
+ await expect(page.getByText("Following octo/app.")).toBeVisible();
+ await page.getByRole("checkbox", { name: "octo/gone" }).click();
+ await expect(page.getByRole("checkbox", { name: "octo/gone" })).toHaveCount(
+ 0,
+ );
+ expect(follows).toEqual([
+ "PUT /api/v2/github/follows/10",
+ "DELETE /api/v2/github/follows/11",
+ ]);
+ await page.getByRole("button", { name: "Resync pull requests" }).click();
+ await expect(
+ page.getByText("Refreshing pull requests of followed repositories."),
+ ).toBeVisible();
+ expect(resynced).toBe(true);
+});
+
+test("expired GitHub authorization asks to connect again and disconnect confirms", async ({
+ page,
+}) => {
+ await remote(page);
+ let connected = true;
+ await page.route("**/api/v2/github/connection", (route) => {
+ if (route.request().method() === "DELETE") {
+ expect(route.request().headers()["x-csrf-token"]).toBe("session-csrf");
+ connected = false;
+ return route.fulfill({ status: 204 });
+ }
+ return route.fulfill({
+ json: connected
+ ? {
+ connected: true,
+ login: "alice-gh",
+ state: "expired",
+ installUrl: "https://github.com/apps/diffx/installations/new",
+ }
+ : {
+ connected: false,
+ installUrl: "https://github.com/apps/diffx/installations/new",
+ },
+ });
+ });
+ await page.goto("/settings");
+ await expect(page.getByText(/expired\. Connect again/)).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Connect GitHub again" }),
+ ).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Repositories" })).toHaveCount(
+ 0,
+ );
+ await page.getByRole("button", { name: "Disconnect", exact: true }).click();
+ const confirm = page.getByRole("dialog", { name: "Disconnect GitHub?" });
+ await confirm.getByRole("button", { name: "Disconnect GitHub" }).click();
+ await expect(confirm).toBeHidden();
+ await expect(
+ page.getByRole("button", { name: "Connect GitHub", exact: true }),
+ ).toBeVisible();
+});
diff --git a/apps/web/test/browser/local-workspace.spec.ts b/apps/web/test/browser/local-workspace.spec.ts
index 71233fd0..8eaf194a 100644
--- a/apps/web/test/browser/local-workspace.spec.ts
+++ b/apps/web/test/browser/local-workspace.spec.ts
@@ -1,6 +1,6 @@
import { type Page, type Route } from "@playwright/test";
import { expect, fileToggle, test, withReviewNavigation } from "./fixtures.ts";
-import type { ApiContext, ApiRepositoryDiff } from "@diffx/api";
+import type { ApiContext, ApiPullRequest, ApiRepositoryDiff } from "@diffx/api";
import {
comparisonKind,
isDiffMode,
@@ -2935,3 +2935,117 @@ test("saved comparison preferences belong to the active account", async ({
await page.goto("/contexts/local-review");
await ready(page, "committed-v1");
});
+
+test("pull request reviews show GitHub identity and a sanitized description; closed ones leave the column", async ({
+ page,
+}) => {
+ const state = await localWorkspace(page);
+ await page.route("**/api/v2/bootstrap", (route) =>
+ route.fulfill({
+ json: {
+ mode: "remote",
+ signupEnabled: false,
+ user: { id: "account-a", username: "alice" },
+ csrfToken: "csrf",
+ collect: false,
+ },
+ }),
+ );
+ const pull = {
+ provider: "github",
+ repositoryId: 10,
+ fullName: "octo/app",
+ number: 12,
+ url: "https://github.com/octo/app/pull/12",
+ title: "Add search",
+ body: [
+ "Adds **search**.",
+ "",
+ "",
+ ' ',
+ "",
+ "[unsafe](javascript:window.injected=true) and ",
+ ].join("\n"),
+ state: "open",
+ draft: false,
+ author: { login: "mona", url: "https://github.com/mona" },
+ baseRef: "main",
+ headRef: "search",
+ headRepository: "mona/app",
+ createdAt: 1,
+ updatedAt: 2,
+ } satisfies ApiPullRequest;
+ state.context.streamId = "pr-stream";
+ state.context.latestContextId = state.context.id;
+ state.context.branch = "search";
+ state.context.observation = {
+ sourceId: "github",
+ hostname: "github.com",
+ runId: "",
+ agent: "",
+ trigger: "github",
+ repositoryKey: "repo",
+ repositoryName: "app",
+ remoteUrl: "https://github.com/octo/app.git",
+ checkoutKey: "pr-12",
+ root: pull.url,
+ worktreeName: "PR #12",
+ branch: "search",
+ head: "a".repeat(40),
+ collectedAt: 2,
+ collectorVersion: "test",
+ pullRequest: pull,
+ };
+ const closed: ApiContext = {
+ ...state.context,
+ id: "closed-head",
+ streamId: "closed-stream",
+ latestContextId: "closed-head",
+ observation: {
+ ...state.context.observation,
+ pullRequest: { ...pull, number: 9, title: "Old work", state: "merged" },
+ },
+ };
+ await page.route("**/api/v2/contexts?*", (route) =>
+ route.fulfill({
+ json: { contexts: [state.context, closed], nextCursor: null },
+ }),
+ );
+ await page.goto("/contexts/local-review?scope=all");
+ await ready(page);
+ const nav = page.getByRole("navigation", { name: "Reviews", exact: true });
+ const row = nav.getByRole("link", { name: /#12 Add search/ });
+ await expect(row).toContainText("GitHub · @mona");
+ await expect(row.getByRole("img", { name: "Pull request" })).toBeVisible();
+ await expect(nav.getByText(/Old work/)).toHaveCount(0);
+
+ const banner = page.getByRole("region", { name: "Pull request #12" });
+ await expect(
+ banner.getByRole("heading", { name: "#12 Add search" }),
+ ).toBeVisible();
+ await expect(banner.getByText("Open", { exact: true })).toBeVisible();
+ await expect(banner).toContainText("@mona");
+ await expect(banner).toContainText("Merging search");
+ await expect(banner).toContainText("into main");
+ await expect(
+ banner.getByRole("link", { name: "Open on GitHub" }),
+ ).toHaveAttribute("href", pull.url);
+ await expect(banner.getByText("Adds")).toBeHidden();
+ await banner.getByRole("button", { name: "Show description" }).click();
+ await expect(banner.getByText("search", { exact: true })).toHaveJSProperty(
+ "tagName",
+ "STRONG",
+ );
+ await expect(banner.locator("script, img")).toHaveCount(0);
+ await expect(banner).not.toContainText("