From f38308e7b01309e6a6e27e02368b79b2fa6c932b Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 2 May 2026 17:47:39 -0700 Subject: [PATCH 1/9] Show PR and diff status icons on task list items --- .../sidebar/components/TaskListView.tsx | 7 + .../sidebar/components/items/TaskItem.tsx | 50 ++++++ .../features/sidebar/hooks/useSidebarData.ts | 13 ++ .../features/sidebar/hooks/useTaskPrStatus.ts | 158 ++++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index b6489d0af..80131a67c 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,10 @@ function TaskRow({ const effectiveMode = workspace?.mode ?? (task.taskRunEnvironment === "cloud" ? "cloud" : undefined); + const { prState, hasDiff } = useTaskPrStatus( + task, + workspace?.worktreePath ?? workspace?.folderPath ?? null, + ); return ( void; @@ -162,6 +168,8 @@ export function TaskItem({ isPinned = false, needsPermission = false, taskRunStatus, + prState, + hasDiff, timestamp, isEditing = false, onClick, @@ -187,6 +195,48 @@ export function TaskItem({ ) : isCloudTask ? ( + ) : prState === "merged" ? ( + + + + + + ) : prState === "open" ? ( + + + + + + ) : prState === "draft" ? ( + + + + + + ) : prState === "closed" ? ( + + + + + + ) : hasDiff ? ( + + + + + ) : isSuspended ? ( 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.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts new file mode 100644 index 000000000..5a1137ff5 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -0,0 +1,158 @@ +import { useTRPC } from "@renderer/trpc"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +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 }; + +function mapPrState( + state: string | null, + merged: boolean, + draft: boolean, +): SidebarPrState { + if (merged) return "merged"; + if (state === "closed") return "closed"; + if (draft) return "draft"; + if (state === "open" || state === "OPEN") return "open"; + return null; +} + +/** + * Per-task hook for sidebar icon state. Uses worktreePath for git queries + * (worktree tasks have isolated branches). Local-mode tasks only check PR + * status since their diff stats reflect the shared repo, not the task. + */ +export function useTaskPrStatus( + task: TaskData, + worktreePath: string | null, +): TaskPrStatus { + const trpc = useTRPC(); + const isCloud = task.taskRunEnvironment === "cloud"; + const cloudPrUrl = task.cloudPrUrl; + const linkedBranch = task.linkedBranch; + const hasWorktree = !!worktreePath; + + // Cloud tasks: resolve PR state from the PR URL + const { data: cloudPrDetails } = useQuery( + trpc.git.getPrDetailsByUrl.queryOptions( + { prUrl: cloudPrUrl as string }, + { + enabled: isCloud && !!cloudPrUrl, + staleTime: SIDEBAR_STALE_TIME, + }, + ), + ); + + // Local tasks with linked branch: get PR URL first + const { data: linkedBranchPrUrl } = useQuery( + trpc.git.getPrUrlForBranch.queryOptions( + { + directoryPath: worktreePath as string, + branchName: linkedBranch as string, + }, + { + enabled: !isCloud && hasWorktree && !!linkedBranch, + staleTime: SIDEBAR_STALE_TIME, + }, + ), + ); + + // Local tasks with linked branch: get PR details from that URL + const { data: linkedPrDetails } = useQuery( + trpc.git.getPrDetailsByUrl.queryOptions( + { prUrl: linkedBranchPrUrl as string }, + { + enabled: !isCloud && !!linkedBranchPrUrl, + staleTime: SIDEBAR_STALE_TIME, + }, + ), + ); + + // Local tasks without linked branch: use getPrStatus (checks current branch) + const { data: localPrStatus } = useQuery( + trpc.git.getPrStatus.queryOptions( + { directoryPath: worktreePath as string }, + { + enabled: !isCloud && hasWorktree && !linkedBranch, + staleTime: SIDEBAR_STALE_TIME, + }, + ), + ); + + // Only query diff stats for worktree tasks (isolated branches). + // Skip if we already know there's a PR (icon takes priority over hasDiff). + const knownPrUrl = !!cloudPrUrl || !!linkedBranchPrUrl; + const knownLocalPr = localPrStatus?.prExists === true; + const skipDiff = isCloud || !hasWorktree || knownPrUrl || knownLocalPr; + + const { data: diffStats } = useQuery( + trpc.git.getDiffStats.queryOptions( + { directoryPath: worktreePath as string }, + { + enabled: !skipDiff, + staleTime: SIDEBAR_STALE_TIME, + }, + ), + ); + + const { data: syncStatus } = useQuery( + trpc.git.getGitSyncStatus.queryOptions( + { directoryPath: worktreePath as string }, + { + enabled: !skipDiff, + staleTime: SIDEBAR_STALE_TIME, + }, + ), + ); + + return useMemo(() => { + // Derive PR state + let prState: SidebarPrState = null; + + if (isCloud && cloudPrDetails) { + prState = mapPrState( + cloudPrDetails.state, + cloudPrDetails.merged, + cloudPrDetails.draft, + ); + } else if (!isCloud && linkedBranch && linkedPrDetails) { + prState = mapPrState( + linkedPrDetails.state, + linkedPrDetails.merged, + linkedPrDetails.draft, + ); + } else if (!isCloud && !linkedBranch && localPrStatus) { + if (localPrStatus.prExists && localPrStatus.prState) { + prState = mapPrState( + localPrStatus.prState.toLowerCase(), + localPrStatus.prState === "MERGED", + localPrStatus.isDraft ?? false, + ); + } + } + + // hasDiff: uncommitted changes OR commits ahead of default branch + const hasDiff = + (diffStats?.filesChanged ?? 0) > 0 || + (syncStatus?.aheadOfDefault ?? 0) > 0; + + if (!prState && !hasDiff) return EMPTY; + return { prState, hasDiff }; + }, [ + isCloud, + cloudPrDetails, + linkedBranch, + linkedPrDetails, + localPrStatus, + diffStats, + syncStatus, + ]); +} From 3c4faa80cb96a3ef4fceece472f190b5072cbe10 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 01:04:35 -0700 Subject: [PATCH 2/9] Fix mapPrState case handling and add tests --- .../sidebar/hooks/useTaskPrStatus.test.ts | 279 ++++++++++++++++++ .../features/sidebar/hooks/useTaskPrStatus.ts | 11 +- 2 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts 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..109e96e3e --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts @@ -0,0 +1,279 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TaskData } from "./useSidebarData"; +import { mapPrState, useTaskPrStatus } from "./useTaskPrStatus"; + +// --- useQuery mock wiring --- + +const queryResults = vi.hoisted( + () => new Map(), +); + +vi.mock("@renderer/trpc/client", () => ({ + useTRPC: () => ({ + git: { + getPrDetailsByUrl: { + queryOptions: ( + input: { prUrl: string }, + opts: { enabled: boolean }, + ) => { + const key = `getPrDetailsByUrl:${input.prUrl}`; + queryResults.set(key, { + data: queryResults.get(key)?.data, + enabled: opts.enabled, + }); + return { queryKey: [key], queryFn: () => undefined, ...opts }; + }, + }, + getPrUrlForBranch: { + queryOptions: ( + input: { directoryPath: string; branchName: string }, + opts: { enabled: boolean }, + ) => { + const key = `getPrUrlForBranch:${input.branchName}`; + queryResults.set(key, { + data: queryResults.get(key)?.data, + enabled: opts.enabled, + }); + return { queryKey: [key], queryFn: () => undefined, ...opts }; + }, + }, + getPrStatus: { + queryOptions: ( + input: { directoryPath: string }, + opts: { enabled: boolean }, + ) => { + const key = `getPrStatus:${input.directoryPath}`; + queryResults.set(key, { + data: queryResults.get(key)?.data, + enabled: opts.enabled, + }); + return { queryKey: [key], queryFn: () => undefined, ...opts }; + }, + }, + getDiffStats: { + queryOptions: ( + input: { directoryPath: string }, + opts: { enabled: boolean }, + ) => { + const key = `getDiffStats:${input.directoryPath}`; + queryResults.set(key, { + data: queryResults.get(key)?.data, + enabled: opts.enabled, + }); + return { queryKey: [key], queryFn: () => undefined, ...opts }; + }, + }, + getGitSyncStatus: { + queryOptions: ( + input: { directoryPath: string }, + opts: { enabled: boolean }, + ) => { + const key = `getGitSyncStatus:${input.directoryPath}`; + queryResults.set(key, { + data: queryResults.get(key)?.data, + enabled: opts.enabled, + }); + return { queryKey: [key], queryFn: () => undefined, ...opts }; + }, + }, + }, + }), +})); + +let mockQueryReturn: { data: unknown } = { data: undefined }; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => mockQueryReturn, +})); + +// --- Helpers --- + +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, + }; +} + +// --- Tests --- + +describe("mapPrState", () => { + it("returns merged when merged is true regardless of state", () => { + expect(mapPrState("open", true, false)).toBe("merged"); + expect(mapPrState("OPEN", true, false)).toBe("merged"); + expect(mapPrState("closed", true, false)).toBe("merged"); + expect(mapPrState(null, true, false)).toBe("merged"); + }); + + it("returns closed for closed state (case-insensitive)", () => { + expect(mapPrState("closed", false, false)).toBe("closed"); + 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"); + expect(mapPrState("OPEN", false, true)).toBe("draft"); + }); + + it("returns open for open state (case-insensitive)", () => { + expect(mapPrState("open", false, false)).toBe("open"); + 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(); + }); + + it("merged takes priority over closed", () => { + expect(mapPrState("closed", true, false)).toBe("merged"); + }); + + it("closed takes priority over draft", () => { + expect(mapPrState("closed", false, true)).toBe("closed"); + }); +}); + +describe("useTaskPrStatus", () => { + beforeEach(() => { + queryResults.clear(); + mockQueryReturn = { data: undefined }; + }); + + it("returns empty status when no data is available", () => { + const task = makeTask(); + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + + expect(result.current).toEqual({ prState: null, hasDiff: false }); + }); + + it("returns empty status when worktreePath is null", () => { + const task = makeTask(); + const { result } = renderHook(() => useTaskPrStatus(task, null)); + + expect(result.current).toEqual({ prState: null, hasDiff: false }); + }); + + it("returns empty for cloud task with no PR URL", () => { + const task = makeTask({ + taskRunEnvironment: "cloud", + cloudPrUrl: null, + }); + const { result } = renderHook(() => useTaskPrStatus(task, null)); + + expect(result.current).toEqual({ prState: null, hasDiff: false }); + }); +}); + +describe("useTaskPrStatus query enablement", () => { + beforeEach(() => { + queryResults.clear(); + mockQueryReturn = { data: undefined }; + }); + + it("enables cloud PR details query only for cloud tasks with a PR URL", () => { + const enabledCases = [ + { + task: makeTask({ + taskRunEnvironment: "cloud", + cloudPrUrl: "https://github.com/org/repo/pull/1", + }), + path: null as string | null, + }, + ]; + + const disabledCases = [ + { task: makeTask({ taskRunEnvironment: "local" }), path: "/worktree" }, + { + task: makeTask({ taskRunEnvironment: "cloud", cloudPrUrl: null }), + path: null as string | null, + }, + ]; + + for (const { task, path } of enabledCases) { + renderHook(() => useTaskPrStatus(task, path)); + const entry = queryResults.get(`getPrDetailsByUrl:${task.cloudPrUrl}`); + expect(entry?.enabled).toBe(true); + } + + for (const { task, path } of disabledCases) { + queryResults.clear(); + renderHook(() => useTaskPrStatus(task, path)); + const entries = [...queryResults.entries()].filter(([k]) => + k.startsWith("getPrDetailsByUrl:"), + ); + for (const [, entry] of entries) { + expect(entry.enabled).toBe(false); + } + } + }); + + it("enables linked branch PR lookup only for local tasks with worktree and linkedBranch", () => { + const task = makeTask({ linkedBranch: "feat/linked" }); + renderHook(() => useTaskPrStatus(task, "/worktree")); + + const entry = queryResults.get("getPrUrlForBranch:feat/linked"); + expect(entry?.enabled).toBe(true); + }); + + it("disables linked branch PR lookup when no worktree path", () => { + const task = makeTask({ linkedBranch: "feat/linked" }); + renderHook(() => useTaskPrStatus(task, null)); + + const entry = queryResults.get("getPrUrlForBranch:feat/linked"); + expect(entry?.enabled).toBe(false); + }); + + it("enables local PR status for worktree tasks without linked branch", () => { + const task = makeTask({ linkedBranch: null }); + renderHook(() => useTaskPrStatus(task, "/worktree")); + + const entry = queryResults.get("getPrStatus:/worktree"); + expect(entry?.enabled).toBe(true); + }); + + it("disables local PR status when task has a linked branch", () => { + const task = makeTask({ linkedBranch: "feat/linked" }); + renderHook(() => useTaskPrStatus(task, "/worktree")); + + const entry = queryResults.get("getPrStatus:/worktree"); + expect(entry?.enabled).toBe(false); + }); + + it("disables diff stats for cloud tasks", () => { + const task = makeTask({ taskRunEnvironment: "cloud" }); + renderHook(() => useTaskPrStatus(task, "/worktree")); + + const entry = queryResults.get("getDiffStats:/worktree"); + expect(entry?.enabled).toBe(false); + }); + + it("disables diff stats when no worktree path", () => { + const task = makeTask(); + renderHook(() => useTaskPrStatus(task, null)); + + const entries = [...queryResults.entries()].filter(([k]) => + k.startsWith("getDiffStats:"), + ); + for (const [, entry] of entries) { + expect(entry.enabled).toBe(false); + } + }); +}); diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts index 5a1137ff5..9c90136d7 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -13,15 +13,16 @@ export interface TaskPrStatus { const SIDEBAR_STALE_TIME = 60_000; const EMPTY: TaskPrStatus = { prState: null, hasDiff: false }; -function mapPrState( +export function mapPrState( state: string | null, merged: boolean, draft: boolean, ): SidebarPrState { if (merged) return "merged"; - if (state === "closed") return "closed"; + const lower = state?.toLowerCase() ?? null; + if (lower === "closed") return "closed"; if (draft) return "draft"; - if (state === "open" || state === "OPEN") return "open"; + if (lower === "open") return "open"; return null; } @@ -132,8 +133,8 @@ export function useTaskPrStatus( } else if (!isCloud && !linkedBranch && localPrStatus) { if (localPrStatus.prExists && localPrStatus.prState) { prState = mapPrState( - localPrStatus.prState.toLowerCase(), - localPrStatus.prState === "MERGED", + localPrStatus.prState, + localPrStatus.prState.toUpperCase() === "MERGED", localPrStatus.isDraft ?? false, ); } From 2b6aa22f0428cfef88dca20516c4339e612a228e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 01:10:12 -0700 Subject: [PATCH 3/9] Remove comments and add derivation tests --- .../sidebar/hooks/useTaskPrStatus.test.ts | 180 +++++++++++++++++- .../features/sidebar/hooks/useTaskPrStatus.ts | 13 -- 2 files changed, 176 insertions(+), 17 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts index 109e96e3e..5b434ea52 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts @@ -81,10 +81,18 @@ vi.mock("@renderer/trpc/client", () => ({ }), })); -let mockQueryReturn: { data: unknown } = { data: undefined }; +// useQuery is called 6 times per render in a fixed order: +// 0: cloudPrDetails, 1: linkedBranchPrUrl, 2: linkedPrDetails, +// 3: localPrStatus, 4: diffStats, 5: syncStatus +let queryReturnsByIndex: Array<{ data: unknown }> = []; +let queryCallIndex = 0; vi.mock("@tanstack/react-query", () => ({ - useQuery: () => mockQueryReturn, + useQuery: () => { + const result = queryReturnsByIndex[queryCallIndex] ?? { data: undefined }; + queryCallIndex++; + return result; + }, })); // --- Helpers --- @@ -154,7 +162,8 @@ describe("mapPrState", () => { describe("useTaskPrStatus", () => { beforeEach(() => { queryResults.clear(); - mockQueryReturn = { data: undefined }; + queryReturnsByIndex = []; + queryCallIndex = 0; }); it("returns empty status when no data is available", () => { @@ -185,7 +194,8 @@ describe("useTaskPrStatus", () => { describe("useTaskPrStatus query enablement", () => { beforeEach(() => { queryResults.clear(); - mockQueryReturn = { data: undefined }; + queryReturnsByIndex = []; + queryCallIndex = 0; }); it("enables cloud PR details query only for cloud tasks with a PR URL", () => { @@ -277,3 +287,165 @@ describe("useTaskPrStatus query enablement", () => { } }); }); + +// Helper to set per-query return values by index +function setQueryData(overrides: { + cloudPrDetails?: unknown; + linkedBranchPrUrl?: unknown; + linkedPrDetails?: unknown; + localPrStatus?: unknown; + diffStats?: unknown; + syncStatus?: unknown; +}) { + queryReturnsByIndex = [ + { data: overrides.cloudPrDetails }, + { data: overrides.linkedBranchPrUrl }, + { data: overrides.linkedPrDetails }, + { data: overrides.localPrStatus }, + { data: overrides.diffStats }, + { data: overrides.syncStatus }, + ]; +} + +describe("useTaskPrStatus derivation", () => { + beforeEach(() => { + queryResults.clear(); + queryReturnsByIndex = []; + queryCallIndex = 0; + }); + + it("derives open state from cloud PR details", () => { + const task = makeTask({ + taskRunEnvironment: "cloud", + cloudPrUrl: "https://github.com/org/repo/pull/1", + }); + setQueryData({ + cloudPrDetails: { state: "open", merged: false, draft: false }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, null)); + expect(result.current.prState).toBe("open"); + }); + + it("derives merged state from cloud PR details", () => { + const task = makeTask({ + taskRunEnvironment: "cloud", + cloudPrUrl: "https://github.com/org/repo/pull/1", + }); + setQueryData({ + cloudPrDetails: { state: "closed", merged: true, draft: false }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, null)); + expect(result.current.prState).toBe("merged"); + }); + + it("derives draft state from cloud PR details", () => { + const task = makeTask({ + taskRunEnvironment: "cloud", + cloudPrUrl: "https://github.com/org/repo/pull/1", + }); + setQueryData({ + cloudPrDetails: { state: "open", merged: false, draft: true }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, null)); + expect(result.current.prState).toBe("draft"); + }); + + it("derives state from linked branch PR details", () => { + const task = makeTask({ linkedBranch: "feat/linked" }); + setQueryData({ + linkedPrDetails: { state: "open", merged: false, draft: false }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.prState).toBe("open"); + }); + + it("derives state from local PR status with uppercase state", () => { + const task = makeTask({ linkedBranch: null }); + setQueryData({ + localPrStatus: { prExists: true, prState: "OPEN", isDraft: false }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.prState).toBe("open"); + }); + + it("derives merged from local PR status with MERGED state", () => { + const task = makeTask({ linkedBranch: null }); + setQueryData({ + localPrStatus: { prExists: true, prState: "MERGED", isDraft: false }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.prState).toBe("merged"); + }); + + it("derives draft from local PR status", () => { + const task = makeTask({ linkedBranch: null }); + setQueryData({ + localPrStatus: { prExists: true, prState: "OPEN", isDraft: true }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.prState).toBe("draft"); + }); + + it("returns null prState when local PR does not exist", () => { + const task = makeTask({ linkedBranch: null }); + setQueryData({ + localPrStatus: { prExists: false, prState: null, isDraft: null }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.prState).toBeNull(); + }); + + it("hasDiff is true when filesChanged > 0", () => { + const task = makeTask({ linkedBranch: null }); + setQueryData({ + diffStats: { filesChanged: 3, linesAdded: 10, linesRemoved: 2 }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.hasDiff).toBe(true); + }); + + it("hasDiff is true when aheadOfDefault > 0", () => { + const task = makeTask({ linkedBranch: null }); + setQueryData({ + syncStatus: { aheadOfDefault: 2 }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.hasDiff).toBe(true); + }); + + it("hasDiff is false when no changes and not ahead", () => { + const task = makeTask({ linkedBranch: null }); + setQueryData({ + diffStats: { filesChanged: 0, linesAdded: 0, linesRemoved: 0 }, + syncStatus: { aheadOfDefault: 0 }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.hasDiff).toBe(false); + }); + + it("cloud PR state takes priority over linked branch data", () => { + const task = makeTask({ + taskRunEnvironment: "cloud", + cloudPrUrl: "https://github.com/org/repo/pull/1", + linkedBranch: "feat/linked", + }); + setQueryData({ + cloudPrDetails: { state: "open", merged: false, draft: false }, + linkedPrDetails: { state: "closed", merged: false, draft: false }, + }); + + const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + expect(result.current.prState).toBe("open"); + }); +}); diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts index 9c90136d7..7c5c5cf19 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -26,11 +26,6 @@ export function mapPrState( return null; } -/** - * Per-task hook for sidebar icon state. Uses worktreePath for git queries - * (worktree tasks have isolated branches). Local-mode tasks only check PR - * status since their diff stats reflect the shared repo, not the task. - */ export function useTaskPrStatus( task: TaskData, worktreePath: string | null, @@ -41,7 +36,6 @@ export function useTaskPrStatus( const linkedBranch = task.linkedBranch; const hasWorktree = !!worktreePath; - // Cloud tasks: resolve PR state from the PR URL const { data: cloudPrDetails } = useQuery( trpc.git.getPrDetailsByUrl.queryOptions( { prUrl: cloudPrUrl as string }, @@ -52,7 +46,6 @@ export function useTaskPrStatus( ), ); - // Local tasks with linked branch: get PR URL first const { data: linkedBranchPrUrl } = useQuery( trpc.git.getPrUrlForBranch.queryOptions( { @@ -66,7 +59,6 @@ export function useTaskPrStatus( ), ); - // Local tasks with linked branch: get PR details from that URL const { data: linkedPrDetails } = useQuery( trpc.git.getPrDetailsByUrl.queryOptions( { prUrl: linkedBranchPrUrl as string }, @@ -77,7 +69,6 @@ export function useTaskPrStatus( ), ); - // Local tasks without linked branch: use getPrStatus (checks current branch) const { data: localPrStatus } = useQuery( trpc.git.getPrStatus.queryOptions( { directoryPath: worktreePath as string }, @@ -88,8 +79,6 @@ export function useTaskPrStatus( ), ); - // Only query diff stats for worktree tasks (isolated branches). - // Skip if we already know there's a PR (icon takes priority over hasDiff). const knownPrUrl = !!cloudPrUrl || !!linkedBranchPrUrl; const knownLocalPr = localPrStatus?.prExists === true; const skipDiff = isCloud || !hasWorktree || knownPrUrl || knownLocalPr; @@ -115,7 +104,6 @@ export function useTaskPrStatus( ); return useMemo(() => { - // Derive PR state let prState: SidebarPrState = null; if (isCloud && cloudPrDetails) { @@ -140,7 +128,6 @@ export function useTaskPrStatus( } } - // hasDiff: uncommitted changes OR commits ahead of default branch const hasDiff = (diffStats?.filesChanged ?? 0) > 0 || (syncStatus?.aheadOfDefault ?? 0) > 0; From 12d8df4cb05469d5d57acf05eea65fa3d317e709 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 16:08:32 -0700 Subject: [PATCH 4/9] Skip diff queries for tasks with linked branch --- .../features/sidebar/hooks/useTaskPrStatus.test.ts | 8 ++++++++ .../renderer/features/sidebar/hooks/useTaskPrStatus.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts index 5b434ea52..a74c0192f 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts @@ -275,6 +275,14 @@ describe("useTaskPrStatus query enablement", () => { expect(entry?.enabled).toBe(false); }); + it("disables diff stats when task has a linked branch", () => { + const task = makeTask({ linkedBranch: "feat/linked" }); + renderHook(() => useTaskPrStatus(task, "/worktree")); + + const entry = queryResults.get("getDiffStats:/worktree"); + expect(entry?.enabled).toBe(false); + }); + it("disables diff stats when no worktree path", () => { const task = makeTask(); renderHook(() => useTaskPrStatus(task, null)); diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts index 7c5c5cf19..e8c12ca1c 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -81,7 +81,8 @@ export function useTaskPrStatus( const knownPrUrl = !!cloudPrUrl || !!linkedBranchPrUrl; const knownLocalPr = localPrStatus?.prExists === true; - const skipDiff = isCloud || !hasWorktree || knownPrUrl || knownLocalPr; + const skipDiff = + isCloud || !hasWorktree || knownPrUrl || knownLocalPr || !!linkedBranch; const { data: diffStats } = useQuery( trpc.git.getDiffStats.queryOptions( From 83607bc5d500751bfe1107643c420b4eb5076d8a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 18:23:34 -0700 Subject: [PATCH 5/9] Handle MERGED state string and fix icon priority --- .../sidebar/components/items/TaskItem.tsx | 20 +++++++++---------- .../sidebar/hooks/useTaskPrStatus.test.ts | 6 ++++++ .../features/sidebar/hooks/useTaskPrStatus.ts | 4 ++-- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx index 7a1eb906c..d0cb87f2d 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -195,6 +195,16 @@ export function TaskItem({ ) : isCloudTask ? ( + ) : isSuspended ? ( + + + + + + ) : isUnread ? ( + + + ) : prState === "merged" ? ( @@ -237,16 +247,6 @@ export function TaskItem({ - ) : isSuspended ? ( - - - - - - ) : isUnread ? ( - - - ) : isPinned ? ( ) : ( diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts index a74c0192f..29c609ee0 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts @@ -145,6 +145,12 @@ describe("mapPrState", () => { expect(mapPrState("Open", false, false)).toBe("open"); }); + it("returns merged when state string is MERGED even if merged boolean is false", () => { + expect(mapPrState("MERGED", false, false)).toBe("merged"); + expect(mapPrState("merged", false, false)).toBe("merged"); + expect(mapPrState("Merged", false, false)).toBe("merged"); + }); + it("returns null for unknown state", () => { expect(mapPrState(null, false, false)).toBeNull(); expect(mapPrState("something", false, false)).toBeNull(); diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts index e8c12ca1c..de5f6a0cf 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -18,8 +18,8 @@ export function mapPrState( merged: boolean, draft: boolean, ): SidebarPrState { - if (merged) return "merged"; 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"; @@ -123,7 +123,7 @@ export function useTaskPrStatus( if (localPrStatus.prExists && localPrStatus.prState) { prState = mapPrState( localPrStatus.prState, - localPrStatus.prState.toUpperCase() === "MERGED", + false, localPrStatus.isDraft ?? false, ); } From 5935e26928c15ff429917195218eed0b1966e006 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 3 May 2026 22:09:59 -0700 Subject: [PATCH 6/9] Fix PR status bleeding across tasks sharing a repo path Generated-By: PostHog Code Task-Id: 024d3719-d3db-4e67-ae44-43cb14f338cd --- .../sidebar/components/TaskListView.tsx | 3 +- .../sidebar/hooks/useTaskPrStatus.test.ts | 94 +++++++++++++------ .../features/sidebar/hooks/useTaskPrStatus.ts | 6 +- 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 80131a67c..371d880e8 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -103,7 +103,8 @@ function TaskRow({ (task.taskRunEnvironment === "cloud" ? "cloud" : undefined); const { prState, hasDiff } = useTaskPrStatus( task, - workspace?.worktreePath ?? workspace?.folderPath ?? null, + workspace?.worktreePath ?? null, + workspace?.folderPath ?? null, ); return ( diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts index 29c609ee0..4ed669ac9 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts @@ -174,14 +174,16 @@ describe("useTaskPrStatus", () => { it("returns empty status when no data is available", () => { const task = makeTask(); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current).toEqual({ prState: null, hasDiff: false }); }); it("returns empty status when worktreePath is null", () => { const task = makeTask(); - const { result } = renderHook(() => useTaskPrStatus(task, null)); + const { result } = renderHook(() => useTaskPrStatus(task, null, null)); expect(result.current).toEqual({ prState: null, hasDiff: false }); }); @@ -191,7 +193,7 @@ describe("useTaskPrStatus", () => { taskRunEnvironment: "cloud", cloudPrUrl: null, }); - const { result } = renderHook(() => useTaskPrStatus(task, null)); + const { result } = renderHook(() => useTaskPrStatus(task, null, null)); expect(result.current).toEqual({ prState: null, hasDiff: false }); }); @@ -211,27 +213,33 @@ describe("useTaskPrStatus query enablement", () => { taskRunEnvironment: "cloud", cloudPrUrl: "https://github.com/org/repo/pull/1", }), - path: null as string | null, + worktreePath: null as string | null, + repoPath: null as string | null, }, ]; const disabledCases = [ - { task: makeTask({ taskRunEnvironment: "local" }), path: "/worktree" }, + { + task: makeTask({ taskRunEnvironment: "local" }), + worktreePath: "/worktree", + repoPath: "/repo", + }, { task: makeTask({ taskRunEnvironment: "cloud", cloudPrUrl: null }), - path: null as string | null, + worktreePath: null as string | null, + repoPath: null as string | null, }, ]; - for (const { task, path } of enabledCases) { - renderHook(() => useTaskPrStatus(task, path)); + for (const { task, worktreePath, repoPath } of enabledCases) { + renderHook(() => useTaskPrStatus(task, worktreePath, repoPath)); const entry = queryResults.get(`getPrDetailsByUrl:${task.cloudPrUrl}`); expect(entry?.enabled).toBe(true); } - for (const { task, path } of disabledCases) { + for (const { task, worktreePath, repoPath } of disabledCases) { queryResults.clear(); - renderHook(() => useTaskPrStatus(task, path)); + renderHook(() => useTaskPrStatus(task, worktreePath, repoPath)); const entries = [...queryResults.entries()].filter(([k]) => k.startsWith("getPrDetailsByUrl:"), ); @@ -243,15 +251,23 @@ describe("useTaskPrStatus query enablement", () => { it("enables linked branch PR lookup only for local tasks with worktree and linkedBranch", () => { const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, "/worktree")); + renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); + + const entry = queryResults.get("getPrUrlForBranch:feat/linked"); + expect(entry?.enabled).toBe(true); + }); + + it("enables linked branch PR lookup with repoPath when no worktree", () => { + const task = makeTask({ linkedBranch: "feat/linked" }); + renderHook(() => useTaskPrStatus(task, null, "/repo")); const entry = queryResults.get("getPrUrlForBranch:feat/linked"); expect(entry?.enabled).toBe(true); }); - it("disables linked branch PR lookup when no worktree path", () => { + it("disables linked branch PR lookup when no worktree or repo path", () => { const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, null)); + renderHook(() => useTaskPrStatus(task, null, null)); const entry = queryResults.get("getPrUrlForBranch:feat/linked"); expect(entry?.enabled).toBe(false); @@ -259,7 +275,7 @@ describe("useTaskPrStatus query enablement", () => { it("enables local PR status for worktree tasks without linked branch", () => { const task = makeTask({ linkedBranch: null }); - renderHook(() => useTaskPrStatus(task, "/worktree")); + renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); const entry = queryResults.get("getPrStatus:/worktree"); expect(entry?.enabled).toBe(true); @@ -267,7 +283,7 @@ describe("useTaskPrStatus query enablement", () => { it("disables local PR status when task has a linked branch", () => { const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, "/worktree")); + renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); const entry = queryResults.get("getPrStatus:/worktree"); expect(entry?.enabled).toBe(false); @@ -275,7 +291,7 @@ describe("useTaskPrStatus query enablement", () => { it("disables diff stats for cloud tasks", () => { const task = makeTask({ taskRunEnvironment: "cloud" }); - renderHook(() => useTaskPrStatus(task, "/worktree")); + renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); const entry = queryResults.get("getDiffStats:/worktree"); expect(entry?.enabled).toBe(false); @@ -283,7 +299,7 @@ describe("useTaskPrStatus query enablement", () => { it("disables diff stats when task has a linked branch", () => { const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, "/worktree")); + renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); const entry = queryResults.get("getDiffStats:/worktree"); expect(entry?.enabled).toBe(false); @@ -291,7 +307,7 @@ describe("useTaskPrStatus query enablement", () => { it("disables diff stats when no worktree path", () => { const task = makeTask(); - renderHook(() => useTaskPrStatus(task, null)); + renderHook(() => useTaskPrStatus(task, null, null)); const entries = [...queryResults.entries()].filter(([k]) => k.startsWith("getDiffStats:"), @@ -337,7 +353,7 @@ describe("useTaskPrStatus derivation", () => { cloudPrDetails: { state: "open", merged: false, draft: false }, }); - const { result } = renderHook(() => useTaskPrStatus(task, null)); + const { result } = renderHook(() => useTaskPrStatus(task, null, null)); expect(result.current.prState).toBe("open"); }); @@ -350,7 +366,7 @@ describe("useTaskPrStatus derivation", () => { cloudPrDetails: { state: "closed", merged: true, draft: false }, }); - const { result } = renderHook(() => useTaskPrStatus(task, null)); + const { result } = renderHook(() => useTaskPrStatus(task, null, null)); expect(result.current.prState).toBe("merged"); }); @@ -363,7 +379,7 @@ describe("useTaskPrStatus derivation", () => { cloudPrDetails: { state: "open", merged: false, draft: true }, }); - const { result } = renderHook(() => useTaskPrStatus(task, null)); + const { result } = renderHook(() => useTaskPrStatus(task, null, null)); expect(result.current.prState).toBe("draft"); }); @@ -373,7 +389,9 @@ describe("useTaskPrStatus derivation", () => { linkedPrDetails: { state: "open", merged: false, draft: false }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.prState).toBe("open"); }); @@ -383,7 +401,9 @@ describe("useTaskPrStatus derivation", () => { localPrStatus: { prExists: true, prState: "OPEN", isDraft: false }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.prState).toBe("open"); }); @@ -393,7 +413,9 @@ describe("useTaskPrStatus derivation", () => { localPrStatus: { prExists: true, prState: "MERGED", isDraft: false }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.prState).toBe("merged"); }); @@ -403,7 +425,9 @@ describe("useTaskPrStatus derivation", () => { localPrStatus: { prExists: true, prState: "OPEN", isDraft: true }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.prState).toBe("draft"); }); @@ -413,7 +437,9 @@ describe("useTaskPrStatus derivation", () => { localPrStatus: { prExists: false, prState: null, isDraft: null }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.prState).toBeNull(); }); @@ -423,7 +449,9 @@ describe("useTaskPrStatus derivation", () => { diffStats: { filesChanged: 3, linesAdded: 10, linesRemoved: 2 }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.hasDiff).toBe(true); }); @@ -433,7 +461,9 @@ describe("useTaskPrStatus derivation", () => { syncStatus: { aheadOfDefault: 2 }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.hasDiff).toBe(true); }); @@ -444,7 +474,9 @@ describe("useTaskPrStatus derivation", () => { syncStatus: { aheadOfDefault: 0 }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.hasDiff).toBe(false); }); @@ -459,7 +491,9 @@ describe("useTaskPrStatus derivation", () => { linkedPrDetails: { state: "closed", merged: false, draft: false }, }); - const { result } = renderHook(() => useTaskPrStatus(task, "/worktree")); + const { result } = renderHook(() => + useTaskPrStatus(task, "/worktree", "/repo"), + ); expect(result.current.prState).toBe("open"); }); }); diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts index de5f6a0cf..a8516b9be 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -29,12 +29,14 @@ export function mapPrState( export function useTaskPrStatus( task: TaskData, worktreePath: string | null, + repoPath: string | null, ): TaskPrStatus { const trpc = useTRPC(); const isCloud = task.taskRunEnvironment === "cloud"; const cloudPrUrl = task.cloudPrUrl; const linkedBranch = task.linkedBranch; const hasWorktree = !!worktreePath; + const hasRepo = !!repoPath; const { data: cloudPrDetails } = useQuery( trpc.git.getPrDetailsByUrl.queryOptions( @@ -49,11 +51,11 @@ export function useTaskPrStatus( const { data: linkedBranchPrUrl } = useQuery( trpc.git.getPrUrlForBranch.queryOptions( { - directoryPath: worktreePath as string, + directoryPath: (worktreePath ?? repoPath) as string, branchName: linkedBranch as string, }, { - enabled: !isCloud && hasWorktree && !!linkedBranch, + enabled: !isCloud && (hasWorktree || hasRepo) && !!linkedBranch, staleTime: SIDEBAR_STALE_TIME, }, ), From 8d21c33bd7ffc1e99e616536cff580551af30101 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 4 May 2026 07:19:15 -0700 Subject: [PATCH 7/9] Move PR status resolution from renderer hook to git service --- .../src/main/services/git/service.test.ts | 39 +- apps/code/src/main/services/git/service.ts | 83 +++ .../src/main/services/workspace/schemas.ts | 19 + .../src/main/services/workspace/service.ts | 55 ++ apps/code/src/main/trpc/routers/workspace.ts | 12 + .../sidebar/components/TaskListView.tsx | 6 +- .../sidebar/hooks/useTaskPrStatus.test.ts | 474 ++---------------- .../features/sidebar/hooks/useTaskPrStatus.ts | 136 +---- 8 files changed, 244 insertions(+), 580 deletions(-) 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/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 371d880e8..07960d98f 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -101,11 +101,7 @@ function TaskRow({ const effectiveMode = workspace?.mode ?? (task.taskRunEnvironment === "cloud" ? "cloud" : undefined); - const { prState, hasDiff } = useTaskPrStatus( - task, - workspace?.worktreePath ?? null, - workspace?.folderPath ?? null, - ); + const { prState, hasDiff } = useTaskPrStatus(task); return ( new Map(), -); +let queryData: unknown; vi.mock("@renderer/trpc/client", () => ({ useTRPC: () => ({ - git: { - getPrDetailsByUrl: { - queryOptions: ( - input: { prUrl: string }, - opts: { enabled: boolean }, - ) => { - const key = `getPrDetailsByUrl:${input.prUrl}`; - queryResults.set(key, { - data: queryResults.get(key)?.data, - enabled: opts.enabled, - }); - return { queryKey: [key], queryFn: () => undefined, ...opts }; - }, - }, - getPrUrlForBranch: { - queryOptions: ( - input: { directoryPath: string; branchName: string }, - opts: { enabled: boolean }, - ) => { - const key = `getPrUrlForBranch:${input.branchName}`; - queryResults.set(key, { - data: queryResults.get(key)?.data, - enabled: opts.enabled, - }); - return { queryKey: [key], queryFn: () => undefined, ...opts }; - }, - }, - getPrStatus: { - queryOptions: ( - input: { directoryPath: string }, - opts: { enabled: boolean }, - ) => { - const key = `getPrStatus:${input.directoryPath}`; - queryResults.set(key, { - data: queryResults.get(key)?.data, - enabled: opts.enabled, - }); - return { queryKey: [key], queryFn: () => undefined, ...opts }; - }, - }, - getDiffStats: { + workspace: { + getTaskPrStatus: { queryOptions: ( - input: { directoryPath: string }, - opts: { enabled: boolean }, - ) => { - const key = `getDiffStats:${input.directoryPath}`; - queryResults.set(key, { - data: queryResults.get(key)?.data, - enabled: opts.enabled, - }); - return { queryKey: [key], queryFn: () => undefined, ...opts }; - }, - }, - getGitSyncStatus: { - queryOptions: ( - input: { directoryPath: string }, - opts: { enabled: boolean }, - ) => { - const key = `getGitSyncStatus:${input.directoryPath}`; - queryResults.set(key, { - data: queryResults.get(key)?.data, - enabled: opts.enabled, - }); - return { queryKey: [key], queryFn: () => undefined, ...opts }; - }, + input: { taskId: string; cloudPrUrl: string | null }, + opts: { staleTime: number }, + ) => ({ + queryKey: ["workspace.getTaskPrStatus", input], + queryFn: () => undefined, + ...opts, + }), }, }, }), })); -// useQuery is called 6 times per render in a fixed order: -// 0: cloudPrDetails, 1: linkedBranchPrUrl, 2: linkedPrDetails, -// 3: localPrStatus, 4: diffStats, 5: syncStatus -let queryReturnsByIndex: Array<{ data: unknown }> = []; -let queryCallIndex = 0; - vi.mock("@tanstack/react-query", () => ({ - useQuery: () => { - const result = queryReturnsByIndex[queryCallIndex] ?? { data: undefined }; - queryCallIndex++; - return result; - }, + useQuery: () => ({ data: queryData }), })); -// --- Helpers --- - function makeTask(overrides: Partial = {}): TaskData { return { id: "task-1", @@ -118,382 +47,37 @@ function makeTask(overrides: Partial = {}): TaskData { }; } -// --- Tests --- - -describe("mapPrState", () => { - it("returns merged when merged is true regardless of state", () => { - expect(mapPrState("open", true, false)).toBe("merged"); - expect(mapPrState("OPEN", true, false)).toBe("merged"); - expect(mapPrState("closed", true, false)).toBe("merged"); - expect(mapPrState(null, true, false)).toBe("merged"); - }); - - it("returns closed for closed state (case-insensitive)", () => { - expect(mapPrState("closed", false, false)).toBe("closed"); - 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"); - expect(mapPrState("OPEN", false, true)).toBe("draft"); - }); - - it("returns open for open state (case-insensitive)", () => { - expect(mapPrState("open", false, false)).toBe("open"); - expect(mapPrState("OPEN", false, false)).toBe("open"); - expect(mapPrState("Open", false, false)).toBe("open"); - }); - - it("returns merged when state string is MERGED even if merged boolean is false", () => { - expect(mapPrState("MERGED", false, false)).toBe("merged"); - expect(mapPrState("merged", false, false)).toBe("merged"); - expect(mapPrState("Merged", false, false)).toBe("merged"); - }); - - it("returns null for unknown state", () => { - expect(mapPrState(null, false, false)).toBeNull(); - expect(mapPrState("something", false, false)).toBeNull(); - }); - - it("merged takes priority over closed", () => { - expect(mapPrState("closed", true, false)).toBe("merged"); - }); - - it("closed takes priority over draft", () => { - expect(mapPrState("closed", false, true)).toBe("closed"); - }); -}); - describe("useTaskPrStatus", () => { beforeEach(() => { - queryResults.clear(); - queryReturnsByIndex = []; - queryCallIndex = 0; + queryData = undefined; }); it("returns empty status when no data is available", () => { - const task = makeTask(); - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - + const { result } = renderHook(() => useTaskPrStatus(makeTask())); expect(result.current).toEqual({ prState: null, hasDiff: false }); }); - it("returns empty status when worktreePath is null", () => { - const task = makeTask(); - const { result } = renderHook(() => useTaskPrStatus(task, null, null)); - + 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 empty for cloud task with no PR URL", () => { - const task = makeTask({ - taskRunEnvironment: "cloud", - cloudPrUrl: null, - }); - const { result } = renderHook(() => useTaskPrStatus(task, null, null)); - - 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 }); }); -}); -describe("useTaskPrStatus query enablement", () => { - beforeEach(() => { - queryResults.clear(); - queryReturnsByIndex = []; - queryCallIndex = 0; + 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("enables cloud PR details query only for cloud tasks with a PR URL", () => { - const enabledCases = [ - { - task: makeTask({ - taskRunEnvironment: "cloud", - cloudPrUrl: "https://github.com/org/repo/pull/1", - }), - worktreePath: null as string | null, - repoPath: null as string | null, - }, - ]; - - const disabledCases = [ - { - task: makeTask({ taskRunEnvironment: "local" }), - worktreePath: "/worktree", - repoPath: "/repo", - }, - { - task: makeTask({ taskRunEnvironment: "cloud", cloudPrUrl: null }), - worktreePath: null as string | null, - repoPath: null as string | null, - }, - ]; - - for (const { task, worktreePath, repoPath } of enabledCases) { - renderHook(() => useTaskPrStatus(task, worktreePath, repoPath)); - const entry = queryResults.get(`getPrDetailsByUrl:${task.cloudPrUrl}`); - expect(entry?.enabled).toBe(true); - } - - for (const { task, worktreePath, repoPath } of disabledCases) { - queryResults.clear(); - renderHook(() => useTaskPrStatus(task, worktreePath, repoPath)); - const entries = [...queryResults.entries()].filter(([k]) => - k.startsWith("getPrDetailsByUrl:"), - ); - for (const [, entry] of entries) { - expect(entry.enabled).toBe(false); - } - } - }); - - it("enables linked branch PR lookup only for local tasks with worktree and linkedBranch", () => { - const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); - - const entry = queryResults.get("getPrUrlForBranch:feat/linked"); - expect(entry?.enabled).toBe(true); - }); - - it("enables linked branch PR lookup with repoPath when no worktree", () => { - const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, null, "/repo")); - - const entry = queryResults.get("getPrUrlForBranch:feat/linked"); - expect(entry?.enabled).toBe(true); - }); - - it("disables linked branch PR lookup when no worktree or repo path", () => { - const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, null, null)); - - const entry = queryResults.get("getPrUrlForBranch:feat/linked"); - expect(entry?.enabled).toBe(false); - }); - - it("enables local PR status for worktree tasks without linked branch", () => { - const task = makeTask({ linkedBranch: null }); - renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); - - const entry = queryResults.get("getPrStatus:/worktree"); - expect(entry?.enabled).toBe(true); - }); - - it("disables local PR status when task has a linked branch", () => { - const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); - - const entry = queryResults.get("getPrStatus:/worktree"); - expect(entry?.enabled).toBe(false); - }); - - it("disables diff stats for cloud tasks", () => { - const task = makeTask({ taskRunEnvironment: "cloud" }); - renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); - - const entry = queryResults.get("getDiffStats:/worktree"); - expect(entry?.enabled).toBe(false); - }); - - it("disables diff stats when task has a linked branch", () => { - const task = makeTask({ linkedBranch: "feat/linked" }); - renderHook(() => useTaskPrStatus(task, "/worktree", "/repo")); - - const entry = queryResults.get("getDiffStats:/worktree"); - expect(entry?.enabled).toBe(false); - }); - - it("disables diff stats when no worktree path", () => { - const task = makeTask(); - renderHook(() => useTaskPrStatus(task, null, null)); - - const entries = [...queryResults.entries()].filter(([k]) => - k.startsWith("getDiffStats:"), - ); - for (const [, entry] of entries) { - expect(entry.enabled).toBe(false); - } - }); -}); - -// Helper to set per-query return values by index -function setQueryData(overrides: { - cloudPrDetails?: unknown; - linkedBranchPrUrl?: unknown; - linkedPrDetails?: unknown; - localPrStatus?: unknown; - diffStats?: unknown; - syncStatus?: unknown; -}) { - queryReturnsByIndex = [ - { data: overrides.cloudPrDetails }, - { data: overrides.linkedBranchPrUrl }, - { data: overrides.linkedPrDetails }, - { data: overrides.localPrStatus }, - { data: overrides.diffStats }, - { data: overrides.syncStatus }, - ]; -} - -describe("useTaskPrStatus derivation", () => { - beforeEach(() => { - queryResults.clear(); - queryReturnsByIndex = []; - queryCallIndex = 0; - }); - - it("derives open state from cloud PR details", () => { - const task = makeTask({ - taskRunEnvironment: "cloud", - cloudPrUrl: "https://github.com/org/repo/pull/1", - }); - setQueryData({ - cloudPrDetails: { state: "open", merged: false, draft: false }, - }); - - const { result } = renderHook(() => useTaskPrStatus(task, null, null)); - expect(result.current.prState).toBe("open"); - }); - - it("derives merged state from cloud PR details", () => { - const task = makeTask({ - taskRunEnvironment: "cloud", - cloudPrUrl: "https://github.com/org/repo/pull/1", - }); - setQueryData({ - cloudPrDetails: { state: "closed", merged: true, draft: false }, - }); - - const { result } = renderHook(() => useTaskPrStatus(task, null, null)); - expect(result.current.prState).toBe("merged"); - }); - - it("derives draft state from cloud PR details", () => { - const task = makeTask({ - taskRunEnvironment: "cloud", - cloudPrUrl: "https://github.com/org/repo/pull/1", - }); - setQueryData({ - cloudPrDetails: { state: "open", merged: false, draft: true }, - }); - - const { result } = renderHook(() => useTaskPrStatus(task, null, null)); - expect(result.current.prState).toBe("draft"); - }); - - it("derives state from linked branch PR details", () => { - const task = makeTask({ linkedBranch: "feat/linked" }); - setQueryData({ - linkedPrDetails: { state: "open", merged: false, draft: false }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.prState).toBe("open"); - }); - - it("derives state from local PR status with uppercase state", () => { - const task = makeTask({ linkedBranch: null }); - setQueryData({ - localPrStatus: { prExists: true, prState: "OPEN", isDraft: false }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.prState).toBe("open"); - }); - - it("derives merged from local PR status with MERGED state", () => { - const task = makeTask({ linkedBranch: null }); - setQueryData({ - localPrStatus: { prExists: true, prState: "MERGED", isDraft: false }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.prState).toBe("merged"); - }); - - it("derives draft from local PR status", () => { - const task = makeTask({ linkedBranch: null }); - setQueryData({ - localPrStatus: { prExists: true, prState: "OPEN", isDraft: true }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.prState).toBe("draft"); - }); - - it("returns null prState when local PR does not exist", () => { - const task = makeTask({ linkedBranch: null }); - setQueryData({ - localPrStatus: { prExists: false, prState: null, isDraft: null }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.prState).toBeNull(); - }); - - it("hasDiff is true when filesChanged > 0", () => { - const task = makeTask({ linkedBranch: null }); - setQueryData({ - diffStats: { filesChanged: 3, linesAdded: 10, linesRemoved: 2 }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.hasDiff).toBe(true); - }); - - it("hasDiff is true when aheadOfDefault > 0", () => { - const task = makeTask({ linkedBranch: null }); - setQueryData({ - syncStatus: { aheadOfDefault: 2 }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.hasDiff).toBe(true); - }); - - it("hasDiff is false when no changes and not ahead", () => { - const task = makeTask({ linkedBranch: null }); - setQueryData({ - diffStats: { filesChanged: 0, linesAdded: 0, linesRemoved: 0 }, - syncStatus: { aheadOfDefault: 0 }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.hasDiff).toBe(false); - }); - - it("cloud PR state takes priority over linked branch data", () => { - const task = makeTask({ - taskRunEnvironment: "cloud", - cloudPrUrl: "https://github.com/org/repo/pull/1", - linkedBranch: "feat/linked", - }); - setQueryData({ - cloudPrDetails: { state: "open", merged: false, draft: false }, - linkedPrDetails: { state: "closed", merged: false, draft: false }, - }); - - const { result } = renderHook(() => - useTaskPrStatus(task, "/worktree", "/repo"), - ); - expect(result.current.prState).toBe("open"); + 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 index a8516b9be..22c7787f3 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts @@ -1,6 +1,5 @@ import { useTRPC } from "@renderer/trpc"; import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; import type { TaskData } from "./useSidebarData"; export type SidebarPrState = "merged" | "open" | "draft" | "closed" | null; @@ -13,137 +12,16 @@ export interface TaskPrStatus { const SIDEBAR_STALE_TIME = 60_000; const EMPTY: TaskPrStatus = { prState: null, hasDiff: false }; -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; -} - -export function useTaskPrStatus( - task: TaskData, - worktreePath: string | null, - repoPath: string | null, -): TaskPrStatus { +export function useTaskPrStatus(task: TaskData): TaskPrStatus { const trpc = useTRPC(); - const isCloud = task.taskRunEnvironment === "cloud"; - const cloudPrUrl = task.cloudPrUrl; - const linkedBranch = task.linkedBranch; - const hasWorktree = !!worktreePath; - const hasRepo = !!repoPath; - - const { data: cloudPrDetails } = useQuery( - trpc.git.getPrDetailsByUrl.queryOptions( - { prUrl: cloudPrUrl as string }, - { - enabled: isCloud && !!cloudPrUrl, - staleTime: SIDEBAR_STALE_TIME, - }, - ), - ); - - const { data: linkedBranchPrUrl } = useQuery( - trpc.git.getPrUrlForBranch.queryOptions( - { - directoryPath: (worktreePath ?? repoPath) as string, - branchName: linkedBranch as string, - }, - { - enabled: !isCloud && (hasWorktree || hasRepo) && !!linkedBranch, - staleTime: SIDEBAR_STALE_TIME, - }, - ), - ); - - const { data: linkedPrDetails } = useQuery( - trpc.git.getPrDetailsByUrl.queryOptions( - { prUrl: linkedBranchPrUrl as string }, - { - enabled: !isCloud && !!linkedBranchPrUrl, - staleTime: SIDEBAR_STALE_TIME, - }, - ), - ); - const { data: localPrStatus } = useQuery( - trpc.git.getPrStatus.queryOptions( - { directoryPath: worktreePath as string }, - { - enabled: !isCloud && hasWorktree && !linkedBranch, - staleTime: SIDEBAR_STALE_TIME, - }, + const { data } = useQuery( + trpc.workspace.getTaskPrStatus.queryOptions( + { taskId: task.id, cloudPrUrl: task.cloudPrUrl }, + { staleTime: SIDEBAR_STALE_TIME }, ), ); - const knownPrUrl = !!cloudPrUrl || !!linkedBranchPrUrl; - const knownLocalPr = localPrStatus?.prExists === true; - const skipDiff = - isCloud || !hasWorktree || knownPrUrl || knownLocalPr || !!linkedBranch; - - const { data: diffStats } = useQuery( - trpc.git.getDiffStats.queryOptions( - { directoryPath: worktreePath as string }, - { - enabled: !skipDiff, - staleTime: SIDEBAR_STALE_TIME, - }, - ), - ); - - const { data: syncStatus } = useQuery( - trpc.git.getGitSyncStatus.queryOptions( - { directoryPath: worktreePath as string }, - { - enabled: !skipDiff, - staleTime: SIDEBAR_STALE_TIME, - }, - ), - ); - - return useMemo(() => { - let prState: SidebarPrState = null; - - if (isCloud && cloudPrDetails) { - prState = mapPrState( - cloudPrDetails.state, - cloudPrDetails.merged, - cloudPrDetails.draft, - ); - } else if (!isCloud && linkedBranch && linkedPrDetails) { - prState = mapPrState( - linkedPrDetails.state, - linkedPrDetails.merged, - linkedPrDetails.draft, - ); - } else if (!isCloud && !linkedBranch && localPrStatus) { - if (localPrStatus.prExists && localPrStatus.prState) { - prState = mapPrState( - localPrStatus.prState, - false, - localPrStatus.isDraft ?? false, - ); - } - } - - const hasDiff = - (diffStats?.filesChanged ?? 0) > 0 || - (syncStatus?.aheadOfDefault ?? 0) > 0; - - if (!prState && !hasDiff) return EMPTY; - return { prState, hasDiff }; - }, [ - isCloud, - cloudPrDetails, - linkedBranch, - linkedPrDetails, - localPrStatus, - diffStats, - syncStatus, - ]); + if (!data || (!data.prState && !data.hasDiff)) return EMPTY; + return data; } From 0d98f40ca3bba4fc3965a0aa39c64b132da90150 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 4 May 2026 07:23:01 -0700 Subject: [PATCH 8/9] Fix noExplicitAny lint warnings in useChatTitleGenerator test --- .../sessions/hooks/useChatTitleGenerator.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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, From 9d74bcd93545f1730900a80abfead32bbca6ef93 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 4 May 2026 07:38:58 -0700 Subject: [PATCH 9/9] Extract PrStatusIcon into its own component --- .../sidebar/components/items/TaskItem.tsx | 111 +++++++++++------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx index d0cb87f2d..eb604baeb 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -156,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, @@ -205,48 +272,8 @@ export function TaskItem({ - ) : prState === "merged" ? ( - - - - - - ) : prState === "open" ? ( - - - - - - ) : prState === "draft" ? ( - - - - - - ) : prState === "closed" ? ( - - - - - - ) : hasDiff ? ( - - - - - + ) : prState || hasDiff ? ( + ) : isPinned ? ( ) : (