authorgravatar for 36650750+sebbonit@users.noreply.github.comsebbonit <36650750+sebbonit@users.noreply.github.com> 2026-08-15 14:07:51+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:07:51+00:00
logd5465aebf2746b8d5f327be3b2424d9412a29075
tree7d37a16519a6f80a713e8fcf0218576eb114d0cc
parentd715c2e56bb718d2225cc0f07cc65e6c637dc229
signature Signed by PGP key B5690EEEBB952194

fix(web): retain terminal PR badges after checkout switch (#4755)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 618 insertions(+), 36 deletions(-)

apps/web/src/components/ChatView.tsx+9-2
...@@ -276,7 +276,10 @@ import {...@@ -276,7 +276,10 @@ import {
276 shouldShowThreadErrorBanner,276 shouldShowThreadErrorBanner,
277 ThreadErrorBanner,277 ThreadErrorBanner,
278} from "./chat/ThreadErrorBanner";278} from "./chat/ThreadErrorBanner";
279import { resolveThreadPr } from "./ThreadStatusIndicators";279import {
280 resolveDisplayedThreadPr,
281 threadChangeRequestSnapshotsAtom,
282} from "./ThreadStatusIndicators";
280import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";283import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
281import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";284import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
282import {285import {
...@@ -1596,6 +1599,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1596,6 +1599,7 @@ function ChatViewContent(props: ChatViewProps) {
1596 [activeThreadEnvironmentId, activeThreadId],1599 [activeThreadEnvironmentId, activeThreadId],
1597 );1600 );
1598 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1601 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1602 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
1599 const [timelineAnchor, setTimelineAnchor] = useState<{1603 const [timelineAnchor, setTimelineAnchor] = useState<{
1600 readonly threadKey: string | null;1604 readonly threadKey: string | null;
1601 readonly messageId: MessageId | null;1605 readonly messageId: MessageId | null;
...@@ -4124,9 +4128,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4124,9 +4128,11 @@ function ChatViewContent(props: ChatViewProps) {
4124 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);4128 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
4125 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);4129 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4126 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);4130 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4127 const activeThreadPr = resolveThreadPr({4131 const activeThreadPr = resolveDisplayedThreadPr({
4128 threadBranch: activeThread?.branch ?? null,4132 threadBranch: activeThread?.branch ?? null,
4129 gitStatus: gitStatusQuery.data ?? null,4133 gitStatus: gitStatusQuery.data ?? null,
4134 snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
4135 retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
4130 });4136 });
4131 // The right panel offers the thread's own change request, so it can only offer it once the4137 // The right panel offers the thread's own change request, so it can only offer it once the
4132 // branch has one; until then the picker says so rather than opening an empty panel.4138 // branch has one; until then the picker says so rather than opening an empty panel.
...@@ -4208,6 +4214,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4208,6 +4214,7 @@ function ChatViewContent(props: ChatViewProps) {
4208 activeThreadShell,4214 activeThreadShell,
4209 autoSettleAfterDays,4215 autoSettleAfterDays,
4210 autoSettleOnMerge,4216 autoSettleOnMerge,
4217 changeRequestSnapshotByKey,
4211 nowMinute,4218 nowMinute,
4212 supportsSettlement,4219 supportsSettlement,
4213 ]);4220 ]);
apps/web/src/components/Sidebar.tsx+49-32
...@@ -143,10 +143,15 @@ import {...@@ -143,10 +143,15 @@ import {
143import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";143import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
144import {144import {
145 ThreadWorktreeIndicator,145 ThreadWorktreeIndicator,
146 nextThreadChangeRequestSnapshot,
146 prStatusIndicator,147 prStatusIndicator,
147 resolveThreadPr,148 resolveDisplayedThreadPr,
149 resolveDisplayedThreadPrProvider,
150 setThreadChangeRequestSnapshot,
148 settledPrHoverColorClass,151 settledPrHoverColorClass,
149 terminalStatusFromRunningIds,152 terminalStatusFromRunningIds,
153 threadChangeRequestSnapshotsAtom,
154 type ThreadChangeRequestSnapshot,
150 type TerminalStatusIndicator,155 type TerminalStatusIndicator,
151} from "./ThreadStatusIndicators";156} from "./ThreadStatusIndicators";
152import {157import {
...@@ -729,11 +734,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -729,11 +734,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
729 onUnsnooze: (threadRef: ScopedThreadRef) => void;734 onUnsnooze: (threadRef: ScopedThreadRef) => void;
730 onUnpin: (threadRef: ScopedThreadRef) => void;735 onUnpin: (threadRef: ScopedThreadRef) => void;
731 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;736 onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void;
732 onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void;737 changeRequestSnapshot: ThreadChangeRequestSnapshot | null;
738 onChangeRequestSnapshot: (
739 threadKey: string,
740 snapshot: ThreadChangeRequestSnapshot | null,
741 ) => void;
733}) {742}) {
734 const {743 const {
735 isRenaming,744 isRenaming,
736 onChangeRequestState,745 changeRequestSnapshot,
746 onChangeRequestSnapshot,
737 onCancelRename,747 onCancelRename,
738 onCommitRename,748 onCommitRename,
739 onContextMenu,749 onContextMenu,
...@@ -778,9 +788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -778,9 +788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
778 })788 })
779 : null,789 : null,
780 );790 );
781 const pr = resolveThreadPr({791 const retainTerminalOnBranchMismatch = thread.worktreePath === null;
792 const pr = resolveDisplayedThreadPr({
782 threadBranch: thread.branch,793 threadBranch: thread.branch,
783 gitStatus: gitStatus.data,794 gitStatus: gitStatus.data,
795 snapshot: changeRequestSnapshot,
796 retainTerminalOnBranchMismatch,
784 });797 });
785 const prState = pr?.state ?? null;798 const prState = pr?.state ?? null;
786799
...@@ -874,13 +887,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -874,13 +887,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
874 activeThreadBranch: thread.branch,887 activeThreadBranch: thread.branch,
875 currentGitBranch: gitStatus.data?.refName ?? null,888 currentGitBranch: gitStatus.data?.refName ?? null,
876 });889 });
877 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);890 const prProvider = resolveDisplayedThreadPrProvider({
891 threadBranch: thread.branch,
892 gitStatus: gitStatus.data,
893 snapshot: changeRequestSnapshot,
894 retainTerminalOnBranchMismatch,
895 });
896 const prStatus = prStatusIndicator(pr, prProvider);
878 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;897 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined;
879 // Report the PR state so the parent can apply the configured merge rule
880 // and the always-on close rule during partitioning.
881 useEffect(() => {898 useEffect(() => {
882 onChangeRequestState(threadKey, prState);899 const nextSnapshot = nextThreadChangeRequestSnapshot({
883 }, [onChangeRequestState, prState, threadKey]);900 threadBranch: thread.branch,
901 gitStatus: gitStatus.data,
902 snapshot: changeRequestSnapshot,
903 retainTerminalOnBranchMismatch,
904 });
905 if (nextSnapshot === undefined) return;
906 onChangeRequestSnapshot(threadKey, nextSnapshot);
907 }, [
908 changeRequestSnapshot,
909 gitStatus.data,
910 onChangeRequestSnapshot,
911 retainTerminalOnBranchMismatch,
912 thread.branch,
913 threadKey,
914 ]);
884915
885 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;916 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
886 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;917 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
...@@ -1858,26 +1889,7 @@ export default function Sidebar() {...@@ -1858,26 +1889,7 @@ export default function Sidebar() {
1858 // fresh clock whenever it recomputes.1889 // fresh clock whenever it recomputes.
1859 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);1890 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
18601891
1861 // PR states stream in per-row. The next partition applies the configured1892 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
1862 // merge rule and the always-on close rule.
1863 const [changeRequestStateByKey, setChangeRequestStateByKey] = useState<
1864 ReadonlyMap<string, "open" | "closed" | "merged">
1865 >(() => new Map());
1866 const handleChangeRequestState = useCallback(
1867 (threadKey: string, state: "open" | "closed" | "merged" | null) => {
1868 setChangeRequestStateByKey((current) => {
1869 if ((current.get(threadKey) ?? null) === state) return current;
1870 const next = new Map(current);
1871 if (state === null) {
1872 next.delete(threadKey);
1873 } else {
1874 next.set(threadKey, state);
1875 }
1876 return next;
1877 });
1878 },
1879 [],
1880 );
18811893
1882 // Project scope: one menu above the list. Scoping filters the list without1894 // Project scope: one menu above the list. Scoping filters the list without
1883 // making the header width depend on the number or length of project names.1895 // making the header width depend on the number or length of project names.
...@@ -1993,7 +2005,11 @@ export default function Sidebar() {...@@ -1993,7 +2005,11 @@ export default function Sidebar() {
1993 const supportsSnooze =2005 const supportsSnooze =
1994 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;2006 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
1995 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));2007 const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
1996 const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;2008 const snapshot = changeRequestSnapshotByKey.get(threadKey);
2009 const changeRequestState =
2010 snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch)
2011 ? snapshot.pr.state
2012 : null;
1997 // Snooze outranks everything, including a pin: "hide until Tuesday"2013 // Snooze outranks everything, including a pin: "hide until Tuesday"
1998 // temporarily suspends "keep on top". The pin survives underneath —2014 // temporarily suspends "keep on top". The pin survives underneath —
1999 // and so does its pinOrderKey, so on wake the thread reappears at2015 // and so does its pinOrderKey, so on wake the thread reappears at
...@@ -2051,7 +2067,7 @@ export default function Sidebar() {...@@ -2051,7 +2067,7 @@ export default function Sidebar() {
2051 }, [2067 }, [
2052 autoSettleAfterDays,2068 autoSettleAfterDays,
2053 autoSettleOnMerge,2069 autoSettleOnMerge,
2054 changeRequestStateByKey,2070 changeRequestSnapshotByKey,
2055 nowMinute,2071 nowMinute,
2056 scopedProjectKeys,2072 scopedProjectKeys,
2057 serverConfigs,2073 serverConfigs,
...@@ -3686,7 +3702,8 @@ export default function Sidebar() {...@@ -3686,7 +3702,8 @@ export default function Sidebar() {
3686 onUnsnooze={attemptUnsnooze}3702 onUnsnooze={attemptUnsnooze}
3687 onUnpin={attemptUnpin}3703 onUnpin={attemptUnpin}
3688 onAcknowledgeWoke={acknowledgeWoke}3704 onAcknowledgeWoke={acknowledgeWoke}
3689 onChangeRequestState={handleChangeRequestState}3705 changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null}
3706 onChangeRequestSnapshot={setThreadChangeRequestSnapshot}
3690 />3707 />
3691 );3708 );
3692 };3709 };
apps/web/src/components/ThreadStatusIndicators.test.ts+386-2
...@@ -1,10 +1,19 @@...@@ -1,10 +1,19 @@
1import type { VcsStatusResult } from "@t3tools/contracts";1import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
2import { describe, expect, it } from "vite-plus/test";2import type { OrchestrationThreadShell } from "@t3tools/contracts";
3import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import { AtomRegistry } from "effect/unstable/reactivity";
37
4import {8import {
9 nextThreadChangeRequestSnapshot,
5 prStatusIndicator,10 prStatusIndicator,
11 resolveDisplayedThreadPr,
12 resolveDisplayedThreadPrProvider,
6 resolveThreadPr,13 resolveThreadPr,
7 settledPrHoverColorClass,14 settledPrHoverColorClass,
15 threadChangeRequestSnapshotsAtom,
16 type ThreadChangeRequestSnapshot,
8} from "./ThreadStatusIndicators";17} from "./ThreadStatusIndicators";
918
10function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {19function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
...@@ -30,6 +39,25 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {...@@ -30,6 +39,25 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
30 };39 };
31}40}
3241
42function mergedFeaturePr(): NonNullable<VcsStatusResult["pr"]> {
43 return {
44 number: 42,
45 title: "Feature PR",
46 url: "https://github.com/pingdotgg/t3code/pull/42",
47 baseRef: "main",
48 headRef: "feature/current",
49 state: "merged",
50 };
51}
52
53function snapshotFor(
54 branch: string,
55 pr: NonNullable<VcsStatusResult["pr"]>,
56 sourceControlProvider?: VcsStatusResult["sourceControlProvider"],
57): ThreadChangeRequestSnapshot {
58 return { branch, pr, sourceControlProvider };
59}
60
33describe("resolveThreadPr", () => {61describe("resolveThreadPr", () => {
34 it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {62 it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {
35 expect(63 expect(
...@@ -70,6 +98,362 @@ describe("resolveThreadPr", () => {...@@ -70,6 +98,362 @@ describe("resolveThreadPr", () => {
70 });98 });
71});99});
72100
101describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => {
102 const featureBranch = "feature/current";
103 const mergedPr = mergedFeaturePr();
104 const provider = {
105 kind: "github" as const,
106 name: "GitHub",
107 baseUrl: "https://github.com",
108 };
109
110 it("returns the live merged PR when the checkout matches the feature branch", () => {
111 const gitStatus = status({
112 refName: featureBranch,
113 pr: mergedPr,
114 sourceControlProvider: provider,
115 });
116
117 expect(
118 resolveDisplayedThreadPr({
119 threadBranch: featureBranch,
120 gitStatus,
121 snapshot: undefined,
122 retainTerminalOnBranchMismatch: true,
123 }),
124 ).toBe(mergedPr);
125 expect(
126 resolveDisplayedThreadPrProvider({
127 threadBranch: featureBranch,
128 gitStatus,
129 snapshot: undefined,
130 retainTerminalOnBranchMismatch: true,
131 }),
132 ).toEqual(provider);
133 });
134
135 it("after caching a merged PR, resolves main status back to the cached feature PR", () => {
136 const matchingStatus = status({
137 refName: featureBranch,
138 pr: mergedPr,
139 sourceControlProvider: provider,
140 });
141 const cached = nextThreadChangeRequestSnapshot({
142 threadBranch: featureBranch,
143 gitStatus: matchingStatus,
144 snapshot: undefined,
145 retainTerminalOnBranchMismatch: true,
146 });
147 expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider));
148
149 const mainStatus = status({
150 refName: "main",
151 isDefaultRef: true,
152 pr: {
153 number: 99,
154 title: "Unrelated main PR",
155 url: "https://github.com/pingdotgg/t3code/pull/99",
156 baseRef: "main",
157 headRef: "main",
158 state: "open",
159 },
160 sourceControlProvider: provider,
161 });
162
163 expect(
164 resolveDisplayedThreadPr({
165 threadBranch: featureBranch,
166 gitStatus: mainStatus,
167 snapshot: cached as ThreadChangeRequestSnapshot,
168 retainTerminalOnBranchMismatch: true,
169 }),
170 ).toEqual(mergedPr);
171 expect(
172 resolveDisplayedThreadPrProvider({
173 threadBranch: featureBranch,
174 gitStatus: mainStatus,
175 snapshot: cached as ThreadChangeRequestSnapshot,
176 retainTerminalOnBranchMismatch: true,
177 }),
178 ).toEqual(provider);
179 });
180
181 it("never attaches a PR reported by main to the feature thread", () => {
182 const mainPr = {
183 number: 99,
184 title: "Unrelated main PR",
185 url: "https://github.com/pingdotgg/t3code/pull/99",
186 baseRef: "develop",
187 headRef: "main",
188 state: "merged" as const,
189 };
190 expect(
191 resolveDisplayedThreadPr({
192 threadBranch: featureBranch,
193 gitStatus: status({ refName: "main", pr: mainPr }),
194 snapshot: undefined,
195 retainTerminalOnBranchMismatch: true,
196 }),
197 ).toBeNull();
198 expect(
199 nextThreadChangeRequestSnapshot({
200 threadBranch: featureBranch,
201 gitStatus: status({ refName: "main", pr: mainPr }),
202 snapshot: undefined,
203 retainTerminalOnBranchMismatch: true,
204 }),
205 ).toBeNull();
206 });
207
208 it("does not show a cached open PR across a branch mismatch", () => {
209 const openSnapshot = snapshotFor(featureBranch, {
210 ...mergedPr,
211 state: "open",
212 title: "Still open",
213 });
214
215 expect(
216 resolveDisplayedThreadPr({
217 threadBranch: featureBranch,
218 gitStatus: status({ refName: "main", pr: null }),
219 snapshot: openSnapshot,
220 retainTerminalOnBranchMismatch: true,
221 }),
222 ).toBeNull();
223 });
224
225 it("retains a cached closed PR across a branch mismatch", () => {
226 const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" };
227 const closedSnapshot = snapshotFor(featureBranch, closedPr, provider);
228
229 expect(
230 resolveDisplayedThreadPr({
231 threadBranch: featureBranch,
232 gitStatus: status({ refName: "main", pr: null }),
233 snapshot: closedSnapshot,
234 retainTerminalOnBranchMismatch: true,
235 }),
236 ).toEqual(closedPr);
237 });
238
239 it("does not retain or display a terminal PR when a worktree switches branches", () => {
240 const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
241 const mismatchedStatus = status({ refName: "feature/other", pr: null });
242
243 expect(
244 resolveDisplayedThreadPr({
245 threadBranch: featureBranch,
246 gitStatus: mismatchedStatus,
247 snapshot: terminalSnapshot,
248 retainTerminalOnBranchMismatch: false,
249 }),
250 ).toBeNull();
251 expect(
252 resolveDisplayedThreadPrProvider({
253 threadBranch: featureBranch,
254 gitStatus: mismatchedStatus,
255 snapshot: terminalSnapshot,
256 retainTerminalOnBranchMismatch: false,
257 }),
258 ).toBeUndefined();
259 expect(
260 nextThreadChangeRequestSnapshot({
261 threadBranch: featureBranch,
262 gitStatus: mismatchedStatus,
263 snapshot: terminalSnapshot,
264 retainTerminalOnBranchMismatch: false,
265 }),
266 ).toBeNull();
267 });
268
269 it("retains a local terminal snapshot when thread metadata follows the new branch", () => {
270 const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider);
271
272 expect(
273 resolveDisplayedThreadPr({
274 threadBranch: featureBranch,
275 gitStatus: status({ refName: "main", pr: null }),
276 snapshot: otherBranchSnapshot,
277 retainTerminalOnBranchMismatch: true,
278 }),
279 ).toEqual(mergedPr);
280 });
281
282 it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => {
283 const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
284
285 expect(
286 nextThreadChangeRequestSnapshot({
287 threadBranch: "main",
288 gitStatus: status({ refName: "main", pr: null }),
289 snapshot: terminalSnapshot,
290 retainTerminalOnBranchMismatch: true,
291 }),
292 ).toBeUndefined();
293 expect(
294 resolveDisplayedThreadPr({
295 threadBranch: "main",
296 gitStatus: status({ refName: "main", pr: null }),
297 snapshot: terminalSnapshot,
298 retainTerminalOnBranchMismatch: true,
299 }),
300 ).toEqual(mergedPr);
301 });
302
303 it("clears an open snapshot when a local thread moves to a branch with no PR", () => {
304 const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" });
305
306 expect(
307 nextThreadChangeRequestSnapshot({
308 threadBranch: "main",
309 gitStatus: status({ refName: "main", pr: null }),
310 snapshot: openSnapshot,
311 retainTerminalOnBranchMismatch: true,
312 }),
313 ).toBeNull();
314 });
315
316 it("clears an open snapshot when a local checkout moves to a different branch", () => {
317 const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" });
318
319 expect(
320 nextThreadChangeRequestSnapshot({
321 threadBranch: featureBranch,
322 gitStatus: status({ refName: "main", pr: null }),
323 snapshot: openSnapshot,
324 retainTerminalOnBranchMismatch: true,
325 }),
326 ).toBeNull();
327 });
328
329 it("clears a retained snapshot when the thread branch is cleared", () => {
330 const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
331
332 expect(
333 nextThreadChangeRequestSnapshot({
334 threadBranch: null,
335 gitStatus: status({ refName: "main", pr: null }),
336 snapshot: terminalSnapshot,
337 retainTerminalOnBranchMismatch: true,
338 }),
339 ).toBeNull();
340 expect(
341 resolveDisplayedThreadPr({
342 threadBranch: null,
343 gitStatus: status({ refName: "main", pr: null }),
344 snapshot: terminalSnapshot,
345 retainTerminalOnBranchMismatch: true,
346 }),
347 ).toBeNull();
348 expect(
349 resolveDisplayedThreadPrProvider({
350 threadBranch: null,
351 gitStatus: status({ refName: "main", pr: null }),
352 snapshot: terminalSnapshot,
353 retainTerminalOnBranchMismatch: true,
354 }),
355 ).toBeUndefined();
356 });
357
358 it("does not erase a terminal snapshot when VCS data is missing", () => {
359 const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider);
360
361 expect(
362 nextThreadChangeRequestSnapshot({
363 threadBranch: featureBranch,
364 gitStatus: null,
365 snapshot: terminalSnapshot,
366 retainTerminalOnBranchMismatch: true,
367 }),
368 ).toBeUndefined();
369 expect(
370 resolveDisplayedThreadPr({
371 threadBranch: featureBranch,
372 gitStatus: null,
373 snapshot: terminalSnapshot,
374 retainTerminalOnBranchMismatch: true,
375 }),
376 ).toEqual(mergedPr);
377 });
378
379 it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => {
380 const matchingStatus = status({
381 refName: featureBranch,
382 pr: mergedPr,
383 sourceControlProvider: provider,
384 });
385 const cached = nextThreadChangeRequestSnapshot({
386 threadBranch: featureBranch,
387 gitStatus: matchingStatus,
388 snapshot: undefined,
389 retainTerminalOnBranchMismatch: true,
390 });
391 expect(cached).not.toBeNull();
392 expect(cached).not.toBeUndefined();
393
394 const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true });
395 const displayed = resolveDisplayedThreadPr({
396 threadBranch: "main",
397 gitStatus: mainStatus,
398 snapshot: cached as ThreadChangeRequestSnapshot,
399 retainTerminalOnBranchMismatch: true,
400 });
401 expect(displayed?.state).toBe("merged");
402
403 const shell = {
404 id: ThreadId.make("thread-1"),
405 projectId: ProjectId.make("project-1"),
406 title: "Feature thread",
407 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
408 runtimeMode: "full-access",
409 interactionMode: "default",
410 branch: "main",
411 worktreePath: null,
412 latestTurn: null,
413 session: null,
414 createdAt: "2026-04-09T00:00:00.000Z",
415 updatedAt: "2026-04-09T00:00:00.000Z",
416 archivedAt: null,
417 settledAt: null,
418 settledOverride: null,
419 latestUserMessageAt: "2026-04-09T00:00:00.000Z",
420 hasPendingApprovals: false,
421 hasPendingUserInput: false,
422 hasActionableProposedPlan: false,
423 } as OrchestrationThreadShell;
424
425 expect(
426 effectiveSettled(shell, {
427 now: "2026-04-10T00:00:00.000Z",
428 autoSettleAfterDays: null,
429 changeRequestState: displayed?.state ?? null,
430 }),
431 ).toBe(true);
432 });
433});
434
435describe("threadChangeRequestSnapshotsAtom", () => {
436 it.effect("retains snapshots while sidebar and chat consumers are unmounted", () =>
437 Effect.gen(function* () {
438 const registry = AtomRegistry.make();
439 const threadKey = "environment-1:thread-1";
440 const snapshot = snapshotFor("feature/current", mergedFeaturePr());
441
442 const unmount = registry.mount(threadChangeRequestSnapshotsAtom);
443 registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]]));
444 unmount();
445
446 yield* Effect.yieldNow;
447
448 const remount = registry.mount(threadChangeRequestSnapshotsAtom);
449 expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot);
450
451 remount();
452 registry.dispose();
453 }),
454 );
455});
456
73describe("prStatusIndicator", () => {457describe("prStatusIndicator", () => {
74 it("formats PR tooltips with number, uppercase status, and title", () => {458 it("formats PR tooltips with number, uppercase status, and title", () => {
75 expect(prStatusIndicator(status().pr, undefined)).toMatchObject({459 expect(prStatusIndicator(status().pr, undefined)).toMatchObject({
apps/web/src/components/ThreadStatusIndicators.tsx+174
...@@ -4,8 +4,10 @@ import {...@@ -4,8 +4,10 @@ import {
4 scopeThreadRef,4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";5} from "@t3tools/client-runtime/environment";
6import type { VcsStatusResult } from "@t3tools/contracts";6import type { VcsStatusResult } from "@t3tools/contracts";
7import { Atom } from "effect/unstable/reactivity";
7import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react";8import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
8import { useMemo } from "react";9import { useMemo } from "react";
10import { appAtomRegistry } from "../rpc/atomRegistry";
9import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";11import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
10import { useProject } from "../state/entities";12import { useProject } from "../state/entities";
11import { useEnvironmentQuery } from "../state/query";13import { useEnvironmentQuery } from "../state/query";
...@@ -126,6 +128,178 @@ export function resolveThreadPr(input: {...@@ -126,6 +128,178 @@ export function resolveThreadPr(input: {
126 return gitStatus.pr ?? null;128 return gitStatus.pr ?? null;
127}129}
128130
131/**
132 * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement
133 * partitions move them, so terminal PR metadata must live above the row.
134 */
135export interface ThreadChangeRequestSnapshot {
136 readonly branch: string;
137 readonly pr: NonNullable<ThreadPr>;
138 readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined;
139}
140
141export const threadChangeRequestSnapshotsAtom = Atom.make<
142 ReadonlyMap<string, ThreadChangeRequestSnapshot>
143>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots"));
144
145function isTerminalChangeRequestState(
146 state: NonNullable<ThreadPr>["state"],
147): state is "merged" | "closed" {
148 return state === "merged" || state === "closed";
149}
150
151function sourceControlProvidersEqual(
152 left: VcsStatusResult["sourceControlProvider"] | undefined,
153 right: VcsStatusResult["sourceControlProvider"] | undefined,
154): boolean {
155 if (left === right) return true;
156 if (left == null || right == null) return left == null && right == null;
157 return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl;
158}
159
160export function threadChangeRequestSnapshotsEqual(
161 left: ThreadChangeRequestSnapshot,
162 right: ThreadChangeRequestSnapshot,
163): boolean {
164 return (
165 left.branch === right.branch &&
166 left.pr.number === right.pr.number &&
167 left.pr.title === right.pr.title &&
168 left.pr.url === right.pr.url &&
169 left.pr.baseRef === right.pr.baseRef &&
170 left.pr.headRef === right.pr.headRef &&
171 left.pr.state === right.pr.state &&
172 sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider)
173 );
174}
175
176export function setThreadChangeRequestSnapshot(
177 threadKey: string,
178 snapshot: ThreadChangeRequestSnapshot | null,
179): void {
180 appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => {
181 const existing = current.get(threadKey);
182 if (snapshot === null) {
183 if (existing === undefined) return [false, current];
184 const next = new Map(current);
185 next.delete(threadKey);
186 return [true, next];
187 }
188 if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) {
189 return [false, current];
190 }
191 const next = new Map(current);
192 next.set(threadKey, snapshot);
193 return [true, next];
194 });
195}
196
197/**
198 * Authoritative snapshot update from live VCS status.
199 * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone
200 * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear
201 * - snapshot: matching branch reports a PR — store/replace
202 */
203export function nextThreadChangeRequestSnapshot(input: {
204 threadBranch: string | null;
205 gitStatus: VcsStatusResult | null;
206 snapshot: ThreadChangeRequestSnapshot | null | undefined;
207 retainTerminalOnBranchMismatch: boolean;
208}): ThreadChangeRequestSnapshot | null | undefined {
209 const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input;
210 if (gitStatus === null) {
211 return undefined;
212 }
213 if (threadBranch === null) {
214 return null;
215 }
216 if (gitStatus.refName !== threadBranch) {
217 return retainTerminalOnBranchMismatch &&
218 snapshot != null &&
219 isTerminalChangeRequestState(snapshot.pr.state)
220 ? undefined
221 : null;
222 }
223 if (gitStatus.pr == null) {
224 if (
225 retainTerminalOnBranchMismatch &&
226 snapshot != null &&
227 isTerminalChangeRequestState(snapshot.pr.state)
228 ) {
229 return undefined;
230 }
231 return null;
232 }
233 return {
234 branch: threadBranch,
235 pr: gitStatus.pr,
236 sourceControlProvider: gitStatus.sourceControlProvider,
237 };
238}
239
240/**
241 * Live PR when the checkout matches the thread branch; otherwise, for local
242 * checkouts only, a cached merged/closed PR for the thread. Local thread
243 * metadata follows the shared checkout, so the cached branch intentionally
244 * survives that metadata changing to the newly checked-out branch. Open PRs
245 * are never retained — their state can still change.
246 */
247export function resolveDisplayedThreadPr(input: {
248 threadBranch: string | null;
249 gitStatus: VcsStatusResult | null;
250 snapshot: ThreadChangeRequestSnapshot | null | undefined;
251 retainTerminalOnBranchMismatch: boolean;
252}): ThreadPr | null {
253 const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input;
254 if (
255 threadBranch !== null &&
256 gitStatus !== null &&
257 gitStatus.refName === threadBranch &&
258 gitStatus.pr != null
259 ) {
260 return gitStatus.pr;
261 }
262
263 if (
264 threadBranch !== null &&
265 retainTerminalOnBranchMismatch &&
266 snapshot != null &&
267 isTerminalChangeRequestState(snapshot.pr.state)
268 ) {
269 return snapshot.pr;
270 }
271
272 return null;
273}
274
275export function resolveDisplayedThreadPrProvider(input: {
276 threadBranch: string | null;
277 gitStatus: VcsStatusResult | null;
278 snapshot: ThreadChangeRequestSnapshot | null | undefined;
279 retainTerminalOnBranchMismatch: boolean;
280}): VcsStatusResult["sourceControlProvider"] | undefined {
281 const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input;
282 if (
283 threadBranch !== null &&
284 gitStatus !== null &&
285 gitStatus.refName === threadBranch &&
286 gitStatus.pr != null
287 ) {
288 return gitStatus.sourceControlProvider;
289 }
290
291 if (
292 threadBranch !== null &&
293 retainTerminalOnBranchMismatch &&
294 snapshot != null &&
295 isTerminalChangeRequestState(snapshot.pr.state)
296 ) {
297 return snapshot.sourceControlProvider;
298 }
299
300 return undefined;
301}
302
129export function terminalStatusFromRunningIds(303export function terminalStatusFromRunningIds(
130 runningTerminalIds: ReadonlyArray<string>,304 runningTerminalIds: ReadonlyArray<string>,
131): TerminalStatusIndicator | null {305): TerminalStatusIndicator | null {