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..eff2d6385 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,154 @@ ${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; + + 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. + this.workspaceService.emit("taskPrInfoChanged", { + taskId, + prUrl: fresh.prUrl, + prState: fresh.prState, + }); + }) + .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 +1957,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..c71ca41f9 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -112,6 +112,12 @@ 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(), +}); + export const linkBranchInput = z.object({ taskId: z.string(), branchName: z.string(), @@ -252,6 +258,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 +278,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 +307,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..f68edbb35 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -127,6 +127,41 @@ function App() { }), ); + useSubscription( + trpcReact.workspace.onTaskPrInfoChanged.subscriptionOptions(undefined, { + 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; + }>( + { + ...trpcReact.workspace.getTaskPrStatus.pathFilter(), + predicate: (query) => { + const [, params] = query.queryKey as [ + unknown, + { input?: { taskId?: string } } | undefined, + ]; + return params?.input?.taskId === taskId; + }, + }, + (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 }, + ); + }, + }), + ); + 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; }