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 ;
+ }
+}