| author | |
| committer | |
| log | 854541a04e07b7960698b381bcfd2fda73eb276c |
| tree | ef75ff2224c1cee716bafdba554999489c4e4834 |
| parent | 48ba76bc26337f23d1f0991bbac51f996a3333dc |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 25 | import { TestClock } from "effect/testing"; | 25 | import { TestClock } from "effect/testing"; |
| 26 | 26 | ||
| 27 | import { GitManager } from "../git/GitManager.ts"; | 27 | import { GitManager } from "../git/GitManager.ts"; |
| 28 | import { PullRequestService } from "../pullRequest/PullRequestService.ts"; | 28 | import { |
| 29 | PullRequestService, | ||
| 30 | type PullRequestMergeEvent, | ||
| 31 | } from "../pullRequest/PullRequestService.ts"; | ||
| 29 | import { ServerActivation } from "../serverActivation.ts"; | 32 | import { ServerActivation } from "../serverActivation.ts"; |
| 30 | import { ServerSettingsService } from "../serverSettings.ts"; | 33 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 31 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; | 34 | import { 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 | ); |
| 375 | 389 | ||
| 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; |
| 40 | 40 | ||
| 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 | }); |
| 147 | 164 | ||
| 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)); |
| 159 | 176 | ||
| 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 | }); |
| 187 | 206 | ||
| 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as Deferred from "effect/Deferred"; | 2 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 4 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 5 | import * as TestClock from "effect/testing/TestClock"; | 8 | import * as TestClock from "effect/testing/TestClock"; |
| 6 | import type { | 9 | import 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 | ); |
| 1004 | 1007 | ||
| 1008 | it.effect("publishes a successful merge for immediate settlement", () => | ||
| 1009 | Effect.scoped( | ||
| 1010 | Effect.gen(function* () { | ||
| 1011 | const mergedAt = "2026-09-03T02:00:00.000Z"; | ||
| 1012 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | ||
| 1013 | const service = yield* makeService({ | ||
| 1014 | projects: [ | ||
| 1015 | project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), | ||
| 1016 | ], | ||
| 1017 | providers: [ | ||
| 1018 | fakeProvider("github", { | ||
| 1019 | runAction: () => TestClock.setTime(Date.parse(mergedAt)), | ||
| 1020 | }), | ||
| 1021 | ], | ||
| 1022 | }); | ||
| 1023 | const merges = yield* service.subscribeMerges; | ||
| 1024 | const observedMerge = yield* Stream.runHead(merges).pipe( | ||
| 1025 | Effect.forkChild({ startImmediately: true }), | ||
| 1026 | ); | ||
| 1027 | |||
| 1028 | yield* service.runAction({ | ||
| 1029 | ...reference, | ||
| 1030 | repository: " ACME/WEB ", | ||
| 1031 | action: "merge", | ||
| 1032 | mergeMethod: "merge", | ||
| 1033 | }); | ||
| 1034 | |||
| 1035 | assert.deepStrictEqual(Option.getOrThrow(yield* Fiber.join(observedMerge)), { | ||
| 1036 | ...reference, | ||
| 1037 | mergedAt, | ||
| 1038 | }); | ||
| 1039 | }), | ||
| 1040 | ), | ||
| 1041 | ); | ||
| 1042 | |||
| 1005 | it.effect("refuses an action this viewer may not take, and says what access it takes", () => | 1043 | it.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 | ); |
| 3194 | 3232 | ||
| 3233 | it.effect("reuses an observed merged state for strict settlement reads", () => | ||
| 3234 | Effect.gen(function* () { | ||
| 3235 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | ||
| 3236 | const service = yield* makeService({ | ||
| 3237 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], | ||
| 3238 | providers: [ | ||
| 3239 | fakeProvider("github", { | ||
| 3240 | getChangeRequest: () => | ||
| 3241 | Effect.succeed({ | ||
| 3242 | ...hostedChangeRequest("merged body", 4), | ||
| 3243 | state: "merged", | ||
| 3244 | updatedAt: "2026-07-03T00:00:00Z", | ||
| 3245 | }), | ||
| 3246 | getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), | ||
| 3247 | }), | ||
| 3248 | ], | ||
| 3249 | }); | ||
| 3250 | |||
| 3251 | yield* service.detail(reference); | ||
| 3252 | |||
| 3253 | const summary = yield* service.summary(reference, { recoverTransientFailure: false }); | ||
| 3254 | assert.strictEqual(summary.state, "merged"); | ||
| 3255 | assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); | ||
| 3256 | }), | ||
| 3257 | ); | ||
| 3258 | |||
| 3195 | it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => | 3259 | it.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 @@ |
| 1 | import * as Cache from "effect/Cache"; | 1 | import * as Cache from "effect/Cache"; |
| 2 | import * as Clock from "effect/Clock"; | 2 | import * as Clock from "effect/Clock"; |
| 3 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 4 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Duration from "effect/Duration"; | 5 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as Exit from "effect/Exit"; | 7 | import * as Exit from "effect/Exit"; |
| 7 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as PubSub from "effect/PubSub"; | ||
| 8 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | import type * as Scope from "effect/Scope"; | ||
| 12 | import * as Stream from "effect/Stream"; | ||
| 9 | import { | 13 | import { |
| 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"; |
| 64 | import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; | 68 | import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; |
| 65 | 69 | ||
| 70 | export 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 a | 75 | * 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 | } |
| 518 | 531 | ||
| 519 | export const make = Effect.gen(function* () { | 532 | export 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 | ); |
| 1412 | 1426 | ||
| 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 that | 1430 | // 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 checks | 1468 | // 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 === false | 2171 | 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 | }; |
| 2158 | 2179 | ||
| 2159 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder | 2180 | // 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 | }); | ||
| 2394 | 2430 | ||
| 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"; |
| 265 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | 265 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 266 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; | 266 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 267 | import { projectEnvironment } from "../state/projects"; | 267 | import { projectEnvironment } from "../state/projects"; |
| 268 | import { linkedPullRequestDetailAtom } from "../state/pullRequests"; | ||
| 269 | import { useEnvironmentQuery } from "../state/query"; | 268 | import { useEnvironmentQuery } from "../state/query"; |
| 270 | import { | 269 | import { |
| 271 | environmentServerConfigsAtom, | 270 | environmentServerConfigsAtom, |
| ... | @@ -325,7 +324,6 @@ import { | ... | @@ -325,7 +324,6 @@ import { |
| 325 | } from "./chat/ThreadErrorBanner"; | 324 | } from "./chat/ThreadErrorBanner"; |
| 326 | import { | 325 | import { |
| 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 a | 3646 | // 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 = isServerThread | 3648 | 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 = linkedThreadPullRequest | 3692 | 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; |
| 4815 | 4906 | 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 | <AgentsPanel | 7429 | <AgentsPanel |
apps/web/src/components/ThreadStatusIndicators.test.ts+33-47| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProjectId, type VcsStatusResult } from "@t3tools/contracts"; | 1 | import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import { AtomRegistry } from "effect/unstable/reactivity"; | 4 | import { 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"; |
| 16 | import { newestPullRequestSummary } from "../state/pullRequests"; | ||
| 17 | 17 | ||
| 18 | function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { | 18 | function 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 | } |
| 59 | 59 | ||
| 60 | describe("threadPullRequestRefreshSource", () => { | 60 | function 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 | } | ||
| 62 | 77 | ||
| 63 | it("refreshes the VCS stream when the open panel is newer than an inferred sidebar PR", () => { | 78 | describe("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 | }); | ||
| 71 | 82 | ||
| 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 | }); |
| 80 | 85 | ||
| 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 | }); | ||
| 89 | 89 | ||
| 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 | }); |
| 104 | 92 | ||
| 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 | }); |
| 114 | 100 |
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"; |
| 12 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; | 12 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 13 | import { useProject } from "../state/entities"; | 13 | import { useProject } from "../state/entities"; |
| 14 | import { useEnvironmentQuery } from "../state/query"; | 14 | import { useEnvironmentQuery } from "../state/query"; |
| 15 | import { linkedPullRequestDetailAtom } from "../state/pullRequests"; | 15 | import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; |
| 16 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; | 16 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 17 | import { vcsEnvironment } from "../state/vcs"; | 17 | import { vcsEnvironment } from "../state/vcs"; |
| 18 | import { useUiStateStore } from "../uiStateStore"; | 18 | import { useUiStateStore } from "../uiStateStore"; |
| ... | @@ -39,32 +39,6 @@ export interface TerminalStatusIndicator { | ... | @@ -39,32 +39,6 @@ export interface TerminalStatusIndicator { |
| 39 | 39 | ||
| 40 | export type ThreadPr = VcsStatusResult["pr"]; | 40 | export type ThreadPr = VcsStatusResult["pr"]; |
| 41 | 41 | ||
| 42 | export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs"; | ||
| 43 | |||
| 44 | /** Refresh only when the panel has newer state for this thread's own pull request. */ | ||
| 45 | export function threadPullRequestRefreshSource(input: { | ||
| 46 | readonly panel: { | ||
| 47 | readonly repository: string; | ||
| 48 | readonly number: number; | ||
| 49 | readonly state: NonNullable<ThreadPr>["state"]; | ||
| 50 | }; | ||
| 51 | readonly thread: { | ||
| 52 | readonly repository: string | null; | ||
| 53 | readonly number: number | null; | ||
| 54 | readonly state: NonNullable<ThreadPr>["state"] | null; | ||
| 55 | readonly linked: boolean; | ||
| 56 | }; | ||
| 57 | }): ThreadPullRequestRefreshSource | null { | ||
| 58 | if ( | ||
| 59 | input.thread.repository?.toLowerCase() !== input.panel.repository.toLowerCase() || | ||
| 60 | input.thread.number !== input.panel.number || | ||
| 61 | input.thread.state === input.panel.state | ||
| 62 | ) { | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | return input.thread.linked ? "linked-detail" : "vcs"; | ||
| 66 | } | ||
| 67 | |||
| 68 | export interface LinkedThreadPullRequestStatus { | 42 | export 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 == null | 52 | environmentId === null || linkedPullRequest == null |
| 79 | ? null | 53 | ? 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); | ||
| 89 | 64 | ||
| 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"; |
| 63 | import { useEnvironments } from "~/state/environments"; | 63 | import { useEnvironments } from "~/state/environments"; |
| 64 | import { useEnvironmentQuery } from "~/state/query"; | 64 | import { useEnvironmentQuery } from "~/state/query"; |
| 65 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 65 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 66 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 66 | import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; |
| 67 | import { useAtomCommand } from "~/state/use-atom-command"; | 67 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 68 | import { vcsEnvironment } from "~/state/vcs"; | 68 | import { vcsEnvironment } from "~/state/vcs"; |
| 69 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 69 | import { 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's | 482 | * 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 out | 483 | * 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 the | 687 | // 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 by | 688 | // 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 pull | 689 | // 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"; |
| 11 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 12 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 14 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 13 | import { useCallback, useMemo } from "react"; | 15 | import { useCallback, useLayoutEffect, useMemo } from "react"; |
| 14 | 16 | ||
| 15 | import { connectionAtomRuntime } from "../connection/runtime"; | 17 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 18 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | @@ -25,6 +27,50 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti | ... | @@ -25,6 +27,50 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti |
| 25 | export const linkedPullRequestDetailAtom = | 27 | export const linkedPullRequestDetailAtom = |
| 26 | createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); | 28 | createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); |
| 27 | 29 | ||
| 30 | const observedPullRequestSummaryAtom = Atom.family((key: string) => | ||
| 31 | Atom.make<PullRequestSummary | null>(null).pipe( | ||
| 32 | Atom.setIdleTTL(5 * 60_000), | ||
| 33 | Atom.withLabel(`web-pull-requests:observed-summary:${key}`), | ||
| 34 | ), | ||
| 35 | ); | ||
| 36 | |||
| 37 | export function newestPullRequestSummary( | ||
| 38 | current: PullRequestSummary | null, | ||
| 39 | observed: PullRequestSummary | null, | ||
| 40 | ): PullRequestSummary | null { | ||
| 41 | if (current === null) return observed; | ||
| 42 | if (observed === null) return current; | ||
| 43 | if (current.state === "merged") return current; | ||
| 44 | if (observed.state === "merged") return observed; | ||
| 45 | return Date.parse(observed.updatedAt) >= Date.parse(current.updatedAt) ? observed : current; | ||
| 46 | } | ||
| 47 | |||
| 48 | export function useSharedPullRequestSummary( | ||
| 49 | environmentId: EnvironmentId | null, | ||
| 50 | reference: PullRequestRef | null, | ||
| 51 | current: PullRequestSummary | null, | ||
| 52 | ): PullRequestSummary | null { | ||
| 53 | const key = | ||
| 54 | environmentId === null || reference === null | ||
| 55 | ? "none" | ||
| 56 | : JSON.stringify([ | ||
| 57 | environmentId, | ||
| 58 | reference.projectId, | ||
| 59 | reference.repository.toLowerCase(), | ||
| 60 | reference.number, | ||
| 61 | ]); | ||
| 62 | const atom = observedPullRequestSummaryAtom(key); | ||
| 63 | const observed = useAtomValue(atom); | ||
| 64 | useLayoutEffect(() => { | ||
| 65 | if (environmentId === null || current === null) return; | ||
| 66 | appAtomRegistry.modify(atom, (previous) => { | ||
| 67 | const next = newestPullRequestSummary(previous, current); | ||
| 68 | return next === previous ? [false, previous] : [true, next]; | ||
| 69 | }); | ||
| 70 | }, [atom, current, environmentId]); | ||
| 71 | return newestPullRequestSummary(current, observed); | ||
| 72 | } | ||
| 73 | |||
| 28 | export interface EnvironmentQueryTarget<Input> { | 74 | export interface EnvironmentQueryTarget<Input> { |
| 29 | readonly environmentId: EnvironmentId; | 75 | readonly environmentId: EnvironmentId; |
| 30 | readonly input: Input; | 76 | readonly input: Input; |