diff --git a/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts b/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts index 37a21a86f..70b0ea60a 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts +++ b/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts @@ -72,7 +72,10 @@ export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { const hasLocalChanges = diffStats.filesChanged > 0; const branchSourceAvailable = !!linkedBranch && aheadOfDefault > 0; - const prUrl = useLinkedBranchPrUrl(taskId); + const prUrl = useLinkedBranchPrUrl({ + linkedBranch, + folderPath: workspace?.folderPath ?? null, + }); const prSourceAvailable = !!prUrl; const repoSlug = repoInfo diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx new file mode 100644 index 000000000..53039e756 --- /dev/null +++ b/apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx @@ -0,0 +1,38 @@ +import { PRBadgeLink } from "@features/git-interaction/components/PRBadgeLink"; +import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; +import { useTaskPrUrl } from "@features/git-interaction/hooks/useTaskPrUrl"; +import type { WorkspaceMode } from "@main/services/workspace/schemas"; + +interface CommandCenterPRButtonProps { + taskId: string; + workspaceMode: WorkspaceMode | null; +} + +/** + * PR badge for a task cell in the command center. Same resolution rules as + * `TaskActionsMenu` via `useTaskPrUrl`, gated by `usePrDetails` returning a + * real PR state. + */ +export function CommandCenterPRButton({ + taskId, + workspaceMode, +}: CommandCenterPRButtonProps) { + const isCloud = workspaceMode === "cloud"; + const prUrl = useTaskPrUrl(taskId, isCloud); + + const { + meta: { state, merged, draft }, + } = usePrDetails(prUrl); + + if (!prUrl || state === null) return null; + + return ( + + ); +} diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx index 8f9a612ba..6a5fa61aa 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx +++ b/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx @@ -1,10 +1,14 @@ +import { useCloudPrUrl } from "@features/git-interaction/hooks/useCloudPrUrl"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; +import { TaskIcon } from "@features/sidebar/components/items/TaskIcon"; +import { useTaskPrStatus } from "@features/sidebar/hooks/useTaskPrStatus"; import { TaskInput } from "@features/task-detail/components/TaskInput"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { ArrowsOut, Cloud, Desktop, + Folder, GitFork, Plus, X, @@ -13,13 +17,16 @@ import { Flex, Text } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { CommandCenterCellData } from "../hooks/useCommandCenterData"; +import type { + CellStatus, + CommandCenterCellData, +} from "../hooks/useCommandCenterData"; import { getCellSessionId, useCommandCenterStore, } from "../stores/commandCenterStore"; +import { CommandCenterPRButton } from "./CommandCenterPRButton"; import { CommandCenterSessionView } from "./CommandCenterSessionView"; -import { StatusBadge } from "./StatusBadge"; import { TaskSelector } from "./TaskSelector"; interface CommandCenterPanelProps { @@ -36,12 +43,57 @@ const environmentConfig: Record< cloud: { label: "Cloud", icon: Cloud }, }; +const STATUS_LABEL: Record = { + running: "Running", + waiting: "Waiting", + idle: "Idle", + completed: "Completed", + error: null, +}; + +function CellStatusBadge({ + cell, +}: { + cell: CommandCenterCellData & { task: Task }; +}) { + const { task, session, workspaceMode, status } = cell; + const isCloud = workspaceMode === "cloud"; + const cloudPrUrl = useCloudPrUrl(task.id); + const { prState, hasDiff } = useTaskPrStatus({ + id: task.id, + cloudPrUrl, + taskRunEnvironment: task.latest_run?.environment, + }); + + const label = STATUS_LABEL[status]; + if (label === null) return null; + + const taskRunStatus = isCloud + ? (session?.cloudStatus ?? task.latest_run?.status ?? undefined) + : undefined; + + return ( + + 0} + taskRunStatus={taskRunStatus} + prState={prState} + hasDiff={hasDiff} + size={10} + /> + {label} + + ); +} + function EnvironmentBadge({ mode }: { mode: WorkspaceMode | null }) { if (!mode) return null; const config = environmentConfig[mode]; const Icon = config.icon; return ( - + {config.label} @@ -170,13 +222,18 @@ function PopulatedCell({ {cell.task.title} - + {cell.repoName && ( - + + {cell.repoName} )} + + ); +} diff --git a/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx b/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx index 24482cdc9..e418528ac 100644 --- a/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx @@ -5,21 +5,20 @@ import { GitCommitDialog, GitPushDialog, } from "@features/git-interaction/components/GitInteractionDialogs"; -import { useCloudPrUrl } from "@features/git-interaction/hooks/useCloudPrUrl"; +import { PRBadgeLink } from "@features/git-interaction/components/PRBadgeLink"; import { type GitMenuAction, type GitMenuActionId, useGitInteraction, } from "@features/git-interaction/hooks/useGitInteraction"; -import { useLinkedBranchPrUrl } from "@features/git-interaction/hooks/useLinkedBranchPrUrl"; import { usePrActions } from "@features/git-interaction/hooks/usePrActions"; import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; +import { useTaskPrUrl } from "@features/git-interaction/hooks/useTaskPrUrl"; import { getPrActionIcon, getPrVisualConfig, - parsePrNumber, } from "@features/git-interaction/utils/prStatus"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { useLocalRepoPath } from "@features/workspace/hooks/useLocalRepoPath"; import type { PrActionType } from "@main/services/git/schemas"; import { ArrowsClockwise, @@ -40,7 +39,6 @@ import { } from "@posthog/quill"; import { ChevronDownIcon } from "@radix-ui/react-icons"; import { Button, DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes"; -import { selectIsFocusedOnWorktree, useFocusStore } from "@stores/focusStore"; import { ChevronDown } from "lucide-react"; interface TaskActionsMenuProps { @@ -72,32 +70,14 @@ const NO_WORK_SLOTS = new Set([ */ export function TaskActionsMenu({ taskId, isCloud }: TaskActionsMenuProps) { // Git state (skipped for cloud — useGitInteraction handles undefined repo). - const workspace = useWorkspace(taskId); - const isFocused = useFocusStore( - selectIsFocusedOnWorktree(workspace?.worktreePath ?? ""), - ); - const localRepoPath = isFocused - ? workspace?.folderPath - : (workspace?.worktreePath ?? workspace?.folderPath); + const localRepoPath = useLocalRepoPath(taskId); const { state: gitState, modals, actions: gitActions, } = useGitInteraction(taskId, isCloud ? undefined : localRepoPath); - // PR URL resolution — pick the right source based on task kind. - // For local tasks, prefer the linked-branch lookup. The agent-side - // AgentFileActivity emit is the primary path for keeping `linkedBranch` in - // sync with PRs created via bash (see AgentService.detectAndAttachPrUrl); - // until that link lands we fall back to whatever `getPrStatus` found on - // `localRepoPath`'s current branch. Coverage is partial — when the user is - // focused on the worktree, `localRepoPath` is the main repo and - // `gitState.prUrl` won't see the worktree's feature-branch PR — but the - // primary path closes that gap once the next bash tool call observes the - // PR URL. - const cloudPrUrl = useCloudPrUrl(taskId); - const linkedPrUrl = useLinkedBranchPrUrl(taskId); - const prUrl = isCloud ? cloudPrUrl : (linkedPrUrl ?? gitState.prUrl ?? null); + const prUrl = useTaskPrUrl(taskId, isCloud); const { meta: { state: prState, merged, draft }, @@ -247,33 +227,19 @@ function PrBadgeControl({ onPrSelect, }: PrBadgeControlProps) { const config = getPrVisualConfig(prState, merged, draft); - const prNumber = parsePrNumber(prUrl); const lifecycleItems = config.actions; const hasDropdown = gitItems.length + lifecycleItems.length > 0; return ( - + {hasDropdown && ( diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts b/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts index 305f8dfa2..7f7540a3e 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts @@ -1,17 +1,20 @@ -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; import { useTRPC } from "@renderer/trpc/client"; import { useQuery } from "@tanstack/react-query"; +interface UseLinkedBranchPrUrlArgs { + linkedBranch: string | null; + folderPath: string | null; +} + /** * Resolves the PR URL for a local task's linked branch by looking it up via * `gh pr list --head`. Returns `null` when the task has no linked branch, no * folder path, or the branch has no associated PR on GitHub. */ -export function useLinkedBranchPrUrl(taskId: string): string | null { - const workspace = useWorkspace(taskId); - const linkedBranch = workspace?.linkedBranch ?? null; - const folderPath = workspace?.folderPath ?? null; - +export function useLinkedBranchPrUrl({ + linkedBranch, + folderPath, +}: UseLinkedBranchPrUrlArgs): string | null { const trpc = useTRPC(); const { data } = useQuery( trpc.git.getPrUrlForBranch.queryOptions( diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts b/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts new file mode 100644 index 000000000..d68b0d57d --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts @@ -0,0 +1,40 @@ +import { useCloudPrUrl } from "@features/git-interaction/hooks/useCloudPrUrl"; +import { useLinkedBranchPrUrl } from "@features/git-interaction/hooks/useLinkedBranchPrUrl"; +import { useLocalRepoPath } from "@features/workspace/hooks/useLocalRepoPath"; +import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { useTRPC } from "@renderer/trpc/client"; +import { useQuery } from "@tanstack/react-query"; + +/** + * Resolves the PR URL for a task across all task kinds: + * - cloud: the cloud run's `pr_url` + * - local: the linked-branch lookup, falling back to `getPrStatus` on the + * active repo path + * + * Shared by the task header (`TaskActionsMenu`) and the command center cell + * header (`CommandCenterPRButton`) so they always agree on what PR a task + * points at. + */ +export function useTaskPrUrl(taskId: string, isCloud: boolean): string | null { + const cloudPrUrl = useCloudPrUrl(taskId); + const workspace = useWorkspace(taskId); + const linkedPrUrl = useLinkedBranchPrUrl({ + linkedBranch: workspace?.linkedBranch ?? null, + folderPath: workspace?.folderPath ?? null, + }); + const localRepoPath = useLocalRepoPath(taskId); + + const trpc = useTRPC(); + const { data: prStatus } = useQuery( + trpc.git.getPrStatus.queryOptions( + { directoryPath: localRepoPath ?? "" }, + { + enabled: !isCloud && !!localRepoPath, + staleTime: 30_000, + }, + ), + ); + + if (isCloud) return cloudPrUrl; + return linkedPrUrl ?? prStatus?.prUrl ?? null; +} diff --git a/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx b/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx index 825603f70..96c4c8860 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx +++ b/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx @@ -3,6 +3,7 @@ import { Check, GitMerge, GitPullRequest, + type Icon, PencilSimple, X, } from "@phosphor-icons/react"; @@ -14,7 +15,7 @@ export interface PrAction { export interface PrVisualConfig { color: "gray" | "green" | "red" | "purple"; - icon: React.ReactNode; + Icon: Icon; label: string; actions: PrAction[]; } @@ -27,7 +28,7 @@ export function getPrVisualConfig( if (merged) { return { color: "purple", - icon: , + Icon: GitMerge, label: "Merged", actions: [], }; @@ -35,7 +36,7 @@ export function getPrVisualConfig( if (state === "closed") { return { color: "red", - icon: , + Icon: GitPullRequest, label: "Closed", actions: [{ id: "reopen", label: "Reopen PR" }], }; @@ -43,7 +44,7 @@ export function getPrVisualConfig( if (draft) { return { color: "gray", - icon: , + Icon: GitPullRequest, label: "Draft", actions: [ { id: "ready", label: "Ready for review" }, @@ -53,7 +54,7 @@ export function getPrVisualConfig( } return { color: "green", - icon: , + Icon: GitPullRequest, label: "Open", actions: [ { id: "draft", label: "Convert to draft" }, diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx index 2d8ef6a02..866a5ed65 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx @@ -23,12 +23,18 @@ export const ICON_SIZE = 12; // selected row, which turns a `currentColor` icon black on hover. An explicit // `fill` is immune, and renders identically in the sidebar. -function CloudStatusIcon({ taskRunStatus }: { taskRunStatus?: TaskRunStatus }) { +function CloudStatusIcon({ + taskRunStatus, + size, +}: { + taskRunStatus?: TaskRunStatus; + size: number; +}) { if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { return ( - + ); @@ -37,7 +43,7 @@ function CloudStatusIcon({ taskRunStatus }: { taskRunStatus?: TaskRunStatus }) { return ( - + ); @@ -48,7 +54,7 @@ function CloudStatusIcon({ taskRunStatus }: { taskRunStatus?: TaskRunStatus }) { return ( - + ); @@ -56,7 +62,7 @@ function CloudStatusIcon({ taskRunStatus }: { taskRunStatus?: TaskRunStatus }) { return ( - + ); @@ -65,15 +71,17 @@ function CloudStatusIcon({ taskRunStatus }: { taskRunStatus?: TaskRunStatus }) { function PrStatusIcon({ prState, hasDiff, + size, }: { prState?: SidebarPrState; hasDiff?: boolean; + size: number; }) { if (prState === "merged") { return ( - + ); @@ -82,11 +90,7 @@ function PrStatusIcon({ return ( - + ); @@ -95,11 +99,7 @@ function PrStatusIcon({ return ( - + ); @@ -108,11 +108,7 @@ function PrStatusIcon({ return ( - + ); @@ -121,7 +117,7 @@ function PrStatusIcon({ return ( - + ); @@ -139,6 +135,7 @@ export interface TaskIconProps { taskRunStatus?: TaskRunStatus; prState?: SidebarPrState; hasDiff?: boolean; + size?: number; } /** @@ -156,6 +153,7 @@ export function TaskIcon({ taskRunStatus, prState, hasDiff, + size = ICON_SIZE, }: TaskIconProps) { const isCloudTask = workspaceMode === "cloud"; const isTerminalCloud = isCloudTask && isTerminalStatus(taskRunStatus); @@ -164,25 +162,25 @@ export function TaskIcon({ return ( - + ); } if (isTerminalCloud) { - return ; + return ; } if (isGenerating) { - return ; + return ; } if (isCloudTask) { - return ; + return ; } if (isSuspended) { return ( - + ); @@ -195,10 +193,10 @@ export function TaskIcon({ ); } if (prState || hasDiff) { - return ; + return ; } if (isPinned) { - return ; + return ; } - return ; + return ; } diff --git a/apps/code/src/renderer/features/workspace/hooks/useLocalRepoPath.ts b/apps/code/src/renderer/features/workspace/hooks/useLocalRepoPath.ts new file mode 100644 index 000000000..13bf3e476 --- /dev/null +++ b/apps/code/src/renderer/features/workspace/hooks/useLocalRepoPath.ts @@ -0,0 +1,17 @@ +import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { selectIsFocusedOnWorktree, useFocusStore } from "@stores/focusStore"; + +/** + * Resolves the local repo path to run git commands against for a task. + * When the user is focused on the worktree, commands target the main repo + * (`folderPath`); otherwise they target the worktree itself. + */ +export function useLocalRepoPath(taskId: string): string | undefined { + const workspace = useWorkspace(taskId); + const isFocused = useFocusStore( + selectIsFocusedOnWorktree(workspace?.worktreePath ?? ""), + ); + return isFocused + ? workspace?.folderPath + : (workspace?.worktreePath ?? workspace?.folderPath); +}