authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 01:47:41-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 01:47:41-04:00
log854541a04e07b7960698b381bcfd2fda73eb276c
treeef75ff2224c1cee716bafdba554999489c4e4834
parent48ba76bc26337f23d1f0991bbac51f996a3333dc
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

9 files changed, 439 insertions(+), 165 deletions(-)

apps/server/src/orchestration/ThreadSettlementReactor.test.ts+71-3
......@@ -25,7 +25,10 @@ import * as Stream from "effect/Stream";
2525import { TestClock } from "effect/testing";
2626
2727import { GitManager } from "../git/GitManager.ts";
28import { PullRequestService } from "../pullRequest/PullRequestService.ts";
28import {
29 PullRequestService,
30 type PullRequestMergeEvent,
31} from "../pullRequest/PullRequestService.ts";
2932import { ServerActivation } from "../serverActivation.ts";
3033import { ServerSettingsService } from "../serverSettings.ts";
3134import { OrchestrationCommandInvariantError } from "./Errors.ts";
......@@ -143,6 +146,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
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:
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:
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:
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", () => {
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* () {
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* () {
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* () {
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* () {
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* () {
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
......@@ -1,7 +1,10 @@
11import { assert, it } from "@effect/vitest";
22import * as Deferred from "effect/Deferred";
33import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
45import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Stream from "effect/Stream";
58import * as TestClock from "effect/testing/TestClock";
69import type {
710 OrchestrationProjectShell,
......@@ -1002,6 +1005,41 @@ it.effect("refuses an action the host never claimed it could run", () =>
10021005 }),
10031006);
10041007
1008it.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
10051043it.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", (
31923230 }),
31933231);
31943232
3233it.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
31953259it.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 @@
11import * as Cache from "effect/Cache";
22import * as Clock from "effect/Clock";
33import * as Context from "effect/Context";
4import * as DateTime from "effect/DateTime";
45import * as Duration from "effect/Duration";
56import * as Effect from "effect/Effect";
67import * as Exit from "effect/Exit";
78import * as Layer from "effect/Layer";
9import * as PubSub from "effect/PubSub";
810import * as Schema from "effect/Schema";
11import type * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
913import {
1014 PullRequestOperationError,
1115 PullRequestUnavailableError,
......@@ -63,6 +67,10 @@ import {
6367} from "./PullRequestProvider.ts";
6468import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts";
6569
70export 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<
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
517530}
518531
519532export 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* () {
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* () {
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* () {
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* () {
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* () {
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";
265265import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
266266import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
267267import { projectEnvironment } from "../state/projects";
268import { linkedPullRequestDetailAtom } from "../state/pullRequests";
269268import { useEnvironmentQuery } from "../state/query";
270269import {
271270 environmentServerConfigsAtom,
......@@ -325,7 +324,6 @@ import {
325324} from "./chat/ThreadErrorBanner";
326325import {
327326 resolveDisplayedThreadPr,
328 threadPullRequestRefreshSource,
329327 threadChangeRequestSnapshotsAtom,
330328 useLinkedThreadPullRequest,
331329} from "./ThreadStatusIndicators";
......@@ -1787,6 +1785,8 @@ function ChatViewContent(props: ChatViewProps) {
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) {
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) {
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) {
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) {
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) {
48434919 activeThreadPr?.state,
48444920 activeThreadRef,
48454921 gitCwd,
4846 linkedThreadPullRequest,
48474922 refreshVcsStatus,
48484923 threadRepository,
48494924 ],
......@@ -7346,7 +7421,9 @@ function ChatViewContent(props: ChatViewProps) {
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 @@
1import { ProjectId, type VcsStatusResult } from "@t3tools/contracts";
1import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
22import { describe, expect, it } from "@effect/vitest";
33import * as Effect from "effect/Effect";
44import { AtomRegistry } from "effect/unstable/reactivity";
......@@ -10,10 +10,10 @@ import {
1010 resolveDisplayedThreadPrProvider,
1111 resolveThreadPr,
1212 settledPrHoverColorClass,
13 threadPullRequestRefreshSource,
1413 threadChangeRequestSnapshotsAtom,
1514 type ThreadChangeRequestSnapshot,
1615} from "./ThreadStatusIndicators";
16import { newestPullRequestSummary } from "../state/pullRequests";
1717
1818function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
1919 return {
......@@ -57,58 +57,44 @@ function snapshotFor(
5757 return { branch, pr, sourceControlProvider };
5858}
5959
60describe("threadPullRequestRefreshSource", () => {
61 const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const };
60function 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 });
78describe("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";
1212import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1313import { useProject } from "../state/entities";
1414import { useEnvironmentQuery } from "../state/query";
15import { linkedPullRequestDetailAtom } from "../state/pullRequests";
15import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
1616import { useThreadRunningTerminalIds } from "../state/terminalSessions";
1717import { vcsEnvironment } from "../state/vcs";
1818import { useUiStateStore } from "../uiStateStore";
......@@ -39,32 +39,6 @@ export interface TerminalStatusIndicator {
3939
4040export type ThreadPr = VcsStatusResult["pr"];
4141
42export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs";
43
44/** Refresh only when the panel has newer state for this thread's own pull request. */
45export 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
6842export interface LinkedThreadPullRequestStatus {
6943 readonly pr: NonNullable<ThreadPr>;
7044 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
......@@ -74,7 +48,7 @@ export function useLinkedThreadPullRequest(
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(
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";
6363import { useEnvironments } from "~/state/environments";
6464import { useEnvironmentQuery } from "~/state/query";
6565import { useLiveRefresh } from "~/hooks/useLiveRefresh";
66import { pullRequestEnvironment } from "~/state/pullRequests";
66import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests";
6767import { useAtomCommand } from "~/state/use-atom-command";
6868import { vcsEnvironment } from "~/state/vcs";
6969import { formatRelativeTimeLabel } from "~/timestampFormat";
......@@ -476,14 +476,8 @@ export function PullRequestDetailPanel({
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({
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({
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 {
77 EnvironmentId,
88 PullRequestListInput,
99 PullRequestListStatsInput,
10 PullRequestRef,
11 PullRequestSummary,
1012} from "@t3tools/contracts";
1113import * as Option from "effect/Option";
1214import { AsyncResult, Atom } from "effect/unstable/reactivity";
13import { useCallback, useMemo } from "react";
15import { useCallback, useLayoutEffect, useMemo } from "react";
1416
1517import { connectionAtomRuntime } from "../connection/runtime";
1618import { appAtomRegistry } from "../rpc/atomRegistry";
......@@ -25,6 +27,50 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti
2527export const linkedPullRequestDetailAtom =
2628 createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);
2729
30const 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
37export 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
48export 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
2874export interface EnvironmentQueryTarget<Input> {
2975 readonly environmentId: EnvironmentId;
3076 readonly input: Input;