diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/apps/code/src/renderer/components/HeaderRow.tsx index fdce96f09..19fd6cb34 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -2,7 +2,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { DiffStatsBadge } from "@features/code-review/components/DiffStatsBadge"; import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; import { CloudGitInteractionHeader } from "@features/git-interaction/components/CloudGitInteractionHeader"; -import { GitInteractionHeader } from "@features/git-interaction/components/GitInteractionHeader"; +import { TaskActionsMenu } from "@features/git-interaction/components/TaskActionsMenu"; import { HandoffConfirmDialog } from "@features/sessions/components/HandoffConfirmDialog"; import { useSessionForTask } from "@features/sessions/hooks/useSession"; import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks"; @@ -206,11 +206,9 @@ export function HeaderRow() { {isCloudTask ? ( ) : ( - <> - - - + )} + )} diff --git a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx b/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx index 1dc29acdb..da7c3ffa9 100644 --- a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx @@ -2,23 +2,15 @@ import { GitBranchDialog, GitCommitDialog, } from "@features/git-interaction/components/GitInteractionDialogs"; -import { useCloudPrUrl } from "@features/git-interaction/hooks/useCloudPrUrl"; import { useGitInteraction } from "@features/git-interaction/hooks/useGitInteraction"; -import { usePrActions } from "@features/git-interaction/hooks/usePrActions"; -import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; import { getSuggestedBranchName } from "@features/git-interaction/utils/getSuggestedBranchName"; -import { - getPrVisualConfig, - parsePrNumber, -} from "@features/git-interaction/utils/prStatus"; import { DirtyTreeDialog } from "@features/sessions/components/DirtyTreeDialog"; import { HandoffConfirmDialog } from "@features/sessions/components/HandoffConfirmDialog"; import { useSessionForTask } from "@features/sessions/hooks/useSession"; import { getLocalHandoffService } from "@features/sessions/service/localHandoffService"; import { useHandoffDialogStore } from "@features/sessions/stores/handoffDialogStore"; -import { ChevronDownIcon } from "@radix-ui/react-icons"; -import { Button, DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes"; +import { Button, Text } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import { useState } from "react"; @@ -31,12 +23,7 @@ export function CloudGitInteractionHeader({ taskId, task, }: CloudGitInteractionHeaderProps) { - const prUrl = useCloudPrUrl(taskId); const session = useSessionForTask(taskId); - const { - meta: { state, merged, draft }, - } = usePrDetails(prUrl); - const { execute, isPending } = usePrActions(prUrl); const localHandoff = getLocalHandoffService(); const confirmOpen = useHandoffDialogStore((s) => s.confirmOpen); @@ -91,13 +78,8 @@ export function CloudGitInteractionHeader({ await localHandoff.resumePending(); }; - const config = - prUrl && state !== null ? getPrVisualConfig(state, merged, draft) : null; - const prNumber = prUrl ? parsePrNumber(prUrl) : null; - const hasDropdown = config ? config.actions.length > 0 : false; - return ( - + <> - {config && ( - - - {hasDropdown && ( - - - - - - {config.actions.map((action) => ( - execute(action.id)} - > - {action.label} - - ))} - - - )} - - )} {confirmOpen && direction === "to-local" && ( )} - + ); } diff --git a/apps/code/src/renderer/features/git-interaction/components/GitInteractionHeader.tsx b/apps/code/src/renderer/features/git-interaction/components/GitInteractionHeader.tsx deleted file mode 100644 index 884ab5b2c..000000000 --- a/apps/code/src/renderer/features/git-interaction/components/GitInteractionHeader.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { CreatePrDialog } from "@features/git-interaction/components/CreatePrDialog"; -import { - GitBranchDialog, - GitCommitDialog, - GitPushDialog, -} from "@features/git-interaction/components/GitInteractionDialogs"; -import { GitInteractionMenu } from "@features/git-interaction/components/GitInteractionMenu"; -import { useGitInteraction } from "@features/git-interaction/hooks/useGitInteraction"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import { selectIsFocusedOnWorktree, useFocusStore } from "@stores/focusStore"; - -interface GitInteractionHeaderProps { - taskId: string; -} - -export function GitInteractionHeader({ taskId }: GitInteractionHeaderProps) { - const workspace = useWorkspace(taskId); - const isFocused = useFocusStore( - selectIsFocusedOnWorktree(workspace?.worktreePath ?? ""), - ); - const repoPath = isFocused - ? workspace?.folderPath - : (workspace?.worktreePath ?? workspace?.folderPath); - const { state, modals, actions } = useGitInteraction(taskId, repoPath); - - return ( - <> -
- -
- - { - if (!open) actions.closeCommit(); - }} - branchName={state.currentBranch} - diffStats={state.diffStats} - commitMessage={modals.commitMessage} - onCommitMessageChange={actions.setCommitMessage} - nextStep={modals.commitNextStep} - onNextStepChange={actions.setCommitNextStep} - pushDisabledReason={state.pushDisabledReason} - onContinue={actions.runCommit} - isSubmitting={modals.isSubmitting} - error={modals.commitError} - onGenerateMessage={actions.generateCommitMessage} - isGeneratingMessage={modals.isGeneratingCommitMessage} - showCommitAllToggle={ - state.stagedFiles.length > 0 && state.unstagedFiles.length > 0 - } - commitAll={modals.commitAll} - onCommitAllChange={actions.setCommitAll} - stagedFileCount={state.stagedFiles.length} - /> - - { - if (!open) actions.closePush(); - }} - branchName={state.currentBranch} - mode={modals.pushMode} - state={modals.pushState} - error={modals.pushError} - onConfirm={actions.runPush} - onClose={actions.closePush} - isSubmitting={modals.isSubmitting} - /> - - { - if (!open) actions.closeCreatePr(); - }} - currentBranch={modals.createPrBaseBranch} - diffStats={state.diffStats} - isSubmitting={modals.isSubmitting} - onSubmit={actions.runCreatePr} - onGenerateCommitMessage={actions.generateCommitMessage} - onGeneratePr={actions.generatePrTitleAndBody} - showCommitAllToggle={ - state.stagedFiles.length > 0 && state.unstagedFiles.length > 0 - } - commitAll={modals.commitAll} - onCommitAllChange={actions.setCommitAll} - stagedFileCount={state.stagedFiles.length} - /> - - { - if (!open) actions.closeBranch(); - }} - branchName={modals.branchName} - onBranchNameChange={actions.setBranchName} - onConfirm={actions.runBranch} - isSubmitting={modals.isSubmitting} - error={modals.branchError} - /> - - ); -} diff --git a/apps/code/src/renderer/features/git-interaction/components/GitInteractionMenu.tsx b/apps/code/src/renderer/features/git-interaction/components/GitInteractionMenu.tsx deleted file mode 100644 index 4a7f46ba7..000000000 --- a/apps/code/src/renderer/features/git-interaction/components/GitInteractionMenu.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import type { - GitMenuAction, - GitMenuActionId, -} from "@features/git-interaction/hooks/useGitInteraction"; -import { - ArrowsClockwise, - CloudArrowUp, - Eye, - GitBranch, - GitCommit, - GitFork, - GitPullRequest, -} from "@phosphor-icons/react"; -import { - Button, - ButtonGroup, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@posthog/quill"; -import { Spinner } from "@radix-ui/themes"; -import { ChevronDown } from "lucide-react"; - -interface GitInteractionMenuProps { - primaryAction: GitMenuAction; - actions: GitMenuAction[]; - isBusy?: boolean; - onPrimary: (actionId: GitMenuActionId) => void; - onSelect: (actionId: GitMenuActionId) => void; -} - -function getActionIcon(actionId: GitMenuActionId) { - switch (actionId) { - case "commit": - return ; - case "push": - return ; - case "sync": - return ; - case "publish": - return ; - case "create-pr": - return ; - case "view-pr": - return ; - case "branch-here": - return ; - default: - return ; - } -} - -export function GitInteractionMenu({ - primaryAction, - actions, - isBusy, - onPrimary, - onSelect, -}: GitInteractionMenuProps) { - const allDisabled = actions.every((a) => !a.enabled); - const showDropdown = actions.length > 1; - const variant = allDisabled ? "default" : "primary"; - const isPrimaryDisabled = !primaryAction.enabled || isBusy; - - const primaryButton = ( - - ); - - const wrappedPrimaryButton = - !primaryAction.enabled && primaryAction.disabledReason ? ( - - {primaryButton} - - ) : ( - primaryButton - ); - - if (!showDropdown || allDisabled) { - return wrappedPrimaryButton; - } - - return ( - - {wrappedPrimaryButton} - - - } - > - - - - {actions.map((action) => { - const icon = getActionIcon(action.id); - const itemContent = ( - <> - {icon} {action.label} - - ); - - if (!action.enabled && action.disabledReason) { - return ( - - {itemContent} - - ); - } - - return ( - onSelect(action.id)} - > - {itemContent} - - ); - })} - - - - ); -} diff --git a/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx b/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx new file mode 100644 index 000000000..c2726ef7a --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx @@ -0,0 +1,451 @@ +import { Tooltip } from "@components/ui/Tooltip"; +import { CreatePrDialog } from "@features/git-interaction/components/CreatePrDialog"; +import { + GitBranchDialog, + GitCommitDialog, + GitPushDialog, +} from "@features/git-interaction/components/GitInteractionDialogs"; +import { useCloudPrUrl } from "@features/git-interaction/hooks/useCloudPrUrl"; +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 { + getPrActionIcon, + getPrVisualConfig, + parsePrNumber, +} from "@features/git-interaction/utils/prStatus"; +import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import type { PrActionType } from "@main/services/git/schemas"; +import { + ArrowsClockwise, + CloudArrowUp, + Eye, + GitBranch, + GitCommit, + GitFork, + GitPullRequest, +} from "@phosphor-icons/react"; +import { + ButtonGroup, + DropdownMenuContent, + DropdownMenuTrigger, + Button as QButton, + DropdownMenu as QDropdownMenu, + DropdownMenuItem as QDropdownMenuItem, +} 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 { + taskId: string; + isCloud: boolean; +} + +/** + * Unified actions control shown in the task header. Combines: + * - Git interaction (commit/push/create-PR/branch) for local tasks + * - PR status badge + PR lifecycle actions (close/draft/ready) for any task + * whose branch has a PR + * + * Trigger is the PR badge when a PR exists (click → GitHub), otherwise the + * primary git action button (click → execute). Chevron opens the full action + * list. Cloud tasks without a PR render nothing. + */ +export function TaskActionsMenu({ taskId, isCloud }: TaskActionsMenuProps) { + // PR URL resolution — pick the right source based on task kind. + const cloudPrUrl = useCloudPrUrl(taskId); + const linkedPrUrl = useLinkedBranchPrUrl(taskId); + const prUrl = isCloud ? cloudPrUrl : linkedPrUrl; + + const { + meta: { state: prState, merged, draft }, + } = usePrDetails(prUrl); + const { execute: executePrAction, isPending: isPrActionPending } = + usePrActions(prUrl); + + // 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 { + state: gitState, + modals, + actions: gitActions, + } = useGitInteraction(taskId, isCloud ? undefined : localRepoPath); + + const pr = prUrl && prState !== null ? { url: prUrl, state: prState } : null; + + // Cloud tasks only appear when they have a PR. + if (isCloud && !pr) return null; + + // "view-pr" is redundant when the badge itself links to the PR; + // "create-pr" is redundant once a PR exists. + const gitItems = isCloud + ? [] + : gitState.actions.filter( + (a) => !(pr && (a.id === "view-pr" || a.id === "create-pr")), + ); + + return ( + <> +
+ {pr ? ( + + ) : ( + + )} +
+ + {!isCloud && ( + <> + { + if (!open) gitActions.closeCommit(); + }} + branchName={gitState.currentBranch} + diffStats={gitState.diffStats} + commitMessage={modals.commitMessage} + onCommitMessageChange={gitActions.setCommitMessage} + nextStep={modals.commitNextStep} + onNextStepChange={gitActions.setCommitNextStep} + pushDisabledReason={gitState.pushDisabledReason} + onContinue={gitActions.runCommit} + isSubmitting={modals.isSubmitting} + error={modals.commitError} + onGenerateMessage={gitActions.generateCommitMessage} + isGeneratingMessage={modals.isGeneratingCommitMessage} + showCommitAllToggle={ + gitState.stagedFiles.length > 0 && + gitState.unstagedFiles.length > 0 + } + commitAll={modals.commitAll} + onCommitAllChange={gitActions.setCommitAll} + stagedFileCount={gitState.stagedFiles.length} + /> + + { + if (!open) gitActions.closePush(); + }} + branchName={gitState.currentBranch} + mode={modals.pushMode} + state={modals.pushState} + error={modals.pushError} + onConfirm={gitActions.runPush} + onClose={gitActions.closePush} + isSubmitting={modals.isSubmitting} + /> + + { + if (!open) gitActions.closeCreatePr(); + }} + currentBranch={modals.createPrBaseBranch} + diffStats={gitState.diffStats} + isSubmitting={modals.isSubmitting} + onSubmit={gitActions.runCreatePr} + onGenerateCommitMessage={gitActions.generateCommitMessage} + onGeneratePr={gitActions.generatePrTitleAndBody} + showCommitAllToggle={ + gitState.stagedFiles.length > 0 && + gitState.unstagedFiles.length > 0 + } + commitAll={modals.commitAll} + onCommitAllChange={gitActions.setCommitAll} + stagedFileCount={gitState.stagedFiles.length} + /> + + { + if (!open) gitActions.closeBranch(); + }} + branchName={modals.branchName} + onBranchNameChange={gitActions.setBranchName} + onConfirm={gitActions.runBranch} + isSubmitting={modals.isSubmitting} + error={modals.branchError} + /> + + )} + + ); +} + +// --- Trigger when a PR exists: colored badge link + combined dropdown --- + +interface PrBadgeControlProps { + prUrl: string; + prState: string; + merged: boolean; + draft: boolean; + isPrPending: boolean; + gitItems: GitMenuAction[]; + onGitSelect: (id: GitMenuActionId) => void; + onPrSelect: (action: PrActionType) => void; +} + +function PrBadgeControl({ + prUrl, + prState, + merged, + draft, + isPrPending, + gitItems, + onGitSelect, + 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 && ( + + + + + + {gitItems.map((item) => ( + + ))} + {gitItems.length > 0 && lifecycleItems.length > 0 && ( + + )} + {lifecycleItems.map((action) => ( + onPrSelect(action.id)} + > + + {getPrActionIcon(action.id)} + {action.label} + + + ))} + + + )} + + ); +} + +// --- Trigger when no PR: solid primary git action + git dropdown --- + +interface GitActionControlProps { + primaryAction: GitMenuAction; + actions: GitMenuAction[]; + isBusy: boolean; + onSelect: (id: GitMenuActionId) => void; +} + +function GitActionControl({ + primaryAction, + actions, + isBusy, + onSelect, +}: GitActionControlProps) { + const allDisabled = actions.every((a) => !a.enabled); + const showDropdown = actions.length > 1; + const variant = allDisabled ? "default" : "primary"; + const isPrimaryDisabled = !primaryAction.enabled || isBusy; + + const primaryButton = ( + onSelect(primaryAction.id)} + className="bg-primary text-primary-foreground not-disabled:hover:bg-primary/80 hover:text-primary-foreground/80" + > + {isBusy ? : getGitActionIcon(primaryAction.id)} + {primaryAction.label} + + ); + + const wrappedPrimaryButton = + !primaryAction.enabled && primaryAction.disabledReason ? ( + + {primaryButton} + + ) : ( + primaryButton + ); + + if (!showDropdown || allDisabled) { + return wrappedPrimaryButton; + } + + return ( + + {wrappedPrimaryButton} + + + } + > + + + + {actions.map((action) => ( + + ))} + + + + ); +} + +// --- Shared dropdown item for git actions (rendered in either menu kind) --- + +function GitDropdownItem({ + action, + onSelect, + renderAs, +}: { + action: GitMenuAction; + onSelect: (id: GitMenuActionId) => void; + renderAs: "quill" | "radix"; +}) { + const icon = getGitActionIcon(action.id); + const label = action.label; + + if (renderAs === "radix") { + const item = ( + onSelect(action.id)} + > + + {icon} + {label} + + + ); + return !action.enabled && action.disabledReason ? ( + + {item} + + ) : ( + item + ); + } + + const itemContent = ( + <> + {icon} {label} + + ); + if (!action.enabled && action.disabledReason) { + return ( + + {itemContent} + + ); + } + return ( + onSelect(action.id)}> + {itemContent} + + ); +} + +function getGitActionIcon(actionId: GitMenuActionId) { + switch (actionId) { + case "commit": + return ; + case "push": + return ; + case "sync": + return ; + case "publish": + return ; + case "create-pr": + return ; + case "view-pr": + return ; + case "branch-here": + return ; + default: + return ; + } +} 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 923618a54..825603f70 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx +++ b/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx @@ -1,5 +1,11 @@ import type { PrActionType } from "@main/services/git/schemas"; -import { GitMerge, GitPullRequest } from "@phosphor-icons/react"; +import { + Check, + GitMerge, + GitPullRequest, + PencilSimple, + X, +} from "@phosphor-icons/react"; export interface PrAction { id: PrActionType; @@ -79,3 +85,16 @@ export const PR_ACTION_LABELS: Record = { export function parsePrNumber(prUrl: string): string | undefined { return prUrl.match(/\/pull\/(\d+)/)?.[1]; } + +export function getPrActionIcon(action: PrActionType): React.ReactNode { + switch (action) { + case "close": + return ; + case "reopen": + return ; + case "ready": + return ; + case "draft": + return ; + } +}