1import type { ProjectId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts";
2import {
3 sourceControlClients,
4 UNKNOWN_SOURCE_CONTROL_CLIENT,
5} from "@t3tools/client-runtime/source-control-clients";
6import {
7 resolveThreadPullRequestChains,
8 visibleThreadPullRequests,
9} from "@t3tools/shared/threadPullRequests";
10import {
11 ArrowUpRightIcon,
12 EyeIcon,
13 EyeOffIcon,
14 LinkIcon,
15 MoreHorizontalIcon,
16 PlusIcon,
17} from "lucide-react";
18import { useCallback, useMemo, useState } from "react";
19
20import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
21import { findProjectForChangeRequest, useOpenPrLink } from "~/lib/openPullRequestLink";
22import { cn } from "~/lib/utils";
23import { useShortcutModifierState } from "~/shortcutModifierState";
24import { useProjects, useServerConfigs, useThreadShell } from "~/state/entities";
25import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
26import { threadEnvironment } from "~/state/threads";
27import { useAtomCommand } from "~/state/use-atom-command";
28import { formatRelativeTimeLabel } from "~/timestampFormat";
29import { Button } from "../ui/button";
30import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
31import { MiddleTruncate } from "../ui/middle-truncate";
32import { ScrollArea } from "../ui/scroll-area";
33import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
34import { openLinkPullRequestDialog } from "./LinkPullRequestDialog";
35import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines";
36import {
37 PULL_REQUEST_ROW_CLASS,
38 PULL_REQUEST_ROW_NUMBER_CLASS,
39 PullRequestRowAuthor,
40 PullRequestRowBranches,
41 PullRequestRowGlyph,
42 PullRequestRowLines,
43} from "./PullRequestListRow";
44import {
45 PullRequestDiffStat,
46 PullRequestReviewDecisionGlyph,
47 pullRequestChecksStatePresentation,
48} from "./pullRequestPresentation";
49import { PullRequestGlyph } from "./pullRequestIcons";
50import { pullRequestQuickActions } from "./pullRequestList.logic";
51import { MergebotQueueBadge } from "./MergebotQueueBadge";
52import { PullRequestSpeedActions } from "./PullRequestSpeedActions";
53
54const SOURCE_LABELS: Record<ThreadPullRequestLink["source"], string> = {
55 manual: "Linked by you",
56 created: "Created from this thread",
57 agent: "Linked by the agent",
58 stack: "Found in the stack",
59 "stack-dismissed": "Dismissed",
60};
61
62function ChecksGlyph({
63 state,
64}: {
65 state: NonNullable<ThreadPullRequestLink["snapshot"]>["checksState"] & string;
66}) {
67 const presentation = pullRequestChecksStatePresentation(state);
68 return (
69 <Tooltip>
70 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
71 <presentation.Icon
72 role="img"
73 aria-label={presentation.label}
74 className={cn("size-3.5", presentation.toneClassName)}
75 />
76 </TooltipTrigger>
77 <TooltipPopup>{presentation.label}</TooltipPopup>
78 </Tooltip>
79 );
80}
81
82function LinkRow({
83 line,
84 threadRef,
85 projectId,
86 speedMode,
87 onUnlink,
88 onSetWatching,
89}: {
90 line: PullRequestListLine;
91 threadRef: ScopedThreadRef;
92 projectId: ProjectId | null;
93 speedMode: boolean;
94 onUnlink: (link: ThreadPullRequestLink) => void;
95 /** Null when the environment cannot watch pull requests. */
96 onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null;
97}) {
98 const openPrLink = useOpenPrLink(threadRef);
99 const [menuOpen, setMenuOpen] = useState(false);
100 const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null);
101 const menuAnchor = useMemo(
102 () =>
103 menuPosition
104 ? { getBoundingClientRect: () => new DOMRect(menuPosition.x, menuPosition.y, 0, 0) }
105 : undefined,
106 [menuPosition],
107 );
108 const { link, depth, stack } = line;
109 const snapshot = link.snapshot;
110 const open = snapshot === null || snapshot.state === "open";
111 const watching = link.watch !== undefined;
112 // A link carries no host summary, so the host's own definition says what it can do.
113 const linkHostActions = (
114 sourceControlClients.findByChangeRequestUrl(link.url) ?? UNKNOWN_SOURCE_CONTROL_CLIENT
115 ).changeRequestActions;
116 const actionEntry =
117 projectId !== null && snapshot !== null && snapshot.state !== "merged"
118 ? {
119 environmentId: threadRef.environmentId,
120 projectId,
121 host: link.host,
122 repository: link.repository,
123 number: link.number,
124 state: snapshot.state,
125 isDraft: snapshot.isDraft,
126 ...(link.stack === null ? {} : { stack: link.stack }),
127 }
128 : null;
129 // Speed mode swaps the menu for quick actions only where a row has some to offer.
130 const hasQuickActions =
131 actionEntry !== null && pullRequestQuickActions(actionEntry, linkHostActions).length > 0;
132 return (
133 <div
134 className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")}
135 onContextMenu={(event) => {
136 event.preventDefault();
137 event.stopPropagation();
138 setMenuPosition({ x: event.clientX, y: event.clientY });
139 setMenuOpen(true);
140 }}
141 // Each layer steps in under the one it targets. The step is capped: beyond a few layers
142 // the indent only says "still in the stack", which the connector line already does, and
143 // a sixteen-layer stack would otherwise stair-step off the right edge.
144 style={{ paddingLeft: `${0.5 + Math.min(depth, 3) * 1.25}rem` }}
145 >
146 {depth > 0 ? <span aria-hidden className="-ml-2 h-6 w-px shrink-0 bg-border/70" /> : null}
147 {snapshot === null ? (
148 <PullRequestGlyph.pullRequest
149 aria-label="Waiting for host state"
150 className="size-4 shrink-0 text-muted-foreground"
151 />
152 ) : (
153 <PullRequestRowGlyph
154 state={snapshot.state}
155 isDraft={snapshot.isDraft}
156 mergeability={snapshot.mergeability}
157 baseBranch={snapshot.baseBranch}
158 />
159 )}
160 <a
161 href={link.url}
162 onClick={(event) => openPrLink(event, link.url, threadRef)}
163 className="flex min-w-0 flex-1"
164 >
165 <PullRequestRowLines
166 number={
167 <Tooltip>
168 <TooltipTrigger render={<span className={PULL_REQUEST_ROW_NUMBER_CLASS} />}>
169 #{link.number}
170 </TooltipTrigger>
171 <TooltipPopup>
172 {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)}
173 </TooltipPopup>
174 </Tooltip>
175 }
176 title={snapshot?.title ?? link.repository}
177 signals={
178 open ? (
179 <>
180 {watching ? (
181 <Tooltip>
182 <TooltipTrigger render={<span className="inline-flex shrink-0" />}>
183 <EyeIcon role="img" aria-label="Watching" className="size-3.5" />
184 </TooltipTrigger>
185 <TooltipPopup>
186 Watching: the agent wakes when checks finish, someone comments, or the branch
187 conflicts
188 </TooltipPopup>
189 </Tooltip>
190 ) : null}
191 {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null}
192 {snapshot?.reviewDecision ? (
193 <PullRequestReviewDecisionGlyph decision={snapshot.reviewDecision} />
194 ) : null}
195 </>
196 ) : null
197 }
198 // Match the full PR list: diff counts up top, checks under the lifecycle glyph, the
199 // verdict by the author. Each is absent rather than neutral when the host said
200 // nothing, so a row without them reads as unknown, not as fine.
201 status={
202 <PullRequestDiffStat
203 additions={snapshot?.additions ?? 0}
204 deletions={snapshot?.deletions ?? 0}
205 className="font-mono"
206 />
207 }
208 meta={
209 <>
210 {open ? (
211 <MergebotQueueBadge
212 environmentId={threadRef.environmentId}
213 repository={link.repository}
214 number={link.number}
215 />
216 ) : null}
217 {stack ? (
218 <Tooltip>
219 <TooltipTrigger
220 render={
221 <span className="inline-flex shrink-0 items-center gap-0.5 text-foreground/70" />
222 }
223 >
224 <PullRequestGlyph.stack aria-hidden className="size-3" />
225 {stack.size}
226 </TooltipTrigger>
227 <TooltipPopup>
228 {stack.kind === "native"
229 ? `${sourceControlClients.hostLabelForChangeRequestUrl(link.url)} stack of ${stack.size}: merging a layer lands the ones below it.`
230 : `${stack.size} pull requests chained by base branch.`}
231 </TooltipPopup>
232 </Tooltip>
233 ) : null}
234 {snapshot?.author ? (
235 <PullRequestRowAuthor
236 actor={snapshot.author}
237 className="shrink-0"
238 labelClassName="max-w-28"
239 />
240 ) : null}
241 {snapshot !== null ? (
242 <>
243 {/* Cut in the middle: rows from one owner differ in the repository name at the
244 end, which a tail cut would hide. */}
245 <Tooltip>
246 <TooltipTrigger render={<span className="flex min-w-0 max-w-32 font-mono" />}>
247 <MiddleTruncate value={link.repository} showTitle={false} />
248 </TooltipTrigger>
249 <TooltipPopup>{link.repository}</TooltipPopup>
250 </Tooltip>
251 <PullRequestRowBranches head={snapshot.headBranch} base={snapshot.baseBranch} />
252 </>
253 ) : (
254 <span className="truncate font-mono">
255 {link.host}/{link.repository}
256 </span>
257 )}
258 </>
259 }
260 updatedAt={snapshot?.updatedAt}
261 />
262 </a>
263 {actionEntry !== null ? (
264 <PullRequestSpeedActions
265 entry={actionEntry}
266 hostActions={linkHostActions}
267 visible={speedMode}
268 />
269 ) : null}
270 {/* Out of the row's flow, so no row reserves a column for a button only the hovered one
271 shows. It sits over the right end of the second line on the row's own hover color,
272 fading in from the left, so it covers the time and leaves the diff counts alone. */}
273 <span
274 className={cn(
275 "absolute right-0 bottom-0.5 flex items-center rounded-r-md bg-background pr-1 pl-5",
276 "[mask-image:linear-gradient(to_right,transparent,black_1rem)]",
277 // Hidden means untouchable too: on a touch screen there is no hover, and an invisible
278 // layer over the right of the row would otherwise swallow the tap meant for the link.
279 "pointer-events-none opacity-0 group-hover/pr-row:pointer-events-auto group-hover/pr-row:opacity-100",
280 "has-[[data-popup-open]]:pointer-events-auto has-[[data-popup-open]]:opacity-100",
281 "has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100",
282 "group-has-[[data-pull-request-action-pending=true]]/pr-row:hidden",
283 speedMode && hasQuickActions && "hidden",
284 )}
285 >
286 <span aria-hidden className="absolute inset-0 bg-accent/60" />
287 <Menu
288 open={menuOpen}
289 onOpenChange={(open) => {
290 setMenuOpen(open);
291 if (!open) setMenuPosition(null);
292 }}
293 >
294 <MenuTrigger
295 render={
296 <Button
297 variant="ghost"
298 size="icon-micro"
299 aria-label={`Actions for #${link.number}`}
300 className="relative"
301 >
302 <MoreHorizontalIcon className="size-3.5" />
303 </Button>
304 }
305 />
306 <MenuPopup
307 anchor={menuAnchor}
308 align={menuPosition ? "start" : "end"}
309 side="bottom"
310 sideOffset={menuPosition ? 0 : 4}
311 >
312 <MenuItem onClick={() => void writeTextToClipboard(link.url, "link")}>
313 <LinkIcon className="size-3.5" />
314 Copy link
315 </MenuItem>
316 <MenuItem onClick={(event) => openPrLink(event, link.url, threadRef)}>
317 <ArrowUpRightIcon className="size-3.5" />
318 Open
319 </MenuItem>
320 {onSetWatching !== null && open ? (
321 <MenuItem onClick={() => onSetWatching(link, !watching)}>
322 {watching ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
323 {watching ? "Stop watching" : "Watch for changes"}
324 </MenuItem>
325 ) : null}
326 <MenuItem onClick={() => onUnlink(link)}>
327 <PullRequestGlyph.unlink className="size-3.5" />
328 {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"}
329 </MenuItem>
330 </MenuPopup>
331 </Menu>
332 </span>
333 </div>
334 );
335}
336
337export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {
338 const configs = useServerConfigs();
339 if (configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequests !== true) {
340 return (
341 <PullRequestsUnavailableState
342 title="Linked pull requests unavailable"
343 error="This environment does not support multiple linked pull requests."
344 />
345 );
346 }
347 return <EnabledThreadPullRequestsPanel threadRef={threadRef} />;
348}
349
350function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) {
351 const thread = useThreadShell(threadRef);
352 const projects = useProjects();
353 const environmentProjects = useMemo(
354 () =>
355 projects
356 .filter((project) => project.environmentId === threadRef.environmentId)
357 .toSorted((left, right) =>
358 left.id === thread?.projectId ? -1 : right.id === thread?.projectId ? 1 : 0,
359 ),
360 [projects, threadRef.environmentId, thread?.projectId],
361 );
362 const modifiers = useShortcutModifierState(true);
363 const speedMode =
364 modifiers.shiftKey && !modifiers.metaKey && !modifiers.ctrlKey && !modifiers.altKey;
365 const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]);
366 const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });
367 const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });
368 const capabilities = useServerConfigs().get(threadRef.environmentId)?.environment.capabilities;
369 const supportsWatch = capabilities?.threadPullRequestWatch === true;
370 const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]);
371 const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]);
372 const handleUnlink = useCallback(
373 (link: ThreadPullRequestLink) => {
374 void unlink({
375 environmentId: threadRef.environmentId,
376 input: {
377 threadId: threadRef.threadId,
378 host: link.host,
379 repository: link.repository,
380 number: link.number,
381 },
382 });
383 },
384 [threadRef, unlink],
385 );
386 const handleSetWatching = useCallback(
387 (link: ThreadPullRequestLink, watching: boolean) => {
388 void watch({
389 environmentId: threadRef.environmentId,
390 input: {
391 threadId: threadRef.threadId,
392 host: link.host,
393 repository: link.repository,
394 number: link.number,
395 watching,
396 },
397 });
398 },
399 [threadRef, watch],
400 );
401 const openCount = useMemo(
402 () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length,
403 [links],
404 );
405 const lastSynced = useMemo(() => {
406 let latest: string | null = null;
407 for (const link of links) {
408 const at = link.snapshot?.syncedAt;
409 if (at !== undefined && (latest === null || at > latest)) latest = at;
410 }
411 return latest;
412 }, [links]);
413
414 if (links.length === 0) {
415 return (
416 <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
417 <PullRequestGlyph.link aria-hidden className="size-6 text-muted-foreground/60" />
418 <p className="text-sm font-medium">No linked pull requests</p>
419 <p className="max-w-60 text-xs text-muted-foreground">
420 Pull requests the agent opens from this thread land here. Link one yourself from a URL or
421 a number.
422 </p>
423 <Button size="sm" variant="outline" onClick={openLinkDialog}>
424 <PlusIcon className="size-3.5" />
425 Link pull request
426 </Button>
427 </div>
428 );
429 }
430
431 return (
432 <div className="flex h-full min-h-0 flex-col">
433 <ScrollArea className="min-h-0 flex-1">
434 <div className="flex flex-col p-1.5">
435 {lines.map((line) => (
436 <LinkRow
437 key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
438 line={line}
439 threadRef={threadRef}
440 projectId={
441 capabilities?.pullRequests === true
442 ? (findProjectForChangeRequest(environmentProjects, line.link)?.id ??
443 thread?.projectId ??
444 null)
445 : null
446 }
447 speedMode={speedMode}
448 onUnlink={handleUnlink}
449 onSetWatching={supportsWatch ? handleSetWatching : null}
450 />
451 ))}
452 </div>
453 </ScrollArea>
454 <footer className="flex items-center justify-between border-t border-border/60 px-2 py-1.5 text-2xs text-muted-foreground">
455 <span>
456 {openCount} open · {links.length} linked
457 {lastSynced ? ` · synced ${formatRelativeTimeLabel(lastSynced)}` : ""}
458 </span>
459 <Button size="xs" variant="ghost" onClick={openLinkDialog}>
460 <PlusIcon className="size-3.5" />
461 Link
462 </Button>
463 </footer>
464 </div>
465 );
466}