feat(code): cache per-task PR url + state for instant task switches#2343
Conversation
Switching between tasks used to block on a `gh pr list --head <branch>` call to figure out which PR the task corresponds to. That made the PR badge / "Open PR" button pop in late on every task switch, especially after a cold start where TanStack Query's in-memory cache is empty. This stores the last-known PR URL and state on the workspace row in SQLite and lets the renderer paint from that cache immediately, while a background revalidation refreshes the value against GitHub. When the revalidated value differs from cache, `WorkspaceService` broadcasts `taskPrInfoChanged` and the renderer updates the matching `getTaskPrStatus` query in place. - New `pr_url` / `pr_state` / `pr_fetched_at` columns on `workspaces` (migration `0007_stiff_reptil`) plus `updatePrCache` on the repository - `GitService.getTaskPrStatus` returns cached state synchronously and schedules a deduplicated background `revalidateTaskPrStatus` that writes through and emits on change; `hasDiff` for worktree tasks still computes inline since it's local-only - `workspace.onTaskPrInfoChanged` tRPC subscription + `App.tsx` handler pushes fresh values into the `getTaskPrStatus` cache via `setQueriesData` - `workspace.getCachedPrUrl` lets `useTaskPrUrl` fall back to the cached PR URL so the task header opens the right PR before the live lookups return - `taskPrInfoChanged` is emitted via string literal to avoid a circular import (workspace/service eagerly loads the DI container) Generated-By: PostHog Code Task-Id: 10fff210-3861-49ad-b1fc-dbabae2fcc17
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/code/src/main/services/git/service.ts:1858-1862
The "no-op" guard uses `!fresh.hasDiff` to skip emitting, but since `hasDiff` is never persisted to the DB there is no baseline to compare against. For a worktree task that has uncommitted changes but no PR (`prUrl: null, prState: null, hasDiff: true`), this condition will always be `false`, so `updatePrCache` and `emit("taskPrInfoChanged")` are called on every revalidation cycle even when nothing has actually changed since the last one. The repeated `setQueriesData` calls in `App.tsx` are harmless because React detects identical data, but the extra DB writes accumulate silently.
```suggestion
const cachedHasDiff = !cachedPrState && !cachedPrUrl;
if (
cachedPrUrl === fresh.prUrl &&
cachedPrState === fresh.prState &&
cachedHasDiff === fresh.hasDiff
) {
```
### Issue 2 of 2
apps/code/src/renderer/App.tsx:132-152
The `prUrl` from the `taskPrInfoChanged` payload is silently dropped here. When a background revalidation discovers a new PR URL (e.g., a PR is created after the task was first opened), the DB is updated and the event fires with the URL, but the `getCachedPrUrl` React Query cache is never invalidated or updated. Because `getCachedPrUrl` has `staleTime: 60_000`, the `cached?.prUrl` fallback in `useTaskPrUrl` will serve the old (null) value for up to a minute, defeating the fast-path for the "Open PR" button that this cache was introduced to provide.
```suggestion
onData: ({ taskId, prUrl, prState, hasDiff }) => {
// Push the fresh PR info into every matching getTaskPrStatus query
// (one per cloudPrUrl variant) so the renderer re-renders without
// waiting for the next staleTime-driven refetch.
queryClient.setQueriesData<{
prState: typeof prState;
hasDiff: boolean;
}>(
{
...trpcReact.workspace.getTaskPrStatus.pathFilter(),
predicate: (query) => {
const [, params] = query.queryKey as [
unknown,
{ input?: { taskId?: string } } | undefined,
];
return params?.input?.taskId === taskId;
},
},
() => ({ prState, hasDiff }),
);
// Keep getCachedPrUrl in sync so the "Open PR" fast-path stays warm.
queryClient.setQueryData(
trpcReact.workspace.getCachedPrUrl.queryKey({ taskId }),
{ prUrl },
);
},
```
Reviews (1): Last reviewed commit: "feat(code): cache per-task PR url + stat..." | Re-trigger Greptile |
Addresses Greptile review feedback on #2343: 1. The no-op guard in `revalidateTaskPrStatus` used `!fresh.hasDiff` to skip emitting when "nothing changed", but `hasDiff` isn't persisted, so for a worktree with uncommitted changes and no PR the guard could never engage — every revalidation cycle wrote to the DB and emitted. `hasDiff` is now excluded from the event entirely (it's still computed inline by `getTaskPrStatus` and refreshed by TanStack on the staleTime cycle), and the emit decision is based purely on whether `prUrl` or `prState` changed. 2. The `App.tsx` subscription dropped `prUrl` from the event payload, so when a PR appeared after the task was first opened, the `getCachedPrUrl` query stayed at its 60s staleTime and `useTaskPrUrl`'s "Open PR" fast-path served `null` until the next refetch. The handler now `setQueryData`s the cached URL too, and merges `prState` into the existing `getTaskPrStatus` cache entry so any inline-computed `hasDiff` survives. Generated-By: PostHog Code Task-Id: 10fff210-3861-49ad-b1fc-dbabae2fcc17
|
getCachedPrUrl extends the exact router, repository pattern CLAUDE.md says not to extend context: R10 / forbidden-patterns: "tRPC routers bypassing their service to call a repository. workspace.ts does this today; do not extend the pattern." This file is already pervasively guilty (togglePin, markViewed, getTaskTimestamps…), so it's consistent with its neighbors — but it's still net-new surface against an explicitly-named rule. Since GitService already owns the PR-cache writes, the natural home is a thin GitService.getCachedPrUrl(taskId) (or WorkspaceService) method, keeping the router a one-liner. Low effort, and it doesn't grow the debt the guide is trying to contain. |
Keep the tRPC router a one-liner instead of reaching into the workspace repository directly (CLAUDE.md R10). GitService already owns the PR-cache writes, so the read belongs alongside them. Generated-By: PostHog Code Task-Id: 87692a3b-f2d5-4729-8fdf-32d6019b39b1
|
moved to a thin |
Generated-By: PostHog Code Task-Id: 87692a3b-f2d5-4729-8fdf-32d6019b39b1
The new PR-review tests from main construct GitService with the old 3-arg signature; this branch adds workspaceRepo as a 4th constructor dependency. Supply the stub so the merge typechecks. Generated-By: PostHog Code Task-Id: 87692a3b-f2d5-4729-8fdf-32d6019b39b1
prFetchedAt was written by updatePrCache but never read anywhere, and getTaskPrStatus revalidates unconditionally on every call, so the timestamp gated nothing. Remove the column, its 0007 migration line, and all write sites rather than ship a written-but-unread field. Generated-By: PostHog Code Task-Id: 94230925-3fa0-419a-9cf6-f34b96c12307
…sk-pr-info # Conflicts: # apps/code/src/main/db/migrations/meta/0007_snapshot.json # apps/code/src/main/db/migrations/meta/_journal.json
Generated-By: PostHog Code Task-Id: 319d5939-5162-43df-bdda-f45635e0e99f
Problem
Switching between tasks in the sidebar/task detail used to block on a
gh pr list --head <branch>call to figure out which PR each task corresponds to. That made the PR badge in the sidebar and the "Open PR" button in the task header pop in noticeably late on every task switch — and on cold start, every visible card paid the cost simultaneously.Changes
Per-task PR info is now cached on the
workspacesSQLite row (pr_url,pr_state,pr_fetched_at) and served stale-while-revalidate.GitService.getTaskPrStatusreturns the cachedprStatesynchronously and schedules a deduplicated background revalidation.hasDifffor worktree tasks still computes inline because it's local-only and cheap.ghlookups as before, writes through to the workspaces row, and — when the value changed — emitsWorkspaceService.taskPrInfoChanged.workspace.onTaskPrInfoChangedtRPC subscription is wired inApp.tsx; the handler updates every matchinggetTaskPrStatusquery viasetQueriesDataso the badge updates in place without a refetch.workspace.getCachedPrUrl(taskId)letsuseTaskPrUrlfall back to the cached URL so the task header's PR button opens the right PR before the liveghlookups return.taskPrInfoChangedemit uses a string literal (not theWorkspaceServiceEventconst) to avoid a circular import —workspace/serviceeagerly loads the DI container, which re-entersgit/service.Migration:
0007_stiff_reptiladds the three nullable columns toworkspaces.How did you test this?
pnpm --filter @posthog/code typecheck✅pnpm exec biome check apps/code/src✅pnpm --filter @posthog/code test✅ (1436 pass; the 23 archive integration failures pre-exist onmainbecausegit commitis blocked in this signed-commit environment — verified by re-running them on a clean tree)No manual UI verification was possible from this environment.
Publish to changelog?
no
Created with PostHog Code