History

fix(pull-requests): shared state + not settling? (#9332)

maria committed 2 months ago

Commit 854541a1 parent 48ba76b9 files · +439 −165Browse files

Unified · Split

9 files
  1. apps/server/src/orchestration/ThreadSettlementReactor.test.ts +71 −3
  2. apps/server/src/orchestration/ThreadSettlementReactor.ts +25 −6
  3. apps/server/src/pullRequest/PullRequestService.test.ts +64 −0
  4. apps/server/src/pullRequest/PullRequestService.ts +47 −8
  5. apps/web/src/components/ChatView.tsx +131 −54
  6. apps/web/src/components/ThreadStatusIndicators.test.ts +33 −47
  7. apps/web/src/components/ThreadStatusIndicators.tsx +3 −28
  8. apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +18 −18
  9. apps/web/src/state/pullRequests.ts +47 −1
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+71−3
@@ -25,7 +25,10 @@ import * as Stream from "effect/Stream"; · 24 unmodified lines
2525 import { TestClock } from "effect/testing";
2626
2727 import { GitManager } from "../git/GitManager.ts";
28−import { PullRequestService } from "../pullRequest/PullRequestService.ts";
28+import {
29+ PullRequestService,
30+ type PullRequestMergeEvent,
31+} from "../pullRequest/PullRequestService.ts";
2932 import { ServerActivation } from "../serverActivation.ts";
3033 import { ServerSettingsService } from "../serverSettings.ts";
3134 import { OrchestrationCommandInvariantError } from "./Errors.ts";
@@ -143,6 +146,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 111 unmodified lines
143146 const snapshotReads = yield* Queue.unbounded<number>();
144147 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
145148 const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
149+ const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>();
146150 const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);
147151 const branchCalls = yield* Ref.make<
148152 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
@@ -168,7 +172,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 19 unmodified lines
168172 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
169173 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
170174 );
171−
172175 const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
173176 Effect.gen(function* () {
174177 yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
@@ -217,7 +220,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 42 unmodified lines
217220 ),
218221 }),
219222 Layer.mock(GitManager)({ branchPullRequest }),
220− Layer.mock(PullRequestService)({ summary: pullRequestSummary }),
223+ Layer.mock(PullRequestService)({
224+ summary: pullRequestSummary,
225+ subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
226+ Effect.map((subscription) => Stream.fromSubscription(subscription)),
227+ ),
228+ }),
221229 Layer.mock(OrchestrationEngineService)({
222230 readEvents: () => Stream.empty,
223231 dispatch,
@@ -239,6 +247,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 15 unmodified lines
239247 summaryCalls,
240248 summaryRecovery,
241249 updateSettings,
250+ publishMerge: PubSub.publish(mergedPullRequests, {
251+ projectId: PROJECT_ID,
252+ repository: "owner/repository",
253+ number: 42,
254+ mergedAt: NOW,
255+ }),
242256 layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
243257 };
244258 });
@@ -373,6 +387,60 @@ describe("ThreadSettlementReactor", () => { · 128 unmodified lines
373387 ),
374388 );
375389
390+ it.effect("reevaluates immediately after a pull request merge", () =>
391+ Effect.scoped(
392+ Effect.gen(function* () {
393+ yield* TestClock.setTime(Date.parse(NOW));
394+ const periodicLookupStarted = yield* Deferred.make<void>();
395+ const releasePeriodicLookup = yield* Deferred.make<void>();
396+ const mergedThreadSettled = yield* Deferred.make<void>();
397+ const branchLookupCount = yield* Ref.make(0);
398+ const fixture = yield* makeHarness({
399+ snapshot: makeSnapshot([
400+ makeThread("merged-in-app", {
401+ linkedPullRequest: {
402+ projectId: PROJECT_ID,
403+ repository: "owner/repository",
404+ number: 42,
405+ url: "https://example.test/owner/repository/pull/42",
406+ },
407+ }),
408+ makeThread("slow-periodic-lookup", { branch: "another-feature" }),
409+ ]),
410+ branchPullRequest: () =>
411+ Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe(
412+ Effect.flatMap((count) =>
413+ count === 1
414+ ? Effect.succeed({ state: "open" as const, updatedAt: NOW })
415+ : Deferred.succeed(periodicLookupStarted, undefined).pipe(
416+ Effect.andThen(Deferred.await(releasePeriodicLookup)),
417+ Effect.as({ state: "open" as const, updatedAt: NOW }),
418+ ),
419+ ),
420+ ),
421+ onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
422+ });
423+
424+ yield* Effect.gen(function* () {
425+ const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
426+ yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
427+ yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
428+ yield* Deferred.await(periodicLookupStarted);
429+
430+ yield* fixture.publishMerge;
431+ yield* Deferred.await(mergedThreadSettled);
432+
433+ assert.deepStrictEqual(
434+ (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
435+ [ThreadId.make("merged-in-app")],
436+ );
437+ yield* Deferred.succeed(releasePeriodicLookup, undefined);
438+ yield* reactor.drain;
439+ }).pipe(Effect.provide(fixture.layer));
440+ }),
441+ ),
442+ );
443+
376444 it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () =>
377445 Effect.scoped(
378446 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+25−6
@@ -38,11 +38,22 @@ export const make = Effect.gen(function* () { · 37 unmodified lines
3838 const pullRequests = yield* PullRequestService.PullRequestService;
3939 const crypto = yield* Crypto.Crypto;
4040
41− const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () {
41+ const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* (
42+ mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
43+ ) {
4244 const snapshot = yield* snapshots.getShellSnapshot();
4345 const now = DateTime.formatIso(yield* DateTime.now);
4446 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
45− const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
47+ const candidates = snapshot.threads.filter(
48+ (thread) =>
49+ isAutoSettlementCandidate(thread, now) &&
50+ (mergedPullRequest === null ||
51+ (thread.linkedPullRequest != null &&
52+ thread.linkedPullRequest.projectId === mergedPullRequest.projectId &&
53+ thread.linkedPullRequest.repository.toLowerCase() ===
54+ mergedPullRequest.repository.toLowerCase() &&
55+ thread.linkedPullRequest.number === mergedPullRequest.number)),
56+ );
4657 const lookupKey = (thread: (typeof candidates)[number]) => {
4758 if (thread.linkedPullRequest != null) {
4859 return JSON.stringify([
@@ -66,6 +77,12 @@ export const make = Effect.gen(function* () { · 17 unmodified lines
6677 thread: (typeof candidates)[number],
6778 ) {
6879 if (thread.linkedPullRequest != null) {
80+ if (mergedPullRequest !== null) {
81+ return {
82+ state: "merged",
83+ updatedAt: mergedPullRequest.mergedAt,
84+ } satisfies SettlementPullRequest;
85+ }
6986 if (!projects.has(thread.linkedPullRequest.projectId)) {
7087 return yield* Effect.die(new Error("linked pull request project not found"));
7188 }
@@ -145,8 +162,8 @@ export const make = Effect.gen(function* () { · 73 unmodified lines
145162 );
146163 });
147164
148− const worker = yield* makeDrainableWorker(() =>
149− sweep().pipe(
165+ const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) =>
166+ sweep(mergedPullRequest).pipe(
150167 Effect.catchCause((cause) =>
151168 Cause.hasInterruptsOnly(cause)
152169 ? Effect.failCause(cause)
@@ -154,13 +171,14 @@ export const make = Effect.gen(function* () { · 1 unmodified line
154171 cause: Cause.pretty(cause),
155172 }),
156173 ),
157− ),
158− );
174+ );
175+ const worker = yield* makeDrainableWorker(() => runSweep(null));
159176
160177 const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(
161178 "ThreadSettlementReactor.start",
162179 )(function* () {
163180 const settingsChanges = yield* settingsService.subscribeChanges;
181+ const mergedPullRequests = yield* pullRequests.subscribeMerges;
164182 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
165183 let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
166184 let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
@@ -183,6 +201,7 @@ export const make = Effect.gen(function* () { · 16 unmodified lines
183201 return worker.enqueue(undefined);
184202 }),
185203 );
204+ yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep));
186205 });
187206
188207 return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"];
apps/server/src/pullRequest/PullRequestService.test.ts+64−0
@@ -1,7 +1,10 @@
11 import { assert, it } from "@effect/vitest";
22 import * as Deferred from "effect/Deferred";
33 import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
45 import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+import * as Stream from "effect/Stream";
58 import * as TestClock from "effect/testing/TestClock";
69 import type {
710 OrchestrationProjectShell,
@@ -1002,6 +1005,41 @@ it.effect("refuses an action the host never claimed it could run", () => · 994 unmodified lines
10021005 }),
10031006 );
10041007
1008+it.effect("publishes a successful merge for immediate settlement", () =>
1009+ Effect.scoped(
1010+ Effect.gen(function* () {
1011+ const mergedAt = "2026-09-03T02:00:00.000Z";
1012+ const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
1013+ const service = yield* makeService({
1014+ projects: [
1015+ project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
1016+ ],
1017+ providers: [
1018+ fakeProvider("github", {
1019+ runAction: () => TestClock.setTime(Date.parse(mergedAt)),
1020+ }),
1021+ ],
1022+ });
1023+ const merges = yield* service.subscribeMerges;
1024+ const observedMerge = yield* Stream.runHead(merges).pipe(
1025+ Effect.forkChild({ startImmediately: true }),
1026+ );
1027+
1028+ yield* service.runAction({
1029+ ...reference,
1030+ repository: " ACME/WEB ",
1031+ action: "merge",
1032+ mergeMethod: "merge",
1033+ });
1034+
1035+ assert.deepStrictEqual(Option.getOrThrow(yield* Fiber.join(observedMerge)), {
1036+ ...reference,
1037+ mergedAt,
1038+ });
1039+ }),
1040+ ),
1041+);
1042+
10051043 it.effect("refuses an action this viewer may not take, and says what access it takes", () =>
10061044 Effect.gen(function* () {
10071045 let ran: string | null = null;
@@ -3192,6 +3230,32 @@ it.effect("does not ask the host again for a linked summary it already holds", ( · 2184 unmodified lines
31923230 }),
31933231 );
31943232
3233+it.effect("reuses an observed merged state for strict settlement reads", () =>
3234+ Effect.gen(function* () {
3235+ const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
3236+ const service = yield* makeService({
3237+ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
3238+ providers: [
3239+ fakeProvider("github", {
3240+ getChangeRequest: () =>
3241+ Effect.succeed({
3242+ ...hostedChangeRequest("merged body", 4),
3243+ state: "merged",
3244+ updatedAt: "2026-07-03T00:00:00Z",
3245+ }),
3246+ getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"),
3247+ }),
3248+ ],
3249+ });
3250+
3251+ yield* service.detail(reference);
3252+
3253+ const summary = yield* service.summary(reference, { recoverTransientFailure: false });
3254+ assert.strictEqual(summary.state, "merged");
3255+ assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z");
3256+ }),
3257+);
3258+
31953259 it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () =>
31963260 Effect.gen(function* () {
31973261 const gate = yield* Deferred.make<void>();
apps/server/src/pullRequest/PullRequestService.ts+47−8
@@ -1,11 +1,15 @@
11 import * as Cache from "effect/Cache";
22 import * as Clock from "effect/Clock";
33 import * as Context from "effect/Context";
4+import * as DateTime from "effect/DateTime";
45 import * as Duration from "effect/Duration";
56 import * as Effect from "effect/Effect";
67 import * as Exit from "effect/Exit";
78 import * as Layer from "effect/Layer";
9+import * as PubSub from "effect/PubSub";
810 import * as Schema from "effect/Schema";
11+import type * as Scope from "effect/Scope";
12+import * as Stream from "effect/Stream";
913 import {
1014 PullRequestOperationError,
1115 PullRequestUnavailableError,
@@ -63,6 +67,10 @@ import { · 51 unmodified lines
6367 } from "./PullRequestProvider.ts";
6468 import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts";
6569
70+export interface PullRequestMergeEvent extends PullRequestRef {
71+ readonly mergedAt: string;
72+}
73+
6674 /**
6775 * Rows per repository when the client does not ask for a page size, and rows per slice when a
6876 * listing is carried on from a cursor.
@@ -135,6 +143,11 @@ export class PullRequestService extends Context.Service< · 66 unmodified lines
135143 input: PullRequestRef,
136144 options?: { readonly recoverTransientFailure?: boolean },
137145 ) => Effect.Effect<PullRequestSummary, PullRequestError>;
146+ readonly subscribeMerges: Effect.Effect<
147+ Stream.Stream<PullRequestMergeEvent>,
148+ never,
149+ Scope.Scope
150+ >;
138151 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
139152 readonly activity: (
140153 input: PullRequestRef,
@@ -517,6 +530,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string · 376 unmodified lines
517530 }
518531
519532 export const make = Effect.gen(function* () {
533+ const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);
520534 const registry = yield* PullRequestProviderRegistry;
521535 const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
522536 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
@@ -1410,9 +1424,9 @@ export const make = Effect.gen(function* () { · 887 unmodified lines
14101424 }),
14111425 );
14121426
1413− const runAction: PullRequestService["Service"]["runAction"] = (input) =>
1427+ const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> =>
14141428 requireProject(input).pipe(
1415− Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {
1429+ Effect.flatMap((project): Effect.Effect<string, PullRequestError> => {
14161430 // The surface hides what a host cannot do, and this refuses it as well: a request that
14171431 // reached here anyway must not be handed to a provider that never claimed the action.
14181432 if (!project.api.capabilities.actions.includes(input.action)) {
@@ -1454,7 +1468,7 @@ export const make = Effect.gen(function* () { · 35 unmodified lines
14541468 // have to say yes. The second is asked last, because it costs a request and the checks
14551469 // above do not.
14561470 return viewerPermissionsOf(project, input, "runAction").pipe(
1457− Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => {
1471+ Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => {
14581472 if (!viewer.actions.includes(input.action)) {
14591473 return Effect.fail(
14601474 new PullRequestOperationError({
@@ -1484,7 +1498,10 @@ export const make = Effect.gen(function* () { · 23 unmodified lines
14841498 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
14851499 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
14861500 })
1487− .pipe(Effect.mapError(toPullRequestError("runAction")));
1501+ .pipe(
1502+ Effect.mapError(toPullRequestError("runAction")),
1503+ Effect.as(project.repository),
1504+ );
14881505 }),
14891506 );
14901507 }),
@@ -2151,9 +2168,13 @@ export const make = Effect.gen(function* () { · 660 unmodified lines
21512168 const summary: PullRequestService["Service"]["summary"] = (input, options) => {
21522169 const key = refCacheKey(input);
21532170 const cached = Cache.get(summaryCache, key);
2154− return options?.recoverTransientFailure === false
2155− ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value)))
2156− : lastGoodSummary.serveHeld(key, cached, "reuse");
2171+ if (options?.recoverTransientFailure !== false) {
2172+ return lastGoodSummary.serveHeld(key, cached, "reuse");
2173+ }
2174+ const held = lastGoodSummary.peek(key);
2175+ return held?.state === "merged"
2176+ ? Effect.succeed(held)
2177+ : cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value)));
21572178 };
21582179
21592180 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
@@ -2391,17 +2412,35 @@ export const make = Effect.gen(function* () { · 231 unmodified lines
23912412 }),
23922413 ),
23932414 );
2415+ const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn(
2416+ "PullRequestService.runActionAndInvalidate",
2417+ )(function* (input) {
2418+ const repository = yield* runAction(input);
2419+ bumpRefEpoch({ ...input, repository });
2420+ listingsEpoch = ++epochCounter;
2421+ if (input.action === "merge") {
2422+ yield* PubSub.publish(mergedPullRequests, {
2423+ projectId: input.projectId,
2424+ repository,
2425+ number: input.number,
2426+ mergedAt: DateTime.formatIso(yield* DateTime.now),
2427+ });
2428+ }
2429+ });
23942430
23952431 return PullRequestService.of({
23962432 list,
23972433 listStats,
23982434 summary,
2435+ subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
2436+ Effect.map((subscription) => Stream.fromSubscription(subscription)),
2437+ ),
23992438 detail,
24002439 activity,
24012440 threadComments,
24022441 diff,
24032442 diffFileContents,
2404− runAction: invalidatedByMutation(runAction),
2443+ runAction: runActionAndInvalidate,
24052444 update: invalidatedByMutation(update),
24062445 comment: invalidatedByMutation(comment),
24072446 updateComment: invalidatedByMutation(updateComment),
apps/web/src/components/ChatView.tsx+131−54
@@ -265,7 +265,6 @@ import { environmentCatalog } from "../connection/catalog"; · 264 unmodified lines
265265 import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
266266 import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
267267 import { projectEnvironment } from "../state/projects";
268−import { linkedPullRequestDetailAtom } from "../state/pullRequests";
269268 import { useEnvironmentQuery } from "../state/query";
270269 import {
271270 environmentServerConfigsAtom,
@@ -325,7 +324,6 @@ import { · 53 unmodified lines
325324 } from "./chat/ThreadErrorBanner";
326325 import {
327326 resolveDisplayedThreadPr,
328− threadPullRequestRefreshSource,
329327 threadChangeRequestSnapshotsAtom,
330328 useLinkedThreadPullRequest,
331329 } from "./ThreadStatusIndicators";
@@ -1787,6 +1785,8 @@ function ChatViewContent(props: ChatViewProps) { · 1455 unmodified lines
17871785 );
17881786 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
17891787 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
1788+ const threadPrRelinkKeysRef = useRef(new Map<string, string>());
1789+ const threadPrRelinkWriteRef = useRef(Promise.resolve());
17901790 const activePreviewState = useThreadPreviewState(activeThreadRef);
17911791 const activePreviewServerEpoch = activePreviewState.serverEpoch;
17921792 const resolvePreviewRuntimeTabId = useMemo(
@@ -3645,9 +3645,50 @@ function ChatViewContent(props: ChatViewProps) { · 1852 unmodified lines
36453645 );
36463646 // The thread's own change request, placed against the project it belongs to. Without a
36473647 // project there is nothing to resolve it against, so the caller falls back to the browser.
3648− const linkedThreadPullRequest = isServerThread
3648+ const persistedLinkedThreadPullRequest = isServerThread
36493649 ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null)
36503650 : (activeThread?.linkedPullRequest ?? null);
3651+ const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
3652+ const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest(
3653+ activeThreadRef?.environmentId ?? null,
3654+ persistedLinkedThreadPullRequest,
3655+ );
3656+ const replacementLinkedThreadPullRequest = useMemo(() => {
3657+ const detected = gitStatusQuery.data?.pr;
3658+ const threadBranch = activeThread?.branch;
3659+ const projectId = activeProject?.id;
3660+ if (
3661+ persistedLinkedThreadPullRequest === null ||
3662+ (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" &&
3663+ persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") ||
3664+ gitStatusQuery.data?.refName !== threadBranch ||
3665+ detected?.state !== "open" ||
3666+ detected.headRef !== threadBranch ||
3667+ projectId === undefined ||
3668+ activeProjectRepository === null ||
3669+ (persistedLinkedThreadPullRequest.projectId === projectId &&
3670+ persistedLinkedThreadPullRequest.repository.toLowerCase() ===
3671+ activeProjectRepository.toLowerCase() &&
3672+ persistedLinkedThreadPullRequest.number === detected.number)
3673+ ) {
3674+ return null;
3675+ }
3676+ return {
3677+ projectId,
3678+ repository: activeProjectRepository,
3679+ number: detected.number,
3680+ url: detected.url,
3681+ };
3682+ }, [
3683+ activeProject?.id,
3684+ activeProjectRepository,
3685+ activeThread?.branch,
3686+ gitStatusQuery.data,
3687+ persistedLinkedThreadPullRequest,
3688+ persistedLinkedThreadPullRequestStatus?.pr.state,
3689+ ]);
3690+ const linkedThreadPullRequest =
3691+ replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest;
36513692 const linkedThreadPullRequestKey = linkedThreadPullRequest
36523693 ? JSON.stringify([
36533694 linkedThreadPullRequest.projectId,
@@ -3655,7 +3696,6 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
36553696 linkedThreadPullRequest.number,
36563697 ])
36573698 : null;
3658− const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
36593699 const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
36603700 const openThreadPullRequest = useCallback(
36613701 (number: number) => {
@@ -3679,6 +3719,63 @@ function ChatViewContent(props: ChatViewProps) { · 17 unmodified lines
36793719 supportsPullRequests,
36803720 ],
36813721 );
3722+ useEffect(() => {
3723+ if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
3724+ return;
3725+ }
3726+ if (replacementLinkedThreadPullRequest === null) {
3727+ threadPrRelinkKeysRef.current.delete(activeThreadKey);
3728+ return;
3729+ }
3730+ const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`;
3731+ if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return;
3732+ threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey);
3733+ const openSurface = selectActiveRightPanelSurface(
3734+ useRightPanelStore.getState().byThreadKey,
3735+ activeThreadRef,
3736+ );
3737+ if (
3738+ openSurface?.kind === "pull-request" &&
3739+ persistedLinkedThreadPullRequest !== null &&
3740+ openSurface.projectId === persistedLinkedThreadPullRequest.projectId &&
3741+ openSurface.repository.toLowerCase() ===
3742+ persistedLinkedThreadPullRequest.repository.toLowerCase() &&
3743+ openSurface.number === persistedLinkedThreadPullRequest.number
3744+ ) {
3745+ useRightPanelStore
3746+ .getState()
3747+ .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest);
3748+ }
3749+
3750+ threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => {
3751+ if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
3752+ const result = await updateThreadMetadata({
3753+ environmentId: activeThreadRef.environmentId,
3754+ input: {
3755+ threadId: activeThreadRef.threadId,
3756+ linkedPullRequest: replacementLinkedThreadPullRequest,
3757+ },
3758+ });
3759+ if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
3760+ if (result._tag !== "Failure") return;
3761+ threadPrRelinkKeysRef.current.delete(activeThreadKey);
3762+ if (isAtomCommandInterrupted(result)) return;
3763+ toastManager.add(
3764+ stackedThreadToast({
3765+ type: "error",
3766+ title: "Unable to update the thread pull request",
3767+ description: chatActionErrorMessage(squashAtomCommandFailure(result)),
3768+ }),
3769+ );
3770+ });
3771+ }, [
3772+ activeThreadKey,
3773+ activeThreadRef,
3774+ isServerThread,
3775+ persistedLinkedThreadPullRequest,
3776+ replacementLinkedThreadPullRequest,
3777+ updateThreadMetadata,
3778+ ]);
36823779 const openProjectPullRequest = useCallback(
36833780 (number: number) => {
36843781 if (
@@ -4782,60 +4879,39 @@ function ChatViewContent(props: ChatViewProps) { · 1097 unmodified lines
47824879 resizeObserver.disconnect();
47834880 };
47844881 }, [composerOverlayElement]);
4785− const linkedPullRequestStatus = useLinkedThreadPullRequest(
4786− activeThreadRef?.environmentId ?? null,
4787− linkedThreadPullRequest,
4788− );
4789− const activeThreadPr = resolveDisplayedThreadPr({
4790− threadBranch: activeThread?.branch ?? null,
4791− gitStatus: gitStatusQuery.data ?? null,
4792− snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
4793− retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
4794− linkedPullRequest: linkedThreadPullRequest,
4795− linkedPullRequestStatus,
4796− });
4882+ const activeThreadPr =
4883+ replacementLinkedThreadPullRequest !== null
4884+ ? (gitStatusQuery.data?.pr ?? null)
4885+ : resolveDisplayedThreadPr({
4886+ threadBranch: activeThread?.branch ?? null,
4887+ gitStatus: gitStatusQuery.data ?? null,
4888+ snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
4889+ retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
4890+ linkedPullRequest: linkedThreadPullRequest,
4891+ linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus,
4892+ });
47974893 const handlePullRequestTabStatusChange = useCallback(
4798− (status: PullRequestTabStatus) => {
4799− const source = threadPullRequestRefreshSource({
4800− panel: status,
4801− thread: {
4802− repository: threadRepository,
4803− number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null,
4804− state: activeThreadPr?.state ?? null,
4805− linked: linkedThreadPullRequest !== null,
4806− },
4807− });
4808− if (source === null) {
4894+ (status: Pick<PullRequestTabStatus, "repository" | "number" | "state">) => {
4895+ if (
4896+ threadRepository?.toLowerCase() !== status.repository.toLowerCase() ||
4897+ activeThreadPr?.number !== status.number ||
4898+ activeThreadPr.state === status.state
4899+ ) {
48094900 sidebarPrRefreshKeyRef.current = null;
48104901 return;
48114902 }
4812− const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`;
4903+ const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`;
48134904 if (sidebarPrRefreshKeyRef.current === refreshKey) return;
48144905 sidebarPrRefreshKeyRef.current = refreshKey;
4815−
4816− if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) {
4817− appAtomRegistry.refresh(
4818− linkedPullRequestDetailAtom({
4819− environmentId: activeThreadRef.environmentId,
4820− input: {
4821− projectId: linkedThreadPullRequest.projectId,
4822− repository: linkedThreadPullRequest.repository,
4823− number: linkedThreadPullRequest.number,
4824− },
4825− }),
4826− );
4827− return;
4828− }
4829− if (source === "vcs" && activeThreadRef && gitCwd !== null) {
4830− void refreshVcsStatus({
4831− environmentId: activeThreadRef.environmentId,
4832− input: { cwd: gitCwd },
4833− }).then(() => {
4834− if (sidebarPrRefreshKeyRef.current === refreshKey) {
4835− sidebarPrRefreshKeyRef.current = null;
4836− }
4837− });
4838− }
4906+ if (activeThreadRef === null || gitCwd === null) return;
4907+ void refreshVcsStatus({
4908+ environmentId: activeThreadRef.environmentId,
4909+ input: { cwd: gitCwd },
4910+ }).then(() => {
4911+ if (sidebarPrRefreshKeyRef.current === refreshKey) {
4912+ sidebarPrRefreshKeyRef.current = null;
4913+ }
4914+ });
48394915 },
48404916 [
48414917 activeThreadKey,
@@ -4843,7 +4919,6 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
48434919 activeThreadPr?.state,
48444920 activeThreadRef,
48454921 gitCwd,
4846− linkedThreadPullRequest,
48474922 refreshVcsStatus,
48484923 threadRepository,
48494924 ],
@@ -7346,7 +7421,9 @@ function ChatViewContent(props: ChatViewProps) { · 2496 unmodified lines
73467421 : "page"
73477422 }
73487423 composerDraftTarget={composerDraftTarget}
7349− onStateChange={handlePullRequestTabStatusChange}
7424+ {...(linkedThreadPullRequest === null
7425+ ? { onStateChange: handlePullRequestTabStatusChange }
7426+ : {})}
73507427 />
73517428 ) : renderedRightPanelSurface?.kind === "agents" ? (
73527429 <AgentsPanel
apps/web/src/components/ThreadStatusIndicators.test.ts+33−47
@@ -1,4 +1,4 @@
1−import { ProjectId, type VcsStatusResult } from "@t3tools/contracts";
1+import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
22 import { describe, expect, it } from "@effect/vitest";
33 import * as Effect from "effect/Effect";
44 import { AtomRegistry } from "effect/unstable/reactivity";
@@ -10,10 +10,10 @@ import { · 5 unmodified lines
1010 resolveDisplayedThreadPrProvider,
1111 resolveThreadPr,
1212 settledPrHoverColorClass,
13− threadPullRequestRefreshSource,
1413 threadChangeRequestSnapshotsAtom,
1514 type ThreadChangeRequestSnapshot,
1615 } from "./ThreadStatusIndicators";
16+import { newestPullRequestSummary } from "../state/pullRequests";
1717
1818 function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
1919 return {
@@ -57,58 +57,44 @@ function snapshotFor( · 37 unmodified lines
5757 return { branch, pr, sourceControlProvider };
5858 }
5959
60−describe("threadPullRequestRefreshSource", () => {
61− const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const };
60+function pullRequestSummary(
61+ state: PullRequestSummary["state"],
62+ updatedAt: string,
63+): PullRequestSummary {
64+ return {
65+ provider: "github",
66+ projectId: ProjectId.make("project-1"),
67+ repository: "pingdotgg/t3code",
68+ number: 42,
69+ title: "Feature PR",
70+ url: "https://github.com/pingdotgg/t3code/pull/42",
71+ state,
72+ headBranch: "feature/current",
73+ baseBranch: "main",
74+ updatedAt,
75+ };
76+}
6277
63− it("refreshes the VCS stream when the open panel is newer than an inferred sidebar PR", () => {
64− expect(
65− threadPullRequestRefreshSource({
66− panel,
67− thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false },
68− }),
69− ).toBe("vcs");
70− });
78+describe("shared pull request state", () => {
79+ it("shows a panel-observed merge instead of an older sidebar summary", () => {
80+ const open = pullRequestSummary("open", "2026-09-03T01:00:00.000Z");
81+ const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z");
7182
72− it("refreshes linked detail when the open panel is newer than a linked sidebar PR", () => {
73− expect(
74− threadPullRequestRefreshSource({
75− panel,
76− thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: true },
77− }),
78− ).toBe("linked-detail");
83+ expect(newestPullRequestSummary(open, merged)).toBe(merged);
7984 });
8085
81− it("refreshes when the sidebar has not resolved state yet", () => {
82− expect(
83− threadPullRequestRefreshSource({
84− panel,
85− thread: { repository: "pingdotgg/t3code", number: 42, state: null, linked: false },
86− }),
87− ).toBe("vcs");
88− });
86+ it("never lets a stale open response regress a merged observation", () => {
87+ const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z");
88+ const staleOpen = pullRequestSummary("open", "2026-09-03T01:00:00.000Z");
8989
90− it("does nothing once sidebar state matches or the panel shows another PR", () => {
91− expect(
92− threadPullRequestRefreshSource({
93− panel,
94− thread: { repository: "pingdotgg/t3code", number: 42, state: "merged", linked: false },
95− }),
96− ).toBeNull();
97− expect(
98− threadPullRequestRefreshSource({
99− panel,
100− thread: { repository: "pingdotgg/t3code", number: 41, state: "open", linked: false },
101− }),
102− ).toBeNull();
90+ expect(newestPullRequestSummary(merged, staleOpen)).toBe(merged);
10391 });
10492
105− it("matches repository identity without case sensitivity", () => {
106− expect(
107− threadPullRequestRefreshSource({
108− panel: { ...panel, repository: "PingDotGG/T3Code" },
109− thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false },
110− }),
111− ).toBe("vcs");
93+ it("accepts a newer open state after a closed pull request is reopened", () => {
94+ const closed = pullRequestSummary("closed", "2026-09-03T01:00:00.000Z");
95+ const reopened = pullRequestSummary("open", "2026-09-03T01:01:00.000Z");
96+
97+ expect(newestPullRequestSummary(closed, reopened)).toBe(reopened);
11298 });
11399 });
114100
apps/web/src/components/ThreadStatusIndicators.tsx+3−28
@@ -12,7 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; · 11 unmodified lines
1212 import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1313 import { useProject } from "../state/entities";
1414 import { useEnvironmentQuery } from "../state/query";
15−import { linkedPullRequestDetailAtom } from "../state/pullRequests";
15+import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
1616 import { useThreadRunningTerminalIds } from "../state/terminalSessions";
1717 import { vcsEnvironment } from "../state/vcs";
1818 import { useUiStateStore } from "../uiStateStore";
@@ -39,32 +39,6 @@ export interface TerminalStatusIndicator { · 20 unmodified lines
3939
4040 export type ThreadPr = VcsStatusResult["pr"];
4141
42−export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs";
43−
44−/** Refresh only when the panel has newer state for this thread's own pull request. */
45−export function threadPullRequestRefreshSource(input: {
46− readonly panel: {
47− readonly repository: string;
48− readonly number: number;
49− readonly state: NonNullable<ThreadPr>["state"];
50− };
51− readonly thread: {
52− readonly repository: string | null;
53− readonly number: number | null;
54− readonly state: NonNullable<ThreadPr>["state"] | null;
55− readonly linked: boolean;
56− };
57−}): ThreadPullRequestRefreshSource | null {
58− if (
59− input.thread.repository?.toLowerCase() !== input.panel.repository.toLowerCase() ||
60− input.thread.number !== input.panel.number ||
61− input.thread.state === input.panel.state
62− ) {
63− return null;
64− }
65− return input.thread.linked ? "linked-detail" : "vcs";
66−}
67−
6842 export interface LinkedThreadPullRequestStatus {
6943 readonly pr: NonNullable<ThreadPr>;
7044 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
@@ -74,7 +48,7 @@ export function useLinkedThreadPullRequest( · 3 unmodified lines
7448 environmentId: EnvironmentId | null,
7549 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
7650 ): LinkedThreadPullRequestStatus | null {
77− const detail = useEnvironmentQuery(
51+ const queried = useEnvironmentQuery(
7852 environmentId === null || linkedPullRequest == null
7953 ? null
8054 : linkedPullRequestDetailAtom({
@@ -86,6 +60,7 @@ export function useLinkedThreadPullRequest( · 5 unmodified lines
8660 },
8761 }),
8862 ).data;
63+ const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried);
8964
9065 return useMemo(
9166 () =>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+18−18
@@ -63,7 +63,7 @@ import { useProjects } from "~/state/entities"; · 62 unmodified lines
6363 import { useEnvironments } from "~/state/environments";
6464 import { useEnvironmentQuery } from "~/state/query";
6565 import { useLiveRefresh } from "~/hooks/useLiveRefresh";
66−import { pullRequestEnvironment } from "~/state/pullRequests";
66+import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests";
6767 import { useAtomCommand } from "~/state/use-atom-command";
6868 import { vcsEnvironment } from "~/state/vcs";
6969 import { formatRelativeTimeLabel } from "~/timestampFormat";
@@ -476,14 +476,8 @@ export function PullRequestDetailPanel({ · 406 unmodified lines
476476 onActed?: () => void;
477477 /** Page-owned detail columns use this to clear the selected pull request. */
478478 onClose?: () => void;
479− /** Keeps surrounding thread state in step with refreshed host state. */
480− onStateChange?: (status: {
481− projectId: string;
482− repository: string;
483− number: number;
484− state: PullRequestState;
485− isDraft: boolean;
486− }) => void;
479+ /** Keeps surrounding inferred thread state in step with refreshed host state. */
480+ onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void;
487481 /**
488482 * Beside a thread, the checkout affordance disappears: the panel is showing that thread's
489483 * own pull request, so the branch is already under the reader's feet — and checking it out
@@ -606,11 +600,19 @@ export function PullRequestDetailPanel({ · 116 unmodified lines
606600 reference.repository,
607601 reference.number,
608602 ]);
609− const coreDetail = resolveDisplayedPullRequestDetail({
603+ const resolvedCoreDetail = resolveDisplayedPullRequestDetail({
610604 live: detailQuery.data,
611605 cached: cachedDetail,
612606 reference,
613607 });
608+ const sharedSummary = useSharedPullRequestSummary(environmentId, reference, resolvedCoreDetail);
609+ const coreDetail = useMemo(
610+ () =>
611+ resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail
612+ ? resolvedCoreDetail
613+ : { ...resolvedCoreDetail, ...sharedSummary },
614+ [resolvedCoreDetail, sharedSummary],
615+ );
614616 const activity = activityQuery.data;
615617 const detail = useMemo(
616618 () =>
@@ -674,16 +676,14 @@ export function PullRequestDetailPanel({ · 57 unmodified lines
674676 }
675677 activityRevision.current = next;
676678 }, [activityQuery.refresh, coreDetail, pullRequestKey]);
677− useEffect(() => {
678− if (!detail) return;
679+ useLayoutEffect(() => {
680+ if (!resolvedCoreDetail) return;
679681 onStateChange?.({
680− projectId: detail.projectId,
681− repository: detail.repository,
682− number: detail.number,
683− state: detail.state,
684− isDraft: detail.isDraft,
682+ repository: resolvedCoreDetail.repository,
683+ number: resolvedCoreDetail.number,
684+ state: resolvedCoreDetail.state,
685685 });
686− }, [detail, onStateChange]);
686+ }, [onStateChange, resolvedCoreDetail]);
687687 // Core detail is cheap enough to re-read while this stays open. Activity is heavier, so the
688688 // revision effect above reads it only after this same pull request reports a change. Keyed by
689689 // the pull request rather than by the panel, because this one panel shows a different pull
apps/web/src/state/pullRequests.ts+47−1
@@ -7,10 +7,12 @@ import type { · 6 unmodified lines
77 EnvironmentId,
88 PullRequestListInput,
99 PullRequestListStatsInput,
10+ PullRequestRef,
11+ PullRequestSummary,
1012 } from "@t3tools/contracts";
1113 import * as Option from "effect/Option";
1214 import { AsyncResult, Atom } from "effect/unstable/reactivity";
13−import { useCallback, useMemo } from "react";
15+import { useCallback, useLayoutEffect, useMemo } from "react";
1416
1517 import { connectionAtomRuntime } from "../connection/runtime";
1618 import { appAtomRegistry } from "../rpc/atomRegistry";
@@ -25,6 +27,50 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti · 8 unmodified lines
2527 export const linkedPullRequestDetailAtom =
2628 createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
2729
30+const observedPullRequestSummaryAtom = Atom.family((key: string) =>
31+ Atom.make<PullRequestSummary | null>(null).pipe(
32+ Atom.setIdleTTL(5 * 60_000),
33+ Atom.withLabel(`web-pull-requests:observed-summary:${key}`),
34+ ),
35+);
36+
37+export function newestPullRequestSummary(
38+ current: PullRequestSummary | null,
39+ observed: PullRequestSummary | null,
40+): PullRequestSummary | null {
41+ if (current === null) return observed;
42+ if (observed === null) return current;
43+ if (current.state === "merged") return current;
44+ if (observed.state === "merged") return observed;
45+ return Date.parse(observed.updatedAt) >= Date.parse(current.updatedAt) ? observed : current;
46+}
47+
48+export function useSharedPullRequestSummary(
49+ environmentId: EnvironmentId | null,
50+ reference: PullRequestRef | null,
51+ current: PullRequestSummary | null,
52+): PullRequestSummary | null {
53+ const key =
54+ environmentId === null || reference === null
55+ ? "none"
56+ : JSON.stringify([
57+ environmentId,
58+ reference.projectId,
59+ reference.repository.toLowerCase(),
60+ reference.number,
61+ ]);
62+ const atom = observedPullRequestSummaryAtom(key);
63+ const observed = useAtomValue(atom);
64+ useLayoutEffect(() => {
65+ if (environmentId === null || current === null) return;
66+ appAtomRegistry.modify(atom, (previous) => {
67+ const next = newestPullRequestSummary(previous, current);
68+ return next === previous ? [false, previous] : [true, next];
69+ });
70+ }, [atom, current, environmentId]);
71+ return newestPullRequestSummary(current, observed);
72+}
73+
2874 export interface EnvironmentQueryTarget<Input> {
2975 readonly environmentId: EnvironmentId;
3076 readonly input: Input;