1import { ThreadDetailsControl } from "./ThreadDetailsControl";
2import { 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 */
19import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
20import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts";
21import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
22import {
23 ArrowUpRightIcon,
24 EyeIcon,
25 EyeOffIcon,
26 FileDiffIcon,
27 GitBranchIcon,
28 TriangleAlertIcon,
29} from "lucide-react";
30import { useState, type MouseEvent as ReactMouseEvent } from "react";
31
32import { useLiveRefresh } from "~/hooks/useLiveRefresh";
33import { usePullRequestChecksRefresh } from "~/hooks/usePullRequestChecksRefresh";
34import { cn } from "~/lib/utils";
35import { useServerConfigs } from "~/state/entities";
36import { pullRequestEnvironment } from "~/state/pullRequests";
37import { useEnvironmentQuery } from "~/state/query";
38
39import {
40 buildFixFindingsHandoff,
41 buildResolveConflictsPrompt,
42 classifyPullRequestChecks,
43 describePullRequestChecks,
44 isPullRequestConflicting,
45 resolveSelectedMergeMethod,
46 allowedPullRequestMergeMethods,
47 resolveThreadPanelPullRequestAction,
48} from "../pullRequest/pullRequestDetail.logic";
49import { PullRequestChecksPopover } from "../pullRequest/PullRequestChecksPopover";
50import {
51 pullRequestChecksState,
52 PullRequestCheckStatusIcon,
53 PullRequestDiffStat,
54 resolvePullRequestState,
55} from "../pullRequest/pullRequestPresentation";
56import {
57 usePullRequestActionRunner,
58 usePullRequestHandoffs,
59} from "../pullRequest/usePullRequestActions";
60import {
61 ChangeRequestStatusIcon,
62 type PrStatusIndicator,
63 type ThreadPr,
64} from "../ThreadStatusIndicators";
65import {
66 AlertDialog,
67 AlertDialogClose,
68 AlertDialogDescription,
69 AlertDialogFooter,
70 AlertDialogHeader,
71 AlertDialogPopup,
72 AlertDialogTitle,
73} from "../ui/alert-dialog";
74import { Button } from "../ui/button";
75import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
76import {
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
83export 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}