| 1 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 2 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| 3 | /** |
| 4 | * The thread details panel's pull request row: what the thread's pull request is, and the one |
| 5 | * thing worth doing to it right now. |
| 6 | * |
| 7 | * The row itself opens the pull request in the right panel, exactly as it always has. Once the |
| 8 | * host answers, the title gets the full first line and a second line carries the number, checks, |
| 9 | * watch, and a single action slot, ranked by what unblocks the merge next: "Resolve" on |
| 10 | * conflicts, "Ready" on a draft, "Fix" under failing checks, and "Merge" only once the branch is |
| 11 | * clean and its checks pass. While checks run the slot reports |
| 12 | * that instead — a merge offered mid-run would race the very runs that gate it. Everything else |
| 13 | * the host knows (title, state, checks tally, diff size) lives in the row's tooltip, so a hover |
| 14 | * answers what previously took opening the panel. |
| 15 | * |
| 16 | * Until the detail arrives — or where pull requests are not supported at all — the row renders |
| 17 | * from the linked snapshot or branch summary, or just the link when status is unavailable. |
| 18 | */ |
| 19 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 20 | import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; |
| 21 | import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; |
| 22 | import { |
| 23 | ArrowUpRightIcon, |
| 24 | EyeIcon, |
| 25 | EyeOffIcon, |
| 26 | FileDiffIcon, |
| 27 | GitBranchIcon, |
| 28 | TriangleAlertIcon, |
| 29 | } from "lucide-react"; |
| 30 | import { useState, type MouseEvent as ReactMouseEvent } from "react"; |
| 31 | |
| 32 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 33 | import { usePullRequestChecksRefresh } from "~/hooks/usePullRequestChecksRefresh"; |
| 34 | import { cn } from "~/lib/utils"; |
| 35 | import { useServerConfigs } from "~/state/entities"; |
| 36 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 37 | import { useEnvironmentQuery } from "~/state/query"; |
| 38 | |
| 39 | import { |
| 40 | buildFixFindingsHandoff, |
| 41 | buildResolveConflictsPrompt, |
| 42 | classifyPullRequestChecks, |
| 43 | describePullRequestChecks, |
| 44 | isPullRequestConflicting, |
| 45 | resolveSelectedMergeMethod, |
| 46 | allowedPullRequestMergeMethods, |
| 47 | resolveThreadPanelPullRequestAction, |
| 48 | } from "../pullRequest/pullRequestDetail.logic"; |
| 49 | import { PullRequestChecksPopover } from "../pullRequest/PullRequestChecksPopover"; |
| 50 | import { |
| 51 | pullRequestChecksState, |
| 52 | PullRequestCheckStatusIcon, |
| 53 | PullRequestDiffStat, |
| 54 | resolvePullRequestState, |
| 55 | } from "../pullRequest/pullRequestPresentation"; |
| 56 | import { |
| 57 | usePullRequestActionRunner, |
| 58 | usePullRequestHandoffs, |
| 59 | } from "../pullRequest/usePullRequestActions"; |
| 60 | import { |
| 61 | ChangeRequestStatusIcon, |
| 62 | type PrStatusIndicator, |
| 63 | type ThreadPr, |
| 64 | } from "../ThreadStatusIndicators"; |
| 65 | import { |
| 66 | AlertDialog, |
| 67 | AlertDialogClose, |
| 68 | AlertDialogDescription, |
| 69 | AlertDialogFooter, |
| 70 | AlertDialogHeader, |
| 71 | AlertDialogPopup, |
| 72 | AlertDialogTitle, |
| 73 | } from "../ui/alert-dialog"; |
| 74 | import { Button } from "../ui/button"; |
| 75 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 76 | import { |
| 77 | THREAD_DETAILS_PANEL_ICON_CLASS, |
| 78 | THREAD_DETAILS_PANEL_LABEL_CLASS, |
| 79 | THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, |
| 80 | THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, |
| 81 | } from "./threadDetailsPanelStyles"; |
| 82 | |
| 83 | export function ThreadDetailsPrRow({ |
| 84 | environmentId, |
| 85 | pr, |
| 86 | number, |
| 87 | reference: linkedReference, |
| 88 | status, |
| 89 | project, |
| 90 | label, |
| 91 | openAriaLabel, |
| 92 | onOpen, |
| 93 | onActed, |
| 94 | onStopWatching, |
| 95 | }: { |
| 96 | environmentId: EnvironmentId; |
| 97 | pr: ThreadPr; |
| 98 | number: number; |
| 99 | reference?: Pick<PullRequestRef, "host" | "repository" | "number"> | null; |
| 100 | status: PrStatusIndicator | null; |
| 101 | /** The thread's project, which is what the pull request is read through on the host. */ |
| 102 | project: EnvironmentProject | null; |
| 103 | label: string; |
| 104 | openAriaLabel: string; |
| 105 | onOpen: (event: ReactMouseEvent<HTMLElement>) => void; |
| 106 | /** An action changed the pull request on the host, so the vcs status behind the row is stale. */ |
| 107 | onActed?: () => void; |
| 108 | /** Set while the server watches this pull request for the thread; stops the watch. */ |
| 109 | onStopWatching?: (() => void) | undefined; |
| 110 | }) { |
| 111 | const serverConfigs = useServerConfigs(); |
| 112 | const supportsPullRequests = |
| 113 | serverConfigs.get(environmentId)?.environment.capabilities.pullRequests === true; |
| 114 | const repository = sourceControlRepositorySelector(project?.repositoryIdentity); |
| 115 | const reference: PullRequestRef | null = |
| 116 | supportsPullRequests && project !== null |
| 117 | ? linkedReference |
| 118 | ? { ...linkedReference, projectId: project.id as ProjectId } |
| 119 | : repository !== null |
| 120 | ? { projectId: project.id as ProjectId, repository, number } |
| 121 | : null |
| 122 | : null; |
| 123 | const detailQuery = useEnvironmentQuery( |
| 124 | reference === null |
| 125 | ? null |
| 126 | : pullRequestEnvironment.detail({ |
| 127 | environmentId, |
| 128 | input: { ...reference, allowStale: false }, |
| 129 | }), |
| 130 | ); |
| 131 | const supportsChecks = |
| 132 | serverConfigs.get(environmentId)?.environment.capabilities.pullRequestChecks === true; |
| 133 | const checksQuery = useEnvironmentQuery( |
| 134 | supportsChecks && reference !== null && detailQuery.data !== null |
| 135 | ? pullRequestEnvironment.checks({ environmentId, input: reference }) |
| 136 | : null, |
| 137 | ); |
| 138 | const detail = |
| 139 | detailQuery.data === null |
| 140 | ? null |
| 141 | : checksQuery.data !== null && checksQuery.dataUpdatedAt >= detailQuery.dataUpdatedAt |
| 142 | ? { ...detailQuery.data, ...checksQuery.data } |
| 143 | : detailQuery.data; |
| 144 | const open = reference !== null && (detail?.state ?? pr?.state) === "open"; |
| 145 | const refreshKey = `${environmentId}:${project?.id}:${reference?.host}:${reference?.repository}:${number}`; |
| 146 | useLiveRefresh(detailQuery.isPending ? null : detailQuery.refresh, { |
| 147 | enabled: open, |
| 148 | key: `workspace-pr:${refreshKey}`, |
| 149 | intervalMs: 10 * 60_000, |
| 150 | }); |
| 151 | usePullRequestChecksRefresh({ |
| 152 | refresh: checksQuery.isPending || detailQuery.isPending ? null : checksQuery.refresh, |
| 153 | enabled: open && supportsChecks && !(checksQuery.isSuccess && checksQuery.data === null), |
| 154 | key: `workspace-pr-checks:${refreshKey}`, |
| 155 | checks: detail?.checks ?? [], |
| 156 | }); |
| 157 | |
| 158 | const { actionPending, perform } = usePullRequestActionRunner({ |
| 159 | environmentId, |
| 160 | reference, |
| 161 | onSuccess: () => { |
| 162 | detailQuery.refresh(); |
| 163 | checksQuery.refresh(); |
| 164 | onActed?.(); |
| 165 | }, |
| 166 | }); |
| 167 | const { handoff, startHandoff } = usePullRequestHandoffs({ environmentId, detail }); |
| 168 | const [confirmingMerge, setConfirmingMerge] = useState(false); |
| 169 | |
| 170 | const rowAction = resolveThreadPanelPullRequestAction(detail); |
| 171 | if (confirmingMerge && rowAction !== "merge") { |
| 172 | setConfirmingMerge(false); |
| 173 | } |
| 174 | const conflicting = isPullRequestConflicting(detail); |
| 175 | const checksState = detail === null ? "none" : classifyPullRequestChecks(detail.checks); |
| 176 | const checksRollup = detail === null ? null : pullRequestChecksState(detail.checks); |
| 177 | const selectedMergeMethod = resolveSelectedMergeMethod( |
| 178 | allowedPullRequestMergeMethods(detail), |
| 179 | "merge", |
| 180 | ); |
| 181 | |
| 182 | const startResolveConflicts = () => { |
| 183 | if (detail === null) return; |
| 184 | void startHandoff("conflicts", { |
| 185 | prompt: buildResolveConflictsPrompt({ |
| 186 | number: detail.number, |
| 187 | url: detail.url, |
| 188 | headBranch: detail.headBranch, |
| 189 | baseBranch: detail.baseBranch, |
| 190 | }), |
| 191 | }); |
| 192 | }; |
| 193 | |
| 194 | const startFixChecks = () => { |
| 195 | if (detail === null) return; |
| 196 | // The compact row fetches no conversation, so the handoff carries the failing checks alone; |
| 197 | // review threads keep arriving through the full panel's richer version of this action. |
| 198 | void startHandoff( |
| 199 | "findings", |
| 200 | buildFixFindingsHandoff({ |
| 201 | number: detail.number, |
| 202 | title: detail.title, |
| 203 | url: detail.url, |
| 204 | headBranch: detail.headBranch, |
| 205 | baseBranch: detail.baseBranch, |
| 206 | reviewThreads: [], |
| 207 | comments: [], |
| 208 | checks: detail.checks, |
| 209 | commentsTruncated: false, |
| 210 | }), |
| 211 | ); |
| 212 | }; |
| 213 | |
| 214 | // Host details distinguish drafts; all panels share the same PR-state glyph. |
| 215 | const statePresentation = |
| 216 | detail === null |
| 217 | ? null |
| 218 | : resolvePullRequestState({ |
| 219 | state: detail.state, |
| 220 | isDraft: detail.isDraft, |
| 221 | }); |
| 222 | const icon = statePresentation ? ( |
| 223 | <statePresentation.Icon |
| 224 | aria-hidden |
| 225 | className={cn("size-4 shrink-0", statePresentation.toneClassName)} |
| 226 | /> |
| 227 | ) : pr && status ? ( |
| 228 | <ChangeRequestStatusIcon |
| 229 | state={pr.state} |
| 230 | className={cn(THREAD_DETAILS_PANEL_ICON_CLASS, status.colorClass)} |
| 231 | /> |
| 232 | ) : ( |
| 233 | <PullRequestGlyph.pullRequest className={THREAD_DETAILS_PANEL_ICON_CLASS} /> |
| 234 | ); |
| 235 | |
| 236 | // Everything the host reported, at a glance. The rows stay short; the tooltip is where the |
| 237 | // rest of the answer lives — styled like the sidebar's thread tooltip, title above icon-led |
| 238 | // detail rows, so the two read as one family. |
| 239 | const rowTooltip = |
| 240 | detail === null || statePresentation === null ? ( |
| 241 | <TooltipPopup side="top">{status?.tooltip ?? `Pull request #${number}`}</TooltipPopup> |
| 242 | ) : ( |
| 243 | <TooltipPopup |
| 244 | side="top" |
| 245 | align="start" |
| 246 | sideOffset={4} |
| 247 | variant="glass" |
| 248 | className="max-w-80 text-left whitespace-normal" |
| 249 | > |
| 250 | <div className="flex min-w-0 max-w-80 flex-col gap-2 px-1 py-2"> |
| 251 | <div className="flex min-w-0 items-baseline gap-1.5 text-xs leading-none"> |
| 252 | <span className="min-w-0 truncate font-medium text-foreground">{detail.title}</span> |
| 253 | <span className="shrink-0 text-muted-foreground">#{detail.number}</span> |
| 254 | </div> |
| 255 | <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground"> |
| 256 | <div className="flex min-w-0 items-center gap-2"> |
| 257 | <statePresentation.Icon |
| 258 | aria-hidden |
| 259 | className={cn("size-3 shrink-0", statePresentation.toneClassName)} |
| 260 | /> |
| 261 | <div className="min-w-0 truncate text-foreground/75">{statePresentation.label}</div> |
| 262 | </div> |
| 263 | <div className="flex min-w-0 items-center gap-2"> |
| 264 | <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" /> |
| 265 | <div className="min-w-0 truncate text-foreground/75"> |
| 266 | {detail.baseBranch} ← {detail.headBranch} |
| 267 | </div> |
| 268 | </div> |
| 269 | {detail.state === "open" && checksState !== "none" ? ( |
| 270 | <div className="flex min-w-0 items-center gap-2"> |
| 271 | <PullRequestCheckStatusIcon |
| 272 | status={ |
| 273 | checksState === "failing" |
| 274 | ? "failure" |
| 275 | : checksState === "pending" |
| 276 | ? "pending" |
| 277 | : "success" |
| 278 | } |
| 279 | /> |
| 280 | <div className="min-w-0 truncate text-foreground/75"> |
| 281 | {describePullRequestChecks(detail.checks)} |
| 282 | </div> |
| 283 | </div> |
| 284 | ) : null} |
| 285 | {detail.isDraft && conflicting ? ( |
| 286 | <div className="flex min-w-0 items-start gap-2 text-destructive"> |
| 287 | <TriangleAlertIcon aria-hidden className="mt-0.5 size-3 shrink-0 stroke-current" /> |
| 288 | <div className="min-w-0 flex-1 wrap-break-word leading-5"> |
| 289 | Merge conflicts with {detail.baseBranch} |
| 290 | </div> |
| 291 | </div> |
| 292 | ) : null} |
| 293 | <div className="flex min-w-0 items-center gap-2"> |
| 294 | <FileDiffIcon className="size-3 shrink-0 stroke-muted-foreground" /> |
| 295 | <div className="min-w-0 flex items-baseline gap-1 truncate text-foreground/75"> |
| 296 | {detail.changedFiles.toLocaleString()}{" "} |
| 297 | {detail.changedFiles === 1 ? "file" : "files"} |
| 298 | <PullRequestDiffStat additions={detail.additions} deletions={detail.deletions} /> |
| 299 | </div> |
| 300 | </div> |
| 301 | </div> |
| 302 | </div> |
| 303 | </TooltipPopup> |
| 304 | ); |
| 305 | |
| 306 | const trailingAction = |
| 307 | rowAction === "resolve" |
| 308 | ? { |
| 309 | label: "Resolve", |
| 310 | pendingLabel: "Preparing...", |
| 311 | pending: handoff === "conflicts", |
| 312 | destructive: true, |
| 313 | suffix: <ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />, |
| 314 | tooltip: "Check the branch out and resolve the conflicts in a new thread", |
| 315 | onClick: startResolveConflicts, |
| 316 | } |
| 317 | : rowAction === "ready" |
| 318 | ? { |
| 319 | label: "Ready", |
| 320 | pendingLabel: "Marking...", |
| 321 | pending: actionPending, |
| 322 | destructive: false, |
| 323 | suffix: null, |
| 324 | tooltip: "Mark this pull request as ready for review", |
| 325 | onClick: () => void perform("ready"), |
| 326 | } |
| 327 | : rowAction === "fix" |
| 328 | ? { |
| 329 | label: "Fix", |
| 330 | pendingLabel: "Preparing...", |
| 331 | pending: handoff === "findings", |
| 332 | destructive: true, |
| 333 | suffix: <ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />, |
| 334 | tooltip: "Fix the failing checks in a new thread", |
| 335 | onClick: startFixChecks, |
| 336 | } |
| 337 | : rowAction === "merge" |
| 338 | ? { |
| 339 | label: "Merge", |
| 340 | pendingLabel: "Merging...", |
| 341 | pending: actionPending, |
| 342 | destructive: false, |
| 343 | suffix: null, |
| 344 | tooltip: `Merge this pull request (${selectedMergeMethod})`, |
| 345 | onClick: () => setConfirmingMerge(true), |
| 346 | } |
| 347 | : null; |
| 348 | |
| 349 | // The server ends a watch when the pull request closes, so only an open one shows the eye. |
| 350 | const watching = |
| 351 | onStopWatching !== undefined && (detail?.state ?? pr?.state ?? "open") === "open"; |
| 352 | const watchControl = (part: "secondary" | "icon") => |
| 353 | watching ? ( |
| 354 | <Tooltip> |
| 355 | <TooltipTrigger |
| 356 | render={ |
| 357 | <ThreadDetailsControl |
| 358 | type="button" |
| 359 | variant="ghost" |
| 360 | size="sm" |
| 361 | part={part} |
| 362 | className="group/watch" |
| 363 | aria-label={`Stop watching #${number}`} |
| 364 | onClick={onStopWatching} |
| 365 | /> |
| 366 | } |
| 367 | > |
| 368 | <EyeIcon |
| 369 | aria-hidden |
| 370 | className={cn(part === "icon" ? "size-3.5" : "size-4", "group-hover/watch:hidden")} |
| 371 | /> |
| 372 | <EyeOffIcon |
| 373 | aria-hidden |
| 374 | className={cn( |
| 375 | part === "icon" ? "size-3.5" : "size-4", |
| 376 | "hidden group-hover/watch:block", |
| 377 | )} |
| 378 | /> |
| 379 | </TooltipTrigger> |
| 380 | <TooltipPopup side="top"> |
| 381 | Watching: the agent wakes when checks finish, someone comments, or the branch conflicts. |
| 382 | Click to stop. |
| 383 | </TooltipPopup> |
| 384 | </Tooltip> |
| 385 | ) : null; |
| 386 | |
| 387 | const openRow = (text: string, part: "row" | "primary") => ( |
| 388 | <Tooltip> |
| 389 | <TooltipTrigger |
| 390 | render={ |
| 391 | <ThreadDetailsControl |
| 392 | type="button" |
| 393 | variant="ghost" |
| 394 | size="sm" |
| 395 | part={part} |
| 396 | aria-label={openAriaLabel} |
| 397 | onClick={onOpen} |
| 398 | /> |
| 399 | } |
| 400 | > |
| 401 | {icon} |
| 402 | <span className={cn("min-w-0 flex-1 truncate text-left", THREAD_DETAILS_PANEL_LABEL_CLASS)}> |
| 403 | {text} |
| 404 | </span> |
| 405 | </TooltipTrigger> |
| 406 | {rowTooltip} |
| 407 | </Tooltip> |
| 408 | ); |
| 409 | |
| 410 | const showChecks = detail !== null && checksRollup !== null && !conflicting && !detail.isDraft; |
| 411 | |
| 412 | return ( |
| 413 | <> |
| 414 | {detail !== null && (showChecks || trailingAction !== null || watching) ? ( |
| 415 | // Two lines so the title keeps the full row width: the number, checks, watch, and the |
| 416 | // next action sit underneath, aligned with the title text. The second line hugs its title |
| 417 | // and the block keeps space below, so stacked pull requests read as separate pairs. |
| 418 | <div className="pb-2"> |
| 419 | {openRow(detail.title, "row")} |
| 420 | <div className="-mt-1 flex h-6 min-w-0 items-center gap-0.5 ps-9 pe-1 text-xs text-muted-foreground"> |
| 421 | <span className="me-1 shrink-0 tabular-nums">#{number}</span> |
| 422 | {showChecks && checksRollup !== null ? ( |
| 423 | <PullRequestChecksPopover |
| 424 | checksState={checksRollup} |
| 425 | checks={detail.checks} |
| 426 | variant="count" |
| 427 | render={<ThreadDetailsControl part="meta" />} |
| 428 | /> |
| 429 | ) : null} |
| 430 | {watchControl("icon")} |
| 431 | <span className="flex-1" /> |
| 432 | {trailingAction ? ( |
| 433 | <Tooltip> |
| 434 | <TooltipTrigger |
| 435 | render={ |
| 436 | <ThreadDetailsControl |
| 437 | type="button" |
| 438 | variant="ghost" |
| 439 | size="sm" |
| 440 | part="meta" |
| 441 | tone={trailingAction.destructive ? "destructive" : "primary"} |
| 442 | disabled={actionPending || handoff !== null} |
| 443 | onClick={trailingAction.onClick} |
| 444 | /> |
| 445 | } |
| 446 | > |
| 447 | {trailingAction.pending ? trailingAction.pendingLabel : trailingAction.label} |
| 448 | {trailingAction.suffix} |
| 449 | </TooltipTrigger> |
| 450 | <TooltipPopup side="top">{trailingAction.tooltip}</TooltipPopup> |
| 451 | </Tooltip> |
| 452 | ) : null} |
| 453 | </div> |
| 454 | </div> |
| 455 | ) : watching ? ( |
| 456 | <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}> |
| 457 | {openRow(label, "primary")} |
| 458 | <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> |
| 459 | {watchControl("secondary")} |
| 460 | </div> |
| 461 | ) : ( |
| 462 | openRow(label, "row") |
| 463 | )} |
| 464 | {rowAction === "merge" ? ( |
| 465 | <AlertDialog open={confirmingMerge} onOpenChange={(open) => setConfirmingMerge(open)}> |
| 466 | <AlertDialogPopup> |
| 467 | <AlertDialogHeader> |
| 468 | <AlertDialogTitle>Merge pull request?</AlertDialogTitle> |
| 469 | <AlertDialogDescription> |
| 470 | This merges #{number} using {selectedMergeMethod}. |
| 471 | </AlertDialogDescription> |
| 472 | </AlertDialogHeader> |
| 473 | <AlertDialogFooter> |
| 474 | <AlertDialogClose render={<Button variant="outline" size="sm" />}> |
| 475 | Cancel |
| 476 | </AlertDialogClose> |
| 477 | <Button |
| 478 | size="sm" |
| 479 | disabled={actionPending} |
| 480 | onClick={() => { |
| 481 | setConfirmingMerge(false); |
| 482 | void perform("merge", selectedMergeMethod); |
| 483 | }} |
| 484 | > |
| 485 | Merge |
| 486 | </Button> |
| 487 | </AlertDialogFooter> |
| 488 | </AlertDialogPopup> |
| 489 | </AlertDialog> |
| 490 | ) : null} |
| 491 | </> |
| 492 | ); |
| 493 | } |