From 3915606398bab63885d4d1db0116ebee80c41a36 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Wed, 5 Aug 2026 10:57:00 -0700 Subject: [PATCH 1/2] Refresh pull request status when turns complete --- .../cache-owners/realtime-cache-registry.ts | 17 +++++++++ .../src/hooks/realtime-cache-effects.test.ts | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 65db79b987..25c2d68391 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -264,6 +264,7 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { dirtyThreadListQueriesForBackgroundActivity, // Sidebar rows render active workflow/background task state. dirtyThreadSearchQueries, // Indexed conversation content may now match a search query. dirtyThreadTimelineQueries, // Timeline rows are built from appended events. + dirtyThreadPullRequestQueryForCompletedTurn, // A turn may create a remote PR without changing the workspace. dirtyThreadPromptHistoryQueriesForTurnRequests, // Follow-up recall is built from client turn requests. ], }, @@ -687,6 +688,22 @@ function dirtyThreadPromptHistoryQueriesForTurnRequests({ return getThreadPromptHistoryInvalidationQueryKeys({ threadId }); } +function dirtyThreadPullRequestQueryForCompletedTurn({ + eventTypes, + queryClient, + threadId, +}: ThreadRealtimeDirtyContext): QueryKey[] { + if (!threadId || !eventTypes?.includes("turn/completed")) { + return []; + } + const environmentId = queryClient.getQueryData( + threadQueryKey(threadId), + )?.environmentId; + return environmentId + ? [environmentPullRequestQueryKey(environmentId)] + : []; +} + function dirtyThreadPendingInteractionQueries({ threadId, }: ThreadRealtimeDirtyContext): QueryKey[] { diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 7234f96791..fd99c747a5 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -12,6 +12,7 @@ import { archivedThreadsListQueryKey, environmentDiffFilesQueryKey, environmentDiffPatchQueryKey, + environmentPullRequestQueryKey, environmentWorkStatusQueryKey, hostPathExistenceQueryKey, projectPathsQueryKey, @@ -412,6 +413,43 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it("refetches the active environment pull request when a turn completes", async () => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const pullRequestKey = environmentPullRequestQueryKey("env-1"); + const nextPullRequest = { + outcome: "available", + pullRequest: { number: 42 }, + }; + const pullRequestQueryFn = vi.fn(async () => nextPullRequest); + queryClient.setQueryData(threadQueryKey("thr_1"), { + environmentId: "env-1", + id: "thr_1", + }); + queryClient.setQueryData(pullRequestKey, { outcome: "absent" }); + const pullRequestObserver = new QueryObserver(queryClient, { + queryFn: pullRequestQueryFn, + queryKey: pullRequestKey, + staleTime: Infinity, + }); + const unsubscribePullRequest = pullRequestObserver.subscribe(() => {}); + + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { eventTypes: ["turn/completed"] }, + changes: ["events-appended"], + }); + await vi.advanceTimersByTimeAsync(50); + + expect(pullRequestQueryFn).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(pullRequestKey)).toEqual(nextPullRequest); + + unsubscribePullRequest(); + effects.dispose(); + }); + it("invalidates cached thread search results when environment metadata changes", () => { vi.useFakeTimers(); const { effects, queryClient } = createRealtimeEffectsTestContext(); From 6f62c9366f4d5dc5fed390b7d1d4648cf2875b3f Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Wed, 5 Aug 2026 11:49:29 -0700 Subject: [PATCH 2/2] Handle PR refresh from sidebar caches --- .../cache-owners/realtime-cache-registry.ts | 15 ++-- .../src/hooks/realtime-cache-effects.test.ts | 80 +++++++++++-------- 2 files changed, 56 insertions(+), 39 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 25c2d68391..3b222d4d5d 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -49,6 +49,7 @@ import { getCachedProjectThreadListInvalidationQueryKeys, getCachedRootOrderThreadListInvalidationQueryKeys, getCachedSidebarNavigationThreads, + getCachedThreadListPlaceholder, getEnvironmentBranchListInvalidationQueryKeys, getEnvironmentRecordInvalidationQueryKeys, getEnvironmentWorkspaceStateInvalidationQueryKeys, @@ -696,12 +697,14 @@ function dirtyThreadPullRequestQueryForCompletedTurn({ if (!threadId || !eventTypes?.includes("turn/completed")) { return []; } - const environmentId = queryClient.getQueryData( - threadQueryKey(threadId), - )?.environmentId; - return environmentId - ? [environmentPullRequestQueryKey(environmentId)] - : []; + const cachedThread = + queryClient.getQueryData(threadQueryKey(threadId)) ?? + getCachedThreadListPlaceholder(queryClient, threadId) ?? + getCachedSidebarNavigationThreads(queryClient).find( + (thread) => thread.id === threadId, + ); + const environmentId = cachedThread?.environmentId; + return environmentId ? [environmentPullRequestQueryKey(environmentId)] : []; } function dirtyThreadPendingInteractionQueries({ diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index fd99c747a5..be0863e94d 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -413,42 +413,56 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); - it("refetches the active environment pull request when a turn completes", async () => { - vi.useFakeTimers(); - const { effects, queryClient } = createRealtimeEffectsTestContext(); - const pullRequestKey = environmentPullRequestQueryKey("env-1"); - const nextPullRequest = { - outcome: "available", - pullRequest: { number: 42 }, - }; - const pullRequestQueryFn = vi.fn(async () => nextPullRequest); - queryClient.setQueryData(threadQueryKey("thr_1"), { - environmentId: "env-1", - id: "thr_1", - }); - queryClient.setQueryData(pullRequestKey, { outcome: "absent" }); - const pullRequestObserver = new QueryObserver(queryClient, { - queryFn: pullRequestQueryFn, - queryKey: pullRequestKey, - staleTime: Infinity, - }); - const unsubscribePullRequest = pullRequestObserver.subscribe(() => {}); + it.each(["thread detail", "sidebar navigation"] as const)( + "refetches the active environment pull request from %s when a turn completes", + async (cacheSource) => { + vi.useFakeTimers(); + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const pullRequestKey = environmentPullRequestQueryKey("env-1"); + const nextPullRequest = { + outcome: "available", + pullRequest: { number: 42 }, + }; + const pullRequestQueryFn = vi.fn(async () => nextPullRequest); + if (cacheSource === "thread detail") { + queryClient.setQueryData(threadQueryKey("thr_1"), { + environmentId: "env-1", + id: "thr_1", + }); + } else { + queryClient.setQueryData(sidebarNavigationQueryKey(), { + personalProject: { threads: [] }, + projects: [ + { + threads: [{ environmentId: "env-1", id: "thr_1" }], + }, + ], + }); + } + queryClient.setQueryData(pullRequestKey, { outcome: "absent" }); + const pullRequestObserver = new QueryObserver(queryClient, { + queryFn: pullRequestQueryFn, + queryKey: pullRequestKey, + staleTime: Infinity, + }); + const unsubscribePullRequest = pullRequestObserver.subscribe(() => {}); - effects.handleChanged({ - type: "changed", - entity: "thread", - id: "thr_1", - metadata: { eventTypes: ["turn/completed"] }, - changes: ["events-appended"], - }); - await vi.advanceTimersByTimeAsync(50); + effects.handleChanged({ + type: "changed", + entity: "thread", + id: "thr_1", + metadata: { eventTypes: ["turn/completed"] }, + changes: ["events-appended"], + }); + await vi.advanceTimersByTimeAsync(50); - expect(pullRequestQueryFn).toHaveBeenCalledTimes(1); - expect(queryClient.getQueryData(pullRequestKey)).toEqual(nextPullRequest); + expect(pullRequestQueryFn).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(pullRequestKey)).toEqual(nextPullRequest); - unsubscribePullRequest(); - effects.dispose(); - }); + unsubscribePullRequest(); + effects.dispose(); + }, + ); it("invalidates cached thread search results when environment metadata changes", () => { vi.useFakeTimers();