From 4ca41bb1cecca954dbe9aa173a59ec8f5b1ca97b Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:51:25 +0800 Subject: [PATCH 1/7] fix: wait for PR status before inactivity auto-settle --- apps/mobile/src/features/home/HomeScreen.tsx | 9 +- .../threads/ThreadNavigationSidebar.tsx | 9 +- .../features/threads/thread-list-v2-items.tsx | 23 +- .../src/features/threads/threadListV2.test.ts | 33 ++ .../src/features/threads/threadListV2.ts | 11 +- apps/mobile/src/state/use-thread-pr.ts | 30 +- apps/server/src/git/GitManager.test.ts | 2 + apps/server/src/git/GitManager.ts | 16 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 318 +++++++++++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 160 +++++++-- apps/web/src/components/ChatView.tsx | 16 +- apps/web/src/components/SidebarV2.tsx | 25 +- docs/user/source-control.md | 1 + .../src/state/threadSettled.test.ts | 66 ++++ .../client-runtime/src/state/threadSettled.ts | 29 +- packages/client-runtime/src/state/vcs.ts | 5 +- packages/contracts/src/git.ts | 3 + packages/shared/src/git.test.ts | 130 ++++++- packages/shared/src/git.ts | 55 ++- 19 files changed, 833 insertions(+), 108 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d54fb7d4890..0f64b033deb 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -11,6 +11,7 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import type { ChangeRequestSettlementState } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -481,14 +482,14 @@ export function HomeScreen(props: HomeScreenProps) { // PR states stream in per-row (rows own the VCS subscriptions); a merged or // closed PR auto-settles its thread on the next partition (mirrors web). const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { + (threadKey: string, state: ChangeRequestSettlementState) => { setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; + if ((current.get(threadKey) ?? "unknown") === state) return current; const next = new Map(current); - if (state === null) { + if (state === "unknown") { next.delete(threadKey); } else { next.set(threadKey, state); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 8a7fc2ed6df..cc362861595 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -7,6 +7,7 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; +import type { ChangeRequestSettlementState } from "@t3tools/client-runtime/state/thread-settled"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; @@ -407,14 +408,14 @@ function ThreadNavigationSidebarPane( // PR states stream in per-row; merged/closed PRs auto-settle their thread // on the next partition. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { + (threadKey: string, state: ChangeRequestSettlementState) => { setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; + if ((current.get(threadKey) ?? "unknown") === state) return current; const next = new Map(current); - if (state === null) { + if (state === "unknown") { next.delete(threadKey); } else { next.set(threadKey, state); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 8d6874c7855..bb079677166 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,7 +3,11 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; +import { + canSnooze, + type ChangeRequestSettlementState, + resolveSnoozePresets, +} from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { @@ -25,7 +29,7 @@ import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { useThreadPr } from "../../state/use-thread-pr"; +import { useThreadPrLookup } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2SnoozeMenuSelection, @@ -346,10 +350,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle merged/closed work (mirrors web's onChangeRequestState). */ - readonly onChangeRequestState?: ( - threadKey: string, - state: "open" | "closed" | "merged" | null, - ) => void; + readonly onChangeRequestState?: (threadKey: string, state: ChangeRequestSettlementState) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -372,12 +373,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } = props; const snoozedRow = props.snoozed === true; - const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); - const prState = pr?.state ?? null; + const { changeRequestState, pr } = useThreadPrLookup( + thread, + props.projectCwd ?? props.project?.workspaceRoot ?? null, + ); const threadKey = `${thread.environmentId}:${thread.id}`; useEffect(() => { - onChangeRequestState?.(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + onChangeRequestState?.(threadKey, changeRequestState); + }, [changeRequestState, onChangeRequestState, threadKey]); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 99b5700f7b0..12bc74448c5 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -457,6 +457,39 @@ describe("buildThreadListV2Items", () => { expect(layout.settledShelfHeaderIndex).toBe(1); }); + it("keeps stale branch threads active until their PR state is known", () => { + const thread = makeThread({ + id: ThreadId.make("unknown-pr"), + title: "Unknown PR", + branch: "feature/unknown-pr", + latestUserMessageAt: "2026-05-01T00:00:00.000Z", + }); + const unresolved = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(unresolved.items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["unknown-pr", "card"], + ]); + expect(unresolved.settledCount).toBe(0); + + const confirmedNoPr = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + changeRequestStateByKey: new Map([[`${environmentId}:${thread.id}`, "none"]]), + now: NOW, + }); + + expect(confirmedNoPr.items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["unknown-pr", "slim"], + ]); + expect(confirmedNoPr.settledCount).toBe(1); + }); + it("collapses settled threads to a counted shelf header", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index c88aff4ec02..f938028541c 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -6,7 +6,10 @@ import { resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; -import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +import type { + ChangeRequestSettlementState, + SnoozePreset, +} from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -317,7 +320,7 @@ export function buildThreadListV2Items(input: { readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestStateByKey?: ReadonlyMap; + readonly changeRequestStateByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -377,7 +380,9 @@ export function buildThreadListV2Items(input: { const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = - input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + thread.branch === null + ? "none" + : (input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? "unknown"); // Visibility parity with web: a snoozed thread leaves the list until it // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed // work). Snooze outranks settled classification, same as web. diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index a3440cd4848..fb2581b31d7 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { resolveChangeRequestSettlementState } from "@t3tools/client-runtime/state/thread-settled"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; @@ -16,10 +17,7 @@ export { * or project root share one stream — and virtualization means only visible * rows subscribe at all. */ -export function useThreadPr( - thread: EnvironmentThreadShell, - projectCwd: string | null, -): ThreadPrPresentation | null { +export function useThreadPrLookup(thread: EnvironmentThreadShell, projectCwd: string | null) { const cwd = thread.worktreePath ?? projectCwd; const gitStatus = useEnvironmentQuery( thread.branch !== null && cwd !== null @@ -31,11 +29,21 @@ export function useThreadPr( ); const status = gitStatus.data; - if (status === null || thread.branch === null || status.refName !== thread.branch) { - return null; - } - if (!status.pr) { - return null; - } - return presentThreadPr(status.pr, status.sourceControlProvider); + const changeRequestState = resolveChangeRequestSettlementState({ + threadBranch: thread.branch, + gitStatus: status, + gitStatusError: gitStatus.error, + }); + const pr = + status !== null && thread.branch !== null && status.refName === thread.branch && status.pr + ? presentThreadPr(status.pr, status.sourceControlProvider) + : null; + return { changeRequestState, pr }; +} + +export function useThreadPr( + thread: EnvironmentThreadShell, + projectCwd: string | null, +): ThreadPrPresentation | null { + return useThreadPrLookup(thread, projectCwd).pr; } diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 695c7b76f64..2c80fd185bf 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1315,6 +1315,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.refName).toBe("feature/status-no-gh"); expect(status.pr).toBeNull(); + expect(status.prLookupFailed).toBe(true); }), ); @@ -1356,6 +1357,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* manager.invalidateStatus(repoDir); const second = yield* manager.status({ cwd: repoDir }); expect(second.pr?.number).toBe(214); + expect(second.prLookupFailed).toBe(true); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index da002df5e6c..e1f680c8111 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -995,7 +995,7 @@ export const make = Effect.gen(function* () { }), ), ), - Effect.map(({ pr }) => pr), + Effect.map(({ pr }) => ({ pr, lookupFailed: false as const })), Effect.catch((error) => Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( Effect.annotateLogs({ @@ -1007,14 +1007,15 @@ export const make = Effect.gen(function* () { : typeof error, }), Effect.andThen(resolveBranchHeadContext(cwd, details)), - Effect.map((headContext) => - resolveLastKnownPr(branchKey, { + Effect.map((headContext) => ({ + pr: resolveLastKnownPr(branchKey, { upstreamRef: details.upstreamRef, headBranch: headContext.headBranch, remoteName: headContext.remoteName, headRemoteUrlKey: headContext.headRemoteUrlKey, }), - ), + lookupFailed: true as const, + })), ), ), ); @@ -1030,21 +1031,22 @@ export const make = Effect.gen(function* () { return null; } - const pr = + const prLookup = details.branch !== null ? yield* lookupStatusPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, isDefaultBranch: details.isDefaultBranch, }) - : null; + : { pr: null, lookupFailed: false as const }; return { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, aheadOfDefaultCount: details.aheadOfDefaultCount, - pr, + pr: prLookup.pr, + ...(prLookup.lookupFailed ? { prLookupFailed: true } : {}), } satisfies VcsStatusRemoteResult; }); const remoteStatusResultCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus(cwd), { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 6820a29e2c8..8f1ff38fb50 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -75,6 +75,10 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; + failRemoteStatus?: boolean; + blockRemoteStatusAtCall?: number; + remoteStatusStarted?: Deferred.Deferred | null; + remoteStatusRelease?: Deferred.Deferred | null; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -87,9 +91,27 @@ function makeTestLayer(state: { return state.currentLocalStatus; }), remoteStatus: (_input, options) => - Effect.sync(() => { + Effect.gen(function* () { state.remoteStatusCalls += 1; state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); + if ( + state.blockRemoteStatusAtCall !== undefined && + state.remoteStatusCalls === state.blockRemoteStatusAtCall && + state.remoteStatusStarted !== null && + state.remoteStatusStarted !== undefined && + state.remoteStatusRelease !== null && + state.remoteStatusRelease !== undefined + ) { + yield* Deferred.succeed(state.remoteStatusStarted, undefined); + yield* Deferred.await(state.remoteStatusRelease); + } + if (state.failRemoteStatus === true) { + return yield* new GitManagerError({ + operation: "VcsStatusBroadcaster.test", + cwd: "/repo", + detail: "remote status failed", + }); + } return state.currentRemoteStatus; }), invalidateLocalStatus: () => @@ -410,10 +432,12 @@ describe("VcsStatusBroadcaster", () => { _tag: "snapshot", local: baseLocalStatus, remote: null, + remoteLoaded: false, } satisfies VcsStatusStreamEvent); assert.deepStrictEqual(remoteUpdated, { _tag: "remoteUpdated", remote: baseRemoteStatus, + remoteLoaded: true, } satisfies VcsStatusStreamEvent); }).pipe(Effect.provide(makeTestLayer(state))); }); @@ -457,10 +481,12 @@ describe("VcsStatusBroadcaster", () => { _tag: "snapshot", local: baseLocalStatus, remote: null, + remoteLoaded: false, } satisfies VcsStatusStreamEvent); assert.deepStrictEqual(remoteUpdated, { _tag: "remoteUpdated", remote: remoteStatusWithPr, + remoteLoaded: true, } satisfies VcsStatusStreamEvent); assert.equal(state.remoteStatusCalls, 1); assert.equal(state.remoteInvalidationCalls, 0); @@ -578,6 +604,7 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(remoteUpdated, { _tag: "remoteUpdated", remote: remoteStatusWithPr, + remoteLoaded: true, } satisfies VcsStatusStreamEvent); assert.equal(state.remoteStatusCalls, 2); assert.equal(state.remoteInvalidationCalls, 0); @@ -598,7 +625,7 @@ describe("VcsStatusBroadcaster", () => { it.effect("delays automatic refresh when a cached remote snapshot is available", () => { const state = { currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, + currentRemoteStatus: null, localStatusCalls: 0, remoteStatusCalls: 0, localInvalidationCalls: 0, @@ -621,7 +648,13 @@ describe("VcsStatusBroadcaster", () => { : Effect.void, ).pipe(Effect.forkIn(scope)); - yield* Deferred.await(snapshotDeferred); + const snapshot = yield* Deferred.await(snapshotDeferred); + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + remoteLoaded: true, + } satisfies VcsStatusStreamEvent); assert.equal(state.remoteStatusCalls, 1); assert.equal(state.remoteInvalidationCalls, 0); @@ -637,6 +670,285 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); + it.effect("marks cached remote status unresolved across refresh failures", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + failRemoteStatus: false, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + const unavailableDeferred = yield* Deferred.make(); + const recoveredDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, + ), + (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag !== "remoteUpdated") return Effect.void; + return event.remoteLoaded === false + ? Deferred.succeed(unavailableDeferred, event).pipe(Effect.ignore) + : Deferred.succeed(recoveredDeferred, event).pipe(Effect.ignore); + }, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(snapshotDeferred); + state.failRemoteStatus = true; + yield* TestClock.adjust(Duration.minutes(1)); + yield* Effect.yieldNow; + const unavailable = yield* Deferred.poll(unavailableDeferred); + assert.isTrue(Option.isSome(unavailable)); + if (Option.isSome(unavailable)) { + assert.deepStrictEqual(yield* Deferred.await(unavailableDeferred), { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + remoteLoaded: false, + } satisfies VcsStatusStreamEvent); + } + + state.failRemoteStatus = false; + yield* TestClock.adjust(Duration.minutes(1)); + yield* Effect.yieldNow; + const recovered = yield* Deferred.poll(recoveredDeferred); + assert.isTrue(Option.isSome(recovered)); + if (Option.isSome(recovered)) { + assert.deepStrictEqual(yield* Deferred.await(recoveredDeferred), { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + remoteLoaded: true, + } satisfies VcsStatusStreamEvent); + } + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + + it.effect("keeps provider-level PR lookup fallbacks unresolved", () => { + const lookupFallback = { + ...baseRemoteStatus, + prLookupFailed: true, + } satisfies VcsStatusRemoteResult; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: lookupFallback as VcsStatusRemoteResult, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const unavailableDeferred = yield* Deferred.make(); + const recoveredDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + (event) => { + if (event._tag !== "remoteUpdated") return Effect.void; + return event.remoteLoaded === false + ? Deferred.succeed(unavailableDeferred, event).pipe(Effect.ignore) + : Deferred.succeed(recoveredDeferred, event).pipe(Effect.ignore); + }, + ).pipe(Effect.forkScoped); + + assert.deepStrictEqual(yield* Deferred.await(unavailableDeferred), { + _tag: "remoteUpdated", + remote: lookupFallback, + remoteLoaded: false, + } satisfies VcsStatusStreamEvent); + + state.currentRemoteStatus = baseRemoteStatus; + yield* TestClock.adjust(Duration.seconds(30)); + assert.deepStrictEqual(yield* Deferred.await(recoveredDeferred), { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + remoteLoaded: true, + } satisfies VcsStatusStreamEvent); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + + it.effect("marks cached remote status unresolved when an explicit refresh fails", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + failRemoteStatus: false, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const snapshotDeferred = yield* Deferred.make(); + const unavailableDeferred = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + return event._tag === "remoteUpdated" && event.remoteLoaded === false + ? Deferred.succeed(unavailableDeferred, event).pipe(Effect.ignore) + : Effect.void; + }).pipe(Effect.forkScoped); + + yield* Deferred.await(snapshotDeferred); + state.failRemoteStatus = true; + const refreshExit = yield* broadcaster.refreshStatus("/repo").pipe(Effect.exit); + assert.isTrue(Exit.isFailure(refreshExit)); + assert.deepStrictEqual(yield* Deferred.await(unavailableDeferred), { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + remoteLoaded: false, + } satisfies VcsStatusStreamEvent); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("drops a full refresh result when the local ref changes in flight", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + blockRemoteStatusAtCall: 2, + remoteStatusStarted: null as Deferred.Deferred | null, + remoteStatusRelease: null as Deferred.Deferred | null, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + (event) => + event._tag === "snapshot" + ? Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(scope)); + yield* Deferred.await(snapshotDeferred); + + state.remoteStatusStarted = yield* Deferred.make(); + state.remoteStatusRelease = yield* Deferred.make(); + const refreshDone = yield* Deferred.make(); + yield* broadcaster + .refreshStatus("/repo") + .pipe( + Effect.ensuring(Deferred.succeed(refreshDone, undefined).pipe(Effect.ignore)), + Effect.forkIn(scope), + ); + yield* Deferred.await(state.remoteStatusStarted); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/new-ref", + }; + yield* broadcaster.refreshLocalStatus("/repo"); + yield* Deferred.succeed(state.remoteStatusRelease, undefined); + yield* Deferred.await(refreshDone); + + const latestSnapshot = yield* Stream.runHead( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + ); + assert.isTrue(Option.isSome(latestSnapshot)); + if (Option.isSome(latestSnapshot)) { + assert.deepStrictEqual(latestSnapshot.value, { + _tag: "snapshot", + local: state.currentLocalStatus, + remote: baseRemoteStatus, + remoteLoaded: false, + } satisfies VcsStatusStreamEvent); + } + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("ignores a stale refresh failure after the new ref has loaded", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + failRemoteStatus: false, + blockRemoteStatusAtCall: 2, + remoteStatusStarted: null as Deferred.Deferred | null, + remoteStatusRelease: null as Deferred.Deferred | null, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const scope = yield* Scope.make(); + state.remoteStatusStarted = yield* Deferred.make(); + state.remoteStatusRelease = yield* Deferred.make(); + const staleRefreshDone = yield* Deferred.make(); + yield* broadcaster + .refreshStatus("/repo") + .pipe( + Effect.ensuring(Deferred.succeed(staleRefreshDone, undefined).pipe(Effect.ignore)), + Effect.forkIn(scope), + ); + yield* Deferred.await(state.remoteStatusStarted); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/new-ref", + }; + yield* broadcaster.refreshLocalStatus("/repo"); + yield* broadcaster.refreshStatus("/repo"); + + state.failRemoteStatus = true; + yield* Deferred.succeed(state.remoteStatusRelease, undefined); + yield* Deferred.await(staleRefreshDone); + state.failRemoteStatus = false; + + const latestSnapshot = yield* Stream.runHead( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + ); + assert.isTrue(Option.isSome(latestSnapshot)); + if (Option.isSome(latestSnapshot)) { + assert.deepStrictEqual(latestSnapshot.value, { + _tag: "snapshot", + local: state.currentLocalStatus, + remote: baseRemoteStatus, + remoteLoaded: true, + } satisfies VcsStatusStreamEvent); + } + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index f28069f6d8b..64f18922a84 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -127,8 +127,17 @@ interface CachedValue { interface CachedVcsStatus { readonly local: CachedValue | null; readonly remote: CachedValue | null; + readonly remoteLoaded: boolean; + readonly refGeneration: number; } +const EMPTY_CACHED_VCS_STATUS: CachedVcsStatus = { + local: null, + remote: null, + remoteLoaded: false, + refGeneration: 0, +}; + interface ActiveRemotePoller { readonly fiber: Fiber.Fiber; readonly subscriberCount: number; @@ -207,11 +216,14 @@ export const make = Effect.gen(function* () { value: local, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; + const previous = cache.get(cwd) ?? EMPTY_CACHED_VCS_STATUS; + const refChanged = previous.local?.value.refName !== local.refName; const nextCache = new Map(cache); nextCache.set(cwd, { ...previous, local: nextLocal, + remoteLoaded: refChanged ? false : previous.remoteLoaded, + refGeneration: refChanged ? previous.refGeneration + 1 : previous.refGeneration, }); return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; }); @@ -231,32 +243,100 @@ export const make = Effect.gen(function* () { ); const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( - function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { + function* ( + cwd: string, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean; expectedRefGeneration?: number }, + ) { const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - remote: nextRemote, - }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; - }); + const remoteLoaded = remote?.prLookupFailed !== true; + const update = yield* Ref.modify( + cacheRef, + ( + cache, + ): readonly [ + { readonly accepted: boolean; readonly shouldPublish: boolean }, + Map, + ] => { + const previous = cache.get(cwd) ?? EMPTY_CACHED_VCS_STATUS; + if ( + options?.expectedRefGeneration !== undefined && + previous.refGeneration !== options.expectedRefGeneration + ) { + return [{ accepted: false, shouldPublish: false }, cache]; + } + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + remoteLoaded, + }); + return [ + { + accepted: true, + shouldPublish: + previous.remote?.fingerprint !== nextRemote.fingerprint || + previous.remoteLoaded !== remoteLoaded, + }, + nextCache, + ]; + }, + ); - if (options?.publish && shouldPublish) { + if (options?.publish && update.shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, event: { _tag: "remoteUpdated", remote, + remoteLoaded, }, }); } - return remote; + return { loaded: update.accepted && remoteLoaded, remote }; + }, + ); + + const markCachedRemoteUnavailable = Effect.fn("VcsStatusBroadcaster.markCachedRemoteUnavailable")( + function* (cwd: string, expectedRefGeneration?: number) { + const transition = yield* Ref.modify( + cacheRef, + ( + cache, + ): readonly [ + { readonly changed: boolean; readonly remote: VcsStatusRemoteResult | null }, + Map, + ] => { + const previous = cache.get(cwd) ?? EMPTY_CACHED_VCS_STATUS; + if ( + expectedRefGeneration !== undefined && + previous.refGeneration !== expectedRefGeneration + ) { + return [{ changed: false, remote: null }, cache]; + } + if (!previous.remoteLoaded) { + return [{ changed: false, remote: null }, cache]; + } + const nextCache = new Map(cache); + nextCache.set(cwd, { ...previous, remoteLoaded: false }); + return [{ changed: true, remote: previous.remote?.value ?? null }, nextCache]; + }, + ); + + if (transition.changed) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote: transition.remote, + remoteLoaded: false, + }, + }); + } }, ); @@ -264,7 +344,7 @@ export const make = Effect.gen(function* () { cwd: string, local: VcsStatusLocalResult, remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, + options?: { publish?: boolean; expectedRefGeneration?: number }, ) { const nextLocal = { fingerprint: fingerprintStatusPart(local), @@ -274,16 +354,27 @@ export const make = Effect.gen(function* () { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; + const remoteLoaded = remote?.prLookupFailed !== true; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; + const previous = cache.get(cwd) ?? EMPTY_CACHED_VCS_STATUS; + if ( + options?.expectedRefGeneration !== undefined && + previous.refGeneration !== options.expectedRefGeneration + ) { + return [false, cache] as const; + } + const refChanged = previous.local?.value.refName !== local.refName; const nextCache = new Map(cache); nextCache.set(cwd, { local: nextLocal, remote: nextRemote, + remoteLoaded, + refGeneration: refChanged ? previous.refGeneration + 1 : previous.refGeneration, }); return [ previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, + previous.remote?.fingerprint !== nextRemote.fingerprint || + previous.remoteLoaded !== remoteLoaded, nextCache, ] as const; }); @@ -295,6 +386,7 @@ export const make = Effect.gen(function* () { _tag: "snapshot", local, remote, + remoteLoaded, }, }); } @@ -358,25 +450,35 @@ export const make = Effect.gen(function* () { cwd: string, options?: { readonly refreshUpstream?: boolean }, ) { - if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); - } - const remote = yield* workflow.remoteStatus({ cwd }, options); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + const expectedRefGeneration = (yield* getCachedStatus(cwd))?.refGeneration ?? 0; + return yield* Effect.gen(function* () { + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); + return yield* updateCachedRemoteStatus(cwd, remote, { + publish: true, + expectedRefGeneration, + }); + }).pipe(Effect.tapError(() => markCachedRemoteUnavailable(cwd, expectedRefGeneration))); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + const expectedRefGeneration = (yield* getCachedStatus(cwd))?.refGeneration ?? 0; // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. yield* workflow.invalidateStatus(cwd); const [local, remote] = yield* Effect.all( [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + ).pipe(Effect.tapError(() => markCachedRemoteUnavailable(cwd, expectedRefGeneration))); + return yield* updateCachedStatus(cwd, local, remote, { + publish: true, + expectedRefGeneration, + }); }); const makeRemoteRefreshLoop = ( @@ -393,7 +495,11 @@ export const make = Effect.gen(function* () { const activeInterval = Duration.isZero(configuredInterval) ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL : configuredInterval; - const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); + const cachedRemoteLoaded = (yield* getCachedStatus(cwd))?.remoteLoaded ?? false; + const needsInitialRefresh = (yield* Ref.get(needsInitialRefreshRef)) || !cachedRemoteLoaded; + if (needsInitialRefresh) { + yield* Ref.set(needsInitialRefreshRef, true); + } if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { return activeInterval; } @@ -418,7 +524,7 @@ export const make = Effect.gen(function* () { refreshUpstream: !Duration.isZero(configuredInterval), }).pipe(Effect.exit); if (Exit.isSuccess(exit)) { - yield* Ref.set(needsInitialRefreshRef, false); + yield* Ref.set(needsInitialRefreshRef, !exit.value.loaded); yield* Ref.set(consecutiveFailuresRef, 0); return activeInterval; } @@ -561,12 +667,13 @@ export const make = Effect.gen(function* () { const initialLocal = yield* getOrLoadLocalStatus(cwd); const cachedStatus = yield* getCachedStatus(cwd); const initialRemote = cachedStatus?.remote?.value ?? null; + const initialRemoteLoaded = cachedStatus?.remoteLoaded ?? false; yield* retainRemotePoller( cwd, input.cwd, options?.automaticRemoteRefreshInterval ?? Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), - cachedStatus?.remote === null || cachedStatus?.remote === undefined, + !initialRemoteLoaded, ); const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid); @@ -576,6 +683,7 @@ export const make = Effect.gen(function* () { _tag: "snapshot" as const, local: initialLocal, remote: initialRemote, + remoteLoaded: initialRemoteLoaded, }), Stream.fromSubscription(subscription).pipe( Stream.filter((event) => event.cwd === cwd), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..a8e265daca8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,7 +25,11 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + effectiveSettled, + effectiveSnoozed, + resolveChangeRequestSettlementState, +} from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -236,7 +240,6 @@ import { shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { @@ -3962,9 +3965,10 @@ 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 activeThreadChangeRequestState = resolveChangeRequestSettlementState({ threadBranch: activeThread?.branch ?? null, - gitStatus: gitStatusQuery.data ?? null, + gitStatus: gitStatusQuery.data, + gitStatusError: gitStatusQuery.error, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; @@ -3990,10 +3994,10 @@ function ChatViewContent(props: ChatViewProps) { return effectiveSettled(activeThreadShell, { now: `${nowMinute}:00.000Z`, autoSettleAfterDays, - changeRequestState: activeThreadPr?.state ?? null, + changeRequestState: activeThreadChangeRequestState, }); }, [ - activeThreadPr?.state, + activeThreadChangeRequestState, activeThreadShell, autoSettleAfterDays, nowMinute, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ee73b570514..c2d21e0f642 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -2,8 +2,10 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { canSnooze, + type ChangeRequestSettlementState, effectiveSettled, effectiveSnoozed, + resolveChangeRequestSettlementState, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; @@ -419,7 +421,7 @@ 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; + onChangeRequestState: (threadKey: string, state: ChangeRequestSettlementState) => void; }) { const { isRenaming, @@ -545,10 +547,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { 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; + const changeRequestState = resolveChangeRequestSettlementState({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + gitStatusError: gitStatus.error, + }); useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + onChangeRequestState(threadKey, changeRequestState); + }, [changeRequestState, onChangeRequestState, threadKey]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1350,14 +1356,14 @@ export default function SidebarV2() { // 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 + ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { + (threadKey: string, state: ChangeRequestSettlementState) => { setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; + if ((current.get(threadKey) ?? "unknown") === state) return current; const next = new Map(current); - if (state === null) { + if (state === "unknown") { next.delete(threadKey); } else { next.set(threadKey, state); @@ -1584,7 +1590,8 @@ 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 changeRequestState = + thread.branch === null ? "none" : (changeRequestStateByKey.get(threadKey) ?? "unknown"); // 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/docs/user/source-control.md b/docs/user/source-control.md index 6d81d2b33ab..9bed09f54be 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -38,6 +38,7 @@ T3 Code works with the platforms your team already uses: **Stay on top of open reviews** - See if your current branch already has an open PR/MR +- Sidebar v2 keeps review threads active while their PR/MR status is loading - Open the review directly in your browser with one click - Check out a teammate's branch to review code locally diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index a7dd4b1eab8..4d551304f8b 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -11,6 +11,7 @@ import { canSettle, effectiveSettled, hasQueuedTurnStart, + resolveChangeRequestSettlementState, threadLastActivityAt, type ChangeRequestStateLike, } from "./threadSettled.ts"; @@ -91,6 +92,57 @@ describe("threadLastActivityAt", () => { }); }); +describe("resolveChangeRequestSettlementState", () => { + it("distinguishes an unresolved lookup from a confirmed missing or matching PR", () => { + expect( + resolveChangeRequestSettlementState({ + threadBranch: "feature/unknown-pr", + gitStatus: null, + gitStatusError: null, + }), + ).toBe("unknown"); + expect( + resolveChangeRequestSettlementState({ + threadBranch: "feature/unknown-pr", + gitStatus: { refName: "feature/unknown-pr", pr: null, remoteLoaded: false }, + gitStatusError: null, + }), + ).toBe("unknown"); + expect( + resolveChangeRequestSettlementState({ + threadBranch: "feature/unknown-pr", + gitStatus: { refName: "feature/unknown-pr", pr: null, remoteLoaded: true }, + gitStatusError: null, + }), + ).toBe("none"); + expect( + resolveChangeRequestSettlementState({ + threadBranch: "feature/unknown-pr", + gitStatus: { refName: "feature/unknown-pr", pr: null, remoteLoaded: true }, + gitStatusError: "remote lookup failed", + }), + ).toBe("unknown"); + expect( + resolveChangeRequestSettlementState({ + threadBranch: "feature/unknown-pr", + gitStatus: { + refName: "feature/unknown-pr", + pr: { + number: 123, + title: "Fix unknown PR settlement", + url: "https://github.com/pingdotgg/t3code/pull/123", + baseRef: "main", + headRef: "feature/unknown-pr", + state: "open", + }, + remoteLoaded: true, + }, + gitStatusError: null, + }), + ).toBe("open"); + }); +}); + describe("effectiveSettled", () => { const overrideCases = [null, "settled", "active"] as const; const changeRequestStates = [undefined, "open", "merged"] as const; @@ -198,6 +250,20 @@ describe("effectiveSettled", () => { ).toBe(true); }); + it("defaults a branch thread to active while its change request state is unknown", () => { + const stale = { + ...makeShell({ activityAt: STALE }), + branch: "feature/unknown-pr", + }; + + expect( + effectiveSettled(stale, { + now: NOW, + autoSettleAfterDays: 3, + }), + ).toBe(false); + }); + it("keeps an explicitly un-settled merged-PR thread active", () => { const shell = makeShell({ settledOverride: "active", diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 595b1303bea..22223999da8 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,7 +1,23 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. -import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import type { OrchestrationThreadShell, VcsStatusResult } from "@t3tools/contracts"; export type ChangeRequestStateLike = "open" | "closed" | "merged"; +export type ChangeRequestSettlementState = ChangeRequestStateLike | "none" | "unknown"; + +export function resolveChangeRequestSettlementState(input: { + readonly threadBranch: string | null; + readonly gitStatus: + | (Pick & { readonly remoteLoaded: boolean }) + | null; + readonly gitStatusError: string | null; +}) { + if (input.threadBranch === null) return "none" as const; + if (input.gitStatusError !== null) return "unknown" as const; + if (input.gitStatus === null) return "unknown" as const; + if (input.gitStatus.refName !== input.threadBranch) return "none" as const; + if (!input.gitStatus.remoteLoaded) return "unknown" as const; + return input.gitStatus.pr?.state ?? ("none" as const); +} const DAY_MS = 24 * 60 * 60 * 1_000; @@ -232,7 +248,7 @@ export function effectiveSettled( options: { readonly now: string; readonly autoSettleAfterDays: number | null; - readonly changeRequestState?: ChangeRequestStateLike | null; + readonly changeRequestState?: ChangeRequestSettlementState | null; }, ): boolean { // Blocked work must remain visible even when a user explicitly settled it. @@ -258,14 +274,19 @@ export function effectiveSettled( // "active" is the explicit keep-active pin: it suppresses auto-settle // until real activity clears it server-side. if (shell.settledOverride === "active") return false; - if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + const changeRequestState = + options.changeRequestState ?? (shell.branch === null ? "none" : "unknown"); + if (changeRequestState === "merged" || changeRequestState === "closed") { return true; } // An open PR is unfinished business regardless of how long the thread has // been quiet: review can take days, and hiding the thread would bury the // work waiting on it. Only merge/close (above) or an explicit user settle // resolves it. - if (options.changeRequestState === "open") return false; + if (changeRequestState === "open") return false; + // A branch may have an open PR whose VCS lookup has not resolved yet. + // Keep it visible until the client can distinguish that from no PR. + if (changeRequestState === "unknown") return false; if (options.autoSettleAfterDays === null) return false; const lastActivityAt = threadLastActivityAt(shell); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index a0d4510be7f..1e70896782b 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -2,10 +2,9 @@ import { type EnvironmentId, type VcsListRefsInput, type VcsListRefsResult, - type VcsStatusResult, WS_METHODS, } from "@t3tools/contracts"; -import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; +import { applyGitStatusStreamEvent, type VcsStatusStreamResult } from "@t3tools/shared/git"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -275,7 +274,7 @@ export function createVcsEnvironmentAtoms( subscribe: (input: EnvironmentRpcInput) => subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( Stream.mapAccum( - () => null as VcsStatusResult | null, + () => null as VcsStatusStreamResult | null, (current, event) => { const next = applyGitStatusStreamEvent(current, event); return [next, [next]] as const; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 2e0552740a6..3727ceb9503 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -225,6 +225,7 @@ const VcsStatusRemoteShape = { behindCount: NonNegativeInt, aheadOfDefaultCount: Schema.optional(NonNegativeInt), pr: Schema.NullOr(VcsStatusChangeRequest), + prLookupFailed: Schema.optionalKey(Schema.Boolean), }; export const VcsStatusLocalResult = Schema.Struct(VcsStatusLocalShape); @@ -243,12 +244,14 @@ export const VcsStatusStreamEvent = Schema.Union([ Schema.TaggedStruct("snapshot", { local: VcsStatusLocalResult, remote: Schema.NullOr(VcsStatusRemoteResult), + remoteLoaded: Schema.optionalKey(Schema.Boolean), }), Schema.TaggedStruct("localUpdated", { local: VcsStatusLocalResult, }), Schema.TaggedStruct("remoteUpdated", { remote: Schema.NullOr(VcsStatusRemoteResult), + remoteLoaded: Schema.optionalKey(Schema.Boolean), }), ]); export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 96539f0aae2..a1a4a9fa7cf 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,4 +1,8 @@ -import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts"; +import type { + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -102,6 +106,56 @@ describe("isTemporaryWorktreeBranch", () => { }); describe("applyGitStatusStreamEvent", () => { + it("tracks whether the initial snapshot has resolved remote status", () => { + const local = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + } satisfies VcsStatusLocalResult; + + expect( + applyGitStatusStreamEvent(null, { + _tag: "snapshot", + local, + remote: null, + remoteLoaded: false, + }), + ).toMatchObject({ remoteLoaded: false }); + expect( + applyGitStatusStreamEvent(null, { + _tag: "snapshot", + local, + remote: null, + remoteLoaded: true, + }), + ).toMatchObject({ remoteLoaded: true }); + + const remote: VcsStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + expect( + applyGitStatusStreamEvent(null, { + _tag: "snapshot", + local, + remote, + }), + ).toMatchObject({ remoteLoaded: true }); + expect( + applyGitStatusStreamEvent(null, { + _tag: "snapshot", + local, + remote, + remoteLoaded: false, + }), + ).toMatchObject({ remoteLoaded: false }); + }); + it("treats a remote-only update as a repository when local state is missing", () => { const remote: VcsStatusRemoteResult = { hasUpstream: true, @@ -121,11 +175,32 @@ describe("applyGitStatusStreamEvent", () => { aheadCount: 2, behindCount: 1, pr: null, + remoteLoaded: true, + }); + }); + + it("marks retained remote data unresolved when its refresh fails", () => { + const remote: VcsStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + + expect( + applyGitStatusStreamEvent(null, { + _tag: "remoteUpdated", + remote, + remoteLoaded: false, + }), + ).toMatchObject({ + ...remote, + remoteLoaded: false, }); }); it("preserves local-only fields when applying a remote update", () => { - const current: VcsStatusResult = { + const current: VcsStatusResult & { readonly remoteLoaded: boolean } = { isRepo: true, sourceControlProvider: { kind: "github", @@ -145,6 +220,7 @@ describe("applyGitStatusStreamEvent", () => { aheadCount: 0, behindCount: 0, pr: null, + remoteLoaded: false, }; const remote: VcsStatusRemoteResult = { @@ -160,6 +236,56 @@ describe("applyGitStatusStreamEvent", () => { aheadCount: 2, behindCount: 1, pr: null, + remoteLoaded: true, + }); + }); + + it("invalidates remote status when a local update changes refs", () => { + const current = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/merged", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: { + number: 123, + title: "Merged work", + url: "https://github.com/pingdotgg/t3code/pull/123", + baseRef: "main", + headRef: "feature/merged", + state: "merged" as const, + }, + remoteLoaded: true, + }; + const nextLocal: VcsStatusLocalResult = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/new-work", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }; + + expect( + applyGitStatusStreamEvent(current, { _tag: "localUpdated", local: nextLocal }), + ).toMatchObject({ + refName: "feature/new-work", + pr: null, + remoteLoaded: false, + }); + expect( + applyGitStatusStreamEvent(current, { + _tag: "localUpdated", + local: { ...nextLocal, refName: current.refName, hasWorkingTreeChanges: true }, + }), + ).toMatchObject({ + refName: "feature/merged", + pr: current.pr, + remoteLoaded: true, }); }); }); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 71fe2e806cf..962f8572b6c 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -11,6 +11,10 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; + +export type VcsStatusStreamResult = VcsStatusResult & { + readonly remoteLoaded: boolean; +}; // Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` // via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly // that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads @@ -240,6 +244,7 @@ function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { ? {} : { aheadOfDefaultCount: status.aheadOfDefaultCount }), pr: status.pr, + ...(status.prLookupFailed === undefined ? {} : { prLookupFailed: status.prLookupFailed }), }; } @@ -258,28 +263,46 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { } export function applyGitStatusStreamEvent( - current: VcsStatusResult | null, + current: VcsStatusStreamResult | null, event: VcsStatusStreamEvent, -): VcsStatusResult { +): VcsStatusStreamResult { switch (event._tag) { case "snapshot": - return mergeGitStatusParts(event.local, event.remote); + return { + ...mergeGitStatusParts(event.local, event.remote), + remoteLoaded: event.remoteLoaded ?? event.remote !== null, + }; case "localUpdated": - return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null); + if (current === null || current.refName !== event.local.refName) { + return { + ...mergeGitStatusParts(event.local, null), + remoteLoaded: false, + }; + } + return { + ...mergeGitStatusParts(event.local, toRemoteStatusPart(current)), + remoteLoaded: current.remoteLoaded, + }; case "remoteUpdated": if (current === null) { - return mergeGitStatusParts( - { - isRepo: true, - hasPrimaryRemote: false, - isDefaultRef: false, - refName: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - }, - event.remote, - ); + return { + ...mergeGitStatusParts( + { + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + event.remote, + ), + remoteLoaded: event.remoteLoaded !== false, + }; } - return mergeGitStatusParts(toLocalStatusPart(current), event.remote); + return { + ...mergeGitStatusParts(toLocalStatusPart(current), event.remote), + remoteLoaded: event.remoteLoaded !== false, + }; } } From 0bf23d2c6f248fa327efc24e0f1ab384c7b0c204 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:16:56 +0800 Subject: [PATCH 2/7] fix: harden unresolved PR settlement state --- apps/mobile/src/features/home/HomeScreen.tsx | 22 +++- .../threads/ThreadNavigationSidebar.tsx | 35 +++++- .../features/threads/thread-list-v2-items.tsx | 108 ++++++++++++++++-- .../src/features/threads/threadListV2.test.ts | 86 +++++++++++++- .../src/features/threads/threadListV2.ts | 97 +++++++++++++++- apps/mobile/src/state/use-thread-pr.ts | 26 +++-- apps/mobile/src/state/vcs.ts | 7 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 90 ++++++++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 38 ++++-- apps/web/src/components/SidebarV2.tsx | 11 +- .../src/state/threadSettled.test.ts | 20 ++++ .../client-runtime/src/state/threadSettled.ts | 8 +- packages/client-runtime/src/state/vcs.test.ts | 14 +++ packages/client-runtime/src/state/vcs.ts | 2 + 14 files changed, 511 insertions(+), 53 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 0f64b033deb..72561091854 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -43,6 +43,7 @@ import { ThreadListShowMoreRow, } from "../threads/thread-list-items"; import { + ThreadListV2ChangeRequestLookupPool, ThreadListV2PendingRow, ThreadListV2Row, ThreadListV2SettledShelfHeader, @@ -479,20 +480,20 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row (rows own the VCS subscriptions); a merged or - // closed PR auto-settles its thread on the next partition (mirrors web). + // PR states stream independently of virtualized rows so every branch-backed + // thread can reach a definitive state before the partition settles it. const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: ChangeRequestSettlementState) => { + (stateKey: string, state: ChangeRequestSettlementState) => { setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? "unknown") === state) return current; + if ((current.get(stateKey) ?? "unknown") === state) return current; const next = new Map(current); if (state === "unknown") { - next.delete(threadKey); + next.delete(stateKey); } else { - next.set(threadKey, state); + next.set(stateKey, state); } return next; }); @@ -1048,6 +1049,15 @@ export function HomeScreen(props: HomeScreenProps) { if (threadListV2Enabled) { return ( + >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: ChangeRequestSettlementState) => { + (stateKey: string, state: ChangeRequestSettlementState) => { setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? "unknown") === state) return current; + if ((current.get(stateKey) ?? "unknown") === state) return current; const next = new Map(current); if (state === "unknown") { - next.delete(threadKey); + next.delete(stateKey); } else { - next.set(threadKey, state); + next.set(stateKey, state); } return next; }); @@ -1156,6 +1157,17 @@ function ThreadNavigationSidebarPane( }} /> + {threadListV2Enabled ? ( + + ) : null} + {threadListV2Enabled ? ( + + ) : null} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index bb079677166..070e57c97db 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -6,8 +6,10 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import { canSnooze, type ChangeRequestSettlementState, + resolveChangeRequestSettlementState, resolveSnoozePresets, } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { @@ -29,13 +31,19 @@ import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { useThreadPrLookup } from "../../state/use-thread-pr"; +import { useThreadPrLookup, useThreadVcsStatus } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { + buildThreadListV2ChangeRequestLookupTargets, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, + selectThreadListV2ChangeRequestLookupWindow, + THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_LIMIT, + THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_WINDOW_MS, + threadChangeRequestStateKey, + type ThreadListV2ChangeRequestLookupTarget, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -304,6 +312,94 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props ); }); +const ThreadListV2ChangeRequestLookupReporter = memo( + function ThreadListV2ChangeRequestLookupReporter(props: { + readonly target: ThreadListV2ChangeRequestLookupTarget; + readonly onChangeRequestState: (stateKey: string, state: ChangeRequestSettlementState) => void; + }) { + const { target, onChangeRequestState } = props; + const gitStatus = useThreadVcsStatus(target.environmentId, target.cwd); + useEffect(() => { + for (const thread of target.threads) { + onChangeRequestState( + threadChangeRequestStateKey(thread), + resolveChangeRequestSettlementState({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + gitStatusError: gitStatus.error, + }), + ); + } + }, [gitStatus.data, gitStatus.error, onChangeRequestState, target.threads]); + return null; + }, +); + +export const ThreadListV2ChangeRequestLookupPool = memo( + function ThreadListV2ChangeRequestLookupPool(props: { + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectRefs?: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + }> | null; + readonly projectCwdByKey: ReadonlyMap; + readonly settlementEnvironmentIds: ReadonlySet; + readonly changeRequestStateByKey: ReadonlyMap; + readonly onChangeRequestState: (stateKey: string, state: ChangeRequestSettlementState) => void; + }) { + const { + threads, + environmentId, + projectRefs, + projectCwdByKey, + settlementEnvironmentIds, + changeRequestStateByKey, + onChangeRequestState, + } = props; + const targets = useMemo( + () => + buildThreadListV2ChangeRequestLookupTargets({ + threads, + environmentId, + projectRefs, + projectCwdByKey, + settlementEnvironmentIds, + changeRequestStateByKey, + }), + [ + changeRequestStateByKey, + environmentId, + projectCwdByKey, + projectRefs, + settlementEnvironmentIds, + threads, + ], + ); + const [windowIndex, setWindowIndex] = useState(0); + useEffect(() => { + if (targets.length <= THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_LIMIT) return; + const interval = setInterval( + () => setWindowIndex((current) => current + 1), + THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_WINDOW_MS, + ); + return () => clearInterval(interval); + }, [targets.length]); + const activeTargets = useMemo( + () => selectThreadListV2ChangeRequestLookupWindow({ targets, windowIndex }), + [targets, windowIndex], + ); + + return activeTargets.map((target) => ( + + )); + }, +); + export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -348,9 +444,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR state up so the partition can auto-settle - merged/closed work (mirrors web's onChangeRequestState). */ - readonly onChangeRequestState?: (threadKey: string, state: ChangeRequestSettlementState) => void; + readonly onChangeRequestState?: (stateKey: string, state: ChangeRequestSettlementState) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -377,10 +471,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { thread, props.projectCwd ?? props.project?.workspaceRoot ?? null, ); - const threadKey = `${thread.environmentId}:${thread.id}`; + const changeRequestStateKey = threadChangeRequestStateKey(thread); useEffect(() => { - onChangeRequestState?.(threadKey, changeRequestState); - }, [changeRequestState, onChangeRequestState, threadKey]); + onChangeRequestState?.(changeRequestStateKey, changeRequestState); + }, [changeRequestState, changeRequestStateKey, onChangeRequestState]); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 12bc74448c5..f85702126ea 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { + buildThreadListV2ChangeRequestLookupTargets, buildThreadListV2Items, buildThreadListV2ListItems, resolveThreadListV2Enabled, @@ -21,7 +22,9 @@ import { resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, + selectThreadListV2ChangeRequestLookupWindow, sortThreadsForListV2, + threadChangeRequestStateKey, } from "./threadListV2"; const environmentId = EnvironmentId.make("environment-1"); @@ -480,7 +483,7 @@ describe("buildThreadListV2Items", () => { threads: [thread], environmentId: null, searchQuery: "", - changeRequestStateByKey: new Map([[`${environmentId}:${thread.id}`, "none"]]), + changeRequestStateByKey: new Map([[threadChangeRequestStateKey(thread), "none"]]), now: NOW, }); @@ -490,6 +493,87 @@ describe("buildThreadListV2Items", () => { expect(confirmedNoPr.settledCount).toBe(1); }); + it("does not reuse a PR state after a thread branch changes", () => { + const previous = makeThread({ + id: ThreadId.make("changed-branch"), + title: "Changed branch", + branch: "feature/previous", + latestUserMessageAt: "2026-05-01T00:00:00.000Z", + }); + const current = { ...previous, branch: "feature/current" }; + const layout = buildThreadListV2Items({ + threads: [current], + environmentId: null, + searchQuery: "", + changeRequestStateByKey: new Map([[threadChangeRequestStateKey(previous), "none"]]), + now: NOW, + }); + + expect(threadChangeRequestStateKey(current)).not.toBe(threadChangeRequestStateKey(previous)); + expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["changed-branch", "card"], + ]); + expect(layout.settledCount).toBe(0); + }); + + it("bounds and fairly advances off-screen PR lookup targets", () => { + const threads = Array.from({ length: 18 }, (_, index) => + makeThread({ + id: ThreadId.make(`lookup-${index}`), + title: `Lookup ${index}`, + branch: `feature/lookup-${index}`, + worktreePath: `/repo/worktrees/${index}`, + }), + ); + const targets = buildThreadListV2ChangeRequestLookupTargets({ + threads, + environmentId: null, + projectCwdByKey: new Map(), + settlementEnvironmentIds: new Set([environmentId]), + changeRequestStateByKey: new Map(), + }); + const firstBatch = selectThreadListV2ChangeRequestLookupWindow({ + targets, + windowIndex: 0, + }); + + expect(firstBatch).toHaveLength(16); + const secondBatch = selectThreadListV2ChangeRequestLookupWindow({ + targets, + windowIndex: 1, + }); + expect(secondBatch.slice(0, 2).map((target) => target.key)).toEqual( + targets.slice(16).map((target) => target.key), + ); + expect(new Set([...firstBatch, ...secondBatch].map((target) => target.key)).size).toBe(18); + }); + + it("deduplicates off-screen PR lookups by environment and cwd", () => { + const targets = buildThreadListV2ChangeRequestLookupTargets({ + threads: [ + makeThread({ + id: ThreadId.make("shared-cwd-a"), + title: "Shared cwd A", + branch: "feature/shared-a", + worktreePath: "/repo/shared", + }), + makeThread({ + id: ThreadId.make("shared-cwd-b"), + title: "Shared cwd B", + branch: "feature/shared-b", + worktreePath: "/repo/shared", + }), + ], + environmentId: null, + projectCwdByKey: new Map(), + settlementEnvironmentIds: new Set([environmentId]), + changeRequestStateByKey: new Map(), + }); + + expect(targets).toHaveLength(1); + expect(targets[0]?.threads).toHaveLength(2); + }); + it("collapses settled threads to a counted shelf header", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f938028541c..5d4d4a4cc0f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -5,6 +5,7 @@ import { QUEUED_TURN_START_GRACE_MS, resolveSnoozePresets, snoozeWakeLabel, + threadChangeRequestStateKey, } from "@t3tools/client-runtime/state/thread-settled"; import type { ChangeRequestSettlementState, @@ -16,7 +17,7 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -export { snoozeWakeLabel }; +export { snoozeWakeLabel, threadChangeRequestStateKey }; /** * Thread List v2 model, ported from the web sidebar v2 @@ -28,6 +29,8 @@ export { snoozeWakeLabel }; */ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; +export const THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_LIMIT = 16; +export const THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_WINDOW_MS = 30_000; export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; @@ -178,6 +181,94 @@ export function sortThreadsForListV2; +} + +export function threadChangeRequestLookupTargetKey( + environmentId: EnvironmentId, + cwd: string, +): string { + return JSON.stringify([environmentId, cwd]); +} + +export function buildThreadListV2ChangeRequestLookupTargets(input: { + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectRefs?: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + }> | null; + readonly projectCwdByKey: ReadonlyMap; + readonly settlementEnvironmentIds: ReadonlySet; + readonly changeRequestStateByKey: ReadonlyMap; +}): ReadonlyArray { + const projectKeys = input.projectRefs + ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) + : null; + const groups = new Map< + string, + { + environmentId: EnvironmentId; + cwd: string; + hasUnknownState: boolean; + threads: EnvironmentThreadShell[]; + } + >(); + + for (const thread of sortThreadsForListV2(input.threads)) { + if (thread.archivedAt !== null || thread.branch === null) continue; + if (!input.settlementEnvironmentIds.has(thread.environmentId)) continue; + if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; + const projectKey = `${thread.environmentId}:${thread.projectId}`; + if (projectKeys !== null && !projectKeys.has(projectKey)) continue; + const state = input.changeRequestStateByKey.get(threadChangeRequestStateKey(thread)); + if (state !== undefined && state !== "unknown" && state !== "open") continue; + const cwd = thread.worktreePath ?? input.projectCwdByKey.get(projectKey) ?? null; + if (cwd === null) continue; + const key = threadChangeRequestLookupTargetKey(thread.environmentId, cwd); + const current = groups.get(key); + if (current) { + current.threads.push(thread); + current.hasUnknownState ||= state === undefined || state === "unknown"; + } else { + groups.set(key, { + environmentId: thread.environmentId, + cwd, + hasUnknownState: state === undefined || state === "unknown", + threads: [thread], + }); + } + } + + return [...groups] + .map(([key, group]) => ({ key, ...group })) + .sort( + (left, right) => + Number(right.hasUnknownState) - Number(left.hasUnknownState) || + left.key.localeCompare(right.key), + ); +} + +export function selectThreadListV2ChangeRequestLookupWindow(input: { + readonly targets: ReadonlyArray; + readonly windowIndex: number; + readonly limit?: number; +}): ReadonlyArray { + const limit = input.limit ?? THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_LIMIT; + const count = Math.min(limit, input.targets.length); + if (count === 0) return []; + const start = (input.windowIndex * limit) % input.targets.length; + return Array.from( + { length: count }, + (_, index) => input.targets[(start + index) % input.targets.length]!, + ); +} + export interface ThreadListV2Item { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -319,7 +410,7 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ + /** PR state reported by branch-aware lookup reporters. */ readonly changeRequestStateByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither @@ -382,7 +473,7 @@ export function buildThreadListV2Items(input: { const changeRequestState = thread.branch === null ? "none" - : (input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? "unknown"); + : (input.changeRequestStateByKey?.get(threadChangeRequestStateKey(thread)) ?? "unknown"); // Visibility parity with web: a snoozed thread leaves the list until it // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed // work). Snooze outranks settled classification, same as web. diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index fb2581b31d7..1adc2627270 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,5 +1,6 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveChangeRequestSettlementState } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; @@ -11,22 +12,25 @@ export { type ThreadPrPresentation, } from "./thread-pr-presentation"; +export function useThreadVcsStatus(environmentId: EnvironmentId, cwd: string | null) { + return useEnvironmentQuery( + cwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd }, + }), + ); +} + /** * Live PR status for a thread's branch. Subscriptions are deduplicated per - * (environmentId, cwd) by the atom family, so many rows on the same worktree - * or project root share one stream — and virtualization means only visible - * rows subscribe at all. + * (environmentId, cwd) by the atom family, so visible rows and Thread List + * v2's bounded off-screen lookup pool share one stream per worktree/project. */ export function useThreadPrLookup(thread: EnvironmentThreadShell, projectCwd: string | null) { const cwd = thread.worktreePath ?? projectCwd; - const gitStatus = useEnvironmentQuery( - thread.branch !== null && cwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd }, - }) - : null, - ); + const gitStatus = useThreadVcsStatus(thread.environmentId, thread.branch === null ? null : cwd); const status = gitStatus.data; const changeRequestState = resolveChangeRequestSettlementState({ diff --git a/apps/mobile/src/state/vcs.ts b/apps/mobile/src/state/vcs.ts index dc8c251149f..d65712dfc21 100644 --- a/apps/mobile/src/state/vcs.ts +++ b/apps/mobile/src/state/vcs.ts @@ -5,5 +5,10 @@ import { import { connectionAtomRuntime } from "../connection/runtime"; -export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); +export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime, { + // Thread List v2 time-slices off-screen PR lookups. Dispose each status + // stream as soon as its last row/reporter unmounts so the pool's cap also + // bounds server pollers; web keeps the default warm-cache TTL. + statusIdleTtlMs: 0, +}); export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 8f1ff38fb50..58a004e8662 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -76,6 +76,8 @@ function makeTestLayer(state: { remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; failRemoteStatus?: boolean; + dieRemoteStatus?: boolean; + dieInvalidateStatus?: boolean; blockRemoteStatusAtCall?: number; remoteStatusStarted?: Deferred.Deferred | null; remoteStatusRelease?: Deferred.Deferred | null; @@ -112,6 +114,9 @@ function makeTestLayer(state: { detail: "remote status failed", }); } + if (state.dieRemoteStatus === true) { + return yield* Effect.die(new Error("remote status defect")); + } return state.currentRemoteStatus; }), invalidateLocalStatus: () => @@ -123,7 +128,10 @@ function makeTestLayer(state: { state.remoteInvalidationCalls += 1; }), invalidateStatus: () => - Effect.sync(() => { + Effect.gen(function* () { + if (state.dieInvalidateStatus === true) { + return yield* Effect.die(new Error("status invalidation defect")); + } state.localInvalidationCalls += 1; state.remoteInvalidationCalls += 1; }), @@ -735,6 +743,49 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); + it.effect("marks cached remote status unresolved across refresh defects", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + dieRemoteStatus: false, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const snapshotDeferred = yield* Deferred.make(); + const unavailableDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, + ), + (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + return event._tag === "remoteUpdated" && event.remoteLoaded === false + ? Deferred.succeed(unavailableDeferred, event).pipe(Effect.ignore) + : Effect.void; + }, + ).pipe(Effect.forkScoped); + + yield* Deferred.await(snapshotDeferred); + state.dieRemoteStatus = true; + yield* TestClock.adjust(Duration.minutes(1)); + yield* Effect.yieldNow; + assert.deepStrictEqual(yield* Deferred.await(unavailableDeferred), { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + remoteLoaded: false, + } satisfies VcsStatusStreamEvent); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + it.effect("keeps provider-level PR lookup fallbacks unresolved", () => { const lookupFallback = { ...baseRemoteStatus, @@ -819,6 +870,43 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("marks cached remote status unresolved when full refresh invalidation defects", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + dieInvalidateStatus: false, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const snapshotDeferred = yield* Deferred.make(); + const unavailableDeferred = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + return event._tag === "remoteUpdated" && event.remoteLoaded === false + ? Deferred.succeed(unavailableDeferred, event).pipe(Effect.ignore) + : Effect.void; + }).pipe(Effect.forkScoped); + + yield* Deferred.await(snapshotDeferred); + state.dieInvalidateStatus = true; + const refreshExit = yield* broadcaster.refreshStatus("/repo").pipe(Effect.exit); + assert.isTrue(Exit.isFailure(refreshExit)); + assert.deepStrictEqual(yield* Deferred.await(unavailableDeferred), { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + remoteLoaded: false, + } satisfies VcsStatusStreamEvent); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it.effect("drops a full refresh result when the local ref changes in flight", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 64f18922a84..542e0c58ba2 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -460,7 +460,13 @@ export const make = Effect.gen(function* () { publish: true, expectedRefGeneration, }); - }).pipe(Effect.tapError(() => markCachedRemoteUnavailable(cwd, expectedRefGeneration))); + }).pipe( + Effect.tapCause((cause) => + cause.reasons.some((reason) => !Cause.isInterruptReason(reason)) + ? markCachedRemoteUnavailable(cwd, expectedRefGeneration) + : Effect.void, + ), + ); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( @@ -468,17 +474,25 @@ export const make = Effect.gen(function* () { )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); const expectedRefGeneration = (yield* getCachedStatus(cwd))?.refGeneration ?? 0; - // invalidateStatus (not the two partial invalidations) so an explicit - // refresh also bypasses GitManager's slow PR-lookup cache. - yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, - ).pipe(Effect.tapError(() => markCachedRemoteUnavailable(cwd, expectedRefGeneration))); - return yield* updateCachedStatus(cwd, local, remote, { - publish: true, - expectedRefGeneration, - }); + return yield* Effect.gen(function* () { + // invalidateStatus (not the two partial invalidations) so an explicit + // refresh also bypasses GitManager's slow PR-lookup cache. + yield* workflow.invalidateStatus(cwd); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote, { + publish: true, + expectedRefGeneration, + }); + }).pipe( + Effect.tapCause((cause) => + cause.reasons.some((reason) => !Cause.isInterruptReason(reason)) + ? markCachedRemoteUnavailable(cwd, expectedRefGeneration) + : Effect.void, + ), + ); }); const makeRemoteRefreshLoop = ( diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index c2d21e0f642..fd9f0ead18c 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -6,6 +6,7 @@ import { effectiveSettled, effectiveSnoozed, resolveChangeRequestSettlementState, + threadChangeRequestStateKey, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; @@ -552,9 +553,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { gitStatus: gitStatus.data, gitStatusError: gitStatus.error, }); + const changeRequestStateCacheKey = threadChangeRequestStateKey(thread); useEffect(() => { - onChangeRequestState(threadKey, changeRequestState); - }, [changeRequestState, onChangeRequestState, threadKey]); + onChangeRequestState(changeRequestStateCacheKey, changeRequestState); + }, [changeRequestState, changeRequestStateCacheKey, onChangeRequestState]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1589,9 +1591,10 @@ export default function SidebarV2() { serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const changeRequestState = - thread.branch === null ? "none" : (changeRequestStateByKey.get(threadKey) ?? "unknown"); + thread.branch === null + ? "none" + : (changeRequestStateByKey.get(threadChangeRequestStateKey(thread)) ?? "unknown"); // 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/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 4d551304f8b..0ffca9d582b 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -12,6 +12,7 @@ import { effectiveSettled, hasQueuedTurnStart, resolveChangeRequestSettlementState, + threadChangeRequestStateKey, threadLastActivityAt, type ChangeRequestStateLike, } from "./threadSettled.ts"; @@ -93,6 +94,18 @@ describe("threadLastActivityAt", () => { }); describe("resolveChangeRequestSettlementState", () => { + it("keys cached PR state by branch", () => { + const thread = { + environmentId: "environment-1", + id: ThreadId.make("thread-1"), + branch: "feature/previous", + }; + + expect(threadChangeRequestStateKey(thread)).not.toBe( + threadChangeRequestStateKey({ ...thread, branch: "feature/current" }), + ); + }); + it("distinguishes an unresolved lookup from a confirmed missing or matching PR", () => { expect( resolveChangeRequestSettlementState({ @@ -115,6 +128,13 @@ describe("resolveChangeRequestSettlementState", () => { gitStatusError: null, }), ).toBe("none"); + expect( + resolveChangeRequestSettlementState({ + threadBranch: "feature/unknown-pr", + gitStatus: { refName: "feature/other-branch", pr: null, remoteLoaded: true }, + gitStatusError: null, + }), + ).toBe("unknown"); expect( resolveChangeRequestSettlementState({ threadBranch: "feature/unknown-pr", diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 22223999da8..2768f53ceb8 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -4,6 +4,12 @@ import type { OrchestrationThreadShell, VcsStatusResult } from "@t3tools/contrac export type ChangeRequestStateLike = "open" | "closed" | "merged"; export type ChangeRequestSettlementState = ChangeRequestStateLike | "none" | "unknown"; +export function threadChangeRequestStateKey( + thread: Pick & { readonly environmentId: string }, +): string { + return `${thread.environmentId}:${thread.id}:${thread.branch ?? ""}`; +} + export function resolveChangeRequestSettlementState(input: { readonly threadBranch: string | null; readonly gitStatus: @@ -14,7 +20,7 @@ export function resolveChangeRequestSettlementState(input: { if (input.threadBranch === null) return "none" as const; if (input.gitStatusError !== null) return "unknown" as const; if (input.gitStatus === null) return "unknown" as const; - if (input.gitStatus.refName !== input.threadBranch) return "none" as const; + if (input.gitStatus.refName !== input.threadBranch) return "unknown" as const; if (!input.gitStatus.remoteLoaded) return "unknown" as const; return input.gitStatus.pr?.state ?? ("none" as const); } diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0a6264c6207..32f14def6c3 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -113,6 +113,20 @@ function cacheWithRefs( }); } +it("allows clients to dispose VCS status streams immediately", () => { + const runtime = Atom.runtime(Layer.empty) as unknown as Parameters< + typeof createVcsEnvironmentAtoms + >[0]; + const atoms = createVcsEnvironmentAtoms(runtime, { statusIdleTtlMs: 0 }); + + expect( + atoms.status({ + environmentId: TARGET.environmentId, + input: { cwd: "/repo" }, + }).idleTTL, + ).toBe(0); +}); + describe("cached VCS refs", () => { it("invalidates all ref streams in the mutated environment", () => { const registry = AtomRegistry.make(); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 1e70896782b..be3753ec2f1 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -234,6 +234,7 @@ export function cachedVcsRefsChanges( export function createVcsEnvironmentAtoms( runtime: Atom.AtomRuntime, + options?: { readonly statusIdleTtlMs?: number }, ) { const listRefsByEnvironment = Atom.family((environmentId: EnvironmentId) => Atom.family((inputKey: string) => { @@ -271,6 +272,7 @@ export function createVcsEnvironmentAtoms( listRefs, status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", + ...(options?.statusIdleTtlMs === undefined ? {} : { idleTtlMs: options.statusIdleTtlMs }), subscribe: (input: EnvironmentRpcInput) => subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( Stream.mapAccum( From 58d2565f7f350cd831a232dd16ff7283d87486a3 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:24:40 +0800 Subject: [PATCH 3/7] fix: return current status after stale refresh --- .../src/vcs/VcsStatusBroadcaster.test.ts | 19 +++++++------- apps/server/src/vcs/VcsStatusBroadcaster.ts | 26 ++++++++++++++----- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 58a004e8662..1048fe5b0e4 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -907,7 +907,7 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); - it.effect("drops a full refresh result when the local ref changes in flight", () => { + it.effect("returns cached status when a full refresh is dropped after a ref change", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -939,13 +939,11 @@ describe("VcsStatusBroadcaster", () => { state.remoteStatusStarted = yield* Deferred.make(); state.remoteStatusRelease = yield* Deferred.make(); - const refreshDone = yield* Deferred.make(); - yield* broadcaster - .refreshStatus("/repo") - .pipe( - Effect.ensuring(Deferred.succeed(refreshDone, undefined).pipe(Effect.ignore)), - Effect.forkIn(scope), - ); + const refreshResult = yield* Deferred.make(); + yield* broadcaster.refreshStatus("/repo").pipe( + Effect.flatMap((status) => Deferred.succeed(refreshResult, status)), + Effect.forkIn(scope), + ); yield* Deferred.await(state.remoteStatusStarted); state.currentLocalStatus = { @@ -954,7 +952,10 @@ describe("VcsStatusBroadcaster", () => { }; yield* broadcaster.refreshLocalStatus("/repo"); yield* Deferred.succeed(state.remoteStatusRelease, undefined); - yield* Deferred.await(refreshDone); + assert.deepStrictEqual(yield* Deferred.await(refreshResult), { + ...state.currentLocalStatus, + ...baseRemoteStatus, + } satisfies VcsStatusResult); const latestSnapshot = yield* Stream.runHead( broadcaster.streamStatus( diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 542e0c58ba2..93dc6f1bbec 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -355,13 +355,21 @@ export const make = Effect.gen(function* () { value: remote, } satisfies CachedValue; const remoteLoaded = remote?.prLookupFailed !== true; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const update = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? EMPTY_CACHED_VCS_STATUS; if ( options?.expectedRefGeneration !== undefined && previous.refGeneration !== options.expectedRefGeneration ) { - return [false, cache] as const; + return [ + { + shouldPublish: false, + status: previous.local + ? mergeGitStatusParts(previous.local.value, previous.remote?.value ?? null) + : mergeGitStatusParts(local, remote), + }, + cache, + ] as const; } const refChanged = previous.local?.value.refName !== local.refName; const nextCache = new Map(cache); @@ -372,14 +380,18 @@ export const make = Effect.gen(function* () { refGeneration: refChanged ? previous.refGeneration + 1 : previous.refGeneration, }); return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint || - previous.remoteLoaded !== remoteLoaded, + { + shouldPublish: + previous.local?.fingerprint !== nextLocal.fingerprint || + previous.remote?.fingerprint !== nextRemote.fingerprint || + previous.remoteLoaded !== remoteLoaded, + status: mergeGitStatusParts(local, remote), + }, nextCache, ] as const; }); - if (options?.publish && shouldPublish) { + if (options?.publish && update.shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, event: { @@ -391,7 +403,7 @@ export const make = Effect.gen(function* () { }); } - return mergeGitStatusParts(local, remote); + return update.status; }); const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( From 8ad231fe7d364540317aa01617b8c08a998051c4 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:36:31 +0800 Subject: [PATCH 4/7] fix: tighten unresolved PR refresh handling --- apps/mobile/src/features/home/HomeScreen.tsx | 18 ++- .../threads/ThreadNavigationSidebar.tsx | 53 ++++----- .../features/threads/thread-list-v2-items.tsx | 2 +- .../src/features/threads/threadListV2.test.ts | 6 +- .../src/features/threads/threadListV2.ts | 2 +- apps/mobile/src/state/use-thread-pr.ts | 4 +- apps/mobile/src/state/vcs.ts | 5 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 104 +++++++----------- apps/server/src/vcs/VcsStatusBroadcaster.ts | 77 +++++-------- apps/web/src/components/SidebarV2.tsx | 14 +-- .../client-runtime/src/state/threadSettled.ts | 12 ++ 11 files changed, 120 insertions(+), 177 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 72561091854..acde2e5c938 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -11,7 +11,10 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; -import type { ChangeRequestSettlementState } from "@t3tools/client-runtime/state/thread-settled"; +import { + type ChangeRequestSettlementState, + updateChangeRequestSettlementState, +} from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId, SidebarProjectGroupingMode, @@ -487,16 +490,9 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const handleChangeRequestState = useCallback( (stateKey: string, state: ChangeRequestSettlementState) => { - setChangeRequestStateByKey((current) => { - if ((current.get(stateKey) ?? "unknown") === state) return current; - const next = new Map(current); - if (state === "unknown") { - next.delete(stateKey); - } else { - next.set(stateKey, state); - } - return next; - }); + setChangeRequestStateByKey((current) => + updateChangeRequestSettlementState(current, stateKey, state), + ); }, [], ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index a8783be176e..f928478fd63 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -7,7 +7,10 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; -import type { ChangeRequestSettlementState } from "@t3tools/client-runtime/state/thread-settled"; +import { + type ChangeRequestSettlementState, + updateChangeRequestSettlementState, +} from "@t3tools/client-runtime/state/thread-settled"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; @@ -413,16 +416,9 @@ function ThreadNavigationSidebarPane( >(() => new Map()); const handleChangeRequestState = useCallback( (stateKey: string, state: ChangeRequestSettlementState) => { - setChangeRequestStateByKey((current) => { - if ((current.get(stateKey) ?? "unknown") === state) return current; - const next = new Map(current); - if (state === "unknown") { - next.delete(stateKey); - } else { - next.set(stateKey, state); - } - return next; - }); + setChangeRequestStateByKey((current) => + updateChangeRequestSettlementState(current, stateKey, state), + ); }, [], ); @@ -1129,6 +1125,17 @@ function ThreadNavigationSidebarPane( : "No threads yet"} ); + const changeRequestLookupPool = threadListV2Enabled ? ( + + ) : null; if (props.nativeChrome) { return ( @@ -1157,17 +1164,7 @@ function ThreadNavigationSidebarPane( }} /> - {threadListV2Enabled ? ( - - ) : null} + {changeRequestLookupPool} - {threadListV2Enabled ? ( - - ) : null} + {changeRequestLookupPool} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 070e57c97db..ff9652973ce 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -8,6 +8,7 @@ import { type ChangeRequestSettlementState, resolveChangeRequestSettlementState, resolveSnoozePresets, + threadChangeRequestStateKey, } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; @@ -42,7 +43,6 @@ import { selectThreadListV2ChangeRequestLookupWindow, THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_LIMIT, THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_WINDOW_MS, - threadChangeRequestStateKey, type ThreadListV2ChangeRequestLookupTarget, type ThreadListV2Status, } from "./threadListV2"; diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index f85702126ea..933af452558 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,6 +1,9 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; +import { + resolveSnoozePresets, + threadChangeRequestStateKey, +} from "@t3tools/client-runtime/state/thread-settled"; import { CommandId, EnvironmentId, @@ -24,7 +27,6 @@ import { resolveThreadListV2SwipeActions, selectThreadListV2ChangeRequestLookupWindow, sortThreadsForListV2, - threadChangeRequestStateKey, } from "./threadListV2"; const environmentId = EnvironmentId.make("environment-1"); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 5d4d4a4cc0f..255afa5778c 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -17,7 +17,7 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -export { snoozeWakeLabel, threadChangeRequestStateKey }; +export { snoozeWakeLabel }; /** * Thread List v2 model, ported from the web sidebar v2 diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 1adc2627270..b79b60535f1 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -4,7 +4,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; -import { vcsEnvironment } from "./vcs"; +import { threadVcsEnvironment } from "./vcs"; export { presentThreadPr, @@ -16,7 +16,7 @@ export function useThreadVcsStatus(environmentId: EnvironmentId, cwd: string | n return useEnvironmentQuery( cwd === null ? null - : vcsEnvironment.status({ + : threadVcsEnvironment.status({ environmentId, input: { cwd }, }), diff --git a/apps/mobile/src/state/vcs.ts b/apps/mobile/src/state/vcs.ts index d65712dfc21..b0c9783b009 100644 --- a/apps/mobile/src/state/vcs.ts +++ b/apps/mobile/src/state/vcs.ts @@ -5,10 +5,11 @@ import { import { connectionAtomRuntime } from "../connection/runtime"; -export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime, { +export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); +export const threadVcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime, { // Thread List v2 time-slices off-screen PR lookups. Dispose each status // stream as soon as its last row/reporter unmounts so the pool's cap also - // bounds server pollers; web keeps the default warm-cache TTL. + // bounds server pollers without changing caching for other mobile Git UI. statusIdleTtlMs: 0, }); export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 1048fe5b0e4..090cdc557bd 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -67,7 +67,7 @@ const baseStatus: VcsStatusResult = { ...baseRemoteStatus, }; -function makeTestLayer(state: { +interface VcsTestState { currentLocalStatus: VcsStatusLocalResult; currentRemoteStatus: VcsStatusRemoteResult | null; localStatusCalls: number; @@ -81,7 +81,21 @@ function makeTestLayer(state: { blockRemoteStatusAtCall?: number; remoteStatusStarted?: Deferred.Deferred | null; remoteStatusRelease?: Deferred.Deferred | null; -}) { +} + +function makeTestState(overrides: Partial = {}): VcsTestState { + return { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + ...overrides, + }; +} + +function makeTestLayer(state: VcsTestState) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), @@ -679,15 +693,7 @@ describe("VcsStatusBroadcaster", () => { }); it.effect("marks cached remote status unresolved across refresh failures", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - failRemoteStatus: false, - }; + const state = makeTestState({ failRemoteStatus: false }); return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -744,15 +750,7 @@ describe("VcsStatusBroadcaster", () => { }); it.effect("marks cached remote status unresolved across refresh defects", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - dieRemoteStatus: false, - }; + const state = makeTestState({ dieRemoteStatus: false }); return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -791,14 +789,9 @@ describe("VcsStatusBroadcaster", () => { ...baseRemoteStatus, prLookupFailed: true, } satisfies VcsStatusRemoteResult; - const state = { - currentLocalStatus: baseLocalStatus, + const state = makeTestState({ currentRemoteStatus: lookupFallback as VcsStatusRemoteResult, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; + }); return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -834,15 +827,7 @@ describe("VcsStatusBroadcaster", () => { }); it.effect("marks cached remote status unresolved when an explicit refresh fails", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - failRemoteStatus: false, - }; + const state = makeTestState({ failRemoteStatus: false }); return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -871,15 +856,7 @@ describe("VcsStatusBroadcaster", () => { }); it.effect("marks cached remote status unresolved when full refresh invalidation defects", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - dieInvalidateStatus: false, - }; + const state = makeTestState({ dieInvalidateStatus: false }); return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -908,17 +885,12 @@ describe("VcsStatusBroadcaster", () => { }); it.effect("returns cached status when a full refresh is dropped after a ref change", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, + const state = makeTestState({ + currentRemoteStatus: remoteStatusWithPr, blockRemoteStatusAtCall: 2, - remoteStatusStarted: null as Deferred.Deferred | null, - remoteStatusRelease: null as Deferred.Deferred | null, - }; + remoteStatusStarted: null, + remoteStatusRelease: null, + }); return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -954,7 +926,11 @@ describe("VcsStatusBroadcaster", () => { yield* Deferred.succeed(state.remoteStatusRelease, undefined); assert.deepStrictEqual(yield* Deferred.await(refreshResult), { ...state.currentLocalStatus, - ...baseRemoteStatus, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, } satisfies VcsStatusResult); const latestSnapshot = yield* Stream.runHead( @@ -968,7 +944,7 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(latestSnapshot.value, { _tag: "snapshot", local: state.currentLocalStatus, - remote: baseRemoteStatus, + remote: remoteStatusWithPr, remoteLoaded: false, } satisfies VcsStatusStreamEvent); } @@ -978,18 +954,12 @@ describe("VcsStatusBroadcaster", () => { }); it.effect("ignores a stale refresh failure after the new ref has loaded", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, + const state = makeTestState({ failRemoteStatus: false, blockRemoteStatusAtCall: 2, - remoteStatusStarted: null as Deferred.Deferred | null, - remoteStatusRelease: null as Deferred.Deferred | null, - }; + remoteStatusStarted: null, + remoteStatusRelease: null, + }); return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 93dc6f1bbec..3c9214d3438 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -246,13 +246,17 @@ export const make = Effect.gen(function* () { function* ( cwd: string, remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean; expectedRefGeneration?: number }, + options?: { + publish?: boolean; + expectedRefGeneration?: number; + remoteLoaded?: boolean; + }, ) { const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; - const remoteLoaded = remote?.prLookupFailed !== true; + const remoteLoaded = options?.remoteLoaded ?? remote?.prLookupFailed !== true; const update = yield* Ref.modify( cacheRef, ( @@ -302,44 +306,26 @@ export const make = Effect.gen(function* () { ); const markCachedRemoteUnavailable = Effect.fn("VcsStatusBroadcaster.markCachedRemoteUnavailable")( - function* (cwd: string, expectedRefGeneration?: number) { - const transition = yield* Ref.modify( - cacheRef, - ( - cache, - ): readonly [ - { readonly changed: boolean; readonly remote: VcsStatusRemoteResult | null }, - Map, - ] => { - const previous = cache.get(cwd) ?? EMPTY_CACHED_VCS_STATUS; - if ( - expectedRefGeneration !== undefined && - previous.refGeneration !== expectedRefGeneration - ) { - return [{ changed: false, remote: null }, cache]; - } - if (!previous.remoteLoaded) { - return [{ changed: false, remote: null }, cache]; - } - const nextCache = new Map(cache); - nextCache.set(cwd, { ...previous, remoteLoaded: false }); - return [{ changed: true, remote: previous.remote?.value ?? null }, nextCache]; - }, - ); - - if (transition.changed) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "remoteUpdated", - remote: transition.remote, - remoteLoaded: false, - }, - }); - } + function* (cwd: string, expectedRefGeneration: number) { + const cached = yield* getCachedStatus(cwd); + if (cached?.remoteLoaded !== true) return; + yield* updateCachedRemoteStatus(cwd, cached.remote?.value ?? null, { + publish: true, + expectedRefGeneration, + remoteLoaded: false, + }); }, ); + const markRemoteUnavailableForCause = ( + cwd: string, + expectedRefGeneration: number, + cause: Cause.Cause, + ) => + cause.reasons.some((reason) => !Cause.isInterruptReason(reason)) + ? markCachedRemoteUnavailable(cwd, expectedRefGeneration) + : Effect.void; + const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( cwd: string, local: VcsStatusLocalResult, @@ -365,7 +351,10 @@ export const make = Effect.gen(function* () { { shouldPublish: false, status: previous.local - ? mergeGitStatusParts(previous.local.value, previous.remote?.value ?? null) + ? mergeGitStatusParts( + previous.local.value, + previous.remoteLoaded ? (previous.remote?.value ?? null) : null, + ) : mergeGitStatusParts(local, remote), }, cache, @@ -473,11 +462,7 @@ export const make = Effect.gen(function* () { expectedRefGeneration, }); }).pipe( - Effect.tapCause((cause) => - cause.reasons.some((reason) => !Cause.isInterruptReason(reason)) - ? markCachedRemoteUnavailable(cwd, expectedRefGeneration) - : Effect.void, - ), + Effect.tapCause((cause) => markRemoteUnavailableForCause(cwd, expectedRefGeneration, cause)), ); }); @@ -499,11 +484,7 @@ export const make = Effect.gen(function* () { expectedRefGeneration, }); }).pipe( - Effect.tapCause((cause) => - cause.reasons.some((reason) => !Cause.isInterruptReason(reason)) - ? markCachedRemoteUnavailable(cwd, expectedRefGeneration) - : Effect.void, - ), + Effect.tapCause((cause) => markRemoteUnavailableForCause(cwd, expectedRefGeneration, cause)), ); }); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index fd9f0ead18c..077e1243f0b 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -8,6 +8,7 @@ import { resolveChangeRequestSettlementState, threadChangeRequestStateKey, threadWokeAt, + updateChangeRequestSettlementState, } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { @@ -1362,16 +1363,9 @@ export default function SidebarV2() { >(() => new Map()); const handleChangeRequestState = useCallback( (threadKey: string, state: ChangeRequestSettlementState) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? "unknown") === state) return current; - const next = new Map(current); - if (state === "unknown") { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); + setChangeRequestStateByKey((current) => + updateChangeRequestSettlementState(current, threadKey, state), + ); }, [], ); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 2768f53ceb8..5f6862558b0 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -4,6 +4,18 @@ import type { OrchestrationThreadShell, VcsStatusResult } from "@t3tools/contrac export type ChangeRequestStateLike = "open" | "closed" | "merged"; export type ChangeRequestSettlementState = ChangeRequestStateLike | "none" | "unknown"; +export function updateChangeRequestSettlementState( + current: ReadonlyMap, + stateKey: string, + state: ChangeRequestSettlementState, +) { + if ((current.get(stateKey) ?? "unknown") === state) return current; + const next = new Map(current); + if (state === "unknown") next.delete(stateKey); + else next.set(stateKey, state); + return next; +} + export function threadChangeRequestStateKey( thread: Pick & { readonly environmentId: string }, ): string { From 3c7a1ad9a6eb404576cb2ed00177a323b8c04cb1 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:41:37 +0800 Subject: [PATCH 5/7] fix: keep offscreen PR status live --- apps/mobile/src/features/home/HomeScreen.tsx | 1 - .../threads/ThreadNavigationSidebar.tsx | 1 - .../features/threads/thread-list-v2-items.tsx | 12 +----------- .../src/features/threads/threadListV2.test.ts | 2 -- .../mobile/src/features/threads/threadListV2.ts | 17 +++-------------- 5 files changed, 4 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index acde2e5c938..da31775912c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1051,7 +1051,6 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs={v2ScopedProjectGroup?.projectRefs ?? null} projectCwdByKey={projectCwdByKey} settlementEnvironmentIds={settlementEnvironmentIds} - changeRequestStateByKey={changeRequestStateByKey} onChangeRequestState={handleChangeRequestState} /> diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index f928478fd63..1453d870823 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1132,7 +1132,6 @@ function ThreadNavigationSidebarPane( projectRefs={selectedProjectScope?.projectRefs ?? null} projectCwdByKey={projectCwdByKey} settlementEnvironmentIds={settlementEnvironmentIds} - changeRequestStateByKey={changeRequestStateByKey} onChangeRequestState={handleChangeRequestState} /> ) : null; diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index ff9652973ce..8a90111d651 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -345,7 +345,6 @@ export const ThreadListV2ChangeRequestLookupPool = memo( }> | null; readonly projectCwdByKey: ReadonlyMap; readonly settlementEnvironmentIds: ReadonlySet; - readonly changeRequestStateByKey: ReadonlyMap; readonly onChangeRequestState: (stateKey: string, state: ChangeRequestSettlementState) => void; }) { const { @@ -354,7 +353,6 @@ export const ThreadListV2ChangeRequestLookupPool = memo( projectRefs, projectCwdByKey, settlementEnvironmentIds, - changeRequestStateByKey, onChangeRequestState, } = props; const targets = useMemo( @@ -365,16 +363,8 @@ export const ThreadListV2ChangeRequestLookupPool = memo( projectRefs, projectCwdByKey, settlementEnvironmentIds, - changeRequestStateByKey, }), - [ - changeRequestStateByKey, - environmentId, - projectCwdByKey, - projectRefs, - settlementEnvironmentIds, - threads, - ], + [environmentId, projectCwdByKey, projectRefs, settlementEnvironmentIds, threads], ); const [windowIndex, setWindowIndex] = useState(0); useEffect(() => { diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 933af452558..c5f15de7dab 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -532,7 +532,6 @@ describe("buildThreadListV2Items", () => { environmentId: null, projectCwdByKey: new Map(), settlementEnvironmentIds: new Set([environmentId]), - changeRequestStateByKey: new Map(), }); const firstBatch = selectThreadListV2ChangeRequestLookupWindow({ targets, @@ -569,7 +568,6 @@ describe("buildThreadListV2Items", () => { environmentId: null, projectCwdByKey: new Map(), settlementEnvironmentIds: new Set([environmentId]), - changeRequestStateByKey: new Map(), }); expect(targets).toHaveLength(1); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 255afa5778c..b735d74f5bf 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -185,7 +185,6 @@ export interface ThreadListV2ChangeRequestLookupTarget { readonly key: string; readonly environmentId: EnvironmentId; readonly cwd: string; - readonly hasUnknownState: boolean; readonly threads: ReadonlyArray; } @@ -205,7 +204,6 @@ export function buildThreadListV2ChangeRequestLookupTargets(input: { }> | null; readonly projectCwdByKey: ReadonlyMap; readonly settlementEnvironmentIds: ReadonlySet; - readonly changeRequestStateByKey: ReadonlyMap; }): ReadonlyArray { const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -215,43 +213,34 @@ export function buildThreadListV2ChangeRequestLookupTargets(input: { { environmentId: EnvironmentId; cwd: string; - hasUnknownState: boolean; threads: EnvironmentThreadShell[]; } >(); + // Keep confirmed states in the rotation: a branch can gain a PR later, and + // a closed PR can reopen while its thread remains off-screen. for (const thread of sortThreadsForListV2(input.threads)) { if (thread.archivedAt !== null || thread.branch === null) continue; if (!input.settlementEnvironmentIds.has(thread.environmentId)) continue; if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; const projectKey = `${thread.environmentId}:${thread.projectId}`; if (projectKeys !== null && !projectKeys.has(projectKey)) continue; - const state = input.changeRequestStateByKey.get(threadChangeRequestStateKey(thread)); - if (state !== undefined && state !== "unknown" && state !== "open") continue; const cwd = thread.worktreePath ?? input.projectCwdByKey.get(projectKey) ?? null; if (cwd === null) continue; const key = threadChangeRequestLookupTargetKey(thread.environmentId, cwd); const current = groups.get(key); if (current) { current.threads.push(thread); - current.hasUnknownState ||= state === undefined || state === "unknown"; } else { groups.set(key, { environmentId: thread.environmentId, cwd, - hasUnknownState: state === undefined || state === "unknown", threads: [thread], }); } } - return [...groups] - .map(([key, group]) => ({ key, ...group })) - .sort( - (left, right) => - Number(right.hasUnknownState) - Number(left.hasUnknownState) || - left.key.localeCompare(right.key), - ); + return [...groups].map(([key, group]) => ({ key, ...group })); } export function selectThreadListV2ChangeRequestLookupWindow(input: { From b0a5e5e2f53f2ce844f7534f94355fedaa271b12 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:45:15 +0800 Subject: [PATCH 6/7] fix: refresh hidden PR settlement state --- .../features/threads/thread-list-v2-items.tsx | 9 +- .../src/features/threads/threadListV2.test.ts | 8 +- .../src/features/threads/threadListV2.ts | 15 --- .../src/vcs/VcsStatusBroadcaster.test.ts | 10 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 11 +- apps/web/src/components/SidebarV2.tsx | 107 ++++++++++++++---- apps/web/src/state/vcs.ts | 5 + .../client-runtime/src/state/threadSettled.ts | 15 +++ packages/client-runtime/src/state/vcs.test.ts | 9 +- 9 files changed, 140 insertions(+), 49 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 8a90111d651..5fb61b3fe11 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -8,6 +8,7 @@ import { type ChangeRequestSettlementState, resolveChangeRequestSettlementState, resolveSnoozePresets, + selectChangeRequestLookupWindow, threadChangeRequestStateKey, } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; @@ -40,7 +41,6 @@ import { resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, - selectThreadListV2ChangeRequestLookupWindow, THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_LIMIT, THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_WINDOW_MS, type ThreadListV2ChangeRequestLookupTarget, @@ -376,7 +376,12 @@ export const ThreadListV2ChangeRequestLookupPool = memo( return () => clearInterval(interval); }, [targets.length]); const activeTargets = useMemo( - () => selectThreadListV2ChangeRequestLookupWindow({ targets, windowIndex }), + () => + selectChangeRequestLookupWindow({ + targets, + windowIndex, + limit: THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_LIMIT, + }), [targets, windowIndex], ); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c5f15de7dab..3ff07496ab1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -2,6 +2,7 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveSnoozePresets, + selectChangeRequestLookupWindow, threadChangeRequestStateKey, } from "@t3tools/client-runtime/state/thread-settled"; import { @@ -25,7 +26,6 @@ import { resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, - selectThreadListV2ChangeRequestLookupWindow, sortThreadsForListV2, } from "./threadListV2"; @@ -533,15 +533,17 @@ describe("buildThreadListV2Items", () => { projectCwdByKey: new Map(), settlementEnvironmentIds: new Set([environmentId]), }); - const firstBatch = selectThreadListV2ChangeRequestLookupWindow({ + const firstBatch = selectChangeRequestLookupWindow({ targets, windowIndex: 0, + limit: 16, }); expect(firstBatch).toHaveLength(16); - const secondBatch = selectThreadListV2ChangeRequestLookupWindow({ + const secondBatch = selectChangeRequestLookupWindow({ targets, windowIndex: 1, + limit: 16, }); expect(secondBatch.slice(0, 2).map((target) => target.key)).toEqual( targets.slice(16).map((target) => target.key), diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index b735d74f5bf..3d7b2aa27c9 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -243,21 +243,6 @@ export function buildThreadListV2ChangeRequestLookupTargets(input: { return [...groups].map(([key, group]) => ({ key, ...group })); } -export function selectThreadListV2ChangeRequestLookupWindow(input: { - readonly targets: ReadonlyArray; - readonly windowIndex: number; - readonly limit?: number; -}): ReadonlyArray { - const limit = input.limit ?? THREAD_LIST_V2_CHANGE_REQUEST_LOOKUP_LIMIT; - const count = Math.min(limit, input.targets.length); - if (count === 0) return []; - const start = (input.windowIndex * limit) % input.targets.length; - return Array.from( - { length: count }, - (_, index) => input.targets[(start + index) % input.targets.length]!, - ); -} - export interface ThreadListV2Item { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 090cdc557bd..939e0e61c94 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -815,14 +815,22 @@ describe("VcsStatusBroadcaster", () => { remote: lookupFallback, remoteLoaded: false, } satisfies VcsStatusStreamEvent); + assert.equal(state.remoteStatusCalls, 1); - state.currentRemoteStatus = baseRemoteStatus; yield* TestClock.adjust(Duration.seconds(30)); + yield* Effect.yieldNow; + assert.equal(state.remoteStatusCalls, 2); + state.currentRemoteStatus = baseRemoteStatus; + yield* TestClock.adjust(Duration.seconds(59)); + yield* Effect.yieldNow; + assert.equal(state.remoteStatusCalls, 2); + yield* TestClock.adjust(Duration.seconds(1)); assert.deepStrictEqual(yield* Deferred.await(recoveredDeferred), { _tag: "remoteUpdated", remote: baseRemoteStatus, remoteLoaded: true, } satisfies VcsStatusStreamEvent); + assert.equal(state.remoteStatusCalls, 3); }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 3c9214d3438..6e43b798c43 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -532,8 +532,15 @@ export const make = Effect.gen(function* () { }).pipe(Effect.exit); if (Exit.isSuccess(exit)) { yield* Ref.set(needsInitialRefreshRef, !exit.value.loaded); - yield* Ref.set(consecutiveFailuresRef, 0); - return activeInterval; + if (exit.value.loaded) { + yield* Ref.set(consecutiveFailuresRef, 0); + return activeInterval; + } + const unresolvedAttempts = yield* Ref.updateAndGet( + consecutiveFailuresRef, + (count) => count + 1, + ); + return remoteRefreshFailureDelay(unresolvedAttempts, activeInterval); } const interruptionReasons = exit.cause.reasons.filter(Cause.isInterruptReason); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 077e1243f0b..745f8fd783f 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -6,6 +6,7 @@ import { effectiveSettled, effectiveSnoozed, resolveChangeRequestSettlementState, + selectChangeRequestLookupWindow, threadChangeRequestStateKey, threadWokeAt, updateChangeRequestSettlementState, @@ -97,7 +98,7 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; -import { vcsEnvironment } from "../state/vcs"; +import { threadVcsEnvironment, vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; @@ -171,6 +172,8 @@ import { useComposerDraftStore } from "../composerDraftStore"; // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; +const CHANGE_REQUEST_LOOKUP_LIMIT = 16; +const CHANGE_REQUEST_LOOKUP_WINDOW_MS = 30_000; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -387,6 +390,43 @@ function SnoozePopoverButton(props: { ); } +function useSidebarV2ChangeRequestState( + thread: SidebarThreadSummary, + projectCwd: string | null, + onChangeRequestState: (stateKey: string, state: ChangeRequestSettlementState) => void, +) { + const gitCwd = thread.worktreePath ?? projectCwd; + const gitStatus = useEnvironmentQuery( + (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + ? threadVcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const changeRequestState = resolveChangeRequestSettlementState({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + gitStatusError: gitStatus.error, + }); + const stateKey = threadChangeRequestStateKey(thread); + useEffect(() => { + onChangeRequestState(stateKey, changeRequestState); + }, [changeRequestState, onChangeRequestState, stateKey]); + return gitStatus; +} + +const SidebarV2ChangeRequestStateReporter = memo( + function SidebarV2ChangeRequestStateReporter(props: { + thread: SidebarThreadSummary; + projectCwd: string | null; + onChangeRequestState: (stateKey: string, state: ChangeRequestSettlementState) => void; + }) { + useSidebarV2ChangeRequestState(props.thread, props.projectCwd, props.onChangeRequestState); + return null; + }, +); + const SidebarV2Row = memo(function SidebarV2Row(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; @@ -423,7 +463,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; - onChangeRequestState: (threadKey: string, state: ChangeRequestSettlementState) => void; + onChangeRequestState: (stateKey: string, state: ChangeRequestSettlementState) => void; }) { const { isRenaming, @@ -526,15 +566,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { } : null; - const gitCwd = thread.worktreePath ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); + const gitStatus = useSidebarV2ChangeRequestState(thread, props.projectCwd, onChangeRequestState); const branchMismatch = resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: thread.worktreePath === null ? "local" : "worktree", activeWorktreePath: thread.worktreePath, @@ -547,18 +579,6 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); 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 changeRequestState = resolveChangeRequestSettlementState({ - threadBranch: thread.branch, - gitStatus: gitStatus.data, - gitStatusError: gitStatus.error, - }); - const changeRequestStateCacheKey = threadChangeRequestStateKey(thread); - useEffect(() => { - onChangeRequestState(changeRequestStateCacheKey, changeRequestState); - }, [changeRequestState, changeRequestStateCacheKey, onChangeRequestState]); - const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; @@ -1362,9 +1382,9 @@ export default function SidebarV2() { ReadonlyMap >(() => new Map()); const handleChangeRequestState = useCallback( - (threadKey: string, state: ChangeRequestSettlementState) => { + (stateKey: string, state: ChangeRequestSettlementState) => { setChangeRequestStateByKey((current) => - updateChangeRequestSettlementState(current, threadKey, state), + updateChangeRequestSettlementState(current, stateKey, state), ); }, [], @@ -1737,6 +1757,35 @@ export default function SidebarV2() { () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], [activeThreads, visibleSnoozedThreads, renderedSettledThreads], ); + // Collapsed and paged shelf rows unmount, so revisit their PR state in a + // bounded pool instead of letting a cached no-PR/closed result live forever. + const backgroundChangeRequestThreads = useMemo(() => { + const renderedStateKeys = new Set( + (isSearchingThreads ? [] : orderedThreads).map(threadChangeRequestStateKey), + ); + return [...activeThreads, ...snoozedThreads, ...settledThreads].filter( + (thread) => + thread.branch !== null && !renderedStateKeys.has(threadChangeRequestStateKey(thread)), + ); + }, [activeThreads, isSearchingThreads, orderedThreads, settledThreads, snoozedThreads]); + const [changeRequestLookupWindowIndex, setChangeRequestLookupWindowIndex] = useState(0); + useEffect(() => { + if (backgroundChangeRequestThreads.length <= CHANGE_REQUEST_LOOKUP_LIMIT) return; + const interval = window.setInterval( + () => setChangeRequestLookupWindowIndex((current) => current + 1), + CHANGE_REQUEST_LOOKUP_WINDOW_MS, + ); + return () => window.clearInterval(interval); + }, [backgroundChangeRequestThreads.length]); + const backgroundChangeRequestLookupThreads = useMemo( + () => + selectChangeRequestLookupWindow({ + targets: backgroundChangeRequestThreads, + windowIndex: changeRequestLookupWindowIndex, + limit: CHANGE_REQUEST_LOOKUP_LIMIT, + }), + [backgroundChangeRequestThreads, changeRequestLookupWindowIndex], + ); const orderedThreadKeys = useMemo( () => orderedThreads.map((thread) => @@ -2587,6 +2636,14 @@ export default function SidebarV2() { return ( <> + {backgroundChangeRequestLookupThreads.map((thread) => ( + + ))}