import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { type EnvironmentId, type PullRequestAction, type PullRequestMergeMethod, type PullRequestListEntry, type PullRequestUpdateMethod, type PullRequestRef, resolveEnvironmentMachineKind, type ScopedThreadRef, } from "@t3tools/contracts"; import { ArrowDownUpIcon, ArrowLeftIcon, ArrowUpRightIcon, BookOpenIcon, CircleDotIcon, ChevronDownIcon, ExternalLinkIcon, FileDiffIcon, FolderGit2Icon, GitBranchIcon, GitCommitHorizontalIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, LayersIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, MoreHorizontalIcon, PanelRightIcon, PencilIcon, PlayIcon, RotateCcwIcon, TriangleAlertIcon, } from "lucide-react"; import { lazy, Suspense, type MouseEvent as ReactMouseEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useClientSettings } from "~/hooks/useSettings"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, selectProjectGroupingSettings, } from "~/logicalProject"; import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; import { useProjects, useServerConfigs } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { PullRequestStackMenu } from "./PullRequestStackMenu"; import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { useUiStateStore } from "~/uiStateStore"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Menu, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger, } from "../ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PullRequestMarkdownContext } from "./PullRequestMarkdown"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; import { buildAddSelectionToAgentHandoff, buildAskAboutPullRequestHandoff, buildExplainPullRequestHandoff, buildFixFindingHandoff, buildFixFindingsHandoff, buildResolveConflictsPrompt, handoffPrompt, handoffReviewComments, latestPullRequestReviewOutcomes, isStackedPullRequestBase, pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, pullRequestComposerTarget, pullRequestCheckoutCommand, pullRequestFindingKey, pullRequestHandoffLabels, PULL_REQUEST_MERGE_METHOD_LABELS, readableFailure, readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, allowsSinglePullRequestMerge, resolveBaseFreshness, resolvePullRequestMergeMethod, type PullRequestFinding, shouldRefreshPullRequestActivity, writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic"; import { resolvePickableEnvironments, type PickableEnvironment, } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, PullRequestReviewOutcomeIcon, pullRequestChecksState, pullRequestReviewOutcomeToneClassName, resolvePullRequestState, summarizePullRequestChecks, } from "./pullRequestPresentation"; type DetailTab = "summary" | "timeline" | "code"; const ACTION_SUCCESS_LABELS: Record = { merge: "Pull request merged", ready: "Marked ready for review", draft: "Converted to draft", close: "Pull request closed", reopen: "Pull request reopened", "update-branch": "Branch updated with the base branch", // True whichever it did: a pull request that was already mergeable merges the moment this is // armed, and the client has no way to tell that apart from one still waiting on something. "enable-auto-merge": "Auto-merge turned on — merges as soon as this is ready, sooner if it already is", "disable-auto-merge": "Auto-merge turned off", revert: "Revert pull request opened", "approve-workflows": "Workflows approved", }; /** Said as the thing that did not happen, rather than as the operation that returned an error. */ const ACTION_FAILURE_LABELS: Record = { merge: "Could not merge this pull request", ready: "Could not mark this ready for review", draft: "Could not convert this to a draft", close: "Could not close this pull request", reopen: "Could not reopen this pull request", "update-branch": "Could not update this branch", "enable-auto-merge": "Could not turn on auto-merge", "disable-auto-merge": "Could not turn off auto-merge", revert: "Could not open a revert pull request", "approve-workflows": "Could not approve workflows", }; /** What to try, for the times the host says only that it refused. */ const ACTION_FAILURE_HINTS: Record = { merge: "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.", ready: "The host refused it. Check that you have write access to this repository.", draft: "The host refused it. Check that you have write access to this repository.", close: "The host refused it. Check that you have write access, or that you opened it.", reopen: "The host refused it. Check that you have write access, and that the branch still exists.", // Said for the merge commit, which is what an update is unless a rebase was asked for. The // rebase has its own reasons to fail and its own sentence below. "update-branch": "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.", // The one refusal that is usually a repository setting rather than anything about this branch: // GitHub will not arm an auto-merge at all unless the repository has the feature switched on. "enable-auto-merge": "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.", "disable-auto-merge": "The host refused it. Check that you have write access, and that the merge has not already happened.", revert: "The host refused it. Check that you have write access and that this pull request was merged on the host.", "approve-workflows": "The host refused it. Check that you have Actions write access and that these workflow runs are still awaiting approval.", }; /** * Said instead of the update hint when the reader asked for a rebase: it is the one that fails on * its own merits, because GitHub replays the commits and stops at the first that does not apply. * Offering the merge commit only makes sense to somebody who did not already choose it. */ const UPDATE_BRANCH_REBASE_FAILURE_HINT = "The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work."; const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ { value: "summary", label: "Summary" }, { value: "timeline", label: "Timeline" }, { value: "code", label: "Code" }, ]; // The diff viewer pulls in its worker pool, so load it only when the reader approaches Code. // Start the download on tab hover or focus, before the click, without loading it for every PR. const loadCodeTab = () => import("./PullRequestCodeTab"); const PullRequestCodeTab = lazy(loadCodeTab); /** * What the last hand-off wrote into each draft, kept outside React because the panel that wrote it * is closed by the time the next one opens. It is how a prompt the reader has since edited is told * apart from the one they were handed: only the sentence still exactly as written may be replaced. */ const lastHandoffPromptByDraft = new Map(); const composerTargetKey = (target: ScopedThreadRef | DraftId): string => typeof target === "string" ? target : scopedThreadKey(target); /** * Which server the checkout and the hand-offs land on, where more than one of them holds this * repository. The list picked one of them to show the pull request under, so that everything on * it is read from somewhere; where the reader wants to work is a separate answer, and this is * where they give it. */ function ActOnEnvironmentPicker({ environments, value, onChange, disabled, }: { environments: ReadonlyArray; value: EnvironmentId; onChange: (environmentId: EnvironmentId) => void; disabled: boolean; }) { return ( <> onChange(environmentId as EnvironmentId)} > {environments.map((environment) => ( {/* The radio item lays its children out as one block, so the icon and the label need their own row to share a line. */} {environment.label} ))} ); } /** The number is a link in every place the host writes it, so the right-click that copies one has to answer here too — otherwise the platform's own cut/paste menu opens over it. */ const openNumberContextMenu = ( event: ReactMouseEvent, detail: { readonly url: string; readonly provider: string }, ): void => { event.preventDefault(); event.stopPropagation(); void showPullRequestLinkContextMenu({ url: detail.url, openLabel: openOnHostLabel(detail.provider), position: { x: event.clientX, y: event.clientY }, }); }; function PullRequestCopyableCode({ value, target, copyLabel, copiedLabel, className, tooltipSide = "top", onError, }: { readonly value: string; readonly target: string; readonly copyLabel: string; readonly copiedLabel: string; readonly className?: string; readonly tooltipSide?: "top" | "bottom"; readonly onError?: (error: Error) => void; }) { const { copyToClipboard, isCopied } = useCopyToClipboard({ target, timeout: 1600, ...(onError ? { onError } : {}), }); return ( copyToClipboard(value)} /> } > {value} {`${isCopied ? "Copied" : copyLabel}: ${value}`} ); } /** * The stale-branch warning, said beside the branch it is about rather than as a bar of its own. * The banner this replaces held a row of chrome open across the top of every pull request that * had fallen behind, pushing the reading down to say something that is true of the base branch * and nothing else; as a mark on the base branch it is where a reader would look for it, and the * sentence and the way out of it arrive together the moment the mark is pointed at. * * A popover rather than a tooltip because what it holds can be pressed: a tooltip's layer takes * no pointer, and a control nobody can reach is worse than no control. */ function PullRequestBaseFreshnessWarning({ baseBranch, freshness, pending, onUpdate, iconClassName, }: { readonly baseBranch: string; readonly freshness: { readonly behindBy: number | null; readonly methods: ReadonlyArray; }; readonly pending: boolean; readonly onUpdate: (method: PullRequestUpdateMethod) => void; readonly iconClassName?: string; }) { const behind = freshness.behindBy === null ? "" : ` by ${freshness.behindBy.toLocaleString()} ${ freshness.behindBy === 1 ? "commit" : "commits" }`; const summary = `This branch is out-of-date with ${baseBranch}${behind}.`; return ( } >

{summary}

Changes can be cleanly merged.

{/* Each way the host offers and this reader may take, as its own button: a split button would need a menu inside a popover, and two buttons say the same thing in one layer. */} {freshness.methods.length > 0 ? ( {freshness.methods.map((method) => ( ))} ) : null}
); } export function PullRequestDetailPanel({ environmentId, threadRef = null, reference: requestedReference, listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, onClose, context = "page", composerDraftTarget, onBack, onSelectPullRequest, }: { environmentId: EnvironmentId; onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; /** * The thread this panel sits beside, if any. Links that are not the pull * request itself (check details, host permalinks) can open in that thread's * in-app browser when the user has asked for it; the page has no thread, so * there they always go to the system browser. */ threadRef?: ScopedThreadRef | null; reference: PullRequestRef; /** Row fields already loaded by the pull-request list, used while richer detail arrives. */ listEntry?: PullRequestListEntry | null; /** * Bumped by whatever holds the panel when a reader asks for everything on screen to be read * again. The panel owns its own reads, so the page cannot refresh them for it — it says when, * and this says it. */ refreshToken?: number; /** * An action changed this pull request on the host, so a list showing it is now out of date. * Told rather than assumed: only the page knows whether it is showing one. */ onActed?: () => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; /** * Beside a thread, the checkout affordance disappears: the panel is showing that thread's * own pull request, so the branch is already under the reader's feet — and checking it out * again is at best a no-op and at worst git refusing a branch two checkouts. */ context?: "page" | "thread"; /** * The open thread's composer. Beside the thread whose own pull request this is, hand-offs * land here instead of opening a new thread — the branch is already under the reader's feet. */ composerDraftTarget?: ScopedThreadRef | DraftId; /** * Beside a thread, the way back to that thread's list of pull requests. The tab strip can * close this surface, but closing is not going back: the reader came from the list and * expects to land on it, with this one still open behind. */ onBack?: (() => void) | undefined; }) { const environmentConfigs = useServerConfigs(); const supportsThreadPullRequests = environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true; const reference = useMemo( () => supportsThreadPullRequests ? requestedReference : { projectId: requestedReference.projectId, repository: requestedReference.repository, number: requestedReference.number, }, [requestedReference, supportsThreadPullRequests], ); const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; const matchingListEntry = listEntry?.projectId === reference.projectId && listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && listEntry.number === reference.number ? listEntry : null; const [threadPickerOpen, setThreadPickerOpen] = useState(false); const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ readonly pullRequestKey: string; readonly oid: string | null; }>(() => ({ pullRequestKey, oid: null })); const selectedCodeCommitOid = codeCommitScope.pullRequestKey === pullRequestKey ? codeCommitScope.oid : null; const selectCodeCommit = (oid: string | null) => { setCodeCommitScope({ pullRequestKey, oid }); }; const openCommit = (oid: string) => { selectCodeCommit(oid); setTab("code"); }; // Every tab the reader has opened stays mounted behind the active one. The diff viewer // always needed this (it virtualizes against its own scroll position); the trace showed the // summary needs it too — a large description re-parses its whole markdown on every return // to the tab. `visibility` keeps boxes, sizes and scroll offsets, and takes hidden content // out of the tab order and the accessibility tree. const tabScopeKey = `${environmentId}:${pullRequestKey}`; const [tabMountState, setTabMountState] = useState(() => ({ key: tabScopeKey, tabs: new Set(["summary"]), })); // A previously visited Code tab must not fetch diffs for every later PR while hidden. const mountedTabs = tabMountState.key === tabScopeKey ? tabMountState.tabs : new Set([tab]); useEffect(() => { setTabMountState((previous) => { if (previous.key !== tabScopeKey) return { key: tabScopeKey, tabs: new Set([tab]) }; if (previous.tabs.has(tab)) return previous; return { key: tabScopeKey, tabs: new Set(previous.tabs).add(tab) }; }); }, [tab, tabScopeKey]); const [chromeCondensed, setChromeCondensed] = useState(false); // Each mounted tab remembers its own scroll chrome; short tabs cannot scroll to reopen it. const chromeStateByTab = useRef>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); const condensed = chromeCondensed; const scrollerRef = useRef(null); const foldRef = useRef(null); const condensedRowRef = useRef(null); // Refund after the fold commits so the content under the reader does not jump with its height. const compensationRef = useRef(null); useLayoutEffect(() => { if (compensationRef.current === null) return; const scroller = scrollerRef.current; const delta = compensationRef.current; compensationRef.current = null; if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); }, [condensed]); const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); const mergeMethodOverrides = useClientSettings( (settings) => settings.pullRequestMergeMethodOverrides, ); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const [mergeMethodSelection, setMergeMethodSelection] = useState<{ readonly pullRequestKey: string; readonly method: PullRequestMergeMethod; } | null>(null); const setMergeMethod = (method: PullRequestMergeMethod) => { setMergeMethodSelection({ pullRequestKey, method }); }; const [confirmation, setConfirmation] = useState<{ readonly open: boolean; readonly action: "merge" | "close" | "enable-auto-merge" | "revert" | "approve-workflows"; }>({ open: false, action: "merge" }); const confirmAction = confirmation.action; // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself // alone. One at a time whatever the key: they all check the same pull request out. const [handoff, setHandoff] = useState(null); const detailQuery = useEnvironmentQuery( pullRequestEnvironment.detail({ environmentId, input: reference }), ); const activityQuery = useEnvironmentQuery( pullRequestEnvironment.activity({ environmentId, input: reference }), ); const turnRefresh = usePullRequestTurnRefresh(environmentId); const [cachedDetail, setCachedDetail] = useState(() => readPullRequestDetailSnapshot( typeof window === "undefined" ? undefined : window.localStorage, environmentId, reference, ), ); useEffect(() => { setCachedDetail( readPullRequestDetailSnapshot( typeof window === "undefined" ? undefined : window.localStorage, environmentId, reference, ), ); }, [environmentId, pullRequestKey, reference.projectId, reference.repository, reference.number]); useEffect(() => { if (detailQuery.data === null) return; writePullRequestDetailSnapshot( typeof window === "undefined" ? undefined : window.localStorage, environmentId, reference, detailQuery.data, ); setCachedDetail(detailQuery.data); }, [ detailQuery.data, environmentId, pullRequestKey, reference.projectId, reference.repository, reference.number, ]); const resolvedCoreDetail = resolveDisplayedPullRequestDetail({ live: detailQuery.data, cached: cachedDetail, reference, }); const sharedSummary = useSharedPullRequestSummary(environmentId, reference, resolvedCoreDetail); const coreDetail = useMemo( () => resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail ? resolvedCoreDetail : { ...resolvedCoreDetail, ...sharedSummary, author: sharedSummary.author ?? resolvedCoreDetail.author, additions: sharedSummary.additions ?? resolvedCoreDetail.additions, deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions, changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles, mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability, closedAt: sharedSummary.closedAt === undefined ? resolvedCoreDetail.closedAt : sharedSummary.closedAt, mergedAt: sharedSummary.mergedAt === undefined ? resolvedCoreDetail.mergedAt : sharedSummary.mergedAt, // A summary may come from an older server that does not report draft state. Keep the // detail's required value instead of making the complete detail shape partial. isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, }, [resolvedCoreDetail, sharedSummary], ); const activity = activityQuery.data; const detail = useMemo( () => coreDetail === null ? null : { ...coreDetail, author: activity?.author ?? coreDetail.author, reviewers: activity?.reviewers ?? coreDetail.reviewers, comments: activity?.comments ?? [], commentCount: activity?.commentCount ?? 0, commentsTruncated: activity?.commentsTruncated ?? false, reviewThreads: activity?.reviewThreads ?? [], commits: activity?.commits ?? [], reactions: activity?.reactions ?? [], }, [activity, coreDetail], ); useEffect(() => { if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); }, [detail?.autoMergeMethod, pullRequestKey]); const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); const markdownContext = useMemo( () => ({ repositoryUrl: detail?.provider === "github" ? repositoryUrl : null, threadRef }), [detail?.provider, repositoryUrl, threadRef], ); const authorProfileUrl = detail?.provider === "github" && detail.author !== null && !detail.author.login.endsWith("[bot]") && repositoryUrl !== null ? new URL(`/${encodeURIComponent(detail.author.login)}`, repositoryUrl).toString() : null; const checkoutCommand = detail ? pullRequestCheckoutCommand( detail.provider, detail.number, detail.headBranch, detail.headRepositoryNameWithOwner, ) : null; const branchRefsQuery = useEnvironmentQuery( detail === null ? null : vcsEnvironment.listRefs({ environmentId, input: { cwd: detail.workspaceRoot, includeMatchingRemoteRefs: true, // listRefs keeps the current ref first and a known default second. limit: 2, }, }), ); const isStackedPullRequest = detail !== null && isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); // The host's own stack, where it keeps one. Only asked for once the detail has landed so a // pull request nobody can read costs one request rather than two. const stackReference = useMemo( () => detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests ? null : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }, [detail, reference, supportsThreadPullRequests], ); const nativeStackQuery = usePullRequestStack(environmentId, stackReference); const nativeStack = nativeStackQuery.data; const supportsStackActions = supportsThreadPullRequests && detail?.capabilities.stacks === true && detail.capabilities.stackActions === true && environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions === true; const canMergeSinglePullRequest = allowsSinglePullRequestMerge({ supportsStackActions, hasStack: nativeStack !== null, stackPending: !nativeStackQuery.isSuccess || nativeStackQuery.isPending, stackError: nativeStackQuery.error, }); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); nativeStackQuery.refresh(); }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]); const [refreshToken, setRefreshToken] = useState(0); const codeRefreshToken = refreshToken + (turnRefresh ?? 0); const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( null, ); useEffect(() => { if (!coreDetail) return; const next = { key: tabScopeKey, updatedAt: coreDetail.updatedAt }; if (shouldRefreshPullRequestActivity(activityRevision.current, next)) { activityQuery.refresh(); setRefreshToken((token) => token + 1); } activityRevision.current = next; }, [activityQuery.refresh, coreDetail, tabScopeKey]); // Reuse activity and diff until core detail reports a changed revision. Keyed by // the pull request rather than by the panel, because this one panel shows a different pull // request every time it is opened. useLiveRefresh( () => { detailQuery.refresh(); }, { key: `pull-request:${environmentId}:${pullRequestKey}` }, ); // The button, on the other hand, goes around the server's cache rather than through it: it is // the answer for a reader who can see that what they are looking at is behind. The // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); const [isInvalidating, setIsInvalidating] = useState(false); const refreshFromHost = useCallback(async () => { setIsInvalidating(true); try { await invalidate({ environmentId, input: { reference } }); refreshDetail(); setRefreshToken((token) => token + 1); } finally { setIsInvalidating(false); } }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page: the detail, and through the token below, the diff with it. const appliedForcedToken = useRef(forcedRefreshToken); useEffect(() => { if (appliedForcedToken.current === forcedRefreshToken) return; appliedForcedToken.current = forcedRefreshToken; void refreshFromHost(); }, [forcedRefreshToken, refreshFromHost]); const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); const postComment = useAtomCommand(pullRequestEnvironment.comment, { reportFailure: false }); // Which action is in flight, not merely that one is: every control here is disabled while any // of them runs, but only the button that was pressed may say what it is doing. const [pendingAction, setPendingAction] = useState(null); const actionPending = pendingAction !== null; const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); // Scoped to the pull request it was typed against, since this one panel shows a different one // every time it is opened and a half-written title must not follow it there. const [titleScope, setTitleScope] = useState<{ readonly pullRequestKey: string; readonly text: string; } | null>(null); const titleDraft = titleScope?.pullRequestKey === pullRequestKey ? titleScope.text : null; const [titleSaving, setTitleSaving] = useState(false); const newThread = useNewThreadHandler(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projects = useProjects(); const unavailableGitHubUrl = useMemo(() => { const identity = projects.find( (project) => project.id === reference.projectId && project.environmentId === environmentId, )?.repositoryIdentity; return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); }, [environmentId, projects, reference.number, reference.projectId, reference.repository]); // Project settings store the override under the sidebar group's key, which a duplicate row // borrows from its siblings, so the project alone does not always name the same key. const projectDefaultMergeMethod = useMemo(() => { const project = projects.find( (candidate) => candidate.environmentId === environmentId && candidate.id === reference.projectId, ); if (!project) return undefined; const projectKey = buildPhysicalToLogicalProjectKeyMap({ projects, settings: projectGroupingSettings, primaryEnvironmentId, }).get(derivePhysicalProjectKey(project)) ?? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); return mergeMethodOverrides[projectKey]; }, [ environmentId, mergeMethodOverrides, primaryEnvironmentId, projectGroupingSettings, projects, reference.projectId, ]); // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and // the thread is already on one server's copy of the branch. const pickableEnvironments = useMemo( () => context === "page" ? resolvePickableEnvironments( { environmentId, projectId: reference.projectId }, projects, environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, machine: resolveEnvironmentMachineKind(environment.serverConfig), })), ) : [], [context, environmentId, environments, projects, reference.projectId], ); // Which server the reader chose, and only for the pull request they chose it on: this one panel // shows a different pull request every time it is opened, and the choice does not follow. const [actingScope, setActingScope] = useState<{ readonly pullRequestKey: string; readonly environmentId: EnvironmentId; } | null>(null); const chosenEnvironmentId = actingScope?.pullRequestKey === pullRequestKey ? actingScope.environmentId : environmentId; // Null wherever there is no choice on offer — one server, or a chosen one that has since gone — // and then the panel's own server and its own checkout are the answer, as they always were. const acting = pickableEnvironments.find((entry) => entry.environmentId === chosenEnvironmentId) ?? null; const actingEnvironmentId = acting?.environmentId ?? environmentId; const prepareThread = usePreparePullRequestThreadAction({ environmentId: actingEnvironmentId, cwd: acting?.workspaceRoot ?? detail?.workspaceRoot ?? null, }); const finishAction = async ( action: PullRequestAction, method?: PullRequestMergeMethod, updateMethod?: PullRequestUpdateMethod, ) => { const result = await runAction({ environmentId, input: { ...reference, action, ...(method ? { mergeMethod: method } : {}), ...(updateMethod ? { updateMethod } : {}), }, }); setPendingAction(null); if (result._tag === "Failure") { // The host's own sentence, because it is the only thing that says why. A merge strategy a // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps // publishes no per-strategy availability to hide the control with — so "action failed" // would leave the reader pressing the same button again. const failure = squashAtomCommandFailure(result); // The hint stands for what was actually asked for: a reader who pressed Update branch is // told to check their access, not offered the merge commit they already chose. const hint = updateMethod === "rebase" ? UPDATE_BRANCH_REBASE_FAILURE_HINT : ACTION_FAILURE_HINTS[action]; toastManager.add({ type: "error", title: ACTION_FAILURE_LABELS[action], description: readableFailure(failure, hint), }); return false; } toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); // A branch update moves the head commit, which leaves the diff atom pointed at a comparison // that no longer exists — the same staleness the manual refresh button fixes, so it goes // through that path rather than a second one. Every other action here only changes metadata; // a merge does move the branch too, but it also closes the pull request, where the diff is // no longer what anyone is looking at. if (pullRequestActionNeedsHostRefresh(action)) { void refreshFromHost(); } else { refreshDetail(); } onActed?.(); return true; }; const perform = async ( action: PullRequestAction, method?: PullRequestMergeMethod, updateMethod?: PullRequestUpdateMethod, ) => { if (pendingAction !== null) return false; setPendingAction(action); return finishAction(action, method, updateMethod); }; const performCommentAction = async (body: string, action: "close" | "reopen") => { if (pendingAction !== null) return { commentPosted: false }; setPendingAction(action); const commentResult = await postComment({ environmentId, input: { ...reference, body }, }); if (commentResult._tag === "Failure") { setPendingAction(null); toastManager.add({ type: "error", title: "Could not post the comment" }); return { commentPosted: false }; } const actionSucceeded = await finishAction(action); // The comment is durable even if the state change was refused, so make it visible while the // shared action failure explains why the pull request stayed where it was. if (!actionSucceeded) refreshDetail(); return { commentPosted: true }; }; const saveTitle = async (next: string) => { const title = next.trim(); if (detail === null || titleSaving) return; if (title.length === 0 || title === detail.title) { setTitleScope(null); return; } setTitleSaving(true); const result = await update({ environmentId, input: { ...reference, title } }); setTitleSaving(false); if (result._tag === "Failure") { // The draft stays open with the words still in it: retyping a title somebody has just // rewritten is the one thing a failed save must not cost them. toastManager.add({ type: "error", title: "The title could not be saved", description: readableFailure( squashAtomCommandFailure(result), "The host refused the new title.", ), }); return; } setTitleScope(null); refreshDetail(); }; type ThreadTask = { prompt: string; reviewComments?: ReadonlyArray; }; // Beside the thread whose own pull request this is, a task belongs in that thread's composer: // the branch is already checked out under it, so opening a second thread would only scatter // the work. const attachTarget = pullRequestComposerTarget(context, composerDraftTarget); const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { const store = useComposerDraftStore.getState(); const draft = store.getComposerDraft(target); const key = composerTargetKey(target); const prompt = handoffPrompt( { prompt: draft?.prompt ?? "", lastHandoffPrompt: lastHandoffPromptByDraft.get(key) }, task.prompt, ); lastHandoffPromptByDraft.set(key, task.prompt); store.setPrompt(target, prompt); store.setReviewComments( target, handoffReviewComments(draft?.reviewComments ?? [], task.reviewComments ?? []), ); }; /** * Opens a thread on this project and leaves the task in its composer for the reader to send. * * Nothing is checked out: asking a question is not a reason to move somebody's working tree or * to make a worktree they did not ask for. The two hand-offs that do need the code call this * after preparing it, so there is one path from "a task" to "a thread holding it". */ const openThreadWithTask = async ( projectRef: ReturnType, task: ThreadTask | null, opened?: { draftId: DraftId }, ): Promise<{ draftId: DraftId } | null> => { const session = opened ?? (await newThread(projectRef).then( (result) => result, () => null, )); if (session === null) return null; if (task === null) return session; // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips // both, rather than stacking a second one under the first. What the reader typed themselves // survives — the composer they are handed is not always a fresh one, and a prompt they have // since edited is theirs rather than the hand-off's. writeTaskToComposer(session.draftId, task); return session; }; /** A question about the change, which needs a thread and nothing else. */ const startAsk = async (kind: string, task: ThreadTask) => { if (!detail || handoff !== null) return; if (attachTarget !== null) { writeTaskToComposer(attachTarget, task); toastManager.add({ type: "success", title: "Added to the composer", description: task.prompt.length > 0 ? "The question is in the composer — read it over, then send." : "The pull request is in the composer — type your question, then send.", }); return; } setHandoff(kind); const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId); const opened = await openThreadWithTask(projectRef, task); setHandoff(null); if (opened === null) { toastManager.add({ type: "error", title: "Could not open a thread", description: "Try again from the project, or open a thread first.", }); return; } toastManager.add({ type: "success", title: "Asked in a thread", // "Ask" leaves the composer empty on purpose, so saying the question is in it would send // the reader looking for something that is not there. The chips are what landed. description: task.prompt.length > 0 ? "The question is in the composer — read it over, then send." : "The pull request is in the composer — type your question, then send.", }); }; // Every handoff works the same way: check the pull request out into its own worktree, open a // thread there, and — when it carries a task — put that in the composer for the user to read // before sending. Checking out is the whole point of the ones that carry nothing. const startHandoff = async ( kind: string, task: { prompt: string; reviewComments?: ReadonlyArray } | null, // A worktree leaves whatever is open alone, which is why it is the default. Checking out in // the repository itself is what you want when the point is to run the thing where you // already work — and it moves the branch under everything else that is open there. mode: "worktree" | "local" = "worktree", ) => { if (!detail || handoff !== null) return; if (attachTarget !== null && task !== null) { writeTaskToComposer(attachTarget, task); toastManager.add({ type: "success", title: "Added to the composer", description: "The task is in the composer — read it over, then send.", }); return; } setHandoff(kind); // The menu closes on the press and takes its "Preparing..." label with it, so this is the // only thing answering for the checkout. It carries no timeout of its own: a loading toast // never expires, and an explicit one would survive the update and pin the result on screen. const toastId = toastManager.add({ type: "loading", title: "Preparing the pull request checkout...", }); // Wherever the reader chose to act: the thread, the checkout it is pointed at and the composer // the task lands in are all one server's, and picking another one moves all three. const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId); // The thread is opened before the checkout rather than after it, because the project's setup // script only runs for a checkout that knows which thread it is for — and a worktree with no // dependencies installed is not something anyone can test. const opened = await newThread(projectRef).then( (session) => session, () => null, ); if (opened === null) { setHandoff(null); // Without a thread there is nowhere for the checkout to belong: its setup script would not // run and its task would have no composer to land in. Better to stop before touching the // working tree than to prepare a worktree nobody asked for. toastManager.update(toastId, { type: "error", title: "Could not open a thread for the checkout", description: "Try again from the project, or open a thread first.", }); return; } const prepared = await prepareThread.run({ reference: detail.url, mode, threadId: opened.threadId, }); if (prepared._tag === "Failure") { setHandoff(null); // The server says what to do about it — that the branch is already checked out in the main // repository, say — and that sentence is the only way out of the failure. const detailMessage = prepareThread.error instanceof Error ? prepareThread.error.message : null; toastManager.update(toastId, { type: "error", title: "Could not prepare the pull request checkout", ...(detailMessage ? { description: detailMessage } : {}), }); return; } // The same thread again, now that there is somewhere to point it at. A local checkout has // no worktree of its own, so the thread runs where the repository already is. const pointed = await newThread(projectRef, { branch: prepared.value.branch, worktreePath: prepared.value.worktreePath, envMode: prepared.value.worktreePath === null ? "local" : "worktree", }).then( (session) => session !== null, () => false, ); if (!pointed) { setHandoff(null); // The checkout is on disk; only the thread failed to move onto it. Writing the task now // would send the agent at whatever the thread was already open on — which is the one // outcome worth stopping for, since it reads as success and is not. toastManager.update(toastId, { type: "error", title: "Checked out, but the thread stayed where it was", description: `The checkout is ready on \`${prepared.value.branch}\`. Point a thread at it from the branch picker, then ask again.`, }); return; } // Released here whatever happened next: a loading toast never expires on its own, so leaving // this set would spin forever and lock every handoff behind it until a reload. setHandoff(null); // A worktree that was already there and had been worked in keeps whatever it holds, so the // thread opens on older code than the pull request carries. Said once, in place of the // success, because everything else about the handoff did happen. const staleCheckoutToast = { type: "warning", title: "Checked out, but not on the latest commits", description: "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.", } as const; if (task === null) { toastManager.update( toastId, prepared.value.isOnPullRequestHead ? { type: "success", title: mode === "local" ? "Checked out here" : "Checked out", description: mode === "local" ? "This repository is on the pull request's branch, with a thread open on it." : "The pull request is in its own worktree, with a thread open on it.", } : staleCheckoutToast, ); return; } await openThreadWithTask(projectRef, task, opened); toastManager.update( toastId, prepared.value.isOnPullRequestHead ? { type: "success", title: "Checkout ready", description: "The task is in the composer — read it over, then send.", } : staleCheckoutToast, ); }; const askAboutPullRequest = () => { if (!detail) return; void startAsk("ask", { ...buildAskAboutPullRequestHandoff({ number: detail.number, title: detail.title, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, }), }); }; const explainPullRequest = () => { if (!detail) return; void startAsk("explain", { ...buildExplainPullRequestHandoff({ number: detail.number, title: detail.title, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, }), }); }; const addSelectionToAgent = (selection: PullRequestAgentSelectionInput) => { if (!detail) return; void startAsk( `selection:${selection.comment.id}`, buildAddSelectionToAgentHandoff({ number: detail.number, title: detail.title, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, comment: selection.comment, request: selection.request, }), ); }; const startCheckout = (mode: "worktree" | "local") => { if (!detail) return; void startHandoff(`checkout:${mode}`, null, mode); }; /** One finding, handed over on its own — the surfaces that show findings call this. */ const startFixFinding = (finding: PullRequestFinding) => { if (!detail) return; void startHandoff( pullRequestFindingKey(finding), buildFixFindingHandoff({ number: detail.number, title: detail.title, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, finding, }), ); }; const startFixFindings = () => { if (!detail) return; void startHandoff( "findings", buildFixFindingsHandoff({ number: detail.number, title: detail.title, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, reviewThreads: detail.reviewThreads, comments: detail.comments, checks: detail.checks, commentsTruncated: detail.commentsTruncated, }), ); }; const startResolveConflicts = () => { if (!detail) return; void startHandoff("conflicts", { prompt: buildResolveConflictsPrompt({ number: detail.number, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, }), }); }; // The host says which strategies it offers at all; the repository narrows that to the ones // it actually allows. const allowedMergeMethods = detail ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) : []; const currentMergeMethod = mergeMethodSelection?.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : null; const selectedMergeMethod = resolvePullRequestMergeMethod( allowedMergeMethods, currentMergeMethod, projectDefaultMergeMethod, lastSelectedMergeMethod, ); const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`; const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; // Only an outright yes arms it. A host that reports nothing has not said the merge is already // spoken for, and an off switch for something that may not be on says the wrong thing twice. const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true; const armedMergeMethod = detail?.autoMergeMethod; const armedAutoMergeLabel = armedMergeMethod ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` : "Auto-merge"; const workflowApprovalsRequired = detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0; // Out of date with the base, and still cleanly mergeable — the one pairing an update button // exists for. Null everywhere else, including hosts that cannot compare at all. const freshness = detail === null ? null : resolveBaseFreshness(detail); // A host that cannot produce a patch has no Code tab to open. The tabs themselves stay hidden // until the detail arrives, so the loading ghost is the panel's only unfinished UI. const visibleTabs = TABS.filter( (item) => item.value !== "code" || detail === null || detail.capabilities.diff, ); // The Code tab can be opened while the detail is still on its way, and the detail may then say // this host has no patch to show. The tab goes, so whoever was standing on it is moved back to // the summary rather than left looking at a panel that is no longer reachable. useEffect(() => { if (!visibleTabs.some((item) => item.value === tab)) setTab("summary"); }, [tab, visibleTabs]); // Two questions, both of which have to say yes: whether this host can do it at all, and // whether this account may. A reader with read access on someone else's project sees the pull // request and none of the buttons that would only ever be refused. const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); const checksState = detail ? pullRequestChecksState(detail.checks) : null; // The merge state remains in one stable slot from waiting through completion. Conflicts take // the slot while they need a person; the armed badge remains beside them so that state is not lost. const primaryAction = detail ? resolvePullRequestPrimaryControl({ state: detail.state, isDraft: detail.isDraft, mergeability: detail.mergeability, checksState, autoMergeEnabled: detail.autoMergeEnabled, hasMergeMethod: allowedMergeMethods.length > 0, canMerge: canMergeSinglePullRequest && can("merge"), canMarkReady: can("ready"), canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"), }) : null; // What the menu's action group holds. Named once so the separators around it are drawn from // the same answer as its contents, rather than on the assumption that it has any. const showsDraftToggle = detail?.state === "open" && can(detail.isDraft ? "ready" : "draft") && !(detail.isDraft && primaryAction === "ready"); const showsAutoMerge = canMergeSinglePullRequest && detail?.state === "open" && ((autoMergeArmed && can("disable-auto-merge")) || (!autoMergeArmed && primaryAction !== "enable-auto-merge" && !detail.isDraft && !conflicting && can("enable-auto-merge") && allowedMergeMethods.length > 0)); const showsMergeNow = canMergeSinglePullRequest && detail?.state === "open" && (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") && can("merge") && !detail.isDraft && !conflicting && allowedMergeMethods.length > 0; const showsMergeMethods = detail?.state === "open" && can("merge") && !detail.isDraft && !conflicting && allowedMergeMethods.length > 1; // The pull request number carries this state in the overview and the right-panel tab mirrors // it. Conflicts take the action slot while they need a person, but do not change the PR state. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; // Approvals that still stand, and only those. A superseded one is dimmed beside the reviewer // who gave it, so counting it here would have the header assert in a number what the row next // to it has just qualified. // // Not counted at all from a conversation this page only holds the recent end of: an approval // older than the window would be missing, and "1" beside a tick is read as the whole answer. // The Summary tab's row can say it may be short; a bare number cannot, so it stays away. const approvalCount = detail && !detail.commentsTruncated ? latestPullRequestReviewOutcomes(detail.comments, detail.commits).filter( (entry) => entry.outcome === "approved" && !entry.stale, ).length : 0; // The list already has the pull request's identity and summary. Keep them on screen // and let the richer detail read replace the remaining placeholders in place. if (detailQuery.isPending && !detail) { return ; } return (
{threadPickerOpen && detail ? ( ) : null}
{detail && statePresentation ? ( <> {onBack ? ( } /> Back to pull requests ) : null} void readLocalApi()?.shell.openExternal(repositoryUrl)} className="min-w-0 cursor-pointer truncate text-left font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" > {detail.repository} ) : ( {detail.repository} ) } /> {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository} void readLocalApi()?.shell.openExternal(detail.url)} onContextMenu={(event) => openNumberContextMenu(event, detail)} className={cn( "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline", statePresentation.toneClassName, )} aria-label={`Open pull request #${detail.number} on host`} > #{detail.number} } /> {openOnHostLabel(detail.provider)} ) : null}
{detail && statePresentation ? ( <> {onBack ? ( } /> Back to pull requests ) : null} void readLocalApi()?.shell.openExternal(detail.url)} onContextMenu={(event) => openNumberContextMenu(event, detail)} className={cn( "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 hover:underline", statePresentation.toneClassName, )} aria-label={`Open pull request #${detail.number} on host`} > #{detail.number} } /> {openOnHostLabel(detail.provider)} {detail.title} } /> {detail.title} ) : null}
{detail ? ( {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( ) : null} {nativeStack ? ( 0 } canRebase={ nativeStackQuery.isFresh && supportsStackActions && detail.viewerPermissions.stackRebase === true } onActed={() => { refreshDetail(); onActed?.(); }} /> ) : null} {context === "page" ? ( ) : null} {/* Checking a pull request out is the reason to open one here at all, so it is a button of its own rather than a side effect of asking an agent for something. It asks where, because the two answers are not interchangeable: one leaves your work where it is, the other moves the repository you are standing in. Only on the page: beside a thread the branch is already checked out right there. */} {context === "page" ? ( {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} } /> } /> Check out this pull request startCheckout("worktree")}> In a separate worktree Its own folder and thread. Nothing you have open moves. startCheckout("local")}> In this repository Switches the branch you are working in, like `gh pr checkout`. {pickableEnvironments.length > 0 ? ( setActingScope({ pullRequestKey, environmentId: next })} disabled={handoff !== null} /> ) : null} ) : null} {/* Said where the Merge button is, because it is the answer to why nobody has pressed it: the merge is already asked for, and the host is holding it. */} {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( {armedAutoMergeLabel} } /> {armedAutoMergeLabel}: the host will merge this on its own once its requirements are met ) : null} {primaryAction === "resolve" ? ( } /> {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} ) : primaryAction === "ready" ? ( } /> Ready for review ) : primaryAction === "enable-auto-merge" ? ( } /> {pendingAction === "enable-auto-merge" ? "Enabling..." : pendingAutoMergeLabel} ) : primaryAction === "auto-merge-armed" ? ( {armedAutoMergeLabel} } /> {armedAutoMergeLabel}: the host will merge this on its own once its requirements are met ) : primaryAction === "merge" ? ( } /> {pendingAction === "merge" ? "Merging..." : selectedMergeMethodLabel} ) : (primaryAction === "merged" || primaryAction === "closed") && statePresentation !== null ? ( {statePresentation.label} ) : null} } > } /> More pull request actions void refreshFromHost()} > Refresh {handoff === "ask" ? "Opening..." : "Ask a question"} {attachTarget !== null ? "Adds the pull request to this thread's composer." : "Opens a thread that knows which pull request you mean."} {handoff === "explain" ? "Opening..." : "Explain this PR"} A walk through the diff and what to read closely. {handoff === "findings" ? "Preparing..." : handoffLabels.fixFindings} {pickableEnvironments.length > 0 ? ( setActingScope({ pullRequestKey, environmentId: next })} disabled={handoff !== null} /> ) : null} {detail.state === "open" ? ( <> {/* Only where the button row could not take it: "Ready for review" on a draft is the primary header button, so offering it here as well would show the same action twice. */} {showsDraftToggle ? ( void perform(detail.isDraft ? "ready" : "draft")} > {detail.isDraft ? ( ) : ( )} {detail.isDraft ? "Ready for review" : "Convert to draft"} ) : null} {showsMergeNow ? ( setConfirmation({ open: true, action: "merge" })} > Merge now ) : null} {/* The same merge, left with the host to carry out once its requirements pass. A conflicting branch cannot be armed because nothing the host waits for will clear the conflict. */} {autoMergeArmed && can("disable-auto-merge") ? ( void perform("disable-auto-merge")} > Disable auto-merge ) : showsAutoMerge ? ( setConfirmation({ open: true, action: "enable-auto-merge" }) } > Enable auto-merge ) : null} {/* A preference for the merge action rather than a second action, so it is a radio group here instead of a chevron welded to the Merge pill. Hidden while conflicting: every method would fail. */} {/* Only where merging is on offer at all: a strategy to merge with is not a choice for someone who may not merge. */} {showsMergeMethods ? ( <> {/* Only below the draft control. A host with no draft of its own, or a draft whose control is already the header button, would leave this against the separator that opened the group. */} {showsDraftToggle || showsMergeNow || showsAutoMerge ? ( ) : null} { const selectedMethod = method as PullRequestMergeMethod; setMergeMethod(selectedMethod); setLastSelectedMergeMethod(selectedMethod); }} > {allowedMergeMethods.map((method) => ( {/* The radio item lays its children out as one block, so the icon and the label need their own row to share a line. */} {PULL_REQUEST_MERGE_METHOD_LABELS[method]} ))} ) : null} {pullRequestActionMenuHasGroup( showsDraftToggle, showsAutoMerge || showsMergeNow, showsMergeMethods, ) ? ( ) : null} ) : null} void readLocalApi()?.shell.openExternal(detail.url)}> {openOnHostLabel(detail.provider)} void writeTextToClipboard(detail.url)}> Copy link {detail.state === "open" && can("close") ? ( <> setConfirmation({ open: true, action: "close" })} > Close pull request ) : detail.state === "closed" && can("reopen") ? ( <> void perform("reopen")}> Reopen pull request ) : detail.state === "merged" && can("revert") ? ( <> setConfirmation({ open: true, action: "revert" })} > Revert changes ) : null} ) : null} {onClose ? ( ) : null}
{detail ? (
{formatRelativeTimeLabel(detail.updatedAt)} {isStackedPullRequest ? ( ) : null} {detail.baseBranch} } /> {isStackedPullRequest ? `Stacked on ${detail.baseBranch}` : detail.baseBranch} {freshness ? ( void perform("update-branch", undefined, method)} iconClassName="size-3" /> ) : null} {detail.headBranch} } /> {detail.headBranch} {detail.changedFiles.toLocaleString()}
) : null}
{detail ? (
{titleDraft === null ? (
{detail.title} } /> {detail.title} {canEditPullRequestChangeRequest(detail) ? ( ) : null}
) : ( // A title is one line of text, not markdown, so it takes an input rather than // the editor the description and the remarks share.
setTitleScope({ pullRequestKey, text: event.target.value }) } onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void saveTitle(titleDraft); } else if (event.key === "Escape") { event.preventDefault(); setTitleScope(null); } }} />
)}
updated {formatRelativeTimeLabel(detail.updatedAt)} {checkoutCommand ? ( toastManager.add({ type: "error", title: "Could not copy checkout command", description: error.message, }) } /> ) : null}
{isStackedPullRequest ? ( ) : null} {detail.baseBranch} } /> {isStackedPullRequest ? `Stacked on ${detail.baseBranch}` : detail.baseBranch} {freshness ? ( void perform("update-branch", undefined, method)} /> ) : null} {detail.changedFiles.toLocaleString()}{" "} {detail.changedFiles === 1 ? "file" : "files"}
) : null}
{detail ? ( ) : null}
{ const scroller = event.target as HTMLElement; scrollerRef.current = scroller; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; const foldHeight = foldRef.current?.scrollHeight ?? 0; // The condensed row remains mounted, so refund only the height that actually leaves. const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); if (previous) { // The hard top reopens the chrome with no refund: the reader asked for the top, // and moving them a fold's height back down would snatch it away — the fold // slides in above while the content stays where they left it. if (top < 4 && foldHeight > 0) { next = false; } } else if (foldHeight > 0 && top > foldHeight + 32) { compensationRef.current = -chromeDelta; next = true; } chromeStateByTab.current[tab] = next; return next; }); }} > {detailQuery.error && !detail ? ( ) : detail ? ( {mountedTabs.has("summary") ? (
) : null} {mountedTabs.has("timeline") ? (
{activityPending ? ( ) : activityError ? ( ) : ( )}
) : null} {mountedTabs.has("code") ? (
}>
) : null}
) : null}
setConfirmation((current) => ({ ...current, open }))} onOpenChangeComplete={(open) => { if (!open) setConfirmation({ open: false, action: "merge" }); }} > {confirmAction === "merge" ? "Merge pull request?" : confirmAction === "enable-auto-merge" ? "Enable auto-merge?" : confirmAction === "revert" ? "Revert these changes?" : confirmAction === "approve-workflows" ? "Approve workflows to run?" : "Close pull request?"} {confirmAction === "merge" ? `This merges #${reference.number} using ${selectedMergeMethod}.` : confirmAction === "enable-auto-merge" ? // The host merges this as soon as it considers the pull request ready, which // may be immediately — there is no telling from here whether anything is // still outstanding. `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.` : confirmAction === "revert" ? `This opens a new pull request that reverses the changes merged by #${reference.number}.` : confirmAction === "approve-workflows" ? `This allows ${workflowApprovalsRequired} ${workflowApprovalsRequired === 1 ? "workflow" : "workflows"} from #${reference.number} to run. Review the code and workflow changes first.` : `This closes #${reference.number} without merging it.`} }> Cancel
); }