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) {...@@ -6244,7 +6244,6 @@ function ChatViewContent(props: ChatViewProps) {
6244 ? "thread"6244 ? "thread"
6245 : "page"6245 : "page"
6246 }6246 }
6247 chromeVariant="collapse"
6248 composerDraftTarget={composerDraftTarget}6247 composerDraftTarget={composerDraftTarget}
6249 onStateChange={handlePullRequestTabStatusChange}6248 onStateChange={handlePullRequestTabStatusChange}
6250 />6249 />
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 {...@@ -12,6 +12,7 @@ interface PanelLayoutControlsProps {
12 rightPanelAvailable: boolean;12 rightPanelAvailable: boolean;
13 rightPanelOpen: boolean;13 rightPanelOpen: boolean;
14 rightPanelShortcutLabel: string | null;14 rightPanelShortcutLabel: string | null;
15 rightPanelUnavailableLabel?: string;
15 /** Running + waiting subagents in this thread; badges the right panel toggle. */16 /** Running + waiting subagents in this thread; badges the right panel toggle. */
16 liveAgentCount: number;17 liveAgentCount: number;
17 onToggleTerminal: () => void;18 onToggleTerminal: () => void;
...@@ -26,6 +27,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -26,6 +27,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
26 rightPanelAvailable,27 rightPanelAvailable,
27 rightPanelOpen,28 rightPanelOpen,
28 rightPanelShortcutLabel,29 rightPanelShortcutLabel,
30 rightPanelUnavailableLabel = "Right panel is unavailable",
29 liveAgentCount,31 liveAgentCount,
30 onToggleTerminal,32 onToggleTerminal,
31 onToggleRightPanel,33 onToggleRightPanel,
...@@ -37,21 +39,19 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -37,21 +39,19 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
37 >39 >
38 {showTerminalControl ? (40 {showTerminalControl ? (
39 <Tooltip>41 <Tooltip>
40 <TooltipTrigger42 <TooltipTrigger render={<span className="flex shrink-0" />}>
41 render={43 <Toggle
42 <Toggle44 className="shrink-0 [-webkit-app-region:no-drag]"
43 className="shrink-0 [-webkit-app-region:no-drag]"45 pressed={terminalOpen}
44 pressed={terminalOpen}46 onPressedChange={onToggleTerminal}
45 onPressedChange={onToggleTerminal}47 aria-label="Toggle terminal drawer"
46 aria-label="Toggle terminal drawer"48 variant="ghost"
47 variant="ghost"49 size="sm"
48 size="sm"50 disabled={!terminalAvailable}
49 disabled={!terminalAvailable}51 >
50 >52 <PanelBottomIcon className="size-4" />
51 <PanelBottomIcon className="size-4" />53 </Toggle>
52 </Toggle>54 </TooltipTrigger>
53 }
54 />
55 <TooltipPopup side="bottom">55 <TooltipPopup side="bottom">
56 {terminalAvailable56 {terminalAvailable
57 ? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}`57 ? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}`
...@@ -60,33 +60,31 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -60,33 +60,31 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
60 </Tooltip>60 </Tooltip>
61 ) : null}61 ) : null}
62 <Tooltip>62 <Tooltip>
63 <TooltipTrigger63 <TooltipTrigger render={<span className="flex shrink-0" />}>
64 render={64 <Toggle
65 <Toggle65 className="shrink-0 [-webkit-app-region:no-drag]"
66 className="shrink-0 [-webkit-app-region:no-drag]"66 pressed={rightPanelOpen}
67 pressed={rightPanelOpen}67 onPressedChange={onToggleRightPanel}
68 onPressedChange={onToggleRightPanel}68 aria-label={
69 aria-label={69 liveAgentCount > 0
70 liveAgentCount > 070 ? `Toggle right panel, ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
71 ? `Toggle right panel, ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`71 : "Toggle right panel"
72 : "Toggle right panel"72 }
73 }73 variant="ghost"
74 variant="ghost"74 size="sm"
75 size="sm"75 disabled={!rightPanelAvailable}
76 disabled={!rightPanelAvailable}76 >
77 >77 <PanelRightIcon className="size-4" />
78 <PanelRightIcon className="size-4" />78 {liveAgentCount > 0 ? (
79 {liveAgentCount > 0 ? (79 <span
80 <span80 aria-hidden
81 aria-hidden81 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 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 >83 {liveAgentCount}
84 {liveAgentCount}84 </span>
85 </span>85 ) : null}
86 ) : null}86 </Toggle>
87 </Toggle>87 </TooltipTrigger>
88 }
89 />
90 <TooltipPopup side="bottom">88 <TooltipPopup side="bottom">
91 {rightPanelAvailable89 {rightPanelAvailable
92 ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}${90 ? `Toggle right panel${rightPanelShortcutLabel ? ` (${rightPanelShortcutLabel})` : ""}${
...@@ -94,7 +92,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({...@@ -94,7 +92,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({
94 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`92 ? ` · ${liveAgentCount} ${liveAgentCount === 1 ? "agent" : "agents"} working`
95 : ""93 : ""
96 }`94 }`
97 : "Right panel is unavailable"}95 : rightPanelUnavailableLabel}
98 </TooltipPopup>96 </TooltipPopup>
99 </Tooltip>97 </Tooltip>
100 </div>98 </div>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+410-431
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 GitPullRequestDraftIcon,25 GitPullRequestDraftIcon,
26 GitPullRequestIcon,26 GitPullRequestIcon,
27 HammerIcon,27 HammerIcon,
28 LayersIcon,
28 MessageCircleQuestionIcon,29 MessageCircleQuestionIcon,
29 MessageSquareIcon,30 MessageSquareIcon,
30 LinkIcon,31 LinkIcon,
...@@ -50,6 +51,7 @@ import {...@@ -50,6 +51,7 @@ import {
50import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";51import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
51import { useNewThreadHandler } from "~/hooks/useHandleNewThread";52import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
52import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";53import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
54import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink";
53import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";55import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
54import { cn } from "~/lib/utils";56import { cn } from "~/lib/utils";
55import { readLocalApi } from "~/localApi";57import { readLocalApi } from "~/localApi";
...@@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query";...@@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query";
60import { useLiveRefresh } from "~/hooks/useLiveRefresh";62import { useLiveRefresh } from "~/hooks/useLiveRefresh";
61import { pullRequestEnvironment } from "~/state/pullRequests";63import { pullRequestEnvironment } from "~/state/pullRequests";
62import { useAtomCommand } from "~/state/use-atom-command";64import { useAtomCommand } from "~/state/use-atom-command";
65import { vcsEnvironment } from "~/state/vcs";
63import { formatRelativeTimeLabel } from "~/timestampFormat";66import { formatRelativeTimeLabel } from "~/timestampFormat";
6467
65import {68import {
...@@ -74,6 +77,7 @@ import {...@@ -74,6 +77,7 @@ import {
74import { Badge } from "../ui/badge";77import { Badge } from "../ui/badge";
75import { Button } from "../ui/button";78import { Button } from "../ui/button";
76import { Input } from "../ui/input";79import { Input } from "../ui/input";
80import { Toggle, ToggleGroup } from "../ui/toggle-group";
77import {81import {
78 Menu,82 Menu,
79 MenuItem,83 MenuItem,
...@@ -104,6 +108,7 @@ import {...@@ -104,6 +108,7 @@ import {
104 handoffPrompt,108 handoffPrompt,
105 handoffReviewComments,109 handoffReviewComments,
106 latestPullRequestReviewOutcomes,110 latestPullRequestReviewOutcomes,
111 isStackedPullRequestBase,
107 pullRequestActionMenuHasGroup,112 pullRequestActionMenuHasGroup,
108 pullRequestActionNeedsHostRefresh,113 pullRequestActionNeedsHostRefresh,
109 pullRequestComposerTarget,114 pullRequestComposerTarget,
...@@ -121,6 +126,7 @@ import {...@@ -121,6 +126,7 @@ import {
121} from "./pullRequestProjectAssignment.logic";126} from "./pullRequestProjectAssignment.logic";
122import { PullRequestChecksPopover } from "./PullRequestChecksPopover";127import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
123import {128import {
129 PullRequestActorAvatar,
124 PullRequestActorLabel,130 PullRequestActorLabel,
125 PullRequestDiffStat,131 PullRequestDiffStat,
126 PullRequestMetaLine,132 PullRequestMetaLine,
...@@ -362,7 +368,6 @@ export function PullRequestDetailPanel({...@@ -362,7 +368,6 @@ export function PullRequestDetailPanel({
362 onClose,368 onClose,
363 onStateChange,369 onStateChange,
364 context = "page",370 context = "page",
365 chromeVariant = "full",
366 composerDraftTarget,371 composerDraftTarget,
367}: {372}: {
368 environmentId: EnvironmentId;373 environmentId: EnvironmentId;
...@@ -394,12 +399,6 @@ export function PullRequestDetailPanel({...@@ -394,12 +399,6 @@ export function PullRequestDetailPanel({
394 * again is at best a no-op and at worst git refusing a branch two checkouts.399 * again is at best a no-op and at worst git refusing a branch two checkouts.
395 */400 */
396 context?: "page" | "thread";401 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";
403 /**402 /**
404 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs403 * The open thread's composer. Beside the thread whose own pull request this is, hand-offs
405 * land here instead of opening a new thread — the branch is already under the reader's feet.404 * land here instead of opening a new thread — the branch is already under the reader's feet.
...@@ -436,27 +435,16 @@ export function PullRequestDetailPanel({...@@ -436,27 +435,16 @@ export function PullRequestDetailPanel({
436 );435 );
437 }, [tab]);436 }, [tab]);
438 const [chromeCondensed, setChromeCondensed] = useState(false);437 const [chromeCondensed, setChromeCondensed] = useState(false);
439 // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll438 // Each mounted tab remembers its own scroll chrome; short tabs cannot scroll to reopen it.
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.
444 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});439 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
445 useEffect(() => {440 useEffect(() => {
446 setChromeCondensed(chromeStateByTab.current[tab] ?? false);441 setChromeCondensed(chromeStateByTab.current[tab] ?? false);
447 }, [tab]);442 }, [tab]);
448 const condensed = chromeVariant === "collapse" && chromeCondensed;443 const condensed = 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.
454 const scrollerRef = useRef<HTMLElement | null>(null);444 const scrollerRef = useRef<HTMLElement | null>(null);
455 const foldRef = useRef<HTMLDivElement | null>(null);445 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.
459 const condensedRowRef = useRef<HTMLDivElement | null>(null);446 const condensedRowRef = useRef<HTMLDivElement | null>(null);
447 // Refund after the fold commits so the content under the reader does not jump with its height.
460 const compensationRef = useRef<number | null>(null);448 const compensationRef = useRef<number | null>(null);
461 useLayoutEffect(() => {449 useLayoutEffect(() => {
462 if (compensationRef.current === null) return;450 if (compensationRef.current === null) return;
...@@ -478,7 +466,6 @@ export function PullRequestDetailPanel({...@@ -478,7 +466,6 @@ export function PullRequestDetailPanel({
478 target: "branch name",466 target: "branch name",
479 timeout: 1600,467 timeout: 1600,
480 });468 });
481
482 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be469 // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be
483 // clicked, so a reader who does click it lands on a chunk already in the module cache.470 // clicked, so a reader who does click it lands on a chunk already in the module cache.
484 useEffect(() => {471 useEffect(() => {
...@@ -517,6 +504,23 @@ export function PullRequestDetailPanel({...@@ -517,6 +504,23 @@ export function PullRequestDetailPanel({
517 },504 },
518 [activity, coreDetail],505 [activity, coreDetail],
519 );506 );
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 ?? []);
520 const activityPending = activityQuery.isPending && activity === null;524 const activityPending = activityQuery.isPending && activity === null;
521 const activityError = activity === null ? activityQuery.error : null;525 const activityError = activity === null ? activityQuery.error : null;
522 const refreshDetail = useCallback(() => {526 const refreshDetail = useCallback(() => {
...@@ -1046,17 +1050,17 @@ export function PullRequestDetailPanel({...@@ -1046,17 +1050,17 @@ export function PullRequestDetailPanel({
1046 const can = (action: PullRequestAction) =>1050 const can = (action: PullRequestAction) =>
1047 detail?.capabilities.actions.includes(action) === true &&1051 detail?.capabilities.actions.includes(action) === true &&
1048 detail.viewerPermissions.actions.includes(action);1052 detail.viewerPermissions.actions.includes(action);
1049 // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes1053 // One live action holds the slot. Conflicts take priority because every other completion action
1050 // to the thing that would help instead of a Merge button that only ever says no.1054 // depends on resolving them first, even for a reader who cannot merge on the host themselves.
1051 const primaryAction =1055 const primaryAction =
1052 detail === null || detail.state !== "open"1056 detail === null || detail.state !== "open"
1053 ? null1057 ? null
1054 : detail.isDraft && can("ready")1058 : conflicting
1055 ? "ready"1059 ? "resolve"
1056 : !can("merge")1060 : detail.isDraft && can("ready")
1057 ? null1061 ? "ready"
1058 : conflicting1062 : !can("merge")
1059 ? "resolve"1063 ? null
1060 : allowedMergeMethods.length > 01064 : allowedMergeMethods.length > 0
1061 ? "merge"1065 ? "merge"
1062 : null;1066 : null;
...@@ -1081,7 +1085,7 @@ export function PullRequestDetailPanel({...@@ -1081,7 +1085,7 @@ export function PullRequestDetailPanel({
1081 !conflicting &&1085 !conflicting &&
1082 allowedMergeMethods.length > 1;1086 allowedMergeMethods.length > 1;
1083 // The pull request number carries this state in the overview and the right-panel tab mirrors1087 // 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.
1085 const statePresentation = detail1089 const statePresentation = detail
1086 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })1090 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
1087 : null;1091 : null;
...@@ -1101,34 +1105,47 @@ export function PullRequestDetailPanel({...@@ -1101,34 +1105,47 @@ export function PullRequestDetailPanel({
1101 ).length1105 ).length
1102 : 0;1106 : 0;
11031107
1108 if (detailQuery.isPending && !detail) {
1109 return <PullRequestDetailGhost />;
1110 }
1111
1104 return (1112 return (
1105 <div className="flex h-full min-h-0 w-full flex-col bg-background">1113 <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. */}
1109 <div className="grid shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 border-b border-border/60">1114 <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 rows1115 <div className="ml-4 grid h-7 min-w-0 items-center">
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">
1113 <div1116 <div
1114 aria-hidden={condensed}1117 aria-hidden={condensed}
1115 inert={condensed}1118 inert={condensed}
1116 className={cn(1119 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",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",
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 condensed1121 condensed
1121 ? "pointer-events-none opacity-0 duration-100"1122 ? "pointer-events-none -translate-y-1 opacity-0 duration-100"
1122 : "opacity-100 delay-75 duration-150",1123 : "translate-y-0 opacity-100 delay-50 duration-150",
1123 )}1124 )}
1124 >1125 >
1125 {detail && statePresentation ? (1126 {detail && statePresentation ? (
1126 <>1127 <>
1127 <Tooltip>1128 <Tooltip>
1128 <TooltipTrigger1129 <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 }
1130 />1145 />
1131 <TooltipPopup side="top">{detail.repository}</TooltipPopup>1146 <TooltipPopup side="top">
1147 {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository}
1148 </TooltipPopup>
1132 </Tooltip>1149 </Tooltip>
1133 <Tooltip>1150 <Tooltip>
1134 <TooltipTrigger1151 <TooltipTrigger
...@@ -1156,10 +1173,10 @@ export function PullRequestDetailPanel({...@@ -1156,10 +1173,10 @@ export function PullRequestDetailPanel({
1156 aria-hidden={!condensed}1173 aria-hidden={!condensed}
1157 inert={!condensed}1174 inert={!condensed}
1158 className={cn(1175 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",
1160 condensed1177 condensed
1161 ? "opacity-100 delay-75 duration-150"1178 ? "translate-y-0 opacity-100 delay-50 duration-150"
1162 : "pointer-events-none opacity-0 duration-100",1179 : "pointer-events-none translate-y-1 opacity-0 duration-100",
1163 )}1180 )}
1164 >1181 >
1165 {detail && statePresentation ? (1182 {detail && statePresentation ? (
...@@ -1194,29 +1211,103 @@ export function PullRequestDetailPanel({...@@ -1194,29 +1211,103 @@ export function PullRequestDetailPanel({
1194 />1211 />
1195 <TooltipPopup side="top">{detail.title}</TooltipPopup>1212 <TooltipPopup side="top">{detail.title}</TooltipPopup>
1196 </Tooltip>1213 </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}
1213 </>1214 </>
1214 ) : null}1215 ) : null}
1215 </div>1216 </div>
1216 </div>1217 </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">
1218 {detail ? (1219 {detail ? (
1219 <>1220 <>
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}
1220 <Menu>1311 <Menu>
1221 <MenuTrigger1312 <MenuTrigger
1222 render={1313 render={
...@@ -1361,13 +1452,6 @@ export function PullRequestDetailPanel({...@@ -1361,13 +1452,6 @@ export function PullRequestDetailPanel({
1361 <LinkIcon className="size-3.5" />1452 <LinkIcon className="size-3.5" />
1362 Copy link1453 Copy link
1363 </MenuItem>1454 </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}
1371 {detail.state === "open" && can("close") ? (1455 {detail.state === "open" && can("close") ? (
1372 <>1456 <>
1373 <MenuSeparator />1457 <MenuSeparator />
...@@ -1391,92 +1475,6 @@ export function PullRequestDetailPanel({...@@ -1391,92 +1475,6 @@ export function PullRequestDetailPanel({
1391 ) : null}1475 ) : null}
1392 </MenuPopup>1476 </MenuPopup>
1393 </Menu>1477 </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}
1480 </>1478 </>
1481 ) : null}1479 ) : null}
1482 {onClose ? (1480 {onClose ? (
...@@ -1491,14 +1489,9 @@ export function PullRequestDetailPanel({...@@ -1491,14 +1489,9 @@ export function PullRequestDetailPanel({
1491 ) : null}1489 ) : null}
1492 </div>1490 </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. */}
1497 <div1492 <div
1498 className={cn(1493 className={cn(
1499 "col-span-2 grid",1494 "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.
1502 condensed1495 condensed
1503 ? "grid-rows-[1fr]"1496 ? "grid-rows-[1fr]"
1504 : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",1497 : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
...@@ -1507,84 +1500,103 @@ export function PullRequestDetailPanel({...@@ -1507,84 +1500,103 @@ export function PullRequestDetailPanel({
1507 <div1500 <div
1508 ref={condensedRowRef}1501 ref={condensedRowRef}
1509 className={cn(1502 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",
1511 condensed1504 condensed
1512 ? "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none"1505 ? "translate-y-0 opacity-100 delay-50"
1513 : "opacity-0",1506 : "translate-y-1 opacity-0 duration-100",
1514 )}1507 )}
1515 inert={!condensed}1508 inert={!condensed}
1516 >1509 >
1517 {detail ? (1510 {detail ? (
1518 <div className="flex min-w-0 items-center gap-1 px-4 pb-2">1511 <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">
1519 <nav aria-label="Pull request tabs" className="flex shrink-0 items-center gap-0.5">1512 <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1520 {visibleTabs.map((item) => (1513 <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
1521 <button1514 <Tooltip>
1522 key={item.value}1515 <TooltipTrigger
1523 type="button"1516 render={
1524 tabIndex={condensed ? 0 : -1}1517 <span
1525 aria-pressed={tab === item.value}1518 className="shrink-0 rounded-full"
1526 onClick={() => setTab(item.value)}1519 aria-label={detail.author?.login ?? "ghost"}
1527 className={cn(1520 />
1528 "rounded-md px-2 py-1 text-[11px] transition-colors",1521 }
1529 tab === item.value1522 >
1530 ? "bg-accent text-foreground"1523 <PullRequestActorAvatar actor={detail.author} />
1531 : "text-muted-foreground hover:text-foreground",1524 </TooltipTrigger>
1532 )}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 }`}
1533 >1579 >
1534 {item.label}1580 <FileDiffIcon aria-hidden className="size-3" />
1535 </button>1581 {detail.changedFiles.toLocaleString()}
1536 ))}1582 </span>
1537 </nav>1583 <PullRequestDiffStat
1538 <span className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground">1584 additions={detail.additions}
1539 <Tooltip>1585 deletions={detail.deletions}
1540 <TooltipTrigger render={<span className="truncate" />}>1586 className="shrink-0 font-mono text-[11px]"
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"
1552 />1587 />
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()}
1566 </span>1588 </span>
1567 <PullRequestDiffStat1589 </div>
1568 additions={detail.additions}
1569 deletions={detail.deletions}
1570 className="shrink-0 font-mono text-[11px]"
1571 />
1572 </span>
1573 </div>1590 </div>
1574 ) : null}1591 ) : null}
1575 </div>1592 </div>
1576 </div>1593 </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. */}
1581 <div1595 <div
1582 className={cn(1596 className={cn(
1583 "col-span-2 grid",1597 "col-span-2 grid",
1584 // Collapse is instant: it happens mid-scroll, and the compensation that keeps the1598 // Collapse before the scroll refund paints; only reopening eases back in. Animating
1585 // content pinned would fight an animated track frame by frame. Reopening happens1599 // both directions makes the shrinking track fight the scrollTop correction.
1586 // at the top with no compensation, so the fold can ease back in instead of
1587 // snapping the content down.
1588 condensed1600 condensed
1589 ? "grid-rows-[0fr]"1601 ? "grid-rows-[0fr]"
1590 : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",1602 : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
...@@ -1592,19 +1604,16 @@ export function PullRequestDetailPanel({...@@ -1592,19 +1604,16 @@ export function PullRequestDetailPanel({
1592 >1604 >
1593 <div1605 <div
1594 ref={foldRef}1606 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.
1598 className={cn(1607 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",
1600 condensed1609 condensed
1601 ? "opacity-0"1610 ? "-translate-y-1 opacity-0 duration-100"
1602 : "opacity-100 transition-opacity duration-200 ease-out motion-reduce:transition-none",1611 : "translate-y-0 opacity-100 delay-50",
1603 )}1612 )}
1604 inert={condensed}1613 inert={condensed}
1605 >1614 >
1606 {detail ? (1615 {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">
1608 {titleDraft === null ? (1617 {titleDraft === null ? (
1609 <div className="group flex min-w-0 items-start gap-1">1618 <div className="group flex min-w-0 items-start gap-1">
1610 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">1619 <h1 className="min-w-0 flex-1 text-base font-semibold leading-snug">
...@@ -1671,60 +1680,75 @@ export function PullRequestDetailPanel({...@@ -1671,60 +1680,75 @@ export function PullRequestDetailPanel({
1671 </PullRequestMetaLine>1680 </PullRequestMetaLine>
16721681
1673 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">1682 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1674 <Tooltip>1683 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs text-muted-foreground/70">
1675 <TooltipTrigger1684 <Tooltip>
1676 render={1685 <TooltipTrigger
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">1686 render={
1678 {detail.baseBranch}1687 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
1679 </code>1688 {isStackedPullRequest ? (
1680 }1689 <LayersIcon
1681 />1690 aria-label="Stacked pull request"
1682 <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup>1691 className="size-3 shrink-0"
1683 </Tooltip>1692 />
1684 {freshness ? (1693 ) : null}
1685 <PullRequestBaseFreshnessWarning1694 <code className="min-w-0 truncate">{detail.baseBranch}</code>
1686 baseBranch={detail.baseBranch}1695 </span>
1687 freshness={freshness}1696 }
1688 pending={actionPending}1697 />
1689 onUpdate={(method) => void perform("update-branch", undefined, method)}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"
1690 />1715 />
1691 ) : null}1716 <Tooltip>
1692 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />1717 <TooltipTrigger
1693 <Tooltip>1718 render={
1694 <TooltipTrigger1719 <button
1695 render={1720 type="button"
1696 <button1721 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"
1697 type="button"1722 aria-label={
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"1723 isBranchCopied ? "Branch name copied" : "Copy pull request branch"
1699 aria-label={1724 }
1700 isBranchCopied ? "Branch name copied" : "Copy pull request branch"1725 onClick={() => copyBranchToClipboard(detail.headBranch)}
1701 }1726 />
1702 onClick={() => copyBranchToClipboard(detail.headBranch)}1727 }
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 )}
1720 >1728 >
1721 Copied1729 <code
1722 </span>1730 className={cn(
1723 </TooltipTrigger>1731 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
1724 <TooltipPopup side="top">1732 isBranchCopied ? "opacity-0" : "opacity-100",
1725 {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`}1733 )}
1726 </TooltipPopup>1734 >
1727 </Tooltip>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>
1728 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">1752 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
1729 <span className="inline-flex items-center gap-1.5 tabular-nums">1753 <span className="inline-flex items-center gap-1.5 tabular-nums">
1730 <FileDiffIcon className="size-3.5" />1754 <FileDiffIcon className="size-3.5" />
...@@ -1740,165 +1764,130 @@ export function PullRequestDetailPanel({...@@ -1740,165 +1764,130 @@ export function PullRequestDetailPanel({
1740 </div>1764 </div>
1741 </div>1765 </div>
1742 ) : null}1766 ) : null}
1767 </div>
1768 </div>
17431769
1744 {detail && conflicting ? (1770 {detail ? (
1745 <div className="col-span-2 flex items-center gap-1 px-4 pb-3">1771 <nav
1746 <Badge1772 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"
1747 variant="error"1773 aria-label="Pull request tabs"
1748 className="h-auto gap-1.5 rounded-md px-3 py-1.5 text-xs text-destructive"1774 >
1749 >1775 <ToggleGroup
1750 <TriangleAlertIcon className="size-3.5" />1776 size="segmented"
1751 Merge conflicts1777 variant="segmented"
1752 </Badge>1778 value={[tab]}
1753 <Button1779 onValueChange={(next) => {
1754 size="xs"1780 const nextTab = visibleTabs.find((item) => item.value === next[0])?.value;
1755 variant="ghost"1781 if (nextTab) setTab(nextTab);
1756 className="ml-auto text-destructive hover:bg-destructive/8 hover:text-destructive"1782 }}
1757 disabled={handoff !== null}1783 >
1758 onClick={startResolveConflicts}1784 {visibleTabs.map((item) => (
1759 >1785 <Toggle key={item.value} value={item.value}>
1760 {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolve}1786 {item.label}
1761 <ArrowUpRightIcon className="size-3.5 text-destructive" />1787 </Toggle>
1762 </Button>1788 ))}
1763 </div>1789 </ToggleGroup>
1764 ) : null}1790 {tab === "summary" ? (
17651791 <span
1766 {detail ? (1792 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
1767 <nav1793 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
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 >1794 >
1771 {visibleTabs.map((item) => (1795 {checksState !== null ? (
1772 <button1796 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />
1773 key={item.value}1797 ) : (
1774 type="button"1798 <CircleDotIcon aria-hidden className="size-3.5" />
1775 aria-pressed={tab === item.value}1799 )}
1776 onClick={() => setTab(item.value)}1800 {checksSummary}
1777 className={cn(1801 </span>
1778 "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors",1802 ) : tab === "timeline" ? (
1779 tab === item.value1803 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
1780 ? "bg-accent text-foreground"1804 <PullRequestMetaLine
1781 : "text-muted-foreground hover:text-foreground",1805 className={cn(
1782 )}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 }
1783 >1819 >
1784 {item.label}1820 <MessageSquareIcon aria-hidden className="size-3" />
1785 </button>1821 {activityError
1786 ))}1822 ? "—"
1787 {tab === "summary" ? (1823 : activityPending
1824 ? "…"
1825 : detail.commentCount.toLocaleString()}
1826 </span>
1788 <span1827 <span
1789 className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"1828 className="inline-flex items-center gap-1"
1790 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}1829 aria-label={
1830 activityError
1831 ? "Commits unavailable"
1832 : `${detail.commits.length.toLocaleString()} ${
1833 detail.commits.length === 1 ? "commit" : "commits"
1834 }`
1835 }
1791 >1836 >
1792 {/* The rollup icon opens the checks behind the summary; with none reported1837 <GitCommitHorizontalIcon aria-hidden className="size-3" />
1793 there is nothing to open, so the plain glyph stays. */}1838 {activityError
1794 {detail && checksState !== null ? (1839 ? "—"
1795 <PullRequestChecksPopover checks={detail.checks} checksState={checksState} />1840 : activityPending
1796 ) : (1841 ? "…"
1797 <CircleDotIcon aria-hidden className="size-3.5" />1842 : detail.commits.length.toLocaleString()}
1798 )}
1799 {checksSummary}
1800 </span>1843 </span>
1801 ) : tab === "timeline" ? (1844 {approvalCount > 0 ? (
1802 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">1845 <span
1803 <PullRequestMetaLine
1804 className={cn(1846 className={cn(
1805 "whitespace-nowrap text-[11px] transition-opacity",1847 "inline-flex items-center gap-1",
1806 (activityPending || activityError) && "opacity-35",1848 pullRequestReviewOutcomeToneClassName("approved"),
1807 )}1849 )}
1808 >1850 >
1809 <span1851 <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" />
1810 className="inline-flex items-center gap-1"1852 {approvalCount.toLocaleString()}
1811 aria-label={1853 <span className="sr-only">
1812 activityError1854 {approvalCount === 1 ? "approval" : "approvals"}
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()}
1842 </span>1855 </span>
1843 {/* Only once somebody has approved: a nought beside a tick would read as a1856 </span>
1844 verdict of its own on a change nobody has looked at yet. */}1857 ) : null}
1845 {approvalCount > 0 ? (1858 </PullRequestMetaLine>
1846 <span1859 <Button
1847 className={cn(1860 size="xs"
1848 "inline-flex items-center gap-1",1861 variant="ghost"
1849 pullRequestReviewOutcomeToneClassName("approved"),1862 className="h-7 px-2 text-[10px] text-muted-foreground"
1850 )}1863 aria-label={
1851 >1864 timelineOrder === "newest"
1852 <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" />1865 ? "Show oldest activity first"
1853 {approvalCount.toLocaleString()}1866 : "Show newest activity first"
1854 {/* The icon is decorative and a name written onto a generic span is1867 }
1855 not announced, so the bare number says what it counts in words. */}1868 onClick={() =>
1856 <span className="sr-only">1869 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
1857 {approvalCount === 1 ? "approval" : "approvals"}1870 }
1858 </span>1871 >
1859 </span>1872 <ArrowDownUpIcon aria-hidden className="size-3" />
1860 ) : null}1873 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
1861 </PullRequestMetaLine>1874 </Button>
1862 <Button1875 </div>
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>
1881 ) : null}1876 ) : null}
1882 </div>1877 </nav>
1883 </div>1878 ) : null}
1884 </div>1879 </div>
18851880
1886 <div1881 <div
1887 className="relative min-h-0 flex-1 overflow-hidden"1882 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.
1891 onScrollCapture={(event) => {1883 onScrollCapture={(event) => {
1892 if (chromeVariant !== "collapse") return;
1893 const scroller = event.target as HTMLElement;1884 const scroller = event.target as HTMLElement;
1894 scrollerRef.current = scroller;1885 scrollerRef.current = scroller;
1895 const top = scroller.scrollTop;1886 const top = scroller.scrollTop;
1896 setChromeCondensed((previous) => {1887 setChromeCondensed((previous) => {
1897 let next = previous;1888 let next = previous;
1898 // `scrollHeight` reads the fold's natural height whichever state the track is in.
1899 const foldHeight = foldRef.current?.scrollHeight ?? 0;1889 const foldHeight = foldRef.current?.scrollHeight ?? 0;
1900 // The chrome trades the fold for the condensed second row, so the height the1890 // The condensed row remains mounted, so refund only the height that actually leaves.
1901 // scrollport actually gains is the difference between the two.
1902 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);1891 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);
1903 if (previous) {1892 if (previous) {
1904 // The hard top reopens the chrome with no refund: the reader asked for the top,1893 // The hard top reopens the chrome with no refund: the reader asked for the top,
...@@ -1916,17 +1905,7 @@ export function PullRequestDetailPanel({...@@ -1916,17 +1905,7 @@ export function PullRequestDetailPanel({
1916 });1905 });
1917 }}1906 }}
1918 >1907 >
1919 {detailQuery.isPending && !detail ? (1908 {detailQuery.error && !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 ? (
1930 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />1909 <PullRequestsUnavailableState error={detailQuery.error} onRetry={refreshDetail} />
1931 ) : detail ? (1910 ) : detail ? (
1932 <>1911 <>
apps/web/src/components/pullRequest/PullRequestGhosts.tsx+91-24
...@@ -45,13 +45,11 @@ export function PullRequestListGhost({...@@ -45,13 +45,11 @@ export function PullRequestListGhost({
45 <GhostBar className="size-4 rounded-full" />45 <GhostBar className="size-4 rounded-full" />
46 <div className="min-w-0 space-y-1.5">46 <div className="min-w-0 space-y-1.5">
47 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />47 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />
48 <GhostBar48 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
49 className={cn("bg-muted-foreground/10", META_WIDTHS[index % META_WIDTHS.length])}
50 />
51 </div>49 </div>
52 <div className="flex flex-col items-end gap-1.5">50 <div className="flex flex-col items-end gap-1.5">
53 <GhostBar className="w-12" />51 <GhostBar className="w-12" />
54 <GhostBar className="w-16 bg-muted-foreground/10" />52 <GhostBar className="w-16" />
55 </div>53 </div>
56 </div>54 </div>
57 ))}55 ))}
...@@ -59,32 +57,101 @@ export function PullRequestListGhost({...@@ -59,32 +57,101 @@ export function PullRequestListGhost({
59 );57 );
60}58}
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 */
63export function PullRequestDetailGhost() {65export function PullRequestDetailGhost() {
64 return (66 return (
65 <div67 <div
66 role="status"68 role="status"
67 aria-label="Loading pull request"69 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"
69 >71 >
70 <div className="space-y-2">72 <div className="shrink-0 border-b border-border/60">
71 <GhostBar className="h-5 w-4/5" />73 <div className="flex h-7 items-center justify-between gap-3 px-4">
72 <GhostBar className="w-2/5 bg-muted-foreground/10" />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>
73 </div>109 </div>
74 <div className="space-y-3">110
75 {Array.from({ length: 4 }, (_, index) => (111 <div className="min-h-0 flex-1 overflow-hidden">
76 <div key={index} className="flex items-center gap-3">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" />
77 <GhostBar className="size-3.5 rounded-full" />146 <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]} />
80 </div>147 </div>
81 ))}148 <div className="space-y-2 px-4 pb-4">
82 </div>149 <GhostBar className="w-full" />
83 <div className="space-y-2 pt-1">150 <GhostBar className="w-11/12" />
84 <GhostBar className="w-full bg-muted-foreground/10" />151 <GhostBar className="w-4/5" />
85 <GhostBar className="w-11/12 bg-muted-foreground/10" />152 <GhostBar className="w-2/3" />
86 <GhostBar className="w-4/5 bg-muted-foreground/10" />153 </div>
87 <GhostBar className="w-2/3 bg-muted-foreground/10" />154 </section>
88 </div>155 </div>
89 </div>156 </div>
90 );157 );
...@@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {...@@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
113 <div key={index} className="relative pb-5">180 <div key={index} className="relative pb-5">
114 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />181 <GhostBar className="absolute -left-[1.55rem] top-1 size-2 rounded-full" />
115 <GhostBar className={cn("h-3.5", TITLE_WIDTHS[index % TITLE_WIDTHS.length])} />182 <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" />
117 </div>184 </div>
118 ))}185 ))}
119 </div>186 </div>
...@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {...@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
134 <GhostBar className="size-5 shrink-0 rounded-full" />201 <GhostBar className="size-5 shrink-0 rounded-full" />
135 <div className="flex-1 space-y-1.5">202 <div className="flex-1 space-y-1.5">
136 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />203 <GhostBar className={META_WIDTHS[index % META_WIDTHS.length]} />
137 <GhostBar className="w-full bg-muted-foreground/10" />204 <GhostBar className="w-full" />
138 <GhostBar className="w-3/4 bg-muted-foreground/10" />205 <GhostBar className="w-3/4" />
139 </div>206 </div>
140 </div>207 </div>
141 ))}208 ))}
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+9-6
...@@ -25,6 +25,7 @@ import { cn } from "~/lib/utils";...@@ -25,6 +25,7 @@ import { cn } from "~/lib/utils";
25import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";25import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
26import { ProjectFavicon } from "../ProjectFavicon";26import { ProjectFavicon } from "../ProjectFavicon";
27import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";27import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
28import { Button } from "../ui/button";
2829
29import {30import {
30 Menu,31 Menu,
...@@ -273,12 +274,14 @@ export function PullRequestFiltersMenu({...@@ -273,12 +274,14 @@ export function PullRequestFiltersMenu({
273 return (274 return (
274 <Menu>275 <Menu>
275 <MenuTrigger276 <MenuTrigger
276 className={cn(277 render={
277 // The icon-button size that pairs with a full-height input, so the two read as one strip.278 <Button
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 className={cn("relative", filtered && "[--control-icon-color:currentColor]")}
279 filtered && "text-foreground",280 size="icon"
280 )}281 variant="outline"
281 aria-label="Filter pull requests"282 aria-label="Filter pull requests"
283 />
284 }
282 >285 >
283 <ListFilterIcon className="size-4" />286 <ListFilterIcon className="size-4" />
284 {filtered ? (287 {filtered ? (
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+3-3
...@@ -220,12 +220,12 @@ function MetaRow({...@@ -220,12 +220,12 @@ function MetaRow({
220 children: ReactNode;220 children: ReactNode;
221}) {221}) {
222 return (222 return (
223 <div className="flex items-center gap-2 py-1.5 text-xs">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 w-24 shrink-0 items-center gap-1.5 text-muted-foreground">224 <span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
225 {icon}225 {icon}
226 {label}226 {label}
227 </span>227 </span>
228 <span className="min-w-0 flex-1 text-foreground">{children}</span>228 <span className="min-w-0 text-foreground">{children}</span>
229 </div>229 </div>
230 );230 );
231}231}
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+42-4
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 handoffPrompt,17 handoffPrompt,
18 handoffReviewComments,18 handoffReviewComments,
19 isPullRequestVerdictStale,19 isPullRequestVerdictStale,
20 isStackedPullRequestBase,
20 isThreadOwnPullRequest,21 isThreadOwnPullRequest,
21 latestPullRequestReviewOutcomes,22 latestPullRequestReviewOutcomes,
22 newestPullRequestCommitAt,23 newestPullRequestCommitAt,
...@@ -142,8 +143,6 @@ describe("pull request handoff labels", () => {...@@ -142,8 +143,6 @@ describe("pull request handoff labels", () => {
142 fixFinding: "Fix in this thread",143 fixFinding: "Fix in this thread",
143 fixCheck: "Fix in this thread",144 fixCheck: "Fix in this thread",
144 fixFindings: "Fix findings in this thread",145 fixFindings: "Fix findings in this thread",
145 resolve: "Resolve in this thread",
146 resolveConflicts: "Resolve conflicts in this thread",
147 });146 });
148 });147 });
149148
...@@ -152,8 +151,6 @@ describe("pull request handoff labels", () => {...@@ -152,8 +151,6 @@ describe("pull request handoff labels", () => {
152 fixFinding: "Fix in a thread",151 fixFinding: "Fix in a thread",
153 fixCheck: "Fix",152 fixCheck: "Fix",
154 fixFindings: "Fix findings in a thread",153 fixFindings: "Fix findings in a thread",
155 resolve: "Resolve in a new thread",
156 resolveConflicts: "Resolve conflicts in a thread",
157 });154 });
158 });155 });
159});156});
...@@ -167,6 +164,47 @@ describe("pull request composer target", () => {...@@ -167,6 +164,47 @@ describe("pull request composer target", () => {
167 });164 });
168});165});
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
170describe("ordering comments", () => {208describe("ordering comments", () => {
171 it("reverses the chronological list for newest first, and leaves oldest first alone", () => {209 it("reverses the chronological list for newest first, and leaves oldest first alone", () => {
172 const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }];210 const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }];
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+15-4
...@@ -11,6 +11,7 @@ import type {...@@ -11,6 +11,7 @@ import type {
11 PullRequestReviewThread,11 PullRequestReviewThread,
12 PullRequestState,12 PullRequestState,
13 PullRequestUpdateMethod,13 PullRequestUpdateMethod,
14 VcsRef,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
1516
16import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";17import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext";
...@@ -75,15 +76,11 @@ export function pullRequestHandoffLabels(inThisThread: boolean) {...@@ -75,15 +76,11 @@ export function pullRequestHandoffLabels(inThisThread: boolean) {
75 fixFinding: "Fix in this thread",76 fixFinding: "Fix in this thread",
76 fixCheck: "Fix in this thread",77 fixCheck: "Fix in this thread",
77 fixFindings: "Fix findings in this thread",78 fixFindings: "Fix findings in this thread",
78 resolve: "Resolve in this thread",
79 resolveConflicts: "Resolve conflicts in this thread",
80 }79 }
81 : {80 : {
82 fixFinding: "Fix in a thread",81 fixFinding: "Fix in a thread",
83 fixCheck: "Fix",82 fixCheck: "Fix",
84 fixFindings: "Fix findings in a thread",83 fixFindings: "Fix findings in a thread",
85 resolve: "Resolve in a new thread",
86 resolveConflicts: "Resolve conflicts in a thread",
87 };84 };
88}85}
8986
...@@ -103,6 +100,20 @@ export function pullRequestActionMenuHasGroup(...@@ -103,6 +100,20 @@ export function pullRequestActionMenuHasGroup(
103 return showsDraftToggle || showsAutoMerge || showsMergeMethods;100 return showsDraftToggle || showsAutoMerge || showsMergeMethods;
104}101}
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
106/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */117/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */
107export function describePullRequestState(state: PullRequestState, isDraft: boolean): string {118export function describePullRequestState(state: PullRequestState, isDraft: boolean): string {
108 if (state === "merged") return "Merged";119 if (state === "merged") return "Merged";
apps/web/src/lib/openPullRequestLink.test.ts+19
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
22
3import {3import {
4 changeRequestRepositoryUrl,
4 findProjectForChangeRequest,5 findProjectForChangeRequest,
5 openPullRequestLink,6 openPullRequestLink,
6 parseChangeRequestUrl,7 parseChangeRequestUrl,
...@@ -8,6 +9,24 @@ import {...@@ -8,6 +9,24 @@ import {
8 shouldOpenPullRequestExternally,9 shouldOpenPullRequestExternally,
9} from "./openPullRequestLink";10} 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
11describe("openPullRequestLink", () => {30describe("openPullRequestLink", () => {
12 it("opens the requested pull request URL", async () => {31 it("opens the requested pull request URL", async () => {
13 const openExternal = vi.fn(async () => undefined);32 const openExternal = vi.fn(async () => undefined);
apps/web/src/lib/openPullRequestLink.ts+17
...@@ -118,6 +118,23 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu...@@ -118,6 +118,23 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
118 return null;118 return null;
119}119}
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
121function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {138function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null {
122 const repository = match?.[1];139 const repository = match?.[1];
123 const number = Number(match?.[2]);140 const number = Number(match?.[2]);
apps/web/src/routes/_chat.pull-requests.tsx+59-48
...@@ -74,6 +74,9 @@ import {...@@ -74,6 +74,9 @@ import {
74 WorkspaceBreadcrumbItem,74 WorkspaceBreadcrumbItem,
75 WorkspaceBreadcrumbSeparator,75 WorkspaceBreadcrumbSeparator,
76} from "../components/WorkspaceBreadcrumb";76} from "../components/WorkspaceBreadcrumb";
77import { WorkspacePageContainer } from "../components/WorkspacePageContainer";
78import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
79import { isElectron } from "../env";
77import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";80import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";
78import { Button } from "../components/ui/button";81import { Button } from "../components/ui/button";
79import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";82import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
...@@ -100,7 +103,6 @@ import {...@@ -100,7 +103,6 @@ import {
100import { useAtomCommand } from "../state/use-atom-command";103import { useAtomCommand } from "../state/use-atom-command";
101import { cn } from "~/lib/utils";104import { cn } from "~/lib/utils";
102import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";105import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
103import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
104106
105export interface PullRequestsSearch {107export interface PullRequestsSearch {
106 readonly involvement: PullRequestInvolvement;108 readonly involvement: PullRequestInvolvement;
...@@ -1181,6 +1183,7 @@ function PullRequestsRouteView() {...@@ -1181,6 +1183,7 @@ function PullRequestsRouteView() {
1181 : null,1183 : null,
1182 [search.number, search.repository, selectedProject],1184 [search.number, search.repository, selectedProject],
1183 );1185 );
1186 const rightPanelAvailable = selectedPullRequestSurface !== null;
1184 useEffect(() => {1187 useEffect(() => {
1185 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;1188 if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return;
1186 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);1189 useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection);
...@@ -1294,9 +1297,10 @@ function PullRequestsRouteView() {...@@ -1294,9 +1297,10 @@ function PullRequestsRouteView() {
1294 terminalAvailable={false}1297 terminalAvailable={false}
1295 terminalOpen={false}1298 terminalOpen={false}
1296 terminalShortcutLabel={null}1299 terminalShortcutLabel={null}
1297 rightPanelAvailable={rightPanelState.surfaces.length > 0}1300 rightPanelAvailable={rightPanelAvailable}
1298 rightPanelOpen={rightPanelState.isOpen}1301 rightPanelOpen={rightPanelState.isOpen}
1299 rightPanelShortcutLabel={null}1302 rightPanelShortcutLabel={null}
1303 rightPanelUnavailableLabel="Select a pull request first"
1300 liveAgentCount={0}1304 liveAgentCount={0}
1301 onToggleTerminal={() => undefined}1305 onToggleTerminal={() => undefined}
1302 onToggleRightPanel={toggleRightPanel}1306 onToggleRightPanel={toggleRightPanel}
...@@ -1603,7 +1607,6 @@ function PullRequestsRouteView() {...@@ -1603,7 +1607,6 @@ function PullRequestsRouteView() {
1603 reviewingQuery.refresh();1607 reviewingQuery.refresh();
1604 }}1608 }}
1605 onStateChange={handlePullRequestTabStatusChange}1609 onStateChange={handlePullRequestTabStatusChange}
1606 chromeVariant="collapse"
1607 />1610 />
1608 </RightPanelTabs>1611 </RightPanelTabs>
1609 ) : null}1612 ) : null}
...@@ -1612,10 +1615,7 @@ function PullRequestsRouteView() {...@@ -1612,10 +1615,7 @@ function PullRequestsRouteView() {
1612 );1615 );
1613}1616}
16141617
1615/**1618/** A compact stand-in for one pill group when the header is narrow. */
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 */
1619function CompactFilterMenu<Value extends string>({1619function CompactFilterMenu<Value extends string>({
1620 label,1620 label,
1621 value,1621 value,
...@@ -1627,7 +1627,8 @@ function CompactFilterMenu<Value extends string>({...@@ -1627,7 +1627,8 @@ function CompactFilterMenu<Value extends string>({
1627 options: ReadonlyArray<PullRequestFilterOption<Value>>;1627 options: ReadonlyArray<PullRequestFilterOption<Value>>;
1628 onChange: (value: Value) => void;1628 onChange: (value: Value) => void;
1629}) {1629}) {
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;
1631 return (1632 return (
1632 <Menu>1633 <Menu>
1633 <MenuTrigger1634 <MenuTrigger
...@@ -1640,15 +1641,12 @@ function CompactFilterMenu<Value extends string>({...@@ -1640,15 +1641,12 @@ function CompactFilterMenu<Value extends string>({
1640 <MenuPopup align="start" side="bottom" className="min-w-40">1641 <MenuPopup align="start" side="bottom" className="min-w-40">
1641 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>1642 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>
1642 {options.map((option) => {1643 {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.
1646 const item = (1644 const item = (
1647 <MenuRadioItem1645 <MenuRadioItem
1648 key={option.value}1646 key={option.value}
1649 value={option.value}1647 value={option.value}
1650 className={option.unavailable ? "data-disabled:pointer-events-auto" : undefined}
1651 disabled={option.unavailable !== undefined}1648 disabled={option.unavailable !== undefined}
1649 className="data-disabled:pointer-events-auto"
1652 >1650 >
1653 <span className="flex min-w-0 items-center gap-2">1651 <span className="flex min-w-0 items-center gap-2">
1654 <option.Icon aria-hidden className="size-3.5" />1652 <option.Icon aria-hidden className="size-3.5" />
...@@ -1656,11 +1654,12 @@ function CompactFilterMenu<Value extends string>({...@@ -1656,11 +1654,12 @@ function CompactFilterMenu<Value extends string>({
1656 </span>1654 </span>
1657 </MenuRadioItem>1655 </MenuRadioItem>
1658 );1656 );
1659 if (!option.unavailable) return item;1657 return option.unavailable === undefined ? (
1660 return (1658 item
1659 ) : (
1661 <Tooltip key={option.value}>1660 <Tooltip key={option.value}>
1662 <TooltipTrigger render={item} />1661 <TooltipTrigger render={item} />
1663 <TooltipPopup side="top" className="max-w-80">1662 <TooltipPopup side="right" className="max-w-64 break-words">
1664 {option.unavailable}1663 {option.unavailable}
1665 </TooltipPopup>1664 </TooltipPopup>
1666 </Tooltip>1665 </Tooltip>
...@@ -1838,18 +1837,10 @@ function PullRequestsColumn({...@@ -1838,18 +1837,10 @@ function PullRequestsColumn({
1838 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a1837 // Painted flat like the chat column: the inset underneath carries the chrome grain, and a
1839 // content surface that lets it show reads as a different background than every thread.1838 // content surface that lets it show reads as a different background than every thread.
1840 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">1839 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
1841 <header1840 {/* A closed right panel leaves this column full-width, so the shared header
1842 className={cn(1841 reserves native window controls. While the panel is open, the column ends
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",1842 at the panel and the absolute controls strip owns the top-right corner. */}
1844 // A closed right panel leaves this column full-width, so its header runs1843 <WorkspacePageHeader electron={isElectron} reserveNativeControls={!rightPanelOpen}>
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 >
1853 {condensed ? (1844 {condensed ? (
1854 <WorkspaceBreadcrumb ariaLabel="Pull request scope">1845 <WorkspaceBreadcrumb ariaLabel="Pull request scope">
1855 {/* The page name remains the foreground anchor in both states; the live filters are1846 {/* The page name remains the foreground anchor in both states; the live filters are
...@@ -1891,27 +1882,22 @@ function PullRequestsColumn({...@@ -1891,27 +1882,22 @@ function PullRequestsColumn({
1891 )}1882 )}
1892 <div className="min-w-0 flex-1" />1883 <div className="min-w-0 flex-1" />
1893 {condensed ? (1884 {condensed ? (
1894 <ExpandableSearch1885 <div className="flex shrink-0 items-center gap-1.5">
1895 searchInput={searchInput}1886 <ExpandableSearch
1896 searchValue={searchValue}1887 searchInput={searchInput}
1897 open={searchOpen}1888 searchValue={searchValue}
1898 onOpenChange={setSearchOpen}1889 open={searchOpen}
1899 focusToken={searchFocusToken}1890 onOpenChange={setSearchOpen}
1900 onFocusWithin={(focused) => {1891 focusToken={searchFocusToken}
1901 topbarSearchFocusedRef.current = focused;1892 onFocusWithin={(focused) => {
1902 }}1893 topbarSearchFocusedRef.current = focused;
1903 />1894 }}
1895 />
1896 <PullRequestRefreshControl compact refreshing={refreshing} onRefresh={onRefresh} />
1897 </div>
1904 ) : null}1898 ) : 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>
1913 {rightPanelControl}1899 {rightPanelControl}
1914 </header>1900 </WorkspacePageHeader>
19151901
1916 <div1902 <div
1917 ref={scrollRef}1903 ref={scrollRef}
...@@ -1920,19 +1906,44 @@ function PullRequestsColumn({...@@ -1920,19 +1906,44 @@ function PullRequestsColumn({
1920 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the1906 {/* The top padding is the fade band's own height (1.5rem here), the same pairing the
1921 settings page makes: at rest the controls sit fully below the mask, and only1907 settings page makes: at rest the controls sit fully below the mask, and only
1922 content actually passing under the chrome fades. */}1908 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">
1924 <div className="flex flex-col gap-3">1910 <div className="flex flex-col gap-3">
1925 <div ref={inFlowSearchRef} className="flex items-center gap-2">1911 <div ref={inFlowSearchRef} className="flex items-center gap-2">
1926 {searchInput}1912 {searchInput}
1927 {filtersMenu}1913 {filtersMenu}
1914 {!condensed ? (
1915 <PullRequestRefreshControl refreshing={refreshing} onRefresh={onRefresh} />
1916 ) : null}
1928 </div>1917 </div>
1929 {/* Scrolled past this marker, the controls are gone and the title takes over. */}1918 {/* Scrolled past this marker, the controls are gone and the title takes over. */}
1930 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />1919 <div ref={markerRef} aria-hidden className="-mt-3 h-px w-full" />
1931 </div>1920 </div>
19321921
1933 {listBody}1922 {listBody}
1934 </div>1923 </WorkspacePageContainer>
1935 </div>1924 </div>
1936 </div>1925 </div>
1937 );1926 );
1938}1927}
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}