import { scopeProjectRef, scopedThreadKey, scopeThreadRef,} from "@t3tools/client-runtime/environment";import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests";import { type EnvironmentId, resolveEnvironmentMachineKind, type ThreadLinkedPullRequest, type VcsStatusResult,} from "@t3tools/contracts";import { Atom } from "effect/unstable/reactivity";import { FolderGit2Icon, TerminalIcon } from "lucide-react";import { useCallback, useMemo } from "react";import { appAtomRegistry } from "../rpc/atomRegistry";import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";import { useProject } from "../state/entities";import { useEnvironmentQuery } from "../state/query";import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";import { useThreadRunningTerminalIds } from "../state/terminalSessions";import { vcsEnvironment } from "../state/vcs";import { useUiStateStore } from "../uiStateStore";import { resolveChangeRequestPresentation } from "../sourceControlPresentation";import { resolveThreadLastVisitedAt, resolveThreadStatusPill, type ThreadStatusPill, useRetainedValue, useSidebarRowSubscriptionLease,} from "./Sidebar.logic";import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";import type { SidebarThreadSummary } from "../types";import { formatWorktreePathForDisplay } from "../worktreeCleanup";import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export interface PrStatusIndicator { label: string; colorClass: string; tooltip: string; tooltipLead: string; tooltipTitle: string; url: string;} export interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; pulse: boolean;} export type ThreadPr = VcsStatusResult["pr"]; export interface LinkedThreadPullRequestStatus { readonly pr: NonNullable<ThreadPr>; readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;} export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, enabled = true,): LinkedThreadPullRequestStatus | null { const queried = useEnvironmentQuery( !enabled || environmentId === null || linkedPullRequest == null ? null : linkedPullRequestDetailAtom({ environmentId, input: { projectId: linkedPullRequest.projectId, repository: linkedPullRequest.repository, number: linkedPullRequest.number, }, }), ).data; const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried); return useMemo( () => detail === null ? null : { pr: pullRequestDetailToVcsStatus(detail), sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "", }, }, [detail], );} export function settledPrHoverColorClass( state: NonNullable<ThreadPr>["state"], isDraft = false,): string { switch (state) { case "open": if (isDraft) { return "group-hover/v2-row:text-zinc-500 dark:group-hover/v2-row:text-zinc-400/80"; } return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90"; case "merged": return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90"; case "closed": return "group-hover/v2-row:text-red-600 dark:group-hover/v2-row:text-red-300/90"; }} export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined,): PrStatusIndicator | null { function formatPrState(pr: NonNullable<ThreadPr>): string { if (pr.state === "open" && pr.isDraft === true) return "Draft"; return pr.state.charAt(0).toUpperCase() + pr.state.slice(1); } function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string { return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`; } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); const tooltipLead = formatPrStatusLead(pr, presentation.shortName); const tooltip = `${tooltipLead}: ${pr.title}`; if (pr.state === "open") { const isDraft = pr.isDraft === true; return { label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`, colorClass: isDraft ? "text-zinc-500 dark:text-zinc-400/80" : "text-emerald-600 dark:text-emerald-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, url: pr.url, }; } if (pr.state === "closed") { return { label: `${presentation.shortName} closed`, colorClass: "text-red-600 dark:text-red-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, url: pr.url, }; } if (pr.state === "merged") { return { label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, url: pr.url, }; } return null;} export function ChangeRequestStatusIcon({ state, isDraft = false, className,}: Pick<NonNullable<ThreadPr>, "state"> & { readonly isDraft?: boolean | undefined; readonly className?: string | undefined;}) { const presentation = resolvePullRequestState({ state, isDraft }); return <presentation.Icon className={className} />;} export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { return ( <span className="flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch overflow-hidden whitespace-nowrap"> <span className="shrink-0 pr-2 font-medium">{status.tooltipLead}</span> <span className="min-h-4 shrink-0 border-border/70 border-l" aria-hidden="true" /> <span className="min-w-0 truncate pl-2">{status.tooltipTitle}</span> </span> );} export function resolveThreadPr(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null;}): ThreadPr | null { const { threadBranch, gitStatus } = input; if (gitStatus === null) { return null; } if (threadBranch === null || gitStatus.refName !== threadBranch) { return null; } return gitStatus.pr ?? null;} /** * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement * partitions move them, so terminal PR metadata must live above the row. */export interface ThreadChangeRequestSnapshot { readonly branch: string; readonly pr: NonNullable<ThreadPr>; readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; readonly linkedPullRequest?: ThreadLinkedPullRequest;} export const threadChangeRequestSnapshotsAtom = Atom.make< ReadonlyMap<string, ThreadChangeRequestSnapshot>>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); function isTerminalChangeRequestState( state: NonNullable<ThreadPr>["state"],): state is "merged" | "closed" { return state === "merged" || state === "closed";} function sourceControlProvidersEqual( left: VcsStatusResult["sourceControlProvider"] | undefined, right: VcsStatusResult["sourceControlProvider"] | undefined,): boolean { if (left === right) return true; if (left == null || right == null) return left == null && right == null; return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl;} function linkedPullRequestsEqual( left: ThreadLinkedPullRequest | null | undefined, right: ThreadLinkedPullRequest | null | undefined,): boolean { if (left == null || right == null) return left == null && right == null; return ( left.projectId === right.projectId && left.repository === right.repository && left.number === right.number && left.url === right.url );} export function threadChangeRequestSnapshotsEqual( left: ThreadChangeRequestSnapshot, right: ThreadChangeRequestSnapshot,): boolean { return ( left.branch === right.branch && left.pr.number === right.pr.number && left.pr.title === right.pr.title && left.pr.url === right.pr.url && left.pr.baseRef === right.pr.baseRef && left.pr.headRef === right.pr.headRef && left.pr.state === right.pr.state && left.pr.isDraft === right.pr.isDraft && (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) );} export function setThreadChangeRequestSnapshot( threadKey: string, snapshot: ThreadChangeRequestSnapshot | null,): void { appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { const existing = current.get(threadKey); if (snapshot === null) { if (existing === undefined) return [false, current]; const next = new Map(current); next.delete(threadKey); return [true, next]; } if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { return [false, current]; } const next = new Map(current); next.set(threadKey, snapshot); return [true, next]; });} /** * Authoritative snapshot update from live VCS status. * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear * - snapshot: matching branch reports a PR — store/replace */export function nextThreadChangeRequestSnapshot(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;}): ThreadChangeRequestSnapshot | null | undefined { const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch, linkedPullRequest, linkedPullRequestStatus, } = input; if (linkedPullRequest != null) { if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) ? undefined : null; } return { branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, pr: linkedPullRequestStatus.pr, sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, linkedPullRequest, }; } if (gitStatus === null) { return snapshot?.linkedPullRequest === undefined ? undefined : null; } if (threadBranch === null) { return null; } if (gitStatus.refName !== threadBranch) { return retainTerminalOnBranchMismatch && snapshot != null && snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ? undefined : null; } if (gitStatus.pr == null) { if ( retainTerminalOnBranchMismatch && snapshot != null && snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return undefined; } return null; } return { branch: threadBranch, pr: gitStatus.pr, sourceControlProvider: gitStatus.sourceControlProvider, };} /** * Live PR when the checkout matches the thread branch; otherwise, for local * checkouts only, a cached merged/closed PR for the thread. Local thread * metadata follows the shared checkout, so the cached branch intentionally * survives that metadata changing to the newly checked-out branch. Open PRs * are retained only while live status is absent and the branch still matches. */export function resolveDisplayedThreadPr(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;}): ThreadPr | null { const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch, linkedPullRequest, linkedPullRequestStatus, } = input; if (linkedPullRequest != null) { return ( linkedPullRequestStatus?.pr ?? (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) ? (snapshot?.pr ?? null) : null) ); } if ( threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch && gitStatus.pr != null ) { return gitStatus.pr; } if ( gitStatus === null && threadBranch !== null && snapshot?.branch === threadBranch && snapshot.linkedPullRequest === undefined ) { return snapshot.pr; } if ( threadBranch !== null && retainTerminalOnBranchMismatch && snapshot != null && snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return snapshot.pr; } return null;} export function resolveDisplayedThreadPrProvider(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined;}): VcsStatusResult["sourceControlProvider"] | undefined { const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch, linkedPullRequest, linkedPullRequestStatus, } = input; if (linkedPullRequest != null) { return ( linkedPullRequestStatus?.sourceControlProvider ?? (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) ? snapshot?.sourceControlProvider : undefined) ); } if ( threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch && gitStatus.pr != null ) { return gitStatus.sourceControlProvider; } if ( gitStatus === null && threadBranch !== null && snapshot?.branch === threadBranch && snapshot.linkedPullRequest === undefined ) { return snapshot.sourceControlProvider; } if ( threadBranch !== null && retainTerminalOnBranchMismatch && snapshot != null && snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return snapshot.sourceControlProvider; } return undefined;} export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray<string>,): TerminalStatusIndicator | null { if (runningTerminalIds.length === 0) { return null; } return { label: "Terminal process running", colorClass: "text-teal-600 dark:text-teal-300/90", pulse: true, };} export function ThreadWorktreeIndicator({ thread,}: { thread: Pick<SidebarThreadSummary, "id" | "branch" | "worktreePath">;}) { const worktreePath = thread.worktreePath?.trim(); if (!worktreePath) { return null; } const displayPath = formatWorktreePathForDisplay(worktreePath); const tooltip = thread.branch ? `Worktree: ${displayPath} (${thread.branch})` : `Worktree: ${displayPath}`; return ( <Tooltip>Lines 1–500 of 730. Load more
import { scopeProjectRef, scopedThreadKey, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; import { type EnvironmentId, resolveEnvironmentMachineKind, type ThreadLinkedPullRequest, type VcsStatusResult, } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadLastVisitedAt, resolveThreadStatusPill, type ThreadStatusPill, useRetainedValue, useSidebarRowSubscriptionLease, } from "./Sidebar.logic"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import type { SidebarThreadSummary } from "../types"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export interface PrStatusIndicator { label: string; colorClass: string; tooltip: string; tooltipLead: string; tooltipTitle: string; url: string; } export interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; pulse: boolean; } export type ThreadPr = VcsStatusResult["pr"]; export interface LinkedThreadPullRequestStatus { readonly pr: NonNullable<ThreadPr>; readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>; } export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, enabled = true, ): LinkedThreadPullRequestStatus | null { const queried = useEnvironmentQuery( !enabled || environmentId === null || linkedPullRequest == null ? null : linkedPullRequestDetailAtom({ environmentId, input: { projectId: linkedPullRequest.projectId, repository: linkedPullRequest.repository, number: linkedPullRequest.number, }, }), ).data; const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried); return useMemo( () => detail === null ? null : { pr: pullRequestDetailToVcsStatus(detail), sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "", }, }, [detail], ); } export function settledPrHoverColorClass( state: NonNullable<ThreadPr>["state"], isDraft = false, ): string { switch (state) { case "open": if (isDraft) { return "group-hover/v2-row:text-zinc-500 dark:group-hover/v2-row:text-zinc-400/80"; } return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90"; case "merged": return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90"; case "closed": return "group-hover/v2-row:text-red-600 dark:group-hover/v2-row:text-red-300/90"; } } export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { function formatPrState(pr: NonNullable<ThreadPr>): string { if (pr.state === "open" && pr.isDraft === true) return "Draft"; return pr.state.charAt(0).toUpperCase() + pr.state.slice(1); } function formatPrStatusLead(pr: NonNullable<ThreadPr>, changeRequestShortName: string): string { return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`; } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); const tooltipLead = formatPrStatusLead(pr, presentation.shortName); const tooltip = `${tooltipLead}: ${pr.title}`; if (pr.state === "open") { const isDraft = pr.isDraft === true; return { label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`, colorClass: isDraft ? "text-zinc-500 dark:text-zinc-400/80" : "text-emerald-600 dark:text-emerald-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, url: pr.url, }; } if (pr.state === "closed") { return { label: `${presentation.shortName} closed`, colorClass: "text-red-600 dark:text-red-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, url: pr.url, }; } if (pr.state === "merged") { return { label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, url: pr.url, }; } return null; } export function ChangeRequestStatusIcon({ state, isDraft = false, className, }: Pick<NonNullable<ThreadPr>, "state"> & { readonly isDraft?: boolean | undefined; readonly className?: string | undefined; }) { const presentation = resolvePullRequestState({ state, isDraft }); return <presentation.Icon className={className} />; } export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { return ( <span className="flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch overflow-hidden whitespace-nowrap"> <span className="shrink-0 pr-2 font-medium">{status.tooltipLead}</span> <span className="min-h-4 shrink-0 border-border/70 border-l" aria-hidden="true" /> <span className="min-w-0 truncate pl-2">{status.tooltipTitle}</span> </span> ); } export function resolveThreadPr(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; }): ThreadPr | null { const { threadBranch, gitStatus } = input; if (gitStatus === null) { return null; } if (threadBranch === null || gitStatus.refName !== threadBranch) { return null; } return gitStatus.pr ?? null; } /** * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement * partitions move them, so terminal PR metadata must live above the row. */ export interface ThreadChangeRequestSnapshot { readonly branch: string; readonly pr: NonNullable<ThreadPr>; readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; readonly linkedPullRequest?: ThreadLinkedPullRequest; } export const threadChangeRequestSnapshotsAtom = Atom.make< ReadonlyMap<string, ThreadChangeRequestSnapshot> >(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); function isTerminalChangeRequestState( state: NonNullable<ThreadPr>["state"], ): state is "merged" | "closed" { return state === "merged" || state === "closed"; } function sourceControlProvidersEqual( left: VcsStatusResult["sourceControlProvider"] | undefined, right: VcsStatusResult["sourceControlProvider"] | undefined, ): boolean { if (left === right) return true; if (left == null || right == null) return left == null && right == null; return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; } function linkedPullRequestsEqual( left: ThreadLinkedPullRequest | null | undefined, right: ThreadLinkedPullRequest | null | undefined, ): boolean { if (left == null || right == null) return left == null && right == null; return ( left.projectId === right.projectId && left.repository === right.repository && left.number === right.number && left.url === right.url ); } export function threadChangeRequestSnapshotsEqual( left: ThreadChangeRequestSnapshot, right: ThreadChangeRequestSnapshot, ): boolean { return ( left.branch === right.branch && left.pr.number === right.pr.number && left.pr.title === right.pr.title && left.pr.url === right.pr.url && left.pr.baseRef === right.pr.baseRef && left.pr.headRef === right.pr.headRef && left.pr.state === right.pr.state && left.pr.isDraft === right.pr.isDraft && (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) ); } export function setThreadChangeRequestSnapshot( threadKey: string, snapshot: ThreadChangeRequestSnapshot | null, ): void { appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { const existing = current.get(threadKey); if (snapshot === null) { if (existing === undefined) return [false, current]; const next = new Map(current); next.delete(threadKey); return [true, next]; } if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { return [false, current]; } const next = new Map(current); next.set(threadKey, snapshot); return [true, next]; }); } /** * Authoritative snapshot update from live VCS status. * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear * - snapshot: matching branch reports a PR — store/replace */ export function nextThreadChangeRequestSnapshot(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): ThreadChangeRequestSnapshot | null | undefined { const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch, linkedPullRequest, linkedPullRequestStatus, } = input; if (linkedPullRequest != null) { if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) ? undefined : null; } return { branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, pr: linkedPullRequestStatus.pr, sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, linkedPullRequest, }; } if (gitStatus === null) { return snapshot?.linkedPullRequest === undefined ? undefined : null; } if (threadBranch === null) { return null; } if (gitStatus.refName !== threadBranch) { return retainTerminalOnBranchMismatch && snapshot != null && snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ? undefined : null; } if (gitStatus.pr == null) { if ( retainTerminalOnBranchMismatch && snapshot != null && snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return undefined; } return null; } return { branch: threadBranch, pr: gitStatus.pr, sourceControlProvider: gitStatus.sourceControlProvider, }; } /** * Live PR when the checkout matches the thread branch; otherwise, for local * checkouts only, a cached merged/closed PR for the thread. Local thread * metadata follows the shared checkout, so the cached branch intentionally * survives that metadata changing to the newly checked-out branch. Open PRs * are retained only while live status is absent and the branch still matches. */ export function resolveDisplayedThreadPr(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): ThreadPr | null { const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch, linkedPullRequest, linkedPullRequestStatus, } = input; if (linkedPullRequest != null) { return ( linkedPullRequestStatus?.pr ?? (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) ? (snapshot?.pr ?? null) : null) ); } if ( threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch && gitStatus.pr != null ) { return gitStatus.pr; } if ( gitStatus === null && threadBranch !== null && snapshot?.branch === threadBranch && snapshot.linkedPullRequest === undefined ) { return snapshot.pr; } if ( threadBranch !== null && retainTerminalOnBranchMismatch && snapshot != null && snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return snapshot.pr; } return null; } export function resolveDisplayedThreadPrProvider(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): VcsStatusResult["sourceControlProvider"] | undefined { const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch, linkedPullRequest, linkedPullRequestStatus, } = input; if (linkedPullRequest != null) { return ( linkedPullRequestStatus?.sourceControlProvider ?? (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) ? snapshot?.sourceControlProvider : undefined) ); } if ( threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch && gitStatus.pr != null ) { return gitStatus.sourceControlProvider; } if ( gitStatus === null && threadBranch !== null && snapshot?.branch === threadBranch && snapshot.linkedPullRequest === undefined ) { return snapshot.sourceControlProvider; } if ( threadBranch !== null && retainTerminalOnBranchMismatch && snapshot != null && snapshot.linkedPullRequest === undefined && isTerminalChangeRequestState(snapshot.pr.state) ) { return snapshot.sourceControlProvider; } return undefined; } export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray<string>, ): TerminalStatusIndicator | null { if (runningTerminalIds.length === 0) { return null; } return { label: "Terminal process running", colorClass: "text-teal-600 dark:text-teal-300/90", pulse: true, }; } export function ThreadWorktreeIndicator({ thread, }: { thread: Pick<SidebarThreadSummary, "id" | "branch" | "worktreePath">; }) { const worktreePath = thread.worktreePath?.trim(); if (!worktreePath) { return null; } const displayPath = formatWorktreePathForDisplay(worktreePath); const tooltip = thread.branch ? `Worktree: ${displayPath} (${thread.branch})` : `Worktree: ${displayPath}`; return ( <Tooltip>