diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bdde62a3e0d..eec1a1ad210 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -236,7 +236,10 @@ import { shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; +import { + resolveDisplayedThreadPr, + threadChangeRequestSnapshotsAtom, +} from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, @@ -1471,6 +1474,7 @@ function ChatViewContent(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -3871,9 +3875,11 @@ function ChatViewContent(props: ChatViewProps) { // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const activeThreadPr = resolveThreadPr({ + const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; @@ -3905,6 +3911,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadPr?.state, activeThreadShell, autoSettleAfterDays, + changeRequestSnapshotByKey, nowMinute, supportsSettlement, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3d314fd1ac2..cb45517bfb2 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -121,8 +121,13 @@ import { } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { + nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveThreadPr, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, + setThreadChangeRequestSnapshot, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, settledPrHoverColorClass, } from "./ThreadStatusIndicators"; import { @@ -391,11 +396,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; + changeRequestSnapshot: ThreadChangeRequestSnapshot | null; + onChangeRequestSnapshot: ( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, + ) => void; }) { const { isRenaming, - onChangeRequestState, + changeRequestSnapshot, + onChangeRequestSnapshot, onCancelRename, onCommitRename, onContextMenu, @@ -502,18 +512,40 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const pr = resolveThreadPr({ + const retainTerminalOnBranchMismatch = thread.worktreePath === null; + const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prProvider = resolveDisplayedThreadPrProvider({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state up: the parent partitions rows with effectiveSettled, - // and a merged/closed PR auto-settles a thread — data only rows have. - const prState = pr?.state ?? null; + // Authoritative VCS updates only: a checkout on another branch must not + // clear a previously verified terminal PR for this thread. useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + const nextSnapshot = nextThreadChangeRequestSnapshot({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + if (nextSnapshot === undefined) return; + onChangeRequestSnapshot(threadKey, nextSnapshot); + }, [ + gitStatus.data, + changeRequestSnapshot, + onChangeRequestSnapshot, + retainTerminalOnBranchMismatch, + thread.branch, + threadKey, + ]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1157,26 +1189,10 @@ export default function SidebarV2() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); + // Full PR snapshots stream in per-row (rows own the VCS subscriptions). A + // merged/closed PR auto-settles its thread; the snapshot retains badge + // metadata when the shared checkout later switches away from the thread branch. + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. @@ -1394,7 +1410,14 @@ export default function SidebarV2() { const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + const snapshot = changeRequestSnapshotByKey.get(threadKey); + // Same resolved terminal PR the row displays: local thread branch + // metadata follows the shared checkout, while worktree snapshots remain + // scoped to the worktree's branch. + const changeRequestState = + snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) + ? snapshot.pr.state + : null; // Snooze outranks settled classification: an explicitly snoozed thread // belongs to the shelf even if it would also auto-settle (the shelf's // wake time is a stronger statement about when it matters again). @@ -1422,7 +1445,7 @@ export default function SidebarV2() { }; }, [ autoSettleAfterDays, - changeRequestStateByKey, + changeRequestSnapshotByKey, nowMinute, scopedProjectKeys, serverConfigs, @@ -2460,7 +2483,8 @@ export default function SidebarV2() { onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} - onChangeRequestState={handleChangeRequestState} + changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} + onChangeRequestSnapshot={setThreadChangeRequestSnapshot} /> ); }; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3eb8e4f710f..bd60dded306 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,10 +1,19 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { + nextThreadChangeRequestSnapshot, prStatusIndicator, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; function status(overrides: Partial = {}): VcsStatusResult { @@ -30,6 +39,25 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } +function mergedFeaturePr(): NonNullable { + return { + number: 42, + title: "Feature PR", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/current", + state: "merged", + }; +} + +function snapshotFor( + branch: string, + pr: NonNullable, + sourceControlProvider?: VcsStatusResult["sourceControlProvider"], +): ThreadChangeRequestSnapshot { + return { branch, pr, sourceControlProvider }; +} + describe("resolveThreadPr", () => { it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { expect( @@ -70,6 +98,320 @@ describe("resolveThreadPr", () => { }); }); +describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { + const featureBranch = "feature/current"; + const mergedPr = mergedFeaturePr(); + const provider = { + kind: "github" as const, + name: "GitHub", + baseUrl: "https://github.com", + }; + + it("returns the live merged PR when the checkout matches the feature branch", () => { + const gitStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBe(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("after caching a merged PR, resolves main status back to the cached feature PR", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); + + const mainStatus = status({ + refName: "main", + isDefaultRef: true, + pr: { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "main", + headRef: "main", + state: "open", + }, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("never attaches a PR reported by main to the feature thread", () => { + const mainPr = { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "develop", + headRef: "main", + state: "merged" as const, + }; + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + }); + + it("does not show a cached open PR across a branch mismatch", () => { + const openSnapshot = snapshotFor(featureBranch, { + ...mergedPr, + state: "open", + title: "Still open", + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("retains a cached closed PR across a branch mismatch", () => { + const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; + const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: closedSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(closedPr); + }); + + it("does not retain or display a terminal PR when a worktree switches branches", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + const mismatchedStatus = status({ refName: "feature/other", pr: null }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeUndefined(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + }); + + it("retains a local terminal snapshot when thread metadata follows the new branch", () => { + const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: otherBranchSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("clears an open snapshot when a local thread moves to a branch with no PR", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("does not erase a terminal snapshot when VCS data is missing", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).not.toBeNull(); + expect(cached).not.toBeUndefined(); + + const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); + const displayed = resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }); + expect(displayed?.state).toBe("merged"); + + const shell = { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Feature thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + createdAt: "2026-04-09T00:00:00.000Z", + updatedAt: "2026-04-09T00:00:00.000Z", + archivedAt: null, + settledAt: null, + settledOverride: null, + latestUserMessageAt: "2026-04-09T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as OrchestrationThreadShell; + + expect( + effectiveSettled(shell, { + now: "2026-04-10T00:00:00.000Z", + autoSettleAfterDays: null, + changeRequestState: displayed?.state ?? null, + }), + ).toBe(true); + }); +}); + +describe("threadChangeRequestSnapshotsAtom", () => { + it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => + Effect.gen(function* () { + const registry = AtomRegistry.make(); + const threadKey = "environment-1:thread-1"; + const snapshot = snapshotFor("feature/current", mergedFeaturePr()); + + const unmount = registry.mount(threadChangeRequestSnapshotsAtom); + registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); + unmount(); + + yield* Effect.yieldNow; + + const remount = registry.mount(threadChangeRequestSnapshotsAtom); + expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); + + remount(); + registry.dispose(); + }), + ); +}); + describe("prStatusIndicator", () => { it("formats PR tooltips with number, uppercase status, and title", () => { expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index af53d1a78b2..096d09dae6f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,8 +4,10 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import type { VcsStatusResult } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -126,6 +128,169 @@ export function resolveThreadPr(input: { 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; + readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; +} + +export const threadChangeRequestSnapshotsAtom = Atom.make< + ReadonlyMap +>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); + +function isTerminalChangeRequestState( + state: NonNullable["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; +} + +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 && + sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) + ); +} + +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), or a worktree mismatch — 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; +}): ThreadChangeRequestSnapshot | null | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if (threadBranch === null || gitStatus === null) { + return undefined; + } + if (gitStatus.refName !== threadBranch) { + return retainTerminalOnBranchMismatch ? undefined : null; + } + if (gitStatus.pr == null) { + if ( + retainTerminalOnBranchMismatch && + snapshot != null && + 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 never retained — their state can still change. + */ +export function resolveDisplayedThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.pr; + } + + if ( + retainTerminalOnBranchMismatch && + snapshot != null && + 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; +}): VcsStatusResult["sourceControlProvider"] | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.sourceControlProvider; + } + + if ( + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.sourceControlProvider; + } + + return undefined; +} + export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null {