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";...@@ -25,7 +25,10 @@ import * as Stream from "effect/Stream";
25import { TestClock } from "effect/testing";25import { TestClock } from "effect/testing";
2626
27import { GitManager } from "../git/GitManager.ts";27import { GitManager } from "../git/GitManager.ts";
28import { PullRequestService } from "../pullRequest/PullRequestService.ts";28import {
29 PullRequestService,
30 type PullRequestMergeEvent,
31} from "../pullRequest/PullRequestService.ts";
29import { ServerActivation } from "../serverActivation.ts";32import { ServerActivation } from "../serverActivation.ts";
30import { ServerSettingsService } from "../serverSettings.ts";33import { ServerSettingsService } from "../serverSettings.ts";
31import { OrchestrationCommandInvariantError } from "./Errors.ts";34import { OrchestrationCommandInvariantError } from "./Errors.ts";
...@@ -143,6 +146,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -143,6 +146,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
143 const snapshotReads = yield* Queue.unbounded<number>();146 const snapshotReads = yield* Queue.unbounded<number>();
144 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);147 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
145 const settingsChanges = yield* PubSub.unbounded<ServerSettings>();148 const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
149 const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>();
146 const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);150 const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);
147 const branchCalls = yield* Ref.make<151 const branchCalls = yield* Ref.make<
148 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>152 ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
...@@ -168,7 +172,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -168,7 +172,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
168 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(172 Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
169 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),173 Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
170 );174 );
171
172 const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>175 const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
173 Effect.gen(function* () {176 Effect.gen(function* () {
174 yield* Ref.update(summaryCalls, (calls) => [...calls, input]);177 yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
...@@ -217,7 +220,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -217,7 +220,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
217 ),220 ),
218 }),221 }),
219 Layer.mock(GitManager)({ branchPullRequest }),222 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 }),
221 Layer.mock(OrchestrationEngineService)({229 Layer.mock(OrchestrationEngineService)({
222 readEvents: () => Stream.empty,230 readEvents: () => Stream.empty,
223 dispatch,231 dispatch,
...@@ -239,6 +247,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -239,6 +247,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
239 summaryCalls,247 summaryCalls,
240 summaryRecovery,248 summaryRecovery,
241 updateSettings,249 updateSettings,
250 publishMerge: PubSub.publish(mergedPullRequests, {
251 projectId: PROJECT_ID,
252 repository: "owner/repository",
253 number: 42,
254 mergedAt: NOW,
255 }),
242 layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),256 layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
243 };257 };
244});258});
...@@ -373,6 +387,60 @@ describe("ThreadSettlementReactor", () => {...@@ -373,6 +387,60 @@ describe("ThreadSettlementReactor", () => {
373 ),387 ),
374 );388 );
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
376 it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () =>444 it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () =>
377 Effect.scoped(445 Effect.scoped(
378 Effect.gen(function* () {446 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+25-6
...@@ -38,11 +38,22 @@ export const make = Effect.gen(function* () {...@@ -38,11 +38,22 @@ export const make = Effect.gen(function* () {
38 const pullRequests = yield* PullRequestService.PullRequestService;38 const pullRequests = yield* PullRequestService.PullRequestService;
39 const crypto = yield* Crypto.Crypto;39 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 ) {
42 const snapshot = yield* snapshots.getShellSnapshot();44 const snapshot = yield* snapshots.getShellSnapshot();
43 const now = DateTime.formatIso(yield* DateTime.now);45 const now = DateTime.formatIso(yield* DateTime.now);
44 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));46 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 );
46 const lookupKey = (thread: (typeof candidates)[number]) => {57 const lookupKey = (thread: (typeof candidates)[number]) => {
47 if (thread.linkedPullRequest != null) {58 if (thread.linkedPullRequest != null) {
48 return JSON.stringify([59 return JSON.stringify([
...@@ -66,6 +77,12 @@ export const make = Effect.gen(function* () {...@@ -66,6 +77,12 @@ export const make = Effect.gen(function* () {
66 thread: (typeof candidates)[number],77 thread: (typeof candidates)[number],
67 ) {78 ) {
68 if (thread.linkedPullRequest != null) {79 if (thread.linkedPullRequest != null) {
80 if (mergedPullRequest !== null) {
81 return {
82 state: "merged",
83 updatedAt: mergedPullRequest.mergedAt,
84 } satisfies SettlementPullRequest;
85 }
69 if (!projects.has(thread.linkedPullRequest.projectId)) {86 if (!projects.has(thread.linkedPullRequest.projectId)) {
70 return yield* Effect.die(new Error("linked pull request project not found"));87 return yield* Effect.die(new Error("linked pull request project not found"));
71 }88 }
...@@ -145,8 +162,8 @@ export const make = Effect.gen(function* () {...@@ -145,8 +162,8 @@ export const make = Effect.gen(function* () {
145 );162 );
146 });163 });
147164
148 const worker = yield* makeDrainableWorker(() =>165 const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) =>
149 sweep().pipe(166 sweep(mergedPullRequest).pipe(
150 Effect.catchCause((cause) =>167 Effect.catchCause((cause) =>
151 Cause.hasInterruptsOnly(cause)168 Cause.hasInterruptsOnly(cause)
152 ? Effect.failCause(cause)169 ? Effect.failCause(cause)
...@@ -154,13 +171,14 @@ export const make = Effect.gen(function* () {...@@ -154,13 +171,14 @@ export const make = Effect.gen(function* () {
154 cause: Cause.pretty(cause),171 cause: Cause.pretty(cause),
155 }),172 }),
156 ),173 ),
157 ),174 );
158 );175 const worker = yield* makeDrainableWorker(() => runSweep(null));
159176
160 const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(177 const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(
161 "ThreadSettlementReactor.start",178 "ThreadSettlementReactor.start",
162 )(function* () {179 )(function* () {
163 const settingsChanges = yield* settingsService.subscribeChanges;180 const settingsChanges = yield* settingsService.subscribeChanges;
181 const mergedPullRequests = yield* pullRequests.subscribeMerges;
164 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);182 const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
165 let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;183 let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
166 let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;184 let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
...@@ -183,6 +201,7 @@ export const make = Effect.gen(function* () {...@@ -183,6 +201,7 @@ export const make = Effect.gen(function* () {
183 return worker.enqueue(undefined);201 return worker.enqueue(undefined);
184 }),202 }),
185 );203 );
204 yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep));
186 });205 });
187206
188 return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"];207 return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"];
apps/server/src/pullRequest/PullRequestService.test.ts+64
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
4import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Stream from "effect/Stream";
5import * as TestClock from "effect/testing/TestClock";8import * as TestClock from "effect/testing/TestClock";
6import type {9import type {
7 OrchestrationProjectShell,10 OrchestrationProjectShell,
...@@ -1002,6 +1005,41 @@ it.effect("refuses an action the host never claimed it could run", () =>...@@ -1002,6 +1005,41 @@ it.effect("refuses an action the host never claimed it could run", () =>
1002 }),1005 }),
1003);1006);
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
1005it.effect("refuses an action this viewer may not take, and says what access it takes", () =>1043it.effect("refuses an action this viewer may not take, and says what access it takes", () =>
1006 Effect.gen(function* () {1044 Effect.gen(function* () {
1007 let ran: string | null = null;1045 let ran: string | null = null;
...@@ -3192,6 +3230,32 @@ it.effect("does not ask the host again for a linked summary it already holds", (...@@ -3192,6 +3230,32 @@ it.effect("does not ask the host again for a linked summary it already holds", (
3192 }),3230 }),
3193);3231);
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
3195it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () =>3259it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () =>
3196 Effect.gen(function* () {3260 Effect.gen(function* () {
3197 const gate = yield* Deferred.make<void>();3261 const gate = yield* Deferred.make<void>();
apps/server/src/pullRequest/PullRequestService.ts+47-8
...@@ -1,11 +1,15 @@...@@ -1,11 +1,15 @@
1import * as Cache from "effect/Cache";1import * as Cache from "effect/Cache";
2import * as Clock from "effect/Clock";2import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";3import * as Context from "effect/Context";
4import * as DateTime from "effect/DateTime";
4import * as Duration from "effect/Duration";5import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";7import * as Exit from "effect/Exit";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9import * as PubSub from "effect/PubSub";
8import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import type * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
9import {13import {
10 PullRequestOperationError,14 PullRequestOperationError,
11 PullRequestUnavailableError,15 PullRequestUnavailableError,
...@@ -63,6 +67,10 @@ import {...@@ -63,6 +67,10 @@ import {
63} from "./PullRequestProvider.ts";67} from "./PullRequestProvider.ts";
64import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts";68import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts";
6569
70export interface PullRequestMergeEvent extends PullRequestRef {
71 readonly mergedAt: string;
72}
73
66/**74/**
67 * Rows per repository when the client does not ask for a page size, and rows per slice when a75 * Rows per repository when the client does not ask for a page size, and rows per slice when a
68 * listing is carried on from a cursor.76 * listing is carried on from a cursor.
...@@ -135,6 +143,11 @@ export class PullRequestService extends Context.Service<...@@ -135,6 +143,11 @@ export class PullRequestService extends Context.Service<
135 input: PullRequestRef,143 input: PullRequestRef,
136 options?: { readonly recoverTransientFailure?: boolean },144 options?: { readonly recoverTransientFailure?: boolean },
137 ) => Effect.Effect<PullRequestSummary, PullRequestError>;145 ) => Effect.Effect<PullRequestSummary, PullRequestError>;
146 readonly subscribeMerges: Effect.Effect<
147 Stream.Stream<PullRequestMergeEvent>,
148 never,
149 Scope.Scope
150 >;
138 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;151 readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
139 readonly activity: (152 readonly activity: (
140 input: PullRequestRef,153 input: PullRequestRef,
...@@ -517,6 +530,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string...@@ -517,6 +530,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string
517}530}
518531
519export const make = Effect.gen(function* () {532export const make = Effect.gen(function* () {
533 const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);
520 const registry = yield* PullRequestProviderRegistry;534 const registry = yield* PullRequestProviderRegistry;
521 const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;535 const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
522 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;536 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
...@@ -1410,9 +1424,9 @@ export const make = Effect.gen(function* () {...@@ -1410,9 +1424,9 @@ export const make = Effect.gen(function* () {
1410 }),1424 }),
1411 );1425 );
14121426
1413 const runAction: PullRequestService["Service"]["runAction"] = (input) =>1427 const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> =>
1414 requireProject(input).pipe(1428 requireProject(input).pipe(
1415 Effect.flatMap((project): Effect.Effect<void, PullRequestError> => {1429 Effect.flatMap((project): Effect.Effect<string, PullRequestError> => {
1416 // The surface hides what a host cannot do, and this refuses it as well: a request that1430 // The surface hides what a host cannot do, and this refuses it as well: a request that
1417 // reached here anyway must not be handed to a provider that never claimed the action.1431 // reached here anyway must not be handed to a provider that never claimed the action.
1418 if (!project.api.capabilities.actions.includes(input.action)) {1432 if (!project.api.capabilities.actions.includes(input.action)) {
...@@ -1454,7 +1468,7 @@ export const make = Effect.gen(function* () {...@@ -1454,7 +1468,7 @@ export const make = Effect.gen(function* () {
1454 // have to say yes. The second is asked last, because it costs a request and the checks1468 // have to say yes. The second is asked last, because it costs a request and the checks
1455 // above do not.1469 // above do not.
1456 return viewerPermissionsOf(project, input, "runAction").pipe(1470 return viewerPermissionsOf(project, input, "runAction").pipe(
1457 Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => {1471 Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => {
1458 if (!viewer.actions.includes(input.action)) {1472 if (!viewer.actions.includes(input.action)) {
1459 return Effect.fail(1473 return Effect.fail(
1460 new PullRequestOperationError({1474 new PullRequestOperationError({
...@@ -1484,7 +1498,10 @@ export const make = Effect.gen(function* () {...@@ -1484,7 +1498,10 @@ export const make = Effect.gen(function* () {
1484 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),1498 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
1485 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),1499 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
1486 })1500 })
1487 .pipe(Effect.mapError(toPullRequestError("runAction")));1501 .pipe(
1502 Effect.mapError(toPullRequestError("runAction")),
1503 Effect.as(project.repository),
1504 );
1488 }),1505 }),
1489 );1506 );
1490 }),1507 }),
...@@ -2151,9 +2168,13 @@ export const make = Effect.gen(function* () {...@@ -2151,9 +2168,13 @@ export const make = Effect.gen(function* () {
2151 const summary: PullRequestService["Service"]["summary"] = (input, options) => {2168 const summary: PullRequestService["Service"]["summary"] = (input, options) => {
2152 const key = refCacheKey(input);2169 const key = refCacheKey(input);
2153 const cached = Cache.get(summaryCache, key);2170 const cached = Cache.get(summaryCache, key);
2154 return options?.recoverTransientFailure === false2171 if (options?.recoverTransientFailure !== false) {
2155 ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value)))2172 return lastGoodSummary.serveHeld(key, cached, "reuse");
2156 : 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)));
2157 };2178 };
21582179
2159 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder2180 // 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* () {...@@ -2391,17 +2412,35 @@ export const make = Effect.gen(function* () {
2391 }),2412 }),
2392 ),2413 ),
2393 );2414 );
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
2395 return PullRequestService.of({2431 return PullRequestService.of({
2396 list,2432 list,
2397 listStats,2433 listStats,
2398 summary,2434 summary,
2435 subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
2436 Effect.map((subscription) => Stream.fromSubscription(subscription)),
2437 ),
2399 detail,2438 detail,
2400 activity,2439 activity,
2401 threadComments,2440 threadComments,
2402 diff,2441 diff,
2403 diffFileContents,2442 diffFileContents,
2404 runAction: invalidatedByMutation(runAction),2443 runAction: runActionAndInvalidate,
2405 update: invalidatedByMutation(update),2444 update: invalidatedByMutation(update),
2406 comment: invalidatedByMutation(comment),2445 comment: invalidatedByMutation(comment),
2407 updateComment: invalidatedByMutation(updateComment),2446 updateComment: invalidatedByMutation(updateComment),
apps/web/src/components/ChatView.tsx+131-54
...@@ -265,7 +265,6 @@ import { environmentCatalog } from "../connection/catalog";...@@ -265,7 +265,6 @@ import { environmentCatalog } from "../connection/catalog";
265import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";265import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
266import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";266import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
267import { projectEnvironment } from "../state/projects";267import { projectEnvironment } from "../state/projects";
268import { linkedPullRequestDetailAtom } from "../state/pullRequests";
269import { useEnvironmentQuery } from "../state/query";268import { useEnvironmentQuery } from "../state/query";
270import {269import {
271 environmentServerConfigsAtom,270 environmentServerConfigsAtom,
...@@ -325,7 +324,6 @@ import {...@@ -325,7 +324,6 @@ import {
325} from "./chat/ThreadErrorBanner";324} from "./chat/ThreadErrorBanner";
326import {325import {
327 resolveDisplayedThreadPr,326 resolveDisplayedThreadPr,
328 threadPullRequestRefreshSource,
329 threadChangeRequestSnapshotsAtom,327 threadChangeRequestSnapshotsAtom,
330 useLinkedThreadPullRequest,328 useLinkedThreadPullRequest,
331} from "./ThreadStatusIndicators";329} from "./ThreadStatusIndicators";
...@@ -1787,6 +1785,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1787,6 +1785,8 @@ function ChatViewContent(props: ChatViewProps) {
1787 );1785 );
1788 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });1786 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1789 const sidebarPrRefreshKeyRef = useRef<string | null>(null);1787 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
1788 const threadPrRelinkKeysRef = useRef(new Map<string, string>());
1789 const threadPrRelinkWriteRef = useRef(Promise.resolve());
1790 const activePreviewState = useThreadPreviewState(activeThreadRef);1790 const activePreviewState = useThreadPreviewState(activeThreadRef);
1791 const activePreviewServerEpoch = activePreviewState.serverEpoch;1791 const activePreviewServerEpoch = activePreviewState.serverEpoch;
1792 const resolvePreviewRuntimeTabId = useMemo(1792 const resolvePreviewRuntimeTabId = useMemo(
...@@ -3645,9 +3645,50 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3645,9 +3645,50 @@ function ChatViewContent(props: ChatViewProps) {
3645 );3645 );
3646 // The thread's own change request, placed against the project it belongs to. Without a3646 // The thread's own change request, placed against the project it belongs to. Without a
3647 // project there is nothing to resolve it against, so the caller falls back to the browser.3647 // project there is nothing to resolve it against, so the caller falls back to the browser.
3648 const linkedThreadPullRequest = isServerThread3648 const persistedLinkedThreadPullRequest = isServerThread
3649 ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null)3649 ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null)
3650 : (activeThread?.linkedPullRequest ?? null);3650 : (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;
3651 const linkedThreadPullRequestKey = linkedThreadPullRequest3692 const linkedThreadPullRequestKey = linkedThreadPullRequest
3652 ? JSON.stringify([3693 ? JSON.stringify([
3653 linkedThreadPullRequest.projectId,3694 linkedThreadPullRequest.projectId,
...@@ -3655,7 +3696,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3655,7 +3696,6 @@ function ChatViewContent(props: ChatViewProps) {
3655 linkedThreadPullRequest.number,3696 linkedThreadPullRequest.number,
3656 ])3697 ])
3657 : null;3698 : null;
3658 const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null;
3659 const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;3699 const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
3660 const openThreadPullRequest = useCallback(3700 const openThreadPullRequest = useCallback(
3661 (number: number) => {3701 (number: number) => {
...@@ -3679,6 +3719,63 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3679,6 +3719,63 @@ function ChatViewContent(props: ChatViewProps) {
3679 supportsPullRequests,3719 supportsPullRequests,
3680 ],3720 ],
3681 );3721 );
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 ]);
3682 const openProjectPullRequest = useCallback(3779 const openProjectPullRequest = useCallback(
3683 (number: number) => {3780 (number: number) => {
3684 if (3781 if (
...@@ -4782,60 +4879,39 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4782,60 +4879,39 @@ function ChatViewContent(props: ChatViewProps) {
4782 resizeObserver.disconnect();4879 resizeObserver.disconnect();
4783 };4880 };
4784 }, [composerOverlayElement]);4881 }, [composerOverlayElement]);
4785 const linkedPullRequestStatus = useLinkedThreadPullRequest(4882 const activeThreadPr =
4786 activeThreadRef?.environmentId ?? null,4883 replacementLinkedThreadPullRequest !== null
4787 linkedThreadPullRequest,4884 ? (gitStatusQuery.data?.pr ?? null)
4788 );4885 : resolveDisplayedThreadPr({
4789 const activeThreadPr = resolveDisplayedThreadPr({4886 threadBranch: activeThread?.branch ?? null,
4790 threadBranch: activeThread?.branch ?? null,4887 gitStatus: gitStatusQuery.data ?? null,
4791 gitStatus: gitStatusQuery.data ?? null,4888 snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,
4792 snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined,4889 retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,
4793 retainTerminalOnBranchMismatch: activeThread?.worktreePath === null,4890 linkedPullRequest: linkedThreadPullRequest,
4794 linkedPullRequest: linkedThreadPullRequest,4891 linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus,
4795 linkedPullRequestStatus,4892 });
4796 });
4797 const handlePullRequestTabStatusChange = useCallback(4893 const handlePullRequestTabStatusChange = useCallback(
4798 (status: PullRequestTabStatus) => {4894 (status: Pick<PullRequestTabStatus, "repository" | "number" | "state">) => {
4799 const source = threadPullRequestRefreshSource({4895 if (
4800 panel: status,4896 threadRepository?.toLowerCase() !== status.repository.toLowerCase() ||
4801 thread: {4897 activeThreadPr?.number !== status.number ||
4802 repository: threadRepository,4898 activeThreadPr.state === status.state
4803 number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null,4899 ) {
4804 state: activeThreadPr?.state ?? null,
4805 linked: linkedThreadPullRequest !== null,
4806 },
4807 });
4808 if (source === null) {
4809 sidebarPrRefreshKeyRef.current = null;4900 sidebarPrRefreshKeyRef.current = null;
4810 return;4901 return;
4811 }4902 }
4812 const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`;4903 const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`;
4813 if (sidebarPrRefreshKeyRef.current === refreshKey) return;4904 if (sidebarPrRefreshKeyRef.current === refreshKey) return;
4814 sidebarPrRefreshKeyRef.current = refreshKey;4905 sidebarPrRefreshKeyRef.current = refreshKey;
48154906 if (activeThreadRef === null || gitCwd === null) return;
4816 if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) {4907 void refreshVcsStatus({
4817 appAtomRegistry.refresh(4908 environmentId: activeThreadRef.environmentId,
4818 linkedPullRequestDetailAtom({4909 input: { cwd: gitCwd },
4819 environmentId: activeThreadRef.environmentId,4910 }).then(() => {
4820 input: {4911 if (sidebarPrRefreshKeyRef.current === refreshKey) {
4821 projectId: linkedThreadPullRequest.projectId,4912 sidebarPrRefreshKeyRef.current = null;
4822 repository: linkedThreadPullRequest.repository,4913 }
4823 number: linkedThreadPullRequest.number,4914 });
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 }
4839 },4915 },
4840 [4916 [
4841 activeThreadKey,4917 activeThreadKey,
...@@ -4843,7 +4919,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4843,7 +4919,6 @@ function ChatViewContent(props: ChatViewProps) {
4843 activeThreadPr?.state,4919 activeThreadPr?.state,
4844 activeThreadRef,4920 activeThreadRef,
4845 gitCwd,4921 gitCwd,
4846 linkedThreadPullRequest,
4847 refreshVcsStatus,4922 refreshVcsStatus,
4848 threadRepository,4923 threadRepository,
4849 ],4924 ],
...@@ -7346,7 +7421,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7346,7 +7421,9 @@ function ChatViewContent(props: ChatViewProps) {
7346 : "page"7421 : "page"
7347 }7422 }
7348 composerDraftTarget={composerDraftTarget}7423 composerDraftTarget={composerDraftTarget}
7349 onStateChange={handlePullRequestTabStatusChange}7424 {...(linkedThreadPullRequest === null
7425 ? { onStateChange: handlePullRequestTabStatusChange }
7426 : {})}
7350 />7427 />
7351 ) : renderedRightPanelSurface?.kind === "agents" ? (7428 ) : renderedRightPanelSurface?.kind === "agents" ? (
7352 <AgentsPanel7429 <AgentsPanel
apps/web/src/components/ThreadStatusIndicators.test.ts+33-47
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { ProjectId, type VcsStatusResult } from "@t3tools/contracts";1import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import { AtomRegistry } from "effect/unstable/reactivity";4import { AtomRegistry } from "effect/unstable/reactivity";
...@@ -10,10 +10,10 @@ import {...@@ -10,10 +10,10 @@ import {
10 resolveDisplayedThreadPrProvider,10 resolveDisplayedThreadPrProvider,
11 resolveThreadPr,11 resolveThreadPr,
12 settledPrHoverColorClass,12 settledPrHoverColorClass,
13 threadPullRequestRefreshSource,
14 threadChangeRequestSnapshotsAtom,13 threadChangeRequestSnapshotsAtom,
15 type ThreadChangeRequestSnapshot,14 type ThreadChangeRequestSnapshot,
16} from "./ThreadStatusIndicators";15} from "./ThreadStatusIndicators";
16import { newestPullRequestSummary } from "../state/pullRequests";
1717
18function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {18function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
19 return {19 return {
...@@ -57,58 +57,44 @@ function snapshotFor(...@@ -57,58 +57,44 @@ function snapshotFor(
57 return { branch, pr, sourceControlProvider };57 return { branch, pr, sourceControlProvider };
58}58}
5959
60describe("threadPullRequestRefreshSource", () => {60function pullRequestSummary(
61 const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const };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", () => {78describe("shared pull request state", () => {
64 expect(79 it("shows a panel-observed merge instead of an older sidebar summary", () => {
65 threadPullRequestRefreshSource({80 const open = pullRequestSummary("open", "2026-09-03T01:00:00.000Z");
66 panel,81 const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z");
67 thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false },
68 }),
69 ).toBe("vcs");
70 });
7182
72 it("refreshes linked detail when the open panel is newer than a linked sidebar PR", () => {83 expect(newestPullRequestSummary(open, merged)).toBe(merged);
73 expect(
74 threadPullRequestRefreshSource({
75 panel,
76 thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: true },
77 }),
78 ).toBe("linked-detail");
79 });84 });
8085
81 it("refreshes when the sidebar has not resolved state yet", () => {86 it("never lets a stale open response regress a merged observation", () => {
82 expect(87 const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z");
83 threadPullRequestRefreshSource({88 const staleOpen = pullRequestSummary("open", "2026-09-03T01:00:00.000Z");
84 panel,
85 thread: { repository: "pingdotgg/t3code", number: 42, state: null, linked: false },
86 }),
87 ).toBe("vcs");
88 });
8989
90 it("does nothing once sidebar state matches or the panel shows another PR", () => {90 expect(newestPullRequestSummary(merged, staleOpen)).toBe(merged);
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();
103 });91 });
10492
105 it("matches repository identity without case sensitivity", () => {93 it("accepts a newer open state after a closed pull request is reopened", () => {
106 expect(94 const closed = pullRequestSummary("closed", "2026-09-03T01:00:00.000Z");
107 threadPullRequestRefreshSource({95 const reopened = pullRequestSummary("open", "2026-09-03T01:01:00.000Z");
108 panel: { ...panel, repository: "PingDotGG/T3Code" },96
109 thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false },97 expect(newestPullRequestSummary(closed, reopened)).toBe(reopened);
110 }),
111 ).toBe("vcs");
112 });98 });
113});99});
114100
apps/web/src/components/ThreadStatusIndicators.tsx+3-28
...@@ -12,7 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";...@@ -12,7 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
12import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";12import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
13import { useProject } from "../state/entities";13import { useProject } from "../state/entities";
14import { useEnvironmentQuery } from "../state/query";14import { useEnvironmentQuery } from "../state/query";
15import { linkedPullRequestDetailAtom } from "../state/pullRequests";15import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests";
16import { useThreadRunningTerminalIds } from "../state/terminalSessions";16import { useThreadRunningTerminalIds } from "../state/terminalSessions";
17import { vcsEnvironment } from "../state/vcs";17import { vcsEnvironment } from "../state/vcs";
18import { useUiStateStore } from "../uiStateStore";18import { useUiStateStore } from "../uiStateStore";
...@@ -39,32 +39,6 @@ export interface TerminalStatusIndicator {...@@ -39,32 +39,6 @@ export interface TerminalStatusIndicator {
3939
40export type ThreadPr = VcsStatusResult["pr"];40export 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
68export interface LinkedThreadPullRequestStatus {42export interface LinkedThreadPullRequestStatus {
69 readonly pr: NonNullable<ThreadPr>;43 readonly pr: NonNullable<ThreadPr>;
70 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;44 readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>;
...@@ -74,7 +48,7 @@ export function useLinkedThreadPullRequest(...@@ -74,7 +48,7 @@ export function useLinkedThreadPullRequest(
74 environmentId: EnvironmentId | null,48 environmentId: EnvironmentId | null,
75 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,49 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
76): LinkedThreadPullRequestStatus | null {50): LinkedThreadPullRequestStatus | null {
77 const detail = useEnvironmentQuery(51 const queried = useEnvironmentQuery(
78 environmentId === null || linkedPullRequest == null52 environmentId === null || linkedPullRequest == null
79 ? null53 ? null
80 : linkedPullRequestDetailAtom({54 : linkedPullRequestDetailAtom({
...@@ -86,6 +60,7 @@ export function useLinkedThreadPullRequest(...@@ -86,6 +60,7 @@ export function useLinkedThreadPullRequest(
86 },60 },
87 }),61 }),
88 ).data;62 ).data;
63 const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried);
8964
90 return useMemo(65 return useMemo(
91 () =>66 () =>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+18-18
...@@ -63,7 +63,7 @@ import { useProjects } from "~/state/entities";...@@ -63,7 +63,7 @@ import { useProjects } from "~/state/entities";
63import { useEnvironments } from "~/state/environments";63import { useEnvironments } from "~/state/environments";
64import { useEnvironmentQuery } from "~/state/query";64import { useEnvironmentQuery } from "~/state/query";
65import { useLiveRefresh } from "~/hooks/useLiveRefresh";65import { useLiveRefresh } from "~/hooks/useLiveRefresh";
66import { pullRequestEnvironment } from "~/state/pullRequests";66import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests";
67import { useAtomCommand } from "~/state/use-atom-command";67import { useAtomCommand } from "~/state/use-atom-command";
68import { vcsEnvironment } from "~/state/vcs";68import { vcsEnvironment } from "~/state/vcs";
69import { formatRelativeTimeLabel } from "~/timestampFormat";69import { formatRelativeTimeLabel } from "~/timestampFormat";
...@@ -476,14 +476,8 @@ export function PullRequestDetailPanel({...@@ -476,14 +476,8 @@ export function PullRequestDetailPanel({
476 onActed?: () => void;476 onActed?: () => void;
477 /** Page-owned detail columns use this to clear the selected pull request. */477 /** Page-owned detail columns use this to clear the selected pull request. */
478 onClose?: () => void;478 onClose?: () => void;
479 /** Keeps surrounding thread state in step with refreshed host state. */479 /** Keeps surrounding inferred thread state in step with refreshed host state. */
480 onStateChange?: (status: {480 onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void;
481 projectId: string;
482 repository: string;
483 number: number;
484 state: PullRequestState;
485 isDraft: boolean;
486 }) => void;
487 /**481 /**
488 * Beside a thread, the checkout affordance disappears: the panel is showing that thread's482 * Beside a thread, the checkout affordance disappears: the panel is showing that thread's
489 * own pull request, so the branch is already under the reader's feet — and checking it out483 * own pull request, so the branch is already under the reader's feet — and checking it out
...@@ -606,11 +600,19 @@ export function PullRequestDetailPanel({...@@ -606,11 +600,19 @@ export function PullRequestDetailPanel({
606 reference.repository,600 reference.repository,
607 reference.number,601 reference.number,
608 ]);602 ]);
609 const coreDetail = resolveDisplayedPullRequestDetail({603 const resolvedCoreDetail = resolveDisplayedPullRequestDetail({
610 live: detailQuery.data,604 live: detailQuery.data,
611 cached: cachedDetail,605 cached: cachedDetail,
612 reference,606 reference,
613 });607 });
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 );
614 const activity = activityQuery.data;616 const activity = activityQuery.data;
615 const detail = useMemo(617 const detail = useMemo(
616 () =>618 () =>
...@@ -674,16 +676,14 @@ export function PullRequestDetailPanel({...@@ -674,16 +676,14 @@ export function PullRequestDetailPanel({
674 }676 }
675 activityRevision.current = next;677 activityRevision.current = next;
676 }, [activityQuery.refresh, coreDetail, pullRequestKey]);678 }, [activityQuery.refresh, coreDetail, pullRequestKey]);
677 useEffect(() => {679 useLayoutEffect(() => {
678 if (!detail) return;680 if (!resolvedCoreDetail) return;
679 onStateChange?.({681 onStateChange?.({
680 projectId: detail.projectId,682 repository: resolvedCoreDetail.repository,
681 repository: detail.repository,683 number: resolvedCoreDetail.number,
682 number: detail.number,684 state: resolvedCoreDetail.state,
683 state: detail.state,
684 isDraft: detail.isDraft,
685 });685 });
686 }, [detail, onStateChange]);686 }, [onStateChange, resolvedCoreDetail]);
687 // Core detail is cheap enough to re-read while this stays open. Activity is heavier, so the687 // Core detail is cheap enough to re-read while this stays open. Activity is heavier, so the
688 // revision effect above reads it only after this same pull request reports a change. Keyed by688 // revision effect above reads it only after this same pull request reports a change. Keyed by
689 // the pull request rather than by the panel, because this one panel shows a different pull689 // 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 {...@@ -7,10 +7,12 @@ import type {
7 EnvironmentId,7 EnvironmentId,
8 PullRequestListInput,8 PullRequestListInput,
9 PullRequestListStatsInput,9 PullRequestListStatsInput,
10 PullRequestRef,
11 PullRequestSummary,
10} from "@t3tools/contracts";12} from "@t3tools/contracts";
11import * as Option from "effect/Option";13import * as Option from "effect/Option";
12import { AsyncResult, Atom } from "effect/unstable/reactivity";14import { AsyncResult, Atom } from "effect/unstable/reactivity";
13import { useCallback, useMemo } from "react";15import { useCallback, useLayoutEffect, useMemo } from "react";
1416
15import { connectionAtomRuntime } from "../connection/runtime";17import { connectionAtomRuntime } from "../connection/runtime";
16import { appAtomRegistry } from "../rpc/atomRegistry";18import { appAtomRegistry } from "../rpc/atomRegistry";
...@@ -25,6 +27,50 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti...@@ -25,6 +27,50 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti
25export const linkedPullRequestDetailAtom =27export const linkedPullRequestDetailAtom =
26 createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime);28 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
28export interface EnvironmentQueryTarget<Input> {74export interface EnvironmentQueryTarget<Input> {
29 readonly environmentId: EnvironmentId;75 readonly environmentId: EnvironmentId;
30 readonly input: Input;76 readonly input: Input;