From d6fed9846d77beedee4e4d6fa50571d157916b92 Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:01:21 +0200 Subject: [PATCH 1/5] fix(web): retain terminal PR badges after checkout switch Keep merged/closed local-checkout PR metadata in Sidebar V2 when the shared checkout moves to another branch, so settled rows stay settled with their original badge. --- apps/web/src/components/SidebarV2.tsx | 80 ++++-- .../components/ThreadStatusIndicators.test.ts | 246 +++++++++++++++++- .../src/components/ThreadStatusIndicators.tsx | 112 ++++++++ 3 files changed, 412 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3d314fd1ac2..c3d6b874e70 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -121,8 +121,12 @@ import { } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { + nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveThreadPr, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, + threadChangeRequestSnapshotsEqual, + type ThreadChangeRequestSnapshot, settledPrHoverColorClass, } from "./ThreadStatusIndicators"; import { @@ -391,11 +395,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 +511,28 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const pr = resolveThreadPr({ + const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prProvider = resolveDisplayedThreadPrProvider({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + }); + 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, + }); + if (nextSnapshot === undefined) return; + onChangeRequestSnapshot(threadKey, nextSnapshot); + }, [gitStatus.data, onChangeRequestSnapshot, thread.branch, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1157,21 +1176,27 @@ 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 + // 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, setChangeRequestSnapshotByKey] = 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) { + const handleChangeRequestSnapshot = useCallback( + (threadKey: string, snapshot: ThreadChangeRequestSnapshot | null) => { + setChangeRequestSnapshotByKey((current) => { + const existing = current.get(threadKey); + if (snapshot === null) { + if (existing === undefined) return current; + const next = new Map(current); next.delete(threadKey); - } else { - next.set(threadKey, state); + return next; + } + if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { + return current; } + const next = new Map(current); + next.set(threadKey, snapshot); return next; }); }, @@ -1394,7 +1419,11 @@ 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: retain merged/closed for + // this thread's branch even after the shared checkout switches away. + const changeRequestState = + snapshot != 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 +1451,7 @@ export default function SidebarV2() { }; }, [ autoSettleAfterDays, - changeRequestStateByKey, + changeRequestSnapshotByKey, nowMinute, scopedProjectKeys, serverConfigs, @@ -2460,7 +2489,8 @@ export default function SidebarV2() { onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} - onChangeRequestState={handleChangeRequestState} + changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} + onChangeRequestSnapshot={handleChangeRequestSnapshot} /> ); }; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3eb8e4f710f..aa47ac38fcd 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,10 +1,16 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +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 "vite-plus/test"; import { + nextThreadChangeRequestSnapshot, prStatusIndicator, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; function status(overrides: Partial = {}): VcsStatusResult { @@ -30,6 +36,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 +95,225 @@ 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, + }), + ).toBe(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + }), + ).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, + }); + 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, + }), + ).toEqual(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + }), + ).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, + }), + ).toBeNull(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + }), + ).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, + }), + ).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, + }), + ).toEqual(closedPr); + }); + + it("rejects a snapshot stored for another branch", () => { + const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: otherBranchSnapshot, + }), + ).toBeNull(); + }); + + it("clears the snapshot when returning to the matching branch with no PR", () => { + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: featureBranch, pr: null }), + }), + ).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, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + }), + ).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, + }); + expect(cached).not.toBeNull(); + expect(cached).not.toBeUndefined(); + + const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); + const displayed = resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + }); + 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: featureBranch, + 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("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..c57f528c9fb 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -126,6 +126,118 @@ 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; +} + +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) + ); +} + +/** + * Authoritative snapshot update from live VCS status. + * - `undefined`: branch mismatch or missing status — leave the map alone + * - `null`: matching branch reports no PR — clear the snapshot + * - snapshot: matching branch reports a PR — store/replace + */ +export function nextThreadChangeRequestSnapshot(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; +}): ThreadChangeRequestSnapshot | null | undefined { + const { threadBranch, gitStatus } = input; + if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { + return undefined; + } + if (gitStatus.pr == null) { + return null; + } + return { + branch: threadBranch, + pr: gitStatus.pr, + sourceControlProvider: gitStatus.sourceControlProvider, + }; +} + +/** + * Live PR when the checkout matches the thread branch; otherwise a cached + * merged/closed PR for that same branch. Open PRs are never retained across + * a mismatch — their state can still change. + */ +export function resolveDisplayedThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; +}): ThreadPr | null { + const { threadBranch, gitStatus, snapshot } = input; + if (threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch) { + return gitStatus.pr ?? null; + } + + if ( + snapshot != null && + snapshot.branch === threadBranch && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.pr; + } + + return null; +} + +export function resolveDisplayedThreadPrProvider(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; +}): VcsStatusResult["sourceControlProvider"] | undefined { + const { threadBranch, gitStatus, snapshot } = input; + if (threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch) { + return gitStatus.sourceControlProvider; + } + + if ( + snapshot != null && + snapshot.branch === threadBranch && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.sourceControlProvider; + } + + return undefined; +} + export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null { From 3994c89e29b464125dd7ca1a6290f42f38d09b9d Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:22:39 +0200 Subject: [PATCH 2/5] fix(web): scope terminal PR retention to local checkouts --- apps/web/src/components/SidebarV2.tsx | 23 ++++- .../components/ThreadStatusIndicators.test.ts | 87 +++++++++++++++++-- .../src/components/ThreadStatusIndicators.tsx | 54 +++++++++--- 3 files changed, 139 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c3d6b874e70..630d4c25245 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -511,15 +511,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); + const retainTerminalOnBranchMismatch = thread.worktreePath === null; const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, }); const prProvider = resolveDisplayedThreadPrProvider({ threadBranch: thread.branch, gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, }); const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; @@ -529,10 +532,19 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { const nextSnapshot = nextThreadChangeRequestSnapshot({ threadBranch: thread.branch, gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, }); if (nextSnapshot === undefined) return; onChangeRequestSnapshot(threadKey, nextSnapshot); - }, [gitStatus.data, onChangeRequestSnapshot, thread.branch, threadKey]); + }, [ + gitStatus.data, + changeRequestSnapshot, + onChangeRequestSnapshot, + retainTerminalOnBranchMismatch, + thread.branch, + threadKey, + ]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1420,10 +1432,13 @@ export default function SidebarV2() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const snapshot = changeRequestSnapshotByKey.get(threadKey); - // Same resolved terminal PR the row displays: retain merged/closed for - // this thread's branch even after the shared checkout switches away. + // 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 && snapshot.branch === thread.branch ? snapshot.pr.state : null; + 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). diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index aa47ac38fcd..c385f27bb5e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -116,6 +116,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { threadBranch: featureBranch, gitStatus, snapshot: undefined, + retainTerminalOnBranchMismatch: true, }), ).toBe(mergedPr); expect( @@ -123,6 +124,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { threadBranch: featureBranch, gitStatus, snapshot: undefined, + retainTerminalOnBranchMismatch: true, }), ).toEqual(provider); }); @@ -136,6 +138,8 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { const cached = nextThreadChangeRequestSnapshot({ threadBranch: featureBranch, gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, }); expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); @@ -158,6 +162,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { threadBranch: featureBranch, gitStatus: mainStatus, snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, }), ).toEqual(mergedPr); expect( @@ -165,6 +170,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { threadBranch: featureBranch, gitStatus: mainStatus, snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, }), ).toEqual(provider); }); @@ -183,12 +189,15 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { 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(); }); @@ -205,6 +214,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { threadBranch: featureBranch, gitStatus: status({ refName: "main", pr: null }), snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, }), ).toBeNull(); }); @@ -218,11 +228,42 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { threadBranch: featureBranch, gitStatus: status({ refName: "main", pr: null }), snapshot: closedSnapshot, + retainTerminalOnBranchMismatch: true, }), ).toEqual(closedPr); }); - it("rejects a snapshot stored for another branch", () => { + 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( @@ -230,15 +271,41 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { threadBranch: featureBranch, gitStatus: status({ refName: "main", pr: null }), snapshot: otherBranchSnapshot, + retainTerminalOnBranchMismatch: true, }), - ).toBeNull(); + ).toEqual(mergedPr); }); - it("clears the snapshot when returning to the matching branch with no PR", () => { + 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: featureBranch, - gitStatus: status({ refName: featureBranch, pr: null }), + 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(); }); @@ -250,6 +317,8 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { nextThreadChangeRequestSnapshot({ threadBranch: featureBranch, gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, }), ).toBeUndefined(); expect( @@ -257,6 +326,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { threadBranch: featureBranch, gitStatus: null, snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, }), ).toEqual(mergedPr); }); @@ -270,15 +340,18 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { 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: featureBranch, + threadBranch: "main", gitStatus: mainStatus, snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, }); expect(displayed?.state).toBe("merged"); @@ -289,7 +362,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", - branch: featureBranch, + branch: "main", worktreePath: null, latestTurn: null, session: null, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index c57f528c9fb..61076f56158 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -169,19 +169,31 @@ export function threadChangeRequestSnapshotsEqual( /** * Authoritative snapshot update from live VCS status. - * - `undefined`: branch mismatch or missing status — leave the map alone - * - `null`: matching branch reports no PR — clear the snapshot + * - `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 } = input; - if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { + 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 { @@ -192,23 +204,31 @@ export function nextThreadChangeRequestSnapshot(input: { } /** - * Live PR when the checkout matches the thread branch; otherwise a cached - * merged/closed PR for that same branch. Open PRs are never retained across - * a mismatch — their state can still change. + * 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 } = input; - if (threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch) { - return gitStatus.pr ?? 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 && - snapshot.branch === threadBranch && isTerminalChangeRequestState(snapshot.pr.state) ) { return snapshot.pr; @@ -221,15 +241,21 @@ export function resolveDisplayedThreadPrProvider(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; }): VcsStatusResult["sourceControlProvider"] | undefined { - const { threadBranch, gitStatus, snapshot } = input; - if (threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch) { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { return gitStatus.sourceControlProvider; } if ( + retainTerminalOnBranchMismatch && snapshot != null && - snapshot.branch === threadBranch && isTerminalChangeRequestState(snapshot.pr.state) ) { return snapshot.sourceControlProvider; From 1a4be9ceefb68f456c20c4fd2a9dd1d48f57b5a2 Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:43:23 +0200 Subject: [PATCH 3/5] fix(web): share retained PR state with chat view --- apps/web/src/components/ChatView.tsx | 11 +++++-- apps/web/src/components/SidebarV2.tsx | 29 +++---------------- .../src/components/ThreadStatusIndicators.tsx | 27 +++++++++++++++++ 3 files changed, 40 insertions(+), 27 deletions(-) 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 630d4c25245..cb45517bfb2 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -125,7 +125,8 @@ import { prStatusIndicator, resolveDisplayedThreadPr, resolveDisplayedThreadPrProvider, - threadChangeRequestSnapshotsEqual, + setThreadChangeRequestSnapshot, + threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, settledPrHoverColorClass, } from "./ThreadStatusIndicators"; @@ -1191,29 +1192,7 @@ export default function SidebarV2() { // 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, setChangeRequestSnapshotByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestSnapshot = useCallback( - (threadKey: string, snapshot: ThreadChangeRequestSnapshot | null) => { - setChangeRequestSnapshotByKey((current) => { - const existing = current.get(threadKey); - if (snapshot === null) { - if (existing === undefined) return current; - const next = new Map(current); - next.delete(threadKey); - return next; - } - if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { - return current; - } - const next = new Map(current); - next.set(threadKey, snapshot); - return next; - }); - }, - [], - ); + 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. @@ -2505,7 +2484,7 @@ export default function SidebarV2() { onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} - onChangeRequestSnapshot={handleChangeRequestSnapshot} + onChangeRequestSnapshot={setThreadChangeRequestSnapshot} /> ); }; diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 61076f56158..3285471d499 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"; @@ -136,6 +138,10 @@ export interface ThreadChangeRequestSnapshot { readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; } +export const threadChangeRequestSnapshotsAtom = Atom.make< + ReadonlyMap +>(new Map()).pipe(Atom.withLabel("sidebar:thread-change-request-snapshots")); + function isTerminalChangeRequestState( state: NonNullable["state"], ): state is "merged" | "closed" { @@ -167,6 +173,27 @@ export function threadChangeRequestSnapshotsEqual( ); } +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 From 87e936bad77958dd3452229c18c4a802e4b404b3 Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:58:53 +0200 Subject: [PATCH 4/5] fix(web): retain PR snapshots across route changes --- .../components/ThreadStatusIndicators.test.ts | 20 +++++++++++++++++++ .../src/components/ThreadStatusIndicators.tsx | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index c385f27bb5e..2ab05a6cedb 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,6 +1,7 @@ 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 { AtomRegistry } from "effect/unstable/reactivity"; import { describe, expect, it } from "vite-plus/test"; import { @@ -10,6 +11,7 @@ import { resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; @@ -387,6 +389,24 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { }); }); +describe("threadChangeRequestSnapshotsAtom", () => { + it("retains snapshots while sidebar and chat consumers are unmounted", () => { + 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(); + + 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 3285471d499..096d09dae6f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -140,7 +140,7 @@ export interface ThreadChangeRequestSnapshot { export const threadChangeRequestSnapshotsAtom = Atom.make< ReadonlyMap ->(new Map()).pipe(Atom.withLabel("sidebar:thread-change-request-snapshots")); +>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); function isTerminalChangeRequestState( state: NonNullable["state"], From 703413f0c755b5cf6d94548746f3c5e00625bcbb Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:10:55 +0200 Subject: [PATCH 5/5] test(web): await atom disposal before retention assertion --- .../components/ThreadStatusIndicators.test.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 2ab05a6cedb..bd60dded306 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,8 +1,9 @@ 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 { describe, expect, it } from "vite-plus/test"; import { nextThreadChangeRequestSnapshot, @@ -390,21 +391,25 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { }); describe("threadChangeRequestSnapshotsAtom", () => { - it("retains snapshots while sidebar and chat consumers are unmounted", () => { - const registry = AtomRegistry.make(); - const threadKey = "environment-1:thread-1"; - const snapshot = snapshotFor("feature/current", mergedFeaturePr()); + 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(); + const unmount = registry.mount(threadChangeRequestSnapshotsAtom); + registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); + unmount(); - const remount = registry.mount(threadChangeRequestSnapshotsAtom); - expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); + yield* Effect.yieldNow; - remount(); - registry.dispose(); - }); + const remount = registry.mount(threadChangeRequestSnapshotsAtom); + expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); + + remount(); + registry.dispose(); + }), + ); }); describe("prStatusIndicator", () => {