| ... | @@ -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 { |
| 50 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 51 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 51 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; | 52 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 52 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 53 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| | 54 | import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; |
| 53 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; | 55 | import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; |
| 54 | import { cn } from "~/lib/utils"; | 56 | import { cn } from "~/lib/utils"; |
| 55 | import { readLocalApi } from "~/localApi"; | 57 | import { readLocalApi } from "~/localApi"; |
| ... | @@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query"; | ... | @@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query"; |
| 60 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 62 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 61 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 63 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 62 | import { useAtomCommand } from "~/state/use-atom-command"; | 64 | import { useAtomCommand } from "~/state/use-atom-command"; |
| | 65 | import { vcsEnvironment } from "~/state/vcs"; |
| 63 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 66 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| 64 | | 67 | |
| 65 | import { | 68 | import { |
| ... | @@ -74,6 +77,7 @@ import { | ... | @@ -74,6 +77,7 @@ import { |
| 74 | import { Badge } from "../ui/badge"; | 77 | import { Badge } from "../ui/badge"; |
| 75 | import { Button } from "../ui/button"; | 78 | import { Button } from "../ui/button"; |
| 76 | import { Input } from "../ui/input"; | 79 | import { Input } from "../ui/input"; |
| | 80 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 77 | import { | 81 | import { |
| 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"; |
| 122 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; | 127 | import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; |
| 123 | import { | 128 | import { |
| | 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-offs | 403 | * 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 scroll | 438 | // 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 be | 469 | // 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 goes | 1053 | // 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 | ? null | 1057 | ? null |
| 1054 | : detail.isDraft && can("ready") | 1058 | : conflicting |
| 1055 | ? "ready" | 1059 | ? "resolve" |
| 1056 | : !can("merge") | 1060 | : detail.isDraft && can("ready") |
| 1057 | ? null | 1061 | ? "ready" |
| 1058 | : conflicting | 1062 | : !can("merge") |
| 1059 | ? "resolve" | 1063 | ? null |
| 1060 | : allowedMergeMethods.length > 0 | 1064 | : 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 mirrors | 1087 | // 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 = detail | 1089 | 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 | ).length | 1105 | ).length |
| 1102 | : 0; | 1106 | : 0; |
| 1103 | | 1107 | |
| | 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 rows | 1115 | <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 | <div | 1116 | <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 | condensed | 1121 | 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 | <TooltipTrigger | 1129 | <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 | <TooltipTrigger | 1151 | <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 | condensed | 1177 | 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 | <MenuTrigger | 1312 | <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 link | 1453 | 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> |
| 1493 | | 1491 | |
| 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 | <div | 1492 | <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 | condensed | 1495 | 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 | <div | 1500 | <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 | condensed | 1504 | 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 | <button | 1514 | <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.value | 1522 | > |
| 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 | <PullRequestDiffStat | 1589 | </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> |
| 1577 | | 1594 | |
| 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 | <div | 1595 | <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 the | 1598 | // Collapse before the scroll refund paints; only reopening eases back in. Animating |
| 1585 | // content pinned would fight an animated track frame by frame. Reopening happens | 1599 | // 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 | condensed | 1600 | 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 | <div | 1605 | <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 | condensed | 1609 | 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> |
| 1672 | | 1681 | |
| 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 | <TooltipTrigger | 1684 | <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 | <PullRequestBaseFreshnessWarning | 1694 | <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 | <TooltipTrigger | 1719 | <button |
| 1695 | render={ | 1720 | type="button" |
| 1696 | <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" |
| 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 | Copied | 1729 | <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> |
| 1743 | | 1769 | |
| 1744 | {detail && conflicting ? ( | 1770 | {detail ? ( |
| 1745 | <div className="col-span-2 flex items-center gap-1 px-4 pb-3"> | 1771 | <nav |
| 1746 | <Badge | 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" |
| 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 conflicts | 1777 | variant="segmented" |
| 1752 | </Badge> | 1778 | value={[tab]} |
| 1753 | <Button | 1779 | 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" ? ( |
| 1765 | | 1791 | <span |
| 1766 | {detail ? ( | 1792 | className="ml-auto inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground" |
| 1767 | <nav | 1793 | 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 | <button | 1796 | <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.value | 1803 | <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 | <span | 1827 | <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 reported | 1837 | <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 | <span | 1851 | <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 | activityError | 1854 | {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 a | 1856 | </span> |
| 1844 | verdict of its own on a change nobody has looked at yet. */} | 1857 | ) : null} |
| 1845 | {approvalCount > 0 ? ( | 1858 | </PullRequestMetaLine> |
| 1846 | <span | 1859 | <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 is | 1867 | } |
| 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 | <Button | 1875 | </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> |
| 1885 | | 1880 | |
| 1886 | <div | 1881 | <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 the | 1890 | // 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 | <> |