1import {
2 mergebotPullRequestUrl,
3 mergebotPullRequestBrowserThreadId,
4 ThreadId,
5} from "@t3tools/contracts";
6import { MergebotQueueBadge } from "./MergebotQueueBadge";
7import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
8import { useAtomValue } from "@effect/atom-react";
9import { usePullRequestStack } from "~/state/usePullRequestStack";
10import { RefreshIcon } from "~/components/ui/refresh-icon";
11import { AnimatedWidth } from "~/components/AnimatedWidth";
12import {
13 scopedThreadKey,
14 scopeProjectRef,
15 scopeThreadRef,
16} from "@t3tools/client-runtime/environment";
17import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
18import {
19 AuthOrchestrationOperateScope,
20 AuthSourceControlWriteScope,
21 type EnvironmentId,
22 type PullRequestAction,
23 type PullRequestMergeMethod,
24 type PullRequestListEntry,
25 type PullRequestUpdateMethod,
26 type PullRequestRef,
27 type PullRequestReviewGuideStop,
28 resolveEnvironmentMachineKind,
29 type ScopedThreadRef,
30} from "@t3tools/contracts";
31import {
32 ArrowDownUpIcon,
33 ArrowLeftIcon,
34 ArrowUpRightIcon,
35 BookOpenIcon,
36 CheckIcon,
37 CircleDotIcon,
38 CopyIcon,
39 ChevronDownIcon,
40 ExternalLinkIcon,
41 FileDiffIcon,
42 FolderGit2Icon,
43 GitBranchIcon,
44 GitCommitHorizontalIcon,
45 HammerIcon,
46 MessageCircleQuestionIcon,
47 MessageSquareIcon,
48 LinkIcon,
49 MoreHorizontalIcon,
50 PanelRightIcon,
51 PlayIcon,
52 RotateCcwIcon,
53 SparklesIcon,
54 Trash2Icon,
55 TriangleAlertIcon,
56} from "lucide-react";
57import {
58 lazy,
59 Suspense,
60 type MouseEvent as ReactMouseEvent,
61 useCallback,
62 useEffect,
63 useEffectEvent,
64 useLayoutEffect,
65 useMemo,
66 useRef,
67 useState,
68 type ReactNode,
69} from "react";
70
71import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
72import { useRightPanelStore } from "~/rightPanelStore";
73import { useEnvironmentSettings } from "~/hooks/useSettings";
74import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
75import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
76import { isCommandPaletteOpen } from "~/commandPaletteBus";
77import {
78 resolveShortcutCommand,
79 shortcutLabelForCommand,
80 type ShortcutMatchContext,
81} from "~/keybindings";
82import { primaryServerKeybindingsAtom } from "~/state/server";
83import { usePullRequestDefaultMergeMethodResolver } from "./usePullRequestActions";
84import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink";
85import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";
86import { cn } from "~/lib/utils";
87import { readLocalApi } from "~/localApi";
88import type { ReviewCommentContext } from "~/reviewCommentContext";
89import { useProjects, useServerConfigs } from "~/state/entities";
90import { useEnvironments } from "~/state/environments";
91import { useEnvironmentQuery } from "~/state/query";
92import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
93import { useLiveRefresh } from "~/hooks/useLiveRefresh";
94import {
95 pullRequestEnvironment,
96 pullRequestListEntryToSummary,
97 newestPullRequestSummary,
98 usePullRequestTurnRefresh,
99 useSharedPullRequestSummary,
100} from "~/state/pullRequests";
101import { useAtomCommand } from "~/state/use-atom-command";
102import { PullRequestStackMenu } from "./PullRequestStackMenu";
103import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
104import { PullRequestThreadLinks } from "./PullRequestThreadLinks";
105import { vcsEnvironment } from "~/state/vcs";
106import { formatRelativeTimeLabel } from "~/timestampFormat";
107import { useUiStateStore } from "~/uiStateStore";
108
109import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
110import { Badge } from "../ui/badge";
111import { Button } from "../ui/button";
112import { PullRequestEditButton } from "./PullRequestEditButton";
113import { Input } from "../ui/input";
114import {
115 Menu,
116 MenuItem,
117 MenuPopup,
118 MenuRadioGroup,
119 MenuRadioItem,
120 MenuSeparator,
121 MenuShortcut,
122 MenuTrigger,
123} from "../ui/menu";
124import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
125import { toastManager } from "../ui/toast";
126import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
127import { MiddleTruncate } from "../ui/middle-truncate";
128import {
129 PullRequestChecksStatusLine,
130 PullRequestDetailHeaderBody,
131 PullRequestDetailTabBar,
132 PullRequestDetailTitleRow,
133} from "./PullRequestDetailLayout";
134import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
135import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
136import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
137import { DiffPanelLoadingState } from "../DiffPanelShell";
138import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
139import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";
140import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
141import { PullRequestMarkdownContext } from "./PullRequestMarkdown";
142import { PullRequestComposer } from "./PullRequestComposer";
143import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
144import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
145import {
146 buildAddSelectionToAgentHandoff,
147 buildAskAboutGuideStopHandoff,
148 buildAskAboutPullRequestHandoff,
149 buildExplainPullRequestHandoff,
150 buildFixFindingHandoff,
151 buildFixFindingsHandoff,
152 buildResolveConflictsPrompt,
153 handoffPrompt,
154 handoffReviewComments,
155 latestPullRequestReviewOutcomes,
156 loadingPullRequestCheckoutCommand,
157 isPullRequestNotFound,
158 isStackedPullRequestBase,
159 pullRequestActionMenuHasGroup,
160 pullRequestActionNeedsHostRefresh,
161 pullRequestCheckoutCommand,
162 pullRequestFindingKey,
163 pullRequestHandoffLabels,
164 PULL_REQUEST_MERGE_METHOD_LABELS,
165 readableFailure,
166 readPullRequestDetailSnapshot,
167 resolvePullRequestReferenceHost,
168 resolveDisplayedPullRequestDetail,
169 resolvePullRequestPrimaryControl,
170 allowsSinglePullRequestMerge,
171 canDeletePullRequestBranch,
172 resolveBaseFreshness,
173 resolvePullRequestMergeMethod,
174 type PullRequestFinding,
175 shouldRefreshPullRequestActivity,
176 stripPullRequestHandoffReferences,
177 writePullRequestDetailSnapshot,
178} from "./pullRequestDetail.logic";
179import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic";
180import {
181 resolvePickableEnvironments,
182 type PickableEnvironment,
183} from "./pullRequestProjectAssignment.logic";
184import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
185import {
186 PullRequestActorLabel,
187 PullRequestDiffStat,
188 PullRequestMetaLine,
189 PullRequestReviewOutcomeIcon,
190 pullRequestChecksState,
191 pullRequestChecksStatePresentation,
192 pullRequestReviewOutcomeToneClassName,
193 resolvePullRequestState,
194 summarizePullRequestChecks,
195} from "./pullRequestPresentation";
196import { PullRequestGlyph } from "./pullRequestIcons";
197import { PullRequestGuideTab } from "./PullRequestGuideTab";
198import { guideRunKey, useGuideRunStore } from "./guide/guideRunStore";
199import { useReviewGuideWriter } from "./guide/useReviewGuideWriter";
200import { useInlineActionConfirm } from "./useInlineActionConfirm";
201
202type DetailTab = "summary" | "guide" | "timeline" | "code";
203
204/** Said as the thing that did not happen, rather than as the operation that returned an error. */
205const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = {
206 merge: "Could not merge this pull request",
207 ready: "Could not mark this ready for review",
208 draft: "Could not convert this to a draft",
209 close: "Could not close this pull request",
210 reopen: "Could not reopen this pull request",
211 "update-branch": "Could not update this branch",
212 "enable-auto-merge": "Could not turn on auto-merge",
213 "disable-auto-merge": "Could not turn off auto-merge",
214 revert: "Could not open a revert pull request",
215 "delete-branch": "Could not delete this branch",
216 "approve-workflows": "Could not approve workflows",
217};
218
219/** What to try, for the times the host says only that it refused. */
220const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = {
221 merge:
222 "The host refused the merge. Check that you have write access, that the checks it requires have passed, and that the branch is not conflicting.",
223 ready: "The host refused it. Check that you have write access to this repository.",
224 draft: "The host refused it. Check that you have write access to this repository.",
225 close: "The host refused it. Check that you have write access, or that you opened it.",
226 reopen:
227 "The host refused it. Check that you have write access, and that the branch still exists.",
228 // Said for the merge commit, which is what an update is unless a rebase was asked for. The
229 // rebase has its own reasons to fail and its own sentence below.
230 "update-branch":
231 "The host refused it. Check that you have write access to the branch — one from a fork also needs its author to allow edits from maintainers — and that it does not conflict with the base.",
232 // The one refusal that is usually a repository setting rather than anything about this branch:
233 // GitHub will not arm an auto-merge at all unless the repository has the feature switched on.
234 "enable-auto-merge":
235 "The host refused it. Check that this repository allows auto-merge, that you have write access, and that there is something left for it to wait on.",
236 "disable-auto-merge":
237 "The host refused it. Check that you have write access, and that the merge has not already happened.",
238 revert:
239 "The host refused it. Check that you have write access and that this pull request was merged on the host.",
240 "delete-branch":
241 "The host refused it. Check that you have write access to the source repository and that the branch has not moved since this page loaded.",
242 "approve-workflows":
243 "The host refused it. Check that you have Actions write access and that these workflow runs are still awaiting approval.",
244};
245
246/**
247 * Said instead of the update hint when the reader asked for a rebase: it is the one that fails on
248 * its own merits, because GitHub replays the commits and stops at the first that does not apply.
249 * Offering the merge commit only makes sense to somebody who did not already choose it.
250 */
251const UPDATE_BRANCH_REBASE_FAILURE_HINT =
252 "The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work.";
253
254const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [
255 { value: "summary", label: "Summary" },
256 { value: "guide", label: "Guide" },
257 { value: "timeline", label: "Timeline" },
258 { value: "code", label: "Code" },
259];
260
261// The diff viewer pulls in its worker pool, so load it only when the reader approaches Code.
262// Start the download on tab hover or focus, before the click, without loading it for every PR.
263const loadCodeTab = () => import("./PullRequestCodeTab");
264const PullRequestCodeTab = lazy(loadCodeTab);
265
266/**
267 * What the last hand-off wrote into each draft, kept outside React because the panel that wrote it
268 * is closed by the time the next one opens. It is how a prompt the reader has since edited is told
269 * apart from the one they were handed: only the sentence still exactly as written may be replaced.
270 */
271const lastHandoffPromptByDraft = new Map<string, string>();
272
273const composerTargetKey = (target: ScopedThreadRef | DraftId): string =>
274 typeof target === "string" ? target : scopedThreadKey(target);
275
276/**
277 * Which server the checkout and the hand-offs land on, where more than one of them holds this
278 * repository. The list picked one of them to show the pull request under, so that everything on
279 * it is read from somewhere; where the reader wants to work is a separate answer, and this is
280 * where they give it.
281 */
282function ActOnEnvironmentPicker({
283 environments,
284 value,
285 onChange,
286 disabled,
287}: {
288 environments: ReadonlyArray<PickableEnvironment>;
289 value: EnvironmentId;
290 onChange: (environmentId: EnvironmentId) => void;
291 disabled: boolean;
292}) {
293 return (
294 <>
295 <MenuSeparator />
296 <MenuRadioGroup
297 value={value}
298 onValueChange={(environmentId) => onChange(environmentId as EnvironmentId)}
299 >
300 {environments.map((environment) => (
301 <MenuRadioItem
302 key={environment.environmentId}
303 value={environment.environmentId}
304 disabled={disabled}
305 >
306 {/* The radio item lays its children out as one block, so the icon and the label
307 need their own row to share a line. */}
308 <span className="flex min-w-0 items-center gap-2">
309 <EnvironmentMachineIcon
310 kind={environment.machine ?? "server"}
311 className="size-3.5 shrink-0"
312 />
313 <span className="truncate">{environment.label}</span>
314 </span>
315 </MenuRadioItem>
316 ))}
317 </MenuRadioGroup>
318 </>
319 );
320}
321
322/** The number is a link in every place the host writes it, so the right-click that copies one
323 has to answer here too — otherwise the platform's own cut/paste menu opens over it. */
324const openNumberContextMenu = (
325 event: ReactMouseEvent,
326 detail: { readonly url: string; readonly provider: string },
327): void => {
328 event.preventDefault();
329 event.stopPropagation();
330 void showPullRequestLinkContextMenu({
331 url: detail.url,
332 openLabel: openOnHostLabel(sourceControlClients.get(detail.provider)),
333 position: { x: event.clientX, y: event.clientY },
334 });
335};
336
337/**
338 * The stale-branch warning, said beside the branch it is about rather than as a bar of its own.
339 * The banner this replaces held a row of chrome open across the top of every pull request that
340 * had fallen behind, pushing the reading down to say something that is true of the base branch
341 * and nothing else; as a mark on the base branch it is where a reader would look for it, and the
342 * sentence and the way out of it arrive together the moment the mark is pointed at.
343 *
344 * A popover rather than a tooltip because what it holds can be pressed: a tooltip's layer takes
345 * no pointer, and a control nobody can reach is worse than no control.
346 */
347function PullRequestBaseFreshnessWarning({
348 baseBranch,
349 freshness,
350 pending,
351 onUpdate,
352 iconClassName,
353 className,
354 children,
355}: {
356 readonly baseBranch: string;
357 readonly freshness: {
358 readonly behindBy: number | null;
359 readonly methods: ReadonlyArray<PullRequestUpdateMethod>;
360 };
361 readonly pending: boolean;
362 readonly onUpdate: (method: PullRequestUpdateMethod) => void;
363 readonly iconClassName?: string;
364 readonly className?: string;
365 /** What the warning is about, drawn in the same amber before the mark: the base branch. */
366 readonly children?: ReactNode;
367}) {
368 const behind =
369 freshness.behindBy === null
370 ? ""
371 : ` by ${freshness.behindBy.toLocaleString()} ${
372 freshness.behindBy === 1 ? "commit" : "commits"
373 }`;
374 const summary = `This branch is out-of-date with ${baseBranch}${behind}.`;
375 return (
376 <Popover>
377 <PopoverTrigger
378 openOnHover
379 delay={0}
380 closeDelay={120}
381 render={
382 <button
383 type="button"
384 aria-label={summary}
385 className={cn(
386 "inline-flex min-w-0 shrink-0 cursor-help items-center gap-1 rounded-sm text-warning-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
387 className,
388 )}
389 />
390 }
391 >
392 {children}
393 <TriangleAlertIcon aria-hidden className={cn("size-3.5 shrink-0", iconClassName)} />
394 </PopoverTrigger>
395 <PopoverPopup align="start" side="bottom" className="max-w-80" padding="compact">
396 <p className="text-xs text-foreground">{summary}</p>
397 <p className="mt-0.5 text-xs text-muted-foreground">Changes can be cleanly merged.</p>
398 {/* Each way the host offers and this reader may take, as its own button: a split button
399 would need a menu inside a popover, and two buttons say the same thing in one layer. */}
400 {freshness.methods.length > 0 ? (
401 <span className="mt-2 flex flex-wrap items-center gap-1.5">
402 {freshness.methods.map((method) => (
403 <Button
404 key={method}
405 size="xs"
406 variant="outline"
407 disabled={pending}
408 onClick={() => onUpdate(method)}
409 >
410 <PullRequestGlyph.merged aria-hidden className="size-3" />
411 {method === "rebase" ? "Update with rebase" : "Update branch"}
412 </Button>
413 ))}
414 </span>
415 ) : null}
416 </PopoverPopup>
417 </Popover>
418 );
419}
420
421const MergebotBrowserPanel = lazy(() =>
422 import("./MergebotBrowserPanel").then((module) => ({ default: module.MergebotBrowserPanel })),
423);
424
425export function PullRequestDetailPanel(props: Parameters<typeof NativePullRequestDetailPanel>[0]) {
426 const configurations = useServerConfigs();
427 const projects = useProjects();
428 const project = projects.find(
429 (candidate) =>
430 candidate.id === props.reference.projectId && candidate.environmentId === props.environmentId,
431 );
432 const reference = resolvePullRequestReferenceHost(props.reference, project?.repositoryIdentity);
433 const url = mergebotPullRequestUrl(configurations.get(props.environmentId)?.mergebot, reference);
434 return url ? (
435 <div className="flex h-full min-h-0 flex-col bg-background">
436 {props.onBack || props.onClose ? (
437 <div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1">
438 {props.onBack ? (
439 <Button
440 size="icon-xs"
441 variant="ghost"
442 aria-label="Back to this thread's pull requests"
443 onClick={props.onBack}
444 >
445 <ArrowLeftIcon aria-hidden className="size-3.5" />
446 </Button>
447 ) : null}
448 {props.onClose ? (
449 <div className="ml-auto">
450 <Button
451 size="icon-xs"
452 variant="ghost"
453 aria-label="Collapse pull request panel"
454 onClick={props.onClose}
455 >
456 <PanelRightIcon aria-hidden className="size-3.5" />
457 </Button>
458 </div>
459 ) : null}
460 </div>
461 ) : null}
462 <Suspense fallback={<div role="status">Opening Mergebot…</div>}>
463 <MergebotBrowserPanel
464 key={`${props.environmentId}:${url}`}
465 threadRef={
466 props.threadRef ??
467 scopeThreadRef(
468 props.environmentId,
469 ThreadId.make(mergebotPullRequestBrowserThreadId(reference)),
470 )
471 }
472 url={url}
473 {...(props.onMergebotReady ? { onReady: props.onMergebotReady } : {})}
474 />
475 </Suspense>
476 </div>
477 ) : (
478 <NativePullRequestDetailPanel {...props} />
479 );
480}
481
482function NativePullRequestDetailPanel({
483 environmentId,
484 shortcutsEnabled,
485 getShortcutContext,
486 threadRef = null,
487 reference: requestedReference,
488 listEntry = null,
489 refreshToken: forcedRefreshToken = 0,
490 onActed,
491 onClose,
492 context = "page",
493 composerDraftTarget,
494 onBack,
495 onSelectPullRequest,
496 onGuideShown,
497}: {
498 environmentId: EnvironmentId;
499 shortcutsEnabled: boolean;
500 getShortcutContext: () => ShortcutMatchContext;
501 onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined;
502 onMergebotReady?: ((tabId: string) => boolean) | undefined;
503 /**
504 * The thread this panel sits beside, if any. Links that are not the pull
505 * request itself (check details, host permalinks) can open in that thread's
506 * in-app browser when the user has asked for it; the page has no thread, so
507 * there they always go to the system browser.
508 */
509 threadRef?: ScopedThreadRef | null;
510 reference: PullRequestRef;
511 /** Row fields already loaded by the pull-request list, used while richer detail arrives. */
512 listEntry?: PullRequestListEntry | null;
513 /**
514 * Bumped by whatever holds the panel when a reader asks for everything on screen to be read
515 * again. The panel owns its own reads, so the page cannot refresh them for it — it says when,
516 * and this says it.
517 */
518 refreshToken?: number;
519 /**
520 * An action changed this pull request on the host, so a list showing it is now out of date.
521 * Told rather than assumed: only the page knows whether it is showing one.
522 */
523 /**
524 * Each host action as it goes: "sent" the moment it leaves, so a list can answer before the
525 * host does; "done" or "failed" when the host has spoken. Undefined for one the caller cannot
526 * name, which is only ever "done".
527 */
528 onActed?: (action?: PullRequestAction, phase?: "sent" | "done" | "failed") => void;
529 /** Page-owned detail columns use this to clear the selected pull request. */
530 onClose?: () => void;
531 /**
532 * Beside a thread, the checkout affordance disappears: the panel is showing that thread's
533 * own pull request, so the branch is already under the reader's feet — and checking it out
534 * again is at best a no-op and at worst git refusing a branch two checkouts.
535 */
536 context?: "page" | "thread";
537 /** The open thread's composer. */
538 composerDraftTarget?: ScopedThreadRef | DraftId;
539 /**
540 * Beside a thread, the way back to that thread's list of pull requests. The tab strip can
541 * close this surface, but closing is not going back: the reader came from the list and
542 * expects to land on it, with this one still open behind.
543 */
544 onBack?: (() => void) | undefined;
545 /** Told when a written guide comes on screen. The page maximizes its panel for the guide. */
546 onGuideShown?: (() => void) | undefined;
547}) {
548 const environmentConfigs = useServerConfigs();
549 const projects = useProjects();
550 const project = projects.find(
551 (project) =>
552 project.id === requestedReference.projectId && project.environmentId === environmentId,
553 );
554 const repositoryIdentity = project?.repositoryIdentity;
555 const supportsThreadPullRequests =
556 environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true;
557 const reference = useMemo(
558 () =>
559 supportsThreadPullRequests
560 ? resolvePullRequestReferenceHost(requestedReference, repositoryIdentity)
561 : {
562 projectId: requestedReference.projectId,
563 repository: requestedReference.repository,
564 number: requestedReference.number,
565 },
566 [requestedReference, repositoryIdentity, supportsThreadPullRequests],
567 );
568 const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`;
569 const matchingListEntry =
570 listEntry?.projectId === reference.projectId &&
571 listEntry.repository.toLowerCase() === reference.repository.toLowerCase() &&
572 (reference.host === undefined ||
573 listEntry.host.toLowerCase() === reference.host.toLowerCase()) &&
574 listEntry.number === reference.number
575 ? listEntry
576 : null;
577 const [threadPickerOpen, setThreadPickerOpen] = useState(false);
578 // Counted up by the guide's last stop to open the composer on the review.
579 const [openReviewRequest, setOpenReviewRequest] = useState(0);
580 const [tab, setTab] = useState<DetailTab>("summary");
581 const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest");
582 const [codeCommitScope, setCodeCommitScope] = useState<{
583 readonly pullRequestKey: string;
584 readonly oid: string | null;
585 }>(() => ({ pullRequestKey, oid: null }));
586 const selectedCodeCommitOid =
587 codeCommitScope.pullRequestKey === pullRequestKey ? codeCommitScope.oid : null;
588 const selectCodeCommit = (oid: string | null) => {
589 setCodeCommitScope({ pullRequestKey, oid });
590 };
591 const openCommit = (oid: string) => {
592 selectCodeCommit(oid);
593 setTab("code");
594 };
595 // The guide names files; following one shows it in the whole diff, not a single commit's.
596 const [codeRevealRequest, setCodeRevealRequest] = useState<{
597 readonly path: string;
598 readonly nonce: number;
599 } | null>(null);
600 const openFileInCode = (path: string) => {
601 selectCodeCommit(null);
602 setCodeRevealRequest((previous) => ({ path, nonce: (previous?.nonce ?? 0) + 1 }));
603 void loadCodeTab();
604 setTab("code");
605 };
606 // A guide notice asks for its pull request's Guide tab once the page has opened it.
607 const guideOpenRequested = useGuideRunStore(
608 (state) => state.openRequestKey === guideRunKey(environmentId, reference),
609 );
610 if (guideOpenRequested && tab !== "guide") setTab("guide");
611 useEffect(() => {
612 if (guideOpenRequested) useGuideRunStore.getState().requestOpen(null);
613 }, [guideOpenRequested]);
614 // Every tab the reader has opened stays mounted behind the active one. The diff viewer
615 // always needed this (it virtualizes against its own scroll position); the trace showed the
616 // summary needs it too — a large description re-parses its whole markdown on every return
617 // to the tab. `visibility` keeps boxes, sizes and scroll offsets, and takes hidden content
618 // out of the tab order and the accessibility tree.
619 const tabScopeKey = `${environmentId}:${pullRequestKey}`;
620 const [tabMountState, setTabMountState] = useState(() => ({
621 key: tabScopeKey,
622 tabs: new Set<DetailTab>(["summary"]),
623 }));
624 // A previously visited Code tab must not fetch diffs for every later PR while hidden.
625 const mountedTabs =
626 tabMountState.key === tabScopeKey ? tabMountState.tabs : new Set<DetailTab>([tab]);
627 useEffect(() => {
628 setTabMountState((previous) => {
629 if (previous.key !== tabScopeKey) return { key: tabScopeKey, tabs: new Set([tab]) };
630 if (previous.tabs.has(tab)) return previous;
631 return { key: tabScopeKey, tabs: new Set(previous.tabs).add(tab) };
632 });
633 }, [tab, tabScopeKey]);
634 const [chromeCondensed, setChromeCondensed] = useState(false);
635 // Each mounted tab remembers its own scroll chrome; short tabs cannot scroll to reopen it.
636 const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({});
637 useEffect(() => {
638 setChromeCondensed(chromeStateByTab.current[tab] ?? false);
639 }, [tab]);
640 const condensed = chromeCondensed;
641 const scrollerByTab = useRef<Partial<Record<DetailTab, HTMLDivElement | null>>>({});
642 const scrollerRefs = useMemo(
643 () => ({
644 summary: (node: HTMLDivElement | null) => {
645 scrollerByTab.current.summary = node;
646 },
647 timeline: (node: HTMLDivElement | null) => {
648 scrollerByTab.current.timeline = node;
649 },
650 code: (node: HTMLDivElement | null) => {
651 scrollerByTab.current.code = node;
652 },
653 }),
654 [],
655 );
656 const foldRef = useRef<HTMLDivElement | null>(null);
657 const condensedRowRef = useRef<HTMLDivElement | null>(null);
658 // Refund after the fold commits so the content under the reader does not jump with its height.
659 const compensationRef = useRef<{
660 tab: DetailTab;
661 scroller: HTMLDivElement;
662 delta: number;
663 } | null>(null);
664 useLayoutEffect(() => {
665 const compensation = compensationRef.current;
666 if (compensation === null) return;
667 compensationRef.current = null;
668 if (
669 !condensed ||
670 compensation.tab !== tab ||
671 scrollerByTab.current[tab] !== compensation.scroller
672 )
673 return;
674 compensation.scroller.scrollTop = Math.max(
675 0,
676 compensation.scroller.scrollTop + compensation.delta,
677 );
678 }, [condensed, tab]);
679 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
680 const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod);
681 const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver(
682 environmentId,
683 reference.projectId,
684 );
685 const projectDefaultMergeMethod = useMemo(
686 () => resolveProjectDefaultMergeMethod(),
687 [resolveProjectDefaultMergeMethod],
688 );
689 const [mergeMethodSelection, setMergeMethodSelection] = useState<{
690 readonly pullRequestKey: string;
691 readonly method: PullRequestMergeMethod;
692 } | null>(null);
693 const setMergeMethod = (method: PullRequestMergeMethod) => {
694 setMergeMethodSelection({ pullRequestKey, method });
695 };
696 const inlineConfirm = useInlineActionConfirm(pullRequestKey);
697 // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself
698 // alone. One at a time whatever the key: they all check the same pull request out.
699 const [handoff, setHandoff] = useState<string | null>(null);
700 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
701 const detailQuery = useEnvironmentQuery(
702 pullRequestEnvironment.detail({ environmentId, input: reference }),
703 );
704 const activityQuery = useEnvironmentQuery(
705 pullRequestEnvironment.activity({ environmentId, input: reference }),
706 );
707 const turnRefresh = usePullRequestTurnRefresh(environmentId);
708 const [cachedDetail, setCachedDetail] = useState(() =>
709 readPullRequestDetailSnapshot(
710 typeof window === "undefined" ? undefined : window.localStorage,
711 environmentId,
712 reference,
713 ),
714 );
715 useEffect(() => {
716 setCachedDetail(
717 readPullRequestDetailSnapshot(
718 typeof window === "undefined" ? undefined : window.localStorage,
719 environmentId,
720 reference,
721 ),
722 );
723 }, [environmentId, pullRequestKey, reference.projectId, reference.repository, reference.number]);
724 useEffect(() => {
725 if (detailQuery.data === null) return;
726 writePullRequestDetailSnapshot(
727 typeof window === "undefined" ? undefined : window.localStorage,
728 environmentId,
729 reference,
730 detailQuery.data,
731 );
732 setCachedDetail(detailQuery.data);
733 }, [
734 detailQuery.data,
735 environmentId,
736 pullRequestKey,
737 reference.projectId,
738 reference.repository,
739 reference.number,
740 ]);
741 const resolvedCoreDetail = resolveDisplayedPullRequestDetail({
742 live: detailQuery.data,
743 cached: cachedDetail,
744 reference,
745 });
746 const listSummary = useMemo(
747 () => (matchingListEntry === null ? null : pullRequestListEntryToSummary(matchingListEntry)),
748 [matchingListEntry],
749 );
750 const detailSummary = useMemo(
751 () =>
752 detailQuery.data === null
753 ? null
754 : {
755 ...detailQuery.data,
756 checksState: pullRequestChecksState(detailQuery.data.checks),
757 },
758 [detailQuery.data],
759 );
760 const observedSummary = useSharedPullRequestSummary(
761 environmentId,
762 reference,
763 detailSummary,
764 detailQuery.dataUpdatedAt,
765 );
766 // The list row is also published to the shared cache, but only after this commit's layout
767 // effects run, so it is compared directly rather than trusted to be there already.
768 const sharedSummary = useMemo(
769 () =>
770 newestPullRequestSummary(
771 resolvedCoreDetail,
772 newestPullRequestSummary(observedSummary, listSummary),
773 ),
774 [resolvedCoreDetail, observedSummary, listSummary],
775 );
776 const coreDetail = useMemo(
777 () =>
778 resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail
779 ? resolvedCoreDetail
780 : {
781 ...resolvedCoreDetail,
782 title: sharedSummary.title,
783 state: sharedSummary.state,
784 headBranch: sharedSummary.headBranch,
785 baseBranch: sharedSummary.baseBranch,
786 updatedAt: sharedSummary.updatedAt,
787 author: sharedSummary.author ?? resolvedCoreDetail.author,
788 additions: sharedSummary.additions ?? resolvedCoreDetail.additions,
789 deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions,
790 changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles,
791 mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability,
792 closedAt:
793 sharedSummary.closedAt === undefined
794 ? resolvedCoreDetail.closedAt
795 : sharedSummary.closedAt,
796 mergedAt:
797 sharedSummary.mergedAt === undefined
798 ? resolvedCoreDetail.mergedAt
799 : sharedSummary.mergedAt,
800 // A summary may come from an older server that does not report draft state. Keep the
801 // detail's required value instead of making the complete detail shape partial.
802 isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft,
803 },
804 [resolvedCoreDetail, sharedSummary],
805 );
806 const activity = activityQuery.data;
807 const detail = useMemo(
808 () =>
809 coreDetail === null
810 ? null
811 : {
812 ...coreDetail,
813 capabilities: canWriteSourceControl
814 ? coreDetail.capabilities
815 : {
816 ...coreDetail.capabilities,
817 reactions: false,
818 edit: { changeRequest: false, comment: false },
819 },
820 viewerPermissions: canWriteSourceControl
821 ? coreDetail.viewerPermissions
822 : {
823 ...coreDetail.viewerPermissions,
824 actions: [],
825 comment: false,
826 resolve: false,
827 verdicts: [],
828 requestReviewers: false,
829 updateMethods: [],
830 labels: false,
831 },
832 author: activity?.author ?? coreDetail.author,
833 reviewers: activity?.reviewers ?? coreDetail.reviewers,
834 comments: activity?.comments ?? [],
835 commentCount: activity?.commentCount ?? 0,
836 commentsTruncated: activity?.commentsTruncated ?? false,
837 reviewThreads: activity?.reviewThreads ?? [],
838 commits: activity?.commits ?? [],
839 reactions: activity?.reactions ?? [],
840 },
841 [activity, canWriteSourceControl, coreDetail],
842 );
843 const handoffSummary = detail ?? sharedSummary;
844 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
845 const { copyToClipboard: copyReference } = useCopyToClipboard<string>({
846 target: "pull request reference",
847 onCopy: (label) => toastManager.add({ type: "success", title: `${label} copied` }),
848 onError: (error, label) =>
849 toastManager.add({
850 type: "error",
851 title: `Failed to copy ${label}`,
852 description: error.message,
853 }),
854 });
855 const copyFromShortcut = useEffectEvent((event: KeyboardEvent) => {
856 if (!shortcutsEnabled || event.defaultPrevented || isCommandPaletteOpen()) return;
857 const command = resolveShortcutCommand(event, keybindings, {
858 context: getShortcutContext(),
859 });
860 if (command !== "pullRequest.copyNumber") return;
861 event.preventDefault();
862 event.stopPropagation();
863 if (!event.repeat) copyReference(`#${reference.number}`, "PR number");
864 });
865 useEffect(() => {
866 const onKeyDown = (event: KeyboardEvent) => copyFromShortcut(event);
867 window.addEventListener("keydown", onKeyDown, true);
868 return () => window.removeEventListener("keydown", onKeyDown, true);
869 }, []);
870 useEffect(() => {
871 if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod);
872 }, [detail?.autoMergeMethod, pullRequestKey]);
873 const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url);
874 const hostClient = sourceControlClients.get(detail?.provider);
875 const markdownContext = useMemo(
876 () => ({
877 repositoryUrl:
878 repositoryUrl === null ? null : hostClient.referenceAutolinkRepositoryUrl(repositoryUrl),
879 threadRef,
880 }),
881 [hostClient, repositoryUrl, threadRef],
882 );
883 const authorProfileUrl =
884 detail?.author && repositoryUrl !== null
885 ? hostClient.authorProfileUrl(detail.author.login, repositoryUrl)
886 : null;
887 const checkoutCommand = handoffSummary
888 ? pullRequestCheckoutCommand(
889 handoffSummary.provider,
890 handoffSummary.number,
891 handoffSummary.headBranch,
892 detail?.headRepositoryNameWithOwner,
893 changeRequestRepositoryUrl(handoffSummary.url),
894 )
895 : loadingPullRequestCheckoutCommand(reference, repositoryIdentity);
896 const onCheckoutCommandError = useCallback((error: Error) => {
897 toastManager.add({
898 type: "error",
899 title: "Could not copy checkout command",
900 description: error.message,
901 });
902 }, []);
903 const branchRefsQuery = useEnvironmentQuery(
904 detail === null
905 ? null
906 : vcsEnvironment.listRefs({
907 environmentId,
908 input: {
909 cwd: detail.workspaceRoot,
910 includeMatchingRemoteRefs: true,
911 // listRefs keeps the current ref first and a known default second.
912 limit: 2,
913 },
914 }),
915 );
916 const isStackedPullRequest =
917 detail !== null &&
918 isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []);
919 // The host's own stack, where it keeps one. Only asked for once the detail has landed so a
920 // pull request nobody can read costs one request rather than two.
921 const stackReference = useMemo(
922 () =>
923 detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests
924 ? null
925 : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host },
926 [detail, reference, supportsThreadPullRequests],
927 );
928 const nativeStackQuery = usePullRequestStack(environmentId, stackReference);
929 const nativeStack = nativeStackQuery.data;
930 const supportsStackActions =
931 supportsThreadPullRequests &&
932 detail?.capabilities.stacks === true &&
933 detail.capabilities.stackActions === true &&
934 environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions ===
935 true;
936 const canMergeSinglePullRequest = allowsSinglePullRequestMerge({
937 supportsStackActions,
938 hasStack: nativeStack !== null,
939 stackPending: !nativeStackQuery.isFresh,
940 stackError: nativeStackQuery.error,
941 });
942 const activityPending = activityQuery.isPending && activity === null;
943 const activityError = activity === null ? activityQuery.error : null;
944 const refreshDetail = useCallback(() => {
945 detailQuery.refresh();
946 activityQuery.refresh();
947 nativeStackQuery.refresh();
948 }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]);
949 const [refreshToken, setRefreshToken] = useState(0);
950 const codeRefreshToken = refreshToken + (turnRefresh ?? 0);
951 const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>(
952 null,
953 );
954 useEffect(() => {
955 if (!coreDetail) return;
956 const next = { key: tabScopeKey, updatedAt: coreDetail.updatedAt };
957 if (shouldRefreshPullRequestActivity(activityRevision.current, next)) {
958 // Let an existing read settle before revalidating the new revision. Interrupting a
959 // mutation's activity refresh can leave SWR displaying its previous value.
960 if (activityQuery.isPending) return;
961 activityQuery.refresh();
962 setRefreshToken((token) => token + 1);
963 }
964 activityRevision.current = next;
965 }, [activityQuery.isPending, activityQuery.refresh, coreDetail, tabScopeKey]);
966 // Reuse activity and diff until core detail reports a changed revision. Keyed by
967 // the pull request rather than by the panel, because this one panel shows a different pull
968 // request every time it is opened.
969 useLiveRefresh(
970 () => {
971 detailQuery.refresh();
972 },
973 { key: `pull-request:${environmentId}:${pullRequestKey}` },
974 );
975 // The button, on the other hand, goes around the server's cache rather than through it: it is
976 // the answer for a reader who can see that what they are looking at is behind. The
977 // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run
978 // and at worst answer from it.
979 const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
980 const [isInvalidating, setIsInvalidating] = useState(false);
981 // One word for "the host is being asked again", whichever of the two halves is in flight:
982 // the invalidation round trip, then the detail read it kicks off.
983 const refreshing = isInvalidating || detailQuery.isPending;
984 const refreshFromHost = useCallback(async () => {
985 setIsInvalidating(true);
986 try {
987 await invalidate({ environmentId, input: { reference } });
988 refreshDetail();
989 setRefreshToken((token) => token + 1);
990 } finally {
991 setIsInvalidating(false);
992 }
993 }, [environmentId, invalidate, reference, refreshDetail]);
994 // A refresh asked for by the page: the detail, and through the token below, the diff with it.
995 const appliedForcedToken = useRef(forcedRefreshToken);
996 useEffect(() => {
997 if (appliedForcedToken.current === forcedRefreshToken) return;
998 appliedForcedToken.current = forcedRefreshToken;
999 void refreshFromHost();
1000 }, [forcedRefreshToken, refreshFromHost]);
1001 const runAction = useAtomCommand(pullRequestEnvironment.runAction, {
1002 reportFailure: false,
1003 });
1004 const postComment = useAtomCommand(pullRequestEnvironment.comment, {
1005 reportFailure: false,
1006 });
1007 // Which action is in flight, not merely that one is: every control here is disabled while any
1008 // of them runs, but only the button that was pressed may say what it is doing.
1009 const [pendingAction, setPendingAction] = useState<PullRequestAction | null>(null);
1010 const actionPending = pendingAction !== null;
1011 const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false });
1012 // Scoped to the pull request it was typed against, since this one panel shows a different one
1013 // every time it is opened and a half-written title must not follow it there.
1014 const [titleScope, setTitleScope] = useState<{
1015 readonly pullRequestKey: string;
1016 readonly text: string;
1017 } | null>(null);
1018 const titleDraft = titleScope?.pullRequestKey === pullRequestKey ? titleScope.text : null;
1019 const [titleSaving, setTitleSaving] = useState(false);
1020 const newThread = useNewThreadHandler();
1021 const { environments } = useEnvironments();
1022 const unavailableGitHubUrl = useMemo(() => {
1023 const identity = projects.find(
1024 (project) => project.id === reference.projectId && project.environmentId === environmentId,
1025 )?.repositoryIdentity;
1026 return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number);
1027 }, [environmentId, projects, reference.number, reference.projectId, reference.repository]);
1028 // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and
1029 // the thread is already on one server's copy of the branch.
1030 const pickableEnvironments = useMemo(
1031 () =>
1032 context === "page"
1033 ? resolvePickableEnvironments(
1034 { environmentId, projectId: reference.projectId },
1035 projects,
1036 environments.map((environment) => ({
1037 environmentId: environment.environmentId,
1038 label: environment.label,
1039 machine: resolveEnvironmentMachineKind(environment.serverConfig),
1040 })),
1041 )
1042 : [],
1043 [context, environmentId, environments, projects, reference.projectId],
1044 );
1045 // Which server the reader chose, and only for the pull request they chose it on: this one panel
1046 // shows a different pull request every time it is opened, and the choice does not follow.
1047 const [actingScope, setActingScope] = useState<{
1048 readonly pullRequestKey: string;
1049 readonly environmentId: EnvironmentId;
1050 } | null>(null);
1051 const chosenEnvironmentId =
1052 actingScope?.pullRequestKey === pullRequestKey ? actingScope.environmentId : environmentId;
1053 // Null wherever there is no choice on offer — one server, or a chosen one that has since gone —
1054 // and then the panel's own server and its own checkout are the answer, as they always were.
1055 const acting =
1056 pickableEnvironments.find((entry) => entry.environmentId === chosenEnvironmentId) ?? null;
1057 const actingEnvironmentId = acting?.environmentId ?? environmentId;
1058 const checkoutRoot =
1059 acting?.workspaceRoot ?? detail?.workspaceRoot ?? project?.workspaceRoot ?? null;
1060 const canOperateThread = useEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope);
1061 const prepareThread = usePreparePullRequestThreadAction({
1062 environmentId: actingEnvironmentId,
1063 cwd: checkoutRoot,
1064 });
1065
1066 const finishAction = async (
1067 action: PullRequestAction,
1068 method?: PullRequestMergeMethod,
1069 updateMethod?: PullRequestUpdateMethod,
1070 ) => {
1071 onActed?.(action, "sent");
1072 const result = await runAction({
1073 environmentId,
1074 input: {
1075 ...reference,
1076 action,
1077 ...(method ? { mergeMethod: method } : {}),
1078 ...(updateMethod ? { updateMethod } : {}),
1079 },
1080 });
1081 setPendingAction(null);
1082 if (result._tag === "Failure") {
1083 // The host's own sentence, because it is the only thing that says why. A merge strategy a
1084 // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps
1085 // publishes no per-strategy availability to hide the control with — so "action failed"
1086 // would leave the reader pressing the same button again.
1087 const failure = squashAtomCommandFailure(result);
1088 // The hint stands for what was actually asked for: a reader who pressed Update branch is
1089 // told to check their access, not offered the merge commit they already chose.
1090 const hint =
1091 updateMethod === "rebase"
1092 ? UPDATE_BRANCH_REBASE_FAILURE_HINT
1093 : ACTION_FAILURE_HINTS[action];
1094 toastManager.add({
1095 type: "error",
1096 title: ACTION_FAILURE_LABELS[action],
1097 description: readableFailure(failure, hint),
1098 });
1099 onActed?.(action, "failed");
1100 return false;
1101 }
1102 // Reverting opens another PR; the current panel has no new state to show.
1103 if (action === "revert") {
1104 toastManager.add({ type: "success", title: "Revert pull request opened" });
1105 }
1106 if (action === "delete-branch") {
1107 toastManager.add({ type: "success", title: "Branch deleted" });
1108 }
1109 // A branch update moves the head commit, which leaves the diff atom pointed at a comparison
1110 // that no longer exists — the same staleness the manual refresh button fixes, so it goes
1111 // through that path rather than a second one. Every other action here only changes metadata;
1112 // a merge does move the branch too, but it also closes the pull request, where the diff is
1113 // no longer what anyone is looking at.
1114 if (pullRequestActionNeedsHostRefresh(action)) {
1115 void refreshFromHost();
1116 } else {
1117 refreshDetail();
1118 }
1119 onActed?.(action, "done");
1120 return true;
1121 };
1122
1123 const perform = async (
1124 action: PullRequestAction,
1125 method?: PullRequestMergeMethod,
1126 updateMethod?: PullRequestUpdateMethod,
1127 ) => {
1128 if (!canWriteSourceControl || pendingAction !== null) return false;
1129 setPendingAction(action);
1130 return finishAction(action, method, updateMethod);
1131 };
1132
1133 const performCommentAction = async (body: string, action: "close" | "reopen") => {
1134 if (!canWriteSourceControl || pendingAction !== null) return { commentPosted: false };
1135 setPendingAction(action);
1136 const commentResult = await postComment({
1137 environmentId,
1138 input: { ...reference, body },
1139 });
1140 if (commentResult._tag === "Failure") {
1141 setPendingAction(null);
1142 toastManager.add({ type: "error", title: "Could not post the comment" });
1143 return { commentPosted: false };
1144 }
1145 const actionSucceeded = await finishAction(action);
1146 // The comment is durable even if the state change was refused, so make it visible while the
1147 // shared action failure explains why the pull request stayed where it was.
1148 if (!actionSucceeded) refreshDetail();
1149 return { commentPosted: true };
1150 };
1151
1152 const saveTitle = async (next: string) => {
1153 const title = next.trim();
1154 if (!canWriteSourceControl || detail === null || titleSaving) return;
1155 if (title.length === 0 || title === detail.title) {
1156 setTitleScope(null);
1157 return;
1158 }
1159 setTitleSaving(true);
1160 const result = await update({ environmentId, input: { ...reference, title } });
1161 setTitleSaving(false);
1162 if (result._tag === "Failure") {
1163 // The draft stays open with the words still in it: retyping a title somebody has just
1164 // rewritten is the one thing a failed save must not cost them.
1165 toastManager.add({
1166 type: "error",
1167 title: "The title could not be saved",
1168 description: readableFailure(
1169 squashAtomCommandFailure(result),
1170 "The host refused the new title.",
1171 ),
1172 });
1173 return;
1174 }
1175 setTitleScope(null);
1176 refreshDetail();
1177 };
1178
1179 type ThreadTask = {
1180 prompt: string;
1181 reviewComments?: ReadonlyArray<ReviewCommentContext>;
1182 };
1183
1184 const attachTarget = composerDraftTarget ?? null;
1185 const canPrepareWorktree = prepareThread.isAllowed && canOperateThread;
1186 const canFixFindings = attachTarget !== null || canPrepareWorktree;
1187 const handoffLabels = pullRequestHandoffLabels(attachTarget !== null);
1188 // The guide is written from the menu as well as its tab, so a reader can ask for a rewrite
1189 // without the tour carrying a control for it.
1190 const guideWriter = useReviewGuideWriter({
1191 environmentId,
1192 reference,
1193 title: detail?.title ?? null,
1194 });
1195 const writeGuide = () => {
1196 void guideWriter.write();
1197 if (guideWriter.data?.guide == null) setTab("guide");
1198 };
1199
1200 const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => {
1201 const store = useComposerDraftStore.getState();
1202 const draft = store.getComposerDraft(target);
1203 const key = composerTargetKey(target);
1204 const previousCommentIds = new Set((draft?.reviewComments ?? []).map((comment) => comment.id));
1205 const repeatedCommentIds = new Set(
1206 (task.reviewComments ?? [])
1207 .filter((comment) => previousCommentIds.has(comment.id))
1208 .map((comment) => comment.id),
1209 );
1210 const promptWithoutPreviousHandoff = stripPullRequestHandoffReferences(
1211 draft?.prompt ?? "",
1212 draft?.reviewComments ?? [],
1213 repeatedCommentIds,
1214 );
1215 const prompt = handoffPrompt(
1216 {
1217 prompt: promptWithoutPreviousHandoff,
1218 lastHandoffPrompt: lastHandoffPromptByDraft.get(key),
1219 },
1220 task.prompt,
1221 );
1222 lastHandoffPromptByDraft.set(key, task.prompt);
1223 store.setPrompt(target, prompt);
1224 store.setReviewComments(
1225 target,
1226 handoffReviewComments(draft?.reviewComments ?? [], task.reviewComments ?? []),
1227 );
1228 for (const comment of task.reviewComments ?? []) {
1229 if (!repeatedCommentIds.has(comment.id)) continue;
1230 store.addReviewComment(target, comment, {
1231 allowDuplicateReference: true,
1232 insertAtCaret: false,
1233 });
1234 }
1235 };
1236
1237 /**
1238 * Opens a thread on this project and leaves the task in its composer for the reader to send.
1239 *
1240 * Nothing is checked out: asking a question is not a reason to move somebody's working tree or
1241 * to make a worktree they did not ask for. The two hand-offs that do need the code call this
1242 * after preparing it, so there is one path from "a task" to "a thread holding it".
1243 */
1244 const openThreadWithTask = async (
1245 projectRef: ReturnType<typeof scopeProjectRef>,
1246 task: ThreadTask | null,
1247 opened?: { draftId: DraftId },
1248 ): Promise<{ draftId: DraftId } | null> => {
1249 const session =
1250 opened ??
1251 (await newThread(projectRef).then(
1252 (result) => result,
1253 () => null,
1254 ));
1255 if (session === null) return null;
1256 if (task === null) return session;
1257 // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips
1258 // both, rather than stacking a second one under the first. What the reader typed themselves
1259 // survives — the composer they are handed is not always a fresh one, and a prompt they have
1260 // since edited is theirs rather than the hand-off's.
1261 writeTaskToComposer(session.draftId, task);
1262 return session;
1263 };
1264
1265 /**
1266 * A question about the change, which needs a thread and nothing else. `keepGuide` keeps the
1267 * reader's place: the new thread opens with this pull request beside it on the Guide tab, at the
1268 * stop they asked from, so the answer and the tour can be read together.
1269 */
1270 const startAsk = async (
1271 kind: string,
1272 task: ThreadTask,
1273 options?: { readonly keepGuide?: boolean },
1274 ) => {
1275 if (!detail || handoff !== null) return;
1276 if (attachTarget !== null) {
1277 writeTaskToComposer(attachTarget, task);
1278 toastManager.add({
1279 type: "success",
1280 title: "Added to the composer",
1281 description:
1282 task.prompt.length > 0
1283 ? "The question is in the composer — read it over, then send."
1284 : "The pull request is in the composer — type your question, then send.",
1285 });
1286 return;
1287 }
1288 setHandoff(kind);
1289 const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId);
1290 const opened = await openThreadWithTask(projectRef, task);
1291 setHandoff(null);
1292 if (opened !== null && options?.keepGuide === true) {
1293 const draft = useComposerDraftStore.getState().getDraftSession(opened.draftId);
1294 if (draft !== null) {
1295 useRightPanelStore
1296 .getState()
1297 .openPullRequest(scopeThreadRef(draft.environmentId, draft.threadId), {
1298 environmentId,
1299 projectId: reference.projectId,
1300 ...(reference.host === undefined ? {} : { host: reference.host }),
1301 repository: reference.repository,
1302 number: reference.number,
1303 url: detail.url,
1304 });
1305 useGuideRunStore.getState().requestOpen(guideRunKey(environmentId, reference));
1306 }
1307 }
1308 if (opened === null) {
1309 toastManager.add({
1310 type: "error",
1311 title: "Could not open a thread",
1312 description: "Try again from the project, or open a thread first.",
1313 });
1314 return;
1315 }
1316 toastManager.add({
1317 type: "success",
1318 title: "Asked in a thread",
1319 // "Ask" leaves the composer empty on purpose, so saying the question is in it would send
1320 // the reader looking for something that is not there. The chips are what landed.
1321 description:
1322 task.prompt.length > 0
1323 ? "The question is in the composer — read it over, then send."
1324 : "The pull request is in the composer — type your question, then send.",
1325 });
1326 };
1327
1328 // Every handoff works the same way: check the pull request out into its own worktree, open a
1329 // thread there, and — when it carries a task — put that in the composer for the user to read
1330 // before sending. Checking out is the whole point of the ones that carry nothing.
1331 const startHandoff = async (
1332 kind: string,
1333 task: { prompt: string; reviewComments?: ReadonlyArray<ReviewCommentContext> } | null,
1334 // A worktree leaves whatever is open alone, which is why it is the default. Checking out in
1335 // the repository itself is what you want when the point is to run the thing where you
1336 // already work — and it moves the branch under everything else that is open there.
1337 mode: "worktree" | "local" = "worktree",
1338 ) => {
1339 if (!handoffSummary || handoff !== null) return;
1340 if (attachTarget !== null && task !== null) {
1341 writeTaskToComposer(attachTarget, task);
1342 toastManager.add({
1343 type: "success",
1344 title: "Added to the composer",
1345 description: "The task is in the composer — read it over, then send.",
1346 });
1347 return;
1348 }
1349 if (checkoutRoot === null) return;
1350 if (
1351 !prepareThread.isAllowed ||
1352 !readEnvironmentScope(actingEnvironmentId, AuthSourceControlWriteScope)
1353 ) {
1354 return;
1355 }
1356 if (
1357 mode === "worktree" &&
1358 !readEnvironmentScope(actingEnvironmentId, AuthOrchestrationOperateScope)
1359 ) {
1360 return;
1361 }
1362 setHandoff(kind);
1363 // The menu closes on the press and takes its "Preparing..." label with it, so this is the
1364 // only thing answering for the checkout. It carries no timeout of its own: a loading toast
1365 // never expires, and an explicit one would survive the update and pin the result on screen.
1366 const toastId = toastManager.add({
1367 type: "loading",
1368 title: "Preparing the pull request checkout...",
1369 });
1370 // Wherever the reader chose to act: the thread, the checkout it is pointed at and the composer
1371 // the task lands in are all one server's, and picking another one moves all three.
1372 const projectRef = scopeProjectRef(
1373 actingEnvironmentId,
1374 acting?.projectId ?? handoffSummary.projectId,
1375 );
1376 // The thread is opened before the checkout rather than after it, because the project's setup
1377 // script only runs for a checkout that knows which thread it is for — and a worktree with no
1378 // dependencies installed is not something anyone can test.
1379 const opened = await newThread(projectRef).then(
1380 (session) => session,
1381 () => null,
1382 );
1383 if (opened === null) {
1384 setHandoff(null);
1385 // Without a thread there is nowhere for the checkout to belong: its setup script would not
1386 // run and its task would have no composer to land in. Better to stop before touching the
1387 // working tree than to prepare a worktree nobody asked for.
1388 toastManager.update(toastId, {
1389 type: "error",
1390 title: "Could not open a thread for the checkout",
1391 description: "Try again from the project, or open a thread first.",
1392 });
1393 return;
1394 }
1395 const prepared = await prepareThread.run({
1396 reference: handoffSummary.url,
1397 mode,
1398 threadId: opened.threadId,
1399 });
1400 if (prepared._tag === "Failure") {
1401 setHandoff(null);
1402 // The server says what to do about it — that the branch is already checked out in the main
1403 // repository, say — and that sentence is the only way out of the failure.
1404 const detailMessage =
1405 prepareThread.error instanceof Error ? prepareThread.error.message : null;
1406 toastManager.update(toastId, {
1407 type: "error",
1408 title: "Could not prepare the pull request checkout",
1409 ...(detailMessage ? { description: detailMessage } : {}),
1410 });
1411 return;
1412 }
1413 // The same thread again, now that there is somewhere to point it at. A local checkout has
1414 // no worktree of its own, so the thread runs where the repository already is.
1415 const pointed = await newThread(projectRef, {
1416 branch: prepared.value.branch,
1417 worktreePath: prepared.value.worktreePath,
1418 envMode: prepared.value.worktreePath === null ? "local" : "worktree",
1419 }).then(
1420 (session) => session !== null,
1421 () => false,
1422 );
1423 if (!pointed) {
1424 setHandoff(null);
1425 // The checkout is on disk; only the thread failed to move onto it. Writing the task now
1426 // would send the agent at whatever the thread was already open on — which is the one
1427 // outcome worth stopping for, since it reads as success and is not.
1428 toastManager.update(toastId, {
1429 type: "error",
1430 title: "Checked out, but the thread stayed where it was",
1431 description: `The checkout is ready on \`${prepared.value.branch}\`. Point a thread at it from the branch picker, then ask again.`,
1432 });
1433 return;
1434 }
1435 // Released here whatever happened next: a loading toast never expires on its own, so leaving
1436 // this set would spin forever and lock every handoff behind it until a reload.
1437 setHandoff(null);
1438 // A worktree that was already there and had been worked in keeps whatever it holds, so the
1439 // thread opens on older code than the pull request carries. Said once, in place of the
1440 // success, because everything else about the handoff did happen.
1441 const staleCheckoutToast = {
1442 type: "warning",
1443 title: "Checked out, but not on the latest commits",
1444 description:
1445 "The checkout could not be moved onto the pull request's latest commits, so the code there is older than the pull request. Uncommitted work or local commits keep it where it is.",
1446 } as const;
1447 if (task === null) {
1448 toastManager.update(
1449 toastId,
1450 prepared.value.isOnPullRequestHead
1451 ? {
1452 type: "success",
1453 title: mode === "local" ? "Checked out here" : "Checked out",
1454 description:
1455 mode === "local"
1456 ? "This repository is on the pull request's branch, with a thread open on it."
1457 : "The pull request is in its own worktree, with a thread open on it.",
1458 }
1459 : staleCheckoutToast,
1460 );
1461 return;
1462 }
1463 await openThreadWithTask(projectRef, task, opened);
1464 toastManager.update(
1465 toastId,
1466 prepared.value.isOnPullRequestHead
1467 ? {
1468 type: "success",
1469 title: "Checkout ready",
1470 description: "The task is in the composer — read it over, then send.",
1471 }
1472 : staleCheckoutToast,
1473 );
1474 };
1475
1476 const askAboutPullRequest = () => {
1477 if (!detail) return;
1478 void startAsk("ask", {
1479 ...buildAskAboutPullRequestHandoff({
1480 number: detail.number,
1481 title: detail.title,
1482 url: detail.url,
1483 headBranch: detail.headBranch,
1484 baseBranch: detail.baseBranch,
1485 state: detail.state,
1486 isDraft: detail.isDraft,
1487 }),
1488 });
1489 };
1490
1491 const explainPullRequest = () => {
1492 if (!detail) return;
1493 void startAsk("explain", {
1494 ...buildExplainPullRequestHandoff({
1495 number: detail.number,
1496 title: detail.title,
1497 url: detail.url,
1498 headBranch: detail.headBranch,
1499 baseBranch: detail.baseBranch,
1500 state: detail.state,
1501 isDraft: detail.isDraft,
1502 }),
1503 });
1504 };
1505
1506 const askAboutGuideStop = (stop: PullRequestReviewGuideStop, index: number, question: string) => {
1507 if (!detail) return;
1508 void startAsk(
1509 `guide-stop:${index}`,
1510 buildAskAboutGuideStopHandoff({
1511 number: detail.number,
1512 title: detail.title,
1513 url: detail.url,
1514 headBranch: detail.headBranch,
1515 baseBranch: detail.baseBranch,
1516 state: detail.state,
1517 isDraft: detail.isDraft,
1518 stop: {
1519 index,
1520 title: stop.title,
1521 summary: stop.summary,
1522 attention: stop.attention?.note ?? null,
1523 excerpts: stop.excerpts,
1524 },
1525 question,
1526 }),
1527 { keepGuide: true },
1528 );
1529 };
1530
1531 const addSelectionToAgent = (
1532 selection: PullRequestAgentSelectionInput,
1533 options?: { readonly keepGuide?: boolean },
1534 ) => {
1535 if (!detail) return;
1536 void startAsk(
1537 `selection:${selection.comment.id}`,
1538 buildAddSelectionToAgentHandoff({
1539 number: detail.number,
1540 title: detail.title,
1541 url: detail.url,
1542 headBranch: detail.headBranch,
1543 baseBranch: detail.baseBranch,
1544 state: detail.state,
1545 isDraft: detail.isDraft,
1546 comment: selection.comment,
1547 request: selection.request,
1548 }),
1549 options,
1550 );
1551 };
1552
1553 const startCheckout = (mode: "worktree" | "local") => {
1554 if (!handoffSummary) return;
1555 void startHandoff(`checkout:${mode}`, null, mode);
1556 };
1557
1558 /** One finding, handed over on its own — the surfaces that show findings call this. */
1559 const startFixFinding = (finding: PullRequestFinding) => {
1560 if (!detail) return;
1561 void startHandoff(
1562 pullRequestFindingKey(finding),
1563 buildFixFindingHandoff({
1564 number: detail.number,
1565 title: detail.title,
1566 url: detail.url,
1567 headBranch: detail.headBranch,
1568 baseBranch: detail.baseBranch,
1569 finding,
1570 }),
1571 );
1572 };
1573
1574 const startFixFindings = () => {
1575 if (!detail) return;
1576 void startHandoff(
1577 "findings",
1578 buildFixFindingsHandoff({
1579 number: detail.number,
1580 title: detail.title,
1581 url: detail.url,
1582 headBranch: detail.headBranch,
1583 baseBranch: detail.baseBranch,
1584 reviewThreads: detail.reviewThreads,
1585 comments: detail.comments,
1586 checks: checksStale ? [] : detail.checks,
1587 commentsTruncated: detail.commentsTruncated,
1588 }),
1589 );
1590 };
1591
1592 const startResolveConflicts = () => {
1593 if (!handoffSummary) return;
1594 void startHandoff("conflicts", {
1595 prompt: buildResolveConflictsPrompt({
1596 number: handoffSummary.number,
1597 url: handoffSummary.url,
1598 headBranch: handoffSummary.headBranch,
1599 baseBranch: handoffSummary.baseBranch,
1600 }),
1601 });
1602 };
1603
1604 // The host says which strategies it offers at all; the repository narrows that to the ones
1605 // it actually allows.
1606 const allowedMergeMethods = detail
1607 ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method])
1608 : [];
1609 const currentMergeMethod =
1610 mergeMethodSelection?.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : null;
1611 const selectedMergeMethod = resolvePullRequestMergeMethod(
1612 allowedMergeMethods,
1613 currentMergeMethod,
1614 projectDefaultMergeMethod,
1615 lastSelectedMergeMethod,
1616 );
1617 const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod];
1618 const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`;
1619 const conflicting = detail?.state === "open" && detail.mergeability === "conflicting";
1620 // Only an outright yes arms it. A host that reports nothing has not said the merge is already
1621 // spoken for, and an off switch for something that may not be on says the wrong thing twice.
1622 const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true;
1623 const armedMergeMethod = detail?.autoMergeMethod;
1624 const armedAutoMergeLabel = armedMergeMethod
1625 ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})`
1626 : "Auto-merge";
1627 const workflowApprovalsRequired =
1628 detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0;
1629 // Out of date with the base, and still cleanly mergeable — the one pairing an update button
1630 // exists for. Null everywhere else, including hosts that cannot compare at all.
1631 const freshness = detail === null ? null : resolveBaseFreshness(detail);
1632 // A host that cannot produce a patch has no Code tab to open. While detail is loading the ghost
1633 // uses this optimistic tab set to reserve the same chrome; a host without a patch removes Code
1634 // when its capabilities arrive.
1635 // The Guide tab is a beta the pull request's server opts into in Settings → Source Control.
1636 const reviewGuidesEnabled = useEnvironmentSettings(
1637 environmentId,
1638 (settings) => settings.reviewGuidesEnabled,
1639 );
1640 const visibleTabs = TABS.filter(
1641 (item) =>
1642 (item.value !== "code" || detail === null || detail.capabilities.diff) &&
1643 (item.value !== "guide" || reviewGuidesEnabled),
1644 );
1645 // The Code tab can be opened while the detail is still on its way, and the detail may then say
1646 // this host has no patch to show. The tab goes, so whoever was standing on it is moved back to
1647 // the summary rather than left looking at a panel that is no longer reachable.
1648 useEffect(() => {
1649 if (!visibleTabs.some((item) => item.value === tab)) setTab("summary");
1650 }, [tab, visibleTabs]);
1651 // Two questions, both of which have to say yes: whether this host can do it at all, and
1652 // whether this account may. A reader with read access on someone else's project sees the pull
1653 // request and none of the buttons that would only ever be refused.
1654 const can = (action: PullRequestAction) =>
1655 detail?.capabilities.actions.includes(action) === true &&
1656 detail.viewerPermissions.actions.includes(action);
1657 const detailChecksState = detail ? pullRequestChecksState(detail.checks) : null;
1658 const latestChecksState =
1659 sharedSummary?.checksState === undefined ? detailChecksState : sharedSummary.checksState;
1660 // List rollups can omit workflows awaiting approval. Only refreshed detail can clear those.
1661 const checksState =
1662 latestChecksState !== "failing" &&
1663 detail?.checks.some((check) => check.status === "action-required")
1664 ? "pending"
1665 : latestChecksState;
1666 // A newer rollup cannot tell us which runs changed or how many passed.
1667 const checksStale = checksState !== detailChecksState;
1668 // The merge state remains in one stable slot from waiting through completion. Conflicts take
1669 // the slot while they need a person; the armed badge remains beside them so that state is not lost.
1670 const primaryAction = detail
1671 ? resolvePullRequestPrimaryControl({
1672 state: detail.state,
1673 isDraft: detail.isDraft,
1674 mergeability: detail.mergeability,
1675 checksState,
1676 autoMergeEnabled: detail.autoMergeEnabled,
1677 hasMergeMethod: allowedMergeMethods.length > 0,
1678 canMerge: canMergeSinglePullRequest && can("merge"),
1679 canMarkReady: can("ready"),
1680 canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"),
1681 })
1682 : null;
1683 // What the menu's action group holds. Named once so the separators around it are drawn from
1684 // the same answer as its contents, rather than on the assumption that it has any.
1685 const showsDraftToggle =
1686 detail?.state === "open" &&
1687 can(detail.isDraft ? "ready" : "draft") &&
1688 !(detail.isDraft && primaryAction === "ready");
1689 const showsAutoMerge =
1690 canMergeSinglePullRequest &&
1691 detail?.state === "open" &&
1692 ((autoMergeArmed && can("disable-auto-merge")) ||
1693 (!autoMergeArmed &&
1694 primaryAction !== "enable-auto-merge" &&
1695 !detail.isDraft &&
1696 !conflicting &&
1697 can("enable-auto-merge") &&
1698 allowedMergeMethods.length > 0));
1699 const showsMergeNow =
1700 canMergeSinglePullRequest &&
1701 detail?.state === "open" &&
1702 (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") &&
1703 can("merge") &&
1704 !detail.isDraft &&
1705 !conflicting &&
1706 allowedMergeMethods.length > 0;
1707 const showsMergeMethods =
1708 detail?.state === "open" &&
1709 can("merge") &&
1710 !detail.isDraft &&
1711 !conflicting &&
1712 allowedMergeMethods.length > 1;
1713 // The pull request number carries this state in the overview and the right-panel tab mirrors
1714 // it. Conflicts take the action slot while they need a person, but do not change the PR state.
1715 const statePresentation = detail
1716 ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft })
1717 : null;
1718 const showsDeleteBranch = detail !== null && canDeletePullRequestBranch(detail);
1719 const environmentConnected = environments.some(
1720 (environment) =>
1721 environment.environmentId === environmentId && environment.connection.phase === "connected",
1722 );
1723 const confirmDeleteBranch = async () => {
1724 if (detail === null) return;
1725 const confirmed = await readLocalApi()?.dialogs.confirm(
1726 [
1727 `Delete branch ${detail.headBranch}?`,
1728 `This deletes ${detail.headBranch} from ${detail.headRepositoryNameWithOwner ?? detail.repository}. Local branches and worktrees are not changed.`,
1729 ].join("\n"),
1730 { variant: "destructive" },
1731 );
1732 if (confirmed) void perform("delete-branch");
1733 };
1734 const showsApproveWorkflows =
1735 workflowApprovalsRequired > 0 && !checksStale && can("approve-workflows");
1736 const checksSummary = checksStale
1737 ? checksState === null
1738 ? "No checks reported"
1739 : pullRequestChecksStatePresentation(checksState).label
1740 : detail
1741 ? summarizePullRequestChecks(detail.checks)
1742 : null;
1743 // Approvals that still stand, and only those. A superseded one is dimmed beside the reviewer
1744 // who gave it, so counting it here would have the header assert in a number what the row next
1745 // to it has just qualified.
1746 //
1747 // Not counted at all from a conversation this page only holds the recent end of: an approval
1748 // older than the window would be missing, and "1" beside a tick is read as the whole answer.
1749 // The Summary tab's row can say it may be short; a bare number cannot, so it stays away.
1750 const approvalCount =
1751 detail && !detail.commentsTruncated
1752 ? latestPullRequestReviewOutcomes(detail.comments, detail.commits).filter(
1753 (entry) => entry.outcome === "approved" && !entry.stale,
1754 ).length
1755 : 0;
1756
1757 const checkoutControl =
1758 context === "page" ? (
1759 <Menu>
1760 <Tooltip>
1761 <TooltipTrigger
1762 render={
1763 <MenuTrigger
1764 disabled={handoff !== null || checkoutRoot === null}
1765 render={
1766 <Button
1767 size="xs"
1768 variant="outline"
1769 aria-label={handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1770 >
1771 <GitBranchIcon aria-hidden className="size-3.5" />
1772 <span className="@max-[35rem]/pr-header:hidden">
1773 {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1774 </span>
1775 <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" />
1776 </Button>
1777 }
1778 />
1779 }
1780 />
1781 <TooltipPopup>Check out this pull request</TooltipPopup>
1782 </Tooltip>
1783 <MenuPopup align="end" side="bottom">
1784 <MenuItem disabled={!canPrepareWorktree} onClick={() => startCheckout("worktree")}>
1785 <GitBranchIcon className="mt-1 size-3.5 shrink-0 self-start" />
1786 <span className="flex min-w-0 flex-col">
1787 <span>In a separate worktree</span>
1788 <span className="text-xs text-muted-foreground">
1789 Its own folder and thread. Nothing you have open moves.
1790 </span>
1791 </span>
1792 </MenuItem>
1793 <MenuItem disabled={!prepareThread.isAllowed} onClick={() => startCheckout("local")}>
1794 <FolderGit2Icon className="mt-1 size-3.5 shrink-0 self-start" />
1795 <span className="flex min-w-0 flex-col">
1796 <span>In this repository</span>
1797 <span className="text-xs text-muted-foreground">
1798 Switches the branch you are working in, like `gh pr checkout`.
1799 </span>
1800 </span>
1801 </MenuItem>
1802 {pickableEnvironments.length > 0 ? (
1803 <ActOnEnvironmentPicker
1804 environments={pickableEnvironments}
1805 value={actingEnvironmentId}
1806 onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
1807 disabled={handoff !== null}
1808 />
1809 ) : null}
1810 </MenuPopup>
1811 </Menu>
1812 ) : null;
1813
1814 const resolveConflictsControl = (
1815 <Tooltip>
1816 <TooltipTrigger
1817 render={
1818 <span className="inline-flex shrink-0">
1819 <Button
1820 size="xs"
1821 variant="destructive-outline"
1822 disabled={handoff !== null || (attachTarget === null && checkoutRoot === null)}
1823 onClick={startResolveConflicts}
1824 aria-label={handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}
1825 >
1826 <PullRequestGlyph.conflicting aria-hidden className="size-3.5" />
1827 <span className="@max-[30rem]/pr-header:hidden">
1828 {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}
1829 </span>
1830 </Button>
1831 </span>
1832 }
1833 />
1834 <TooltipPopup side="top">
1835 {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"}
1836 </TooltipPopup>
1837 </Tooltip>
1838 );
1839
1840 // The list already has the pull request's identity and summary. Keep them on screen
1841 // and let the richer detail read replace the remaining placeholders in place.
1842 if (detailQuery.isPending && !detail) {
1843 return (
1844 <PullRequestDetailGhost
1845 seed={matchingListEntry}
1846 summary={sharedSummary}
1847 checkoutCommand={checkoutCommand}
1848 onCheckoutError={onCheckoutCommandError}
1849 number={reference.number}
1850 tabs={visibleTabs}
1851 activeTab={tab}
1852 {...(onBack ? { onBack } : {})}
1853 {...(onClose ? { onClose } : {})}
1854 actions={
1855 handoffSummary ? (
1856 <TooltipProvider delay={150} closeDelay={150} timeout={400}>
1857 {handoffSummary.state === "open" && handoffSummary.mergeability === "conflicting"
1858 ? resolveConflictsControl
1859 : null}
1860 {checkoutControl}
1861 </TooltipProvider>
1862 ) : undefined
1863 }
1864 />
1865 );
1866 }
1867
1868 return (
1869 <div className="relative flex h-full min-h-0 w-full flex-col bg-background">
1870 {threadPickerOpen && detail ? (
1871 <PullRequestThreadLinks
1872 key={`${environmentId}:${detail.url}`}
1873 display="picker"
1874 environmentId={environmentId}
1875 reference={reference}
1876 url={detail.url}
1877 threadRef={null}
1878 onPickerOpenChange={setThreadPickerOpen}
1879 />
1880 ) : null}
1881 <div
1882 className={cn(
1883 "@container/pr-header grid min-w-0 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2",
1884 detail && "border-b border-border/60",
1885 !detail && !onClose && "hidden",
1886 )}
1887 >
1888 <div className="pl-4 grid h-7 min-w-0 items-center overflow-hidden">
1889 <div
1890 aria-hidden={condensed}
1891 inert={condensed}
1892 className={cn(
1893 "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",
1894 condensed
1895 ? "pointer-events-none -translate-y-1 opacity-0 duration-100"
1896 : "translate-y-0 opacity-100 delay-50 duration-150",
1897 )}
1898 >
1899 {detail && statePresentation ? (
1900 <>
1901 {onBack ? (
1902 <Tooltip>
1903 <TooltipTrigger
1904 render={
1905 <Button
1906 size="icon-micro"
1907 variant="ghost-muted"
1908 onClick={onBack}
1909 className="-ml-1.5"
1910 aria-label="Back to this thread's pull requests"
1911 >
1912 <ArrowLeftIcon aria-hidden className="size-3.5" />
1913 </Button>
1914 }
1915 />
1916 <TooltipPopup side="top">Back to pull requests</TooltipPopup>
1917 </Tooltip>
1918 ) : null}
1919 <Tooltip>
1920 <TooltipTrigger
1921 render={
1922 repositoryUrl ? (
1923 <button
1924 type="button"
1925 onClick={() => void readLocalApi()?.shell.openExternal(repositoryUrl)}
1926 className="min-w-0 cursor-pointer truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
1927 >
1928 {detail.repository}
1929 </button>
1930 ) : (
1931 <span className="min-w-0 truncate font-medium text-muted-foreground">
1932 {detail.repository}
1933 </span>
1934 )
1935 }
1936 />
1937 <TooltipPopup side="top">
1938 {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository}
1939 </TooltipPopup>
1940 </Tooltip>
1941 <Tooltip>
1942 <TooltipTrigger
1943 render={
1944 <button
1945 type="button"
1946 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1947 onContextMenu={(event) => openNumberContextMenu(event, detail)}
1948 className={cn(
1949 "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline",
1950 statePresentation.toneClassName,
1951 )}
1952 aria-label={`Open pull request #${detail.number} on host`}
1953 >
1954 #{detail.number}
1955 <ExternalLinkIcon aria-hidden className="size-2.5" />
1956 </button>
1957 }
1958 />
1959 <TooltipPopup side="top">{openOnHostLabel(hostClient)}</TooltipPopup>
1960 </Tooltip>
1961 </>
1962 ) : null}
1963 </div>
1964 <div
1965 aria-hidden={!condensed}
1966 inert={!condensed}
1967 className={cn(
1968 "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",
1969 condensed
1970 ? "translate-y-0 opacity-100 delay-50 duration-150"
1971 : "pointer-events-none translate-y-1 opacity-0 duration-100",
1972 )}
1973 >
1974 {detail && statePresentation ? (
1975 <>
1976 {onBack ? (
1977 <Tooltip>
1978 <TooltipTrigger
1979 render={
1980 <Button
1981 size="icon-micro"
1982 variant="ghost-muted"
1983 tabIndex={condensed ? 0 : -1}
1984 onClick={onBack}
1985 className="-ml-1.5"
1986 aria-label="Back to this thread's pull requests"
1987 >
1988 <ArrowLeftIcon aria-hidden className="size-3.5" />
1989 </Button>
1990 }
1991 />
1992 <TooltipPopup side="top">Back to pull requests</TooltipPopup>
1993 </Tooltip>
1994 ) : null}
1995 <Tooltip>
1996 <TooltipTrigger
1997 render={
1998 <button
1999 type="button"
2000 tabIndex={condensed ? 0 : -1}
2001 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
2002 onContextMenu={(event) => openNumberContextMenu(event, detail)}
2003 className={cn(
2004 "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline",
2005 statePresentation.toneClassName,
2006 )}
2007 aria-label={`Open pull request #${detail.number} on host`}
2008 >
2009 #{detail.number}
2010 <ExternalLinkIcon aria-hidden className="size-2.5" />
2011 </button>
2012 }
2013 />
2014 <TooltipPopup side="top">{openOnHostLabel(hostClient)}</TooltipPopup>
2015 </Tooltip>
2016 <Tooltip>
2017 <TooltipTrigger
2018 render={
2019 <span className="min-w-0 truncate font-medium text-foreground">
2020 {detail.title}
2021 </span>
2022 }
2023 />
2024 <TooltipPopup side="top">{detail.title}</TooltipPopup>
2025 </Tooltip>
2026 </>
2027 ) : null}
2028 </div>
2029 </div>
2030 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
2031 {detail ? (
2032 <TooltipProvider delay={150} closeDelay={150} timeout={400}>
2033 {!nativeStack && supportsStackActions && nativeStackQuery.error ? (
2034 <Button variant="ghost" size="xs" onClick={nativeStackQuery.refresh}>
2035 Retry stack lookup
2036 </Button>
2037 ) : null}
2038 {nativeStack ? (
2039 <PullRequestStackMenu
2040 hostLabel={hostClient.label}
2041 stack={nativeStack}
2042 notice={nativeStackQuery.notice}
2043 onRetry={nativeStackQuery.error ? nativeStackQuery.refresh : undefined}
2044 reference={reference}
2045 environmentId={environmentId}
2046 onSelect={onSelectPullRequest}
2047 mergeMethod={selectedMergeMethod}
2048 canMerge={
2049 nativeStackQuery.isFresh &&
2050 supportsStackActions &&
2051 can("merge") &&
2052 allowedMergeMethods.length > 0
2053 }
2054 canRebase={
2055 nativeStackQuery.isFresh &&
2056 supportsStackActions &&
2057 detail.viewerPermissions.stackRebase === true
2058 }
2059 onActed={() => {
2060 refreshDetail();
2061 onActed?.();
2062 }}
2063 />
2064 ) : null}
2065 {context === "page" ? (
2066 <PullRequestThreadLinks
2067 display="count"
2068 environmentId={environmentId}
2069 reference={reference}
2070 url={detail.url}
2071 threadRef={null}
2072 />
2073 ) : null}
2074 {/* Said where the Merge button is, because it is the answer to why nobody has
2075 pressed it: the merge is already asked for, and the host is holding it. */}
2076 {autoMergeArmed && primaryAction !== "auto-merge-armed" ? (
2077 <Tooltip>
2078 <TooltipTrigger
2079 render={
2080 <Badge
2081 size="control"
2082 variant="info"
2083 role="img"
2084 aria-label={armedAutoMergeLabel}
2085 >
2086 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
2087 <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span>
2088 </Badge>
2089 }
2090 />
2091 <TooltipPopup side="top">
2092 {armedAutoMergeLabel}: the host will merge this on its own once its requirements
2093 are met
2094 </TooltipPopup>
2095 </Tooltip>
2096 ) : null}
2097 {primaryAction === "resolve" ? (
2098 resolveConflictsControl
2099 ) : primaryAction === "ready" ? (
2100 <Tooltip>
2101 <TooltipTrigger
2102 render={
2103 <span className="inline-flex shrink-0">
2104 <Button
2105 size="xs"
2106 variant="default"
2107 disabled={actionPending}
2108 onClick={() => void perform("ready")}
2109 aria-label="Ready for review"
2110 >
2111 <PullRequestGlyph.pullRequest aria-hidden className="size-3.5" />
2112 <span className="@max-[30rem]/pr-header:hidden">Ready for review</span>
2113 </Button>
2114 </span>
2115 }
2116 />
2117 <TooltipPopup side="top">Ready for review</TooltipPopup>
2118 </Tooltip>
2119 ) : primaryAction === "enable-auto-merge" ? (
2120 <Tooltip>
2121 <TooltipTrigger
2122 render={
2123 <span className="inline-flex shrink-0">
2124 <Button
2125 size="xs"
2126 variant="default"
2127 disabled={actionPending}
2128 {...inlineConfirm.props(
2129 "enable-auto-merge",
2130 () => void perform("enable-auto-merge", selectedMergeMethod),
2131 )}
2132 aria-label={
2133 pendingAction === "enable-auto-merge"
2134 ? "Enabling..."
2135 : inlineConfirm.isArmed("enable-auto-merge")
2136 ? "Confirm auto-merge?"
2137 : pendingAutoMergeLabel
2138 }
2139 >
2140 {inlineConfirm.isArmed("enable-auto-merge") ? (
2141 <CheckIcon aria-hidden className="size-3.5" />
2142 ) : (
2143 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
2144 )}
2145 <AnimatedWidth className="@max-[30rem]/pr-header:hidden">
2146 <span className="grid">
2147 <span aria-hidden className="invisible col-start-1 row-start-1">
2148 {pendingAutoMergeLabel}
2149 </span>
2150 <span className="col-start-1 row-start-1">
2151 {pendingAction === "enable-auto-merge"
2152 ? "Enabling..."
2153 : inlineConfirm.isArmed("enable-auto-merge")
2154 ? "Confirm auto-merge?"
2155 : pendingAutoMergeLabel}
2156 </span>
2157 </span>
2158 </AnimatedWidth>
2159 </Button>
2160 </span>
2161 }
2162 />
2163 <TooltipPopup side="top">
2164 {pendingAction === "enable-auto-merge"
2165 ? "Enabling..."
2166 : inlineConfirm.isArmed("enable-auto-merge")
2167 ? "Confirm auto-merge?"
2168 : pendingAutoMergeLabel}
2169 </TooltipPopup>
2170 </Tooltip>
2171 ) : primaryAction === "auto-merge-armed" ? (
2172 <Tooltip>
2173 <TooltipTrigger
2174 render={
2175 <Badge
2176 size="control"
2177 variant="info"
2178 role="img"
2179 aria-label={armedAutoMergeLabel}
2180 >
2181 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
2182 <span className="@max-[30rem]/pr-header:hidden">{armedAutoMergeLabel}</span>
2183 </Badge>
2184 }
2185 />
2186 <TooltipPopup side="top">
2187 {armedAutoMergeLabel}: the host will merge this on its own once its requirements
2188 are met
2189 </TooltipPopup>
2190 </Tooltip>
2191 ) : primaryAction === "merge" ? (
2192 <Tooltip>
2193 <TooltipTrigger
2194 render={
2195 <span className="inline-flex shrink-0">
2196 <Button
2197 size="xs"
2198 variant="default"
2199 disabled={actionPending}
2200 {...inlineConfirm.props(
2201 "merge",
2202 () => void perform("merge", selectedMergeMethod),
2203 )}
2204 aria-label={
2205 pendingAction === "merge"
2206 ? "Merging..."
2207 : inlineConfirm.isArmed("merge")
2208 ? "Confirm merge?"
2209 : selectedMergeMethodLabel
2210 }
2211 >
2212 {inlineConfirm.isArmed("merge") ? (
2213 <CheckIcon aria-hidden className="size-3.5" />
2214 ) : (
2215 <PullRequestGlyph.merged aria-hidden className="size-3.5" />
2216 )}
2217 <AnimatedWidth className="@max-[30rem]/pr-header:hidden">
2218 <span className="grid">
2219 <span aria-hidden className="invisible col-start-1 row-start-1">
2220 {selectedMergeMethodLabel}
2221 </span>
2222 <span className="col-start-1 row-start-1">
2223 {pendingAction === "merge"
2224 ? "Merging..."
2225 : inlineConfirm.isArmed("merge")
2226 ? "Confirm merge?"
2227 : selectedMergeMethodLabel}
2228 </span>
2229 </span>
2230 </AnimatedWidth>
2231 </Button>
2232 </span>
2233 }
2234 />
2235 <TooltipPopup side="top">
2236 {pendingAction === "merge"
2237 ? "Merging..."
2238 : inlineConfirm.isArmed("merge")
2239 ? "Confirm merge?"
2240 : selectedMergeMethodLabel}
2241 </TooltipPopup>
2242 </Tooltip>
2243 ) : (primaryAction === "merged" || primaryAction === "closed") &&
2244 statePresentation !== null ? (
2245 <Badge size="control" variant="outline">
2246 <span className={cn("flex items-center gap-1", statePresentation.toneClassName)}>
2247 <statePresentation.Icon className="size-3.5" />
2248 {statePresentation.label}
2249 </span>
2250 </Badge>
2251 ) : null}
2252 {/* Keep checkout beside the menu so host actions arriving on its left cannot
2253 move a different action under the reader's pointer. */}
2254 {checkoutControl}
2255 <Menu>
2256 <Tooltip>
2257 <TooltipTrigger
2258 render={
2259 <MenuTrigger
2260 render={
2261 <Button
2262 aria-label={
2263 refreshing ? "Refreshing pull request" : "More pull request actions"
2264 }
2265 size="icon-xs"
2266 variant="ghost-muted"
2267 />
2268 }
2269 >
2270 {/* The refresh lives in this menu, so while one runs the trigger wears
2271 the spinning glyph in place of the dots: the reader sees the panel
2272 is fetching without a control appearing or the row shifting. */}
2273 {refreshing ? (
2274 <RefreshIcon refreshing size="md" />
2275 ) : (
2276 <MoreHorizontalIcon className="size-4" />
2277 )}
2278 </MenuTrigger>
2279 }
2280 />
2281 <TooltipPopup>
2282 {refreshing ? "Refreshing pull request" : "More pull request actions"}
2283 </TooltipPopup>
2284 </Tooltip>
2285 <MenuPopup align="end" side="bottom">
2286 <PullRequestThreadLinks
2287 display="menu-item"
2288 environmentId={environmentId}
2289 reference={reference}
2290 url={detail.url}
2291 threadRef={
2292 threadRef ??
2293 (typeof composerDraftTarget === "object" ? composerDraftTarget : null)
2294 }
2295 onPickerOpenChange={setThreadPickerOpen}
2296 />
2297 <MenuItem disabled={refreshing} onClick={() => void refreshFromHost()}>
2298 <RefreshIcon size="sm" refreshing={refreshing} />
2299 Refresh
2300 </MenuItem>
2301 <MenuItem disabled={handoff !== null} onClick={askAboutPullRequest}>
2302 <MessageCircleQuestionIcon className="mt-1 size-3.5 shrink-0 self-start" />
2303 <span className="flex min-w-0 flex-col">
2304 <span>{handoff === "ask" ? "Opening..." : "Ask a question"}</span>
2305 <span className="text-xs text-muted-foreground">
2306 {attachTarget !== null
2307 ? "Adds the pull request to this thread's composer."
2308 : "Opens a thread that knows which pull request you mean."}
2309 </span>
2310 </span>
2311 </MenuItem>
2312 <MenuItem disabled={handoff !== null} onClick={explainPullRequest}>
2313 <BookOpenIcon className="mt-1 size-3.5 shrink-0 self-start" />
2314 <span className="flex min-w-0 flex-col">
2315 <span>{handoff === "explain" ? "Opening..." : "Explain this PR"}</span>
2316 <span className="text-xs text-muted-foreground">
2317 A walk through the diff and what to read closely.
2318 </span>
2319 </span>
2320 </MenuItem>
2321 <MenuItem
2322 disabled={handoff !== null || !canFixFindings}
2323 onClick={startFixFindings}
2324 >
2325 <HammerIcon className="size-3.5" />
2326 {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings}
2327 </MenuItem>
2328 {reviewGuidesEnabled && guideWriter.canWrite ? (
2329 <MenuItem disabled={guideWriter.running} onClick={writeGuide}>
2330 <SparklesIcon className="size-3.5" />
2331 {guideWriter.running
2332 ? "Writing guide..."
2333 : guideWriter.data?.guide == null
2334 ? "Write guide"
2335 : "Rewrite guide"}
2336 </MenuItem>
2337 ) : null}
2338 {pickableEnvironments.length > 0 ? (
2339 <ActOnEnvironmentPicker
2340 environments={pickableEnvironments}
2341 value={actingEnvironmentId}
2342 onChange={(next) => setActingScope({ pullRequestKey, environmentId: next })}
2343 disabled={handoff !== null}
2344 />
2345 ) : null}
2346 <MenuSeparator />
2347 {detail.state === "open" ? (
2348 <>
2349 {/* Only where the button row could not take it: "Ready for review" on a
2350 draft is the primary header button, so offering it here as well would
2351 show the same action twice. */}
2352 {showsDraftToggle ? (
2353 <MenuItem
2354 disabled={actionPending}
2355 onClick={() => void perform(detail.isDraft ? "ready" : "draft")}
2356 >
2357 {detail.isDraft ? (
2358 <PullRequestGlyph.pullRequest className="size-3.5" />
2359 ) : (
2360 <PullRequestGlyph.draft className="size-3.5" />
2361 )}
2362 {detail.isDraft ? "Ready for review" : "Convert to draft"}
2363 </MenuItem>
2364 ) : null}
2365 {showsMergeNow ? (
2366 <MenuItem
2367 closeOnClick={inlineConfirm.isArmed("merge")}
2368 aria-label={
2369 inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"
2370 }
2371 disabled={actionPending}
2372 {...inlineConfirm.props(
2373 "merge",
2374 () => void perform("merge", selectedMergeMethod),
2375 )}
2376 >
2377 <PullRequestGlyph.merged className="size-3.5" />
2378 {inlineConfirm.isArmed("merge") ? "Confirm merge?" : "Merge now"}
2379 </MenuItem>
2380 ) : null}
2381 {/* The same merge, left with the host to carry out once its requirements
2382 pass. A conflicting branch cannot be armed because nothing the host
2383 waits for will clear the conflict. */}
2384 {autoMergeArmed && can("disable-auto-merge") ? (
2385 <MenuItem
2386 disabled={actionPending}
2387 onClick={() => void perform("disable-auto-merge")}
2388 >
2389 <PullRequestGlyph.merged className="size-3.5" />
2390 Disable auto-merge
2391 </MenuItem>
2392 ) : showsAutoMerge ? (
2393 <MenuItem
2394 closeOnClick={inlineConfirm.isArmed("enable-auto-merge")}
2395 aria-label={
2396 inlineConfirm.isArmed("enable-auto-merge")
2397 ? "Confirm auto-merge?"
2398 : "Enable auto-merge"
2399 }
2400 disabled={actionPending}
2401 {...inlineConfirm.props(
2402 "enable-auto-merge",
2403 () => void perform("enable-auto-merge", selectedMergeMethod),
2404 )}
2405 >
2406 <PullRequestGlyph.merged className="size-3.5" />
2407 {inlineConfirm.isArmed("enable-auto-merge")
2408 ? "Confirm auto-merge?"
2409 : "Enable auto-merge"}
2410 </MenuItem>
2411 ) : null}
2412 {/* A preference for the merge action rather than a second action, so it
2413 is a radio group here instead of a chevron welded to the Merge pill.
2414 Hidden while conflicting: every method would fail. */}
2415 {/* Only where merging is on offer at all: a strategy to merge with is not
2416 a choice for someone who may not merge. */}
2417 {showsMergeMethods ? (
2418 <>
2419 {/* Only below the draft control. A host with no draft of its own, or
2420 a draft whose control is already the header button, would leave
2421 this against the separator that opened the group. */}
2422 {showsDraftToggle || showsMergeNow || showsAutoMerge ? (
2423 <MenuSeparator />
2424 ) : null}
2425 <MenuRadioGroup
2426 value={selectedMergeMethod}
2427 onValueChange={(method) => {
2428 const selectedMethod = method as PullRequestMergeMethod;
2429 setMergeMethod(selectedMethod);
2430 setLastSelectedMergeMethod(selectedMethod);
2431 }}
2432 >
2433 {allowedMergeMethods.map((method) => (
2434 <MenuRadioItem
2435 key={method}
2436 value={method}
2437 disabled={actionPending}
2438 closeOnClick
2439 >
2440 {/* The radio item lays its children out as one block, so the
2441 icon and the label need their own row to share a line. */}
2442 <span className="flex min-w-0 items-center gap-2">
2443 <PullRequestGlyph.merged className="size-3.5" />
2444 <span>{PULL_REQUEST_MERGE_METHOD_LABELS[method]}</span>
2445 </span>
2446 </MenuRadioItem>
2447 ))}
2448 </MenuRadioGroup>
2449 </>
2450 ) : null}
2451 {pullRequestActionMenuHasGroup(
2452 showsDraftToggle,
2453 showsAutoMerge || showsMergeNow,
2454 showsMergeMethods,
2455 ) ? (
2456 <MenuSeparator />
2457 ) : null}
2458 </>
2459 ) : null}
2460 <MenuItem onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}>
2461 <ArrowUpRightIcon className="size-3.5" />
2462 {openOnHostLabel(hostClient)}
2463 </MenuItem>
2464 <MenuItem onClick={() => copyReference(detail.url, "PR link")}>
2465 <LinkIcon className="size-3.5" />
2466 Copy link
2467 <MenuShortcut>
2468 {shortcutLabelForCommand(keybindings, "thread.copyReference")}
2469 </MenuShortcut>
2470 </MenuItem>
2471 <MenuItem onClick={() => copyReference(`#${reference.number}`, "PR number")}>
2472 <CopyIcon className="size-3.5" />
2473 Copy PR number
2474 <MenuShortcut>
2475 {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")}
2476 </MenuShortcut>
2477 </MenuItem>
2478 {detail.state === "open" && can("close") ? (
2479 <>
2480 <MenuSeparator />
2481 <MenuItem
2482 closeOnClick={inlineConfirm.isArmed("close")}
2483 aria-label={
2484 inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"
2485 }
2486 variant="destructive"
2487 disabled={actionPending}
2488 {...inlineConfirm.props("close", () => void perform("close"))}
2489 >
2490 <PullRequestGlyph.closed className="size-3.5" />
2491 {inlineConfirm.isArmed("close") ? "Confirm close?" : "Close pull request"}
2492 </MenuItem>
2493 </>
2494 ) : detail.state === "closed" && can("reopen") ? (
2495 <>
2496 <MenuSeparator />
2497 <MenuItem disabled={actionPending} onClick={() => void perform("reopen")}>
2498 <PullRequestGlyph.reopen className="size-3.5" />
2499 Reopen pull request
2500 </MenuItem>
2501 </>
2502 ) : detail.state === "merged" && can("revert") ? (
2503 <>
2504 <MenuSeparator />
2505 <MenuItem
2506 closeOnClick={inlineConfirm.isArmed("revert")}
2507 aria-label={
2508 inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"
2509 }
2510 variant={inlineConfirm.isArmed("revert") ? "destructive" : "default"}
2511 disabled={actionPending}
2512 {...inlineConfirm.props("revert", () => void perform("revert"))}
2513 >
2514 <RotateCcwIcon className="size-3.5" />
2515 {inlineConfirm.isArmed("revert") ? "Confirm revert?" : "Revert changes"}
2516 </MenuItem>
2517 </>
2518 ) : null}
2519 {showsDeleteBranch ? (
2520 <>
2521 <MenuSeparator />
2522 <MenuItem
2523 variant="destructive"
2524 disabled={actionPending || !environmentConnected}
2525 onClick={() => void confirmDeleteBranch()}
2526 >
2527 <Trash2Icon className="mt-1 size-3.5 shrink-0 self-start" />
2528 <span className="flex min-w-0 flex-col">
2529 <span>
2530 {pendingAction === "delete-branch"
2531 ? "Deleting branch..."
2532 : "Delete branch"}
2533 </span>
2534 <span className="truncate text-xs text-muted-foreground">
2535 {environmentConnected
2536 ? detail.headBranch
2537 : "Reconnect to delete this branch"}
2538 </span>
2539 </span>
2540 </MenuItem>
2541 </>
2542 ) : null}
2543 </MenuPopup>
2544 </Menu>
2545 </TooltipProvider>
2546 ) : null}
2547 {onClose ? (
2548 <Button
2549 size="icon-xs"
2550 variant="ghost"
2551 aria-label="Collapse pull request panel"
2552 onClick={onClose}
2553 >
2554 <PanelRightIcon className="size-3.5" />
2555 </Button>
2556 ) : null}
2557 </div>
2558
2559 <div
2560 className={cn(
2561 "col-span-2 grid",
2562 condensed
2563 ? "grid-rows-[1fr]"
2564 : "grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
2565 )}
2566 >
2567 <div
2568 ref={condensedRowRef}
2569 className={cn(
2570 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
2571 condensed
2572 ? "translate-y-0 opacity-100 delay-50"
2573 : "translate-y-1 opacity-0 duration-100",
2574 )}
2575 inert={!condensed}
2576 >
2577 {detail ? (
2578 <div className="col-span-2 min-w-0 px-4 pb-2 pt-1">
2579 <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
2580 <span className="flex min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground">
2581 <PullRequestActorLabel
2582 actor={detail.author}
2583 profileUrl={authorProfileUrl}
2584 variant="avatar"
2585 className="shrink-0"
2586 />
2587 <span className="shrink-0">{formatRelativeTimeLabel(detail.updatedAt)}</span>
2588 </span>
2589 <span aria-hidden className="h-3 w-px shrink-0 bg-border/70" />
2590 <span className="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-2xs text-muted-foreground/65">
2591 {/* An out-of-date base wears the warning on the branch name itself, so the
2592 name is amber and pointing at either the name or the mark opens the way
2593 out. Up to date, the name keeps its plain tooltip. */}
2594 {freshness ? (
2595 <PullRequestBaseFreshnessWarning
2596 baseBranch={detail.baseBranch}
2597 freshness={freshness}
2598 pending={actionPending}
2599 onUpdate={(method) => void perform("update-branch", undefined, method)}
2600 iconClassName="size-3"
2601 className="max-w-[40%]"
2602 >
2603 {isStackedPullRequest ? (
2604 <PullRequestGlyph.stack
2605 aria-label="Stacked pull request"
2606 className="size-3 shrink-0"
2607 />
2608 ) : null}
2609 <code className="flex min-w-0">
2610 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2611 </code>
2612 </PullRequestBaseFreshnessWarning>
2613 ) : (
2614 <Tooltip>
2615 <TooltipTrigger
2616 render={
2617 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
2618 {isStackedPullRequest ? (
2619 <PullRequestGlyph.stack
2620 aria-label="Stacked pull request"
2621 className="size-3 shrink-0"
2622 />
2623 ) : null}
2624 <code className="flex min-w-0">
2625 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2626 </code>
2627 </span>
2628 }
2629 />
2630 <TooltipPopup side="top">
2631 {isStackedPullRequest
2632 ? `Stacked on ${detail.baseBranch}`
2633 : detail.baseBranch}
2634 </TooltipPopup>
2635 </Tooltip>
2636 )}
2637 <ArrowLeftIcon
2638 aria-label="receives changes from"
2639 className="size-3 shrink-0 opacity-60"
2640 />
2641 <Tooltip>
2642 <TooltipTrigger
2643 render={
2644 <code className="flex min-w-0 flex-1">
2645 <MiddleTruncate value={detail.headBranch} showTitle={false} />
2646 </code>
2647 }
2648 />
2649 <TooltipPopup side="top">{detail.headBranch}</TooltipPopup>
2650 </Tooltip>
2651 </span>
2652 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2 text-2xs">
2653 <span
2654 className="inline-flex items-center gap-1 tabular-nums"
2655 aria-label={`${detail.changedFiles.toLocaleString()} changed ${
2656 detail.changedFiles === 1 ? "file" : "files"
2657 }`}
2658 >
2659 <FileDiffIcon aria-hidden className="size-3" />
2660 {detail.changedFiles.toLocaleString()}
2661 </span>
2662 <PullRequestDiffStat
2663 additions={detail.additions}
2664 deletions={detail.deletions}
2665 className="shrink-0 font-mono text-2xs"
2666 />
2667 </span>
2668 </div>
2669 </div>
2670 ) : null}
2671 </div>
2672 </div>
2673
2674 <div
2675 className={cn(
2676 "col-span-2 grid",
2677 // Collapse before the scroll refund paints; only reopening eases back in. Animating
2678 // both directions makes the shrinking track fight the scrollTop correction.
2679 condensed
2680 ? "grid-rows-[0fr]"
2681 : "grid-rows-[1fr] transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
2682 )}
2683 >
2684 <div
2685 ref={foldRef}
2686 className={cn(
2687 "min-h-0 overflow-hidden transition-[opacity,transform] duration-150 ease-out motion-reduce:transform-none motion-reduce:transition-none",
2688 condensed
2689 ? "-translate-y-1 opacity-0 duration-100"
2690 : "translate-y-0 opacity-100 delay-50",
2691 )}
2692 inert={condensed}
2693 >
2694 {detail ? (
2695 <PullRequestDetailHeaderBody
2696 title={
2697 titleDraft === null ? (
2698 <PullRequestDetailTitleRow className="group gap-1">
2699 <Tooltip>
2700 <TooltipTrigger
2701 render={
2702 <h1 className="min-w-0 flex-1 truncate text-base font-semibold leading-snug">
2703 {detail.title}
2704 </h1>
2705 }
2706 />
2707 <TooltipPopup side="top">{detail.title}</TooltipPopup>
2708 </Tooltip>
2709 {canEditPullRequestChangeRequest(detail) ? (
2710 <PullRequestEditButton
2711 aria-label="Edit title"
2712 onClick={() => setTitleScope({ pullRequestKey, text: detail.title })}
2713 />
2714 ) : null}
2715 </PullRequestDetailTitleRow>
2716 ) : (
2717 // A title is one line of text, not markdown, so it takes an input rather than
2718 // the editor the description and the remarks share.
2719 <div className="space-y-2">
2720 <Input
2721 autoFocus
2722 size="sm"
2723 disabled={titleSaving}
2724 value={titleDraft}
2725 aria-label="Pull request title"
2726 onChange={(event) =>
2727 setTitleScope({ pullRequestKey, text: event.target.value })
2728 }
2729 onKeyDown={(event) => {
2730 if (event.key === "Enter") {
2731 event.preventDefault();
2732 void saveTitle(titleDraft);
2733 } else if (event.key === "Escape") {
2734 event.preventDefault();
2735 setTitleScope(null);
2736 }
2737 }}
2738 />
2739 <div className="flex justify-end gap-2">
2740 <Button
2741 size="xs"
2742 variant="ghost"
2743 disabled={titleSaving}
2744 onClick={() => setTitleScope(null)}
2745 >
2746 Cancel
2747 </Button>
2748 <Button
2749 size="xs"
2750 variant="outline"
2751 disabled={
2752 !canWriteSourceControl || titleSaving || titleDraft.trim().length === 0
2753 }
2754 onClick={() => void saveTitle(titleDraft)}
2755 >
2756 {titleSaving ? "Saving..." : "Save"}
2757 </Button>
2758 </div>
2759 </div>
2760 )
2761 }
2762 author={
2763 <PullRequestActorLabel actor={detail.author} profileUrl={authorProfileUrl} />
2764 }
2765 updated={<span>updated {formatRelativeTimeLabel(detail.updatedAt)}</span>}
2766 checkout={
2767 checkoutCommand ? (
2768 <PullRequestCopyableCode
2769 key={checkoutCommand}
2770 value={checkoutCommand}
2771 target="pull request checkout command"
2772 copyLabel="Copy checkout command"
2773 copiedLabel="Checkout command copied"
2774 className="ml-auto font-mono"
2775 tooltipSide="bottom"
2776 onError={onCheckoutCommandError}
2777 />
2778 ) : null
2779 }
2780 base={
2781 /* An out-of-date base wears the warning on the branch name itself, so the
2782 name is amber and pointing at either the name or the mark opens the way
2783 out. Up to date, the name keeps its plain tooltip. */
2784 freshness ? (
2785 <PullRequestBaseFreshnessWarning
2786 baseBranch={detail.baseBranch}
2787 freshness={freshness}
2788 pending={actionPending}
2789 onUpdate={(method) => void perform("update-branch", undefined, method)}
2790 className="max-w-[40%]"
2791 >
2792 {isStackedPullRequest ? (
2793 <PullRequestGlyph.stack
2794 aria-label="Stacked pull request"
2795 className="size-3 shrink-0"
2796 />
2797 ) : null}
2798 <code className="flex min-w-0">
2799 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2800 </code>
2801 </PullRequestBaseFreshnessWarning>
2802 ) : (
2803 <Tooltip>
2804 <TooltipTrigger
2805 render={
2806 <span className="inline-flex min-w-0 max-w-[40%] shrink-0 items-center gap-1">
2807 {isStackedPullRequest ? (
2808 <PullRequestGlyph.stack
2809 aria-label="Stacked pull request"
2810 className="size-3 shrink-0"
2811 />
2812 ) : null}
2813 <code className="flex min-w-0">
2814 <MiddleTruncate value={detail.baseBranch} showTitle={false} />
2815 </code>
2816 </span>
2817 }
2818 />
2819 <TooltipPopup side="top">
2820 {isStackedPullRequest
2821 ? `Stacked on ${detail.baseBranch}`
2822 : detail.baseBranch}
2823 </TooltipPopup>
2824 </Tooltip>
2825 )
2826 }
2827 head={
2828 <PullRequestCopyableCode
2829 key={detail.headBranch}
2830 value={detail.headBranch}
2831 target="branch name"
2832 copyLabel="Copy pull request branch"
2833 copiedLabel="Branch name copied"
2834 />
2835 }
2836 files={`${detail.changedFiles.toLocaleString()} ${detail.changedFiles === 1 ? "file" : "files"}`}
2837 diffStat={
2838 <PullRequestDiffStat
2839 additions={detail.additions}
2840 deletions={detail.deletions}
2841 className="shrink-0 font-mono text-xs"
2842 />
2843 }
2844 />
2845 ) : null}
2846 {detail?.state === "open" && !condensed ? (
2847 <div className="px-4 pb-3">
2848 <MergebotQueueBadge
2849 environmentId={environmentId}
2850 repository={detail.repository}
2851 number={detail.number}
2852 headSha={detail.headSha}
2853 />
2854 </div>
2855 ) : null}
2856 </div>
2857 </div>
2858
2859 {detail ? (
2860 <PullRequestDetailTabBar
2861 tabs={visibleTabs}
2862 value={tab}
2863 onValueChange={setTab}
2864 onTabIntent={(item) => {
2865 if (item === "code") void loadCodeTab();
2866 }}
2867 >
2868 {tab === "summary" ? (
2869 showsApproveWorkflows ? (
2870 <Tooltip>
2871 <TooltipTrigger
2872 render={
2873 <span className="ml-auto inline-flex shrink-0">
2874 <Button
2875 size="xs"
2876 variant="warning-outline"
2877 disabled={actionPending}
2878 {...inlineConfirm.props(
2879 "approve-workflows",
2880 () => void perform("approve-workflows"),
2881 )}
2882 aria-label={
2883 pendingAction === "approve-workflows"
2884 ? "Approving..."
2885 : inlineConfirm.isArmed("approve-workflows")
2886 ? "Confirm approve?"
2887 : "Approve workflows to run"
2888 }
2889 >
2890 <PlayIcon aria-hidden className="size-3.5" />
2891 <span className="inline-grid">
2892 <span aria-hidden className="invisible col-start-1 row-start-1">
2893 Approve workflows to run
2894 </span>
2895 <span aria-hidden className="invisible col-start-1 row-start-1">
2896 Confirm approve?
2897 </span>
2898 <span className="col-start-1 row-start-1">
2899 {pendingAction === "approve-workflows"
2900 ? "Approving..."
2901 : inlineConfirm.isArmed("approve-workflows")
2902 ? "Confirm approve?"
2903 : "Approve workflows to run"}
2904 </span>
2905 </span>
2906 </Button>
2907 </span>
2908 }
2909 />
2910 <TooltipPopup side="top">
2911 {pendingAction === "approve-workflows"
2912 ? "Approving..."
2913 : inlineConfirm.isArmed("approve-workflows")
2914 ? "Confirm approve?"
2915 : "Approve workflows to run"}
2916 </TooltipPopup>
2917 </Tooltip>
2918 ) : (
2919 <PullRequestChecksStatusLine
2920 className="text-muted-foreground"
2921 aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
2922 icon={
2923 checksState !== null ? (
2924 <PullRequestChecksPopover
2925 checks={detail.checks}
2926 stale={checksStale}
2927 checksState={checksState}
2928 threadRef={threadRef}
2929 />
2930 ) : (
2931 <CircleDotIcon aria-hidden className="size-3.5" />
2932 )
2933 }
2934 label={checksSummary}
2935 />
2936 )
2937 ) : tab === "timeline" ? (
2938 <div className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
2939 <PullRequestMetaLine
2940 className={cn(
2941 "whitespace-nowrap text-2xs transition-opacity",
2942 (activityPending || activityError) && "opacity-35",
2943 )}
2944 >
2945 <span
2946 className="inline-flex items-center gap-1"
2947 aria-label={
2948 activityError
2949 ? "Comments unavailable"
2950 : `${detail.commentCount.toLocaleString()} ${
2951 detail.commentCount === 1 ? "comment" : "comments"
2952 }`
2953 }
2954 >
2955 <MessageSquareIcon aria-hidden className="size-3" />
2956 {activityError
2957 ? "—"
2958 : activityPending
2959 ? "…"
2960 : detail.commentCount.toLocaleString()}
2961 </span>
2962 <span
2963 className="inline-flex items-center gap-1"
2964 aria-label={
2965 activityError
2966 ? "Commits unavailable"
2967 : `${detail.commits.length.toLocaleString()} ${
2968 detail.commits.length === 1 ? "commit" : "commits"
2969 }`
2970 }
2971 >
2972 <GitCommitHorizontalIcon aria-hidden className="size-3" />
2973 {activityError
2974 ? "—"
2975 : activityPending
2976 ? "…"
2977 : detail.commits.length.toLocaleString()}
2978 </span>
2979 {approvalCount > 0 ? (
2980 <span
2981 className={cn(
2982 "inline-flex items-center gap-1",
2983 pullRequestReviewOutcomeToneClassName("approved"),
2984 )}
2985 >
2986 <PullRequestReviewOutcomeIcon outcome="approved" className="size-3" />
2987 {approvalCount.toLocaleString()}
2988 <span className="sr-only">
2989 {approvalCount === 1 ? "approval" : "approvals"}
2990 </span>
2991 </span>
2992 ) : null}
2993 </PullRequestMetaLine>
2994 <Button
2995 size="xs"
2996 variant="ghost-muted"
2997 aria-label={
2998 timelineOrder === "newest"
2999 ? "Show oldest activity first"
3000 : "Show newest activity first"
3001 }
3002 onClick={() =>
3003 setTimelineOrder((value) => (value === "newest" ? "oldest" : "newest"))
3004 }
3005 >
3006 <ArrowDownUpIcon aria-hidden className="size-3" />
3007 {timelineOrder === "newest" ? "Newest first" : "Oldest first"}
3008 </Button>
3009 </div>
3010 ) : null}
3011 </PullRequestDetailTabBar>
3012 ) : null}
3013 </div>
3014
3015 <div
3016 className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
3017 onScrollCapture={(event) => {
3018 const scroller = scrollerByTab.current[tab];
3019 if (event.target !== scroller || scroller === null || scroller === undefined) return;
3020 const top = scroller.scrollTop;
3021 setChromeCondensed((previous) => {
3022 let next = previous;
3023 const foldHeight = foldRef.current?.scrollHeight ?? 0;
3024 // The condensed row remains mounted, so refund only the height that actually leaves.
3025 const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0);
3026 if (previous) {
3027 // The hard top reopens the chrome with no refund: the reader asked for the top,
3028 // and moving them a fold's height back down would snatch it away — the fold
3029 // slides in above while the content stays where they left it.
3030 if (top < 4 && foldHeight > 0) {
3031 next = false;
3032 }
3033 } else if (foldHeight > 0 && top > foldHeight + 32) {
3034 compensationRef.current = { tab, scroller, delta: -chromeDelta };
3035 next = true;
3036 }
3037 chromeStateByTab.current[tab] = next;
3038 return next;
3039 });
3040 }}
3041 >
3042 {detailQuery.error && !detail ? (
3043 <PullRequestsUnavailableState
3044 {...(isPullRequestNotFound(detailQuery.failure)
3045 ? {
3046 title: `Pull request #${reference.number} not found`,
3047 error:
3048 "It may be an issue rather than a pull request, or this account can't see it.",
3049 }
3050 : { error: detailQuery.error })}
3051 refreshing={detailQuery.isPending}
3052 onRetry={refreshDetail}
3053 {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})}
3054 />
3055 ) : detail ? (
3056 <PullRequestMarkdownContext value={markdownContext}>
3057 {mountedTabs.has("summary") ? (
3058 <div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>
3059 <PullRequestSummaryTab
3060 scrollerRef={scrollerRefs.summary}
3061 environmentId={environmentId}
3062 threadRef={threadRef}
3063 reference={reference}
3064 detail={detail}
3065 activityPending={activityPending}
3066 checksStale={checksStale}
3067 activityError={activityError}
3068 pendingFinding={handoff}
3069 fixFindingLabel={handoffLabels.fixFinding}
3070 fixCheckLabel={handoffLabels.fixCheck}
3071 {...(canFixFindings ? { onFixFinding: startFixFinding } : {})}
3072 onRefresh={refreshDetail}
3073 onRefreshChecks={refreshFromHost}
3074 />
3075 </div>
3076 ) : null}
3077 {mountedTabs.has("guide") ? (
3078 <div className={cn("absolute inset-0", tab !== "guide" && "invisible")}>
3079 <PullRequestGuideTab
3080 environmentId={environmentId}
3081 reference={reference}
3082 active={tab === "guide"}
3083 title={detail.title}
3084 workspaceRoot={detail.workspaceRoot}
3085 canComment={
3086 detail.capabilities.review.inlineComment && detail.viewerPermissions.comment
3087 }
3088 onAskAboutLines={(comment, request) =>
3089 addSelectionToAgent({ comment, request }, { keepGuide: true })
3090 }
3091 onAskAboutStop={askAboutGuideStop}
3092 onFinishReview={
3093 detail.capabilities.review.verdicts.some((verdict) =>
3094 detail.viewerPermissions.verdicts.includes(verdict),
3095 )
3096 ? () => setOpenReviewRequest((count) => count + 1)
3097 : null
3098 }
3099 onOpenFile={openFileInCode}
3100 onTourShown={onGuideShown}
3101 />
3102 </div>
3103 ) : null}
3104 {mountedTabs.has("timeline") ? (
3105 <div className={cn("absolute inset-0", tab !== "timeline" && "invisible")}>
3106 {activityPending ? (
3107 <PullRequestTimelineGhost />
3108 ) : activityError ? (
3109 <PullRequestActivityUnavailableState
3110 error={activityError}
3111 onRetry={activityQuery.refresh}
3112 />
3113 ) : (
3114 <PullRequestTimelineTab
3115 scrollerRef={scrollerRefs.timeline}
3116 detail={detail}
3117 environmentId={environmentId}
3118 threadRef={threadRef}
3119 reference={reference}
3120 order={timelineOrder}
3121 onOpenCommit={openCommit}
3122 onRefresh={refreshDetail}
3123 />
3124 )}
3125 </div>
3126 ) : null}
3127 {mountedTabs.has("code") ? (
3128 <div className={cn("absolute inset-0", tab !== "code" && "invisible")}>
3129 <Suspense fallback={<DiffPanelLoadingState label="Loading pull request diff..." />}>
3130 <PullRequestCodeTab
3131 scrollerRef={scrollerRefs.code}
3132 onAddToAgentSelection={addSelectionToAgent}
3133 environmentId={environmentId}
3134 reference={reference}
3135 detail={detail}
3136 selectedCommitOid={selectedCodeCommitOid}
3137 onSelectedCommitChange={selectCodeCommit}
3138 pendingFinding={handoff}
3139 fixFindingLabel={handoffLabels.fixFinding}
3140 {...(canFixFindings ? { onFixFinding: startFixFinding } : {})}
3141 onRefresh={refreshDetail}
3142 refreshToken={codeRefreshToken}
3143 revealRequest={codeRevealRequest}
3144 />
3145 </Suspense>
3146 </div>
3147 ) : null}
3148 </PullRequestMarkdownContext>
3149 ) : null}
3150 </div>
3151
3152 {/* Float over the content; do not reserve a footer or padding in the PR tabs. */}
3153 {detail ? (
3154 <div className="absolute right-4 bottom-3 z-20">
3155 <PullRequestComposer
3156 key={JSON.stringify([
3157 environmentId,
3158 reference.projectId,
3159 reference.host,
3160 reference.repository,
3161 reference.number,
3162 ])}
3163 environmentId={environmentId}
3164 reference={reference}
3165 detail={detail}
3166 actionPending={actionPending}
3167 onCommentAction={performCommentAction}
3168 onCommented={refreshDetail}
3169 onReviewSubmitted={refreshDetail}
3170 openReviewRequest={openReviewRequest}
3171 />
3172 </div>
3173 ) : null}
3174 </div>
3175 );
3176}