diff --git a/frontend/src/components/ui-new/actions/index.ts b/frontend/src/components/ui-new/actions/index.ts index 6234b3a1..56cd8fed 100644 --- a/frontend/src/components/ui-new/actions/index.ts +++ b/frontend/src/components/ui-new/actions/index.ts @@ -30,6 +30,7 @@ import { CrosshairIcon, DesktopIcon, PencilSimpleIcon, + ArrowUpIcon, } from '@phosphor-icons/react'; import { useDiffViewStore } from '@/stores/useDiffViewStore'; import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore'; @@ -99,6 +100,8 @@ export interface ActionVisibilityContext { // Git panel state hasGitRepos: boolean; hasMultipleRepos: boolean; + hasOpenPR: boolean; + hasUnpushedCommits: boolean; } // Base properties shared by all actions @@ -686,6 +689,30 @@ export const Actions = { }); }, }, + + GitPush: { + id: 'git-push', + label: 'Push', + icon: ArrowUpIcon, + requiresTarget: 'git', + isVisible: (ctx) => + ctx.hasWorkspace && + ctx.hasGitRepos && + ctx.hasOpenPR && + ctx.hasUnpushedCommits, + execute: async (ctx, workspaceId, repoId) => { + const result = await attemptsApi.push(workspaceId, { repo_id: repoId }); + if (!result.success) { + if (result.error?.type === 'force_push_required') { + throw new Error( + 'Force push required. The remote branch has diverged.' + ); + } + throw new Error('Failed to push changes'); + } + invalidateWorkspaceQueries(ctx.queryClient, workspaceId); + }, + }, } as const satisfies Record; // Helper to resolve dynamic label diff --git a/frontend/src/components/ui-new/actions/pages.ts b/frontend/src/components/ui-new/actions/pages.ts index 9b7eb637..cde344eb 100644 --- a/frontend/src/components/ui-new/actions/pages.ts +++ b/frontend/src/components/ui-new/actions/pages.ts @@ -166,6 +166,7 @@ export const Pages: Record = { items: [ { type: 'action', action: Actions.GitCreatePR }, { type: 'action', action: Actions.GitMerge }, + { type: 'action', action: Actions.GitPush }, { type: 'action', action: Actions.GitRebase }, { type: 'action', action: Actions.GitChangeTarget }, ], diff --git a/frontend/src/components/ui-new/actions/useActionVisibility.ts b/frontend/src/components/ui-new/actions/useActionVisibility.ts index d839b49a..2fed226d 100644 --- a/frontend/src/components/ui-new/actions/useActionVisibility.ts +++ b/frontend/src/components/ui-new/actions/useActionVisibility.ts @@ -5,7 +5,8 @@ import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; import { useUserSystem } from '@/components/ConfigProvider'; import { useDevServer } from '@/hooks/useDevServer'; -import type { Workspace } from 'shared/types'; +import { useBranchStatus } from '@/hooks/useBranchStatus'; +import type { Workspace, Merge } from 'shared/types'; import type { ActionVisibilityContext, ActionDefinition, @@ -29,6 +30,7 @@ export function useActionVisibilityContext(): ActionVisibilityContext { const { config } = useUserSystem(); const { isStarting, isStopping, runningDevServers } = useDevServer(workspaceId); + const { data: branchStatus } = useBranchStatus(workspaceId); return useMemo(() => { // Compute isAllDiffsExpanded @@ -45,6 +47,18 @@ export function useActionVisibilityContext(): ActionVisibilityContext { ? 'running' : 'stopped'; + // Compute git state from branch status + const hasOpenPR = + branchStatus?.some((repo) => + repo.merges?.some( + (m: Merge) => m.type === 'pr' && m.pr_info.status === 'open' + ) + ) ?? false; + + const hasUnpushedCommits = + branchStatus?.some((repo) => (repo.remote_commits_ahead ?? 0) > 0) ?? + false; + return { isChangesMode: layout.isChangesMode, isLogsMode: layout.isLogsMode, @@ -63,6 +77,8 @@ export function useActionVisibilityContext(): ActionVisibilityContext { runningDevServers, hasGitRepos: repos.length > 0, hasMultipleRepos: repos.length > 1, + hasOpenPR, + hasUnpushedCommits, }; }, [ layout.isChangesMode, @@ -81,6 +97,7 @@ export function useActionVisibilityContext(): ActionVisibilityContext { isStarting, isStopping, runningDevServers, + branchStatus, ]); } diff --git a/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx b/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx index d77c2518..99640d98 100644 --- a/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx +++ b/frontend/src/components/ui-new/containers/WorkspacesLayout.tsx @@ -27,7 +27,10 @@ import { NavbarContainer } from '@/components/ui-new/containers/NavbarContainer' import { PreviewBrowserContainer } from '@/components/ui-new/containers/PreviewBrowserContainer'; import { PreviewControlsContainer } from '@/components/ui-new/containers/PreviewControlsContainer'; import { useRenameBranch } from '@/hooks/useRenameBranch'; +import { usePush } from '@/hooks/usePush'; import { repoApi } from '@/lib/api'; +import { ConfirmDialog } from '@/components/ui-new/dialogs/ConfirmDialog'; +import { ForcePushDialog } from '@/components/dialogs/git/ForcePushDialog'; import { useDiffStream } from '@/hooks/useDiffStream'; import { useTask } from '@/hooks/useTask'; import { useAttemptRepo } from '@/hooks/useAttemptRepo'; @@ -53,6 +56,8 @@ interface GitPanelContainerProps { onBranchNameChange: (name: string) => void; } +type PushState = 'idle' | 'pending' | 'success' | 'error'; + function GitPanelContainer({ selectedWorkspace, repos, @@ -61,6 +66,101 @@ function GitPanelContainer({ }: GitPanelContainerProps) { const { executeAction } = useActions(); + // Track push state per repo: idle, pending, success, or error + const [pushStates, setPushStates] = useState>({}); + const pushStatesRef = useRef>({}); + pushStatesRef.current = pushStates; + const successTimeoutRef = useRef | null>(null); + const currentPushRepoRef = useRef(null); + + // Reset push-related state when the selected workspace changes to avoid + // leaking push state across workspaces with repos that share the same ID. + useEffect(() => { + setPushStates({}); + pushStatesRef.current = {}; + currentPushRepoRef.current = null; + + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + successTimeoutRef.current = null; + } + }, [selectedWorkspace?.id]); + // Use push hook for direct API access with proper error handling + const pushMutation = usePush( + selectedWorkspace?.id, + // onSuccess + () => { + const repoId = currentPushRepoRef.current; + if (!repoId) return; + setPushStates((prev) => ({ ...prev, [repoId]: 'success' })); + // Clear success state after 2 seconds + successTimeoutRef.current = setTimeout(() => { + setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); + }, 2000); + }, + // onError + async (err, errorData) => { + const repoId = currentPushRepoRef.current; + if (!repoId) return; + + // Handle force push required - show confirmation dialog + if (errorData?.type === 'force_push_required' && selectedWorkspace?.id) { + setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); + await ForcePushDialog.show({ + attemptId: selectedWorkspace.id, + repoId, + }); + return; + } + + // Show error state and dialog for other errors + setPushStates((prev) => ({ ...prev, [repoId]: 'error' })); + const message = + err instanceof Error ? err.message : 'Failed to push changes'; + ConfirmDialog.show({ + title: 'Error', + message, + confirmText: 'OK', + showCancelButton: false, + variant: 'destructive', + }); + // Clear error state after 3 seconds + successTimeoutRef.current = setTimeout(() => { + setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); + }, 3000); + } + ); + + // Clean up timeout on unmount + useEffect(() => { + return () => { + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + } + }; + }, []); + + // Compute repoInfos with push button state + const repoInfosWithPushButton = useMemo( + () => + repoInfos.map((repo) => { + const state = pushStates[repo.id] ?? 'idle'; + const hasUnpushedCommits = + repo.prStatus === 'open' && (repo.remoteCommitsAhead ?? 0) > 0; + // Show push button if there are unpushed commits OR if we're in a push flow + // (pending/success/error states keep the button visible for feedback) + const isInPushFlow = state !== 'idle'; + return { + ...repo, + showPushButton: hasUnpushedCommits && !isInPushFlow, + isPushPending: state === 'pending', + isPushSuccess: state === 'success', + isPushError: state === 'error', + }; + }), + [repoInfos, pushStates] + ); + // Handle copying repo path to clipboard const handleCopyPath = useCallback( (repoId: string) => { @@ -100,6 +200,7 @@ function GitPanelContainer({ merge: Actions.GitMerge, rebase: Actions.GitRebase, 'change-target': Actions.GitChangeTarget, + push: Actions.GitPush, }; const actionDef = actionMap[action]; @@ -111,12 +212,33 @@ function GitPanelContainer({ [selectedWorkspace, executeAction] ); + // Handle push button click - use mutation for proper state tracking + const handlePushClick = useCallback( + (repoId: string) => { + // Use ref to check current state to avoid stale closure + if (pushStatesRef.current[repoId] === 'pending') return; + + // Clear any existing timeout + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + successTimeoutRef.current = null; + } + + // Track which repo we're pushing + currentPushRepoRef.current = repoId; + setPushStates((prev) => ({ ...prev, [repoId]: 'pending' })); + pushMutation.mutate({ repo_id: repoId }); + }, + [pushMutation] + ); + return ( console.log('Add repo clicked')} @@ -256,6 +378,7 @@ export function WorkspacesLayout() { name: repo.display_name || repo.name, targetBranch: repo.target_branch || 'main', commitsAhead: repoStatus?.commits_ahead ?? 0, + remoteCommitsAhead: repoStatus?.remote_commits_ahead ?? 0, filesChanged: diffStats.filesChanged, linesAdded: diffStats.linesAdded, linesRemoved: diffStats.linesRemoved, diff --git a/frontend/src/components/ui-new/primitives/RepoCard.tsx b/frontend/src/components/ui-new/primitives/RepoCard.tsx index b657dee2..c360f624 100644 --- a/frontend/src/components/ui-new/primitives/RepoCard.tsx +++ b/frontend/src/components/ui-new/primitives/RepoCard.tsx @@ -11,6 +11,8 @@ import { CopyIcon, GitMergeIcon, CheckCircleIcon, + SpinnerGapIcon, + WarningCircleIcon, } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { @@ -24,7 +26,12 @@ import { CollapsibleSection } from './CollapsibleSection'; import { SplitButton, type SplitButtonOption } from './SplitButton'; import { useRepoAction, PERSIST_KEYS } from '@/stores/useUiPreferencesStore'; -export type RepoAction = 'pull-request' | 'merge' | 'change-target' | 'rebase'; +export type RepoAction = + | 'pull-request' + | 'merge' + | 'change-target' + | 'rebase' + | 'push'; const repoActionOptions: SplitButtonOption[] = [ { @@ -46,10 +53,15 @@ interface RepoCardProps { prNumber?: number; prUrl?: string; prStatus?: 'open' | 'merged' | 'closed' | 'unknown'; + showPushButton?: boolean; + isPushPending?: boolean; + isPushSuccess?: boolean; + isPushError?: boolean; branchDropdownContent?: React.ReactNode; onChangeTarget?: () => void; onRebase?: () => void; onActionsClick?: (action: RepoAction) => void; + onPushClick?: () => void; onOpenInEditor?: () => void; onCopyPath?: () => void; } @@ -65,10 +77,15 @@ export function RepoCard({ prNumber, prUrl, prStatus, + showPushButton = false, + isPushPending = false, + isPushSuccess = false, + isPushError = false, branchDropdownContent, onChangeTarget, onRebase, onActionsClick, + onPushClick, onOpenInEditor, onCopyPath, }: RepoCardProps) { @@ -205,6 +222,40 @@ export function RepoCard({ {t('git.pr.open', { number: prNumber })} )} + {/* Push button - shows loading/success/error state */} + {(showPushButton || + isPushPending || + isPushSuccess || + isPushError) && ( + + )} )} diff --git a/frontend/src/components/ui-new/views/GitPanel.tsx b/frontend/src/components/ui-new/views/GitPanel.tsx index d10bac98..4b9812c8 100644 --- a/frontend/src/components/ui-new/views/GitPanel.tsx +++ b/frontend/src/components/ui-new/views/GitPanel.tsx @@ -16,12 +16,17 @@ export interface RepoInfo { name: string; targetBranch: string; commitsAhead: number; + remoteCommitsAhead?: number; filesChanged: number; linesAdded: number; linesRemoved: number; prNumber?: number; prUrl?: string; prStatus?: 'open' | 'merged' | 'closed' | 'unknown'; + showPushButton?: boolean; + isPushPending?: boolean; + isPushSuccess?: boolean; + isPushError?: boolean; } interface GitPanelProps { @@ -29,6 +34,7 @@ interface GitPanelProps { workingBranchName: string; onWorkingBranchNameChange: (name: string) => void; onActionsClick?: (repoId: string, action: RepoAction) => void; + onPushClick?: (repoId: string) => void; onOpenInEditor?: (repoId: string) => void; onCopyPath?: (repoId: string) => void; onAddRepo?: () => void; @@ -41,6 +47,7 @@ export function GitPanel({ workingBranchName, onWorkingBranchNameChange, onActionsClick, + onPushClick, onOpenInEditor, onCopyPath, className, @@ -75,9 +82,14 @@ export function GitPanel({ prNumber={repo.prNumber} prUrl={repo.prUrl} prStatus={repo.prStatus} + showPushButton={repo.showPushButton} + isPushPending={repo.isPushPending} + isPushSuccess={repo.isPushSuccess} + isPushError={repo.isPushError} onChangeTarget={() => onActionsClick?.(repo.id, 'change-target')} onRebase={() => onActionsClick?.(repo.id, 'rebase')} onActionsClick={(action) => onActionsClick?.(repo.id, action)} + onPushClick={() => onPushClick?.(repo.id)} onOpenInEditor={() => onOpenInEditor?.(repo.id)} onCopyPath={() => onCopyPath?.(repo.id)} /> diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index f35b550b..73c2679f 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -352,6 +352,7 @@ "pushed": "Pushed!", "pushing": "Pushing...", "push": "Push", + "pushFailed": "Failed", "forcePush": "Force Push", "forcePushing": "Force Pushing...", "creating": "Creating...", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 30d62623..2087a854 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -309,6 +309,7 @@ "push": "Enviar", "pushed": "¡Enviado!", "pushing": "Enviando...", + "pushFailed": "Falló", "rebase": "Rebase", "rebasing": "Rebaseando..." }, diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 8b0eb401..c0b0d133 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -307,6 +307,7 @@ "push": "プッシュ", "pushed": "プッシュ完了!", "pushing": "プッシュ中...", + "pushFailed": "失敗", "forcePush": "強制プッシュ", "forcePushing": "強制プッシュ中...", "rebase": "リベース", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index bb81da1b..4f7cd622 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -263,6 +263,7 @@ "pushed": "푸시됨!", "pushing": "푸시 중...", "push": "푸시", + "pushFailed": "실패", "forcePush": "강제 푸시", "forcePushing": "강제 푸시 중...", "creating": "생성 중...", diff --git a/frontend/src/i18n/locales/zh-Hans/tasks.json b/frontend/src/i18n/locales/zh-Hans/tasks.json index 9d47627d..dd63d9fd 100644 --- a/frontend/src/i18n/locales/zh-Hans/tasks.json +++ b/frontend/src/i18n/locales/zh-Hans/tasks.json @@ -298,6 +298,7 @@ "pushed": "已推送!", "pushing": "推送中...", "push": "推送", + "pushFailed": "失败", "forcePush": "强制推送", "forcePushing": "强制推送中...", "creating": "创建中...", diff --git a/frontend/src/i18n/locales/zh-Hant/tasks.json b/frontend/src/i18n/locales/zh-Hant/tasks.json index d20a956f..5f14612b 100644 --- a/frontend/src/i18n/locales/zh-Hant/tasks.json +++ b/frontend/src/i18n/locales/zh-Hant/tasks.json @@ -298,6 +298,7 @@ "pushed": "已推送!", "pushing": "推送中...", "push": "推送", + "pushFailed": "失敗", "forcePush": "強制推送", "forcePushing": "強制推送中...", "creating": "建立中...",