import { scopeProjectRef } from "@t3tools/client-runtime/environment";import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";import type { EnvironmentId, PullRequestAction, PullRequestMergeMethod, PullRequestRef, PullRequestState,} from "@t3tools/contracts";import { ArrowDownUpIcon, ArrowLeftIcon, ArrowUpRightIcon, BookOpenIcon, CircleDotIcon, ChevronDownIcon, FileDiffIcon, FolderGit2Icon, GitBranchIcon, GitCommitHorizontalIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, MoreHorizontalIcon, PanelRightIcon, RefreshCwIcon, TriangleAlertIcon,} from "lucide-react";import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,} from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";import { useNewThreadHandler } from "~/hooks/useHandleNewThread";import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions";import { cn } from "~/lib/utils";import { readLocalApi } from "~/localApi";import type { ReviewCommentContext } from "~/reviewCommentContext";import { useEnvironmentQuery } from "~/state/query";import { useLiveRefresh } from "~/hooks/useLiveRefresh";import { pullRequestEnvironment } from "~/state/pullRequests";import { useAtomCommand } from "~/state/use-atom-command";import { formatRelativeTimeLabel } from "~/timestampFormat"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle,} from "../ui/alert-dialog";import { Badge } from "../ui/badge";import { Button } from "../ui/button";import { Menu, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger,} from "../ui/menu";import { toastManager } from "../ui/toast";import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";import { DiffPanelLoadingState } from "../DiffPanelShell";import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab";import { PullRequestSummaryTab } from "./PullRequestSummaryTab";import { PullRequestTimelineTab } from "./PullRequestTimelineTab";import { buildAskAboutLinesHandoff, buildAskAboutPullRequestHandoff, buildExplainPullRequestHandoff, buildFixFindingHandoff, buildFixFindingsHandoff, buildResolveConflictsPrompt, handoffPrompt, handoffReviewComments, pullRequestFindingKey, readableFailure, type PullRequestFinding,} from "./pullRequestDetail.logic";import { PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, resolvePullRequestState, summarizePullRequestChecks,} from "./pullRequestPresentation"; type DetailTab = "summary" | "timeline" | "code"; const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = { merge: "Pull request merged", ready: "Marked ready for review", draft: "Converted to draft", close: "Pull request closed", reopen: "Pull request reopened",}; /** Said as the thing that did not happen, rather than as the operation that returned an error. */const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = { 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",}; /** What to try, for the times the host says only that it refused. */const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = { 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.",}; /** Named for the host rather than "externally": the point is where you will land. */const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = { github: "Open on GitHub", gitlab: "Open on GitLab", bitbucket: "Open on Bitbucket", "azure-devops": "Open on Azure DevOps",}; 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 it stays out of the bundle until Code is opened.// Named rather than inlined so the panel can also call it itself, to start the download before// anyone has clicked the tab.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<DraftId, string>(); export function PullRequestDetailPanel({ environmentId, reference, refreshToken: forcedRefreshToken = 0, onActed, onClose, onStateChange, context = "page", chromeVariant = "full",}: { environmentId: EnvironmentId; reference: PullRequestRef; /** * 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; /** Keeps compact chrome, such as the right-panel tab, in step with refreshed host state. */ onStateChange?: (status: { projectId: string; repository: string; number: number; state: PullRequestState; isDraft: boolean; }) => 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"; /** * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` * folds the whole of it into the top row once the active tab scrolls, and unfolds at the * top — the chrome spends its height on what is being read. */ chromeVariant?: "full" | "collapse";}) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; const [tab, setTab] = useState<DetailTab>("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 [mountedTabs, setMountedTabs] = useState<ReadonlySet<DetailTab>>( () => new Set<DetailTab>(["summary"]), ); useEffect(() => { setMountedTabs((previous) => previous.has(tab) ? previous : new Set<DetailTab>(previous).add(tab), ); }, [tab]); const [chromeCondensed, setChromeCondensed] = useState(false); // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll // events, so the capture handler always writes the active tab's entry — and a tab switch // reads the destination's memory instead of inheriting the tab being left. A tab too short // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome // it has no scrollbar to reopen. const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); const condensed = chromeVariant === "collapse" && chromeCondensed; // Collapsing removes the fold's height from the chrome, which would otherwise hand that // height to the scrollport and leap the content up by it mid-scroll. The cure is exact // compensation: collapse only once the reader has scrolled at least the fold's height, // then give that height back to `scrollTop` before the next paint — the content under // their eyes does not move, and the collapse itself is the only thing that changes. const scrollerRef = useRef<HTMLElement | null>(null); const foldRef = useRef<HTMLDivElement | null>(null); // The condensed chrome's second row opens as the fold closes, so the height the scrollport // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` // through a zero track reads its natural height in either state. const condensedRowRef = useRef<HTMLDivElement | null>(null); const compensationRef = useRef<number | null>(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 [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>("merge"); const [confirmAction, setConfirmAction] = useState<"merge" | "close" | null>(null); // 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<string | null>(null); const { copyToClipboard: copyBranchToClipboard, isCopied: isBranchCopied } = useCopyToClipboard({ target: "branch name", timeout: 1600, }); // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be // clicked, so a reader who does click it lands on a chunk already in the module cache. useEffect(() => { void loadCodeTab(); }, []); const detailQuery = useEnvironmentQuery( pullRequestEnvironment.detail({ environmentId, input: reference }), ); const activityQuery = useEnvironmentQuery( pullRequestEnvironment.activity({ environmentId, input: reference }), ); // Detail and diff are independent server reads, so the diff for the default view (no commit, // no cursor) is started here too rather than waiting for the Code tab to mount. This is one // extra cached read per opened pull request even for readers who never open the tab, but it // turns the tab's first paint from a cold request into a cache hit. const _diffWarmUpQuery = useEnvironmentQuery( pullRequestEnvironment.diff({ environmentId, input: { ...reference } }), ); const coreDetail = detailQuery.data; 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 ?? [], }, [activity, coreDetail], ); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); }, [activityQuery.refresh, detailQuery.refresh]); useEffect(() => { if (!detail) return; onStateChange?.({ projectId: detail.projectId, repository: detail.repository, number: detail.number, state: detail.state, isDraft: detail.isDraft, }); }, [detail, onStateChange]); // A pull request changes while it is open in front of somebody — a push lands, a check // finishes, a review arrives — so the panel reads it again on the way back to the window and // while a reader sits on it. 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(refreshDetail, { key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`, }); // 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 [refreshToken, setRefreshToken] = useState(0); const refreshFromHost = useCallback(async () => { await invalidate({ environmentId, input: { reference } }); refreshDetail(); setRefreshToken((token) => token + 1); }, [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 [actionPending, setActionPending] = useState(false); const newThread = useNewThreadHandler(); const prepareThread = usePreparePullRequestThreadAction({ environmentId, cwd: detail?.workspaceRoot ?? null, }); const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => { if (actionPending) return; setActionPending(true); const result = await runAction({ environmentId, input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) }, }); setActionPending(false); 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); toastManager.add({ type: "error", title: ACTION_FAILURE_LABELS[action], description: readableFailure(failure, ACTION_FAILURE_HINTS[action]), }); return; } toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); refreshDetail(); onActed?.(); }; type ThreadTask = { prompt: string; reviewComments?: ReadonlyArray<ReviewCommentContext>; }; /** * 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<typeof scopeProjectRef>, 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; const store = useComposerDraftStore.getState(); 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. const draft = store.getComposerDraft(session.draftId); const existingComments = draft?.reviewComments ?? []; const prompt = handoffPrompt( { prompt: draft?.prompt ?? "", lastHandoffPrompt: lastHandoffPromptByDraft.get(session.draftId), }, task.prompt, ); // Remember the hand-off's own contribution, not the merged prompt: only that sentence is // this session's to take back next time, and the reader's text around it is not. lastHandoffPromptByDraft.set(session.draftId, task.prompt); store.setPrompt(session.draftId, prompt); store.setReviewComments( session.draftId, handoffReviewComments(existingComments, task.reviewComments ?? []), ); 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; setHandoff(kind); const projectRef = scopeProjectRef(environmentId, 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<ReviewCommentContext> } | 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; 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...", }); const projectRef = scopeProjectRef(environmentId, 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.Lines 1–500 of 1414. Load more
import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestAction, PullRequestMergeMethod, PullRequestRef, PullRequestState, } from "@t3tools/contracts"; import { ArrowDownUpIcon, ArrowLeftIcon, ArrowUpRightIcon, BookOpenIcon, CircleDotIcon, ChevronDownIcon, FileDiffIcon, FolderGit2Icon, GitBranchIcon, GitCommitHorizontalIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, MoreHorizontalIcon, PanelRightIcon, RefreshCwIcon, TriangleAlertIcon, } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuTrigger, } from "../ui/menu"; import { toastManager } from "../ui/toast"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; import { buildAskAboutLinesHandoff, buildAskAboutPullRequestHandoff, buildExplainPullRequestHandoff, buildFixFindingHandoff, buildFixFindingsHandoff, buildResolveConflictsPrompt, handoffPrompt, handoffReviewComments, pullRequestFindingKey, readableFailure, type PullRequestFinding, } from "./pullRequestDetail.logic"; import { PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, resolvePullRequestState, summarizePullRequestChecks, } from "./pullRequestPresentation"; type DetailTab = "summary" | "timeline" | "code"; const ACTION_SUCCESS_LABELS: Record<PullRequestAction, string> = { merge: "Pull request merged", ready: "Marked ready for review", draft: "Converted to draft", close: "Pull request closed", reopen: "Pull request reopened", }; /** Said as the thing that did not happen, rather than as the operation that returned an error. */ const ACTION_FAILURE_LABELS: Record<PullRequestAction, string> = { 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", }; /** What to try, for the times the host says only that it refused. */ const ACTION_FAILURE_HINTS: Record<PullRequestAction, string> = { 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.", }; /** Named for the host rather than "externally": the point is where you will land. */ const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = { github: "Open on GitHub", gitlab: "Open on GitLab", bitbucket: "Open on Bitbucket", "azure-devops": "Open on Azure DevOps", }; 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 it stays out of the bundle until Code is opened. // Named rather than inlined so the panel can also call it itself, to start the download before // anyone has clicked the tab. 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<DraftId, string>(); export function PullRequestDetailPanel({ environmentId, reference, refreshToken: forcedRefreshToken = 0, onActed, onClose, onStateChange, context = "page", chromeVariant = "full", }: { environmentId: EnvironmentId; reference: PullRequestRef; /** * 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; /** Keeps compact chrome, such as the right-panel tab, in step with refreshed host state. */ onStateChange?: (status: { projectId: string; repository: string; number: number; state: PullRequestState; isDraft: boolean; }) => 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"; /** * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` * folds the whole of it into the top row once the active tab scrolls, and unfolds at the * top — the chrome spends its height on what is being read. */ chromeVariant?: "full" | "collapse"; }) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; const [tab, setTab] = useState<DetailTab>("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 [mountedTabs, setMountedTabs] = useState<ReadonlySet<DetailTab>>( () => new Set<DetailTab>(["summary"]), ); useEffect(() => { setMountedTabs((previous) => previous.has(tab) ? previous : new Set<DetailTab>(previous).add(tab), ); }, [tab]); const [chromeCondensed, setChromeCondensed] = useState(false); // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll // events, so the capture handler always writes the active tab's entry — and a tab switch // reads the destination's memory instead of inheriting the tab being left. A tab too short // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome // it has no scrollbar to reopen. const chromeStateByTab = useRef<Partial<Record<DetailTab, boolean>>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); const condensed = chromeVariant === "collapse" && chromeCondensed; // Collapsing removes the fold's height from the chrome, which would otherwise hand that // height to the scrollport and leap the content up by it mid-scroll. The cure is exact // compensation: collapse only once the reader has scrolled at least the fold's height, // then give that height back to `scrollTop` before the next paint — the content under // their eyes does not move, and the collapse itself is the only thing that changes. const scrollerRef = useRef<HTMLElement | null>(null); const foldRef = useRef<HTMLDivElement | null>(null); // The condensed chrome's second row opens as the fold closes, so the height the scrollport // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` // through a zero track reads its natural height in either state. const condensedRowRef = useRef<HTMLDivElement | null>(null); const compensationRef = useRef<number | null>(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 [mergeMethod, setMergeMethod] = useState<PullRequestMergeMethod>("merge"); const [confirmAction, setConfirmAction] = useState<"merge" | "close" | null>(null); // 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<string | null>(null); const { copyToClipboard: copyBranchToClipboard, isCopied: isBranchCopied } = useCopyToClipboard({ target: "branch name", timeout: 1600, }); // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be // clicked, so a reader who does click it lands on a chunk already in the module cache. useEffect(() => { void loadCodeTab(); }, []); const detailQuery = useEnvironmentQuery( pullRequestEnvironment.detail({ environmentId, input: reference }), ); const activityQuery = useEnvironmentQuery( pullRequestEnvironment.activity({ environmentId, input: reference }), ); // Detail and diff are independent server reads, so the diff for the default view (no commit, // no cursor) is started here too rather than waiting for the Code tab to mount. This is one // extra cached read per opened pull request even for readers who never open the tab, but it // turns the tab's first paint from a cold request into a cache hit. const _diffWarmUpQuery = useEnvironmentQuery( pullRequestEnvironment.diff({ environmentId, input: { ...reference } }), ); const coreDetail = detailQuery.data; 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 ?? [], }, [activity, coreDetail], ); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); }, [activityQuery.refresh, detailQuery.refresh]); useEffect(() => { if (!detail) return; onStateChange?.({ projectId: detail.projectId, repository: detail.repository, number: detail.number, state: detail.state, isDraft: detail.isDraft, }); }, [detail, onStateChange]); // A pull request changes while it is open in front of somebody — a push lands, a check // finishes, a review arrives — so the panel reads it again on the way back to the window and // while a reader sits on it. 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(refreshDetail, { key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`, }); // 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 [refreshToken, setRefreshToken] = useState(0); const refreshFromHost = useCallback(async () => { await invalidate({ environmentId, input: { reference } }); refreshDetail(); setRefreshToken((token) => token + 1); }, [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 [actionPending, setActionPending] = useState(false); const newThread = useNewThreadHandler(); const prepareThread = usePreparePullRequestThreadAction({ environmentId, cwd: detail?.workspaceRoot ?? null, }); const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => { if (actionPending) return; setActionPending(true); const result = await runAction({ environmentId, input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) }, }); setActionPending(false); 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); toastManager.add({ type: "error", title: ACTION_FAILURE_LABELS[action], description: readableFailure(failure, ACTION_FAILURE_HINTS[action]), }); return; } toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); refreshDetail(); onActed?.(); }; type ThreadTask = { prompt: string; reviewComments?: ReadonlyArray<ReviewCommentContext>; }; /** * 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<typeof scopeProjectRef>, 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; const store = useComposerDraftStore.getState(); 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. const draft = store.getComposerDraft(session.draftId); const existingComments = draft?.reviewComments ?? []; const prompt = handoffPrompt( { prompt: draft?.prompt ?? "", lastHandoffPrompt: lastHandoffPromptByDraft.get(session.draftId), }, task.prompt, ); // Remember the hand-off's own contribution, not the merged prompt: only that sentence is // this session's to take back next time, and the reader's text around it is not. lastHandoffPromptByDraft.set(session.draftId, task.prompt); store.setPrompt(session.draftId, prompt); store.setReviewComments( session.draftId, handoffReviewComments(existingComments, task.reviewComments ?? []), ); 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; setHandoff(kind); const projectRef = scopeProjectRef(environmentId, 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<ReviewCommentContext> } | 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; 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...", }); const projectRef = scopeProjectRef(environmentId, 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.