From 0a36a619f98162043619aa1aad4de9fe6ece9dab Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Mon, 25 May 2026 06:11:18 +0300 Subject: [PATCH 1/2] feat(code): cache per-task PR url + state on workspaces row Switching between tasks used to block on a `gh pr list --head ` 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 --- .../main/db/migrations/0007_stiff_reptil.sql | 3 + .../db/migrations/meta/0007_snapshot.json | 580 ++++++++++++++++++ .../src/main/db/migrations/meta/_journal.json | 7 + .../repositories/workspace-repository.mock.ts | 18 + .../db/repositories/workspace-repository.ts | 20 + apps/code/src/main/db/schema.ts | 6 + .../src/main/services/git/service.test.ts | 6 + apps/code/src/main/services/git/service.ts | 128 +++- .../src/main/services/workspace/schemas.ts | 18 + .../src/main/services/workspace/service.ts | 3 + apps/code/src/main/trpc/routers/workspace.ts | 11 + apps/code/src/renderer/App.tsx | 26 + .../git-interaction/hooks/useTaskPrUrl.ts | 13 +- 13 files changed, 829 insertions(+), 10 deletions(-) create mode 100644 apps/code/src/main/db/migrations/0007_stiff_reptil.sql create mode 100644 apps/code/src/main/db/migrations/meta/0007_snapshot.json diff --git a/apps/code/src/main/db/migrations/0007_stiff_reptil.sql b/apps/code/src/main/db/migrations/0007_stiff_reptil.sql new file mode 100644 index 000000000..bc08feb7a --- /dev/null +++ b/apps/code/src/main/db/migrations/0007_stiff_reptil.sql @@ -0,0 +1,3 @@ +ALTER TABLE `workspaces` ADD `pr_url` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `pr_state` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `pr_fetched_at` text; \ No newline at end of file diff --git a/apps/code/src/main/db/migrations/meta/0007_snapshot.json b/apps/code/src/main/db/migrations/meta/0007_snapshot.json new file mode 100644 index 000000000..21ec048a2 --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,580 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "59b807ad-4cd5-4587-9b2b-a559039e97bb", + "prevId": "805d2ed3-331d-4ba6-8379-30f926268064", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_fetched_at": { + "name": "pr_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 98745d4e4..e266e2f96 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1777639303535, "tag": "0006_youthful_warstar", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1779677685455, + "tag": "0007_stiff_reptil", + "breakpoints": true } ] } diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/apps/code/src/main/db/repositories/workspace-repository.mock.ts index 775fed571..01ffe1b47 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.mock.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.mock.ts @@ -64,6 +64,9 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { lastActivityAt: null, linkedBranch: null, additionalDirectories: "[]", + prUrl: null, + prState: null, + prFetchedAt: null, createdAt: now, updatedAt: now, }; @@ -84,6 +87,9 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { lastActivityAt: null, linkedBranch: null, additionalDirectories: "[]", + prUrl: null, + prState: null, + prFetchedAt: null, createdAt: now, updatedAt: now, }; @@ -133,6 +139,18 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { current.includes(path) ? current.filter((p) => p !== path) : null, ); }, + updatePrCache: (taskId, update) => { + const w = findLiveByTaskId(taskId); + if (!w) return; + const now = new Date().toISOString(); + workspaces.set(w.id, { + ...w, + prUrl: update.prUrl, + prState: update.prState, + prFetchedAt: now, + updatedAt: now, + }); + }, deleteAll: () => { workspaces.clear(); taskIndex.clear(); diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/apps/code/src/main/db/repositories/workspace-repository.ts index 760ba9503..13a9ce3b8 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.ts @@ -8,6 +8,7 @@ import type { DatabaseService } from "../service"; export type Workspace = typeof workspaces.$inferSelect; export type NewWorkspace = typeof workspaces.$inferInsert; export type WorkspaceMode = "cloud" | "local" | "worktree"; +export type CachedPrState = "open" | "merged" | "closed" | "draft"; export interface CreateWorkspaceData { taskId: string; @@ -15,6 +16,11 @@ export interface CreateWorkspaceData { mode: WorkspaceMode; } +export interface PrCacheUpdate { + prUrl: string | null; + prState: CachedPrState | null; +} + export interface IWorkspaceRepository { findById(id: string): Workspace | null; findByTaskId(taskId: string): Workspace | null; @@ -38,6 +44,7 @@ export interface IWorkspaceRepository { getAdditionalDirectories(taskId: string): string[]; addAdditionalDirectory(taskId: string, path: string): void; removeAdditionalDirectory(taskId: string, path: string): void; + updatePrCache(taskId: string, update: PrCacheUpdate): void; deleteAll(): void; } @@ -223,6 +230,19 @@ export class WorkspaceRepository implements IWorkspaceRepository { ); } + updatePrCache(taskId: string, update: PrCacheUpdate): void { + this.db + .update(workspaces) + .set({ + prUrl: update.prUrl, + prState: update.prState, + prFetchedAt: now(), + updatedAt: now(), + }) + .where(byTaskId(taskId)) + .run(); + } + deleteAll(): void { this.db.delete(workspaces).run(); } diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 8823ad274..89b9d91e9 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -33,6 +33,12 @@ export const workspaces = sqliteTable( lastActivityAt: text(), /** JSON-encoded array of absolute paths the agent can access for this task. */ additionalDirectories: text().notNull().default("[]"), + /** Cached PR URL for this task so task switches render without waiting on `gh`. */ + prUrl: text(), + /** Cached PR state — values match the `SidebarPrState` union (open/merged/closed/draft). */ + prState: text({ enum: ["open", "merged", "closed", "draft"] }), + /** When the cached PR fields were last refreshed against GitHub. */ + prFetchedAt: text(), createdAt: createdAt(), updatedAt: updatedAt(), }, diff --git a/apps/code/src/main/services/git/service.test.ts b/apps/code/src/main/services/git/service.test.ts index 76bdf4959..3ea19d820 100644 --- a/apps/code/src/main/services/git/service.test.ts +++ b/apps/code/src/main/services/git/service.test.ts @@ -23,11 +23,14 @@ vi.mock("../../utils/logger.js", () => ({ }, })); +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import type { AgentService } from "../agent/service"; import type { LlmGatewayService } from "../llm-gateway/service"; import type { WorkspaceService } from "../workspace/service"; import { GitService, mapPrState } from "./service"; +const stubWorkspaceRepo = {} as IWorkspaceRepository; + describe("GitService.getPrChangedFiles", () => { let service: GitService; @@ -37,6 +40,7 @@ describe("GitService.getPrChangedFiles", () => { {} as LlmGatewayService, {} as WorkspaceService, { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, ); }); @@ -149,6 +153,7 @@ describe("GitService.getGhAuthToken", () => { {} as LlmGatewayService, {} as WorkspaceService, { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, ); }); @@ -211,6 +216,7 @@ describe("GitService.getPrUrlForBranch", () => { {} as LlmGatewayService, {} as WorkspaceService, { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, ); }); diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index a45d2eb9d..9225eeb22 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -38,6 +38,7 @@ import { PullSaga } from "@posthog/git/sagas/pull"; import { PushSaga } from "@posthog/git/sagas/push"; import { parseGithubUrl } from "@posthog/git/utils"; import { inject, injectable } from "inversify"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; @@ -131,6 +132,7 @@ function toUnifiedDiffPatch( @injectable() export class GitService extends TypedEventEmitter { private lastFetchTime = new Map(); + private taskPrRevalidations = new Map>(); constructor( @inject(MAIN_TOKENS.LlmGatewayService) @@ -139,6 +141,8 @@ export class GitService extends TypedEventEmitter { private readonly workspaceService: WorkspaceService, @inject(MAIN_TOKENS.AgentService) private readonly agentService: AgentService, + @inject(MAIN_TOKENS.WorkspaceRepository) + private readonly workspaceRepo: IWorkspaceRepository, ) { super(); } @@ -1787,51 +1791,157 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; } } + /** + * Returns cached PR state for the task immediately and kicks off a background + * revalidation against `gh`. The fresh result is written back to the DB and, + * if the state changed, broadcast via `WorkspaceServiceEvent.TaskPrInfoChanged` + * so the renderer can update without re-querying. + * + * `hasDiff` is computed synchronously for worktrees without a cached PR — it + * relies only on local git state, so it stays cheap. + */ async getTaskPrStatus( taskId: string, cloudPrUrl: string | null, ): Promise<{ prState: SidebarPrState; hasDiff: boolean }> { + const cached = this.workspaceRepo.findByTaskId(taskId); + const cachedPrState = (cached?.prState ?? null) as SidebarPrState; + + void this.revalidateTaskPrStatus(taskId, cloudPrUrl); + + if (cachedPrState) return { prState: cachedPrState, hasDiff: false }; + + const hasDiff = await this.computeWorktreeHasDiff(taskId); + return { prState: null, hasDiff }; + } + + private async computeWorktreeHasDiff(taskId: string): Promise { + const workspace = await this.workspaceService.getWorkspace(taskId); + if ( + !workspace || + workspace.mode !== "worktree" || + !workspace.worktreePath + ) { + return false; + } + if (workspace.linkedBranch) return false; + const [diffStats, syncStatus] = await Promise.all([ + this.getDiffStats(workspace.worktreePath), + this.getGitSyncStatus(workspace.worktreePath), + ]); + return ( + (diffStats?.filesChanged ?? 0) > 0 || + (syncStatus?.aheadOfDefault ?? 0) > 0 + ); + } + + /** + * Performs the actual `gh` lookups for a task's PR, writes the result to the + * workspaces cache, and emits `TaskPrInfoChanged` when the cached value + * changed. Deduplicated per task so concurrent callers share one network + * roundtrip. + */ + private async revalidateTaskPrStatus( + taskId: string, + cloudPrUrl: string | null, + ): Promise { + const inFlight = this.taskPrRevalidations.get(taskId); + if (inFlight) return inFlight; + + const promise = this.computeTaskPrStatus(taskId, cloudPrUrl) + .then((fresh) => { + const cached = this.workspaceRepo.findByTaskId(taskId); + if (!cached) return; + + const cachedPrUrl = cached.prUrl ?? null; + const cachedPrState = (cached.prState ?? null) as SidebarPrState; + if ( + cachedPrUrl === fresh.prUrl && + cachedPrState === fresh.prState && + !fresh.hasDiff + ) { + // Touch fetchedAt without emitting if nothing meaningful changed. + this.workspaceRepo.updatePrCache(taskId, { + prUrl: fresh.prUrl, + prState: fresh.prState, + }); + return; + } + + this.workspaceRepo.updatePrCache(taskId, { + prUrl: fresh.prUrl, + prState: fresh.prState, + }); + // String literal (rather than `WorkspaceServiceEvent.TaskPrInfoChanged`) + // avoids a circular import: workspace/service eagerly loads the DI + // container, which in turn re-enters this module. + this.workspaceService.emit("taskPrInfoChanged", { + taskId, + prUrl: fresh.prUrl, + prState: fresh.prState, + hasDiff: fresh.hasDiff, + }); + }) + .catch((err) => { + log.warn("Failed to revalidate task PR status", { taskId, err }); + }) + .finally(() => { + this.taskPrRevalidations.delete(taskId); + }); + + this.taskPrRevalidations.set(taskId, promise); + return promise; + } + + private async computeTaskPrStatus( + taskId: string, + cloudPrUrl: string | null, + ): Promise<{ + prUrl: string | null; + prState: SidebarPrState; + hasDiff: boolean; + }> { const workspace = await this.workspaceService.getWorkspace(taskId); - if (!workspace) return { prState: null, hasDiff: false }; + if (!workspace) return { prUrl: null, prState: null, hasDiff: false }; const { mode, worktreePath, folderPath, linkedBranch } = workspace; const isCloud = mode === "cloud"; const repoPath = worktreePath ?? (folderPath || null); - // Cloud tasks: look up PR details by the cloud run's PR URL if (isCloud && cloudPrUrl) { const details = await this.getPrDetailsByUrl(cloudPrUrl); if (details) { return { + prUrl: cloudPrUrl, prState: mapPrState(details.state, details.merged, details.draft), hasDiff: false, }; } - return { prState: null, hasDiff: false }; + return { prUrl: cloudPrUrl, prState: null, hasDiff: false }; } - if (isCloud) return { prState: null, hasDiff: false }; + if (isCloud) return { prUrl: null, prState: null, hasDiff: false }; - // Linked branch: look up PR by branch name if (linkedBranch && repoPath) { const prUrl = await this.getPrUrlForBranch(repoPath, linkedBranch); if (prUrl) { const details = await this.getPrDetailsByUrl(prUrl); if (details) { return { + prUrl, prState: mapPrState(details.state, details.merged, details.draft), hasDiff: false, }; } } - return { prState: null, hasDiff: false }; + return { prUrl: null, prState: null, hasDiff: false }; } - // Worktree tasks without linked branch: check current branch PR + diff if (worktreePath) { const prStatus = await this.getPrStatus(worktreePath); if (prStatus.prExists && prStatus.prState) { return { + prUrl: prStatus.prUrl, prState: mapPrState( prStatus.prState, false, @@ -1850,9 +1960,9 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; (diffStats?.filesChanged ?? 0) > 0 || (syncStatus?.aheadOfDefault ?? 0) > 0; - return { prState: null, hasDiff }; + return { prUrl: null, prState: null, hasDiff }; } - return { prState: null, hasDiff: false }; + return { prUrl: null, prState: null, hasDiff: false }; } } diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts index 2569bab38..9adaa0589 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -112,6 +112,13 @@ export const linkedBranchChangedPayload = z.object({ branchName: z.string().nullable(), }); +export const taskPrInfoChangedPayload = z.object({ + taskId: z.string(), + prUrl: z.string().nullable(), + prState: z.enum(["merged", "open", "draft", "closed"]).nullable(), + hasDiff: z.boolean(), +}); + export const linkBranchInput = z.object({ taskId: z.string(), branchName: z.string(), @@ -252,6 +259,14 @@ export const taskPrStatusInput = z.object({ cloudPrUrl: z.string().nullable(), }); +export const cachedPrUrlInput = z.object({ + taskId: z.string(), +}); + +export const cachedPrUrlOutput = z.object({ + prUrl: z.string().nullable(), +}); + export const sidebarPrStateSchema = z .enum(["merged", "open", "draft", "closed"]) .nullable(); @@ -264,6 +279,8 @@ export const taskPrStatusOutput = z.object({ export type TaskPrStatusInput = z.infer; export type SidebarPrState = z.infer; export type TaskPrStatus = z.infer; +export type CachedPrUrlInput = z.infer; +export type CachedPrUrlOutput = z.infer; // Type exports export type WorkspaceMode = z.infer; @@ -291,6 +308,7 @@ export type BranchChangedPayload = z.infer; export type LinkedBranchChangedPayload = z.infer< typeof linkedBranchChangedPayload >; +export type TaskPrInfoChangedPayload = z.infer; export type LinkBranchInput = z.infer; export type UnlinkBranchInput = z.infer; export type LocalBackgroundedPayload = z.infer; diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 851b627d4..6c599c824 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -39,6 +39,7 @@ import type { CreateWorkspaceInput, LinkedBranchChangedPayload, ReconcileCloudWorkspacesOutput, + TaskPrInfoChangedPayload, Workspace, WorkspaceErrorPayload, WorkspaceInfo, @@ -128,6 +129,7 @@ export const WorkspaceServiceEvent = { Promoted: "promoted", BranchChanged: "branchChanged", LinkedBranchChanged: "linkedBranchChanged", + TaskPrInfoChanged: "taskPrInfoChanged", } as const; export interface WorkspaceServiceEvents { @@ -136,6 +138,7 @@ export interface WorkspaceServiceEvents { [WorkspaceServiceEvent.Promoted]: WorkspacePromotedPayload; [WorkspaceServiceEvent.BranchChanged]: BranchChangedPayload; [WorkspaceServiceEvent.LinkedBranchChanged]: LinkedBranchChangedPayload; + [WorkspaceServiceEvent.TaskPrInfoChanged]: TaskPrInfoChangedPayload; } @injectable() diff --git a/apps/code/src/main/trpc/routers/workspace.ts b/apps/code/src/main/trpc/routers/workspace.ts index 8e84c7953..fe006f6d4 100644 --- a/apps/code/src/main/trpc/routers/workspace.ts +++ b/apps/code/src/main/trpc/routers/workspace.ts @@ -3,6 +3,8 @@ import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; import type { GitService } from "../../services/git/service"; import { + cachedPrUrlInput, + cachedPrUrlOutput, createWorkspaceInput, createWorkspaceOutput, deleteWorkspaceInput, @@ -223,9 +225,18 @@ export const workspaceRouter = router({ getGitService().getTaskPrStatus(input.taskId, input.cloudPrUrl), ), + getCachedPrUrl: publicProcedure + .input(cachedPrUrlInput) + .output(cachedPrUrlOutput) + .query(({ input }) => { + const row = getWorkspaceRepo().findByTaskId(input.taskId); + return { prUrl: row?.prUrl ?? null }; + }), + onError: subscribe(WorkspaceServiceEvent.Error), onWarning: subscribe(WorkspaceServiceEvent.Warning), onPromoted: subscribe(WorkspaceServiceEvent.Promoted), onBranchChanged: subscribe(WorkspaceServiceEvent.BranchChanged), onLinkedBranchChanged: subscribe(WorkspaceServiceEvent.LinkedBranchChanged), + onTaskPrInfoChanged: subscribe(WorkspaceServiceEvent.TaskPrInfoChanged), }); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index c6595e9e2..e4d5f34a6 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -127,6 +127,32 @@ function App() { }), ); + useSubscription( + trpcReact.workspace.onTaskPrInfoChanged.subscriptionOptions(undefined, { + onData: ({ taskId, 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 }), + ); + }, + }), + ); + useSubscription( trpcReact.focus.onBranchRenamed.subscriptionOptions(undefined, { onData: ({ worktreePath, newBranch }) => { diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts b/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts index d68b0d57d..872fe4cea 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts @@ -11,6 +11,10 @@ import { useQuery } from "@tanstack/react-query"; * - local: the linked-branch lookup, falling back to `getPrStatus` on the * active repo path * + * On task switch we prefer the cached PR URL from the workspaces table so the + * value is available synchronously — the live `gh` lookups still run and + * supersede the cache as their values arrive. + * * Shared by the task header (`TaskActionsMenu`) and the command center cell * header (`CommandCenterPRButton`) so they always agree on what PR a task * points at. @@ -35,6 +39,13 @@ export function useTaskPrUrl(taskId: string, isCloud: boolean): string | null { ), ); + const { data: cached } = useQuery( + trpc.workspace.getCachedPrUrl.queryOptions( + { taskId }, + { enabled: !isCloud, staleTime: 60_000 }, + ), + ); + if (isCloud) return cloudPrUrl; - return linkedPrUrl ?? prStatus?.prUrl ?? null; + return linkedPrUrl ?? prStatus?.prUrl ?? cached?.prUrl ?? null; } From 8fbf85b022d7749cd8f609e245817e5db762ef5f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Mon, 25 May 2026 06:30:55 +0300 Subject: [PATCH 2/2] fix(code): drop hasDiff from taskPrInfoChanged + warm getCachedPrUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/code/src/main/services/git/service.ts | 23 ++++++++----------- .../src/main/services/workspace/schemas.ts | 1 - apps/code/src/renderer/App.tsx | 19 +++++++++++---- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 9225eeb22..eff2d6385 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -1855,23 +1855,21 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; const cachedPrUrl = cached.prUrl ?? null; const cachedPrState = (cached.prState ?? null) as SidebarPrState; - if ( - cachedPrUrl === fresh.prUrl && - cachedPrState === fresh.prState && - !fresh.hasDiff - ) { - // Touch fetchedAt without emitting if nothing meaningful changed. - this.workspaceRepo.updatePrCache(taskId, { - prUrl: fresh.prUrl, - prState: fresh.prState, - }); - return; - } this.workspaceRepo.updatePrCache(taskId, { prUrl: fresh.prUrl, prState: fresh.prState, }); + + // Emit only when PR identity or state actually changed. `hasDiff` is + // not persisted (and is recomputed inline on each `getTaskPrStatus` + // call), so it must not feed into the emit decision — otherwise a + // worktree with uncommitted changes but no PR would emit on every + // revalidation cycle. + if (cachedPrUrl === fresh.prUrl && cachedPrState === fresh.prState) { + return; + } + // String literal (rather than `WorkspaceServiceEvent.TaskPrInfoChanged`) // avoids a circular import: workspace/service eagerly loads the DI // container, which in turn re-enters this module. @@ -1879,7 +1877,6 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; taskId, prUrl: fresh.prUrl, prState: fresh.prState, - hasDiff: fresh.hasDiff, }); }) .catch((err) => { diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts index 9adaa0589..c71ca41f9 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -116,7 +116,6 @@ export const taskPrInfoChangedPayload = z.object({ taskId: z.string(), prUrl: z.string().nullable(), prState: z.enum(["merged", "open", "draft", "closed"]).nullable(), - hasDiff: z.boolean(), }); export const linkBranchInput = z.object({ diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index e4d5f34a6..f68edbb35 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -129,10 +129,11 @@ function App() { useSubscription( trpcReact.workspace.onTaskPrInfoChanged.subscriptionOptions(undefined, { - onData: ({ taskId, 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. + onData: ({ taskId, prUrl, prState }) => { + // Push the fresh PR state into every matching getTaskPrStatus query + // (one per cloudPrUrl variant). hasDiff isn't carried by the event — + // it's recomputed inline by the next refetch — so we preserve any + // existing value rather than overwriting it. queryClient.setQueriesData<{ prState: typeof prState; hasDiff: boolean; @@ -147,7 +148,15 @@ function App() { return params?.input?.taskId === taskId; }, }, - () => ({ prState, hasDiff }), + (prev) => (prev ? { ...prev, prState } : { prState, hasDiff: false }), + ); + + // Keep the cached PR URL warm so `useTaskPrUrl`'s "Open PR" fast-path + // sees the new URL immediately instead of waiting for `getCachedPrUrl` + // to go stale. + queryClient.setQueryData( + trpcReact.workspace.getCachedPrUrl.queryKey({ taskId }), + { prUrl }, ); }, }),