1import {
2 scopeProjectRef,
3 scopedThreadKey,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
6import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
7
8import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
9import {
10 resolveEnvironmentMachineKind,
11 type EnvironmentId,
12 type ScopedThreadRef,
13 type ThreadLinkedPullRequest,
14 type ThreadPullRequestLink,
15 type VcsStatusResult,
16} from "@t3tools/contracts";
17import { Atom } from "effect/reactivity";
18import { FolderGit2Icon, TerminalIcon } from "lucide-react";
19import { useCallback, useMemo } from "react";
20import { appAtomRegistry } from "../rpc/atomRegistry";
21import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
22import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
23import { useProject } from "../state/entities";
24import {
25 resolveThreadCurrentPullRequestLink,
26 resolveThreadPullRequestChains,
27 visibleThreadPullRequests,
28 type ThreadPullRequestBadge,
29} from "@t3tools/shared/threadPullRequests";
30import { useRender } from "@base-ui/react/use-render";
31import {
32 type ReactNode,
33 type AnimationEvent,
34 type MouseEvent,
35 type ReactElement,
36 type ComponentProps,
37} from "react";
38import { cn } from "../lib/utils";
39import { revealThreadTerminal } from "../lib/revealThreadTerminal";
40
41import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
42import { useEnvironmentQuery } from "../state/query";
43import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
44import { useThreadRunningTerminalIds } from "../state/terminalSessions";
45import { vcsEnvironment } from "../state/vcs";
46import { useUiStateStore } from "../uiStateStore";
47import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
48import {
49 pullRequestMatchesSearchQuery,
50 resolveThreadLastVisitedAt,
51 resolveThreadStatusPill,
52 type ThreadStatusPill,
53 useRetainedValue,
54 useSidebarRowSubscriptionLease,
55} from "./Sidebar.logic";
56
57import type { SidebarThreadSummary } from "../types";
58import { formatWorktreePathForDisplay } from "../worktreeCleanup";
59import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
60import { pullRequestListLines } from "./pullRequest/pullRequestListLines";
61import {
62 PULL_REQUEST_STATE_PRESENTATION,
63 PullRequestGlyph,
64 type PullRequestGlyphIcon,
65} from "./pullRequest/pullRequestIcons";
66import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
67
68export interface PrStatusIndicator {
69 label: string;
70 colorClass: string;
71 Icon: PullRequestGlyphIcon;
72 tooltip: string;
73 tooltipLead: string;
74 tooltipTitle: string;
75 url: string;
76}
77
78export interface TerminalStatusIndicator {
79 label: "Terminal process running";
80 colorClass: string;
81 pulse: boolean;
82}
83
84export type ThreadPr = VcsStatusResult["pr"];
85
86export interface LinkedThreadPullRequestStatus {
87 readonly pr: NonNullable<ThreadPr>;
88 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
89}
90
91/** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */
92export function useLinkedThreadPullRequest(
93 environmentId: EnvironmentId | null,
94 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
95 enabled = true,
96 pullRequests?: ReadonlyArray<ThreadPullRequestLink>,
97 branchPullRequest?: ThreadLinkedPullRequest | null,
98): LinkedThreadPullRequestStatus | null {
99 const supportsLinks = useSupportsMultiplePullRequests(environmentId);
100 const current = useMemo(
101 () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null),
102 [pullRequests, supportsLinks],
103 );
104 const fallback =
105 current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null;
106 // Stable per link: the shared summary effect keys on this object, and a sidebar row must not
107 // touch the cache on every render.
108 const reference = useMemo(() => {
109 if (fallback == null) return null;
110 const host = parseChangeRequestUrl(fallback.url)?.host;
111 return { ...fallback, ...(host === undefined ? {} : { host }) };
112 }, [fallback]);
113 const queried = useEnvironmentQuery(
114 !enabled || environmentId === null || reference === null
115 ? null
116 : linkedPullRequestDetailAtom({ environmentId, input: reference }),
117 );
118 const detail = useSharedPullRequestSummary(
119 environmentId,
120 reference,
121 queried.data,
122 queried.dataUpdatedAt,
123 );
124
125 return useMemo(() => {
126 if (current !== null) return linkedPullRequestSnapshotStatus(current);
127 return detail === null
128 ? null
129 : {
130 pr: pullRequestDetailToVcsStatus(detail),
131 sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" },
132 };
133 }, [current, detail]);
134}
135
136export function linkedPullRequestSnapshotStatus(
137 link: ThreadPullRequestLink,
138): LinkedThreadPullRequestStatus | null {
139 const snapshot = link.snapshot;
140 if (snapshot === null) return null;
141 // A link records no host kind, so read it from the URL's change request path.
142 const kind = (
143 sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined)
144 ).kind;
145 return {
146 pr: {
147 number: link.number,
148 url: link.url,
149 title: snapshot.title,
150 state: snapshot.state,
151 isDraft: snapshot.isDraft,
152 headRef: snapshot.headBranch,
153 baseRef: snapshot.baseBranch,
154 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),
155 },
156 sourceControlProvider: { kind, name: kind, baseUrl: "" },
157 };
158}
159
160export {
161 resolveThreadPullRequestBadge,
162 type ThreadPullRequestBadge,
163} from "@t3tools/shared/threadPullRequests";
164
165export interface ThreadPullRequestBadgePresentation {
166 readonly Icon: PullRequestGlyphIcon;
167 readonly toneClassName: string;
168 readonly label: string;
169 readonly text: string | number;
170}
171
172/** Resolve the complete badge appearance before rendering it in the sidebar or composer. */
173export function resolveThreadPullRequestBadgePresentation({
174 badge,
175 number,
176 url,
177 status,
178}: {
179 readonly badge: ThreadPullRequestBadge | null;
180 readonly number?: number | undefined;
181 readonly url?: string | undefined;
182 readonly status: PrStatusIndicator | null;
183}): ThreadPullRequestBadgePresentation | null {
184 // The badge already folds every visible link into one state, draft included, so both the
185 // stack and the linked count index the shared table directly rather than the single-PR resolver.
186 if (badge?.kind === "stack") {
187 const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
188 return {
189 Icon: PullRequestGlyph.stack,
190 toneClassName: aggregate.toneClassName,
191 label: `Stack of ${badge.layers} pull requests, ${aggregate.label.toLowerCase()}`,
192 text: badge.layers,
193 };
194 }
195 if (number === undefined || url === undefined) return null;
196
197 const tooltip = status?.tooltip ?? `PR #${number}, status pending`;
198 if (badge?.kind === "pull-request" && badge.others > 0) {
199 // Unrelated links fold into one state, so a count of merged PRs reads as merged.
200 const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
201 return {
202 Icon: aggregate.Icon,
203 toneClassName: aggregate.toneClassName,
204 label: `${tooltip}, and ${badge.others} more linked; overall ${aggregate.label.toLowerCase()}`,
205 text: `+${badge.others + 1}`,
206 };
207 }
208 return {
209 Icon: status?.Icon ?? PullRequestGlyph.pullRequest,
210 toneClassName: status?.colorClass ?? "text-muted-foreground",
211 label: tooltip,
212 text: number,
213 };
214}
215
216/**
217 * The linked-PR badge shared by the sidebar and composer footer. The badge owns what it shows:
218 * the state glyph and number at the meta size, in the state's color. The caller owns the control
219 * it sits in through `render` (an inline link in a sidebar row, a toolbar control in the
220 * composer), and the badge fills in the behavior: a single PR is a link to it, while a stack or
221 * several linked PRs is a button that opens the thread's pull requests tab.
222 */
223export function ThreadPullRequestBadgeControl({
224 render,
225 badge,
226 pullRequests,
227 number,
228 url,
229 status,
230 onOpenList,
231 onOpenPullRequest,
232}: {
233 render: ReactElement<{ render?: useRender.RenderProp }>;
234 badge: ThreadPullRequestBadge | null;
235 pullRequests: ReadonlyArray<ThreadPullRequestLink>;
236 number?: number | undefined;
237 url?: string | undefined;
238 status: PrStatusIndicator | null;
239 onOpenList: () => void;
240 onOpenPullRequest: (event: MouseEvent<HTMLElement>, url?: string) => void;
241}) {
242 const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status });
243 if (presentation === null) return null;
244 return (
245 <PullRequestBadge
246 render={render}
247 presentation={presentation}
248 opensList={badge !== null && (badge.kind === "stack" || badge.others > 0)}
249 url={url}
250 number={number}
251 status={status}
252 pullRequests={pullRequests}
253 onOpenList={onOpenList}
254 onOpenPullRequest={onOpenPullRequest}
255 />
256 );
257}
258
259function PullRequestBadge({
260 render,
261 presentation,
262 opensList,
263 url,
264 number,
265 status,
266 pullRequests,
267 onOpenList,
268 onOpenPullRequest,
269}: {
270 render: ReactElement<{ render?: useRender.RenderProp }>;
271 presentation: NonNullable<ReturnType<typeof resolveThreadPullRequestBadgePresentation>>;
272 opensList: boolean;
273 url: string | undefined;
274 number: number | undefined;
275 status: PrStatusIndicator | null;
276 pullRequests: ReadonlyArray<ThreadPullRequestLink>;
277 onOpenList: () => void;
278 onOpenPullRequest: (event: MouseEvent<HTMLElement>, url?: string) => void;
279}) {
280 const onClick = opensList
281 ? (event: MouseEvent<HTMLElement>) => {
282 event.preventDefault();
283 event.stopPropagation();
284 onOpenList();
285 }
286 : onOpenPullRequest;
287 const element = opensList ? (
288 <button type="button" />
289 ) : (
290 <a href={url} target="_blank" rel="noopener noreferrer" />
291 );
292 // The caller's control (InlineButton, ComposerControl) renders as the link or stack button
293 // through its own render prop; useRender merges the badge's behavior into it.
294 const control = useRender({
295 render,
296 props: {
297 render: element,
298 "aria-label": presentation.label,
299 onPointerDown: (event: MouseEvent<HTMLElement>) => event.stopPropagation(),
300 onClick,
301 },
302 });
303 return (
304 <Tooltip key={opensList ? "list" : "link"}>
305 <TooltipTrigger render={control}>
306 <span
307 className={cn("contents font-normal text-xs tabular-nums", presentation.toneClassName)}
308 >
309 <presentation.Icon aria-hidden className="size-3 shrink-0" />
310 {/* An element, not bare text: bare text takes its line box from the control, which
311 inherits the row's size, so beside a text-sm title it sat below the other meta. */}
312 <span>{presentation.text}</span>
313 </span>
314 </TooltipTrigger>
315 <TooltipPopup
316 side="top"
317 sideOffset={0}
318 variant="glass"
319 className="pointer-events-auto w-80 max-w-[calc(100vw-2rem)] text-left whitespace-normal"
320 >
321 {/* The viewport pads 8px inline and 4px block; pull the rows out so their hover
322 highlight sits 4px from every edge. */}
323 <div className="-mx-1">
324 {visibleThreadPullRequests(pullRequests).length > 0 ? (
325 <ThreadPullRequestsMiniList
326 pullRequests={pullRequests}
327 onOpenPullRequest={onOpenPullRequest}
328 />
329 ) : number !== undefined && url !== undefined ? (
330 <ul className="flex flex-col gap-1">
331 <ThreadPullRequestMiniListItem
332 number={number}
333 url={url}
334 title={status?.tooltipTitle ?? presentation.label}
335 presentation={presentation}
336 onOpenPullRequest={onOpenPullRequest}
337 />
338 </ul>
339 ) : null}
340 </div>
341 </TooltipPopup>
342 </Tooltip>
343 );
344}
345
346/**
347 * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation,
348 * so the hover answers "what is in here" without opening the surface.
349 */
350export function ThreadPullRequestsMiniList({
351 pullRequests,
352 onOpenPullRequest,
353}: {
354 pullRequests: ReadonlyArray<ThreadPullRequestLink>;
355 onOpenPullRequest?: (event: MouseEvent<HTMLAnchorElement>, url: string) => void;
356}) {
357 const lines = useMemo(
358 () =>
359 pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))),
360 [pullRequests],
361 );
362 if (lines.length === 0) return null;
363 return (
364 <ul className="flex flex-col gap-1">
365 {lines.map((line) => {
366 const snapshot = line.link.snapshot;
367 const presentation =
368 snapshot === null
369 ? null
370 : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft });
371 return (
372 <ThreadPullRequestMiniListItem
373 key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
374 number={line.link.number}
375 url={line.link.url}
376 title={snapshot?.title ?? line.link.repository}
377 presentation={presentation}
378 depth={line.depth}
379 onOpenPullRequest={onOpenPullRequest}
380 >
381 {line.stack ? (
382 <span className="ml-auto shrink-0 pl-1 text-3xs">
383 {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size}
384 </span>
385 ) : null}
386 </ThreadPullRequestMiniListItem>
387 );
388 })}
389 </ul>
390 );
391}
392
393function ThreadPullRequestMiniListItem({
394 number,
395 url,
396 title,
397 presentation,
398 depth = 0,
399 onOpenPullRequest,
400 children,
401}: {
402 number: number;
403 url: string;
404 title: string;
405 presentation: Pick<ThreadPullRequestBadgePresentation, "Icon" | "toneClassName"> | null;
406 depth?: number;
407 onOpenPullRequest?: ((event: MouseEvent<HTMLAnchorElement>, url: string) => void) | undefined;
408 children?: ReactNode;
409}) {
410 const Icon = presentation?.Icon ?? PullRequestGlyph.pullRequest;
411 const content = (
412 <>
413 <Icon
414 aria-hidden
415 className={cn("size-3 shrink-0", presentation?.toneClassName ?? "stroke-muted-foreground")}
416 />
417 <span className="shrink-0 font-mono tabular-nums">#{number}</span>
418 <span className="min-w-0 truncate text-foreground/75">{title}</span>
419 {children}
420 </>
421 );
422 return (
423 <li style={{ paddingLeft: `${Math.min(depth, 3) * 0.75}rem` }}>
424 {onOpenPullRequest ? (
425 <a
426 href={url}
427 target="_blank"
428 rel="noopener noreferrer"
429 className="flex min-w-0 items-center gap-2 rounded-sm px-1 py-1 hover:bg-accent focus-visible:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
430 onPointerDown={(event) => event.stopPropagation()}
431 onClick={(event) => onOpenPullRequest(event, url)}
432 >
433 {content}
434 </a>
435 ) : (
436 <div className="flex min-w-0 items-center gap-2">{content}</div>
437 )}
438 </li>
439 );
440}
441
442export function prStatusIndicator(
443 pr: ThreadPr,
444 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
445): PrStatusIndicator | null {
446 if (!pr) return null;
447 const { shortLabel } = sourceControlClients.get(provider?.kind).changeRequest;
448 const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft === true });
449
450 const tooltipLead = `${shortLabel} #${pr.number} - ${state.label}`;
451 return {
452 label: `${shortLabel} ${state.label.toLowerCase()}`,
453 colorClass: state.toneClassName,
454 Icon: state.Icon,
455 tooltip: `${tooltipLead}: ${pr.title}`,
456 tooltipLead,
457 tooltipTitle: pr.title,
458 url: pr.url,
459 };
460}
461
462export function ChangeRequestStatusIcon({
463 state,
464 isDraft = false,
465 className,
466}: Pick<NonNullable<ThreadPr>, "state"> & {
467 readonly isDraft?: boolean | undefined;
468 readonly className?: string | undefined;
469}) {
470 const presentation = resolvePullRequestState({ state, isDraft });
471 return <presentation.Icon className={className} />;
472}
473
474export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) {
475 return (
476 <span className="flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch overflow-hidden whitespace-nowrap">
477 <span className="shrink-0 pr-2 font-medium">{status.tooltipLead}</span>
478 <span className="min-h-4 shrink-0 border-border/70 border-l" aria-hidden="true" />
479 <span className="min-w-0 truncate pl-2">{status.tooltipTitle}</span>
480 </span>
481 );
482}
483
484export function resolveThreadPr(input: {
485 threadBranch: string | null;
486 gitStatus: VcsStatusResult | null;
487}): ThreadPr | null {
488 const { threadBranch, gitStatus } = input;
489 if (gitStatus === null) {
490 return null;
491 }
492
493 if (threadBranch === null || gitStatus.refName !== threadBranch) {
494 return null;
495 }
496
497 return gitStatus.pr ?? null;
498}
499
500/**
501 * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement
502 * partitions move them, so terminal PR metadata must live above the row.
503 */
504export interface ThreadChangeRequestSnapshot {
505 readonly branch: string;
506 readonly pr: NonNullable<ThreadPr>;
507 readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined;
508 readonly linkedPullRequest?: ThreadLinkedPullRequest;
509}
510
511export const threadChangeRequestSnapshotsAtom = Atom.make<
512 ReadonlyMap<string, ThreadChangeRequestSnapshot>
513>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots"));
514
515function isTerminalChangeRequestState(
516 state: NonNullable<ThreadPr>["state"],
517): state is "merged" | "closed" {
518 return state === "merged" || state === "closed";
519}
520
521function sourceControlProvidersEqual(
522 left: VcsStatusResult["sourceControlProvider"] | undefined,
523 right: VcsStatusResult["sourceControlProvider"] | undefined,
524): boolean {
525 if (left === right) return true;
526 if (left == null || right == null) return left == null && right == null;
527 return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl;
528}
529
530function linkedPullRequestsEqual(
531 left: ThreadLinkedPullRequest | null | undefined,
532 right: ThreadLinkedPullRequest | null | undefined,
533): boolean {
534 if (left == null || right == null) return left == null && right == null;
535 return (
536 left.projectId === right.projectId &&
537 left.repository === right.repository &&
538 left.number === right.number &&
539 left.url === right.url
540 );
541}
542
543export function threadChangeRequestSnapshotsEqual(
544 left: ThreadChangeRequestSnapshot,
545 right: ThreadChangeRequestSnapshot,
546): boolean {
547 return (
548 left.branch === right.branch &&
549 left.pr.number === right.pr.number &&
550 left.pr.title === right.pr.title &&
551 left.pr.url === right.pr.url &&
552 left.pr.baseRef === right.pr.baseRef &&
553 left.pr.headRef === right.pr.headRef &&
554 left.pr.state === right.pr.state &&
555 left.pr.isDraft === right.pr.isDraft &&
556 (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) &&
557 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) &&
558 linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest)
559 );
560}
561
562export function setThreadChangeRequestSnapshot(
563 threadKey: string,
564 snapshot: ThreadChangeRequestSnapshot | null,
565): void {
566 appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => {
567 const existing = current.get(threadKey);
568 if (snapshot === null) {
569 if (existing === undefined) return [false, current];
570 const next = new Map(current);
571 next.delete(threadKey);
572 return [true, next];
573 }
574 if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) {
575 return [false, current];
576 }
577 const next = new Map(current);
578 next.set(threadKey, snapshot);
579 return [true, next];
580 });
581}
582
583/**
584 * Authoritative snapshot update from live VCS status.
585 * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone
586 * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear
587 * - snapshot: matching branch reports a PR — store/replace
588 */
589export function nextThreadChangeRequestSnapshot(input: {
590 threadBranch: string | null;
591 gitStatus: VcsStatusResult | null;
592 snapshot: ThreadChangeRequestSnapshot | null | undefined;
593 retainTerminalOnBranchMismatch: boolean;
594 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
595 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
596}): ThreadChangeRequestSnapshot | null | undefined {
597 const {
598 threadBranch,
599 gitStatus,
600 snapshot,
601 retainTerminalOnBranchMismatch,
602 linkedPullRequest,
603 linkedPullRequestStatus,
604 } = input;
605 if (linkedPullRequest != null) {
606 if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) {
607 return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
608 ? undefined
609 : null;
610 }
611 return {
612 branch: threadBranch ?? linkedPullRequestStatus.pr.headRef,
613 pr: linkedPullRequestStatus.pr,
614 sourceControlProvider: linkedPullRequestStatus.sourceControlProvider,
615 linkedPullRequest,
616 };
617 }
618 if (gitStatus === null) {
619 return snapshot?.linkedPullRequest === undefined ? undefined : null;
620 }
621 if (threadBranch === null) {
622 return null;
623 }
624 if (gitStatus.refName !== threadBranch) {
625 return retainTerminalOnBranchMismatch &&
626 snapshot != null &&
627 snapshot.linkedPullRequest === undefined &&
628 isTerminalChangeRequestState(snapshot.pr.state)
629 ? undefined
630 : null;
631 }
632 if (gitStatus.pr == null) {
633 if (
634 retainTerminalOnBranchMismatch &&
635 snapshot != null &&
636 snapshot.linkedPullRequest === undefined &&
637 isTerminalChangeRequestState(snapshot.pr.state)
638 ) {
639 return undefined;
640 }
641 return null;
642 }
643 return {
644 branch: threadBranch,
645 pr: gitStatus.pr,
646 sourceControlProvider: gitStatus.sourceControlProvider,
647 };
648}
649
650/**
651 * Live PR when the checkout matches the thread branch; otherwise, for local
652 * checkouts only, a cached merged/closed PR for the thread. Local thread
653 * metadata follows the shared checkout, so the cached branch intentionally
654 * survives that metadata changing to the newly checked-out branch. Open PRs
655 * are retained only while live status is absent and the branch still matches.
656 */
657export function resolveDisplayedThreadPr(input: {
658 threadBranch: string | null;
659 gitStatus: VcsStatusResult | null;
660 snapshot: ThreadChangeRequestSnapshot | null | undefined;
661 retainTerminalOnBranchMismatch: boolean;
662 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
663 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
664}): ThreadPr | null {
665 const {
666 threadBranch,
667 gitStatus,
668 snapshot,
669 retainTerminalOnBranchMismatch,
670 linkedPullRequest,
671 linkedPullRequestStatus,
672 } = input;
673 if (linkedPullRequest != null) {
674 return (
675 linkedPullRequestStatus?.pr ??
676 (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
677 ? (snapshot?.pr ?? null)
678 : null)
679 );
680 }
681 if (
682 threadBranch !== null &&
683 gitStatus !== null &&
684 gitStatus.refName === threadBranch &&
685 gitStatus.pr != null
686 ) {
687 return gitStatus.pr;
688 }
689
690 if (
691 gitStatus === null &&
692 threadBranch !== null &&
693 snapshot?.branch === threadBranch &&
694 snapshot.linkedPullRequest === undefined
695 ) {
696 return snapshot.pr;
697 }
698
699 if (
700 threadBranch !== null &&
701 retainTerminalOnBranchMismatch &&
702 snapshot != null &&
703 snapshot.linkedPullRequest === undefined &&
704 isTerminalChangeRequestState(snapshot.pr.state)
705 ) {
706 return snapshot.pr;
707 }
708
709 return null;
710}
711
712export function resolveDisplayedThreadPrProvider(input: {
713 threadBranch: string | null;
714 gitStatus: VcsStatusResult | null;
715 snapshot: ThreadChangeRequestSnapshot | null | undefined;
716 retainTerminalOnBranchMismatch: boolean;
717 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
718 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
719}): VcsStatusResult["sourceControlProvider"] | undefined {
720 const {
721 threadBranch,
722 gitStatus,
723 snapshot,
724 retainTerminalOnBranchMismatch,
725 linkedPullRequest,
726 linkedPullRequestStatus,
727 } = input;
728 if (linkedPullRequest != null) {
729 return (
730 linkedPullRequestStatus?.sourceControlProvider ??
731 (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest)
732 ? snapshot?.sourceControlProvider
733 : undefined)
734 );
735 }
736 if (
737 threadBranch !== null &&
738 gitStatus !== null &&
739 gitStatus.refName === threadBranch &&
740 gitStatus.pr != null
741 ) {
742 return gitStatus.sourceControlProvider;
743 }
744
745 if (
746 gitStatus === null &&
747 threadBranch !== null &&
748 snapshot?.branch === threadBranch &&
749 snapshot.linkedPullRequest === undefined
750 ) {
751 return snapshot.sourceControlProvider;
752 }
753
754 if (
755 threadBranch !== null &&
756 retainTerminalOnBranchMismatch &&
757 snapshot != null &&
758 snapshot.linkedPullRequest === undefined &&
759 isTerminalChangeRequestState(snapshot.pr.state)
760 ) {
761 return snapshot.sourceControlProvider;
762 }
763
764 return undefined;
765}
766
767export function terminalStatusFromRunningIds(
768 runningTerminalIds: ReadonlyArray<string>,
769): TerminalStatusIndicator | null {
770 if (runningTerminalIds.length === 0) {
771 return null;
772 }
773 return {
774 label: "Terminal process running",
775 colorClass: "text-teal-600 dark:text-teal-300/90",
776 pulse: true,
777 };
778}
779
780/** Align newly started pulses with the document clock without a timer or frame loop. */
781export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) {
782 if (event.animationName !== "status-pulse") return;
783
784 for (const animation of event.currentTarget.getAnimations()) {
785 if ("animationName" in animation && animation.animationName === "status-pulse") {
786 animation.startTime = 0;
787 }
788 }
789}
790
791export function ThreadTerminalStatusButton({
792 threadRef,
793 terminalIds,
794 onThreadActivate,
795}: {
796 threadRef: ScopedThreadRef;
797 terminalIds: readonly string[];
798 onThreadActivate: (threadRef: ScopedThreadRef) => void;
799}) {
800 const terminalId = terminalIds[0];
801 if (terminalId === undefined) return null;
802 const status = terminalStatusFromRunningIds(terminalIds);
803 const label =
804 terminalIds.length === 1
805 ? "1 terminal process running. Open terminal"
806 : `${terminalIds.length} terminal processes running. Open terminal`;
807 return (
808 <Tooltip>
809 <TooltipTrigger
810 render={
811 <button
812 type="button"
813 aria-label={label}
814 data-testid={`sidebar-terminal-status-${threadRef.threadId}`}
815 className={cn(
816 "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-sm outline-none hover:opacity-80 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
817 status?.colorClass,
818 )}
819 onPointerDown={(event) => event.stopPropagation()}
820 onKeyDown={(event) => event.stopPropagation()}
821 onClick={(event) => {
822 event.preventDefault();
823 event.stopPropagation();
824 revealThreadTerminal(threadRef, terminalId);
825 onThreadActivate(threadRef);
826 }}
827 />
828 }
829 >
830 <TerminalIcon
831 className="size-3.5 motion-safe:animate-status-pulse"
832 onAnimationStart={synchronizeTerminalPulse}
833 />
834 </TooltipTrigger>
835 <TooltipPopup side="top">{label}</TooltipPopup>
836 </Tooltip>
837 );
838}
839
840export function ThreadWorktreeIndicator({
841 thread,
842}: {
843 thread: Pick<SidebarThreadSummary, "id" | "branch" | "worktreePath">;
844}) {
845 const worktreePath = thread.worktreePath?.trim();
846 if (!worktreePath) {
847 return null;
848 }
849
850 const displayPath = formatWorktreePathForDisplay(worktreePath);
851 const tooltip = thread.branch
852 ? `Worktree: ${displayPath} (${thread.branch})`
853 : `Worktree: ${displayPath}`;
854
855 return (
856 <Tooltip>
857 <TooltipTrigger
858 render={
859 <span
860 role="img"
861 aria-label={tooltip}
862 data-testid={`thread-worktree-${thread.id}`}
863 className="inline-flex items-center justify-center"
864 />
865 }
866 >
867 <FolderGit2Icon className="size-3 text-muted-foreground/40" />
868 </TooltipTrigger>
869 <TooltipPopup side="top">{tooltip}</TooltipPopup>
870 </Tooltip>
871 );
872}
873
874export function ThreadStatusLabel({
875 status,
876 compact = false,
877}: {
878 status: ThreadStatusPill;
879 compact?: boolean;
880}) {
881 if (compact) {
882 return (
883 <Tooltip>
884 <TooltipTrigger
885 render={
886 <span
887 role="img"
888 aria-label={status.label}
889 className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}
890 />
891 }
892 >
893 <span
894 className={`size-[9px] rounded-full ${status.dotClass} ${
895 status.pulse ? "animate-status-pulse" : ""
896 }`}
897 />
898 </TooltipTrigger>
899 <TooltipPopup side="top">{status.label}</TooltipPopup>
900 </Tooltip>
901 );
902 }
903
904 return (
905 <Tooltip>
906 <TooltipTrigger
907 render={
908 <span
909 role="img"
910 aria-label={status.label}
911 className={`inline-flex items-center gap-1 text-3xs ${status.colorClass}`}
912 />
913 }
914 >
915 <span
916 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
917 status.pulse ? "animate-status-pulse" : ""
918 }`}
919 />
920 <span className="hidden md:inline">{status.label}</span>
921 </TooltipTrigger>
922 <TooltipPopup side="top">{status.label}</TooltipPopup>
923 </Tooltip>
924 );
925}
926
927/**
928 * Non-interactive leading status icons for a thread row in compact contexts
929 * like the command palette. Shows the change request state icon (if present) and the
930 * thread status dot, matching the sidebar's leading indicators.
931 */
932export function ThreadRowLeadingStatus({
933 thread,
934 snapshot,
935}: {
936 thread: SidebarThreadSummary;
937 snapshot?: ThreadChangeRequestSnapshot | undefined;
938}) {
939 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false);
940 // Observe the containing title even when this thread has no badge yet.
941 const statusRef = useCallback(
942 (node: HTMLSpanElement | null) => rowRef(node?.parentElement ?? null),
943 [rowRef],
944 );
945 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
946 const localLastVisitedAt = useUiStateStore(
947 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],
948 );
949 const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt);
950 const threadProject = useProject(
951 useMemo(
952 () => scopeProjectRef(thread.environmentId, thread.projectId),
953 [thread.environmentId, thread.projectId],
954 ),
955 );
956 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
957 const gitCwd = thread.worktreePath ?? threadProjectCwd;
958 const linkedPullRequest = useLinkedThreadPullRequest(
959 thread.environmentId,
960 thread.linkedPullRequest,
961 leaseLiveStatus,
962 );
963 const gitStatus = useEnvironmentQuery(
964 leaseLiveStatus &&
965 thread.linkedPullRequest == null &&
966 (thread.branch != null || thread.worktreePath !== null) &&
967 gitCwd !== null
968 ? vcsEnvironment.status({
969 environmentId: thread.environmentId,
970 input: { cwd: gitCwd, includeRemote: false },
971 })
972 : null,
973 );
974 const visibleGitStatus = useRetainedValue(
975 JSON.stringify([thread.environmentId, gitCwd]),
976 gitStatus.data,
977 );
978 const displayedPrInput = {
979 threadBranch: thread.branch,
980 gitStatus: visibleGitStatus,
981 snapshot,
982 retainTerminalOnBranchMismatch: thread.worktreePath === null,
983 linkedPullRequest: thread.linkedPullRequest,
984 linkedPullRequestStatus: linkedPullRequest,
985 };
986 const pr = resolveDisplayedThreadPr(displayedPrInput);
987 const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput));
988 const threadStatus = resolveThreadStatusPill({
989 thread: {
990 ...thread,
991 lastVisitedAt,
992 },
993 });
994
995 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
996 const pendingLink =
997 pr === null && supportsMultiplePullRequests
998 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
999 : null;
1000
1001 return (
1002 <span
1003 ref={statusRef}
1004 className={
1005 prStatus || threadStatus ? "inline-flex shrink-0 items-center gap-1.5" : "contents"
1006 }
1007 >
1008 {prStatus && pr ? (
1009 <Tooltip>
1010 <TooltipTrigger
1011 render={
1012 <span
1013 aria-label={prStatus.tooltip}
1014 className={`inline-flex items-center justify-center ${prStatus.colorClass}`}
1015 />
1016 }
1017 >
1018 <ChangeRequestStatusIcon state={pr.state} isDraft={pr.isDraft} className="size-3" />
1019 </TooltipTrigger>
1020 <TooltipPopup side="top">
1021 <PrStatusTooltipContent status={prStatus} />
1022 </TooltipPopup>
1023 </Tooltip>
1024 ) : null}
1025 {pendingLink ? (
1026 <PullRequestGlyph.pullRequest
1027 className="size-3 text-muted-foreground"
1028 aria-label={`PR #${pendingLink.number}, status pending`}
1029 />
1030 ) : null}
1031 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
1032 </span>
1033 );
1034}
1035
1036/**
1037 * Non-interactive trailing status icons for a thread row in compact contexts
1038 * like the command palette. Shows a terminal-running indicator and a remote
1039 * environment indicator, matching the sidebar's trailing indicators.
1040 */
1041export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) {
1042 const runningTerminalIds = useThreadRunningTerminalIds({
1043 environmentId: thread.environmentId,
1044 threadId: thread.id,
1045 });
1046 const environment = useEnvironment(thread.environmentId);
1047 const primaryEnvironmentId = usePrimaryEnvironmentId();
1048 // No primary (the hosted app) means every thread is remote, and the machine
1049 // glyph is what tells the environments apart.
1050 const isRemoteThread = thread.environmentId !== primaryEnvironmentId;
1051 const remoteEnvLabel = environment?.label ?? null;
1052 const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null;
1053 const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null);
1054 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
1055
1056 if (!terminalStatus && !isRemoteThread) {
1057 return null;
1058 }
1059
1060 return (
1061 <span className="inline-flex shrink-0 items-center gap-1.5">
1062 {terminalStatus ? (
1063 <Tooltip>
1064 <TooltipTrigger
1065 render={
1066 <span
1067 role="img"
1068 aria-label={terminalStatus.label}
1069 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
1070 />
1071 }
1072 >
1073 <TerminalIcon
1074 className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`}
1075 onAnimationStart={synchronizeTerminalPulse}
1076 />
1077 </TooltipTrigger>
1078 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
1079 </Tooltip>
1080 ) : null}
1081 {isRemoteThread ? (
1082 <Tooltip>
1083 <TooltipTrigger
1084 render={
1085 <span
1086 aria-label={threadEnvironmentLabel ?? "Remote"}
1087 className="inline-flex items-center justify-center"
1088 />
1089 }
1090 >
1091 <EnvironmentMachineIcon
1092 kind={remoteMachine}
1093 className="size-3 text-muted-foreground/60"
1094 />
1095 </TooltipTrigger>
1096 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
1097 </Tooltip>
1098 ) : null}
1099 </span>
1100 );
1101}
1102
1103/** Number presentation for compact thread search results. */
1104function ThreadPullRequestNumber({
1105 number,
1106 className,
1107 ...props
1108}: ComponentProps<"span"> & { number: number }) {
1109 return (
1110 <span {...props} className={cn("shrink-0 text-xs tabular-nums", className)}>
1111 #{number}
1112 </span>
1113 );
1114}
1115
1116function settledPrHoverColorClass(state: NonNullable<ThreadPr>["state"], isDraft = false): string {
1117 switch (state) {
1118 case "open":
1119 if (isDraft) {
1120 return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80";
1121 }
1122 return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90";
1123 case "merged":
1124 return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90";
1125 case "closed":
1126 return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90";
1127 }
1128}
1129
1130export function ThreadSearchPullRequestNumber({
1131 thread,
1132 query,
1133 enabled = true,
1134 settled = false,
1135}: {
1136 thread: SidebarThreadSummary;
1137 query: string;
1138 enabled?: boolean;
1139 settled?: boolean;
1140}) {
1141 const matchedLink = visibleThreadPullRequests(thread.pullRequests).find((pr) =>
1142 pullRequestMatchesSearchQuery({ pullRequests: [pr] }, query),
1143 );
1144 const fallbackCandidates =
1145 thread.pullRequests.length === 0
1146 ? [thread.linkedPullRequest, thread.branchPullRequest]
1147 : [thread.branchPullRequest];
1148 const legacyReference = matchedLink
1149 ? undefined
1150 : fallbackCandidates.find(
1151 (pr) => pr != null && pullRequestMatchesSearchQuery({ linkedPullRequest: pr }, query),
1152 );
1153 const legacyStatus = useLinkedThreadPullRequest(
1154 thread.environmentId,
1155 legacyReference,
1156 enabled,
1157 undefined,
1158 legacyReference,
1159 );
1160 const reference = matchedLink ?? legacyReference;
1161 const linked = matchedLink ? linkedPullRequestSnapshotStatus(matchedLink) : legacyStatus;
1162 const status = prStatusIndicator(linked?.pr ?? null, linked?.sourceControlProvider);
1163 return reference ? (
1164 <ThreadPullRequestNumber
1165 number={reference.number}
1166 className={
1167 settled
1168 ? cn(
1169 "text-secondary-label transition-colors",
1170 linked?.pr && settledPrHoverColorClass(linked.pr.state, linked.pr.isDraft),
1171 )
1172 : (status?.colorClass ?? "text-secondary-label")
1173 }
1174 aria-label={status?.tooltip ?? `Pull request #${reference.number}`}
1175 />
1176 ) : null;
1177}