1import { pipe } from "effect/Function";
2import {
3 scopeProjectRef,
4 scopedThreadKey,
5 scopeThreadRef,
6} from "@t3tools/client-runtime/environment";
7import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
8import { useStableCallback } from "~/hooks/useStableCallback";
9
10import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
12import {
13 resolveEnvironmentMachineKind,
14 type EnvironmentId,
15 type ScopedThreadRef,
16 type ThreadLinkedPullRequest,
17 type ThreadPullRequestLink,
18 type VcsStatusResult,
19} from "@t3tools/contracts";
20import { FolderGit2Icon, TerminalIcon } from "lucide-react";
21import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
22import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
23import { useProject } from "../state/entities";
24import {
25 pullRequestListLines,
26 resolveThreadCurrentPullRequestLink,
27 resolveThreadPullRequestBadge,
28 resolveThreadPullRequestChains,
29 type ThreadPullRequestBadge,
30 visibleThreadPullRequests,
31} from "@t3tools/client-runtime/thread-pull-requests";
32import { useRender } from "@base-ui/react/use-render";
33import { type ReactNode, type AnimationEvent, type MouseEvent, type ReactElement } from "react";
34import { cn } from "../lib/utils";
35import { revealThreadTerminal } from "../lib/revealThreadTerminal";
36
37import { useEnvironmentQuery } from "../state/query";
38import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
39import { useThreadRunningTerminalIds } from "../state/terminalSessions";
40import { vcsEnvironment } from "../state/vcs";
41import { useUiStateStore } from "../uiStateStore";
42import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
43import {
44 pullRequestMatchesSearchQuery,
45 resolveThreadLastVisitedAt,
46 resolveThreadStatusPill,
47 type ThreadStatusPill,
48 useRetainedValue,
49 useSidebarRowSubscriptionLease,
50} from "./Sidebar.logic";
51
52import type { SidebarThreadSummary } from "../types";
53import { formatWorktreePathForDisplay } from "../worktreeCleanup";
54import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
55import {
56 PULL_REQUEST_STATE_PRESENTATION,
57 PullRequestGlyph,
58 type PullRequestGlyphIcon,
59} from "./pullRequest/pullRequestIcons";
60import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
61
62export interface PrStatusIndicator {
63 label: string;
64 colorClass: string;
65 Icon: PullRequestGlyphIcon;
66 tooltip: string;
67 tooltipLead: string;
68 tooltipTitle: string;
69 url: string;
70}
71
72export interface TerminalStatusIndicator {
73 label: "Terminal process running";
74 colorClass: string;
75 pulse: boolean;
76}
77
78export type ThreadPr = VcsStatusResult["pr"];
79
80export interface LinkedThreadPullRequestStatus {
81 readonly pr: NonNullable<ThreadPr>;
82 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
83}
84
85export function useLinkedThreadPullRequest(
86 environmentId: EnvironmentId | null,
87 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
88 enabled = true,
89 pullRequests?: ReadonlyArray<ThreadPullRequestLink>,
90 branchPullRequest?: ThreadLinkedPullRequest | null,
91): LinkedThreadPullRequestStatus | null {
92 const supportsLinks = useSupportsMultiplePullRequests(environmentId);
93 const current = supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null;
94 const currentStatus = current === null ? null : linkedPullRequestSnapshotStatus(current);
95 const fallback =
96 current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null;
97 // Preserve reference identity so sidebar renders do not touch the summary cache.
98 const reference = fallback == null ? null : linkedPullRequestReference(fallback);
99 // Mounted for its answers, which land in the shared summary read below.
100 useEnvironmentQuery(
101 !enabled || environmentId === null || reference === null
102 ? null
103 : linkedPullRequestDetailAtom({ environmentId, input: reference }),
104 );
105 const detail = useSharedPullRequestSummary(environmentId, reference);
106
107 if (current !== null) return currentStatus;
108 return detail === null
109 ? null
110 : {
111 pr: pullRequestDetailToVcsStatus(detail),
112 sourceControlProvider: {
113 kind: detail.provider,
114 name: sourceControlClients.get(detail.provider).label,
115 baseUrl: "",
116 },
117 };
118}
119
120/** The pull request a thread shows: its current link, else the one found for its branch. */
121export function useThreadDisplayedPullRequest(
122 environmentId: EnvironmentId,
123 thread: Pick<
124 EnvironmentThreadShell,
125 "pullRequests" | "linkedPullRequest" | "branchPullRequest"
126 > | null,
127 branchPr: ThreadPr = null,
128 branchSourceControlProvider?: VcsStatusResult["sourceControlProvider"],
129) {
130 const supportsLinks = useSupportsMultiplePullRequests(environmentId);
131 const linkedStatus = useLinkedThreadPullRequest(
132 environmentId,
133 thread?.linkedPullRequest,
134 true,
135 thread?.pullRequests,
136 thread?.branchPullRequest,
137 );
138 const currentLink = supportsLinks
139 ? resolveThreadCurrentPullRequestLink(thread?.pullRequests ?? [])
140 : null;
141 const pr = linkedStatus?.pr ?? (currentLink === null ? branchPr : null);
142 return {
143 currentLink,
144 badge: supportsLinks ? resolveThreadPullRequestBadge(thread?.pullRequests) : null,
145 pr,
146 status: prStatusIndicator(
147 pr,
148 linkedStatus?.sourceControlProvider ?? branchSourceControlProvider,
149 ),
150 number: currentLink?.number ?? pr?.number,
151 url: currentLink?.url ?? pr?.url,
152 };
153}
154
155function linkedPullRequestReference(fallback: ThreadLinkedPullRequest) {
156 const host = sourceControlClients.parseChangeRequestUrl(fallback.url)?.link.host;
157 return { ...fallback, ...(host === undefined ? {} : { host }) };
158}
159
160function changeRequestTerminologyForUrl(url: string | undefined) {
161 const host = url === undefined ? null : sourceControlClients.findByChangeRequestUrl(url);
162 return (host ?? sourceControlClients.get(undefined)).changeRequest;
163}
164
165export function linkedPullRequestSnapshotStatus(
166 link: ThreadPullRequestLink,
167): LinkedThreadPullRequestStatus | null {
168 const snapshot = link.snapshot;
169 if (snapshot === null) return null;
170 const kind = (
171 sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined)
172 ).kind;
173 return {
174 pr: {
175 number: link.number,
176 url: link.url,
177 title: snapshot.title,
178 state: snapshot.state,
179 isDraft: snapshot.isDraft,
180 headRef: snapshot.headBranch,
181 baseRef: snapshot.baseBranch,
182 ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }),
183 },
184 sourceControlProvider: { kind, name: kind, baseUrl: "" },
185 };
186}
187
188export {
189 resolveThreadPullRequestBadge,
190 type ThreadPullRequestBadge,
191} from "@t3tools/client-runtime/thread-pull-requests";
192
193export interface ThreadPullRequestBadgePresentation {
194 readonly Icon: PullRequestGlyphIcon;
195 readonly toneClassName: string;
196 readonly label: string;
197 readonly text: string | number;
198}
199
200function resolveThreadPullRequestBadgePresentation({
201 badge,
202 number,
203 url,
204 status,
205}: {
206 readonly badge: ThreadPullRequestBadge | null;
207 readonly number?: number | undefined;
208 readonly url?: string | undefined;
209 readonly status: PrStatusIndicator | null;
210}): ThreadPullRequestBadgePresentation | null {
211 const terminology = changeRequestTerminologyForUrl(url);
212 if (badge?.kind === "stack") {
213 const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
214 return {
215 Icon: PullRequestGlyph.stack,
216 toneClassName: aggregate.toneClassName,
217 label: `Stack of ${badge.layers} ${terminology.plural}, ${aggregate.label.toLowerCase()}`,
218 text: badge.layers,
219 };
220 }
221 if (number === undefined || url === undefined) return null;
222
223 const tooltip = status?.tooltip ?? `${terminology.shortLabel} #${number}, status pending`;
224 if (badge?.kind === "pull-request" && badge.others > 0) {
225 const aggregate = PULL_REQUEST_STATE_PRESENTATION[badge.state];
226 return {
227 Icon: aggregate.Icon,
228 toneClassName: aggregate.toneClassName,
229 label: `${tooltip}, and ${badge.others} more linked; overall ${aggregate.label.toLowerCase()}`,
230 text: `+${badge.others + 1}`,
231 };
232 }
233 return {
234 Icon: status?.Icon ?? PullRequestGlyph.pullRequest,
235 toneClassName: status?.colorClass ?? "text-muted-foreground",
236 label: tooltip,
237 text: number,
238 };
239}
240
241export function ThreadPullRequestBadgeControl({
242 render,
243 badge,
244 pullRequests,
245 number,
246 url,
247 status,
248 onOpenList,
249 onOpenPullRequest,
250}: {
251 render: ReactElement<{ render?: useRender.RenderProp }>;
252 badge: ThreadPullRequestBadge | null;
253 pullRequests: ReadonlyArray<ThreadPullRequestLink>;
254 number?: number | undefined;
255 url?: string | undefined;
256 status: PrStatusIndicator | null;
257 onOpenList: () => void;
258 onOpenPullRequest: (event: MouseEvent<HTMLElement>, url?: string) => void;
259}) {
260 const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status });
261 if (presentation === null) return null;
262 return (
263 <PullRequestBadge
264 render={render}
265 presentation={presentation}
266 opensList={badge !== null && (badge.kind === "stack" || badge.others > 0)}
267 url={url}
268 number={number}
269 status={status}
270 pullRequests={pullRequests}
271 onOpenList={onOpenList}
272 onOpenPullRequest={onOpenPullRequest}
273 />
274 );
275}
276
277function PullRequestBadge({
278 render,
279 presentation,
280 opensList,
281 url,
282 number,
283 status,
284 pullRequests,
285 onOpenList,
286 onOpenPullRequest,
287}: {
288 render: ReactElement<{ render?: useRender.RenderProp }>;
289 presentation: NonNullable<ReturnType<typeof resolveThreadPullRequestBadgePresentation>>;
290 opensList: boolean;
291 url: string | undefined;
292 number: number | undefined;
293 status: PrStatusIndicator | null;
294 pullRequests: ReadonlyArray<ThreadPullRequestLink>;
295 onOpenList: () => void;
296 onOpenPullRequest: (event: MouseEvent<HTMLElement>, url?: string) => void;
297}) {
298 const onClick = opensList
299 ? (event: MouseEvent<HTMLElement>) => {
300 event.preventDefault();
301 event.stopPropagation();
302 onOpenList();
303 }
304 : onOpenPullRequest;
305 const element = opensList ? (
306 <button type="button" />
307 ) : (
308 <a href={url} target="_blank" rel="noopener noreferrer" />
309 );
310 const control = useRender({
311 render,
312 props: {
313 render: element,
314 "aria-label": presentation.label,
315 onPointerDown: (event: MouseEvent<HTMLElement>) => event.stopPropagation(),
316 onClick,
317 },
318 });
319 return (
320 <Tooltip key={opensList ? "list" : "link"}>
321 <TooltipTrigger render={control}>
322 <span
323 className={cn("contents font-normal text-xs tabular-nums", presentation.toneClassName)}
324 >
325 <presentation.Icon aria-hidden className="size-3 shrink-0" />
326 <span className="text-secondary-label">{presentation.text}</span>
327 </span>
328 </TooltipTrigger>
329 <TooltipPopup
330 side="top"
331 sideOffset={0}
332 variant="glass"
333 className="pointer-events-auto w-80 max-w-[calc(100vw-2rem)] text-left whitespace-normal"
334 >
335 <div className="-mx-1 max-h-[calc(var(--available-height)-0.5rem-2px)] overflow-y-auto overscroll-contain">
336 {visibleThreadPullRequests(pullRequests).length > 0 ? (
337 <ThreadPullRequestsMiniList
338 pullRequests={pullRequests}
339 onOpenPullRequest={onOpenPullRequest}
340 />
341 ) : number !== undefined && url !== undefined ? (
342 <ul className="flex flex-col gap-1">
343 <ThreadPullRequestMiniListItem
344 number={number}
345 url={url}
346 title={status?.tooltipTitle ?? presentation.label}
347 presentation={presentation}
348 onOpenPullRequest={onOpenPullRequest}
349 />
350 </ul>
351 ) : null}
352 </div>
353 </TooltipPopup>
354 </Tooltip>
355 );
356}
357
358export function ThreadPullRequestsMiniList({
359 pullRequests,
360 onOpenPullRequest,
361}: {
362 pullRequests: ReadonlyArray<ThreadPullRequestLink>;
363 onOpenPullRequest?: (event: MouseEvent<HTMLAnchorElement>, url: string) => void;
364}) {
365 const lines = pipe(
366 visibleThreadPullRequests(pullRequests),
367 resolveThreadPullRequestChains,
368 pullRequestListLines,
369 );
370 if (lines.length === 0) return null;
371 return (
372 <ul className="flex flex-col gap-1">
373 {lines.map((line) => {
374 const snapshot = line.link.snapshot;
375 const presentation =
376 snapshot === null
377 ? null
378 : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft });
379 return (
380 <ThreadPullRequestMiniListItem
381 key={`${line.link.host}/${line.link.repository}#${line.link.number}`}
382 number={line.link.number}
383 url={line.link.url}
384 title={snapshot?.title ?? line.link.repository}
385 presentation={presentation}
386 depth={line.depth}
387 onOpenPullRequest={onOpenPullRequest}
388 >
389 {line.stack ? (
390 <span className="ml-auto shrink-0 pl-1 text-3xs">
391 {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size}
392 </span>
393 ) : null}
394 </ThreadPullRequestMiniListItem>
395 );
396 })}
397 </ul>
398 );
399}
400
401function ThreadPullRequestMiniListItem({
402 number,
403 url,
404 title,
405 presentation,
406 depth = 0,
407 onOpenPullRequest,
408 children,
409}: {
410 number: number;
411 url: string;
412 title: string;
413 presentation: Pick<ThreadPullRequestBadgePresentation, "Icon" | "toneClassName"> | null;
414 depth?: number;
415 onOpenPullRequest?: ((event: MouseEvent<HTMLAnchorElement>, url: string) => void) | undefined;
416 children?: ReactNode;
417}) {
418 const Icon = presentation?.Icon ?? PullRequestGlyph.pullRequest;
419 const content = (
420 <>
421 <Icon
422 aria-hidden
423 className={cn("size-3 shrink-0", presentation?.toneClassName ?? "stroke-muted-foreground")}
424 />
425 <span className="shrink-0 font-mono tabular-nums">#{number}</span>
426 <span className="min-w-0 truncate text-foreground/75">{title}</span>
427 {children}
428 </>
429 );
430 return (
431 <li style={{ paddingLeft: `${Math.min(depth, 3) * 0.75}rem` }}>
432 {onOpenPullRequest ? (
433 <a
434 href={url}
435 target="_blank"
436 rel="noopener noreferrer"
437 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-offset-2 focus-visible:outline-ring"
438 onPointerDown={(event) => event.stopPropagation()}
439 onClick={(event) => onOpenPullRequest(event, url)}
440 >
441 {content}
442 </a>
443 ) : (
444 <div className="flex min-w-0 items-center gap-2">{content}</div>
445 )}
446 </li>
447 );
448}
449
450export function prStatusIndicator(
451 pr: ThreadPr,
452 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
453): PrStatusIndicator | null {
454 if (!pr) return null;
455 const { shortLabel } = sourceControlClients.get(provider?.kind).changeRequest;
456 const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft === true });
457
458 const tooltipLead = `${shortLabel} #${pr.number} - ${state.label}`;
459 return {
460 label: `${shortLabel} ${state.label.toLowerCase()}`,
461 colorClass: state.toneClassName,
462 Icon: state.Icon,
463 tooltip: `${tooltipLead}: ${pr.title}`,
464 tooltipLead,
465 tooltipTitle: pr.title,
466 url: pr.url,
467 };
468}
469
470export function ChangeRequestStatusIcon({
471 state,
472 isDraft = false,
473 className,
474}: Pick<NonNullable<ThreadPr>, "state"> & {
475 readonly isDraft?: boolean | undefined;
476 readonly className?: string | undefined;
477}) {
478 const presentation = resolvePullRequestState({ state, isDraft });
479 return <presentation.Icon className={className} />;
480}
481
482function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) {
483 return (
484 <span className="flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch overflow-hidden whitespace-nowrap">
485 <span className="shrink-0 pr-2 font-medium">{status.tooltipLead}</span>
486 <span className="min-h-4 shrink-0 border-border/70 border-l" aria-hidden="true" />
487 <span className="min-w-0 truncate pl-2">{status.tooltipTitle}</span>
488 </span>
489 );
490}
491
492export function resolveDisplayedThreadPr(input: {
493 threadBranch: string | null;
494 gitStatus: VcsStatusResult | null;
495 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
496 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
497}): ThreadPr | null {
498 const { threadBranch, gitStatus, linkedPullRequest, linkedPullRequestStatus } = input;
499 if (linkedPullRequest != null) {
500 return linkedPullRequestStatus?.pr ?? null;
501 }
502 if (
503 threadBranch !== null &&
504 gitStatus !== null &&
505 gitStatus.refName === threadBranch &&
506 gitStatus.pr?.state === "open"
507 ) {
508 return gitStatus.pr;
509 }
510
511 return null;
512}
513
514export function resolveDisplayedThreadPrProvider(input: {
515 threadBranch: string | null;
516 gitStatus: VcsStatusResult | null;
517 linkedPullRequest?: ThreadLinkedPullRequest | null | undefined;
518 linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;
519}): VcsStatusResult["sourceControlProvider"] | undefined {
520 const { gitStatus, linkedPullRequest, linkedPullRequestStatus } = input;
521 if (linkedPullRequest != null) {
522 return linkedPullRequestStatus?.sourceControlProvider;
523 }
524 if (resolveDisplayedThreadPr(input) !== null) {
525 return gitStatus?.sourceControlProvider;
526 }
527
528 return undefined;
529}
530
531export function terminalStatusFromRunningIds(
532 runningTerminalIds: ReadonlyArray<string>,
533): TerminalStatusIndicator | null {
534 if (runningTerminalIds.length === 0) {
535 return null;
536 }
537 return {
538 label: "Terminal process running",
539 colorClass: "text-teal-600 dark:text-teal-300/90",
540 pulse: true,
541 };
542}
543
544function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) {
545 if (event.animationName !== "status-pulse") return;
546
547 for (const animation of event.currentTarget.getAnimations()) {
548 if ("animationName" in animation && animation.animationName === "status-pulse") {
549 animation.startTime = 0;
550 }
551 }
552}
553
554export function ThreadTerminalStatusButton({
555 threadRef,
556 terminalIds,
557 onThreadActivate,
558}: {
559 threadRef: ScopedThreadRef;
560 terminalIds: readonly string[];
561 onThreadActivate: (threadRef: ScopedThreadRef) => void;
562}) {
563 const terminalId = terminalIds[0];
564 if (terminalId === undefined) return null;
565 const status = terminalStatusFromRunningIds(terminalIds);
566 const label =
567 terminalIds.length === 1
568 ? "1 terminal process running. Open terminal"
569 : `${terminalIds.length} terminal processes running. Open terminal`;
570 return (
571 <Tooltip>
572 <TooltipTrigger
573 render={
574 <button
575 type="button"
576 aria-label={label}
577 data-testid={`sidebar-terminal-status-${threadRef.threadId}`}
578 className={cn(
579 "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",
580 status?.colorClass,
581 )}
582 onPointerDown={(event) => event.stopPropagation()}
583 onKeyDown={(event) => event.stopPropagation()}
584 onClick={(event) => {
585 event.preventDefault();
586 event.stopPropagation();
587 revealThreadTerminal(threadRef, terminalId);
588 onThreadActivate(threadRef);
589 }}
590 />
591 }
592 >
593 <TerminalIcon
594 className="size-3.5 motion-safe:animate-status-pulse"
595 onAnimationStart={synchronizeTerminalPulse}
596 />
597 </TooltipTrigger>
598 <TooltipPopup side="top">{label}</TooltipPopup>
599 </Tooltip>
600 );
601}
602
603export function ThreadWorktreeIndicator({
604 thread,
605}: {
606 thread: Pick<SidebarThreadSummary, "id" | "branch" | "worktreePath">;
607}) {
608 const worktreePath = thread.worktreePath?.trim();
609 if (!worktreePath) {
610 return null;
611 }
612
613 const displayPath = formatWorktreePathForDisplay(worktreePath);
614 const tooltip = thread.branch
615 ? `Worktree: ${displayPath} (${thread.branch})`
616 : `Worktree: ${displayPath}`;
617
618 return (
619 <Tooltip>
620 <TooltipTrigger
621 render={
622 <span
623 role="img"
624 aria-label={tooltip}
625 data-testid={`thread-worktree-${thread.id}`}
626 className="inline-flex items-center justify-center"
627 />
628 }
629 >
630 <FolderGit2Icon className="size-3.5 shrink-0 text-icon-muted" />
631 </TooltipTrigger>
632 <TooltipPopup side="top">{tooltip}</TooltipPopup>
633 </Tooltip>
634 );
635}
636
637function ThreadStatusLabel({
638 status,
639 compact = false,
640}: {
641 status: ThreadStatusPill;
642 compact?: boolean;
643}) {
644 if (compact) {
645 return (
646 <Tooltip>
647 <TooltipTrigger
648 render={
649 <span
650 role="img"
651 aria-label={status.label}
652 className={`inline-flex size-3.5 shrink-0 items-center justify-center ${status.colorClass}`}
653 />
654 }
655 >
656 <span
657 className={`size-[9px] rounded-full ${status.dotClass} ${
658 status.pulse ? "motion-safe:animate-status-pulse" : ""
659 }`}
660 />
661 </TooltipTrigger>
662 <TooltipPopup side="top">{status.label}</TooltipPopup>
663 </Tooltip>
664 );
665 }
666
667 return (
668 <Tooltip>
669 <TooltipTrigger
670 render={
671 <span
672 role="img"
673 aria-label={status.label}
674 className={`inline-flex items-center gap-1 text-3xs ${status.colorClass}`}
675 />
676 }
677 >
678 <span
679 className={`h-1.5 w-1.5 rounded-full ${status.dotClass} ${
680 status.pulse ? "motion-safe:animate-status-pulse" : ""
681 }`}
682 />
683 <span className="hidden md:inline">{status.label}</span>
684 </TooltipTrigger>
685 <TooltipPopup side="top">{status.label}</TooltipPopup>
686 </Tooltip>
687 );
688}
689
690export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) {
691 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false);
692 // A new ref callback would detach and reattach the row, and each reattach sets lease state.
693 const statusRef = useStableCallback((node: HTMLSpanElement | null) =>
694 rowRef(node?.parentElement ?? null),
695 );
696 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
697 const localLastVisitedAt = useUiStateStore(
698 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],
699 );
700 const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt);
701 const threadProject = useProject(scopeProjectRef(thread.environmentId, thread.projectId));
702 const threadProjectCwd = threadProject?.workspaceRoot ?? null;
703 const gitCwd = thread.worktreePath ?? threadProjectCwd;
704 const linkedPullRequest = useLinkedThreadPullRequest(
705 thread.environmentId,
706 thread.linkedPullRequest,
707 leaseLiveStatus,
708 thread.pullRequests,
709 thread.branchPullRequest,
710 );
711 const gitStatus = useEnvironmentQuery(
712 leaseLiveStatus &&
713 thread.linkedPullRequest == null &&
714 (thread.branch != null || thread.worktreePath !== null) &&
715 gitCwd !== null
716 ? vcsEnvironment.status({
717 environmentId: thread.environmentId,
718 input: { cwd: gitCwd, includeRemote: false },
719 })
720 : null,
721 );
722 const visibleGitStatus = useRetainedValue(
723 JSON.stringify([thread.environmentId, gitCwd]),
724 gitStatus.data,
725 );
726 const displayedPrInput = {
727 threadBranch: thread.branch,
728 gitStatus: visibleGitStatus,
729 linkedPullRequest: thread.linkedPullRequest,
730 linkedPullRequestStatus: linkedPullRequest,
731 };
732 const pr = linkedPullRequest?.pr ?? resolveDisplayedThreadPr(displayedPrInput);
733 const prStatus = prStatusIndicator(
734 pr,
735 linkedPullRequest?.sourceControlProvider ?? resolveDisplayedThreadPrProvider(displayedPrInput),
736 );
737 const threadStatus = resolveThreadStatusPill({
738 thread: {
739 ...thread,
740 lastVisitedAt,
741 },
742 });
743
744 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
745 const pendingLink =
746 pr === null && supportsMultiplePullRequests
747 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
748 : null;
749
750 return (
751 <span
752 ref={statusRef}
753 className={
754 prStatus || threadStatus ? "inline-flex shrink-0 items-center gap-1.5" : "contents"
755 }
756 >
757 {prStatus && pr ? (
758 <Tooltip>
759 <TooltipTrigger
760 render={
761 <span
762 aria-label={prStatus.tooltip}
763 className={`inline-flex items-center justify-center ${prStatus.colorClass}`}
764 />
765 }
766 >
767 <ChangeRequestStatusIcon state={pr.state} isDraft={pr.isDraft} className="size-3" />
768 </TooltipTrigger>
769 <TooltipPopup side="top">
770 <PrStatusTooltipContent status={prStatus} />
771 </TooltipPopup>
772 </Tooltip>
773 ) : null}
774 {pendingLink ? (
775 <PullRequestGlyph.pullRequest
776 className="size-3 text-muted-foreground"
777 aria-label={`${changeRequestTerminologyForUrl(pendingLink.url).shortLabel} #${pendingLink.number}, status pending`}
778 />
779 ) : null}
780 {threadStatus ? <ThreadStatusLabel status={threadStatus} /> : null}
781 </span>
782 );
783}
784
785export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) {
786 const runningTerminalIds = useThreadRunningTerminalIds({
787 environmentId: thread.environmentId,
788 threadId: thread.id,
789 });
790 const environment = useEnvironment(thread.environmentId);
791 const primaryEnvironmentId = usePrimaryEnvironmentId();
792 const isRemoteThread = thread.environmentId !== primaryEnvironmentId;
793 const remoteEnvLabel = environment?.label ?? null;
794 const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null;
795 const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null);
796 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
797
798 if (!terminalStatus && !isRemoteThread) {
799 return null;
800 }
801
802 return (
803 <span className="inline-flex shrink-0 items-center gap-1.5">
804 {terminalStatus ? (
805 <Tooltip>
806 <TooltipTrigger
807 render={
808 <span
809 role="img"
810 aria-label={terminalStatus.label}
811 className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
812 />
813 }
814 >
815 <TerminalIcon
816 className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`}
817 onAnimationStart={synchronizeTerminalPulse}
818 />
819 </TooltipTrigger>
820 <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
821 </Tooltip>
822 ) : null}
823 {isRemoteThread ? (
824 <Tooltip>
825 <TooltipTrigger
826 render={
827 <span
828 aria-label={threadEnvironmentLabel ?? "Remote"}
829 className="inline-flex items-center justify-center"
830 />
831 }
832 >
833 <EnvironmentMachineIcon
834 kind={remoteMachine}
835 className="size-3 text-muted-foreground/60"
836 />
837 </TooltipTrigger>
838 <TooltipPopup side="top">{threadEnvironmentLabel}</TooltipPopup>
839 </Tooltip>
840 ) : null}
841 </span>
842 );
843}
844
845function settledPrHoverColorClass(state: ThreadPullRequestBadge["state"]): string {
846 switch (state) {
847 case "draft":
848 return "group-hover/sidebar-row:text-zinc-500 dark:group-hover/sidebar-row:text-zinc-400/80";
849 case "open":
850 return "group-hover/sidebar-row:text-emerald-600 dark:group-hover/sidebar-row:text-emerald-300/90";
851 case "merged":
852 return "group-hover/sidebar-row:text-violet-600 dark:group-hover/sidebar-row:text-violet-300/90";
853 case "closed":
854 return "group-hover/sidebar-row:text-red-600 dark:group-hover/sidebar-row:text-red-300/90";
855 }
856}
857
858// Search rows are already buttons, so this renders the sidebar badge's look without its controls.
859export function ThreadSearchPullRequestNumber({
860 thread,
861 query,
862 enabled = true,
863 settled = false,
864}: {
865 thread: SidebarThreadSummary;
866 query: string;
867 enabled?: boolean;
868 settled?: boolean;
869}) {
870 const matched = pullRequestMatchesSearchQuery(thread, query);
871 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId);
872 const linked = useLinkedThreadPullRequest(
873 thread.environmentId,
874 thread.linkedPullRequest,
875 enabled && matched,
876 thread.pullRequests,
877 thread.branchPullRequest,
878 );
879 if (!matched) return null;
880 const badge = supportsMultiplePullRequests
881 ? resolveThreadPullRequestBadge(thread.pullRequests)
882 : null;
883 const current = supportsMultiplePullRequests
884 ? resolveThreadCurrentPullRequestLink(thread.pullRequests)
885 : null;
886 const pr = linked?.pr ?? null;
887 // Stored references already carry a number, so the badge shows before PR details load.
888 const stored =
889 current ??
890 [thread.linkedPullRequest, thread.branchPullRequest].find(
891 (reference) =>
892 reference != null && pullRequestMatchesSearchQuery({ linkedPullRequest: reference }, query),
893 );
894 const presentation = resolveThreadPullRequestBadgePresentation({
895 badge,
896 number: pr?.number ?? stored?.number,
897 url: pr?.url ?? stored?.url,
898 status: prStatusIndicator(pr, linked?.sourceControlProvider),
899 });
900 if (presentation === null) return null;
901 const aggregate = badge !== null && (badge.kind === "stack" || badge.others > 0);
902 const state = aggregate
903 ? badge.state
904 : pr === null
905 ? null
906 : pr.state === "open" && pr.isDraft
907 ? "draft"
908 : pr.state;
909 return (
910 <span
911 aria-label={presentation.label}
912 className={cn(
913 "inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums",
914 settled
915 ? cn("text-secondary-label transition-colors", state && settledPrHoverColorClass(state))
916 : presentation.toneClassName,
917 )}
918 >
919 {/* text-current opts out of the command palette's muted icon color. */}
920 <presentation.Icon aria-hidden className="size-3 shrink-0 text-current" />
921 <span>{presentation.text}</span>
922 </span>
923 );
924}