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 {
276276 shouldShowThreadErrorBanner,
277277 ThreadErrorBanner,
278278} from "./chat/ThreadErrorBanner";
279import { resolveThreadPr } from "./ThreadStatusIndicators";
279import {
280 resolveDisplayedThreadPr,
281 threadChangeRequestSnapshotsAtom,
282} from "./ThreadStatusIndicators";
280283import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
281284import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
282285import {
......@@ -1596,6 +1599,7 @@ function ChatViewContent(props: ChatViewProps) {
15961599 [activeThreadEnvironmentId, activeThreadId],
15971600 );
15981601 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1602 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
15991603 const [timelineAnchor, setTimelineAnchor] = useState<{
16001604 readonly threadKey: string | null;
16011605 readonly messageId: MessageId | null;
......@@ -4124,9 +4128,11 @@ function ChatViewContent(props: ChatViewProps) {
41244128 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
41254129 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
41264130 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4127 const activeThreadPr = resolveThreadPr({
4131 const activeThreadPr = resolveDisplayedThreadPr({
41284132 threadBranch: activeThread?.branch ?? null,
41294133 gitStatus: gitStatusQuery.data ?? null,
4134 snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
4135 retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
41304136 });
41314137 // The right panel offers the thread's own change request, so it can only offer it once the
41324138 // branch has one; until then the picker says so rather than opening an empty panel.
......@@ -4208,6 +4214,7 @@ function ChatViewContent(props: ChatViewProps) {
42084214 activeThreadShell,
42094215 autoSettleAfterDays,
42104216 autoSettleOnMerge,
4217 changeRequestSnapshotByKey,
42114218 nowMinute,
42124219 supportsSettlement,
42134220 ]);
apps/web/src/components/Sidebar.tsx+49-32
......@@ -143,10 +143,15 @@ import {
143143import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
144144import {
145145 ThreadWorktreeIndicator,
146 nextThreadChangeRequestSnapshot,
146147 prStatusIndicator,
147 resolveThreadPr,
148 resolveDisplayedThreadPr,
149 resolveDisplayedThreadPrProvider,
150 setThreadChangeRequestSnapshot,
148151 settledPrHoverColorClass,
149152 terminalStatusFromRunningIds,
153 threadChangeRequestSnapshotsAtom,
154 type ThreadChangeRequestSnapshot,
150155 type TerminalStatusIndicator,
151156} from "./ThreadStatusIndicators";
152157import {
......@@ -729,11 +734,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
729734 onUnsnooze: (threadRef: ScopedThreadRef) => void;
730735 onUnpin: (threadRef: ScopedThreadRef) => void;
731736 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;
733742}) {
734743 const {
735744 isRenaming,
736 onChangeRequestState,
745 changeRequestSnapshot,
746 onChangeRequestSnapshot,
737747 onCancelRename,
738748 onCommitRename,
739749 onContextMenu,
......@@ -778,9 +788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
778788 })
779789 : null,
780790 );
781 const pr = resolveThreadPr({
791 const retainTerminalOnBranchMismatch = thread.worktreePath === null;
792 const pr = resolveDisplayedThreadPr({
782793 threadBranch: thread.branch,
783794 gitStatus: gitStatus.data,
795 snapshot: changeRequestSnapshot,
796 retainTerminalOnBranchMismatch,
784797 });
785798 const prState = pr?.state ?? null;
786799
......@@ -874,13 +887,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
874887 activeThreadBranch: thread.branch,
875888 currentGitBranch: gitStatus.data?.refName ?? null,
876889 });
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);
878897 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.
881898 useEffect(() => {
882 onChangeRequestState(threadKey, prState);
883 }, [onChangeRequestState, prState, threadKey]);
899 const nextSnapshot = nextThreadChangeRequestSnapshot({
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
885916 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
886917 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
......@@ -1858,26 +1889,7 @@ export default function Sidebar() {
18581889 // fresh clock whenever it recomputes.
18591890 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
18601891
1861 // PR states stream in per-row. The next partition applies the configured
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 );
1892 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
18811893
18821894 // Project scope: one menu above the list. Scoping filters the list without
18831895 // making the header width depend on the number or length of project names.
......@@ -1993,7 +2005,11 @@ export default function Sidebar() {
19932005 const supportsSnooze =
19942006 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
19952007 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;
19972013 // Snooze outranks everything, including a pin: "hide until Tuesday"
19982014 // temporarily suspends "keep on top". The pin survives underneath —
19992015 // and so does its pinOrderKey, so on wake the thread reappears at
......@@ -2051,7 +2067,7 @@ export default function Sidebar() {
20512067 }, [
20522068 autoSettleAfterDays,
20532069 autoSettleOnMerge,
2054 changeRequestStateByKey,
2070 changeRequestSnapshotByKey,
20552071 nowMinute,
20562072 scopedProjectKeys,
20572073 serverConfigs,
......@@ -3686,7 +3702,8 @@ export default function Sidebar() {
36863702 onUnsnooze={attemptUnsnooze}
36873703 onUnpin={attemptUnpin}
36883704 onAcknowledgeWoke={acknowledgeWoke}
3689 onChangeRequestState={handleChangeRequestState}
3705 changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null}
3706 onChangeRequestSnapshot={setThreadChangeRequestSnapshot}
36903707 />
36913708 );
36923709 };
apps/web/src/components/ThreadStatusIndicators.test.ts+386-2
......@@ -1,10 +1,19 @@
1import type { VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
1import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled";
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
48import {
9 nextThreadChangeRequestSnapshot,
510 prStatusIndicator,
11 resolveDisplayedThreadPr,
12 resolveDisplayedThreadPrProvider,
613 resolveThreadPr,
714 settledPrHoverColorClass,
15 threadChangeRequestSnapshotsAtom,
16 type ThreadChangeRequestSnapshot,
817} from "./ThreadStatusIndicators";
918
1019function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
......@@ -30,6 +39,25 @@ function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
3039 };
3140}
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
3361describe("resolveThreadPr", () => {
3462 it("keeps local-checkout PR indicators scoped to the stored thread branch", () => {
3563 expect(
......@@ -70,6 +98,362 @@ describe("resolveThreadPr", () => {
7098 });
7199});
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
73457describe("prStatusIndicator", () => {
74458 it("formats PR tooltips with number, uppercase status, and title", () => {
75459 expect(prStatusIndicator(status().pr, undefined)).toMatchObject({
apps/web/src/components/ThreadStatusIndicators.tsx+174
......@@ -4,8 +4,10 @@ import {
44 scopeThreadRef,
55} from "@t3tools/client-runtime/environment";
66import type { VcsStatusResult } from "@t3tools/contracts";
7import { Atom } from "effect/unstable/reactivity";
78import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
89import { useMemo } from "react";
10import { appAtomRegistry } from "../rpc/atomRegistry";
911import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1012import { useProject } from "../state/entities";
1113import { useEnvironmentQuery } from "../state/query";
......@@ -126,6 +128,178 @@ export function resolveThreadPr(input: {
126128 return gitStatus.pr ?? null;
127129}
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
129303export function terminalStatusFromRunningIds(
130304 runningTerminalIds: ReadonlyArray<string>,
131305): TerminalStatusIndicator | null {