feat(web): refresh pull request details#7148

merged7
main
ui-refresh/pull-requests
openedAug 16, 2026byMmaria
View on GitHubTwo-way sync enabledPR last imported 3d ago
31 unresolved threads · Jump to file
Selected thread: apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx:1120 — <!-- MURMUR_IGNORE --> `pullRequest/PullRequestDetailPanel.tsx:1120` Both crossfade layers of the top row dropped `sm:text-xs` while keeping `text-sm`, so the repository/number line (and the condensed title layer, line 1176, which carries the identical string) now renders at 14px on desktop inside a row that shrank from `h-11` to `h-7` — while every other line of this chrome (the condensed meta row, the branch pair, the tab bar) stays 12px. `text-sm … sm:text-xs` is the coarse-pointer/desktop pairing the control primitives themselves use (`Button` `xs`, `Select` `xs`, `Badge`), so the desktop step is worth keeping here too. ```suggestion "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out sm:text-xs motion-reduce:transform-none motion-reduce:transition-none", ``` The same addition applies to the condensed layer at line 1176. No longer relevant as of 1088b8114cca06cba3fc0b18e926a5a7582c3195
0 / 12 viewed12 files changedLine totals unavailable

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision c4b280b2 · right 1641–1644

Original diff
@@ -1650,147 +1630,115 @@ export function PullRequestDetailPanel({
                 </div>
               </div>
             ) : null}
+          </div>
+        </div>
 
-            {detail && conflicting ? (
-              <div className="col-span-2 flex items-center gap-1 px-4 pb-3">
-                <Badge
-                  variant="error"
-                  className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive"
+        {detail ? (
+          <nav
+            className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
+            aria-label="Pull request tabs"
+          >
+            <ToggleGroup
+              size="segmented"
+              variant="segmented"
+              value={[tab]}

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1641–1644

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestDetailPanel.tsx:1644

The segmented track treatment (rounded-lg bg-muted/45 p-0.5) is hand-written here, making this the fourth call site carrying the same three classes — UsagePage repeats them verbatim on each of its three segmented ToggleGroups. That is durable geometry for the segmented variant, not contextual layout, so it belongs in the primitive: ToggleGroup already branches on variant === "segmented" for its gap, and the radius, surface, and inset padding can join that branch. Call sites would then pass only variant="segmented" size="segmented" plus any local layout classes, and the track stays consistent if it is ever retuned.

Cross-file fix, so no inline suggestion: add the track classes to the segmented branch in apps/web/src/components/ui/toggle-group.tsx and drop the className here.

No longer relevant as of c4b280b249755ab463d28edee1532e856ae838a5

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 02917c5c · right 1513–1517

Original diff
@@ -1507,104 +1500,105 @@ export function PullRequestDetailPanel({
           <div
             ref={condensedRowRef}
             className={cn(
-              "min-h-0 overflow-hidden",
+              "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
               condensed
-                ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"
-                : "opacity-0",
+                ? "translate-y-0 opacity-100 delay-50"
+                : "translate-y-1 opacity-0 duration-100",
             )}
             inert={!condensed}
           >
             {detail ? (
-              <div className="flex min-w-0 items-center gap-1 px-4 pb-2">
-                <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5">
-                  {visibleTabs.map((item) => (
-                    <button
-                      key={item.value}
-                      type="button"
-                      tabIndex={condensed ? 0 : -1}
-                      aria-pressed={tab === item.value}
-                      onClick={() => setTab(item.value)}
-                      className={cn(
-                        "rounded-md px-2 py-1 text-[11px] transition-colors",
-                        tab === item.value
-                          ? "bg-accent text-foreground"
-                          : "text-muted-foreground hover:text-foreground",
-                      )}
+              <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">
+                <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
+                  <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
+                    <PullRequestActorAvatar actor={detail.author} className="shrink-0" />
+                    <span className="sr-only">{detail.author?.login ?? "ghost"}</span>
+                    <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>
+                  </span>

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1513–1517

M
macroscopeappBotAug 19, 2026

pullRequest/PullRequestDetailPanel.tsx:1517

The condensed chrome shows the author as a bare avatar: PullRequestActorAvatar renders aria-hidden markup, so the login only exists in the sr-only span and a sighted reader has no way to find out who it is. This surface's convention is the opposite — CommentAuthor in PullRequestSummaryTab ("The avatar carries the attribution alone; who it is arrives on hover") and PullRequestActorLabel both hang a tooltip off the avatar, and the expanded fold below uses the labelled form.

Wrapping the avatar in the same tooltip trigger keeps one attribution treatment and drops the need for the separate sr-only node.

suggestion
1 <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
2 <Tooltip>
3 <TooltipTrigger
4 render={
5 <span
6 className="shrink-0 rounded-full"
7 aria-label={detail.author?.login ?? "ghost"}
8 />
9 }
10 >
11 <PullRequestActorAvatar actor={detail.author} />
12 </TooltipTrigger>
13 <TooltipPopup side="top">{detail.author?.login ?? "ghost"}</TooltipPopup>
14 </Tooltip>
15 <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>
16 </span>

No longer relevant as of 1088b8114cca06cba3fc0b18e926a5a7582c3195

M
mariaAug 19, 2026

Fixed in 1088b8114. The condensed author avatar now uses the shared tooltip treatment with an accessible login label.

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 89fac2a9 · right 1393–1427

Original diff
@@ -1394,7 +1382,17 @@ export function PullRequestDetailPanel({
                   Auto-merge
                 </Badge>
               ) : null}
-              {primaryAction === "ready" ? (
+              {primaryAction === "resolve" ? (
+                <Button
+                  size="xs"
+                  variant="destructive-outline"
+                  disabled={handoff !== null}
+                  onClick={startResolveConflicts}
+                >
+                  <TriangleAlertIcon className="size-3" />
+                  {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1393–1427

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestDetailPanel.tsx:1393

This button replaced the conflict row's only consumer of handoffLabels.resolve, so the shared label contract (pullRequestHandoffLabels, "names where a pull-request task will land, without letting each surface guess independently") now has an unused entry while this surface hardcodes its own copy. Since the click still hands off to a thread, the thread context loses the "in this thread" destination the contract exists to state. Reusing the shared label keeps the two consistent.

suggestion
1 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve}

No longer relevant as of b45d74ed7e0e01be81d283140f980943dfa14849

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 5cc755c2 · right 1159–1159

Original diff
@@ -1131,38 +1141,22 @@ export function PullRequestDetailPanel({
                 >
                   #{detail.number}
                 </button>
-                <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
+                <h1 className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
                   {detail.title}
-                </span>
-                {conflicting ? (
-                  <Badge
-                    variant="error"
-                    className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
-                  >
-                    <TriangleAlertIcon className="size-3" />
-                    Conflicts
-                  </Badge>
-                ) : checksSummary ? (
-                  <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
-                    {detail && checksState !== null ? (
-                      <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
-                    ) : null}
-                    {checksSummary}
-                  </span>
-                ) : null}
+                </h1>
               </>
             ) : null}
           </div>
         </div>
-        <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
+        <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">
           {detail ? (
             <>
               <Menu>
                 <MenuTrigger
                   render={
                     <Button
                       aria-label="More pull request actions"
-                      className="size-6"
+                      className="order-last size-6"

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1159–1159

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestDetailPanel.tsx:1159

order-last paints the overflow menu at the end of this action row while it stays first in the DOM, so keyboard focus reaches "More pull request actions" before the Check out, primary action and Collapse controls that are drawn ahead of it — visual order and focus order no longer match. Moving the <Menu> block to the end of this flex row (and dropping order-last) keeps the two in sync without changing the layout.

No longer relevant as of 2dd5525de4577b23ff331860af354f54ead0a154

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 6fe8428c · right 431–436

Original diff
@@ -430,26 +429,13 @@ export function PullRequestDetailPanel({
     );
   }, [tab]);
   const [chromeCondensed, setChromeCondensed] = useState(false);
-  // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll
-  // events, so the capture handler always writes the active tab's entry — and a tab switch
-  // reads the destination's memory instead of inheriting the tab being left. A tab too short
-  // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome
-  // it has no scrollbar to reopen.
   const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
   useEffect(() => {
     setChromeCondensed(chromeStateByTab.current[tab] ?? false);
   }, [tab]);
-  const condensed = chromeVariant === "collapse" && chromeCondensed;
-  // Collapsing removes the fold's height from the chrome, which would otherwise hand that
-  // height to the scrollport and leap the content up by it mid-scroll. The cure is exact
-  // compensation: collapse only once the reader has scrolled at least the fold's height,
-  // then give that height back to `scrollTop` before the next paint — the content under
-  // their eyes does not move, and the collapse itself is the only thing that changes.
+  const condensed = chromeCondensed;

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 431–436

M
macroscopeappBotAug 19, 2026

pullRequest/PullRequestDetailPanel.tsx:436

Two of the four rationales dropped from the collapse machinery are back (the one-way track at 1567-1574 and the refund at 1858), but the two here are still gone while the code they explained is unchanged:

  • chromeStateByTab (431-435): nothing left says why chrome state is remembered per tab — only the active tab emits scroll, and a tab too short to scroll has to remember "expanded" or it is stranded under a chrome it has no scrollbar to reopen.
  • the compensationRef layout effect (440-447): nothing says the scrollTop write exists so the content under the reader does not leap when the fold's height returns to the scrollport.

Both are non-obvious scroll-compensation constraints, so a line of why above each would keep the next reader from taking them for removable state.

No longer relevant as of 5ea3cfaeeae3981a36e3a757ac6e71c8789c7e77

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 929b2835 · right 1284–1284

Original diff
@@ -1185,29 +1200,103 @@ export function PullRequestDetailPanel({
                   />
                   <TooltipPopup side="top">{detail.title}</TooltipPopup>
                 </Tooltip>
-                {conflicting ? (
-                  <Badge
-                    variant="error"
-                    className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
-                  >
-                    <TriangleAlertIcon className="size-3" />
-                    Conflicts
-                  </Badge>
-                ) : checksSummary ? (
-                  <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
-                    {detail && checksState !== null ? (
-                      <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
-                    ) : null}
-                    {checksSummary}
-                  </span>
-                ) : null}
               </>
             ) : null}
           </div>
         </div>
-        <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
+        <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">
           {detail ? (
             <>
+              {/* Checking a pull request out is the reason to open one here at all, so it is a
+                  button of its own rather than a side effect of asking an agent for something.
+                  It asks where, because the two answers are not interchangeable: one leaves your
+                  work where it is, the other moves the repository you are standing in. Only on
+                  the page: beside a thread the branch is already checked out right there. */}
+              {context === "page" ? (
+                <Menu>
+                  <MenuTrigger
+                    disabled={handoff !== null}
+                    render={
+                      <Button size="xs" variant="outline">
+                        <GitBranchIcon aria-hidden className="size-3.5" />
+                        {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
+                        <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" />
+                      </Button>
+                    }
+                  />
+                  <MenuPopup align="end" side="bottom" className="min-w-72">
+                    <MenuItem onClick={() => startCheckout("worktree")}>
+                      <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
+                      <span className="flex min-w-0 flex-col">
+                        <span>In a separate worktree</span>
+                        <span className="text-xs text-muted-foreground">
+                          Its own folder and thread. Nothing you have open moves.
+                        </span>
+                      </span>
+                    </MenuItem>
+                    <MenuItem onClick={() => startCheckout("local")}>
+                      <FolderGit2Icon className="mt-0.5 size-3.5 shrink-0 self-start" />
+                      <span className="flex min-w-0 flex-col">
+                        <span>In this repository</span>
+                        <span className="text-xs text-muted-foreground">
+                          Switches the branch you are working in, like `gh pr checkout`.
+                        </span>
+                      </span>
+                    </MenuItem>
+                    {pickableEnvironments.length > 0 ? (
+                      <ActOnEnvironmentPicker
+                        environments={pickableEnvironments}
+                        value={actingEnvironmentId}
+                        onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
+                        disabled={handoff !== null}
+                      />
+                    ) : null}
+                  </MenuPopup>
+                </Menu>
+              ) : null}
+              {/* Said where the Merge button is, because it is the answer to why nobody has
+                  pressed it: the merge is already asked for, and the host is holding it. */}
+              {autoMergeArmed ? (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <Badge
+                        variant="info"
+                        className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
+                      >
+                        <GitMergeIcon aria-hidden className="size-3" />
+                        Auto-merge
+                      </Badge>
+                    }
+                  />
+                  <TooltipPopup side="top">
+                    The host will merge this on its own once its requirements are met
+                  </TooltipPopup>
+                </Tooltip>
+              ) : null}
+              {primaryAction === "resolve" ? (
+                <Button
+                  size="xs"
+                  variant="destructive-outline"
+                  disabled={handoff !== null}
+                  onClick={startResolveConflicts}
+                >
+                  <TriangleAlertIcon className="size-3" />

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1284–1284

M
macroscopeappBotAug 17, 2026

pullRequest/PullRequestDetailPanel.tsx:1284

This size="xs" button pins its glyph to size-3 while the Check out button now beside it in the same action row uses size-3.5, so two peers of the same size variant show differently sized icons. Matching the neighbour (or dropping the override entirely and letting the xs size contract pick size-4/sm:size-3.5) keeps the row uniform.

suggestion
1 <TriangleAlertIcon className="size-3.5" />

No longer relevant as of 696a2e10e7ace22568c0c6b8a62d571ab5854f69

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 210047d2 · right 1520–1534

Original diff
@@ -1507,104 +1500,105 @@ export function PullRequestDetailPanel({
           <div
             ref={condensedRowRef}
             className={cn(
-              "min-h-0 overflow-hidden",
+              "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
               condensed
-                ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"
-                : "opacity-0",
+                ? "translate-y-0 opacity-100 delay-50"
+                : "translate-y-1 opacity-0 duration-100",
             )}
             inert={!condensed}
           >
             {detail ? (
-              <div className="flex min-w-0 items-center gap-1 px-4 pb-2">
-                <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5">
-                  {visibleTabs.map((item) => (
-                    <button
-                      key={item.value}
-                      type="button"
-                      tabIndex={condensed ? 0 : -1}
-                      aria-pressed={tab === item.value}
-                      onClick={() => setTab(item.value)}
-                      className={cn(
-                        "rounded-md px-2 py-1 text-[11px] transition-colors",
-                        tab === item.value
-                          ? "bg-accent text-foreground"
-                          : "text-muted-foreground hover:text-foreground",
-                      )}
+              <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">
+                <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
+                  <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
+                    <PullRequestActorAvatar actor={detail.author} className="shrink-0" />
+                    <span className="sr-only">{detail.author?.login ?? "ghost"}</span>
+                    <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>
+                  </span>
+                  <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" />
+                  <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65">
+                    <Tooltip>
+                      <TooltipTrigger
+                        render={
+                          <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
+                            {isStackedPullRequest ? (
+                              <LayersIcon
+                                aria-label="Stacked pull request"
+                                className="size-3 shrink-0"
+                              />
+                            ) : null}
+                            <code className="min-w-0 truncate">{detail.baseBranch}</code>
+                          </span>
+                        }
+                      />
+                      <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup>

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1520–1534

M
macroscopeappBotAug 19, 2026

pullRequest/PullRequestDetailPanel.tsx:1534

The new LayersIcon is the only place "stacked pull request" is said, and it is said on a bare <svg> inside a tooltip trigger whose popup repeats just the branch name — so a sighted reader gets an unexplained glyph, and the label sits on an element with no role rather than on the trigger. Its neighbour in this same row (PullRequestBaseFreshnessWarning) does the opposite: the label is on the interactive element and the meaning is spelled out in the layer it opens.

Folding the meaning into the tooltip that is already here is the smallest fix (the expanded copy of this row at ~1683 needs the same change).

suggestion
1 <TooltipPopup side="top">
2 {isStackedPullRequest
3 ? `Stacked on ${detail.baseBranch}`
4 : detail.baseBranch}
5 </TooltipPopup>

No longer relevant as of f1c8120882a1c7ad951abf7f8e9d12bb06ac1f2e

M
mariaAug 19, 2026

Fixed on the latest head. The tooltip now explains the glyph as “Stacked on ”.

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision fe40b2ac · right 436–443

Original diff
@@ -430,26 +429,13 @@ export function PullRequestDetailPanel({
     );
   }, [tab]);
   const [chromeCondensed, setChromeCondensed] = useState(false);
-  // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll
-  // events, so the capture handler always writes the active tab's entry — and a tab switch
-  // reads the destination's memory instead of inheriting the tab being left. A tab too short
-  // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome
-  // it has no scrollbar to reopen.
   const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
   useEffect(() => {
     setChromeCondensed(chromeStateByTab.current[tab] ?? false);
   }, [tab]);
-  const condensed = chromeVariant === "collapse" && chromeCondensed;
-  // Collapsing removes the fold's height from the chrome, which would otherwise hand that
-  // height to the scrollport and leap the content up by it mid-scroll. The cure is exact
-  // compensation: collapse only once the reader has scrolled at least the fold's height,
-  // then give that height back to `scrollTop` before the next paint — the content under
-  // their eyes does not move, and the collapse itself is the only thing that changes.
+  const condensed = chromeCondensed;

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 436–443

M
macroscopeappBotAug 19, 2026

pullRequest/PullRequestDetailPanel.tsx:436

The collapse machinery survived this restructure unchanged, but its explanations did not: chromeStateByTab and the compensationRef layout effect (lines 432-447), the foldHeight - condensedRow refund (line 1856), and the deliberately one-directional track animation (grid-rows-[0fr] with no transition on collapse, eased only on reopen — lines 1570-1572) are all still here with nothing left saying why.

The asymmetric track is the risky one: it now reads as an oversight, so the next reader is likely to "fix" it by letting collapse animate too, which puts the animated track back in a fight with the scrollTop write and reintroduces the mid-scroll content jump the refund exists to prevent. Since the mechanism itself did not change, consider restoring the two notes that cover it — why collapse is instant while reopening eases, and why the refund is the fold's height minus the condensed row's.

No longer relevant as of 6fe8428ca433ad6d4151f6757be25b190638c33e

apps/web/src/lib/openPullRequestLink.ts · Earlier revision 8c3da416 · right 130

Original diff
@@ -118,6 +118,17 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
   return null;
 }
 
+/** The repository root behind a recognised change-request URL, without PR-specific state. */
+export function changeRequestRepositoryUrl(targetUrl: string): string | null {
+  const changeRequest = parseChangeRequestUrl(targetUrl);
+  if (changeRequest === null) return null;
+  const url = new URL(targetUrl);
+  url.pathname = `/${changeRequest.repository}`;
+  url.search = "";
+  url.hash = "";
+  return url.toString();
+}
C
cursorBotAug 16, 2026

Repo link lowercases the opened path

Low Severity

changeRequestRepositoryUrl rebuilds the repository path from parseChangeRequestUrl, which lowercases the captured path for identity matching. The opened URL therefore loses the host’s original casing and can 404 on case-sensitive GitLab or similar installs, while the pull request number still uses the original detail.url.

Reviewed by Cursor Bugbot for commit 8c3da41600c7ab9da4c3c561ce9057bbdbc48943. Configure here.

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision b73193d0 · right 1159–1159

Original diff
@@ -1131,38 +1141,22 @@ export function PullRequestDetailPanel({
                 >
                   #{detail.number}
                 </button>
-                <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
+                <h1 className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
                   {detail.title}
-                </span>
-                {conflicting ? (
-                  <Badge
-                    variant="error"
-                    className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
-                  >
-                    <TriangleAlertIcon className="size-3" />
-                    Conflicts
-                  </Badge>
-                ) : checksSummary ? (
-                  <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
-                    {detail && checksState !== null ? (
-                      <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
-                    ) : null}
-                    {checksSummary}
-                  </span>
-                ) : null}
+                </h1>
               </>
             ) : null}
           </div>
         </div>
-        <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
+        <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">
           {detail ? (
             <>
               <Menu>
                 <MenuTrigger
                   render={
                     <Button
                       aria-label="More pull request actions"
-                      className="size-6"
+                      className="order-last size-6"

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1159–1159

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestDetailPanel.tsx:1159

order-last paints this trigger at the end of the actions row while it stays first in the DOM, so keyboard focus reaches "More pull request actions" before Check out and the primary action, and assistive tech reads the row in an order that no longer matches what is shown (it would also land after the panel-collapse button when onClose is passed). Moving the whole <Menu> block to after the primary-action button and dropping order-last keeps visual, DOM and focus order the same.

No longer relevant as of 2dd5525de4577b23ff331860af354f54ead0a154

apps/web/src/components/pullRequest/pullRequestDetail.logic.ts · Earlier revision 8c3da416 · right 84

Original diff
@@ -73,6 +74,15 @@ export function pullRequestActionMenuHasGroup(
   return showsDraftToggle || showsAutoMerge || showsMergeMethods;
 }
 
+export function isStackedPullRequestBase(
+  baseBranch: string,
+  refs: ReadonlyArray<Pick<VcsRef, "name" | "isDefault">>,
+): boolean {
+  const defaultRef = refs.find((refName) => refName.isDefault);
+  if (!defaultRef) return false;
+  return defaultRef.name !== baseBranch && !defaultRef.name.endsWith(`/${baseBranch}`);
+}
C
cursorBotAug 16, 2026

Stacked check over-matches branch suffixes

Low Severity

isStackedPullRequestBase treats any default ref that merely ends with /${baseBranch} as the same branch. That is meant for origin/main versus main, but it also matches unrelated names such as release/1.0 versus 1.0, so a stacked pull request can lose the stacked indicator.

Reviewed by Cursor Bugbot for commit 8c3da41600c7ab9da4c3c561ce9057bbdbc48943. Configure here.

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision ac244453 · right 1181–1193

Original diff
@@ -1157,201 +1172,171 @@ export function PullRequestDetailPanel({
                   />
                   <TooltipPopup side="top">{detail.title}</TooltipPopup>
                 </Tooltip>
-                {conflicting ? (
-                  <Badge
-                    variant="error"
-                    className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
-                  >
-                    <TriangleAlertIcon className="size-3" />
-                    Conflicts
-                  </Badge>
-                ) : checksSummary ? (
-                  <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
-                    {detail && checksState !== null ? (
-                      <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
-                    ) : null}
-                    {checksSummary}
-                  </span>
-                ) : null}
               </>
             ) : null}
           </div>
         </div>
-        <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
+        <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">
           {detail ? (
-            <>
-              <Menu>
-                <MenuTrigger
-                  render={
-                    <Button
-                      aria-label="More pull request actions"
-                      className="size-6"
-                      size="icon-xs"
-                      variant="ghost-muted"
-                    />
-                  }
-                >
-                  <MoreHorizontalIcon className="size-4" />
-                </MenuTrigger>
-                <MenuPopup align="end" side="bottom" className="min-w-72">
-                  <MenuItem disabled={detailQuery.isPending} onClick={() => void refreshFromHost()}>
-                    <RefreshCwIcon className="size-3.5" />
-                    Refresh
-                  </MenuItem>
-                  <MenuItem disabled={handoff !== null} onClick={askAboutPullRequest}>
-                    <MessageCircleQuestionIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
-                    <span className="flex min-w-0 flex-col">
-                      <span>{handoff === "ask" ? "Opening..." : "Ask a question"}</span>
-                      <span className="text-xs text-muted-foreground">
-                        {attachTarget !== null
-                          ? "Adds the pull request to this thread's composer."
-                          : "Opens a thread that knows which pull request you mean."}
-                      </span>
+            <Menu>

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1181–1193

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestDetailPanel.tsx:1181

Moving the trigger last does fix visual/DOM order, but wrapping the whole actions row in this <Menu> also puts the Check out <Menu> (line 1346) — plus the auto-merge Badge and the primary-action Button — inside the actions menu's React context. Base UI derives submenu behaviour from that context (MenuSub in ui/menu.tsx is just MenuPrimitive.SubmenuRoot, the same root component), so the Check out menu is now treated as a nested menu rather than an independent one: nested menus default to openOnHover and share the parent's floating tree, focus restoration and Escape handling. Nothing else in apps/web/src nests a Menu root outside a popup.

Keeping the overflow menu self-contained — Menu → MenuTrigger → MenuPopup — and rendering that whole block after the primary-action button keeps visual, DOM and focus order aligned without changing menu semantics.

No longer relevant as of 46c5c1ba3bca374dbff131076a47e5d063865899

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 2dd5525d · right 1406

Original diff
@@ -1407,7 +1391,19 @@ export function PullRequestDetailPanel({
                   {pendingAction === "merge" ? "Merging..." : "Merge"}
                 </Button>
               ) : null}
-            </>
+              <MenuTrigger
+                render={
+                  <Button
+                    aria-label="More pull request actions"
+                    className="size-6"
+                    size="icon-xs"
+                    variant="ghost-muted"
+                  />
+                }
+              >
+                <MoreHorizontalIcon className="size-4" />
+              </MenuTrigger>
+            </Menu>
C
cursorBotAug 16, 2026

Actions menu wraps unrelated controls

Medium Severity

The overflow actions Menu now wraps the checkout Menu, badges, and primary action buttons, with MenuTrigger after MenuPopup. Those controls used to be siblings in a fragment. Nesting an independent menu and non-menu actions under the overflow root ties them into the same menu tree and can break open/dismiss and focus behavior for checkout and the ⋯ menu.

Reviewed by Cursor Bugbot for commit 2dd5525de4577b23ff331860af354f54ead0a154. Configure here.

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision f1c81208 · right 1120–1120

Original diff
@@ -1101,34 +1105,47 @@ export function PullRequestDetailPanel({
         ).length
       : 0;
 
+  if (detailQuery.isPending && !detail) {
+    return <PullRequestDetailGhost />;
+  }
+
   return (
     <div className="flex h-full min-h-0 w-full flex-col bg-background">
-      {/* The top row's geometry never changes: both of its states occupy the same stacked
-          cell and crossfade, so the actions on the right have one home whatever the chrome
-          is doing below. The fold and this fade share one 200ms clock. */}
       <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
-        {/* The fixed height lives on the two top-row cells — not the grid, whose later rows
-            are the fold — so the actions have one immovable home in both states. */}
-        <div className="ml-4 grid h-11 min-w-0 items-center">
+        <div className="ml-4 grid h-7 min-w-0 items-center">
           <div
             aria-hidden={condensed}
             inert={condensed}
             className={cn(
-              "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-opacity sm:text-xs motion-reduce:transition-none",
-              // Sequenced, not simultaneous: the leaving layer clears quickly before the
-              // arriving one lands, so no frame shows both texts superimposed at half opacity.
+              "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out motion-reduce:transform-none motion-reduce:transition-none",

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1120–1120

M
macroscopeappBotAug 19, 2026

pullRequest/PullRequestDetailPanel.tsx:1120

Both crossfade layers of the top row dropped sm:text-xs while keeping text-sm, so the repository/number line (and the condensed title layer, line 1176, which carries the identical string) now renders at 14px on desktop inside a row that shrank from h-11 to h-7 — while every other line of this chrome (the condensed meta row, the branch pair, the tab bar) stays 12px. text-sm … sm:text-xs is the coarse-pointer/desktop pairing the control primitives themselves use (Button xs, Select xs, Badge), so the desktop step is worth keeping here too.

suggestion
1 "col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-sm text-muted-foreground transition-[opacity,transform] ease-out sm:text-xs motion-reduce:transform-none motion-reduce:transition-none",

The same addition applies to the condensed layer at line 1176.

No longer relevant as of 1088b8114cca06cba3fc0b18e926a5a7582c3195

M
mariaAug 19, 2026

Fixed in 1088b8114. Both crossfade layers keep sm:text-xs so the compact desktop header stays at the intended scale.

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 929b2835 · right 1673–1673

Original diff
@@ -1660,60 +1636,71 @@ export function PullRequestDetailPanel({
                 </PullRequestMetaLine>
 
                 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
-                  <Tooltip>
-                    <TooltipTrigger
-                      render={
-                        <code className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground">
-                          {detail.baseBranch}
-                        </code>
-                      }
-                    />
-                    <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup>
-                  </Tooltip>
-                  {freshness ? (
-                    <PullRequestBaseFreshnessWarning
-                      baseBranch={detail.baseBranch}
-                      freshness={freshness}
-                      pending={actionPending}
-                      onUpdate={(method) => void perform("update-branch", undefined, method)}
+                  <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
+                    <Tooltip>
+                      <TooltipTrigger
+                        render={
+                          <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
+                            {isStackedPullRequest ? (
+                              <LayersIcon
+                                aria-label="Stacked pull request"
+                                className="size-3 shrink-0"
+                              />
+                            ) : null}
+                            <code className="min-w-0 truncate">{detail.baseBranch}</code>
+                          </span>
+                        }
+                      />
+                      <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup>
+                    </Tooltip>
+                    {freshness ? (
+                      <PullRequestBaseFreshnessWarning
+                        baseBranch={detail.baseBranch}
+                        freshness={freshness}
+                        pending={actionPending}
+                        onUpdate={(method) => void perform("update-branch", undefined, method)}
+                      />
+                    ) : null}
+                    <ArrowLeftIcon
+                      aria-label="receives changes from"
+                      className="size-3.5 shrink-0 opacity-60"
                     />
-                  ) : null}
-                  <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />
-                  <Tooltip>
-                    <TooltipTrigger
-                      render={
-                        <button
-                          type="button"
-                          className="grid min-w-0 max-w-64 shrink cursor-pointer rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
-                          aria-label={
-                            isBranchCopied ? "Branch name copied" : "Copy pull request branch"
-                          }
-                          onClick={() => copyBranchToClipboard(detail.headBranch)}
-                        />
-                      }
-                    >
-                      <code
-                        className={cn(
-                          "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
-                          isBranchCopied ? "opacity-0" : "opacity-100",
-                        )}
-                      >
-                        {detail.headBranch}
-                      </code>
-                      <span
-                        aria-hidden="true"
-                        className={cn(
-                          "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
-                          isBranchCopied ? "opacity-100" : "opacity-0",
-                        )}
+                    <Tooltip>
+                      <TooltipTrigger
+                        render={
+                          <button
+                            type="button"
+                            className="grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1673–1673

M
macroscopeappBotAug 17, 2026

pullRequest/PullRequestDetailPanel.tsx:1673

The rewritten copy-branch button keeps outline-none but drops focus-visible:ring-offset-1 focus-visible:ring-offset-background, so its keyboard focus ring now paints flush against the branch text, while every control around it — Button, Toggle, and this button's previous class string — draws the same ring with a background-coloured offset. Restoring the offset pair keeps one focus treatment across the row.

suggestion
1 className="grid w-fit min-w-0 max-w-full shrink cursor-pointer rounded px-1 py-0.5 text-left outline-none transition-colors hover:bg-accent/45 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"

No longer relevant as of 696a2e10e7ace22568c0c6b8a62d571ab5854f69

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision c4b280b2 · right 1336–1336

Original diff
@@ -1339,16 +1333,10 @@ export function PullRequestDetailPanel({
                   <MenuTrigger
                     disabled={handoff !== null}
                     render={
-                      <Button size="xs" variant="outline">
-                        {handoff?.startsWith("checkout") ? (
-                          "Checking out..."
-                        ) : (
-                          <>
-                            <GitBranchIcon className="size-3" />
-                            Check out
-                            <ChevronDownIcon className="size-3 text-muted-foreground" />
-                          </>
-                        )}
+                      <Button size="xs" variant="outline" className="ps-[8.5px]">

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1336–1336

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestDetailPanel.tsx:1336

ps-[8.5px] overrides the xs size's own inline-start padding at one call site, so the two size="xs" icon-leading buttons in this row no longer share a geometry: the neighbouring Resolve conflicts / Merge buttons keep the primitive's px-[calc(--spacing(2)-1px)]. The primitive already accounts for leading glyphs via [&_svg]:-mx-0.5.

Dropping the override keeps the row uniform; if the optical compensation is genuinely wanted for icon-leading buttons, it belongs in the Button size contract rather than here.

suggestion
1 <Button size="xs" variant="outline">

No longer relevant as of 2dd5525de4577b23ff331860af354f54ead0a154

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision fb06af7a · right 1560–1560

Original diff
@@ -1480,120 +1476,100 @@ export function PullRequestDetailPanel({
           ) : null}
         </div>
 
-        {/* The condensed chrome's second row: the tabs that the closing fold takes with it,
-            and compact copies of the branch pair and diff stat so they stay in sight while
-            the full rows are folded away. Same zero-track mechanism as the fold, inverted. */}
-        <div
-          className={cn(
-            "col-span-2 grid",
-            // Inverse of the fold's track: closing eases while the fold above eases open,
-            // so the chrome trades rows in one motion instead of two jumps.
-            condensed
-              ? "grid-rows-[1fr]"
-              : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
-          )}
-        >
+        <div className={cn("col-span-2 grid", condensed ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
           <div
             ref={condensedRowRef}
             className={cn(
-              "min-h-0 overflow-hidden",
+              "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
               condensed
-                ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"
-                : "opacity-0",
+                ? "translate-y-0 opacity-100 delay-50"
+                : "translate-y-1 opacity-0 duration-100",
             )}
             inert={!condensed}
           >
             {detail ? (
-              <div className="flex min-w-0 items-center gap-1 px-4 pb-2">
-                <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5">
-                  {visibleTabs.map((item) => (
-                    <button
-                      key={item.value}
-                      type="button"
-                      tabIndex={condensed ? 0 : -1}
-                      aria-pressed={tab === item.value}
-                      onClick={() => setTab(item.value)}
-                      className={cn(
-                        "rounded-md px-2 py-1 text-[11px] transition-colors",
-                        tab === item.value
-                          ? "bg-accent text-foreground"
-                          : "text-muted-foreground hover:text-foreground",
-                      )}
+              <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">
+                <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
+                  <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
+                    <PullRequestActorAvatar actor={detail.author} className="shrink-0" />
+                    <span className="sr-only">{detail.author?.login ?? "ghost"}</span>
+                    <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>
+                  </span>
+                  <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" />
+                  <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65">
+                    <Tooltip>
+                      <TooltipTrigger
+                        render={
+                          <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
+                            {isStackedPullRequest ? (
+                              <LayersIcon
+                                aria-label="Stacked pull request"
+                                className="size-3 shrink-0"
+                              />
+                            ) : null}
+                            <code className="min-w-0 truncate">{detail.baseBranch}</code>
+                          </span>
+                        }
+                      />
+                      <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup>
+                    </Tooltip>
+                    {freshness ? (
+                      <PullRequestBaseFreshnessWarning
+                        baseBranch={detail.baseBranch}
+                        freshness={freshness}
+                        pending={actionPending}
+                        onUpdate={(method) => void perform("update-branch", undefined, method)}
+                        iconClassName="size-3"
+                      />
+                    ) : null}
+                    <ArrowLeftIcon
+                      aria-label="receives changes from"
+                      className="size-3 shrink-0 opacity-60"
+                    />
+                    <Tooltip>
+                      <TooltipTrigger
+                        render={
+                          <code className="min-w-0 flex-1 truncate">{detail.headBranch}</code>
+                        }
+                      />
+                      <TooltipPopup side="top">{detail.headBranch}</TooltipPopup>
+                    </Tooltip>
+                  </span>
+                  <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2 text-[11px]">
+                    <span
+                      className="inline-flex items-center gap-1 tabular-nums"
+                      aria-label={`${detail.changedFiles.toLocaleString()} changed ${
+                        detail.changedFiles === 1 ? "file" : "files"
+                      }`}
                     >
-                      {item.label}
-                    </button>
-                  ))}
-                </nav>
-                <span className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground">
-                  <Tooltip>
-                    <TooltipTrigger render={<span className="truncate" />}>
-                      {detail.baseBranch}
-                    </TooltipTrigger>
-                    <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup>
-                  </Tooltip>
-                  {freshness ? (
-                    <PullRequestBaseFreshnessWarning
-                      baseBranch={detail.baseBranch}
-                      freshness={freshness}
-                      pending={actionPending}
-                      onUpdate={(method) => void perform("update-branch", undefined, method)}
-                      iconClassName="size-3"
+                      <FileDiffIcon aria-hidden className="size-3" />
+                      {detail.changedFiles.toLocaleString()}
+                    </span>
+                    <PullRequestDiffStat
+                      additions={detail.additions}
+                      deletions={detail.deletions}
+                      className="shrink-0 font-mono text-[11px]"
                     />
-                  ) : null}
-                  <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" />
-                  <Tooltip>
-                    <TooltipTrigger render={<span className="truncate" />}>
-                      {detail.headBranch}
-                    </TooltipTrigger>
-                    <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup>
-                  </Tooltip>
-                </span>
-                <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
-                  <span className="inline-flex items-center gap-1 tabular-nums">
-                    <FileDiffIcon className="size-3" />
-                    {detail.changedFiles.toLocaleString()}
                   </span>
-                  <PullRequestDiffStat
-                    additions={detail.additions}
-                    deletions={detail.deletions}
-                    className="shrink-0 font-mono text-[11px]"
-                  />
-                </span>
+                </div>
               </div>
             ) : null}
           </div>
         </div>
 
-        {/* Folding is a grid track going to zero: the rows below stay mounted, the track
-            animates closed over them, and `inert` takes the hidden controls out of the tab
-            order for as long as the chrome is condensed. */}
-        <div
-          className={cn(
-            "col-span-2 grid",
-            // Collapse is instant: it happens mid-scroll, and the compensation that keeps the
-            // content pinned would fight an animated track frame by frame. Reopening happens
-            // at the top with no compensation, so the fold can ease back in instead of
-            // snapping the content down.
-            condensed
-              ? "grid-rows-[0fr]"
-              : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
-          )}
-        >
+        <div className={cn("col-span-2 grid", condensed ? "grid-rows-[0fr]" : "grid-rows-[1fr]")}>

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1560–1560

M
macroscopeappBotAug 17, 2026

pullRequest/PullRequestDetailPanel.tsx:1560

Removing the eased track makes reopening the chrome snap. grid-rows-[0fr]/[1fr] now flips with no transition-[grid-template-rows], so when the reader scrolls back to top < 4 the fold's full height reappears in one frame and the content area below jumps down by it. Collapsing does have to stay instant — the scrollTop compensation refunds foldHeight - condensedRow and would fight an animated track — but the reopen path deliberately gets no refund, which is why that direction used to ease.

Restoring the transition on the expanding branch (and the mirrored one on the condensed row's track at line 1479, so the two rows still trade height in one motion) keeps the geometry it had before.

suggestion
1 <div
2 className={cn(
3 "col-span-2 grid",
4 condensed
5 ? "grid-rows-[0fr]"
6 : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
7 )}
8 >

No longer relevant as of e942ef87286e84e130ba58d6ad5685ade4e2f5b6

apps/web/src/lib/openPullRequestLink.ts · Earlier revision 1dd5875f · right 126–130

Original diff
@@ -118,6 +118,22 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
   return null;
 }
 
+/** The repository root behind a recognised change-request URL, without PR-specific state. */
+export function changeRequestRepositoryUrl(targetUrl: string): string | null {
+  const changeRequest = parseChangeRequestUrl(targetUrl);
+  if (changeRequest === null) return null;
+  const url = new URL(targetUrl);
+  const repositoryPath =
+    /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
+      url.pathname,
+    )?.[1];
+  if (!repositoryPath) return null;

apps/web/src/lib/openPullRequestLink.ts · right 126–130

M
macroscopeappBotAug 16, 2026

🟡 Medium lib/openPullRequestLink.ts:126

changeRequestRepositoryUrl returns https://gitlab.example/group for a valid nested GitLab URL such as .../group/pull/123/repo/-/merge_requests/42, so the repository link opens the namespace page instead of the repository. The provider-agnostic regex matches /pull/123 before reaching GitLab’s /-/merge_requests/42 marker; extract the GitLab repository path first, then fall back to the other provider patterns.

✅ Resolved in 0437edb65b7dc3483cfa26bb49e9366d6d2e60b0

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 210047d2 · right 1296–1296

Original diff
@@ -1194,29 +1211,103 @@ export function PullRequestDetailPanel({
                   />
                   <TooltipPopup side="top">{detail.title}</TooltipPopup>
                 </Tooltip>
-                {conflicting ? (
-                  <Badge
-                    variant="error"
-                    className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
-                  >
-                    <TriangleAlertIcon className="size-3" />
-                    Conflicts
-                  </Badge>
-                ) : checksSummary ? (
-                  <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
-                    {detail && checksState !== null ? (
-                      <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
-                    ) : null}
-                    {checksSummary}
-                  </span>
-                ) : null}
               </>
             ) : null}
           </div>
         </div>
-        <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
+        <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">
           {detail ? (
             <>
+              {/* Checking a pull request out is the reason to open one here at all, so it is a
+                  button of its own rather than a side effect of asking an agent for something.
+                  It asks where, because the two answers are not interchangeable: one leaves your
+                  work where it is, the other moves the repository you are standing in. Only on
+                  the page: beside a thread the branch is already checked out right there. */}
+              {context === "page" ? (
+                <Menu>
+                  <MenuTrigger
+                    disabled={handoff !== null}
+                    render={
+                      <Button size="xs" variant="outline">
+                        <GitBranchIcon aria-hidden className="size-3.5" />
+                        {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
+                        <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" />
+                      </Button>
+                    }
+                  />
+                  <MenuPopup align="end" side="bottom" className="min-w-72">
+                    <MenuItem onClick={() => startCheckout("worktree")}>
+                      <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
+                      <span className="flex min-w-0 flex-col">
+                        <span>In a separate worktree</span>
+                        <span className="text-xs text-muted-foreground">
+                          Its own folder and thread. Nothing you have open moves.
+                        </span>
+                      </span>
+                    </MenuItem>
+                    <MenuItem onClick={() => startCheckout("local")}>
+                      <FolderGit2Icon className="mt-0.5 size-3.5 shrink-0 self-start" />
+                      <span className="flex min-w-0 flex-col">
+                        <span>In this repository</span>
+                        <span className="text-xs text-muted-foreground">
+                          Switches the branch you are working in, like `gh pr checkout`.
+                        </span>
+                      </span>
+                    </MenuItem>
+                    {pickableEnvironments.length > 0 ? (
+                      <ActOnEnvironmentPicker
+                        environments={pickableEnvironments}
+                        value={actingEnvironmentId}
+                        onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
+                        disabled={handoff !== null}
+                      />
+                    ) : null}
+                  </MenuPopup>
+                </Menu>
+              ) : null}
+              {/* Said where the Merge button is, because it is the answer to why nobody has
+                  pressed it: the merge is already asked for, and the host is holding it. */}
+              {autoMergeArmed ? (
+                <Tooltip>
+                  <TooltipTrigger
+                    render={
+                      <Badge
+                        variant="info"
+                        className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
+                      >
+                        <GitMergeIcon aria-hidden className="size-3" />
+                        Auto-merge
+                      </Badge>
+                    }
+                  />
+                  <TooltipPopup side="top">
+                    The host will merge this on its own once its requirements are met
+                  </TooltipPopup>
+                </Tooltip>
+              ) : null}
+              {primaryAction === "resolve" ? (
+                <Button
+                  size="xs"
+                  variant="destructive-outline"
+                  disabled={handoff !== null}
+                  onClick={startResolveConflicts}
+                >
+                  <TriangleAlertIcon className="size-3.5" />
+                  {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1296–1296

M
macroscopeappBotAug 19, 2026

pullRequest/PullRequestDetailPanel.tsx:1296

This revision deletes the overflow-menu fallback that used handoffLabels.resolveConflicts, so with the conflict row already gone pullRequestHandoffLabels has two entries (resolve, resolveConflicts) that nothing renders, while this button hardcodes its own copy. The contract exists to name where a hand-off lands "without letting each surface guess independently", and the sibling hand-offs in this same panel (fixFindings in the menu, fixFinding/fixCheck passed to the tabs) still read from it — so in thread context this is now the one hand-off that does not say the work stays in the open thread.

Smallest fix either way: render handoffLabels.resolveConflicts here, or drop the two entries (and their assertions in pullRequestDetail.logic.test.ts) if the short copy is deliberate for this compact row, so the contract stops advertising labels no surface uses.

No longer relevant as of f1c8120882a1c7ad951abf7f8e9d12bb06ac1f2e

M
mariaAug 19, 2026

Fixed on the latest head by removing the dead resolve handoff labels and keeping the compact visible copy.

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision 8c3da416 · right 1638–1641

Original diff
@@ -1650,147 +1630,114 @@ export function PullRequestDetailPanel({
                 </div>
               </div>
             ) : null}
+          </div>
+        </div>
 
-            {detail && conflicting ? (
-              <div className="col-span-2 flex items-center gap-1 px-4 pb-3">
-                <Badge
-                  variant="error"
-                  className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive"
+        {detail ? (
+          <nav
+            className={cn(
+              "col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
+              condensed ? "py-1.5" : "py-2",
+            )}

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1638–1641

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestDetailPanel.tsx:1641

The tab nav's padding now changes with the chrome state (py-2 → py-1.5), but the scroll compensation below still refunds only foldHeight - condensedRow height. The chrome loses 4px more than that when it collapses, so the content under the reader jumps up ~4px mid-scroll — exactly what the compensation exists to prevent. Keeping the nav height constant is the smallest fix; otherwise this row has to be measured into chromeDelta too.

suggestion
1 className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"

No longer relevant as of 0437edb65b7dc3483cfa26bb49e9366d6d2e60b0

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision ac244453 · left 1316

Original diff
@@ -1157,201 +1172,171 @@ export function PullRequestDetailPanel({
                   />
                   <TooltipPopup side="top">{detail.title}</TooltipPopup>
                 </Tooltip>
-                {conflicting ? (
-                  <Badge
-                    variant="error"
-                    className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
-                  >
-                    <TriangleAlertIcon className="size-3" />
-                    Conflicts
-                  </Badge>
-                ) : checksSummary ? (
-                  <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
-                    {detail && checksState !== null ? (
-                      <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
-                    ) : null}
-                    {checksSummary}
-                  </span>
-                ) : null}
               </>
             ) : null}
           </div>
         </div>
-        <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
+        <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">
           {detail ? (
-            <>
-              <Menu>
-                <MenuTrigger
-                  render={
-                    <Button
-                      aria-label="More pull request actions"
-                      className="size-6"
-                      size="icon-xs"
-                      variant="ghost-muted"
-                    />
-                  }
-                >
-                  <MoreHorizontalIcon className="size-4" />
-                </MenuTrigger>
-                <MenuPopup align="end" side="bottom" className="min-w-72">
-                  <MenuItem disabled={detailQuery.isPending} onClick={() => void refreshFromHost()}>
-                    <RefreshCwIcon className="size-3.5" />
-                    Refresh
-                  </MenuItem>
-                  <MenuItem disabled={handoff !== null} onClick={askAboutPullRequest}>
-                    <MessageCircleQuestionIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
-                    <span className="flex min-w-0 flex-col">
-                      <span>{handoff === "ask" ? "Opening..." : "Ask a question"}</span>
-                      <span className="text-xs text-muted-foreground">
-                        {attachTarget !== null
-                          ? "Adds the pull request to this thread's composer."
-                          : "Opens a thread that knows which pull request you mean."}
-                      </span>
+            <Menu>
+              <MenuPopup align="end" side="bottom" className="min-w-72">
+                <MenuItem disabled={detailQuery.isPending} onClick={() => void refreshFromHost()}>
+                  <RefreshCwIcon className="size-3.5" />
+                  Refresh
+                </MenuItem>
+                <MenuItem disabled={handoff !== null} onClick={askAboutPullRequest}>
+                  <MessageCircleQuestionIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
+                  <span className="flex min-w-0 flex-col">
+                    <span>{handoff === "ask" ? "Opening..." : "Ask a question"}</span>
+                    <span className="text-xs text-muted-foreground">
+                      {attachTarget !== null
+                        ? "Adds the pull request to this thread's composer."
+                        : "Opens a thread that knows which pull request you mean."}
                     </span>
-                  </MenuItem>
-                  <MenuItem disabled={handoff !== null} onClick={explainPullRequest}>
-                    <BookOpenIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
-                    <span className="flex min-w-0 flex-col">
-                      <span>{handoff === "explain" ? "Opening..." : "Explain this PR"}</span>
-                      <span className="text-xs text-muted-foreground">
-                        A walk through the diff and what to read closely.
-                      </span>
+                  </span>
+                </MenuItem>
+                <MenuItem disabled={handoff !== null} onClick={explainPullRequest}>
+                  <BookOpenIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
+                  <span className="flex min-w-0 flex-col">
+                    <span>{handoff === "explain" ? "Opening..." : "Explain this PR"}</span>
+                    <span className="text-xs text-muted-foreground">
+                      A walk through the diff and what to read closely.
                     </span>
-                  </MenuItem>
-                  <MenuItem disabled={handoff !== null} onClick={startFixFindings}>
-                    <HammerIcon className="size-3.5" />
-                    {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}
-                  </MenuItem>
-                  {pickableEnvironments.length > 0 ? (
-                    <ActOnEnvironmentPicker
-                      environments={pickableEnvironments}
-                      value={actingEnvironmentId}
-                      onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
-                      disabled={handoff !== null}
-                    />
-                  ) : null}
-                  <MenuSeparator />
-                  {detail.state === "open" ? (
-                    <>
-                      {/* Only where the button row could not take it: "Ready for review" on a
+                  </span>
+                </MenuItem>
+                <MenuItem disabled={handoff !== null} onClick={startFixFindings}>
+                  <HammerIcon className="size-3.5" />
+                  {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}
+                </MenuItem>
+                {pickableEnvironments.length > 0 ? (
+                  <ActOnEnvironmentPicker
+                    environments={pickableEnvironments}
+                    value={actingEnvironmentId}
+                    onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
+                    disabled={handoff !== null}
+                  />
+                ) : null}
+                <MenuSeparator />
+                {detail.state === "open" ? (
+                  <>
+                    {/* Only where the button row could not take it: "Ready for review" on a
                           draft is the primary header button, so offering it here as well would
                           show the same action twice. */}
-                      {showsDraftToggle ? (
-                        <MenuItem
-                          disabled={actionPending}
-                          onClick={() => void perform(detail.isDraft ? "ready" : "draft")}
-                        >
-                          {detail.isDraft ? (
-                            <GitPullRequestIcon className="size-3.5" />
-                          ) : (
-                            <GitPullRequestDraftIcon className="size-3.5" />
-                          )}
-                          {detail.isDraft ? "Ready for review" : "Convert to draft"}
-                        </MenuItem>
-                      ) : null}
-                      {/* The same merge, left with the host to carry out once the things it
+                    {showsDraftToggle ? (
+                      <MenuItem
+                        disabled={actionPending}
+                        onClick={() => void perform(detail.isDraft ? "ready" : "draft")}
+                      >
+                        {detail.isDraft ? (
+                          <GitPullRequestIcon className="size-3.5" />
+                        ) : (
+                          <GitPullRequestDraftIcon className="size-3.5" />
+                        )}
+                        {detail.isDraft ? "Ready for review" : "Convert to draft"}
+                      </MenuItem>
+                    ) : null}
+                    {/* The same merge, left with the host to carry out once the things it
                           waits on are done. It is offered beside the merge rather than instead
                           of it, because the reader who can wait and the reader who cannot are
                           the same person on different days — and a conflicting branch is neither,
                           since nothing the host waits for will clear it. */}
-                      {autoMergeArmed && can("disable-auto-merge") ? (
-                        <MenuItem
-                          disabled={actionPending}
-                          onClick={() => void perform("disable-auto-merge")}
-                        >
-                          <GitMergeIcon className="size-3.5" />
-                          Disable auto-merge
-                        </MenuItem>
-                      ) : !autoMergeArmed &&
-                        !detail.isDraft &&
-                        !conflicting &&
-                        can("enable-auto-merge") &&
-                        allowedMergeMethods.length > 0 ? (
-                        <MenuItem
-                          disabled={actionPending}
-                          onClick={() => setConfirmAction("enable-auto-merge")}
-                        >
-                          <GitMergeIcon className="size-3.5" />
-                          Enable auto-merge
-                        </MenuItem>
-                      ) : null}
-                      {/* A preference for the merge action rather than a second action, so it
+                    {autoMergeArmed && can("disable-auto-merge") ? (
+                      <MenuItem
+                        disabled={actionPending}
+                        onClick={() => void perform("disable-auto-merge")}
+                      >
+                        <GitMergeIcon className="size-3.5" />
+                        Disable auto-merge
+                      </MenuItem>
+                    ) : !autoMergeArmed &&
+                      !detail.isDraft &&
+                      !conflicting &&
+                      can("enable-auto-merge") &&
+                      allowedMergeMethods.length > 0 ? (
+                      <MenuItem
+                        disabled={actionPending}
+                        onClick={() => setConfirmAction("enable-auto-merge")}
+                      >
+                        <GitMergeIcon className="size-3.5" />
+                        Enable auto-merge
+                      </MenuItem>
+                    ) : null}
+                    {/* A preference for the merge action rather than a second action, so it
                           is a radio group here instead of a chevron welded to the Merge pill.
                           Hidden while conflicting: every method would fail. */}
-                      {/* Only where merging is on offer at all: a strategy to merge with is not
+                    {/* Only where merging is on offer at all: a strategy to merge with is not
                           a choice for someone who may not merge. */}
-                      {showsMergeMethods ? (
-                        <>
-                          {/* Only below the draft control. A host with no draft of its own, or
+                    {showsMergeMethods ? (
+                      <>
+                        {/* Only below the draft control. A host with no draft of its own, or
                               a draft whose control is already the header button, would leave
                               this against the separator that opened the group. */}
-                          {showsDraftToggle ? <MenuSeparator /> : null}
-                          <MenuRadioGroup
-                            value={selectedMergeMethod}
-                            onValueChange={(method) =>
-                              setMergeMethod(method as PullRequestMergeMethod)
-                            }
-                          >
-                            {allowedMergeMethods.map((method) => (
-                              <MenuRadioItem key={method} value={method} disabled={actionPending}>
-                                {/* The radio item lays its children out as one block, so the
+                        {showsDraftToggle ? <MenuSeparator /> : null}
+                        <MenuRadioGroup
+                          value={selectedMergeMethod}
+                          onValueChange={(method) =>
+                            setMergeMethod(method as PullRequestMergeMethod)
+                          }
+                        >
+                          {allowedMergeMethods.map((method) => (
+                            <MenuRadioItem key={method} value={method} disabled={actionPending}>
+                              {/* The radio item lays its children out as one block, so the
                                     icon and the label need their own row to share a line. */}
-                                <span className="flex min-w-0 items-center gap-2">
-                                  <GitMergeIcon className="size-3.5" />
-                                  <span className="capitalize">{method}</span>
-                                </span>
-                              </MenuRadioItem>
-                            ))}
-                          </MenuRadioGroup>
-                        </>
-                      ) : null}
-                      {pullRequestActionMenuHasGroup(
-                        showsDraftToggle,
-                        showsAutoMerge,
-                        showsMergeMethods,
-                      ) ? (
-                        <MenuSeparator />
-                      ) : null}
-                    </>
-                  ) : null}
C
cursorBotAug 16, 2026

Unreachable conflict menu action

Low Severity

The overflow menu still gates Resolve conflicts with conflicting && primaryAction !== "resolve", but open conflicting PRs now always set primaryAction to resolve. That branch can no longer render, so the menu path is dead leftover from the old priority order.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit ac244453615ce03ede0d2b210249f038c9b980e6. Configure here.

apps/web/src/routes/_chat.pull-requests.tsx · Earlier revision b73193d0 · right 1648

Original diff
@@ -1627,37 +1639,18 @@ function CompactFilterMenu<Value extends string>({
   options: ReadonlyArray<PullRequestFilterOption<Value>>;
   onChange: (value: Value) => void;
 }) {
-  const current = options.find((option) => option.value === value) ?? options[0]!;
+  const headerOptions = useMemo<ReadonlyArray<HeaderFilterMenuOption<Value>>>(
+    () =>
+      options.map((option) => ({
+        value: option.value,
+        label: option.label,
+        icon: <option.Icon aria-hidden className="size-3.5" />,
+        ...(option.unavailable ? { disabledReason: option.unavailable } : {}),
M
macroscopeappBotAug 16, 2026

🟠 High routes/_chat.pull-requests.tsx:1648

An unavailable host with unavailable: "" is re-enabled, allowing selection of a source already known to be unreadable. This truthiness check omits disabledReason for empty strings; preserve definedness by checking option.unavailable !== undefined.

✅ Resolved in 2dd5525de4577b23ff331860af354f54ead0a154

apps/web/src/routes/_chat.pull-requests.tsx · Earlier revision 8c3da416 · right 1947

Original diff
@@ -1909,19 +1914,43 @@ function PullRequestsColumn({
         {/* The top padding is the fade band's own height (1.5rem here), the same pairing the
             settings page makes: at rest the controls sit fully below the mask, and only
             content actually passing under the chrome fades. */}
-        <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12">
+        <WorkspacePageContainer className="gap-4">
           <div className="flex flex-col gap-3">
             <div ref={inFlowSearchRef} className="flex items-center gap-2">
               {searchInput}
               {filtersMenu}
+              {!condensed ? (
+                <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
+              ) : null}
             </div>
             {/* Scrolled past this marker, the controls are gone and the title takes over. */}
             <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />
           </div>
 
           {listBody}
-        </div>
+        </WorkspacePageContainer>
       </div>
     </div>
   );
 }
+
+function PullRequestRefreshControl({
+  compact = false,
+  refreshing,
+  onRefresh,
+}: {
+  compact?: boolean;
+  refreshing: boolean;
+  onRefresh: () => void;
+}) {
+  return (
+    <Button
M
macroscopeappBotAug 16, 2026

🟡 Medium routes/_chat.pull-requests.tsx:1947

While refreshing is true, repeated clicks still invoke onRefresh, so duplicate invalidations start the list, partitions, stats, and detail refresh cascade concurrently. Disable the Button with disabled={refreshing} or otherwise guard the click, matching the existing empty-state refresh controls.

✅ Resolved in 89fac2a9dc591dd8d696ec484495ed574ac41650

apps/web/src/routes/_chat.pull-requests.tsx · Earlier revision 986c2d59 · right 1197

Original diff
@@ -1181,6 +1184,17 @@ function PullRequestsRouteView() {
         : null,
     [search.number, search.repository, selectedProject],
   );
+  const linkedSelectionMatchesSurface =
+    linkedSelection !== null &&
+    selectedPullRequestSurface !== null &&
+    linkedSelection.environmentId === selectedPullRequestSurface.environmentId &&
+    linkedSelection.projectId === selectedPullRequestSurface.projectId &&
+    linkedSelection.repository === selectedPullRequestSurface.repository &&
+    linkedSelection.number === selectedPullRequestSurface.number;
+  // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are
+  // history, though, not a current selection: without this check they leave the toggle looking
+  // available after the selected pull request has been cleared.
+  const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface;
M
macroscopeappBotAug 17, 2026

🟡 Medium routes/_chat.pull-requests.tsx:1197

Closing the panel clears the linked selection, so rightPanelAvailable becomes false even when selectedPullRequestSurface still contains a retained tab. The layout toggle is then disabled and the user cannot reopen the panel; base availability on the retained selected surface instead.

✅ Resolved in 46c5c1ba3bca374dbff131076a47e5d063865899

apps/web/src/components/pullRequest/pullRequestDetail.logic.ts · Earlier revision 8c3da416 · right 83–83

Original diff
@@ -73,6 +74,15 @@ export function pullRequestActionMenuHasGroup(
   return showsDraftToggle || showsAutoMerge || showsMergeMethods;
 }
 
+export function isStackedPullRequestBase(
+  baseBranch: string,
+  refs: ReadonlyArray<Pick<VcsRef, "name" | "isDefault">>,
+): boolean {
+  const defaultRef = refs.find((refName) => refName.isDefault);
+  if (!defaultRef) return false;
+  return defaultRef.name !== baseBranch && !defaultRef.name.endsWith(`/${baseBranch}`);

apps/web/src/components/pullRequest/pullRequestDetail.logic.ts · right 83–83

M
macroscopeappBotAug 16, 2026

🟡 Medium pullRequest/pullRequestDetail.logic.ts:83

isStackedPullRequestBase treats origin/feature/main as matching base branch main, so distinct branches are reported as non-stacked and the stacked-PR indicator is hidden. The unrestricted endsWith check matches nested branch names; strip only the remote-name prefix before comparing with baseBranch.

✅ Resolved in 89fac2a9dc591dd8d696ec484495ed574ac41650

apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx · Earlier revision 8c3da416 · right 199–199

Original diff
@@ -196,12 +196,12 @@ function MetaRow({
   children: ReactNode;
 }) {
   return (
-    <div className="flex items-center gap-2 py-1.5 text-xs">
-      <span className="flex w-24 shrink-0 items-center gap-1.5 text-muted-foreground">
+    <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 text-xs">

apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx · right 199–199

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestSummaryTab.tsx:199

Trading py-1.5 for min-h-8 drops the row's vertical padding, and both the Reviewers and Labels cells are flex-wrap — once they wrap, the pills sit flush against the neighbouring rows. Keeping py-1.5 alongside min-h-8 preserves the 32px single-line rhythm the ghost mirrors and restores the gap when a row wraps.

suggestion
1 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs">

No longer relevant as of 0437edb65b7dc3483cfa26bb49e9366d6d2e60b0

apps/web/src/components/HeaderFilterMenu.tsx · Earlier revision ac244453 · right 55–55

Original diff
@@ -38,8 +41,22 @@ export function HeaderFilterMenu<Value extends string>({
       <MenuPopup align={align} side="bottom" className={popupClassName ?? "min-w-40"}>
         <MenuRadioGroup value={current.value} onValueChange={(next) => onChange(next as Value)}>
           {options.map((option) => (
-            <MenuRadioItem key={option.value} value={option.value}>
-              {option.label}
+            <MenuRadioItem
+              key={option.value}
+              value={option.value}
+              disabled={option.disabledReason !== undefined}
+            >
+              <span className="flex min-w-0 flex-1 items-center gap-2">
+                <span className="flex min-w-0 items-center gap-2">
+                  {option.icon}
+                  {option.label}
+                </span>
+                {option.disabledReason !== undefined ? (
+                  <span className="ms-auto text-xs text-muted-foreground">

apps/web/src/components/HeaderFilterMenu.tsx · right 55–55

M
macroscopeappBotAug 16, 2026

disabledReason is fed the host's full failure sentence (e.g. "GitHub CLI (gh) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload."), and this span has no width cap, so one unreadable host stretches the popup far past its min-w-40. The pill/menu row for the same option data keeps that string inside a max-w-80 tooltip (PullRequestListFilters.tsx:179-184), so the two treatments of one string also diverge. Capping the width lets the reason wrap inside the item instead of widening the menu.

suggestion
1 <span className="ms-auto max-w-56 text-xs text-muted-foreground">

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Removed from this PR when the shared filter control moved down to #7147 with its first consumer.

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · Earlier revision c4b280b2 · right 1642–1776

Original diff
@@ -1650,147 +1630,115 @@ export function PullRequestDetailPanel({
                 </div>
               </div>
             ) : null}
+          </div>
+        </div>
 
-            {detail && conflicting ? (
-              <div className="col-span-2 flex items-center gap-1 px-4 pb-3">
-                <Badge
-                  variant="error"
-                  className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive"
+        {detail ? (
+          <nav
+            className="col-span-2 flex min-w-0 items-center gap-1 overflow-x-auto border-t border-border/60 px-4 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
+            aria-label="Pull request tabs"
+          >
+            <ToggleGroup
+              size="segmented"

apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx · right 1642–1776

M
macroscopeappBotAug 16, 2026

pullRequest/PullRequestDetailPanel.tsx:1642

This repeats the segmented track treatment (rounded-lg bg-muted/45 p-0.5) that UsagePage already sets on each of its three ToggleGroups, so with this call site the same durable geometry is spelled out in four places. ToggleGroup already special-cases variant === "segmented" (for the gap) but owns only the items, not the surface they sit in — a call site that forgets the string gets a visually different segmented control.

Consider moving the track classes into the segmented branch of ToggleGroup and leaving only contextual layout here.

No longer relevant as of 2dd5525de4577b23ff331860af354f54ead0a154

apps/web/src/components/HeaderFilterMenu.tsx · Earlier revision 0437edb6 · right 47

Original diff
@@ -37,8 +40,16 @@ export function HeaderFilterMenu<Value extends string>({
       <MenuPopup align={align} side="bottom" className={popupClassName ?? "min-w-40"}>
         <MenuRadioGroup value={current.value} onValueChange={(next) => onChange(next as Value)}>
           {options.map((option) => (
-            <MenuRadioItem key={option.value} value={option.value}>
-              {option.label}
+            <MenuRadioItem
+              key={option.value}
+              value={option.value}
+              disabled={option.disabledReason !== undefined}
+              title={option.disabledReason}
M
macroscopeappBotAug 16, 2026

🟡 Medium components/HeaderFilterMenu.tsx:47

Disabled options expose disabledReason only through title, but MenuRadioItem applies pointer-events-none and disabled items cannot receive focus. As a result, users see unavailable options with no way to discover why they are disabled; render the reason in visible/accessibly focusable UI or attach the tooltip to a non-disabled wrapper.

✅ Resolved in 2dd5525de4577b23ff331860af354f54ead0a154

apps/web/src/components/chat/PanelLayoutControls.tsx · Outdated on GitHub · line 63

Original diff
@@ -60,41 +62,39 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
         </Tooltip>
       ) : null}
       <Tooltip>
-        <TooltipTrigger
-          render={
-            <Toggle
-              className="shrink-0 [-webkit-app-region:no-drag]"
-              pressed={rightPanelOpen}
-              onPressedChange={onToggleRightPanel}
-              aria-label={
-                liveAgentCount > 0
-                  ? `Toggle right panel, ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
-                  : "Toggle right panel"
-              }
-              variant="ghost"
-              size="sm"
-              disabled={!rightPanelAvailable}
-            >
-              <PanelRightIcon className="size-4" />
-              {liveAgentCount > 0 ? (
-                <span
-                  aria-hidden
-                  className="absolute -top-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-[9px] font-semibold tabular-nums text-white"
-                >
-                  {liveAgentCount}
-                </span>
-              ) : null}
-            </Toggle>
-          }
-        />
+        <TooltipTrigger render={<span className="flex shrink-0" />}>
M
macroscopeappBotAug 17, 2026

chat/PanelLayoutControls.tsx:65

This wrapper is the right fix for a tooltip on a disabled control, but it is now the only one: the terminal control just above (lines 41–62) still renders its trigger as the Toggle, so while terminalAvailable is false, toggleVariants' base disabled:pointer-events-none plus the native disabled attribute keep hover and focus from ever opening "Terminal drawer is unavailable" — exactly the case that branch exists for. Two sibling controls in one component with the same need and two different compositions.

Consider hanging the terminal trigger on the same wrapper so both unavailable states explain themselves identically:

diff
1 <Tooltip>
2- <TooltipTrigger
3- render={
4- <Toggle
5- className="shrink-0 [-webkit-app-region:no-drag]"
6- pressed={terminalOpen}
7- onPressedChange={onToggleTerminal}
8- aria-label="Toggle terminal drawer"
9- variant="ghost"
10- size="sm"
11- disabled={!terminalAvailable}
12- >
13- <PanelBottomIcon className="size-4" />
14- </Toggle>
15- }
16- />
17+ <TooltipTrigger render={<span className="flex shrink-0" />}>
18+ <Toggle
19+ className="shrink-0 [-webkit-app-region:no-drag]"
20+ pressed={terminalOpen}
21+ onPressedChange={onToggleTerminal}
22+ aria-label="Toggle terminal drawer"
23+ variant="ghost"
24+ size="sm"
25+ disabled={!terminalAvailable}
26+ >
27+ <PanelBottomIcon className="size-4" />
28+ </Toggle>
29+ </TooltipTrigger>
30 <TooltipPopup side="bottom">

The new test could then cover both triggers with the same assertion.

No longer relevant as of a7586658ecd31f5b053350cdbcfd8d2d2f2ffcf5

apps/web/src/components/chat/PanelLayoutControls.tsx · Outdated on GitHub · line 95

Original diff
@@ -94,7 +96,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
                   ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
                   : ""
               }`
-            : "Right panel is unavailable"}
+            : rightPanelUnavailableLabel}
M
macroscopeappBotAug 17, 2026

chat/PanelLayoutControls.tsx:99

This label can't be reached, so the route's "Select a pull request first" never renders: the tooltip trigger is the Toggle, and while it is disabled toggleVariants' base disabled:pointer-events-none (plus the native disabled attribute Base UI puts on it) keeps hover and focus from ever opening the popup. The branch only runs when rightPanelAvailable is false, i.e. exactly when the control is disabled.

The established pattern here for explaining a disabled control is to hang the trigger on a wrapper element rather than on the disabled control — see GitActionsControl.tsx:1768-1785, where a disabled MenuItem sits inside a <span> trigger, and the menu rows that use data-disabled:pointer-events-auto. Rendering TooltipTrigger as a wrapping <span className="flex shrink-0" /> around the Toggle (leaving the Toggle disabled) keeps the disabled semantics and makes the reason visible.

No longer relevant as of 7d39c806200f28ca98850d29da23777149fed7cf