/** * The thread details panel's pull request row: what the branch's pull request is, and the one * thing worth doing to it right now. * * The row itself opens the pull request in the right panel, exactly as it always has. Around * that, the host's richer answer collapses into a single trailing slot, ranked by what unblocks * the merge next: "Resolve" on conflicts, "Ready" on a draft, "Fix" under failing checks, and * "Merge" only once the branch is clean and its checks pass. While checks run the slot reports * that instead — a merge offered mid-run would race the very runs that gate it. Everything else * the host knows (title, state, checks tally, diff size) lives in the row's tooltip, so a hover * answers what previously took opening the panel. * * Until the detail arrives — or where pull requests are not supported at all — the row renders * from the `vcs.status` summary alone, which is the plain row this panel showed before. */ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; import { ArrowUpRightIcon, FileDiffIcon, GitBranchIcon, TriangleAlertIcon } from "lucide-react"; import { useState, type MouseEvent as ReactMouseEvent } from "react"; import { cn } from "~/lib/utils"; import { useServerConfigs } from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { buildFixFindingsHandoff, buildResolveConflictsPrompt, classifyPullRequestChecks, describePullRequestChecks, isPullRequestConflicting, resolveSelectedMergeMethod, allowedPullRequestMergeMethods, resolveThreadPanelPullRequestAction, } from "../pullRequest/pullRequestDetail.logic"; import { PullRequestCheckStatusIcon, PullRequestDiffStat, resolvePullRequestState, } from "../pullRequest/pullRequestPresentation"; import { usePullRequestActionRunner, usePullRequestHandoffs, } from "../pullRequest/usePullRequestActions"; import { ChangeRequestStatusIcon, type PrStatusIndicator, type ThreadPr, } from "../ThreadStatusIndicators"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { THREAD_DETAILS_PANEL_ICON_CLASS, THREAD_DETAILS_PANEL_LINK_SPLIT_ACTION_CLASS, THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS, THREAD_DETAILS_PANEL_ROW_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./threadDetailsPanelStyles"; export function ThreadDetailsPrRow({ environmentId, pr, status, project, label, openAriaLabel, onOpen, onActed, }: { environmentId: EnvironmentId; pr: NonNullable; status: PrStatusIndicator; /** The thread's project, which is what the pull request is read through on the host. */ project: EnvironmentProject | null; label: string; openAriaLabel: string; onOpen: (event: ReactMouseEvent) => void; /** An action changed the pull request on the host, so the vcs status behind the row is stale. */ onActed?: () => void; }) { const serverConfigs = useServerConfigs(); const supportsPullRequests = serverConfigs.get(environmentId)?.environment.capabilities.pullRequests === true; // The identity's own spelling, the way the detail panel is addressed everywhere else. const identity = project?.repositoryIdentity; const repository = identity?.displayName ?? (identity?.owner && identity.name ? `${identity.owner}/${identity.name}` : null); const reference: PullRequestRef | null = supportsPullRequests && project !== null && repository !== null ? { projectId: project.id as ProjectId, repository, number: pr.number } : null; const detailQuery = useEnvironmentQuery( reference === null ? null : pullRequestEnvironment.detail({ environmentId, input: reference }), ); const detail = detailQuery.data ?? null; const { actionPending, perform } = usePullRequestActionRunner({ environmentId, reference, onSuccess: () => { detailQuery.refresh(); onActed?.(); }, }); const { handoff, startHandoff } = usePullRequestHandoffs({ environmentId, detail }); const [confirmingMerge, setConfirmingMerge] = useState(false); const rowAction = resolveThreadPanelPullRequestAction(detail); const conflicting = isPullRequestConflicting(detail); const checksState = detail === null ? "none" : classifyPullRequestChecks(detail.checks); const checksRunning = detail?.state === "open" && rowAction === null && checksState === "pending"; const selectedMergeMethod = resolveSelectedMergeMethod( allowedPullRequestMergeMethods(detail), "merge", ); const startResolveConflicts = () => { if (detail === null) return; void startHandoff("conflicts", { prompt: buildResolveConflictsPrompt({ number: detail.number, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, }), }); }; const startFixChecks = () => { if (detail === null) return; // The compact row fetches no conversation, so the handoff carries the failing checks alone; // review threads keep arriving through the full panel's richer version of this action. void startHandoff( "findings", buildFixFindingsHandoff({ number: detail.number, title: detail.title, url: detail.url, headBranch: detail.headBranch, baseBranch: detail.baseBranch, reviewThreads: [], comments: [], checks: detail.checks, commentsTruncated: false, }), ); }; // Once the host has answered, the glyph knows about drafts and conflicts, which the vcs // summary does not. Draft outranks conflicts in it, same as the detail panel. const statePresentation = detail === null ? null : resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft, mergeability: detail.mergeability, baseBranch: detail.baseBranch, }); const icon = statePresentation ? ( ) : ( ); // Everything the host reported, at a glance. The row stays one line; the tooltip is where the // rest of the answer lives — styled like the sidebar's thread tooltip, title above icon-led // detail rows, so the two read as one family. const rowTooltip = detail === null || statePresentation === null ? ( {status.tooltip} ) : (
{detail.title} #{detail.number}
{statePresentation.label}
{detail.baseBranch} ← {detail.headBranch}
{detail.state === "open" && checksState !== "none" ? (
{describePullRequestChecks(detail.checks)}
) : null} {detail.isDraft && conflicting ? (
Merge conflicts with {detail.baseBranch}
) : null}
{detail.changedFiles.toLocaleString()}{" "} {detail.changedFiles === 1 ? "file" : "files"}
); const trailingAction = rowAction === "resolve" ? { label: "Resolve", pendingLabel: "Preparing...", pending: handoff === "conflicts", destructive: true, suffix: , tooltip: "Check the branch out and resolve the conflicts in a new thread", onClick: startResolveConflicts, } : rowAction === "ready" ? { label: "Ready", pendingLabel: "Marking...", pending: actionPending, destructive: false, suffix: null, tooltip: "Mark this pull request as ready for review", onClick: () => void perform("ready"), } : rowAction === "fix" ? { label: "Fix", pendingLabel: "Preparing...", pending: handoff === "findings", destructive: true, suffix: , tooltip: "Fix the failing checks in a new thread", onClick: startFixChecks, } : rowAction === "merge" ? { label: "Merge", pendingLabel: "Merging...", pending: actionPending, destructive: false, suffix: null, tooltip: `Merge this pull request (${selectedMergeMethod})`, onClick: () => setConfirmingMerge(true), } : null; const rowContent = ( <> {icon} {label} ); return ( <> {trailingAction || checksRunning ? (
} > {rowContent} {rowTooltip}
) : ( } > {rowContent} {rowTooltip} )} {rowAction === "merge" ? ( setConfirmingMerge(open)}> Merge pull request? This merges #{pr.number} using {selectedMergeMethod}. }> Cancel ) : null} ); }