1import { resolvePullRequestDetailChecks } from "@t3tools/client-runtime/pull-request-checks";
2import {
3 capitalizeTerm,
4 sourceControlClients,
5} from "@t3tools/client-runtime/source-control-clients";
6import { PullRequestChangeBreakdownRows } from "../pullRequest/PullRequestChangeBreakdownStat";
7import { ThreadDetailsControl } from "./ThreadDetailsControl";
8import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
9import { selectEnvironmentQueryData } from "@t3tools/client-runtime/state/runtime";
10import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
11import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts";
12import {
13 ArrowUpRightIcon,
14 EyeIcon,
15 EyeOffIcon,
16 FileDiffIcon,
17 GitBranchIcon,
18 TriangleAlertIcon,
19} from "lucide-react";
20import { AsyncResult } from "effect/reactivity";
21import { useState, type MouseEvent as ReactMouseEvent } from "react";
22
23import type { ComposerThreadTarget } from "~/composerDraftStore";
24import { useLiveRefresh } from "~/hooks/useLiveRefresh";
25import { usePullRequestChecksRefresh } from "~/hooks/usePullRequestChecksRefresh";
26import { cn } from "~/lib/utils";
27import { useServerConfigs } from "~/state/entities";
28import { pullRequestEnvironment } from "~/state/pullRequests";
29import { useUiStateStore } from "~/uiStateStore";
30import {
31 useEnvironmentQueryIsNewer,
32 useEnvironmentQueryRefresh,
33 useEnvironmentQuerySelector,
34} from "~/state/query";
35
36import {
37 buildFixFindingsHandoff,
38 buildResolveConflictsPrompt,
39 classifyPullRequestChecks,
40 describePullRequestChecks,
41 isPullRequestConflicting,
42 resolvePullRequestMergeMethod,
43 allowedPullRequestMergeMethods,
44 resolveThreadPanelPullRequestAction,
45} from "../pullRequest/pullRequestDetail.logic";
46import { PullRequestChecksPopover } from "../pullRequest/PullRequestChecksPopover";
47import {
48 pullRequestChecksState,
49 PullRequestCheckStatusIcon,
50 PullRequestDiffStat,
51 resolvePullRequestState,
52} from "../pullRequest/pullRequestPresentation";
53import {
54 usePullRequestActionRunner,
55 usePullRequestDefaultMergeMethodResolver,
56 usePullRequestHandoffs,
57 writeHandoffToComposer,
58 type PullRequestThreadTask,
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 { Spinner } from "../ui/spinner";
76import { toastManager } from "../ui/toast";
77import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
78import {
79 THREAD_DETAILS_PANEL_ICON_CLASS,
80 THREAD_DETAILS_PANEL_LABEL_CLASS,
81 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
82 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
83} from "./threadDetailsPanelStyles";
84
85// A null checks response is settled; polling cannot find checks.
86const isSettledWithoutChecks = (result: AsyncResult.AsyncResult<unknown, unknown>) =>
87 result._tag === "Success" && result.value === null;
88
89export function ThreadDetailsPrRow({
90 environmentId,
91 pr,
92 number,
93 reference: linkedReference,
94 status,
95 project,
96 label,
97 openAriaLabel,
98 onOpen,
99 onActed,
100 onStopWatching,
101 composerTarget,
102}: {
103 environmentId: EnvironmentId;
104 pr: ThreadPr;
105 number: number;
106 reference?: Pick<PullRequestRef, "host" | "repository" | "number"> | null;
107 status: PrStatusIndicator | null;
108 project: EnvironmentProject | null;
109 label: string;
110 openAriaLabel: string;
111 onOpen: (event: ReactMouseEvent<HTMLElement>) => void;
112 onActed?: () => void;
113 onStopWatching?: (() => void) | undefined;
114 /** Fix and Resolve write here instead of checking the pull request out in a new thread. */
115 composerTarget?: ComposerThreadTarget | undefined;
116}) {
117 const serverConfigs = useServerConfigs();
118 const supportsPullRequests = serverConfigs.get(environmentId) !== undefined;
119 const repository = sourceControlClients.repositorySelector(project?.repositoryIdentity);
120 const reference: PullRequestRef | null =
121 supportsPullRequests && project !== null
122 ? linkedReference
123 ? { ...linkedReference, projectId: project.id as ProjectId }
124 : repository !== null
125 ? { projectId: project.id as ProjectId, repository, number }
126 : null
127 : null;
128 const detailAtom =
129 reference === null
130 ? null
131 : pullRequestEnvironment.detail({
132 environmentId,
133 input: { ...reference, allowStale: false },
134 });
135 // Select query parts because polling rebuilds unchanged results.
136 const detailData = useEnvironmentQuerySelector(detailAtom, selectEnvironmentQueryData);
137 const detailQuery = useEnvironmentQueryRefresh(detailAtom);
138 const supportsChecks =
139 serverConfigs.get(environmentId)?.environment.capabilities.pullRequestChecks === true;
140 const checksAtom =
141 supportsChecks && reference !== null && detailData !== null
142 ? pullRequestEnvironment.checks({ environmentId, input: reference })
143 : null;
144 const checksData = useEnvironmentQuerySelector(checksAtom, selectEnvironmentQueryData);
145 const checksSettledEmpty = useEnvironmentQuerySelector(checksAtom, isSettledWithoutChecks);
146 const checksQuery = useEnvironmentQueryRefresh(checksAtom);
147 const checksAreNewer = useEnvironmentQueryIsNewer(checksAtom, detailAtom);
148 const detail = resolvePullRequestDetailChecks(detailData, checksData, checksAreNewer);
149 const terminology = sourceControlClients.get(
150 detail?.provider ?? project?.repositoryIdentity?.provider,
151 ).changeRequest;
152 const open = reference !== null && (detail?.state ?? pr?.state) === "open";
153 const refreshKey = `${environmentId}:${project?.id}:${reference?.host}:${reference?.repository}:${number}`;
154 useLiveRefresh(
155 () => {
156 if (!detailQuery.isPending()) detailQuery.refresh();
157 },
158 { enabled: open, key: `workspace-pr:${refreshKey}`, intervalMs: 10 * 60_000 },
159 );
160 usePullRequestChecksRefresh({
161 refresh: () => {
162 if (!checksQuery.isPending() && !detailQuery.isPending()) checksQuery.refresh();
163 },
164 enabled: open && supportsChecks && !checksSettledEmpty,
165 key: `workspace-pr-checks:${refreshKey}`,
166 checks: detail?.checks ?? [],
167 });
168
169 const { actionPending, perform } = usePullRequestActionRunner({
170 environmentId,
171 reference,
172 terminology,
173 onSuccess: () => {
174 checksQuery.refresh();
175 onActed?.();
176 },
177 });
178 const { handoff, startHandoff } = usePullRequestHandoffs({ environmentId, detail });
179 const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod);
180 const resolveProjectDefaultMergeMethod = usePullRequestDefaultMergeMethodResolver(
181 environmentId,
182 (project?.id as ProjectId | undefined) ?? null,
183 );
184 const [confirmingMerge, setConfirmingMerge] = useState(false);
185
186 const rowAction = resolveThreadPanelPullRequestAction(detail);
187 if (confirmingMerge && rowAction !== "merge") {
188 setConfirmingMerge(false);
189 }
190 const conflicting = isPullRequestConflicting(detail);
191 const checksState = detail === null ? "none" : classifyPullRequestChecks(detail.checks);
192 const checksRollup = detail === null ? null : pullRequestChecksState(detail.checks);
193 const selectedMergeMethod = resolvePullRequestMergeMethod(
194 allowedPullRequestMergeMethods(detail),
195 null,
196 resolveProjectDefaultMergeMethod(),
197 lastSelectedMergeMethod,
198 );
199
200 const handOff = (kind: "conflicts" | "findings", task: PullRequestThreadTask) => {
201 if (composerTarget === undefined) {
202 void startHandoff(kind, task);
203 return;
204 }
205 writeHandoffToComposer(composerTarget, task);
206 toastManager.add({
207 type: "success",
208 title: "Added to the composer",
209 description: "The task is in the composer. Read it over, then send.",
210 });
211 };
212
213 const startResolveConflicts = () => {
214 if (detail === null) return;
215 handOff("conflicts", {
216 prompt: buildResolveConflictsPrompt({
217 number: detail.number,
218 url: detail.url,
219 headBranch: detail.headBranch,
220 baseBranch: detail.baseBranch,
221 inThisThread: composerTarget !== undefined,
222 }),
223 });
224 };
225
226 const startFixChecks = () => {
227 if (detail === null) return;
228 handOff(
229 "findings",
230 buildFixFindingsHandoff({
231 number: detail.number,
232 title: detail.title,
233 url: detail.url,
234 headBranch: detail.headBranch,
235 baseBranch: detail.baseBranch,
236 state: detail.state,
237 isDraft: detail.isDraft,
238 reviewThreads: [],
239 comments: [],
240 checks: detail.checks,
241 commentsTruncated: false,
242 inThisThread: composerTarget !== undefined,
243 }),
244 );
245 };
246
247 const statePresentation =
248 detail === null
249 ? null
250 : resolvePullRequestState({
251 state: detail.state,
252 isDraft: detail.isDraft,
253 });
254 const icon = statePresentation ? (
255 <statePresentation.Icon
256 aria-hidden
257 className={cn("size-4 shrink-0", statePresentation.toneClassName)}
258 />
259 ) : pr && status ? (
260 <ChangeRequestStatusIcon
261 state={pr.state}
262 className={cn(THREAD_DETAILS_PANEL_ICON_CLASS, status.colorClass)}
263 />
264 ) : (
265 <PullRequestGlyph.pullRequest className={THREAD_DETAILS_PANEL_ICON_CLASS} />
266 );
267
268 const rowTooltip =
269 detail === null || statePresentation === null ? (
270 <TooltipPopup side="top">
271 {status?.tooltip ?? `${capitalizeTerm(terminology.singular)} #${number}`}
272 </TooltipPopup>
273 ) : (
274 <TooltipPopup
275 side="top"
276 align="start"
277 sideOffset={4}
278 variant="glass"
279 className="max-w-80 text-left whitespace-normal"
280 >
281 <div className="flex min-w-0 max-w-80 flex-col gap-2 px-1 py-2">
282 <div className="flex min-w-0 items-baseline gap-1.5 text-xs leading-none">
283 <span className="min-w-0 truncate font-medium text-foreground">{detail.title}</span>
284 <span className="shrink-0 text-muted-foreground">#{detail.number}</span>
285 </div>
286 <div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">
287 <div className="flex min-w-0 items-center gap-2">
288 <statePresentation.Icon
289 aria-hidden
290 className={cn("size-3 shrink-0", statePresentation.toneClassName)}
291 />
292 <div className="min-w-0 truncate text-foreground/75">{statePresentation.label}</div>
293 </div>
294 <div className="flex min-w-0 items-center gap-2">
295 <GitBranchIcon className="size-3 shrink-0 stroke-muted-foreground" />
296 <div className="min-w-0 truncate text-foreground/75">
297 {detail.baseBranch} ← {detail.headBranch}
298 </div>
299 </div>
300 {detail.state === "open" && checksState !== "none" ? (
301 <div className="flex min-w-0 items-center gap-2">
302 <PullRequestCheckStatusIcon
303 status={
304 checksState === "failing"
305 ? "failure"
306 : checksState === "pending"
307 ? "pending"
308 : "success"
309 }
310 />
311 <div className="min-w-0 truncate text-foreground/75">
312 {describePullRequestChecks(detail.checks)}
313 </div>
314 </div>
315 ) : null}
316 {detail.isDraft && conflicting ? (
317 <div className="flex min-w-0 items-start gap-2 text-destructive">
318 <TriangleAlertIcon aria-hidden className="mt-0.5 size-3 shrink-0 stroke-current" />
319 <div className="min-w-0 flex-1 wrap-break-word">
320 Merge conflicts with {detail.baseBranch}
321 </div>
322 </div>
323 ) : null}
324 <div className="flex min-w-0 items-center gap-2">
325 <FileDiffIcon className="size-3 shrink-0 stroke-muted-foreground" />
326 <div className="min-w-0 flex items-baseline gap-1 truncate text-foreground/75">
327 {detail.changedFiles.toLocaleString()}{" "}
328 {detail.changedFiles === 1 ? "file" : "files"}
329 <PullRequestDiffStat additions={detail.additions} deletions={detail.deletions} />
330 </div>
331 </div>
332 {reference !== null && (detail.additions > 0 || detail.deletions > 0) ? (
333 <PullRequestChangeBreakdownRows
334 environmentId={environmentId}
335 reference={reference}
336 className="pl-5 text-foreground/75"
337 />
338 ) : null}
339 </div>
340 </div>
341 </TooltipPopup>
342 );
343
344 const newThreadSuffix =
345 composerTarget === undefined ? (
346 <ArrowUpRightIcon aria-hidden className="size-3 shrink-0" />
347 ) : null;
348 const trailingAction =
349 rowAction === "resolve"
350 ? {
351 label: "Resolve",
352 pendingLabel: "Preparing...",
353 pending: handoff === "conflicts",
354 destructive: true,
355 suffix: newThreadSuffix,
356 tooltip:
357 composerTarget === undefined
358 ? "Check the branch out and resolve the conflicts in a new thread"
359 : "Resolve the conflicts in this thread",
360 onClick: startResolveConflicts,
361 }
362 : rowAction === "ready"
363 ? {
364 label: "Ready",
365 pendingLabel: "Marking...",
366 pending: actionPending,
367 destructive: false,
368 suffix: null,
369 tooltip: `Mark this ${terminology.singular} as ready for review`,
370 onClick: () => void perform("ready"),
371 }
372 : rowAction === "fix"
373 ? {
374 label: "Fix",
375 pendingLabel: "Fix",
376 pending: handoff === "findings",
377 destructive: true,
378 suffix:
379 handoff === "findings" ? (
380 <Spinner size="xs" aria-label="Preparing fix" />
381 ) : (
382 newThreadSuffix
383 ),
384 tooltip:
385 composerTarget === undefined
386 ? "Fix the failing checks in a new thread"
387 : "Fix the failing checks in this thread",
388 onClick: startFixChecks,
389 }
390 : rowAction === "merge"
391 ? {
392 label: "Merge",
393 pendingLabel: "Merging...",
394 pending: actionPending,
395 destructive: false,
396 suffix: null,
397 tooltip: `Merge this ${terminology.singular} (${selectedMergeMethod})`,
398 onClick: () => setConfirmingMerge(true),
399 }
400 : null;
401
402 const watching =
403 onStopWatching !== undefined && (detail?.state ?? pr?.state ?? "open") === "open";
404 const watchControl = (part: "secondary" | "icon") =>
405 watching ? (
406 <Tooltip>
407 <TooltipTrigger
408 render={
409 <ThreadDetailsControl
410 type="button"
411 variant="ghost"
412 size="sm"
413 part={part}
414 className="group/watch"
415 aria-label={`Stop watching #${number}`}
416 onClick={onStopWatching}
417 />
418 }
419 >
420 <EyeIcon
421 aria-hidden
422 className={cn(part === "icon" ? "size-3.5" : "size-4", "group-hover/watch:hidden")}
423 />
424 <EyeOffIcon
425 aria-hidden
426 className={cn(
427 part === "icon" ? "size-3.5" : "size-4",
428 "hidden group-hover/watch:block",
429 )}
430 />
431 </TooltipTrigger>
432 <TooltipPopup side="top">
433 Watching: the agent wakes when checks finish, someone comments, or the branch conflicts.
434 Click to stop.
435 </TooltipPopup>
436 </Tooltip>
437 ) : null;
438
439 // The title row wraps to two lines; a conventional-commit prefix eats most of one line.
440 const openRow = (text: string, part: "row" | "primary", wrap = false) => (
441 <Tooltip>
442 <TooltipTrigger
443 render={
444 <ThreadDetailsControl
445 type="button"
446 variant="ghost"
447 size="sm"
448 part={part}
449 multiline={wrap}
450 className={wrap ? "items-start [&>svg]:mt-px" : undefined}
451 aria-label={openAriaLabel}
452 onClick={onOpen}
453 onAuxClick={onOpen}
454 />
455 }
456 >
457 {icon}
458 <span
459 className={cn(
460 "min-w-0 flex-1 text-left",
461 wrap
462 ? "line-clamp-2 whitespace-normal leading-4.5"
463 : cn("truncate", THREAD_DETAILS_PANEL_LABEL_CLASS),
464 )}
465 >
466 {text}
467 </span>
468 </TooltipTrigger>
469 {rowTooltip}
470 </Tooltip>
471 );
472
473 const showChecks = detail !== null && checksRollup !== null && !conflicting && !detail.isDraft;
474
475 return (
476 <>
477 {detail !== null && (showChecks || trailingAction !== null || watching) ? (
478 <div className="pb-1.5">
479 {openRow(detail.title, "row", true)}
480 <div className="mt-1.5 flex h-5 min-w-0 items-center gap-0.5 ps-9 pe-2.5 text-xs text-muted-foreground">
481 <span className="me-1 shrink-0 tabular-nums">#{number}</span>
482 {showChecks && checksRollup !== null ? (
483 <PullRequestChecksPopover
484 checksState={checksRollup}
485 checks={detail.checks}
486 variant="count"
487 render={<ThreadDetailsControl part="meta" className="min-w-0 shrink" />}
488 />
489 ) : null}
490 {watchControl("icon")}
491 <span className="flex-1" />
492 {trailingAction ? (
493 <Tooltip>
494 <TooltipTrigger
495 render={
496 <ThreadDetailsControl
497 type="button"
498 variant="ghost"
499 size="sm"
500 part="meta"
501 tone={trailingAction.destructive ? "destructive" : "primary"}
502 disabled={actionPending || handoff !== null}
503 onClick={trailingAction.onClick}
504 />
505 }
506 >
507 {trailingAction.pending ? trailingAction.pendingLabel : trailingAction.label}
508 {trailingAction.suffix}
509 </TooltipTrigger>
510 <TooltipPopup side="top">{trailingAction.tooltip}</TooltipPopup>
511 </Tooltip>
512 ) : null}
513 </div>
514 </div>
515 ) : watching ? (
516 <div className={THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS}>
517 {openRow(label, "primary")}
518 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
519 {watchControl("secondary")}
520 </div>
521 ) : (
522 openRow(label, "row")
523 )}
524 {rowAction === "merge" ? (
525 <AlertDialog open={confirmingMerge} onOpenChange={(open) => setConfirmingMerge(open)}>
526 <AlertDialogPopup>
527 <AlertDialogHeader>
528 <AlertDialogTitle>Merge {terminology.singular}?</AlertDialogTitle>
529 <AlertDialogDescription>
530 This merges #{number} using {selectedMergeMethod}.
531 </AlertDialogDescription>
532 </AlertDialogHeader>
533 <AlertDialogFooter>
534 <AlertDialogClose render={<Button variant="outline" size="sm" />}>
535 Cancel
536 </AlertDialogClose>
537 <Button
538 size="sm"
539 disabled={actionPending}
540 onClick={() => {
541 setConfirmingMerge(false);
542 void perform("merge", selectedMergeMethod);
543 }}
544 >
545 Merge
546 </Button>
547 </AlertDialogFooter>
548 </AlertDialogPopup>
549 </AlertDialog>
550 ) : null}
551 </>
552 );
553}