diff --git a/apps/code/src/main/services/git/service.test.ts b/apps/code/src/main/services/git/service.test.ts index 3abc28e15..04ec35b41 100644 --- a/apps/code/src/main/services/git/service.test.ts +++ b/apps/code/src/main/services/git/service.test.ts @@ -25,7 +25,7 @@ vi.mock("../../utils/logger.js", () => ({ import type { LlmGatewayService } from "../llm-gateway/service"; import type { WorkspaceService } from "../workspace/service"; -import { GitService } from "./service"; +import { GitService, mapPrState } from "./service"; describe("GitService.getPrChangedFiles", () => { let service: GitService; @@ -269,3 +269,40 @@ describe("GitService.getPrUrlForBranch", () => { expect(result).toBeNull(); }); }); + +describe("mapPrState", () => { + it("returns merged when merged boolean is true", () => { + expect(mapPrState("open", true, false)).toBe("merged"); + expect(mapPrState("closed", true, false)).toBe("merged"); + expect(mapPrState(null, true, false)).toBe("merged"); + }); + + it("returns merged when state string is MERGED", () => { + expect(mapPrState("MERGED", false, false)).toBe("merged"); + expect(mapPrState("merged", false, false)).toBe("merged"); + expect(mapPrState("Merged", false, false)).toBe("merged"); + }); + + it("returns closed for closed state", () => { + expect(mapPrState("closed", false, false)).toBe("closed"); + expect(mapPrState("CLOSED", false, false)).toBe("closed"); + }); + + it("returns draft when draft is true and not merged/closed", () => { + expect(mapPrState("open", false, true)).toBe("draft"); + }); + + it("closed takes priority over draft", () => { + expect(mapPrState("closed", false, true)).toBe("closed"); + }); + + it("returns open for open state", () => { + expect(mapPrState("open", false, false)).toBe("open"); + expect(mapPrState("OPEN", false, false)).toBe("open"); + }); + + it("returns null for unknown state", () => { + expect(mapPrState(null, false, false)).toBeNull(); + expect(mapPrState("something", false, false)).toBeNull(); + }); +}); diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 19d8da28b..e69bb3d9d 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -41,6 +41,7 @@ import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; import type { LlmGatewayService } from "../llm-gateway/service"; +import type { SidebarPrState } from "../workspace/schemas"; import type { WorkspaceService } from "../workspace/service"; import { CreatePrSaga } from "./create-pr-saga"; import type { @@ -94,6 +95,19 @@ const log = logger.scope("git-service"); const FETCH_THROTTLE_MS = 5 * 60 * 1000; const MAX_DIFF_LENGTH = 8000; +export function mapPrState( + state: string | null, + merged: boolean, + draft: boolean, +): SidebarPrState { + const lower = state?.toLowerCase() ?? null; + if (merged || lower === "merged") return "merged"; + if (lower === "closed") return "closed"; + if (draft) return "draft"; + if (lower === "open") return "open"; + return null; +} + /** * Wraps a GitHub API per-file patch (hunk content only) with * the `diff --git` / `---` / `+++` header so that unified-diff @@ -1668,4 +1682,73 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; return []; } } + + async getTaskPrStatus( + taskId: string, + cloudPrUrl: string | null, + ): Promise<{ prState: SidebarPrState; hasDiff: boolean }> { + const workspace = await this.workspaceService.getWorkspace(taskId); + if (!workspace) return { 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 { + prState: mapPrState(details.state, details.merged, details.draft), + hasDiff: false, + }; + } + return { prState: null, hasDiff: false }; + } + + if (isCloud) return { 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 { + prState: mapPrState(details.state, details.merged, details.draft), + hasDiff: false, + }; + } + } + return { 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 { + prState: mapPrState( + prStatus.prState, + false, + prStatus.isDraft ?? false, + ), + hasDiff: false, + }; + } + + const [diffStats, syncStatus] = await Promise.all([ + this.getDiffStats(worktreePath), + this.getGitSyncStatus(worktreePath), + ]); + + const hasDiff = + (diffStats?.filesChanged ?? 0) > 0 || + (syncStatus?.aheadOfDefault ?? 0) > 0; + + return { prState: null, hasDiff }; + } + + return { 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 d770db847..72137ad48 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -229,6 +229,25 @@ export const getAllTaskTimestampsOutput = z.record( }), ); +// Task PR status +export const taskPrStatusInput = z.object({ + taskId: z.string(), + cloudPrUrl: z.string().nullable(), +}); + +export const sidebarPrStateSchema = z + .enum(["merged", "open", "draft", "closed"]) + .nullable(); + +export const taskPrStatusOutput = z.object({ + prState: sidebarPrStateSchema, + hasDiff: z.boolean(), +}); + +export type TaskPrStatusInput = z.infer; +export type SidebarPrState = z.infer; +export type TaskPrStatus = z.infer; + // Type exports export type WorkspaceMode = z.infer; export type WorktreeInfo = z.infer; diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 2510b65c1..186a5c7bb 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -784,6 +784,61 @@ export class WorkspaceService extends TypedEventEmitter return { exists: false }; } + async getWorkspace(taskId: string): Promise { + const assoc = this.findTaskAssociation(taskId); + if (!assoc) return null; + + const dbRow = this.workspaceRepo.findByTaskId(taskId); + const linkedBranch = dbRow?.linkedBranch ?? null; + + if (assoc.mode === "cloud") { + return { + taskId, + folderId: assoc.folderId ?? "", + folderPath: "", + mode: "cloud", + worktreePath: null, + worktreeName: null, + branchName: null, + baseBranch: null, + linkedBranch, + createdAt: new Date().toISOString(), + }; + } + + const folderPath = this.getFolderPath(assoc.folderId); + if (!folderPath) return null; + + let worktreePath: string | null = null; + let worktreeName: string | null = null; + let branchName: string | null = null; + + if (assoc.mode === "worktree") { + worktreeName = assoc.worktree; + worktreePath = deriveWorktreePath(folderPath, worktreeName); + const gitBranch = await getBranchFromPath(worktreePath); + branchName = gitBranch ?? assoc.branchName; + } else if (assoc.mode === "local") { + const localWorktreePath = + await this.getLocalWorktreePathIfExists(folderPath); + const branchPath = localWorktreePath ?? folderPath; + branchName = await getBranchFromPath(branchPath); + } + + return { + taskId, + folderId: assoc.folderId, + folderPath, + mode: assoc.mode, + worktreePath, + worktreeName, + branchName, + baseBranch: null, + linkedBranch, + createdAt: new Date().toISOString(), + }; + } + async getWorkspaceInfo(taskId: string): Promise { const association = this.findTaskAssociation(taskId); if (!association) { diff --git a/apps/code/src/main/trpc/routers/workspace.ts b/apps/code/src/main/trpc/routers/workspace.ts index d37da9a22..ca1085e3e 100644 --- a/apps/code/src/main/trpc/routers/workspace.ts +++ b/apps/code/src/main/trpc/routers/workspace.ts @@ -1,6 +1,7 @@ import type { WorkspaceRepository } from "../../db/repositories/workspace-repository"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; +import type { GitService } from "../../services/git/service"; import { createWorkspaceInput, createWorkspaceOutput, @@ -24,6 +25,8 @@ import { listGitWorktreesOutput, markActivityInput, markViewedInput, + taskPrStatusInput, + taskPrStatusOutput, togglePinInput, togglePinOutput, unlinkBranchInput, @@ -40,6 +43,8 @@ import { publicProcedure, router } from "../trpc"; const getService = () => container.get(MAIN_TOKENS.WorkspaceService); +const getGitService = () => container.get(MAIN_TOKENS.GitService); + const getWorkspaceRepo = () => container.get(MAIN_TOKENS.WorkspaceRepository); @@ -193,6 +198,13 @@ export const workspaceRouter = router({ .input(unlinkBranchInput) .mutation(({ input }) => getService().unlinkBranch(input.taskId, "user")), + getTaskPrStatus: publicProcedure + .input(taskPrStatusInput) + .output(taskPrStatusOutput) + .query(({ input }) => + getGitService().getTaskPrStatus(input.taskId, input.cloudPrUrl), + ), + onError: subscribe(WorkspaceServiceEvent.Error), onWarning: subscribe(WorkspaceServiceEvent.Warning), onPromoted: subscribe(WorkspaceServiceEvent.Promoted), diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts index 365911f3c..061244159 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts @@ -51,15 +51,14 @@ vi.mock("@utils/logger", () => ({ })); vi.mock("@features/sessions/stores/sessionStore", () => { - const fn: any = (selector: any) => - selector({ - taskIdIndex: { "task-1": "run-1" }, - sessions: { "run-1": { events: mockPrompts.value } }, - }); - fn.getState = () => ({ + const state = { taskIdIndex: { "task-1": "run-1" }, sessions: { "run-1": { events: mockPrompts.value } }, - }); + }; + const fn = Object.assign( + (selector: (s: typeof state) => unknown) => selector(state), + { getState: () => state }, + ); return { useSessionStore: fn, sessionStoreSetters: mockSessionStoreSetters, diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index b6489d0af..07960d98f 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -26,6 +26,7 @@ import { getRelativeDateGroup } from "@utils/time"; import { motion } from "framer-motion"; import { Fragment, useCallback, useEffect, useMemo } from "react"; import type { TaskData, TaskGroup } from "../hooks/useSidebarData"; +import { useTaskPrStatus } from "../hooks/useTaskPrStatus"; import { useSidebarStore } from "../stores/sidebarStore"; import { DraggableFolder } from "./DraggableFolder"; import { TaskItem } from "./items/TaskItem"; @@ -100,6 +101,7 @@ function TaskRow({ const effectiveMode = workspace?.mode ?? (task.taskRunEnvironment === "cloud" ? "cloud" : undefined); + const { prState, hasDiff } = useTaskPrStatus(task); return ( void; @@ -150,6 +156,73 @@ function CloudStatusIcon({ ); } +function PrStatusIcon({ + prState, + hasDiff, +}: { + prState?: SidebarPrState; + hasDiff?: boolean; +}) { + if (prState === "merged") { + return ( + + + + + + ); + } + if (prState === "open") { + return ( + + + + + + ); + } + if (prState === "draft") { + return ( + + + + + + ); + } + if (prState === "closed") { + return ( + + + + + + ); + } + if (hasDiff) { + return ( + + + + + + ); + } + return null; +} + export function TaskItem({ depth = 0, taskId, @@ -162,6 +235,8 @@ export function TaskItem({ isPinned = false, needsPermission = false, taskRunStatus, + prState, + hasDiff, timestamp, isEditing = false, onClick, @@ -197,6 +272,8 @@ export function TaskItem({ + ) : prState || hasDiff ? ( + ) : isPinned ? ( ) : ( diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index 4dc20b092..6909c083b 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -31,6 +31,10 @@ export interface TaskData { folderId?: string; taskRunStatus?: TaskRunStatus; taskRunEnvironment?: "local" | "cloud"; + folderPath: string | null; + cloudPrUrl: string | null; + branchName: string | null; + linkedBranch: string | null; } export type TaskGroup = GenericTaskGroup; @@ -158,6 +162,11 @@ export function useSidebarData({ const isUnread = taskLastViewedAt != null && lastActivityAt > taskLastViewedAt; + const cloudPrUrl = + typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : ((session?.cloudOutput?.pr_url as string | undefined) ?? null); + return { id: task.id, title: task.title, @@ -172,6 +181,10 @@ export function useSidebarData({ folderId: workspace?.folderId || undefined, taskRunStatus: session?.cloudStatus ?? task.latest_run?.status, taskRunEnvironment: task.latest_run?.environment, + folderPath: workspace?.folderPath ?? null, + cloudPrUrl, + branchName: workspace?.branchName ?? null, + linkedBranch: workspace?.linkedBranch ?? null, }; }); }, [ diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts new file mode 100644 index 000000000..50c3c3633 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts @@ -0,0 +1,83 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TaskData } from "./useSidebarData"; +import { useTaskPrStatus } from "./useTaskPrStatus"; + +let queryData: unknown; + +vi.mock("@renderer/trpc/client", () => ({ + useTRPC: () => ({ + workspace: { + getTaskPrStatus: { + queryOptions: ( + input: { taskId: string; cloudPrUrl: string | null }, + opts: { staleTime: number }, + ) => ({ + queryKey: ["workspace.getTaskPrStatus", input], + queryFn: () => undefined, + ...opts, + }), + }, + }, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: queryData }), +})); + +function makeTask(overrides: Partial = {}): TaskData { + return { + id: "task-1", + title: "Test task", + createdAt: Date.now(), + lastActivityAt: Date.now(), + isGenerating: false, + isUnread: false, + isPinned: false, + needsPermission: false, + repository: null, + isSuspended: false, + taskRunEnvironment: "local" as const, + folderPath: "/repo", + cloudPrUrl: null, + branchName: "feat/test", + linkedBranch: null, + ...overrides, + }; +} + +describe("useTaskPrStatus", () => { + beforeEach(() => { + queryData = undefined; + }); + + it("returns empty status when no data is available", () => { + const { result } = renderHook(() => useTaskPrStatus(makeTask())); + expect(result.current).toEqual({ prState: null, hasDiff: false }); + }); + + it("returns empty status when data has no prState and no diff", () => { + queryData = { prState: null, hasDiff: false }; + const { result } = renderHook(() => useTaskPrStatus(makeTask())); + expect(result.current).toEqual({ prState: null, hasDiff: false }); + }); + + it("returns prState from query data", () => { + queryData = { prState: "open", hasDiff: false }; + const { result } = renderHook(() => useTaskPrStatus(makeTask())); + expect(result.current).toEqual({ prState: "open", hasDiff: false }); + }); + + it("returns hasDiff from query data", () => { + queryData = { prState: null, hasDiff: true }; + const { result } = renderHook(() => useTaskPrStatus(makeTask())); + expect(result.current).toEqual({ prState: null, hasDiff: true }); + }); + + it("returns both prState and hasDiff from query data", () => { + queryData = { prState: "merged", hasDiff: true }; + const { result } = renderHook(() => useTaskPrStatus(makeTask())); + expect(result.current).toEqual({ prState: "merged", hasDiff: true }); + }); +}); diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts new file mode 100644 index 000000000..22c7787f3 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -0,0 +1,27 @@ +import { useTRPC } from "@renderer/trpc"; +import { useQuery } from "@tanstack/react-query"; +import type { TaskData } from "./useSidebarData"; + +export type SidebarPrState = "merged" | "open" | "draft" | "closed" | null; + +export interface TaskPrStatus { + prState: SidebarPrState; + hasDiff: boolean; +} + +const SIDEBAR_STALE_TIME = 60_000; +const EMPTY: TaskPrStatus = { prState: null, hasDiff: false }; + +export function useTaskPrStatus(task: TaskData): TaskPrStatus { + const trpc = useTRPC(); + + const { data } = useQuery( + trpc.workspace.getTaskPrStatus.queryOptions( + { taskId: task.id, cloudPrUrl: task.cloudPrUrl }, + { staleTime: SIDEBAR_STALE_TIME }, + ), + ); + + if (!data || (!data.prState && !data.hasDiff)) return EMPTY; + return data; +}