authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-19 17:49:55-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 17:49:55-04:00
loga850895f6833b99d90fc6c50192b5eaa4966d5c7
tree38c556bc3452f70e441a623da0030df3058b03c3
parent0508792c572abd39b1afd6f68cd420f2ca00a484
signature Signed by PGP key B5690EEEBB952194

feat(web): refresh pull request details (#7148)


12 files changed, 734 insertions(+), 564 deletions(-)

apps/web/src/components/ChatView.tsx-1
......@@ -6244,7 +6244,6 @@ function ChatViewContent(props: ChatViewProps) {
62446244 ? "thread"
62456245 : "page"
62466246 }
6247 chromeVariant="collapse"
62486247 composerDraftTarget={composerDraftTarget}
62496248 onStateChange={handlePullRequestTabStatusChange}
62506249 />
apps/web/src/components/chat/PanelLayoutControls.test.tsx created+28
......@@ -0,0 +1,28 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { PanelLayoutControls } from "./PanelLayoutControls";
5
6describe("PanelLayoutControls", () => {
7 it("keeps unavailable panel tooltip triggers interactive", () => {
8 const markup = renderToStaticMarkup(
9 <PanelLayoutControls
10 showTerminalControl
11 terminalAvailable={false}
12 terminalOpen={false}
13 terminalShortcutLabel={null}
14 rightPanelAvailable={false}
15 rightPanelOpen={false}
16 rightPanelShortcutLabel={null}
17 liveAgentCount={0}
18 onToggleTerminal={() => {}}
19 onToggleRightPanel={() => {}}
20 />,
21 );
22
23 expect(markup.match(/data-slot="tooltip-trigger"/g)).toHaveLength(2);
24 expect(markup.match(/data-slot="tooltip-trigger"[^>]*><button[^>]*disabled=""/g)).toHaveLength(
25 2,
26 );
27 });
28});
apps/web/src/components/chat/PanelLayoutControls.tsx+41-43
......@@ -12,6 +12,7 @@ interface PanelLayoutControlsProps {
1212 rightPanelAvailable: boolean;
1313 rightPanelOpen: boolean;
1414 rightPanelShortcutLabel: string | null;
15 rightPanelUnavailableLabel?: string;
1516 /** Running + waiting subagents in this thread; badges the right panel toggle. */
1617 liveAgentCount: number;
1718 onToggleTerminal: () => void;
......@@ -26,6 +27,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
2627 rightPanelAvailable,
2728 rightPanelOpen,
2829 rightPanelShortcutLabel,
30 rightPanelUnavailableLabel = "Right panel is unavailable",
2931 liveAgentCount,
3032 onToggleTerminal,
3133 onToggleRightPanel,
......@@ -37,21 +39,19 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
3739 >
3840 {showTerminalControl ? (
3941 <Tooltip>
40 <TooltipTrigger
41 render={
42 <Toggle
43 className="shrink-0 [-webkit-app-region:no-drag]"
44 pressed={terminalOpen}
45 onPressedChange={onToggleTerminal}
46 aria-label="Toggle terminal drawer"
47 variant="ghost"
48 size="sm"
49 disabled={!terminalAvailable}
50 >
51 <PanelBottomIcon className="size-4" />
52 </Toggle>
53 }
54 />
42 <TooltipTrigger render={<span className="flex shrink-0" />}>
43 <Toggle
44 className="shrink-0 [-webkit-app-region:no-drag]"
45 pressed={terminalOpen}
46 onPressedChange={onToggleTerminal}
47 aria-label="Toggle terminal drawer"
48 variant="ghost"
49 size="sm"
50 disabled={!terminalAvailable}
51 >
52 <PanelBottomIcon className="size-4" />
53 </Toggle>
54 </TooltipTrigger>
5555 <TooltipPopup side="bottom">
5656 {terminalAvailable
5757 ? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}`
......@@ -60,33 +60,31 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
6060 </Tooltip>
6161 ) : null}
6262 <Tooltip>
63 <TooltipTrigger
64 render={
65 <Toggle
66 className="shrink-0 [-webkit-app-region:no-drag]"
67 pressed={rightPanelOpen}
68 onPressedChange={onToggleRightPanel}
69 aria-label={
70 liveAgentCount > 0
71 ? `Toggle right panel, ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
72 : "Toggle right panel"
73 }
74 variant="ghost"
75 size="sm"
76 disabled={!rightPanelAvailable}
77 >
78 <PanelRightIcon className="size-4" />
79 {liveAgentCount > 0 ? (
80 <span
81 aria-hidden
82 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"
83 >
84 {liveAgentCount}
85 </span>
86 ) : null}
87 </Toggle>
88 }
89 />
63 <TooltipTrigger render={<span className="flex shrink-0" />}>
64 <Toggle
65 className="shrink-0 [-webkit-app-region:no-drag]"
66 pressed={rightPanelOpen}
67 onPressedChange={onToggleRightPanel}
68 aria-label={
69 liveAgentCount > 0
70 ? `Toggle right panel, ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
71 : "Toggle right panel"
72 }
73 variant="ghost"
74 size="sm"
75 disabled={!rightPanelAvailable}
76 >
77 <PanelRightIcon className="size-4" />
78 {liveAgentCount > 0 ? (
79 <span
80 aria-hidden
81 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"
82 >
83 {liveAgentCount}
84 </span>
85 ) : null}
86 </Toggle>
87 </TooltipTrigger>
9088 <TooltipPopup side="bottom">
9189 {rightPanelAvailable
9290 ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}${
......@@ -94,7 +92,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
9492 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
9593 : ""
9694 }`
97 : "Right panel is unavailable"}
95 : rightPanelUnavailableLabel}
9896 </TooltipPopup>
9997 </Tooltip>
10098 </div>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+410-431
......@@ -25,6 +25,7 @@ import {
2525 GitPullRequestDraftIcon,
2626 GitPullRequestIcon,
2727 HammerIcon,
28 LayersIcon,
2829 MessageCircleQuestionIcon,
2930 MessageSquareIcon,
3031 LinkIcon,
......@@ -50,6 +51,7 @@ import {
5051import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
5152import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
5253import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
54import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink";
5355import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
5456import { cn } from "~/lib/utils";
5557import { readLocalApi } from "~/localApi";
......@@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query";
6062import { useLiveRefresh } from "~/hooks/useLiveRefresh";
6163import { pullRequestEnvironment } from "~/state/pullRequests";
6264import { useAtomCommand } from "~/state/use-atom-command";
65import { vcsEnvironment } from "~/state/vcs";
6366import { formatRelativeTimeLabel } from "~/timestampFormat";
6467
6568import {
......@@ -74,6 +77,7 @@ import {
7477import { Badge } from "../ui/badge";
7578import { Button } from "../ui/button";
7679import { Input } from "../ui/input";
80import { Toggle, ToggleGroup } from "../ui/toggle-group";
7781import {
7882 Menu,
7983 MenuItem,
......@@ -104,6 +108,7 @@ import {
104108 handoffPrompt,
105109 handoffReviewComments,
106110 latestPullRequestReviewOutcomes,
111 isStackedPullRequestBase,
107112 pullRequestActionMenuHasGroup,
108113 pullRequestActionNeedsHostRefresh,
109114 pullRequestComposerTarget,
......@@ -121,6 +126,7 @@ import {
121126} from "./pullRequestProjectAssignment.logic";
122127import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
123128import {
129 PullRequestActorAvatar,
124130 PullRequestActorLabel,
125131 PullRequestDiffStat,
126132 PullRequestMetaLine,
......@@ -362,7 +368,6 @@ export function PullRequestDetailPanel({
362368 onClose,
363369 onStateChange,
364370 context = "page",
365 chromeVariant = "full",
366371 composerDraftTarget,
367372}: {
368373 environmentId: EnvironmentId;
......@@ -394,12 +399,6 @@ export function PullRequestDetailPanel({
394399 * again is at best a no-op and at worst git refusing a branch two checkouts.
395400 */
396401 context?: "page" | "thread";
397 /**
398 * How the metadata above the content behaves: `full` keeps every row pinned; `collapse`
399 * folds the whole of it into the top row once the active tab scrolls, and unfolds at the
400 * top — the chrome spends its height on what is being read.
401 */
402 chromeVariant?: "full" | "collapse";
403402 /**
404403 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs
405404 * land here instead of opening a new thread — the branch is already under the reader's feet.
......@@ -436,27 +435,16 @@ export function PullRequestDetailPanel({
436435 );
437436 }, [tab]);
438437 const [chromeCondensed, setChromeCondensed] = useState(false);
439 // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll
440 // events, so the capture handler always writes the active tab's entry — and a tab switch
441 // reads the destination's memory instead of inheriting the tab being left. A tab too short
442 // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome
443 // it has no scrollbar to reopen.
438 // Each mounted tab remembers its own scroll chrome; short tabs cannot scroll to reopen it.
444439 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
445440 useEffect(() => {
446441 setChromeCondensed(chromeStateByTab.current[tab] ?? false);
447442 }, [tab]);
448 const condensed = chromeVariant === "collapse" && chromeCondensed;
449 // Collapsing removes the fold's height from the chrome, which would otherwise hand that
450 // height to the scrollport and leap the content up by it mid-scroll. The cure is exact
451 // compensation: collapse only once the reader has scrolled at least the fold's height,
452 // then give that height back to `scrollTop` before the next paint — the content under
453 // their eyes does not move, and the collapse itself is the only thing that changes.
443 const condensed = chromeCondensed;
454444 const scrollerRef = useRef<HTMLElement | null>(null);
455445 const foldRef = useRef<HTMLDivElement | null>(null);
456 // The condensed chrome's second row opens as the fold closes, so the height the scrollport
457 // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight`
458 // through a zero track reads its natural height in either state.
459446 const condensedRowRef = useRef<HTMLDivElement | null>(null);
447 // Refund after the fold commits so the content under the reader does not jump with its height.
460448 const compensationRef = useRef<number | null>(null);
461449 useLayoutEffect(() => {
462450 if (compensationRef.current === null) return;
......@@ -478,7 +466,6 @@ export function PullRequestDetailPanel({
478466 target: "branch name",
479467 timeout: 1600,
480468 });
481
482469 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
483470 // clicked, so a reader who does click it lands on a chunk already in the module cache.
484471 useEffect(() => {
......@@ -517,6 +504,23 @@ export function PullRequestDetailPanel({
517504 },
518505 [activity, coreDetail],
519506 );
507 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);
508 const branchRefsQuery = useEnvironmentQuery(
509 detail === null
510 ? null
511 : vcsEnvironment.listRefs({
512 environmentId,
513 input: {
514 cwd: detail.workspaceRoot,
515 includeMatchingRemoteRefs: true,
516 // listRefs keeps the current ref first and a known default second.
517 limit: 2,
518 },
519 }),
520 );
521 const isStackedPullRequest =
522 detail !== null &&
523 isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []);
520524 const activityPending = activityQuery.isPending && activity === null;
521525 const activityError = activity === null ? activityQuery.error : null;
522526 const refreshDetail = useCallback(() => {
......@@ -1046,17 +1050,17 @@ export function PullRequestDetailPanel({
10461050 const can = (action: PullRequestAction) =>
10471051 detail?.capabilities.actions.includes(action) === true &&
10481052 detail.viewerPermissions.actions.includes(action);
1049 // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes
1050 // to the thing that would help instead of a Merge button that only ever says no.
1053 // One live action holds the slot. Conflicts take priority because every other completion action
1054 // depends on resolving them first, even for a reader who cannot merge on the host themselves.
10511055 const primaryAction =
10521056 detail === null || detail.state !== "open"
10531057 ? null
1054 : detail.isDraft && can("ready")
1055 ? "ready"
1056 : !can("merge")
1057 ? null
1058 : conflicting
1059 ? "resolve"
1058 : conflicting
1059 ? "resolve"
1060 : detail.isDraft && can("ready")
1061 ? "ready"
1062 : !can("merge")
1063 ? null
10601064 : allowedMergeMethods.length > 0
10611065 ? "merge"
10621066 : null;
......@@ -1081,7 +1085,7 @@ export function PullRequestDetailPanel({
10811085 !conflicting &&
10821086 allowedMergeMethods.length > 1;
10831087 // The pull request number carries this state in the overview and the right-panel tab mirrors
1084 // it. Conflicts keep their own row below: an open pull request remains green there.
1088 // it. The conflict action is separate from this state: an open pull request remains green.
10851089 const statePresentation = detail
10861090 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
10871091 : null;
......@@ -1101,34 +1105,47 @@ export function PullRequestDetailPanel({
11011105 ).length
11021106 : 0;
11031107
1108 if (detailQuery.isPending && !detail) {
1109 return <PullRequestDetailGhost />;
1110 }
1111
11041112 return (
11051113 <div className="flex h-full min-h-0 w-full flex-col bg-background">
1106 {/* The top row's geometry never changes: both of its states occupy the same stacked
1107 cell and crossfade, so the actions on the right have one home whatever the chrome
1108 is doing below. The fold and this fade share one 200ms clock. */}
11091114 <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">
1110 {/* The fixed height lives on the two top-row cells — not the grid, whose later rows
1111 are the fold — so the actions have one immovable home in both states. */}
1112 <div className="ml-4 grid h-11 min-w-0 items-center">
1115 <div className="ml-4 grid h-7 min-w-0 items-center">
11131116 <div
11141117 aria-hidden={condensed}
11151118 inert={condensed}
11161119 className={cn(
1117 "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",
1118 // Sequenced, not simultaneous: the leaving layer clears quickly before the
1119 // arriving one lands, so no frame shows both texts superimposed at half opacity.
1120 "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 sm:text-xs",
11201121 condensed
1121 ? "pointer-events-none opacity-0 duration-100"
1122 : "opacity-100 delay-75 duration-150",
1122 ? "pointer-events-none -translate-y-1 opacity-0 duration-100"
1123 : "translate-y-0 opacity-100 delay-50 duration-150",
11231124 )}
11241125 >
11251126 {detail && statePresentation ? (
11261127 <>
11271128 <Tooltip>
11281129 <TooltipTrigger
1129 render={<span className="min-w-0 truncate">{detail.repository}</span>}
1130 render={
1131 repositoryUrl ? (
1132 <button
1133 type="button"
1134 onClick={() => void readLocalApi()?.shell.openExternal(repositoryUrl)}
1135 className="min-w-0 cursor-pointer truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
1136 >
1137 {detail.repository}
1138 </button>
1139 ) : (
1140 <span className="min-w-0 truncate font-medium text-muted-foreground">
1141 {detail.repository}
1142 </span>
1143 )
1144 }
11301145 />
1131 <TooltipPopup side="top">{detail.repository}</TooltipPopup>
1146 <TooltipPopup side="top">
1147 {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository}
1148 </TooltipPopup>
11321149 </Tooltip>
11331150 <Tooltip>
11341151 <TooltipTrigger
......@@ -1156,10 +1173,10 @@ export function PullRequestDetailPanel({
11561173 aria-hidden={!condensed}
11571174 inert={!condensed}
11581175 className={cn(
1159 "col-start-1 row-start-1 flex min-w-0 items-center gap-1.5 text-sm transition-opacity sm:text-xs motion-reduce:transition-none",
1176 "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 sm:text-xs",
11601177 condensed
1161 ? "opacity-100 delay-75 duration-150"
1162 : "pointer-events-none opacity-0 duration-100",
1178 ? "translate-y-0 opacity-100 delay-50 duration-150"
1179 : "pointer-events-none translate-y-1 opacity-0 duration-100",
11631180 )}
11641181 >
11651182 {detail && statePresentation ? (
......@@ -1194,29 +1211,103 @@ export function PullRequestDetailPanel({
11941211 />
11951212 <TooltipPopup side="top">{detail.title}</TooltipPopup>
11961213 </Tooltip>
1197 {conflicting ? (
1198 <Badge
1199 variant="error"
1200 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px] text-destructive"
1201 >
1202 <TriangleAlertIcon className="size-3" />
1203 Conflicts
1204 </Badge>
1205 ) : checksSummary ? (
1206 <span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
1207 {detail && checksState !== null ? (
1208 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1209 ) : null}
1210 {checksSummary}
1211 </span>
1212 ) : null}
12131214 </>
12141215 ) : null}
12151216 </div>
12161217 </div>
1217 <div className="mr-4 flex h-11 min-w-0 flex-nowrap items-center justify-end gap-1">
1218 <div className="mr-4 flex h-7 min-w-0 flex-nowrap items-center justify-end gap-1">
12181219 {detail ? (
12191220 <>
1221 {/* Checking a pull request out is the reason to open one here at all, so it is a
1222 button of its own rather than a side effect of asking an agent for something.
1223 It asks where, because the two answers are not interchangeable: one leaves your
1224 work where it is, the other moves the repository you are standing in. Only on
1225 the page: beside a thread the branch is already checked out right there. */}
1226 {context === "page" ? (
1227 <Menu>
1228 <MenuTrigger
1229 disabled={handoff !== null}
1230 render={
1231 <Button size="xs" variant="outline">
1232 <GitBranchIcon aria-hidden className="size-3.5" />
1233 {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1234 <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" />
1235 </Button>
1236 }
1237 />
1238 <MenuPopup align="end" side="bottom" className="min-w-72">
1239 <MenuItem onClick={() => startCheckout("worktree")}>
1240 <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
1241 <span className="flex min-w-0 flex-col">
1242 <span>In a separate worktree</span>
1243 <span className="text-xs text-muted-foreground">
1244 Its own folder and thread. Nothing you have open moves.
1245 </span>
1246 </span>
1247 </MenuItem>
1248 <MenuItem onClick={() => startCheckout("local")}>
1249 <FolderGit2Icon className="mt-0.5 size-3.5 shrink-0 self-start" />
1250 <span className="flex min-w-0 flex-col">
1251 <span>In this repository</span>
1252 <span className="text-xs text-muted-foreground">
1253 Switches the branch you are working in, like `gh pr checkout`.
1254 </span>
1255 </span>
1256 </MenuItem>
1257 {pickableEnvironments.length > 0 ? (
1258 <ActOnEnvironmentPicker
1259 environments={pickableEnvironments}
1260 value={actingEnvironmentId}
1261 onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
1262 disabled={handoff !== null}
1263 />
1264 ) : null}
1265 </MenuPopup>
1266 </Menu>
1267 ) : null}
1268 {/* Said where the Merge button is, because it is the answer to why nobody has
1269 pressed it: the merge is already asked for, and the host is holding it. */}
1270 {autoMergeArmed ? (
1271 <Tooltip>
1272 <TooltipTrigger
1273 render={
1274 <Badge
1275 variant="info"
1276 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
1277 >
1278 <GitMergeIcon aria-hidden className="size-3" />
1279 Auto-merge
1280 </Badge>
1281 }
1282 />
1283 <TooltipPopup side="top">
1284 The host will merge this on its own once its requirements are met
1285 </TooltipPopup>
1286 </Tooltip>
1287 ) : null}
1288 {primaryAction === "resolve" ? (
1289 <Button
1290 size="xs"
1291 variant="destructive-outline"
1292 disabled={handoff !== null}
1293 onClick={startResolveConflicts}
1294 >
1295 <TriangleAlertIcon className="size-3.5" />
1296 {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}
1297 </Button>
1298 ) : primaryAction === "ready" ? (
1299 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
1300 Ready for review
1301 </Button>
1302 ) : primaryAction === "merge" ? (
1303 <Button
1304 size="xs"
1305 disabled={actionPending}
1306 onClick={() => setConfirmation({ open: true, action: "merge" })}
1307 >
1308 {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}
1309 </Button>
1310 ) : null}
12201311 <Menu>
12211312 <MenuTrigger
12221313 render={
......@@ -1361,13 +1452,6 @@ export function PullRequestDetailPanel({
13611452 <LinkIcon className="size-3.5" />
13621453 Copy link
13631454 </MenuItem>
1364 {/* Only where the button row could not take it, so it is never offered twice. */}
1365 {conflicting && primaryAction !== "resolve" ? (
1366 <MenuItem disabled={handoff !== null} onClick={startResolveConflicts}>
1367 <GitMergeIcon className="size-3.5" />
1368 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolveConflicts}
1369 </MenuItem>
1370 ) : null}
13711455 {detail.state === "open" && can("close") ? (
13721456 <>
13731457 <MenuSeparator />
......@@ -1391,92 +1475,6 @@ export function PullRequestDetailPanel({
13911475 ) : null}
13921476 </MenuPopup>
13931477 </Menu>
1394 {/* Checking a pull request out is the reason to open one here at all, so it is a
1395 button of its own rather than a side effect of asking an agent for something.
1396 It asks where, because the two answers are not interchangeable: one leaves your
1397 work where it is, the other moves the repository you are standing in. Only on
1398 the page: beside a thread the branch is already checked out right there. */}
1399 {context === "page" ? (
1400 <Menu>
1401 <MenuTrigger
1402 disabled={handoff !== null}
1403 render={
1404 <Button size="xs" variant="outline">
1405 {handoff?.startsWith("checkout") ? (
1406 "Checking out..."
1407 ) : (
1408 <>
1409 <GitBranchIcon className="size-3" />
1410 Check out
1411 <ChevronDownIcon className="size-3 text-muted-foreground" />
1412 </>
1413 )}
1414 </Button>
1415 }
1416 />
1417 <MenuPopup align="end" side="bottom" className="min-w-72">
1418 <MenuItem onClick={() => startCheckout("worktree")}>
1419 <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
1420 <span className="flex min-w-0 flex-col">
1421 <span>In a separate worktree</span>
1422 <span className="text-xs text-muted-foreground">
1423 Its own folder and thread. Nothing you have open moves.
1424 </span>
1425 </span>
1426 </MenuItem>
1427 <MenuItem onClick={() => startCheckout("local")}>
1428 <FolderGit2Icon className="mt-0.5 size-3.5 shrink-0 self-start" />
1429 <span className="flex min-w-0 flex-col">
1430 <span>In this repository</span>
1431 <span className="text-xs text-muted-foreground">
1432 Switches the branch you are working in, like `gh pr checkout`.
1433 </span>
1434 </span>
1435 </MenuItem>
1436 {pickableEnvironments.length > 0 ? (
1437 <ActOnEnvironmentPicker
1438 environments={pickableEnvironments}
1439 value={actingEnvironmentId}
1440 onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
1441 disabled={handoff !== null}
1442 />
1443 ) : null}
1444 </MenuPopup>
1445 </Menu>
1446 ) : null}
1447 {/* Said where the Merge button is, because it is the answer to why nobody has
1448 pressed it: the merge is already asked for, and the host is holding it. */}
1449 {autoMergeArmed ? (
1450 <Tooltip>
1451 <TooltipTrigger
1452 render={
1453 <Badge
1454 variant="info"
1455 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
1456 >
1457 <GitMergeIcon aria-hidden className="size-3" />
1458 Auto-merge
1459 </Badge>
1460 }
1461 />
1462 <TooltipPopup side="top">
1463 The host will merge this on its own once its requirements are met
1464 </TooltipPopup>
1465 </Tooltip>
1466 ) : null}
1467 {primaryAction === "ready" ? (
1468 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
1469 Ready for review
1470 </Button>
1471 ) : primaryAction === "merge" ? (
1472 <Button
1473 size="xs"
1474 disabled={actionPending}
1475 onClick={() => setConfirmation({ open: true, action: "merge" })}
1476 >
1477 {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel}
1478 </Button>
1479 ) : null}
14801478 </>
14811479 ) : null}
14821480 {onClose ? (
......@@ -1491,14 +1489,9 @@ export function PullRequestDetailPanel({
14911489 ) : null}
14921490 </div>
14931491
1494 {/* The condensed chrome's second row: the tabs that the closing fold takes with it,
1495 and compact copies of the branch pair and diff stat so they stay in sight while
1496 the full rows are folded away. Same zero-track mechanism as the fold, inverted. */}
14971492 <div
14981493 className={cn(
14991494 "col-span-2 grid",
1500 // Inverse of the fold's track: closing eases while the fold above eases open,
1501 // so the chrome trades rows in one motion instead of two jumps.
15021495 condensed
15031496 ? "grid-rows-[1fr]"
15041497 : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
......@@ -1507,84 +1500,103 @@ export function PullRequestDetailPanel({
15071500 <div
15081501 ref={condensedRowRef}
15091502 className={cn(
1510 "min-h-0 overflow-hidden",
1503 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
15111504 condensed
1512 ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"
1513 : "opacity-0",
1505 ? "translate-y-0 opacity-100 delay-50"
1506 : "translate-y-1 opacity-0 duration-100",
15141507 )}
15151508 inert={!condensed}
15161509 >
15171510 {detail ? (
1518 <div className="flex min-w-0 items-center gap-1 px-4 pb-2">
1519 <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5">
1520 {visibleTabs.map((item) => (
1521 <button
1522 key={item.value}
1523 type="button"
1524 tabIndex={condensed ? 0 : -1}
1525 aria-pressed={tab === item.value}
1526 onClick={() => setTab(item.value)}
1527 className={cn(
1528 "rounded-md px-2 py-1 text-[11px] transition-colors",
1529 tab === item.value
1530 ? "bg-accent text-foreground"
1531 : "text-muted-foreground hover:text-foreground",
1532 )}
1511 <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">
1512 <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1513 <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
1514 <Tooltip>
1515 <TooltipTrigger
1516 render={
1517 <span
1518 className="shrink-0 rounded-full"
1519 aria-label={detail.author?.login ?? "ghost"}
1520 />
1521 }
1522 >
1523 <PullRequestActorAvatar actor={detail.author} />
1524 </TooltipTrigger>
1525 <TooltipPopup side="top">{detail.author?.login ?? "ghost"}</TooltipPopup>
1526 </Tooltip>
1527 <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>
1528 </span>
1529 <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" />
1530 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-[11px] text-muted-foreground/65">
1531 <Tooltip>
1532 <TooltipTrigger
1533 render={
1534 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
1535 {isStackedPullRequest ? (
1536 <LayersIcon
1537 aria-label="Stacked pull request"
1538 className="size-3 shrink-0"
1539 />
1540 ) : null}
1541 <code className="min-w-0 truncate">{detail.baseBranch}</code>
1542 </span>
1543 }
1544 />
1545 <TooltipPopup side="top">
1546 {isStackedPullRequest
1547 ? `Stacked on ${detail.baseBranch}`
1548 : detail.baseBranch}
1549 </TooltipPopup>
1550 </Tooltip>
1551 {freshness ? (
1552 <PullRequestBaseFreshnessWarning
1553 baseBranch={detail.baseBranch}
1554 freshness={freshness}
1555 pending={actionPending}
1556 onUpdate={(method) => void perform("update-branch", undefined, method)}
1557 iconClassName="size-3"
1558 />
1559 ) : null}
1560 <ArrowLeftIcon
1561 aria-label="receives changes from"
1562 className="size-3 shrink-0 opacity-60"
1563 />
1564 <Tooltip>
1565 <TooltipTrigger
1566 render={
1567 <code className="min-w-0 flex-1 truncate">{detail.headBranch}</code>
1568 }
1569 />
1570 <TooltipPopup side="top">{detail.headBranch}</TooltipPopup>
1571 </Tooltip>
1572 </span>
1573 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2 text-[11px]">
1574 <span
1575 className="inline-flex items-center gap-1 tabular-nums"
1576 aria-label={`${detail.changedFiles.toLocaleString()} changed ${
1577 detail.changedFiles === 1 ? "file" : "files"
1578 }`}
15331579 >
1534 {item.label}
1535 </button>
1536 ))}
1537 </nav>
1538 <span className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground">
1539 <Tooltip>
1540 <TooltipTrigger render={<span className="truncate" />}>
1541 {detail.baseBranch}
1542 </TooltipTrigger>
1543 <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup>
1544 </Tooltip>
1545 {freshness ? (
1546 <PullRequestBaseFreshnessWarning
1547 baseBranch={detail.baseBranch}
1548 freshness={freshness}
1549 pending={actionPending}
1550 onUpdate={(method) => void perform("update-branch", undefined, method)}
1551 iconClassName="size-3"
1580 <FileDiffIcon aria-hidden className="size-3" />
1581 {detail.changedFiles.toLocaleString()}
1582 </span>
1583 <PullRequestDiffStat
1584 additions={detail.additions}
1585 deletions={detail.deletions}
1586 className="shrink-0 font-mono text-[11px]"
15521587 />
1553 ) : null}
1554 <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" />
1555 <Tooltip>
1556 <TooltipTrigger render={<span className="truncate" />}>
1557 {detail.headBranch}
1558 </TooltipTrigger>
1559 <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup>
1560 </Tooltip>
1561 </span>
1562 <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
1563 <span className="inline-flex items-center gap-1 tabular-nums">
1564 <FileDiffIcon className="size-3" />
1565 {detail.changedFiles.toLocaleString()}
15661588 </span>
1567 <PullRequestDiffStat
1568 additions={detail.additions}
1569 deletions={detail.deletions}
1570 className="shrink-0 font-mono text-[11px]"
1571 />
1572 </span>
1589 </div>
15731590 </div>
15741591 ) : null}
15751592 </div>
15761593 </div>
15771594
1578 {/* Folding is a grid track going to zero: the rows below stay mounted, the track
1579 animates closed over them, and `inert` takes the hidden controls out of the tab
1580 order for as long as the chrome is condensed. */}
15811595 <div
15821596 className={cn(
15831597 "col-span-2 grid",
1584 // Collapse is instant: it happens mid-scroll, and the compensation that keeps the
1585 // content pinned would fight an animated track frame by frame. Reopening happens
1586 // at the top with no compensation, so the fold can ease back in instead of
1587 // snapping the content down.
1598 // Collapse before the scroll refund paints; only reopening eases back in. Animating
1599 // both directions makes the shrinking track fight the scrollTop correction.
15881600 condensed
15891601 ? "grid-rows-[0fr]"
15901602 : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
......@@ -1592,19 +1604,16 @@ export function PullRequestDetailPanel({
15921604 >
15931605 <div
15941606 ref={foldRef}
1595 // One-way on purpose: appearing content eases in over ground the instant track
1596 // already reserved; departing content cuts, because its ground is gone in the
1597 // same frame and the scroll compensation reads it as scrolled past.
15981607 className={cn(
1599 "min-h-0 overflow-hidden",
1608 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
16001609 condensed
1601 ? "opacity-0"
1602 : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none",
1610 ? "-translate-y-1 opacity-0 duration-100"
1611 : "translate-y-0 opacity-100 delay-50",
16031612 )}
16041613 inert={condensed}
16051614 >
16061615 {detail ? (
1607 <div className="col-span-2 mt-3 min-w-0 px-4 pb-4">
1616 <div className="col-span-2 mt-1 min-w-0 px-4 pb-4">
16081617 {titleDraft === null ? (
16091618 <div className="group flex min-w-0 items-start gap-1">
16101619 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">
......@@ -1671,60 +1680,75 @@ export function PullRequestDetailPanel({
16711680 </PullRequestMetaLine>
16721681
16731682 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1674 <Tooltip>
1675 <TooltipTrigger
1676 render={
1677 <code className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground">
1678 {detail.baseBranch}
1679 </code>
1680 }
1681 />
1682 <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup>
1683 </Tooltip>
1684 {freshness ? (
1685 <PullRequestBaseFreshnessWarning
1686 baseBranch={detail.baseBranch}
1687 freshness={freshness}
1688 pending={actionPending}
1689 onUpdate={(method) => void perform("update-branch", undefined, method)}
1683 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
1684 <Tooltip>
1685 <TooltipTrigger
1686 render={
1687 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
1688 {isStackedPullRequest ? (
1689 <LayersIcon
1690 aria-label="Stacked pull request"
1691 className="size-3 shrink-0"
1692 />
1693 ) : null}
1694 <code className="min-w-0 truncate">{detail.baseBranch}</code>
1695 </span>
1696 }
1697 />
1698 <TooltipPopup side="top">
1699 {isStackedPullRequest
1700 ? `Stacked on ${detail.baseBranch}`
1701 : detail.baseBranch}
1702 </TooltipPopup>
1703 </Tooltip>
1704 {freshness ? (
1705 <PullRequestBaseFreshnessWarning
1706 baseBranch={detail.baseBranch}
1707 freshness={freshness}
1708 pending={actionPending}
1709 onUpdate={(method) => void perform("update-branch", undefined, method)}
1710 />
1711 ) : null}
1712 <ArrowLeftIcon
1713 aria-label="receives changes from"
1714 className="size-3.5 shrink-0 opacity-60"
16901715 />
1691 ) : null}
1692 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />
1693 <Tooltip>
1694 <TooltipTrigger
1695 render={
1696 <button
1697 type="button"
1698 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"
1699 aria-label={
1700 isBranchCopied ? "Branch name copied" : "Copy pull request branch"
1701 }
1702 onClick={() => copyBranchToClipboard(detail.headBranch)}
1703 />
1704 }
1705 >
1706 <code
1707 className={cn(
1708 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
1709 isBranchCopied ? "opacity-0" : "opacity-100",
1710 )}
1711 >
1712 {detail.headBranch}
1713 </code>
1714 <span
1715 aria-hidden="true"
1716 className={cn(
1717 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
1718 isBranchCopied ? "opacity-100" : "opacity-0",
1719 )}
1716 <Tooltip>
1717 <TooltipTrigger
1718 render={
1719 <button
1720 type="button"
1721 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"
1722 aria-label={
1723 isBranchCopied ? "Branch name copied" : "Copy pull request branch"
1724 }
1725 onClick={() => copyBranchToClipboard(detail.headBranch)}
1726 />
1727 }
17201728 >
1721 Copied
1722 </span>
1723 </TooltipTrigger>
1724 <TooltipPopup side="top">
1725 {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`}
1726 </TooltipPopup>
1727 </Tooltip>
1729 <code
1730 className={cn(
1731 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
1732 isBranchCopied ? "opacity-0" : "opacity-100",
1733 )}
1734 >
1735 {detail.headBranch}
1736 </code>
1737 <span
1738 aria-hidden="true"
1739 className={cn(
1740 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
1741 isBranchCopied ? "opacity-100" : "opacity-0",
1742 )}
1743 >
1744 Copied
1745 </span>
1746 </TooltipTrigger>
1747 <TooltipPopup side="top">
1748 {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`}
1749 </TooltipPopup>
1750 </Tooltip>
1751 </span>
17281752 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
17291753 <span className="inline-flex items-center gap-1.5 tabular-nums">
17301754 <FileDiffIcon className="size-3.5" />
......@@ -1740,165 +1764,130 @@ export function PullRequestDetailPanel({
17401764 </div>
17411765 </div>
17421766 ) : null}
1767 </div>
1768 </div>
17431769
1744 {detail && conflicting ? (
1745 <div className="col-span-2 flex items-center gap-1 px-4 pb-3">
1746 <Badge
1747 variant="error"
1748 className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive"
1749 >
1750 <TriangleAlertIcon className="size-3.5" />
1751 Merge conflicts
1752 </Badge>
1753 <Button
1754 size="xs"
1755 variant="ghost"
1756 className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive"
1757 disabled={handoff !== null}
1758 onClick={startResolveConflicts}
1759 >
1760 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve}
1761 <ArrowUpRightIcon className="size-3.5 text-destructive" />
1762 </Button>
1763 </div>
1764 ) : null}
1765
1766 {detail ? (
1767 <nav
1768 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"
1769 aria-label="Pull request tabs"
1770 {detail ? (
1771 <nav
1772 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"
1773 aria-label="Pull request tabs"
1774 >
1775 <ToggleGroup
1776 size="segmented"
1777 variant="segmented"
1778 value={[tab]}
1779 onValueChange={(next) => {
1780 const nextTab = visibleTabs.find((item) => item.value === next[0])?.value;
1781 if (nextTab) setTab(nextTab);
1782 }}
1783 >
1784 {visibleTabs.map((item) => (
1785 <Toggle key={item.value} value={item.value}>
1786 {item.label}
1787 </Toggle>
1788 ))}
1789 </ToggleGroup>
1790 {tab === "summary" ? (
1791 <span
1792 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
1793 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
17701794 >
1771 {visibleTabs.map((item) => (
1772 <button
1773 key={item.value}
1774 type="button"
1775 aria-pressed={tab === item.value}
1776 onClick={() => setTab(item.value)}
1777 className={cn(
1778 "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors",
1779 tab === item.value
1780 ? "bg-accent text-foreground"
1781 : "text-muted-foreground hover:text-foreground",
1782 )}
1795 {checksState !== null ? (
1796 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1797 ) : (
1798 <CircleDotIcon aria-hidden className="size-3.5" />
1799 )}
1800 {checksSummary}
1801 </span>
1802 ) : tab === "timeline" ? (
1803 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
1804 <PullRequestMetaLine
1805 className={cn(
1806 "whitespace-nowrap text-[11px] transition-opacity",
1807 (activityPending || activityError) && "opacity-35",
1808 )}
1809 >
1810 <span
1811 className="inline-flex items-center gap-1"
1812 aria-label={
1813 activityError
1814 ? "Comments unavailable"
1815 : `${detail.commentCount.toLocaleString()} ${
1816 detail.commentCount === 1 ? "comment" : "comments"
1817 }`
1818 }
17831819 >
1784 {item.label}
1785 </button>
1786 ))}
1787 {tab === "summary" ? (
1820 <MessageSquareIcon aria-hidden className="size-3" />
1821 {activityError
1822 ? "—"
1823 : activityPending
1824 ? "…"
1825 : detail.commentCount.toLocaleString()}
1826 </span>
17881827 <span
1789 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
1790 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
1828 className="inline-flex items-center gap-1"
1829 aria-label={
1830 activityError
1831 ? "Commits unavailable"
1832 : `${detail.commits.length.toLocaleString()} ${
1833 detail.commits.length === 1 ? "commit" : "commits"
1834 }`
1835 }
17911836 >
1792 {/* The rollup icon opens the checks behind the summary; with none reported
1793 there is nothing to open, so the plain glyph stays. */}
1794 {detail && checksState !== null ? (
1795 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1796 ) : (
1797 <CircleDotIcon aria-hidden className="size-3.5" />
1798 )}
1799 {checksSummary}
1837 <GitCommitHorizontalIcon aria-hidden className="size-3" />
1838 {activityError
1839 ? "—"
1840 : activityPending
1841 ? "…"
1842 : detail.commits.length.toLocaleString()}
18001843 </span>
1801 ) : tab === "timeline" ? (
1802 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
1803 <PullRequestMetaLine
1844 {approvalCount > 0 ? (
1845 <span
18041846 className={cn(
1805 "whitespace-nowrap text-[11px] transition-opacity",
1806 (activityPending || activityError) && "opacity-35",
1847 "inline-flex items-center gap-1",
1848 pullRequestReviewOutcomeToneClassName("approved"),
18071849 )}
18081850 >
1809 <span
1810 className="inline-flex items-center gap-1"
1811 aria-label={
1812 activityError
1813 ? "Comments unavailable"
1814 : `${detail.commentCount.toLocaleString()} ${
1815 detail.commentCount === 1 ? "comment" : "comments"
1816 }`
1817 }
1818 >
1819 <MessageSquareIcon aria-hidden className="size-3" />
1820 {activityError
1821 ? "—"
1822 : activityPending
1823 ? "…"
1824 : detail.commentCount.toLocaleString()}
1825 </span>
1826 <span
1827 className="inline-flex items-center gap-1"
1828 aria-label={
1829 activityError
1830 ? "Commits unavailable"
1831 : `${detail.commits.length.toLocaleString()} ${
1832 detail.commits.length === 1 ? "commit" : "commits"
1833 }`
1834 }
1835 >
1836 <GitCommitHorizontalIcon aria-hidden className="size-3" />
1837 {activityError
1838 ? "—"
1839 : activityPending
1840 ? "…"
1841 : detail.commits.length.toLocaleString()}
1851 <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" />
1852 {approvalCount.toLocaleString()}
1853 <span className="sr-only">
1854 {approvalCount === 1 ? "approval" : "approvals"}
18421855 </span>
1843 {/* Only once somebody has approved: a nought beside a tick would read as a
1844 verdict of its own on a change nobody has looked at yet. */}
1845 {approvalCount > 0 ? (
1846 <span
1847 className={cn(
1848 "inline-flex items-center gap-1",
1849 pullRequestReviewOutcomeToneClassName("approved"),
1850 )}
1851 >
1852 <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" />
1853 {approvalCount.toLocaleString()}
1854 {/* The icon is decorative and a name written onto a generic span is
1855 not announced, so the bare number says what it counts in words. */}
1856 <span className="sr-only">
1857 {approvalCount === 1 ? "approval" : "approvals"}
1858 </span>
1859 </span>
1860 ) : null}
1861 </PullRequestMetaLine>
1862 <Button
1863 size="xs"
1864 variant="ghost"
1865 className="h-7 px-2 text-[10px] text-muted-foreground"
1866 aria-label={
1867 timelineOrder === "newest"
1868 ? "Show oldest activity first"
1869 : "Show newest activity first"
1870 }
1871 onClick={() =>
1872 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
1873 }
1874 >
1875 <ArrowDownUpIcon aria-hidden className="size-3" />
1876 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
1877 </Button>
1878 </div>
1879 ) : null}
1880 </nav>
1856 </span>
1857 ) : null}
1858 </PullRequestMetaLine>
1859 <Button
1860 size="xs"
1861 variant="ghost"
1862 className="h-7 px-2 text-[10px] text-muted-foreground"
1863 aria-label={
1864 timelineOrder === "newest"
1865 ? "Show oldest activity first"
1866 : "Show newest activity first"
1867 }
1868 onClick={() =>
1869 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
1870 }
1871 >
1872 <ArrowDownUpIcon aria-hidden className="size-3" />
1873 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
1874 </Button>
1875 </div>
18811876 ) : null}
1882 </div>
1883 </div>
1877 </nav>
1878 ) : null}
18841879 </div>
18851880
18861881 <div
18871882 className="relative min-h-0 flex-1 overflow-hidden"
1888 // Scroll does not bubble, but it captures: one listener hears every tab's own scroll
1889 // container. Collapse past two line-heights, expand only back at the very top, so the
1890 // boundary row cannot flap the chrome open and shut.
18911883 onScrollCapture={(event) => {
1892 if (chromeVariant !== "collapse") return;
18931884 const scroller = event.target as HTMLElement;
18941885 scrollerRef.current = scroller;
18951886 const top = scroller.scrollTop;
18961887 setChromeCondensed((previous) => {
18971888 let next = previous;
1898 // `scrollHeight` reads the fold's natural height whichever state the track is in.
18991889 const foldHeight = foldRef.current?.scrollHeight ?? 0;
1900 // The chrome trades the fold for the condensed second row, so the height the
1901 // scrollport actually gains is the difference between the two.
1890 // The condensed row remains mounted, so refund only the height that actually leaves.
19021891 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);
19031892 if (previous) {
19041893 // The hard top reopens the chrome with no refund: the reader asked for the top,
......@@ -1916,17 +1905,7 @@ export function PullRequestDetailPanel({
19161905 });
19171906 }}
19181907 >
1919 {detailQuery.isPending && !detail ? (
1920 // The ghost wears the shape of the tab being waited on, so switching tabs mid-load
1921 // does not flash a summary outline under a timeline heading.
1922 tab === "timeline" ? (
1923 <PullRequestTimelineGhost />
1924 ) : tab === "code" ? (
1925 <DiffPanelLoadingState label="Loading pull request diff..." />
1926 ) : (
1927 <PullRequestDetailGhost />
1928 )
1929 ) : detailQuery.error && !detail ? (
1908 {detailQuery.error && !detail ? (
19301909 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />
19311910 ) : detail ? (
19321911 <>
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+91-24
......@@ -45,13 +45,11 @@ export function PullRequestListGhost({
4545 <GhostBar className="size-4 rounded-full" />
4646 <div className="min-w-0 space-y-1.5">
4747 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
48 <GhostBar
49 className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])}
50 />
48 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
5149 </div>
5250 <div className="flex flex-col items-end gap-1.5">
5351 <GhostBar className="w-12" />
54 <GhostBar className="w-16 bg-muted-foreground/10" />
52 <GhostBar className="w-16" />
5553 </div>
5654 </div>
5755 ))}
......@@ -59,32 +57,101 @@ export function PullRequestListGhost({
5957 );
6058}
6159
62/** The summary's own shape: a title, a byline, the facts rows, the description. */
60/**
61 * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description
62 * boundaries in the ghost prevents the loaded pull request from replacing one layout with
63 * another a moment later.
64 */
6365export function PullRequestDetailGhost() {
6466 return (
6567 <div
6668 role="status"
6769 aria-label="Loading pull request"
68 className="animate-ghost-pulse space-y-6 px-4 py-5"
70 className="animate-ghost-pulse flex h-full min-h-0 flex-col overflow-hidden bg-background"
6971 >
70 <div className="space-y-2">
71 <GhostBar className="h-5 w-4/5" />
72 <GhostBar className="w-2/5 bg-muted-foreground/10" />
72 <div className="shrink-0 border-b border-border/60">
73 <div className="flex h-7 items-center justify-between gap-3 px-4">
74 <div className="flex min-w-0 flex-1 items-center gap-1.5">
75 <GhostBar className="w-24" />
76 <GhostBar className="w-9" />
77 </div>
78 <div className="flex shrink-0 items-center gap-1">
79 <GhostBar className="h-5 w-16 rounded-md" />
80 <GhostBar className="size-5 rounded-md" />
81 </div>
82 </div>
83
84 <div className="px-4 pb-4 pt-1">
85 <GhostBar className="h-5 w-4/5 max-w-md" />
86 <div className="mt-2 flex items-center gap-1.5">
87 <GhostBar className="size-4 rounded-full" />
88 <GhostBar className="w-24" />
89 </div>
90 <div className="mt-4 flex min-w-0 items-center gap-2">
91 <GhostBar className="h-6 w-24 rounded-md" />
92 <GhostBar className="size-3 rounded-full" />
93 <GhostBar className="h-6 w-32 rounded-md" />
94 <div className="ml-auto flex shrink-0 items-center gap-2">
95 <GhostBar className="w-10" />
96 <GhostBar className="w-20" />
97 </div>
98 </div>
99 </div>
100
101 <div className="flex min-h-10 items-center justify-between gap-3 border-t border-border/60 px-4 py-2">
102 <div className="flex items-center gap-1 p-0.5">
103 <GhostBar className="h-6 w-16 rounded-md" />
104 <GhostBar className="h-6 w-16 rounded-md" />
105 <GhostBar className="h-6 w-12 rounded-md" />
106 </div>
107 <GhostBar className="w-20" />
108 </div>
73109 </div>
74 <div className="space-y-3">
75 {Array.from({ length: 4 }, (_, index) => (
76 <div key={index} className="flex items-center gap-3">
110
111 <div className="min-h-0 flex-1 overflow-hidden">
112 <section className="px-4 py-3">
113 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
114 <div className="flex items-center gap-1.5">
115 <GhostBar className="size-3.5 rounded-full" />
116 <GhostBar className="w-14" />
117 </div>
118 <div className="flex items-center gap-1">
119 <GhostBar className="size-4 rounded-full" />
120 <GhostBar className="size-4 rounded-full" />
121 <GhostBar className="ml-1 size-5 rounded-md" />
122 </div>
123 </div>
124 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
125 <div className="flex items-center gap-1.5">
126 <GhostBar className="size-3.5 rounded-full" />
127 <GhostBar className="w-10" />
128 </div>
129 <div className="flex items-center gap-1">
130 <GhostBar className="h-5 w-24 rounded-full" />
131 <GhostBar className="h-5 w-20 rounded-full" />
132 </div>
133 </div>
134 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2">
135 <div className="flex items-center gap-1.5">
136 <GhostBar className="size-3.5 rounded-full" />
137 <GhostBar className="w-14" />
138 </div>
139 <GhostBar className="w-20" />
140 </div>
141 </section>
142
143 <section className="border-t border-border/60">
144 <div className="flex min-h-11 items-center gap-1.5 px-4 py-3">
145 <GhostBar className="h-4 w-24" />
77146 <GhostBar className="size-3.5 rounded-full" />
78 <GhostBar className="w-20 bg-muted-foreground/10" />
79 <GhostBar className={TITLE_WIDTHS[(index + 1) % TITLE_WIDTHS.length]} />
80147 </div>
81 ))}
82 </div>
83 <div className="space-y-2 pt-1">
84 <GhostBar className="w-full bg-muted-foreground/10" />
85 <GhostBar className="w-11/12 bg-muted-foreground/10" />
86 <GhostBar className="w-4/5 bg-muted-foreground/10" />
87 <GhostBar className="w-2/3 bg-muted-foreground/10" />
148 <div className="space-y-2 px-4 pb-4">
149 <GhostBar className="w-full" />
150 <GhostBar className="w-11/12" />
151 <GhostBar className="w-4/5" />
152 <GhostBar className="w-2/3" />
153 </div>
154 </section>
88155 </div>
89156 </div>
90157 );
......@@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
113180 <div key={index} className="relative pb-5">
114181 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />
115182 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
116 <GhostBar className="mt-1.5 w-16 bg-muted-foreground/10" />
183 <GhostBar className="mt-1.5 w-16" />
117184 </div>
118185 ))}
119186 </div>
......@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
134201 <GhostBar className="size-5 shrink-0 rounded-full" />
135202 <div className="flex-1 space-y-1.5">
136203 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
137 <GhostBar className="w-full bg-muted-foreground/10" />
138 <GhostBar className="w-3/4 bg-muted-foreground/10" />
204 <GhostBar className="w-full" />
205 <GhostBar className="w-3/4" />
139206 </div>
140207 </div>
141208 ))}
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+9-6
......@@ -25,6 +25,7 @@ import { cn } from "~/lib/utils";
2525import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
2626import { ProjectFavicon } from "../ProjectFavicon";
2727import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
28import { Button } from "../ui/button";
2829
2930import {
3031 Menu,
......@@ -273,12 +274,14 @@ export function PullRequestFiltersMenu({
273274 return (
274275 <Menu>
275276 <MenuTrigger
276 className={cn(
277 // The icon-button size that pairs with a full-height input, so the two read as one strip.
278 "relative inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-input text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground sm:size-8",
279 filtered && "text-foreground",
280 )}
281 aria-label="Filter pull requests"
277 render={
278 <Button
279 className={cn("relative", filtered && "[--control-icon-color:currentColor]")}
280 size="icon"
281 variant="outline"
282 aria-label="Filter pull requests"
283 />
284 }
282285 >
283286 <ListFilterIcon className="size-4" />
284287 {filtered ? (
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+3-3
......@@ -220,12 +220,12 @@ function MetaRow({
220220 children: ReactNode;
221221}) {
222222 return (
223 <div className="flex items-center gap-2 py-1.5 text-xs">
224 <span className="flex w-24 shrink-0 items-center gap-1.5 text-muted-foreground">
223 <div className="grid min-h-8 grid-cols-[6rem_minmax(0,1fr)] items-center gap-2 py-1.5 text-xs">
224 <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
225225 {icon}
226226 {label}
227227 </span>
228 <span className="min-w-0 flex-1 text-foreground">{children}</span>
228 <span className="min-w-0 text-foreground">{children}</span>
229229 </div>
230230 );
231231}
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+42-4
......@@ -17,6 +17,7 @@ import {
1717 handoffPrompt,
1818 handoffReviewComments,
1919 isPullRequestVerdictStale,
20 isStackedPullRequestBase,
2021 isThreadOwnPullRequest,
2122 latestPullRequestReviewOutcomes,
2223 newestPullRequestCommitAt,
......@@ -142,8 +143,6 @@ describe("pull request handoff labels", () => {
142143 fixFinding: "Fix in this thread",
143144 fixCheck: "Fix in this thread",
144145 fixFindings: "Fix findings in this thread",
145 resolve: "Resolve in this thread",
146 resolveConflicts: "Resolve conflicts in this thread",
147146 });
148147 });
149148
......@@ -152,8 +151,6 @@ describe("pull request handoff labels", () => {
152151 fixFinding: "Fix in a thread",
153152 fixCheck: "Fix",
154153 fixFindings: "Fix findings in a thread",
155 resolve: "Resolve in a new thread",
156 resolveConflicts: "Resolve conflicts in a thread",
157154 });
158155 });
159156});
......@@ -167,6 +164,47 @@ describe("pull request composer target", () => {
167164 });
168165});
169166
167describe("stacked pull request classification", () => {
168 it("requires a known default branch", () => {
169 expect(isStackedPullRequestBase("main", [{ name: "main", isDefault: false }])).toBe(false);
170 });
171
172 it("recognizes local and remote forms of the default branch", () => {
173 expect(
174 isStackedPullRequestBase("main", [{ name: "main", isDefault: true, isRemote: false }]),
175 ).toBe(false);
176 expect(
177 isStackedPullRequestBase("main", [
178 { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" },
179 ]),
180 ).toBe(false);
181 });
182
183 it("classifies a non-default base as stacked once the default is known", () => {
184 expect(
185 isStackedPullRequestBase("feature-base", [
186 { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" },
187 ]),
188 ).toBe(true);
189 });
190
191 it("does not mistake a nested branch suffix for the default branch", () => {
192 expect(
193 isStackedPullRequestBase("main", [
194 {
195 name: "origin/feature/main",
196 isDefault: true,
197 isRemote: true,
198 remoteName: "origin",
199 },
200 ]),
201 ).toBe(true);
202 expect(
203 isStackedPullRequestBase("1.0", [{ name: "release/1.0", isDefault: true, isRemote: false }]),
204 ).toBe(true);
205 });
206});
207
170208describe("ordering comments", () => {
171209 it("reverses the chronological list for newest first, and leaves oldest first alone", () => {
172210 const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }];
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+15-4
......@@ -11,6 +11,7 @@ import type {
1111 PullRequestReviewThread,
1212 PullRequestState,
1313 PullRequestUpdateMethod,
14 VcsRef,
1415} from "@t3tools/contracts";
1516
1617import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";
......@@ -75,15 +76,11 @@ export function pullRequestHandoffLabels(inThisThread: boolean) {
7576 fixFinding: "Fix in this thread",
7677 fixCheck: "Fix in this thread",
7778 fixFindings: "Fix findings in this thread",
78 resolve: "Resolve in this thread",
79 resolveConflicts: "Resolve conflicts in this thread",
8079 }
8180 : {
8281 fixFinding: "Fix in a thread",
8382 fixCheck: "Fix",
8483 fixFindings: "Fix findings in a thread",
85 resolve: "Resolve in a new thread",
86 resolveConflicts: "Resolve conflicts in a thread",
8784 };
8885}
8986
......@@ -103,6 +100,20 @@ export function pullRequestActionMenuHasGroup(
103100 return showsDraftToggle || showsAutoMerge || showsMergeMethods;
104101}
105102
103export function isStackedPullRequestBase(
104 baseBranch: string,
105 refs: ReadonlyArray<Pick<VcsRef, "name" | "isDefault" | "isRemote" | "remoteName">>,
106): boolean {
107 const defaultRef = refs.find((refName) => refName.isDefault);
108 if (!defaultRef) return false;
109 if (defaultRef.isRemote !== true) return defaultRef.name !== baseBranch;
110 const remotePrefix = `${defaultRef.remoteName ?? defaultRef.name.split("/")[0]}/`;
111 const defaultBranch = defaultRef.name.startsWith(remotePrefix)
112 ? defaultRef.name.slice(remotePrefix.length)
113 : defaultRef.name;
114 return defaultBranch !== baseBranch;
115}
116
106117/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */
107118export function describePullRequestState(state: PullRequestState, isDraft: boolean): string {
108119 if (state === "merged") return "Merged";
apps/web/src/lib/openPullRequestLink.test.ts+19
......@@ -1,6 +1,7 @@
11import { describe, expect, it, vi } from "vite-plus/test";
22
33import {
4 changeRequestRepositoryUrl,
45 findProjectForChangeRequest,
56 openPullRequestLink,
67 parseChangeRequestUrl,
......@@ -8,6 +9,24 @@ import {
89 shouldOpenPullRequestExternally,
910} from "./openPullRequestLink";
1011
12describe("changeRequestRepositoryUrl", () => {
13 it("preserves repository path casing", () => {
14 expect(
15 changeRequestRepositoryUrl(
16 "https://gitlab.example.test/Team/Platform/Repo/-/merge_requests/42/diffs#note_1",
17 ),
18 ).toBe("https://gitlab.example.test/Team/Platform/Repo");
19 });
20
21 it("keeps pull-like segments inside nested GitLab repository paths", () => {
22 expect(
23 changeRequestRepositoryUrl(
24 "https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42",
25 ),
26 ).toBe("https://gitlab.example.test/group/pull/123/repo");
27 });
28});
29
1130describe("openPullRequestLink", () => {
1231 it("opens the requested pull request URL", async () => {
1332 const openExternal = vi.fn(async () => undefined);
apps/web/src/lib/openPullRequestLink.ts+17
......@@ -118,6 +118,23 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
118118 return null;
119119}
120120
121/** The repository root behind a recognised change-request URL, without PR-specific state. */
122export function changeRequestRepositoryUrl(targetUrl: string): string | null {
123 const changeRequest = parseChangeRequestUrl(targetUrl);
124 if (changeRequest === null) return null;
125 const url = new URL(targetUrl);
126 const repositoryPath =
127 /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
128 /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
129 url.pathname,
130 )?.[1];
131 if (!repositoryPath) return null;
132 url.pathname = repositoryPath;
133 url.search = "";
134 url.hash = "";
135 return url.toString();
136}
137
121138function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
122139 const repository = match?.[1];
123140 const number = Number(match?.[2]);
apps/web/src/routes/_chat.pull-requests.tsx+59-48
......@@ -74,6 +74,9 @@ import {
7474 WorkspaceBreadcrumbItem,
7575 WorkspaceBreadcrumbSeparator,
7676} from "../components/WorkspaceBreadcrumb";
77import { WorkspacePageContainer } from "../components/WorkspacePageContainer";
78import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
79import { isElectron } from "../env";
7780import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";
7881import { Button } from "../components/ui/button";
7982import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
......@@ -100,7 +103,6 @@ import {
100103import { useAtomCommand } from "../state/use-atom-command";
101104import { cn } from "~/lib/utils";
102105import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
103import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
104106
105107export interface PullRequestsSearch {
106108 readonly involvement: PullRequestInvolvement;
......@@ -1181,6 +1183,7 @@ function PullRequestsRouteView() {
11811183 : null,
11821184 [search.number, search.repository, selectedProject],
11831185 );
1186 const rightPanelAvailable = selectedPullRequestSurface !== null;
11841187 useEffect(() => {
11851188 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;
11861189 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);
......@@ -1294,9 +1297,10 @@ function PullRequestsRouteView() {
12941297 terminalAvailable={false}
12951298 terminalOpen={false}
12961299 terminalShortcutLabel={null}
1297 rightPanelAvailable={rightPanelState.surfaces.length > 0}
1300 rightPanelAvailable={rightPanelAvailable}
12981301 rightPanelOpen={rightPanelState.isOpen}
12991302 rightPanelShortcutLabel={null}
1303 rightPanelUnavailableLabel="Select a pull request first"
13001304 liveAgentCount={0}
13011305 onToggleTerminal={() => undefined}
13021306 onToggleRightPanel={toggleRightPanel}
......@@ -1603,7 +1607,6 @@ function PullRequestsRouteView() {
16031607 reviewingQuery.refresh();
16041608 }}
16051609 onStateChange={handlePullRequestTabStatusChange}
1606 chromeVariant="collapse"
16071610 />
16081611 </RightPanelTabs>
16091612 ) : null}
......@@ -1612,10 +1615,7 @@ function PullRequestsRouteView() {
16121615 );
16131616}
16141617
1615/**
1616 * A compact stand-in for one pill group: the trigger wears the current choice, the choices
1617 * live in a menu. Same options, same handler — only the footprint changes.
1618 */
1618/** A compact stand-in for one pill group when the header is narrow. */
16191619function CompactFilterMenu<Value extends string>({
16201620 label,
16211621 value,
......@@ -1627,7 +1627,8 @@ function CompactFilterMenu<Value extends string>({
16271627 options: ReadonlyArray<PullRequestFilterOption<Value>>;
16281628 onChange: (value: Value) => void;
16291629}) {
1630 const current = options.find((option) => option.value === value) ?? options[0]!;
1630 const current = options.find((option) => option.value === value) ?? options[0];
1631 if (!current) return null;
16311632 return (
16321633 <Menu>
16331634 <MenuTrigger
......@@ -1640,15 +1641,12 @@ function CompactFilterMenu<Value extends string>({
16401641 <MenuPopup align="start" side="bottom" className="min-w-40">
16411642 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>
16421643 {options.map((option) => {
1643 // A host the server has already said it cannot read is not a choice here either.
1644 // The pills disable it; a menu that offers it would answer the press by replacing
1645 // a working list with the same failure the pill row exists to explain.
16461644 const item = (
16471645 <MenuRadioItem
16481646 key={option.value}
16491647 value={option.value}
1650 className={option.unavailable ? "data-disabled:pointer-events-auto" : undefined}
16511648 disabled={option.unavailable !== undefined}
1649 className="data-disabled:pointer-events-auto"
16521650 >
16531651 <span className="flex min-w-0 items-center gap-2">
16541652 <option.Icon aria-hidden className="size-3.5" />
......@@ -1656,11 +1654,12 @@ function CompactFilterMenu<Value extends string>({
16561654 </span>
16571655 </MenuRadioItem>
16581656 );
1659 if (!option.unavailable) return item;
1660 return (
1657 return option.unavailable === undefined ? (
1658 item
1659 ) : (
16611660 <Tooltip key={option.value}>
16621661 <TooltipTrigger render={item} />
1663 <TooltipPopup side="top" className="max-w-80">
1662 <TooltipPopup side="right" className="max-w-64 break-words">
16641663 {option.unavailable}
16651664 </TooltipPopup>
16661665 </Tooltip>
......@@ -1838,18 +1837,10 @@ function PullRequestsColumn({
18381837 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a
18391838 // content surface that lets it show reads as a different background than every thread.
18401839 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
1841 <header
1842 className={cn(
1843 "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center gap-1.5 px-3 sm:px-5",
1844 // A closed right panel leaves this column full-width, so its header runs
1845 // underneath the native window controls on Windows; reserve the inset the
1846 // way Settings and the chat view do. While the panel is open the column
1847 // ends at the panel's left edge and the absolute controls strip (already
1848 // WCO-aware) owns the top-right corner.
1849 !rightPanelOpen && "wco:pr-[var(--workspace-native-controls-inset)]",
1850 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
1851 )}
1852 >
1840 {/* A closed right panel leaves this column full-width, so the shared header
1841 reserves native window controls. While the panel is open, the column ends
1842 at the panel and the absolute controls strip owns the top-right corner. */}
1843 <WorkspacePageHeader electron={isElectron} reserveNativeControls={!rightPanelOpen}>
18531844 {condensed ? (
18541845 <WorkspaceBreadcrumb ariaLabel="Pull request scope">
18551846 {/* The page name remains the foreground anchor in both states; the live filters are
......@@ -1891,27 +1882,22 @@ function PullRequestsColumn({
18911882 )}
18921883 <div className="min-w-0 flex-1" />
18931884 {condensed ? (
1894 <ExpandableSearch
1895 searchInput={searchInput}
1896 searchValue={searchValue}
1897 open={searchOpen}
1898 onOpenChange={setSearchOpen}
1899 focusToken={searchFocusToken}
1900 onFocusWithin={(focused) => {
1901 topbarSearchFocusedRef.current = focused;
1902 }}
1903 />
1885 <div className="flex shrink-0 items-center gap-1.5">
1886 <ExpandableSearch
1887 searchInput={searchInput}
1888 searchValue={searchValue}
1889 open={searchOpen}
1890 onOpenChange={setSearchOpen}
1891 focusToken={searchFocusToken}
1892 onFocusWithin={(focused) => {
1893 topbarSearchFocusedRef.current = focused;
1894 }}
1895 />
1896 <PullRequestRefreshControl compact refreshing={refreshing} onRefresh={onRefresh} />
1897 </div>
19041898 ) : null}
1905 <Button
1906 size="icon-sm"
1907 variant="ghost"
1908 aria-label="Refresh pull requests"
1909 onClick={onRefresh}
1910 >
1911 <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
1912 </Button>
19131899 {rightPanelControl}
1914 </header>
1900 </WorkspacePageHeader>
19151901
19161902 <div
19171903 ref={scrollRef}
......@@ -1920,19 +1906,44 @@ function PullRequestsColumn({
19201906 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the
19211907 settings page makes: at rest the controls sit fully below the mask, and only
19221908 content actually passing under the chrome fades. */}
1923 <div className="mx-auto flex w-full max-w-4xl flex-col gap-4 px-5 pt-6 pb-12">
1909 <WorkspacePageContainer className="gap-4">
19241910 <div className="flex flex-col gap-3">
19251911 <div ref={inFlowSearchRef} className="flex items-center gap-2">
19261912 {searchInput}
19271913 {filtersMenu}
1914 {!condensed ? (
1915 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
1916 ) : null}
19281917 </div>
19291918 {/* Scrolled past this marker, the controls are gone and the title takes over. */}
19301919 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />
19311920 </div>
19321921
19331922 {listBody}
1934 </div>
1923 </WorkspacePageContainer>
19351924 </div>
19361925 </div>
19371926 );
19381927}
1928
1929function PullRequestRefreshControl({
1930 compact = false,
1931 refreshing,
1932 onRefresh,
1933}: {
1934 compact?: boolean;
1935 refreshing: boolean;
1936 onRefresh: () => void;
1937}) {
1938 return (
1939 <Button
1940 size={compact ? "icon-sm" : "icon"}
1941 variant={compact ? "ghost" : "outline"}
1942 aria-label="Refresh pull requests"
1943 onClick={onRefresh}
1944 disabled={refreshing}
1945 >
1946 <RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
1947 </Button>
1948 );
1949}