From 51a0d05667ab0f5a0f9910f819cf2e239d875bc8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 19 Sep 2026 03:24:23 +0000 Subject: [PATCH 01/22] feat(web): use the linked pull request row layout on the pull requests page The pull requests page and a thread's linked pull requests panel drew the same data in two layouts. The page rows were padded to 66px with the number and dot separators on a second line, while the panel was denser and easier to scan. Both now render through one shared row: glyph, number and title with verdict, checks and diff counts on the right, then author, branches and labels under them with the time on the right. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/PullRequestGhosts.tsx | 8 +- .../pullRequest/PullRequestListRow.tsx | 109 ++++++++ .../components/pullRequest/PullRequestRow.tsx | 236 +++++++++--------- .../pullRequest/ThreadPullRequestsPanel.tsx | 163 ++++++------ .../pullRequest/pullRequestChecks.test.tsx | 10 +- apps/web/src/routes/_chat.pull-requests.tsx | 2 +- 6 files changed, 323 insertions(+), 205 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestListRow.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index f8c922356548..5c9e1e006644 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,19 +45,19 @@ export function PullRequestListGhost({ className="motion-safe:animate-skeleton space-y-0.5" > {caption ? ( -

{caption}

+

{caption}

) : null} {Array.from({ length: rows }, (_, index) => (
-
+
-
+
diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx new file mode 100644 index 000000000000..06e7214262ee --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -0,0 +1,109 @@ +import type { PullRequestMergeability, PullRequestState } from "@t3tools/contracts"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { PullRequestConflictGlyph, PullRequestStateGlyph } from "./pullRequestPresentation"; + +/** + * The one row shape both pull request lists share: the full page and a thread's linked panel. + * A glyph, then two lines — number and title with the verdict, checks and diff counts on the + * right; author, branches and the rest under them with the time on the right. The caller owns + * the wrapper (a link on the panel, a button on the page) and hands in the slots. + */ +export const PULL_REQUEST_ROW_CLASS = + "group/pr-row flex w-full items-center gap-2 rounded-md py-1 pr-1 text-left"; + +/** The wrapper's own block size, for `content-visibility` on the long page list. */ +export const PULL_REQUEST_ROW_INTRINSIC_HEIGHT = "[contain-intrinsic-block-size:44px]"; + +export const PULL_REQUEST_ROW_NUMBER_CLASS = + "shrink-0 font-mono text-xs tabular-nums text-muted-foreground"; + +/** + * The conflict warning rides the corner of the lifecycle glyph, over the arrow's merge circle, + * so the leading slot stays one icon wide and titles line up whether or not a row is blocked. + * The background fill cuts it out of the glyph beneath. + */ +export function PullRequestRowGlyph({ + state, + isDraft, + mergeability, + baseBranch, +}: { + state: PullRequestState; + isDraft: boolean; + mergeability?: PullRequestMergeability | undefined; + baseBranch?: string | undefined; +}) { + return ( + + + {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the + badge's size and anchors the popup to it. */} + + + + + ); +} + +export function PullRequestRowLines({ + number, + title, + status, + meta, + metaClassName, + updatedAt, +}: { + /** The `#n` reference, already wrapped in whatever tooltip or menu the caller wants on it. */ + number: ReactNode; + title: ReactNode; + /** Right end of the first line: review verdict, checks, diff counts. */ + status?: ReactNode; + /** Left of the second line: author, branches, labels. */ + meta?: ReactNode; + metaClassName?: string; + updatedAt?: string | null | undefined; +}) { + return ( + + + {number} + {title} + {status ? ( + {status} + ) : null} + + + {meta} + {updatedAt ? ( + + {formatRelativeTimeLabel(updatedAt)} + + ) : null} + + + ); +} + +/** `head → base`, in the mono the branches are typed in. */ +export function PullRequestRowBranches({ head, base }: { head: string; base: string }) { + return ( + + {head} → {base} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 14b031314739..b101be6155bd 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -4,19 +4,23 @@ import { memo, type RefCallback } from "react"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; -import { formatRelativeTimeLabel } from "~/timestampFormat"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; +import { + PULL_REQUEST_ROW_CLASS, + PULL_REQUEST_ROW_INTRINSIC_HEIGHT, + PULL_REQUEST_ROW_NUMBER_CLASS, + PullRequestRowBranches, + PullRequestRowGlyph, + PullRequestRowLines, +} from "./PullRequestListRow"; import { PullRequestActorLabel, - PullRequestConflictGlyph, PullRequestDiffStat, - PullRequestMetaLine, PullRequestApprovalGlyph, - PullRequestStateGlyph, } from "./pullRequestPresentation"; /** @@ -105,95 +109,105 @@ function PullRequestRowImpl({ aria-current={selected ? "true" : undefined} onClick={() => onSelect(entry)} className={cn( - "@container/pr-row grid w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + PULL_REQUEST_ROW_CLASS, + "cursor-pointer pl-2 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", // Offscreen rows are skipped for style, layout and paint: a long list costs what the // viewport shows, not what the pages have loaded. The intrinsic size keeps the // scrollbar honest while a row is skipped. - "[contain-intrinsic-block-size:66px] [content-visibility:auto]", + PULL_REQUEST_ROW_INTRINSIC_HEIGHT, + "[content-visibility:auto]", selected ? "bg-accent" : "hover:bg-accent/60", )} > - {/* The conflict warning rides the corner of the lifecycle glyph, over the arrow's - merge circle, so the leading slot stays one icon wide and titles line up whether or - not a row is blocked. The background fill cuts it out of the glyph beneath. */} - - - {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the - badge's size and anchors the popup to it. */} - - - - - - - {entry.title} - - - {entry.stack ? ( - - onSelect({ ...target, host: entry.host, environmentId: entry.environmentId }) - } - /> - ) : null} - {/* Only a verdict somebody has actually given: "review required" is the absence of - one, and saying so on every unreviewed row would say nothing. */} - {entry.reviewDecision === "approved" ? ( - - ) : entry.reviewDecision === "changes-requested" ? ( - - Changes requested - - ) : null} - {entry.checksState === undefined ? null : ( - - )} - - - - {matchedElsewhere ? ( - - + + { + event.preventDefault(); + event.stopPropagation(); + void showPullRequestLinkContextMenu({ + url: entry.url, + openLabel: openOnHostLabel(entry.provider), + position: { x: event.clientX, y: event.clientY }, + }); + }} + > + #{entry.number} + + } + title={entry.title} + status={ + <> + {entry.stack ? ( + + onSelect({ ...target, host: entry.host, environmentId: entry.environmentId }) } - > - matched in the description - - - matched in the description - - - Matched in the description - - ) : null} - + /> + ) : null} + {/* Only a verdict somebody has actually given: "review required" is the absence of + one, and saying so on every unreviewed row would say nothing. */} + {entry.reviewDecision === "approved" ? ( + + ) : entry.reviewDecision === "changes-requested" ? ( + + Changes requested + + ) : null} + {entry.checksState === undefined ? null : ( + + )} + + + } + metaClassName="@container/pr-row-meta" + meta={ + <> + {matchedElsewhere ? ( + + + } + > + matched in the description + + + matched in the description + + + Matched in the description + + ) : null} {showProvider ? ( }> @@ -202,39 +216,23 @@ function PullRequestRowImpl({ {providerName} ) : null} - {/* The number carries the link, here as much as on the detail: a right-click on it - copies the pull request's own address rather than opening the editing menu. */} - { - event.preventDefault(); - event.stopPropagation(); - void showPullRequestLinkContextMenu({ - url: entry.url, - openLabel: openOnHostLabel(entry.provider), - position: { x: event.clientX, y: event.clientY }, - }); - }} - > - #{entry.number} - - - {showProjectTitle ? {entry.repository} : null} - {environmentLabel ? ( - {environmentLabel} - ) : null} - - {entry.labels.length > 0 ? : null} - - - - {formatRelativeTimeLabel(entry.updatedAt)} - - - + + {showProjectTitle ? ( + {entry.repository} + ) : null} + {environmentLabel ? ( + {environmentLabel} + ) : null} + + {entry.labels.length > 0 ? : null} + + } + updatedAt={entry.updatedAt} + /> ); } diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 05bb598e11db..bf4f7d4ddee8 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -21,11 +21,16 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { openLinkPullRequestDialog } from "./LinkPullRequestDialog"; import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines"; import { - PullRequestActorAvatar, - PullRequestConflictGlyph, + PULL_REQUEST_ROW_CLASS, + PULL_REQUEST_ROW_NUMBER_CLASS, + PullRequestRowBranches, + PullRequestRowGlyph, + PullRequestRowLines, +} from "./PullRequestListRow"; +import { + PullRequestActorLabel, PullRequestDiffStat, PullRequestApprovalGlyph, - PullRequestStateGlyph, pullRequestChecksStatePresentation, } from "./pullRequestPresentation"; import { PullRequestGlyph } from "./pullRequestIcons"; @@ -72,7 +77,7 @@ function LinkRow({ const snapshot = link.snapshot; return (
) : ( - - - - + )} openPrLink(event, link.url, threadRef)} - className="min-w-0 flex-1" + className="flex min-w-0 flex-1" > - - - - } - > - #{link.number} - - - {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)} - - - - {snapshot?.title ?? link.repository} - - {/* Match the full PR list: review verdict, checks, then diff counts. - Each is absent rather than neutral when the - host said nothing, so a row without them reads as unknown, not as fine. */} - - {snapshot?.state === "open" && - (snapshot.reviewDecision === "approved" || - snapshot.reviewDecision === "changes-requested") ? ( - snapshot.reviewDecision === "approved" ? ( - - ) : ( - Changes requested - ) - ) : null} - {snapshot?.checksState ? : null} - - - - - {stack ? ( + - - } - > - - {stack.size} + }> + #{link.number} - {stack.kind === "native" - ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.` - : `${stack.size} pull requests chained by base branch.`} + {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)} - ) : null} - {snapshot?.author ? ( - - - {snapshot.author.login} - - ) : null} - - {snapshot !== null - ? `${snapshot.headBranch} → ${snapshot.baseBranch}` - : `${link.host}/${link.repository}`} - - {snapshot?.updatedAt ? ( - {formatRelativeTimeLabel(snapshot.updatedAt)} - ) : null} - + } + title={snapshot?.title ?? link.repository} + // Match the full PR list: review verdict, checks, then diff counts. Each is absent + // rather than neutral when the host said nothing, so a row without them reads as + // unknown, not as fine. + status={ + <> + {snapshot?.state === "open" && + (snapshot.reviewDecision === "approved" || + snapshot.reviewDecision === "changes-requested") ? ( + snapshot.reviewDecision === "approved" ? ( + + ) : ( + + Changes requested + + ) + ) : null} + {snapshot?.checksState ? : null} + + + } + meta={ + <> + {stack ? ( + + + } + > + + {stack.size} + + + {stack.kind === "native" + ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.` + : `${stack.size} pull requests chained by base branch.`} + + + ) : null} + {snapshot?.author ? ( + + ) : null} + {snapshot !== null ? ( + + ) : ( + + {link.host}/{link.repository} + + )} + + } + updatedAt={snapshot?.updatedAt} + /> { }); }); -/** Every element of the tree the row returned, so a nested indicator can be looked for. */ +/** + * Every element of the tree the row returned, so a nested indicator can be looked for. The row + * hands its slots to the shared row lines as props rather than children, so every prop that + * holds an element is walked too. + */ function flatten(node: ReactNode): ReadonlyArray> { const found: unknown[] = []; for (const child of Children.toArray(node)) { if (!isValidElement(child)) continue; found.push(child); - found.push(...flatten((child.props as { readonly children?: ReactNode }).children)); + for (const value of Object.values(child.props as Record)) { + if (isValidElement(value) || Array.isArray(value)) found.push(...flatten(value)); + } } return found as ReadonlyArray>; } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index f54415b1986c..abf8d815d379 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1660,7 +1660,7 @@ function PullRequestsRouteView() { {displayGroups.map((group) => (
{group.label ? ( -

+

{group.label}

) : null} From e090c361df9991bc5ce9168803654094b3260425 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 19 Sep 2026 03:30:02 +0000 Subject: [PATCH 02/22] fix(web): let pull request row meta shrink and keep the panel's author size The repository name, author and search badge could not shrink, so a narrow row with a long repository name pushed the branches, labels and time out of the line. They shrink and truncate again, and the meta line clips its overflow. The author is a shared piece at the linked panel's original avatar size and gap. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/PullRequestListRow.tsx | 41 +++++++++++++++++-- .../components/pullRequest/PullRequestRow.tsx | 19 ++++----- .../pullRequest/ThreadPullRequestsPanel.tsx | 4 +- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index 06e7214262ee..9d456cfd2a3c 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -1,10 +1,19 @@ -import type { PullRequestMergeability, PullRequestState } from "@t3tools/contracts"; +import type { + PullRequestActor, + PullRequestMergeability, + PullRequestState, +} from "@t3tools/contracts"; import type { ReactNode } from "react"; import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; -import { PullRequestConflictGlyph, PullRequestStateGlyph } from "./pullRequestPresentation"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + PullRequestActorAvatar, + PullRequestConflictGlyph, + PullRequestStateGlyph, +} from "./pullRequestPresentation"; /** * The one row shape both pull request lists share: the full page and a thread's linked panel. @@ -84,7 +93,7 @@ export function PullRequestRowLines({ @@ -99,6 +108,32 @@ export function PullRequestRowLines({ ); } +/** Avatar and login at the meta line's own size, with the actor's full name on hover. */ +export function PullRequestRowAuthor({ + actor, + className, + labelClassName, +}: { + actor: PullRequestActor | null; + className?: string; + labelClassName?: string; +}) { + const login = actor?.login ?? "ghost"; + return ( + + } + > + + {login} + + + {actor?.name && actor.name !== login ? `${actor.name} (@${login})` : login} + + + ); +} + /** `head → base`, in the mono the branches are typed in. */ export function PullRequestRowBranches({ head, base }: { head: string; base: string }) { return ( diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index b101be6155bd..4ccac0cbab61 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -13,15 +13,12 @@ import { PULL_REQUEST_ROW_CLASS, PULL_REQUEST_ROW_INTRINSIC_HEIGHT, PULL_REQUEST_ROW_NUMBER_CLASS, + PullRequestRowAuthor, PullRequestRowBranches, PullRequestRowGlyph, PullRequestRowLines, } from "./PullRequestListRow"; -import { - PullRequestActorLabel, - PullRequestDiffStat, - PullRequestApprovalGlyph, -} from "./pullRequestPresentation"; +import { PullRequestDiffStat, PullRequestApprovalGlyph } from "./pullRequestPresentation"; /** * Each slot past the first only appears once the meta line is wide enough to hold it, so a @@ -196,7 +193,7 @@ function PullRequestRowImpl({ + } > matched in the description @@ -216,14 +213,12 @@ function PullRequestRowImpl({ {providerName} ) : null} - - {showProjectTitle ? ( - {entry.repository} - ) : null} + {showProjectTitle ? {entry.repository} : null} {environmentLabel ? ( {environmentLabel} ) : null} diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index bf4f7d4ddee8..0d0d520326fd 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -23,12 +23,12 @@ import { pullRequestListLines, type PullRequestListLine } from "./pullRequestLis import { PULL_REQUEST_ROW_CLASS, PULL_REQUEST_ROW_NUMBER_CLASS, + PullRequestRowAuthor, PullRequestRowBranches, PullRequestRowGlyph, PullRequestRowLines, } from "./PullRequestListRow"; import { - PullRequestActorLabel, PullRequestDiffStat, PullRequestApprovalGlyph, pullRequestChecksStatePresentation, @@ -158,7 +158,7 @@ function LinkRow({ ) : null} {snapshot?.author ? ( - Date: Sat, 19 Sep 2026 03:35:32 +0000 Subject: [PATCH 03/22] test(web): walk only element arrays in the pull request row helper Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/pullRequest/pullRequestChecks.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx index d2b92261b4b6..48a371a7a9c0 100644 --- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx @@ -63,7 +63,8 @@ function flatten(node: ReactNode): ReadonlyArray> { if (!isValidElement(child)) continue; found.push(child); for (const value of Object.values(child.props as Record)) { - if (isValidElement(value) || Array.isArray(value)) found.push(...flatten(value)); + if (isValidElement(value)) found.push(...flatten(value)); + else if (Array.isArray(value)) found.push(...flatten(value.filter(isValidElement))); } } return found as ReadonlyArray>; From 7c90434031ffac9140ac5907de4e50eb941fa39b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 19 Sep 2026 04:03:51 +0000 Subject: [PATCH 04/22] feat(web): give pull request page rows a little of their old room The page rows keep the shared two-line layout but sit at 52px with the old side padding, so the list is still denser than before without being as tight as the linked panel's narrow column. Co-Authored-By: Claude Fable 5.1 --- .../src/components/pullRequest/PullRequestGhosts.tsx | 4 ++-- .../components/pullRequest/PullRequestListRow.tsx | 3 --- .../src/components/pullRequest/PullRequestRow.tsx | 12 +++++++++--- apps/web/src/routes/_chat.pull-requests.tsx | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 5c9e1e006644..7d3176406ad3 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,12 +45,12 @@ export function PullRequestListGhost({ className="motion-safe:animate-skeleton space-y-0.5" > {caption ? ( -

{caption}

+

{caption}

) : null} {Array.from({ length: rows }, (_, index) => (
diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index 9d456cfd2a3c..97ed796014b5 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -24,9 +24,6 @@ import { export const PULL_REQUEST_ROW_CLASS = "group/pr-row flex w-full items-center gap-2 rounded-md py-1 pr-1 text-left"; -/** The wrapper's own block size, for `content-visibility` on the long page list. */ -export const PULL_REQUEST_ROW_INTRINSIC_HEIGHT = "[contain-intrinsic-block-size:44px]"; - export const PULL_REQUEST_ROW_NUMBER_CLASS = "shrink-0 font-mono text-xs tabular-nums text-muted-foreground"; diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 4ccac0cbab61..d4ca8ff6e563 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -11,7 +11,6 @@ import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullR import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PULL_REQUEST_ROW_CLASS, - PULL_REQUEST_ROW_INTRINSIC_HEIGHT, PULL_REQUEST_ROW_NUMBER_CLASS, PullRequestRowAuthor, PullRequestRowBranches, @@ -64,6 +63,13 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ ); } +/** + * The page row keeps a little more room around the shared lines than the panel, which sits in + * a narrow column. The intrinsic size is the row's own block size at this padding, for the + * `content-visibility` skip below. + */ +const PAGE_ROW_CLASS = "px-3 py-2 [contain-intrinsic-block-size:52px]"; + export type PullRequestRowTarget = Pick< EnvironmentPullRequestEntry, "environmentId" | "projectId" | "host" | "repository" | "number" @@ -107,11 +113,11 @@ function PullRequestRowImpl({ onClick={() => onSelect(entry)} className={cn( PULL_REQUEST_ROW_CLASS, - "cursor-pointer pl-2 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + PAGE_ROW_CLASS, + "cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", // Offscreen rows are skipped for style, layout and paint: a long list costs what the // viewport shows, not what the pages have loaded. The intrinsic size keeps the // scrollbar honest while a row is skipped. - PULL_REQUEST_ROW_INTRINSIC_HEIGHT, "[content-visibility:auto]", selected ? "bg-accent" : "hover:bg-accent/60", )} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index abf8d815d379..f54415b1986c 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1660,7 +1660,7 @@ function PullRequestsRouteView() { {displayGroups.map((group) => (
{group.label ? ( -

+

{group.label}

) : null} From 0cf51755df4d3fe1e876eb29343d4012b45b2c87 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 19 Sep 2026 04:08:57 +0000 Subject: [PATCH 05/22] fix(web): size skipped pull request rows and ghosts like rendered rows The intrinsic block size is the content box a skipped row reserves, so it is the two lines without the padding. Ghost rows now measure the same as a loaded row. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/pullRequest/PullRequestGhosts.tsx | 8 ++++---- apps/web/src/components/pullRequest/PullRequestRow.tsx | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 7d3176406ad3..bd456a438893 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -53,11 +53,11 @@ export function PullRequestListGhost({ className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-3 py-2" > -
- - +
+ +
-
+
diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index d4ca8ff6e563..273a0c563159 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -65,10 +65,10 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ /** * The page row keeps a little more room around the shared lines than the panel, which sits in - * a narrow column. The intrinsic size is the row's own block size at this padding, for the - * `content-visibility` skip below. + * a narrow column. The intrinsic size is the content box a skipped row reserves, which is the + * two lines without the padding: a 52px row less 16px of `py-2`. */ -const PAGE_ROW_CLASS = "px-3 py-2 [contain-intrinsic-block-size:52px]"; +const PAGE_ROW_CLASS = "px-3 py-2 [contain-intrinsic-block-size:36.5px]"; export type PullRequestRowTarget = Pick< EnvironmentPullRequestEntry, From d4a6011f5b844fe90439c21ca90f71deb9409cb0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 19 Sep 2026 06:34:28 +0000 Subject: [PATCH 06/22] feat(web): drop branch names from pull request page rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page row now shows author, repository and labels on its second line. The linked panel keeps head → base, where the branch is the thing a thread is about. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/pullRequest/PullRequestRow.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 273a0c563159..18a6028499c9 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -13,7 +13,6 @@ import { PULL_REQUEST_ROW_CLASS, PULL_REQUEST_ROW_NUMBER_CLASS, PullRequestRowAuthor, - PullRequestRowBranches, PullRequestRowGlyph, PullRequestRowLines, } from "./PullRequestListRow"; @@ -228,7 +227,6 @@ function PullRequestRowImpl({ {environmentLabel ? ( {environmentLabel} ) : null} - {entry.labels.length > 0 ? : null} } From 0ebe4019b5a10a5d07f4698d9c99f0acfd8eea10 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 19 Sep 2026 06:37:32 +0000 Subject: [PATCH 07/22] docs(web): say which pull request list shows branches in the shared row Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/pullRequest/PullRequestListRow.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index 97ed796014b5..85843d14683c 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -18,7 +18,8 @@ import { /** * The one row shape both pull request lists share: the full page and a thread's linked panel. * A glyph, then two lines — number and title with the verdict, checks and diff counts on the - * right; author, branches and the rest under them with the time on the right. The caller owns + * right; author and whatever else the caller shows under them with the time on the right. The + * page puts repository and labels there, the panel puts the branches there. The caller owns * the wrapper (a link on the panel, a button on the page) and hands in the slots. */ export const PULL_REQUEST_ROW_CLASS = @@ -74,7 +75,7 @@ export function PullRequestRowLines({ title: ReactNode; /** Right end of the first line: review verdict, checks, diff counts. */ status?: ReactNode; - /** Left of the second line: author, branches, labels. */ + /** Left of the second line: author, then repository and labels or the branches. */ meta?: ReactNode; metaClassName?: string; updatedAt?: string | null | undefined; From da8eb7074d7f90cd13c1d27beda147d5ad3955bb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 21:49:39 +0000 Subject: [PATCH 08/22] feat(web): give pull request page rows a little more room Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/pullRequest/PullRequestGhosts.tsx | 2 +- apps/web/src/components/pullRequest/PullRequestRow.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index bd456a438893..c9aa4c89b863 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -50,7 +50,7 @@ export function PullRequestListGhost({ {Array.from({ length: rows }, (_, index) => (
diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 18a6028499c9..ac055a9bb566 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -65,9 +65,9 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ /** * The page row keeps a little more room around the shared lines than the panel, which sits in * a narrow column. The intrinsic size is the content box a skipped row reserves, which is the - * two lines without the padding: a 52px row less 16px of `py-2`. + * two lines without the padding: a 56px row less 20px of `py-2.5`. */ -const PAGE_ROW_CLASS = "px-3 py-2 [contain-intrinsic-block-size:36.5px]"; +const PAGE_ROW_CLASS = "px-3 py-2.5 [contain-intrinsic-block-size:36.5px]"; export type PullRequestRowTarget = Pick< EnvironmentPullRequestEntry, From 303b092cee094384aa833ce6ae654a35aff18696 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 21:49:41 +0000 Subject: [PATCH 09/22] feat(web): show pull request labels as flat tinted tags The bordered pill with a colored dot is gone from the list rows, the ghost rows and the summary tab. One shared chip renders the label as a wash of its own color with the name in a shade of it, mixed toward the foreground so it reads in both themes. Labels without a usable color fall back to the muted tag. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/PullRequestGhosts.tsx | 29 +++++--------- .../components/pullRequest/PullRequestRow.tsx | 27 +++++-------- .../pullRequest/PullRequestSummaryTab.tsx | 28 +++++-------- .../pullRequest/pullRequestPresentation.tsx | 40 ++++++++++++++++++- 4 files changed, 67 insertions(+), 57 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index c9aa4c89b863..0d69b0a81ded 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -13,11 +13,11 @@ import { ArrowLeftIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { formatRelativeTimeLabel } from "~/timestampFormat"; -import { pullRequestLabelColor } from "./pullRequestList.logic"; import { PullRequestActorLabel, - PullRequestDiffStat, pullRequestChecksStatePresentation, + PullRequestDiffStat, + PullRequestLabelChip, resolvePullRequestState, } from "./pullRequestPresentation"; @@ -218,26 +218,15 @@ export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry |
{seed ? ( - seed.labels.slice(0, 3).map((label) => { - const color = pullRequestLabelColor(label.color); - return ( - - - {label.name} - - ); - }) + seed.labels + .slice(0, 3) + .map((label) => ( + + )) ) : ( <> - - + + )}
diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index ac055a9bb566..c8697f21b2af 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -7,7 +7,7 @@ import { getSourceControlPresentationForKind } from "~/sourceControlPresentation import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; -import { pullRequestLabelColor, type EnvironmentPullRequestEntry } from "./pullRequestList.logic"; +import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PULL_REQUEST_ROW_CLASS, @@ -16,7 +16,11 @@ import { PullRequestRowGlyph, PullRequestRowLines, } from "./PullRequestListRow"; -import { PullRequestDiffStat, PullRequestApprovalGlyph } from "./pullRequestPresentation"; +import { + PullRequestApprovalGlyph, + PullRequestDiffStat, + PullRequestLabelChip, +} from "./pullRequestPresentation"; /** * Each slot past the first only appears once the meta line is wide enough to hold it, so a @@ -36,26 +40,13 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ {LABEL_SLOTS.map((slot, index) => { const label = labels[index]; if (!label) return null; - const dot = pullRequestLabelColor(label.color); const remaining = labels.length - index - 1; return ( - - - {label.name} + {remaining > 0 ? ( - +{remaining} + +{remaining} ) : null} - + ); })} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index e89b38c28e1e..2f8ae3272a3e 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -30,8 +30,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestActorLabel, PullRequestCheckStatusIcon, - PullRequestReviewOutcomeBadge, pullRequestCheckStatusLabel, + PullRequestLabelChip, + PullRequestReviewOutcomeBadge, pullRequestReviewOutcomeLabel, pullRequestReviewOutcomeRingClassName, pullRequestReviewOutcomeStaleLabel, @@ -56,7 +57,6 @@ import { PullRequestCommentBody } from "./PullRequestCommentBody"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; -import { pullRequestLabelColor } from "./pullRequestList.logic"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; /** One reviewer, however a host happens to have cased their login this time. */ @@ -805,22 +805,14 @@ export function PullRequestSummaryTab({ {detail.labels.length === 0 ? ( None ) : ( - detail.labels.map((label) => { - const dot = pullRequestLabelColor(label.color); - return ( - - - {label.name} - - ); - }) + detail.labels.map((label) => ( + + )) )} {detail.capabilities.labels === true ? ( ; + size?: "sm" | "default"; + className?: string; + children?: ReactNode; +}) { + const color = pullRequestLabelColor(label.color); + return ( + + {label.name} + {children} + + ); +} + export function PullRequestApprovalGlyph() { return ( From b8d465c6166c18728a49eebf0a3c0524bb506055 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 21:55:01 +0000 Subject: [PATCH 10/22] fix(web): keep label chips readable, shrinkable and one height Mix the chip text further toward the foreground (40% label on light, 50% on dark) so white, black and pale yellow labels clear 4:1 on their wash. Let the chip shrink with its row instead of overlapping the time, and pin the small chip to 16px on every viewport so a labeled row matches the reserved height. Co-Authored-By: Claude Fable 5.1 --- .../src/components/pullRequest/PullRequestRow.tsx | 2 +- .../pullRequest/pullRequestPresentation.tsx | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index cd5c5d1578fb..076ab2d6ca93 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -44,7 +44,7 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ return ( {remaining > 0 ? ( - +{remaining} + +{remaining} ) : null} ); diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index a8cd9eea573e..22ad79184666 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -32,9 +32,11 @@ import { /** * A host label as a flat tinted tag in the label's own color: a wash of it behind, the name - * in a shade of it that reads on that wash in either theme (mixed toward the foreground, so - * darker on light and lighter on dark). A label with no usable color falls back to the muted - * tag. Children ride after the name, for an overflow count. + * in a mix of it and the theme foreground. The mix leans to the foreground because hosts hand + * out any color at all: at 40% of the label on light and 50% on dark, white, black and + * GitHub's pale yellows all clear 4:1 on their wash, and saturated colors sit well above. + * A label with no usable color falls back to the muted tag. Children ride after the name, + * for an overflow count. The height is pinned so a labeled row is as tall as one without. */ export function PullRequestLabelChip({ label, @@ -53,9 +55,10 @@ export function PullRequestLabelChip({ size={size} variant="secondary" className={cn( - "min-w-0 max-w-40 justify-start gap-1 px-1.5", + "min-w-0 max-w-40 shrink justify-start gap-1 px-1.5", + size === "sm" && "h-4 text-[.625rem]", color && - "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_68%,var(--foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)]", + "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_40%,var(--foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_50%,var(--foreground))]", className, )} {...(color ? { style: { "--label": color } as CSSProperties } : {})} From 952dc853c1709fdde2e58d9ba64033d61ae7384c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 22:10:29 +0000 Subject: [PATCH 11/22] fix(web): lean label chip text further to the foreground on light Pale and white labels reached only 4.0:1 at a 40% mix, less on a selected row. At 30% the worst case is 5.1:1. Co-Authored-By: Claude Fable 5.1 --- .../src/components/pullRequest/pullRequestPresentation.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 22ad79184666..46795e1a6bdc 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -33,8 +33,9 @@ import { /** * A host label as a flat tinted tag in the label's own color: a wash of it behind, the name * in a mix of it and the theme foreground. The mix leans to the foreground because hosts hand - * out any color at all: at 40% of the label on light and 50% on dark, white, black and - * GitHub's pale yellows all clear 4:1 on their wash, and saturated colors sit well above. + * out any color at all: at 30% of the label on light and 50% on dark, white, black and + * GitHub's pale yellows all clear 4.5:1 on their wash, selected row included, and + * saturated colors sit well above. * A label with no usable color falls back to the muted tag. Children ride after the name, * for an overflow count. The height is pinned so a labeled row is as tall as one without. */ @@ -58,7 +59,7 @@ export function PullRequestLabelChip({ "min-w-0 max-w-40 shrink justify-start gap-1 px-1.5", size === "sm" && "h-4 text-[.625rem]", color && - "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_40%,var(--foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_50%,var(--foreground))]", + "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_30%,var(--foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_50%,var(--foreground))]", className, )} {...(color ? { style: { "--label": color } as CSSProperties } : {})} From 4780f43fe4ec39abb2bbb3922f202c8e2eec62f1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 22:14:21 +0000 Subject: [PATCH 12/22] fix(web): mix label chip text toward the contrast-aware foreground The theme's foreground color already follows the appearance contrast setting; the chip mixed toward the raw palette token and ignored it. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/pullRequest/pullRequestPresentation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 46795e1a6bdc..b7ed3ab2310a 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -59,7 +59,7 @@ export function PullRequestLabelChip({ "min-w-0 max-w-40 shrink justify-start gap-1 px-1.5", size === "sm" && "h-4 text-[.625rem]", color && - "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_30%,var(--foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_50%,var(--foreground))]", + "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_30%,var(--color-foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_50%,var(--color-foreground))]", className, )} {...(color ? { style: { "--label": color } as CSSProperties } : {})} From 6d09bb8b1c15d5389e1a8c75a601d0e6f3dbcee6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 22:15:36 +0000 Subject: [PATCH 13/22] fix(web): darken label chip text a little more on dark A black label on a selected dark row sat at 4.45:1; 45% label clears 4.5:1 there too. Co-Authored-By: Claude Fable 5.1 --- .../src/components/pullRequest/pullRequestPresentation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index b7ed3ab2310a..0c4718bbcfa7 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -33,7 +33,7 @@ import { /** * A host label as a flat tinted tag in the label's own color: a wash of it behind, the name * in a mix of it and the theme foreground. The mix leans to the foreground because hosts hand - * out any color at all: at 30% of the label on light and 50% on dark, white, black and + * out any color at all: at 30% of the label on light and 45% on dark, white, black and * GitHub's pale yellows all clear 4.5:1 on their wash, selected row included, and * saturated colors sit well above. * A label with no usable color falls back to the muted tag. Children ride after the name, @@ -59,7 +59,7 @@ export function PullRequestLabelChip({ "min-w-0 max-w-40 shrink justify-start gap-1 px-1.5", size === "sm" && "h-4 text-[.625rem]", color && - "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_30%,var(--color-foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_50%,var(--color-foreground))]", + "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_30%,var(--color-foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_45%,var(--color-foreground))]", className, )} {...(color ? { style: { "--label": color } as CSSProperties } : {})} From 741f8519e00f76659d23a83cd29c8146b48d1025 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 23:50:25 +0000 Subject: [PATCH 14/22] feat(web): put checks and the review verdict right after the title The diff counts stay alone at the row's right edge. The checks glyph and a review glyph follow the title text as a pair: green person-check when approved, amber person-x when changes were requested, and a muted outline while an open pull request waits for a verdict, so green checks on an unreviewed pull request do not look like green checks on an approved one. Same in the linked panel. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/PullRequestListRow.tsx | 46 +++++++++++------ .../components/pullRequest/PullRequestRow.tsx | 45 +++++++++-------- .../pullRequest/ThreadPullRequestsPanel.tsx | 44 ++++++++--------- .../pullRequest/pullRequestPresentation.tsx | 49 +++++++++++++++++++ 4 files changed, 124 insertions(+), 60 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestListRow.tsx b/apps/web/src/components/pullRequest/PullRequestListRow.tsx index a8d151c0fcc6..e77f82717698 100644 --- a/apps/web/src/components/pullRequest/PullRequestListRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListRow.tsx @@ -17,8 +17,10 @@ import { /** * The one row shape both pull request lists share: the full page and a thread's linked panel. - * A glyph, then two lines — number and title with the verdict, checks and diff counts on the - * right; author and whatever else the caller shows under them with the time on the right. The + * A status column, then two lines — number and title with the diff counts on the right; + * author and whatever else the caller shows under them with the time on the right. The column + * stacks the lifecycle glyph over the checks glyph, so what a pull request is and how it is + * doing read top to bottom at the left edge, and the right edge is only numbers and time. The * page puts repository and labels there, the panel puts the branches there. The caller owns * the wrapper (a link on the panel, a button on the page) and hands in the slots. */ @@ -38,28 +40,34 @@ export function PullRequestRowGlyph({ isDraft, mergeability, baseBranch, + below, className, }: { state: PullRequestState; isDraft: boolean; mergeability?: PullRequestMergeability | undefined; baseBranch?: string | undefined; + /** Under the lifecycle glyph, level with the second line: the checks glyph. */ + below?: ReactNode; className?: string; }) { return ( - - - {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the - badge's size and anchors the popup to it. */} - - + + + + {/* The wrapper takes the offset, not the icon, so the tooltip trigger inside keeps the + badge's size and anchors the popup to it. */} + + + + {below ? {below} : null} ); } @@ -68,6 +76,7 @@ export function PullRequestRowLines({ number, title, status, + signals, meta, metaClassName, updatedAt, @@ -75,8 +84,10 @@ export function PullRequestRowLines({ /** The `#n` reference, already wrapped in whatever tooltip or menu the caller wants on it. */ number: ReactNode; title: ReactNode; - /** Right end of the first line: review verdict, checks, diff counts. */ + /** Right end of the first line: stack, diff counts. */ status?: ReactNode; + /** Right after the title text: checks and review verdict glyphs. */ + signals?: ReactNode; /** Left of the second line: author, then repository and labels or the branches. */ meta?: ReactNode; metaClassName?: string; @@ -86,7 +97,10 @@ export function PullRequestRowLines({ {number} - {title} + {title} + {signals ? ( + {signals} + ) : null} {status ? ( {status} ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 076ab2d6ca93..4c5031538315 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -17,9 +17,9 @@ import { PullRequestRowLines, } from "./PullRequestListRow"; import { - PullRequestApprovalGlyph, PullRequestDiffStat, PullRequestLabelChip, + PullRequestReviewDecisionGlyph, } from "./pullRequestPresentation"; /** @@ -140,6 +140,29 @@ function PullRequestRowImpl({ } title={entry.title} + signals={ + <> + {entry.checksState === undefined ? null : ( + + )} + {/* An open pull request with no verdict is awaiting one, and says so: green checks + on an unreviewed pull request must not look like green checks on an approved + one. Drafts and closed ones are not waiting on anybody. */} + {entry.reviewDecision !== undefined ? ( + + ) : entry.state === "open" && !entry.isDraft ? ( + + ) : null} + + } status={ <> {entry.stack ? ( @@ -157,26 +180,6 @@ function PullRequestRowImpl({ } /> ) : null} - {/* Only a verdict somebody has actually given: "review required" is the absence of - one, and saying so on every unreviewed row would say nothing. */} - {entry.reviewDecision === "approved" ? ( - - ) : entry.reviewDecision === "changes-requested" ? ( - - Changes requested - - ) : null} - {entry.checksState === undefined ? null : ( - - )} } title={snapshot?.title ?? link.repository} - // Match the full PR list: review verdict, checks, then diff counts. Each is absent - // rather than neutral when the host said nothing, so a row without them reads as - // unknown, not as fine. + signals={ + snapshot?.state === "open" ? ( + <> + {snapshot.checksState ? : null} + {snapshot.reviewDecision ? ( + + ) : snapshot.isDraft ? null : ( + + )} + + ) : null + } + // Match the full PR list: diff counts up top, checks under the lifecycle glyph, the + // verdict by the author. Each is absent rather than neutral when the host said + // nothing, so a row without them reads as unknown, not as fine. status={ - <> - {snapshot?.state === "open" && - (snapshot.reviewDecision === "approved" || - snapshot.reviewDecision === "changes-requested") ? ( - snapshot.reviewDecision === "approved" ? ( - - ) : ( - - Changes requested - - ) - ) : null} - {snapshot?.checksState ? : null} - - + } meta={ <> diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 0c4718bbcfa7..8a37dd6ce7ee 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -6,6 +6,7 @@ import type { PullRequestChecksState, PullRequestLabel, PullRequestMergeability, + PullRequestReviewDecision, PullRequestState, } from "@t3tools/contracts"; import { @@ -14,6 +15,8 @@ import { CircleDotIcon, CircleXIcon, UserCheckIcon, + UserRoundIcon, + UserRoundXIcon, } from "lucide-react"; import { Children, type CSSProperties, isValidElement, type ReactNode, useState } from "react"; @@ -70,6 +73,52 @@ export function PullRequestLabelChip({ ); } +/** + * The review verdict as one glyph beside the checks glyph, so a row answers both "does it + * build" and "did someone say yes" in the same spot. Awaiting review is drawn, not omitted: + * green checks on an unreviewed pull request must not look the same as green checks on an + * approved one. + */ +function reviewDecisionPresentation(decision: PullRequestReviewDecision) { + switch (decision) { + case "approved": + return { + Icon: UserCheckIcon, + label: "Approved", + toneClassName: CHECK_STATUS_PRESENTATION.success.toneClassName, + }; + case "changes-requested": + return { + Icon: UserRoundXIcon, + label: "Changes requested", + toneClassName: "text-amber-600/90 dark:text-amber-400/80", + }; + case "review-required": + return { + Icon: UserRoundIcon, + label: "Awaiting review", + toneClassName: "text-muted-foreground/60", + }; + } +} + +export function PullRequestReviewDecisionGlyph({ + decision, +}: { + decision: PullRequestReviewDecision; +}) { + const presentation = reviewDecisionPresentation(decision); + return ( + + }> + + {presentation.label} + + {presentation.label} + + ); +} + export function PullRequestApprovalGlyph() { return ( From 726f520267ef3bc90caf2636b34559bad995e1cc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 20 Sep 2026 23:57:36 +0000 Subject: [PATCH 15/22] fix(web): drop the unused approval glyph and keep the review fallback to GitHub Only GitHub reports a review decision, so an open pull request from another host was shown as awaiting review when the host simply says nothing. Co-Authored-By: Claude Fable 5.1 --- .../src/components/pullRequest/PullRequestRow.tsx | 5 +++-- .../pullRequest/ThreadPullRequestsPanel.tsx | 6 ++++-- .../pullRequest/pullRequestPresentation.tsx | 15 --------------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 4c5031538315..89ac7c66a1fe 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -155,10 +155,11 @@ function PullRequestRowImpl({ )} {/* An open pull request with no verdict is awaiting one, and says so: green checks on an unreviewed pull request must not look like green checks on an approved - one. Drafts and closed ones are not waiting on anybody. */} + one. Drafts and closed ones are not waiting on anybody. Only GitHub reports a + review decision at all, so on other hosts silence means unknown, not pending. */} {entry.reviewDecision !== undefined ? ( - ) : entry.state === "open" && !entry.isDraft ? ( + ) : entry.provider === "github" && entry.state === "open" && !entry.isDraft ? ( ) : null} diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index cebe41afc723..65481419f964 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -120,9 +120,11 @@ function LinkRow({ {snapshot.checksState ? : null} {snapshot.reviewDecision ? ( - ) : snapshot.isDraft ? null : ( + ) : link.host === "github.com" && !snapshot.isDraft ? ( + // Only GitHub reports a review decision, so only there does silence mean + // "nobody has said yes yet" rather than "this host does not say". - )} + ) : null} ) : null } diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 8a37dd6ce7ee..6dc584265870 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -119,21 +119,6 @@ export function PullRequestReviewDecisionGlyph({ ); } -export function PullRequestApprovalGlyph() { - return ( - - }> - - Approved - - Approved - - ); -} - /** * How a pull request's state reads anywhere it appears: the thread badge, the right-panel tab, * the list, and the detail header all resolve through here so one pull request cannot look like From 3e28ed9e519c7b7cdbd5d57d3099c9c55b367e7b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 00:10:59 +0000 Subject: [PATCH 16/22] feat(web): give pull request groups the sidebar's section header Authored and Others were bare muted text. Each group now opens like a sidebar shelf: the involvement glyph, the name, the count, and a rule out to the edge. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/routes/_chat.pull-requests.tsx | 34 ++++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index f54415b1986c..dcd8354f4037 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -24,11 +24,13 @@ import { LayersIcon, ListChecksIcon, PenLineIcon, + UsersIcon, Plug2Icon, Maximize2Icon, Minimize2Icon, SearchIcon, UserLockIcon, + type LucideIcon, } from "lucide-react"; import { useCallback, @@ -188,6 +190,32 @@ export interface PullRequestsSearch extends PullRequestListPreferences { readonly selectedEnvironmentId?: EnvironmentId; } +/** + * A group reads like the sidebar's shelves: its glyph, its name, how many, then a rule out + * to the edge. The glyph is the one the involvement filter uses for the same idea. + */ +const GROUP_ICONS: Record = { + authored: PenLineIcon, + reviewRequested: EyeIcon, + others: UsersIcon, +}; + +function PullRequestGroupHeader({ + group, +}: { + group: { key: string; label: string; entries: ReadonlyArray }; +}) { + const Icon = GROUP_ICONS[group.key] ?? LayersIcon; + return ( +

+ + {group.label} + {group.entries.length} + +

+ ); +} + // The state filters wear the same glyphs the rows do, so the two read as one vocabulary. const INVOLVEMENT_TABS = [ { value: "all", label: "All", Icon: LayersIcon }, @@ -1659,11 +1687,7 @@ function PullRequestsRouteView() {
{displayGroups.map((group) => (
- {group.label ? ( -

- {group.label} -

- ) : null} + {group.label ? : null} {group.entries.map((entry) => { const entryKey = pullRequestEntryKey(entry); return ( From 7e511a680ef7e3b5d31943c8e7cc241603dcfbdb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 00:15:14 +0000 Subject: [PATCH 17/22] fix(web): draw the group header rule with the shared separator Co-Authored-By: Claude Fable 5.1 --- apps/web/src/routes/_chat.pull-requests.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index dcd8354f4037..485fa7718e3e 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -148,6 +148,7 @@ import { } from "../state/pullRequests"; import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; +import { Separator } from "~/components/ui/separator"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; @@ -207,12 +208,12 @@ function PullRequestGroupHeader({ }) { const Icon = GROUP_ICONS[group.key] ?? LayersIcon; return ( -

+
- {group.label} +

{group.label}

{group.entries.length} - -

+ +
); } From e22d1ecbe2e2033c9a0f48b551f9034720c94c14 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 00:20:41 +0000 Subject: [PATCH 18/22] fix(web): show the linked pull request row menu on hover without reserving its column The actions button was invisible until hover but still took its width on every row. It now overlays the right end of the second line on the hovered row only, on the row's own hover color fading in from the left, so rows use their full width at rest and the diff counts stay visible. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/ThreadPullRequestsPanel.tsx | 72 +++++++++++-------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index 65481419f964..b31cf9b7aed0 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -77,7 +77,7 @@ function LinkRow({ const snapshot = link.snapshot; return (
- - - - - } - /> - - void writeTextToClipboard(link.url, "link")}> - - Copy link - - openPrLink(event, link.url, threadRef)}> - - Open - - onUnlink(link)}> - - {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"} - - - + {/* Out of the row's flow, so no row reserves a column for a button only the hovered one + shows. It sits over the right end of the second line on the row's own hover color, + fading in from the left, so it covers the time and leaves the diff counts alone. */} + + + + + + + } + /> + + void writeTextToClipboard(link.url, "link")}> + + Copy link + + openPrLink(event, link.url, threadRef)}> + + Open + + onUnlink(link)}> + + {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"} + + + +
); } From a053747b8b385d6722d307df04ee72d6df8d0f5a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 01:08:47 +0000 Subject: [PATCH 19/22] feat(web): pill label chips with a softer wash, review glyph only on a reported verdict The chip is round with no border or dot, washed at 8% of the label color on light and 12% on dark. The review glyph appears only when the host reports a verdict: approved, changes requested, or a review the branch rules still require. An unreviewed pull request wears none, since that is the common case. Co-Authored-By: Claude Fable 5.1 --- .../src/components/pullRequest/PullRequestRow.tsx | 14 ++++++-------- .../pullRequest/ThreadPullRequestsPanel.tsx | 4 ---- .../pullRequest/pullRequestPresentation.tsx | 9 ++++----- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 89ac7c66a1fe..e32decde136c 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -153,15 +153,13 @@ function PullRequestRowImpl({ }} /> )} - {/* An open pull request with no verdict is awaiting one, and says so: green checks - on an unreviewed pull request must not look like green checks on an approved - one. Drafts and closed ones are not waiting on anybody. Only GitHub reports a - review decision at all, so on other hosts silence means unknown, not pending. */} - {entry.reviewDecision !== undefined ? ( + {/* Only a verdict the host actually reports: an approval, a request for changes, + or a review the branch rules still require. No glyph on the common case of a + pull request nobody has reviewed, so a row only wears a person when the person + has said something. */} + {entry.reviewDecision === undefined ? null : ( - ) : entry.provider === "github" && entry.state === "open" && !entry.isDraft ? ( - - ) : null} + )} } status={ diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index b31cf9b7aed0..5be99595cae9 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -120,10 +120,6 @@ function LinkRow({ {snapshot.checksState ? : null} {snapshot.reviewDecision ? ( - ) : link.host === "github.com" && !snapshot.isDraft ? ( - // Only GitHub reports a review decision, so only there does silence mean - // "nobody has said yes yet" rather than "this host does not say". - ) : null} ) : null diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 6dc584265870..d46cad36622c 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -59,10 +59,10 @@ export function PullRequestLabelChip({ size={size} variant="secondary" className={cn( - "min-w-0 max-w-40 shrink justify-start gap-1 px-1.5", + "min-w-0 max-w-40 shrink justify-start gap-1 rounded-full px-2", size === "sm" && "h-4 text-[.625rem]", color && - "bg-[color-mix(in_srgb,var(--label)_12%,transparent)] text-[color-mix(in_srgb,var(--label)_30%,var(--color-foreground))] dark:bg-[color-mix(in_srgb,var(--label)_18%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_45%,var(--color-foreground))]", + "bg-[color-mix(in_srgb,var(--label)_8%,transparent)] text-[color-mix(in_srgb,var(--label)_30%,var(--color-foreground))] dark:bg-[color-mix(in_srgb,var(--label)_12%,transparent)] dark:text-[color-mix(in_srgb,var(--label)_45%,var(--color-foreground))]", className, )} {...(color ? { style: { "--label": color } as CSSProperties } : {})} @@ -75,9 +75,8 @@ export function PullRequestLabelChip({ /** * The review verdict as one glyph beside the checks glyph, so a row answers both "does it - * build" and "did someone say yes" in the same spot. Awaiting review is drawn, not omitted: - * green checks on an unreviewed pull request must not look the same as green checks on an - * approved one. + * build" and "did someone say yes" in the same spot. "Awaiting review" is only drawn when the + * host reports it, which on GitHub means the branch rules require a review nobody has given. */ function reviewDecisionPresentation(decision: PullRequestReviewDecision) { switch (decision) { From 31f2cff858e050192cdc1365b8aec90f6a06b7fe Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 01:08:49 +0000 Subject: [PATCH 20/22] feat(server): count a review bot's approval as the pull request's verdict GitHub's reviewDecision counts only reviews that satisfy the branch rules, so an approval from an app such as Macroscope left it empty and the row showed nothing. The list, search and detail reads now carry each reviewer's latest review, and when GitHub reports no verdict the reviews decide: changes requested outranks approval. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/gitHubPullRequestJson.test.ts | 34 +++++++++++ .../src/pullRequest/gitHubPullRequestJson.ts | 57 +++++++++++++++---- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 05796283b9b5..81033e2e0d94 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -97,6 +97,40 @@ describe("pull request list decoding", () => { ]); }); + it("takes the verdict from the latest reviews when GitHub summarizes none, as for a bot's approval", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { + reviewDecision: null, + latestReviews: [{ author: { login: "macroscopeapp" }, state: "APPROVED" }], + }, + { + reviewDecision: "REVIEW_REQUIRED", + latestReviews: [ + { author: { login: "octocat" }, state: "APPROVED" }, + { author: { login: "hubot" }, state: "CHANGES_REQUESTED" }, + ], + }, + { + reviewDecision: "APPROVED", + latestReviews: [{ author: { login: "hubot" }, state: "CHANGES_REQUESTED" }], + }, + { + reviewDecision: null, + latestReviews: [{ author: { login: "octocat" }, state: "COMMENTED" }], + }, + ]), + ), + ); + expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ + "approved", + "changes-requested", + "approved", + null, + ]); + }); + it("rolls the head commit's checks up to the one word a row has space for", () => { const batch = expectSuccess( decodePullRequestListJson( diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 8a1434224ad2..eaaa4e34da84 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -68,6 +68,12 @@ const RawReviewRequestSchema = Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), }); +/** One reviewer's most recent review: the state is all the verdict needs, the author is for who. */ +const RawLatestReviewSchema = Schema.Struct({ + author: Schema.optional(Schema.NullOr(RawActorSchema)), + state: Schema.optional(Schema.NullOr(Schema.String)), +}); + const RawCheckSchema = Schema.Struct({ __typename: Schema.optional(Schema.String), name: Schema.optional(Schema.NullOr(Schema.String)), @@ -106,6 +112,7 @@ const RawListItemSchema = Schema.Struct({ updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), + latestReviews: Schema.optional(Schema.NullOr(Schema.Array(RawLatestReviewSchema))), labels: Schema.optional(Schema.Array(RawLabelSchema)), /** * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there @@ -143,6 +150,9 @@ const RawSearchItemSchema = Schema.Struct({ isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), + latestReviews: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLatestReviewSchema)) })), + ), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), @@ -514,13 +524,7 @@ const RawReviewThreadsSchema = Schema.Struct({ ), ), latestReviews: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nodes: Schema.Array( - Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), - ), - }), - ), + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(RawLatestReviewSchema) })), ), reviewDismissals: Schema.optional( Schema.NullOr( @@ -698,7 +702,7 @@ export function decodeActorAvatarsJson( } export const PULL_REQUEST_LIST_JSON_FIELDS = - "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,latestReviews,labels,statusCheckRollup"; export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; @@ -817,6 +821,7 @@ export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = fals isDraft mergeable reviewDecision + latestReviews(first: 20) { nodes { state author { login } } } createdAt updatedAt mergedAt @@ -891,7 +896,7 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin } } latestReviews(first: 50) { - nodes { author { __typename login avatarUrl } } + nodes { state author { __typename login avatarUrl } } } reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { pageInfo { hasNextPage endCursor } @@ -1325,6 +1330,35 @@ function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod } } +/** + * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an + * approval from an app (a review bot) or from anyone without the required permission leaves it + * empty. The reviewers still said something, and a row should show it: when GitHub reports no + * verdict, the latest review per reviewer decides, changes requested outranking approval. + */ +function toReviewDecisionWithReviews( + value: string | null | undefined, + // `gh pr list` hands the reviews as an array; the GraphQL reads hand a connection. + latestReviews: + | ReadonlyArray> + | { readonly nodes: ReadonlyArray> } + | null + | undefined, +): PullRequestReviewDecision | null { + const summarized = toReviewDecision(value); + if (summarized === "approved" || summarized === "changes-requested") return summarized; + const reviews = + latestReviews === null || latestReviews === undefined + ? [] + : "nodes" in latestReviews + ? latestReviews.nodes + : latestReviews; + const states = new Set(reviews.map((review) => review.state?.trim().toUpperCase() ?? "")); + if (states.has("CHANGES_REQUESTED")) return "changes-requested"; + if (states.has("APPROVED")) return "approved"; + return summarized; +} + function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { switch (value?.trim().toUpperCase()) { case "APPROVED": @@ -1558,7 +1592,7 @@ function toListItem(raw: Schema.Schema.Type): GitHubPu state: toState(raw), isDraft: raw.isDraft ?? false, mergeability: toMergeability(raw.mergeable), - reviewDecision: toReviewDecision(raw.reviewDecision), + reviewDecision: toReviewDecisionWithReviews(raw.reviewDecision, raw.latestReviews), additions: raw.additions ?? 0, deletions: raw.deletions ?? 0, createdAt: raw.createdAt, @@ -1681,6 +1715,9 @@ export function decodePullRequestSearchJson( items.push({ ...toListItem({ ...node, + latestReviews: (node.latestReviews?.nodes ?? []).flatMap((review) => + review === null ? [] : [review], + ), reviewRequests: (node.reviewRequests?.nodes ?? []).flatMap((request) => { const login = trimmed(request?.requestedReviewer?.login); return login === null ? [] : [{ login }]; From 1b8699c7dbb5adb371d488547918db54da5ceaed Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 01:08:50 +0000 Subject: [PATCH 21/22] feat(web): put the out-of-date warning on the base branch name When the base has moved on, the branch name itself turns amber and opens the same popover as the mark beside it, with the update actions. Co-Authored-By: Claude Fable 5.1 --- .../pullRequest/PullRequestDetailPanel.tsx | 128 +++++++++++------- 1 file changed, 82 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 98d172f53eb5..44b70f2e18ff 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -50,6 +50,7 @@ import { useMemo, useRef, useState, + type ReactNode, } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; @@ -344,6 +345,8 @@ function PullRequestBaseFreshnessWarning({ pending, onUpdate, iconClassName, + className, + children, }: { readonly baseBranch: string; readonly freshness: { @@ -353,6 +356,9 @@ function PullRequestBaseFreshnessWarning({ readonly pending: boolean; readonly onUpdate: (method: PullRequestUpdateMethod) => void; readonly iconClassName?: string; + readonly className?: string; + /** What the warning is about, drawn in the same amber before the mark: the base branch. */ + readonly children?: ReactNode; }) { const behind = freshness.behindBy === null @@ -371,11 +377,15 @@ function PullRequestBaseFreshnessWarning({