Alex/refactor bb rebase (#824)

* Refactor task_attempt branch handling and enforce NOT NULL constraint on branch column

* Change backend rebase to no change base branch, add change target branch api

* Change frontend rebase on branch to change target branch

Change status to show ahead/behind, always show rebase

* Use target branch for everything except rebase

* Remove base_branch

* Remove base branch frontend

* add rebase dialog with target and upstream options

* Fix unused upstream arg

* Add i18n

* Remove stray ts-rs file

* dont show +0, -0

* Move upstream to foldable advanced rebase

* Move buttons around

* Move git state/actions into a component

* Add task/target labels

* Fix action buttons layout

* Fmt

* i18n

* remove branch origin removal

* Remove empty divs

* Remove [1fr_auto_1fr] class in favour if divs

* use theme colours, make gear icon bigger

* Fix plural i18n

* Remove legacy ui reducer
This commit is contained in:
Alex Netsch
2025-09-29 19:50:29 +01:00
committed by GitHub
parent bcd6bdbe05
commit 091e903cf6
44 changed files with 1415 additions and 802 deletions

View File

@@ -42,6 +42,11 @@ export {
type TaskTemplateEditDialogProps,
type TaskTemplateEditResult,
} from './tasks/TaskTemplateEditDialog';
export {
ChangeTargetBranchDialog,
type ChangeTargetBranchDialogProps,
type ChangeTargetBranchDialogResult,
} from './tasks/ChangeTargetBranchDialog';
export {
RebaseDialog,
type RebaseDialogProps,

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import BranchSelector from '@/components/tasks/BranchSelector';
import type { GitBranch } from 'shared/types';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
export interface ChangeTargetBranchDialogProps {
branches: GitBranch[];
isChangingTargetBranch?: boolean;
}
export type ChangeTargetBranchDialogResult = {
action: 'confirmed' | 'canceled';
branchName?: string;
};
export const ChangeTargetBranchDialog =
NiceModal.create<ChangeTargetBranchDialogProps>(
({ branches, isChangingTargetBranch: isChangingTargetBranch = false }) => {
const modal = useModal();
const { t } = useTranslation(['tasks', 'common']);
const [selectedBranch, setSelectedBranch] = useState<string>('');
const handleConfirm = () => {
if (selectedBranch) {
modal.resolve({
action: 'confirmed',
branchName: selectedBranch,
} as ChangeTargetBranchDialogResult);
modal.hide();
}
};
const handleCancel = () => {
modal.resolve({ action: 'canceled' } as ChangeTargetBranchDialogResult);
modal.hide();
};
const handleOpenChange = (open: boolean) => {
if (!open) {
handleCancel();
}
};
return (
<Dialog open={modal.visible} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t('branches.changeTarget.dialog.title')}
</DialogTitle>
<DialogDescription>
{t('branches.changeTarget.dialog.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="base-branch" className="text-sm font-medium">
{t('rebase.dialog.targetLabel')}
</label>
<BranchSelector
branches={branches}
selectedBranch={selectedBranch}
onBranchSelect={setSelectedBranch}
placeholder={t('branches.changeTarget.dialog.placeholder')}
excludeCurrentBranch={false}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isChangingTargetBranch}
>
{t('common:buttons.cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={isChangingTargetBranch || !selectedBranch}
>
{isChangingTargetBranch
? t('branches.changeTarget.dialog.inProgress')
: t('branches.changeTarget.dialog.action')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
);

View File

@@ -50,9 +50,9 @@ const CreatePrDialog = NiceModal.create(() => {
.then((projectBranches) => {
setBranches(projectBranches);
// Set smart default: task base branch OR current branch
if (data.attempt.base_branch) {
setPrBaseBranch(data.attempt.base_branch);
// Set smart default: task target branch OR current branch
if (data.attempt.target_branch) {
setPrBaseBranch(data.attempt.target_branch);
} else {
const currentBranch = projectBranches.find((b) => b.is_current);
if (currentBranch) {
@@ -77,7 +77,7 @@ const CreatePrDialog = NiceModal.create(() => {
const result = await attemptsApi.createPR(data.attempt.id, {
title: prTitle,
body: prBody || null,
base_branch: prBaseBranch || null,
target_branch: prBaseBranch || null,
});
if (result.success) {

View File

@@ -1,4 +1,6 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
@@ -15,23 +17,52 @@ import NiceModal, { useModal } from '@ebay/nice-modal-react';
export interface RebaseDialogProps {
branches: GitBranch[];
isRebasing?: boolean;
initialTargetBranch?: string;
initialUpstreamBranch?: string;
}
export type RebaseDialogResult = {
action: 'confirmed' | 'canceled';
branchName?: string;
upstreamBranch?: string;
};
export const RebaseDialog = NiceModal.create<RebaseDialogProps>(
({ branches, isRebasing = false }) => {
({
branches,
isRebasing = false,
initialTargetBranch,
initialUpstreamBranch,
}) => {
const modal = useModal();
const [selectedBranch, setSelectedBranch] = useState<string>('');
const { t } = useTranslation(['tasks', 'common']);
const [selectedBranch, setSelectedBranch] = useState<string>(
initialTargetBranch ?? ''
);
const [selectedUpstream, setSelectedUpstream] = useState<string>(
initialUpstreamBranch ?? ''
);
useEffect(() => {
if (initialTargetBranch) {
setSelectedBranch(initialTargetBranch);
}
}, [initialTargetBranch]);
useEffect(() => {
if (initialUpstreamBranch) {
setSelectedUpstream(initialUpstreamBranch);
}
}, [initialUpstreamBranch]);
const [showAdvanced, setShowAdvanced] = useState(false);
const handleConfirm = () => {
if (selectedBranch) {
modal.resolve({
action: 'confirmed',
branchName: selectedBranch,
upstreamBranch: selectedUpstream,
} as RebaseDialogResult);
modal.hide();
}
@@ -52,25 +83,54 @@ export const RebaseDialog = NiceModal.create<RebaseDialogProps>(
<Dialog open={modal.visible} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rebase Task Attempt</DialogTitle>
<DialogTitle>{t('rebase.dialog.title')}</DialogTitle>
<DialogDescription>
Choose a new base branch to rebase this task attempt onto.
{t('rebase.dialog.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="base-branch" className="text-sm font-medium">
Base Branch
<label htmlFor="target-branch" className="text-sm font-medium">
{t('rebase.dialog.targetLabel')}
</label>
<BranchSelector
branches={branches}
selectedBranch={selectedBranch}
onBranchSelect={setSelectedBranch}
placeholder="Select a base branch"
placeholder={t('rebase.dialog.targetPlaceholder')}
excludeCurrentBranch={false}
/>
</div>
<div className="space-y-2">
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="flex w-full items-center gap-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRight
className={`h-3 w-3 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
/>
<span>{t('rebase.dialog.advanced')}</span>
</button>
{showAdvanced && (
<div className="space-y-2">
<label
htmlFor="upstream-branch"
className="text-sm font-medium"
>
{t('rebase.dialog.upstreamLabel')}
</label>
<BranchSelector
branches={branches}
selectedBranch={selectedUpstream}
onBranchSelect={setSelectedUpstream}
placeholder={t('rebase.dialog.upstreamPlaceholder')}
excludeCurrentBranch={false}
/>
</div>
)}
</div>
</div>
<DialogFooter>
@@ -79,13 +139,15 @@ export const RebaseDialog = NiceModal.create<RebaseDialogProps>(
onClick={handleCancel}
disabled={isRebasing}
>
Cancel
{t('common:buttons.cancel')}
</Button>
<Button
onClick={handleConfirm}
disabled={isRebasing || !selectedBranch}
>
{isRebasing ? 'Rebasing...' : 'Rebase'}
{isRebasing
? t('rebase.common.inProgress')
: t('rebase.common.action')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -219,7 +219,7 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
attemptsApi
.get(parentTaskAttemptId)
.then((attempt) => {
const parentBranch = attempt.branch || attempt.base_branch;
const parentBranch = attempt.branch || attempt.target_branch;
if (parentBranch && branches.some((b) => b.name === parentBranch)) {
setSelectedBranch(parentBranch);
}

View File

@@ -18,6 +18,7 @@ import { useAttemptExecution } from '@/hooks/useAttemptExecution';
import { useMemo, useState } from 'react';
import NiceModal from '@ebay/nice-modal-react';
import { OpenInIdeButton } from '@/components/ide/OpenInIdeButton';
import { useTranslation } from 'react-i18next';
interface AttemptHeaderCardProps {
attemptNumber: number;
@@ -37,6 +38,7 @@ export function AttemptHeaderCard({
projectId,
onJumpToDiffFullScreen,
}: AttemptHeaderCardProps) {
const { t } = useTranslation('tasks');
const {
start: startDevServer,
stop: stopDevServer,
@@ -113,7 +115,7 @@ export function AttemptHeaderCard({
const handleRebaseClick = async () => {
setRebasing(true);
try {
await rebaseMutation.mutateAsync(undefined);
await rebaseMutation.mutateAsync({});
} catch (error) {
// Error handling is done by the mutation
} finally {
@@ -136,16 +138,22 @@ export function AttemptHeaderCard({
<Card className="border-b border-dashed bg-background flex items-center text-sm">
<div className="flex-1 min-w-0 flex items-center gap-3 p-3 flex-nowrap">
<p className="shrink-0 whitespace-nowrap">
<span className="text-secondary-foreground">Attempt &middot; </span>
<span className="text-secondary-foreground">
{t('attempt.labels.attempt')} &middot;{' '}
</span>
{attemptNumber}/{totalAttempts}
</p>
<p className="shrink-0 whitespace-nowrap">
<span className="text-secondary-foreground">Agent &middot; </span>
<span className="text-secondary-foreground">
{t('attempt.labels.agent')} &middot;{' '}
</span>
{selectedAttempt?.executor}
</p>
{selectedAttempt?.branch && (
<p className="flex-1 min-w-0 truncate">
<span className="text-secondary-foreground">Branch &middot; </span>
<span className="text-secondary-foreground">
{t('attempt.labels.branch')} &middot;{' '}
</span>
{selectedAttempt.branch}
</p>
)}
@@ -157,7 +165,7 @@ export function AttemptHeaderCard({
className="h-4 p-0"
onClick={onJumpToDiffFullScreen}
>
Diffs
{t('attempt.labels.diffs')}
</Button>{' '}
&middot; <span className="text-console-success">+{added}</span>{' '}
<span className="text-console-error">-{deleted}</span>
@@ -179,7 +187,7 @@ export function AttemptHeaderCard({
className="h-10 w-10 p-0 shrink-0"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
<span className="sr-only">{t('attempt.actions.openMenu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -187,7 +195,7 @@ export function AttemptHeaderCard({
onClick={() => openInEditor()}
disabled={!selectedAttempt}
>
Open in IDE
{t('attempt.actions.openInIde')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
@@ -196,7 +204,9 @@ export function AttemptHeaderCard({
disabled={!selectedAttempt}
className={runningDevServer ? 'text-destructive' : ''}
>
{runningDevServer ? 'Stop dev server' : 'Start dev server'}
{runningDevServer
? t('attempt.actions.stopDevServer')
: t('attempt.actions.startDevServer')}
</DropdownMenuItem>
{selectedAttempt &&
branchStatus &&
@@ -206,14 +216,16 @@ export function AttemptHeaderCard({
onClick={handleRebaseClick}
disabled={rebasing || isAttemptRunning || hasConflicts}
>
{rebasing ? 'Rebasing...' : 'Rebase'}
{rebasing
? t('rebase.common.inProgress')
: t('rebase.common.action')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleCreatePR}
disabled={!selectedAttempt}
>
Create PR
{t('git.states.createPr')}
</DropdownMenuItem>
{selectedAttempt && branchStatus && !mergeInfo.hasMergedPR && (
<DropdownMenuItem
@@ -227,7 +239,7 @@ export function AttemptHeaderCard({
(branchStatus.commits_ahead ?? 0) === 0
}
>
{merging ? 'Merging...' : 'Merge'}
{merging ? t('git.states.merging') : t('git.states.merge')}
</DropdownMenuItem>
)}
{/* <DropdownMenuItem

View File

@@ -54,17 +54,6 @@ function BranchSelector({
return filtered;
}, [branches, branchSearchTerm]);
const displayName = useMemo(() => {
if (!selectedBranch) return placeholder;
// For remote branches, show just the branch name without the remote prefix
if (selectedBranch.includes('/')) {
const parts = selectedBranch.split('/');
return parts[parts.length - 1];
}
return selectedBranch;
}, [selectedBranch, placeholder]);
const handleBranchSelect = (branchName: string) => {
onBranchSelect(branchName);
setBranchSearchTerm('');
@@ -94,7 +83,7 @@ function BranchSelector({
>
<div className="flex items-center gap-1.5 w-full">
<GitBranchIcon className="h-3 w-3" />
<span className="truncate">{displayName}</span>
<span className="truncate">{selectedBranch || placeholder}</span>
</div>
<ArrowDown className="h-3 w-3" />
</Button>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { projectsApi, attemptsApi } from '@/lib/api';
@@ -9,59 +9,15 @@ import type {
} from 'shared/types';
import type { ExecutorProfileId } from 'shared/types';
import { useAttemptExecution } from '@/hooks';
import { useAttemptExecution, useBranchStatus } from '@/hooks';
import { useTaskStopping } from '@/stores/useTaskDetailsUiStore';
import CreateAttempt from '@/components/tasks/Toolbar/CreateAttempt.tsx';
import CurrentAttempt from '@/components/tasks/Toolbar/CurrentAttempt.tsx';
import GitOperations from '@/components/tasks/Toolbar/GitOperations.tsx';
import { useUserSystem } from '@/components/config-provider';
import { Card } from '../ui/card';
// UI State Management
type UiAction =
| { type: 'OPEN_CREATE_PR' }
| { type: 'CLOSE_CREATE_PR' }
| { type: 'CREATE_PR_START' }
| { type: 'CREATE_PR_DONE' }
| { type: 'ENTER_CREATE_MODE' }
| { type: 'LEAVE_CREATE_MODE' }
| { type: 'SET_ERROR'; payload: string | null };
interface UiState {
showCreatePRDialog: boolean;
creatingPR: boolean;
userForcedCreateMode: boolean;
error: string | null;
}
const initialUi: UiState = {
showCreatePRDialog: false,
creatingPR: false,
userForcedCreateMode: false,
error: null,
};
function uiReducer(state: UiState, action: UiAction): UiState {
switch (action.type) {
case 'OPEN_CREATE_PR':
return { ...state, showCreatePRDialog: true };
case 'CLOSE_CREATE_PR':
return { ...state, showCreatePRDialog: false };
case 'CREATE_PR_START':
return { ...state, creatingPR: true };
case 'CREATE_PR_DONE':
return { ...state, creatingPR: false };
case 'ENTER_CREATE_MODE':
return { ...state, userForcedCreateMode: true };
case 'LEAVE_CREATE_MODE':
return { ...state, userForcedCreateMode: false };
case 'SET_ERROR':
return { ...state, error: action.payload };
default:
return state;
}
}
function TaskDetailsToolbar({
task,
projectId,
@@ -86,9 +42,11 @@ function TaskDetailsToolbar({
// const { setLoading } = useTaskLoading(task.id);
const { isStopping } = useTaskStopping(task.id);
const { isAttemptRunning } = useAttemptExecution(selectedAttempt?.id);
const { data: branchStatus } = useBranchStatus(selectedAttempt?.id);
// UI state using reducer
const [ui, dispatch] = useReducer(uiReducer, initialUi);
// UI state
const [userForcedCreateMode, setUserForcedCreateMode] = useState(false);
const [error, setError] = useState<string | null>(null);
// Data state
const [branches, setBranches] = useState<GitBranch[]>([]);
@@ -111,8 +69,7 @@ function TaskDetailsToolbar({
// Derived state
const isInCreateAttemptMode =
forceCreateAttempt ??
(ui.userForcedCreateMode || taskAttempts.length === 0);
forceCreateAttempt ?? (userForcedCreateMode || taskAttempts.length === 0);
// Derive createAttemptBranch for backward compatibility
const createAttemptBranch = useMemo(() => {
@@ -124,10 +81,10 @@ function TaskDetailsToolbar({
// 2. Latest attempt's base branch (existing behavior for resume/rerun)
if (
latestAttempt?.base_branch &&
branches.some((b: GitBranch) => b.name === latestAttempt.base_branch)
latestAttempt?.target_branch &&
branches.some((b: GitBranch) => b.name === latestAttempt.target_branch)
) {
return latestAttempt.base_branch;
return latestAttempt.target_branch;
}
// 3. Parent task attempt's base branch (NEW - for inherited tasks)
@@ -178,52 +135,30 @@ function TaskDetailsToolbar({
// Handle entering create attempt mode
const handleEnterCreateAttemptMode = useCallback(() => {
dispatch({ type: 'ENTER_CREATE_MODE' });
setUserForcedCreateMode(true);
}, []);
// Stub handlers for backward compatibility with CreateAttempt
const setCreateAttemptBranch = useCallback(
(branch: string | null | ((prev: string | null) => string | null)) => {
if (typeof branch === 'function') {
setSelectedBranch((prev) => branch(prev));
} else {
setSelectedBranch(branch);
}
// This is now derived state, so no-op
},
[]
);
const setIsInCreateAttemptMode = useCallback(
(value: boolean | ((prev: boolean) => boolean)) => {
const boolValue =
typeof value === 'function' ? value(isInCreateAttemptMode) : value;
if (boolValue) {
dispatch({ type: 'ENTER_CREATE_MODE' });
setUserForcedCreateMode(true);
} else {
if (onLeaveForceCreateAttempt) onLeaveForceCreateAttempt();
dispatch({ type: 'LEAVE_CREATE_MODE' });
setUserForcedCreateMode(false);
}
},
[isInCreateAttemptMode, onLeaveForceCreateAttempt]
);
// Wrapper functions for UI state dispatch
const setError = useCallback(
(value: string | null | ((prev: string | null) => string | null)) => {
const errorValue = typeof value === 'function' ? value(ui.error) : value;
dispatch({ type: 'SET_ERROR', payload: errorValue });
},
[ui.error]
);
return (
<>
<div>
{/* Error Display */}
{ui.error && (
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200">
<div className="text-destructive text-sm">{ui.error}</div>
<div className="text-destructive text-sm">{error}</div>
</div>
)}
@@ -235,7 +170,7 @@ function TaskDetailsToolbar({
selectedProfile={selectedProfile}
taskAttempts={taskAttempts}
branches={branches}
setCreateAttemptBranch={setCreateAttemptBranch}
setCreateAttemptBranch={setSelectedBranch}
setIsInCreateAttemptMode={setIsInCreateAttemptMode}
setSelectedProfile={setSelectedProfile}
availableProfiles={profiles}
@@ -256,11 +191,7 @@ function TaskDetailsToolbar({
projectHasDevScript={projectHasDevScript ?? false}
selectedAttempt={selectedAttempt}
taskAttempts={taskAttempts}
selectedBranch={selectedBranch}
setError={setError}
creatingPR={ui.creatingPR}
handleEnterCreateAttemptMode={handleEnterCreateAttemptMode}
branches={branches}
setSelectedAttempt={setSelectedAttempt}
/>
) : (
@@ -291,6 +222,20 @@ function TaskDetailsToolbar({
</div>
</div>
)}
{/* Independent Git Operations Section */}
{selectedAttempt && branchStatus && (
<GitOperations
selectedAttempt={selectedAttempt}
task={task}
projectId={projectId}
branchStatus={branchStatus}
branches={branches}
isAttemptRunning={isAttemptRunning}
setError={setError}
selectedBranch={selectedBranch}
/>
)}
</div>
</>
);

View File

@@ -63,13 +63,13 @@ export function TaskFollowUpSection({
if (!hasConflicts) return null;
return buildResolveConflictsInstructions(
attemptBranch,
branchStatus?.base_branch_name,
branchStatus?.target_branch_name,
branchStatus?.conflicted_files || [],
branchStatus?.conflict_op ?? null
);
}, [
attemptBranch,
branchStatus?.base_branch_name,
branchStatus?.target_branch_name,
branchStatus?.conflicted_files,
branchStatus?.conflict_op,
]);

View File

@@ -22,7 +22,7 @@ type Props = {
selectedProfile: ExecutorProfileId | null;
selectedBranch: string | null;
setIsInCreateAttemptMode: Dispatch<SetStateAction<boolean>>;
setCreateAttemptBranch: Dispatch<SetStateAction<string | null>>;
setCreateAttemptBranch: (branch: string | null) => void;
setSelectedProfile: Dispatch<SetStateAction<ExecutorProfileId | null>>;
availableProfiles: Record<string, ExecutorConfig> | null;
selectedAttempt: TaskAttempt | null;

View File

@@ -1,14 +1,10 @@
import {
ExternalLink,
GitBranch as GitBranchIcon,
GitFork,
GitPullRequest,
History,
Play,
Plus,
RefreshCw,
ScrollText,
Settings,
StopCircle,
} from 'lucide-react';
import {
@@ -24,36 +20,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu.tsx';
import {
Dispatch,
SetStateAction,
useCallback,
useMemo,
useRef,
useState,
useEffect,
} from 'react';
import type {
GitBranch,
TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types';
import { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import type { TaskAttempt, TaskWithAttemptStatus } from 'shared/types';
import { useBranchStatus, useOpenInEditor } from '@/hooks';
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
import { useDevServer } from '@/hooks/useDevServer';
import { useRebase } from '@/hooks/useRebase';
import { useMerge } from '@/hooks/useMerge';
import NiceModal from '@ebay/nice-modal-react';
import { Err } from '@/lib/api';
import type { GitOperationError } from 'shared/types';
import { displayConflictOpLabel } from '@/lib/conflicts';
import { usePush } from '@/hooks/usePush';
import { useUserSystem } from '@/components/config-provider.tsx';
import { writeClipboardViaBridge } from '@/vscode/bridge';
import { useProcessSelection } from '@/contexts/ProcessSelectionContext';
import { openTaskForm } from '@/lib/openTaskForm';
import { showModal } from '@/lib/modals';
// Helper function to get the display name for different editor types
function getEditorDisplayName(editorType: string): string {
@@ -81,14 +57,9 @@ type Props = {
task: TaskWithAttemptStatus;
projectId: string;
projectHasDevScript: boolean;
setError: Dispatch<SetStateAction<string | null>>;
selectedBranch: string | null;
selectedAttempt: TaskAttempt;
taskAttempts: TaskAttempt[];
creatingPR: boolean;
handleEnterCreateAttemptMode: () => void;
branches: GitBranch[];
setSelectedAttempt: (attempt: TaskAttempt | null) => void;
};
@@ -96,13 +67,9 @@ function CurrentAttempt({
task,
projectId,
projectHasDevScript,
setError,
selectedBranch,
selectedAttempt,
taskAttempts,
creatingPR,
handleEnterCreateAttemptMode,
branches,
setSelectedAttempt,
}: Props) {
const { config } = useUserSystem();
@@ -117,10 +84,6 @@ function CurrentAttempt({
() => Boolean((branchStatus?.conflicted_files?.length ?? 0) > 0),
[branchStatus?.conflicted_files]
);
const conflictOpLabel = useMemo(
() => displayConflictOpLabel(branchStatus?.conflict_op),
[branchStatus?.conflict_op]
);
const handleOpenInEditor = useOpenInEditor(selectedAttempt?.id);
const { jumpToProcess } = useProcessSelection();
@@ -132,16 +95,8 @@ function CurrentAttempt({
runningDevServer,
latestDevServerProcess,
} = useDevServer(selectedAttempt?.id);
const rebaseMutation = useRebase(selectedAttempt?.id, projectId);
const mergeMutation = useMerge(selectedAttempt?.id);
const pushMutation = usePush(selectedAttempt?.id);
const [merging, setMerging] = useState(false);
const [pushing, setPushing] = useState(false);
const [rebasing, setRebasing] = useState(false);
const [copied, setCopied] = useState(false);
const [mergeSuccess, setMergeSuccess] = useState(false);
const [pushSuccess, setPushSuccess] = useState(false);
const handleViewDevServerLogs = () => {
if (latestDevServerProcess) {
@@ -152,7 +107,8 @@ function CurrentAttempt({
const handleCreateSubtaskClick = () => {
openTaskForm({
projectId,
initialBaseBranch: selectedAttempt.branch || selectedAttempt.base_branch,
initialBaseBranch:
selectedAttempt.branch || selectedAttempt.target_branch,
parentTaskAttemptId: selectedAttempt.id,
});
};
@@ -167,106 +123,6 @@ function CurrentAttempt({
[setSelectedAttempt]
);
const handleMergeClick = async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
// Directly perform merge without checking branch status
await performMerge();
};
const handlePushClick = async () => {
try {
setPushing(true);
await pushMutation.mutateAsync();
setError(null); // Clear any previous errors on success
setPushSuccess(true);
setTimeout(() => setPushSuccess(false), 2000);
} catch (error: any) {
setError(error.message || 'Failed to push changes');
} finally {
setPushing(false);
}
};
const performMerge = async () => {
try {
setMerging(true);
await mergeMutation.mutateAsync();
setError(null); // Clear any previous errors on success
setMergeSuccess(true);
setTimeout(() => setMergeSuccess(false), 2000);
} catch (error) {
// @ts-expect-error it is type ApiError
setError(error.message || 'Failed to merge changes');
} finally {
setMerging(false);
}
};
const handleRebaseClick = async () => {
setRebasing(true);
await rebaseMutation
.mutateAsync(undefined)
.then(() => setError(null))
.catch((err: Err<GitOperationError>) => {
const data = err?.error;
const isConflict =
data?.type === 'merge_conflicts' ||
data?.type === 'rebase_in_progress';
if (!isConflict) setError(err.message || 'Failed to rebase branch');
});
setRebasing(false);
};
const handleRebaseWithNewBranch = async (newBaseBranch: string) => {
setRebasing(true);
await rebaseMutation
.mutateAsync(newBaseBranch)
.then(() => setError(null))
.catch((err: Err<GitOperationError>) => {
const data = err?.error;
const isConflict =
data?.type === 'merge_conflicts' ||
data?.type === 'rebase_in_progress';
if (!isConflict) setError(err.message || 'Failed to rebase branch');
});
setRebasing(false);
};
const handleRebaseDialogOpen = async () => {
try {
const result = await showModal<{
action: 'confirmed' | 'canceled';
branchName?: string;
}>('rebase-dialog', {
branches,
isRebasing: rebasing,
});
if (result.action === 'confirmed' && result.branchName) {
await handleRebaseWithNewBranch(result.branchName);
}
} catch (error) {
// User cancelled - do nothing
}
};
const handlePRButtonClick = async () => {
if (!projectId || !selectedAttempt?.id || !selectedAttempt?.task_id) return;
// If PR already exists, push to it
if (mergeInfo.hasOpenPR) {
await handlePushClick();
return;
}
NiceModal.show('create-pr', {
attempt: selectedAttempt,
task,
projectId,
});
};
// Refresh branch status when a process completes (e.g., rebase resolved by agent)
const prevRunningRef = useRef<boolean>(isAttemptRunning);
useEffect(() => {
@@ -276,60 +132,12 @@ function CurrentAttempt({
prevRunningRef.current = isAttemptRunning;
}, [isAttemptRunning, selectedAttempt?.id, refetchBranchStatus]);
// Get display name for selected branch
const selectedBranchDisplayName = useMemo(() => {
if (!selectedBranch) return 'current';
// For remote branches, show just the branch name without the remote prefix
if (selectedBranch.includes('/')) {
const parts = selectedBranch.split('/');
return parts[parts.length - 1];
}
return selectedBranch;
}, [selectedBranch]);
// Get display name for the configured editor
const editorDisplayName = useMemo(() => {
if (!config?.editor?.editor_type) return 'Editor';
return getEditorDisplayName(config.editor.editor_type);
}, [config?.editor?.editor_type]);
// Memoize merge status information to avoid repeated calculations
const mergeInfo = useMemo(() => {
if (!branchStatus?.merges)
return {
hasOpenPR: false,
openPR: null,
hasMergedPR: false,
mergedPR: null,
hasMerged: false,
latestMerge: null,
};
const openPR = branchStatus.merges.find(
(m) => m.type === 'pr' && m.pr_info.status === 'open'
);
const mergedPR = branchStatus.merges.find(
(m) => m.type === 'pr' && m.pr_info.status === 'merged'
);
const merges = branchStatus.merges.filter(
(m) =>
m.type === 'direct' ||
(m.type === 'pr' && m.pr_info.status === 'merged')
);
return {
hasOpenPR: !!openPR,
openPR,
hasMergedPR: !!mergedPR,
mergedPR,
hasMerged: merges.length > 0,
latestMerge: branchStatus.merges[0] || null, // Most recent merge
};
}, [branchStatus?.merges]);
const handleCopyWorktreePath = useCallback(async () => {
try {
await writeClipboardViaBridge(selectedAttempt.container_ref || '');
@@ -340,172 +148,16 @@ function CurrentAttempt({
}
}, [selectedAttempt.container_ref]);
// Get status information for display
const getStatusInfo = useCallback(() => {
if (hasConflicts) {
return {
dotColor: 'bg-orange-500',
textColor: 'text-orange-700',
text: `${conflictOpLabel} conflicts`,
isClickable: false,
} as const;
}
if (branchStatus?.is_rebase_in_progress) {
return {
dotColor: 'bg-orange-500',
textColor: 'text-orange-700',
text: 'Rebase in progress',
isClickable: false,
} as const;
}
if (mergeInfo.hasMergedPR && mergeInfo.mergedPR?.type === 'pr') {
const prMerge = mergeInfo.mergedPR;
return {
dotColor: 'bg-green-500',
textColor: 'text-green-700',
text: `PR #${prMerge.pr_info.number} merged`,
isClickable: true,
onClick: () => window.open(prMerge.pr_info.url, '_blank'),
};
}
if (
mergeInfo.hasMerged &&
mergeInfo.latestMerge?.type === 'direct' &&
(branchStatus?.commits_ahead ?? 0) === 0
) {
return {
dotColor: 'bg-green-500',
textColor: 'text-green-700',
text: `Merged`,
isClickable: false,
};
}
if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') {
const prMerge = mergeInfo.openPR;
return {
dotColor: 'bg-blue-500',
textColor: 'text-blue-700 dark:text-blue-400',
text: `PR #${prMerge.pr_info.number}`,
isClickable: true,
onClick: () => window.open(prMerge.pr_info.url, '_blank'),
};
}
if ((branchStatus?.commits_behind ?? 0) > 0) {
return {
dotColor: 'bg-orange-500',
textColor: 'text-orange-700',
text: `Rebase needed${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`,
isClickable: false,
};
}
if ((branchStatus?.commits_ahead ?? 0) > 0) {
return {
dotColor: 'bg-yellow-500',
textColor: 'text-yellow-700',
text:
branchStatus?.commits_ahead === 1
? `1 commit ahead${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`
: `${branchStatus?.commits_ahead} commits ahead${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`,
isClickable: false,
};
}
return {
dotColor: 'bg-gray-500',
textColor: 'text-gray-700',
text: `Up to date${branchStatus?.has_uncommitted_changes ? ' (dirty)' : ''}`,
isClickable: false,
};
}, [mergeInfo, branchStatus]);
return (
<div className="space-y-2 @container">
{/* <div className="flex gap-6 items-start"> */}
<div className="grid grid-cols-2 gap-3 items-start @md:flex @md:items-start">
<div className="flex items-start">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
Agent
</div>
<div className="text-sm font-medium">{selectedAttempt.executor}</div>
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
Task Branch
</div>
<div className="flex items-center gap-1.5">
<GitBranchIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{selectedAttempt.branch}
</span>
</div>
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
<span className="truncate">Base Branch</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={handleRebaseDialogOpen}
disabled={rebasing || isAttemptRunning || hasConflicts}
className="h-4 w-4 p-0 hover:bg-muted"
>
<Settings className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Change base branch</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1.5">
<GitBranchIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{branchStatus?.base_branch_name || selectedBranchDisplayName}
</span>
</div>
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
Status
</div>
<div className="flex items-center gap-1.5">
{(() => {
const statusInfo = getStatusInfo();
return (
<>
<div
className={`h-2 w-2 ${statusInfo.dotColor} rounded-full`}
/>
{statusInfo.isClickable ? (
<button
onClick={statusInfo.onClick}
className={`text-sm font-medium ${statusInfo.textColor} hover:underline cursor-pointer`}
>
{statusInfo.text}
</button>
) : (
<span
className={`text-sm font-medium ${statusInfo.textColor} truncate`}
title={statusInfo.text}
>
{statusInfo.text}
</span>
)}
</>
);
})()}
</div>
</div>
</div>
<div>
@@ -546,9 +198,10 @@ function CurrentAttempt({
</div>
</div>
<div>
<div className="grid grid-cols-2 gap-3 @md:flex @md:flex-wrap @md:items-center">
<div className="flex gap-2 @md:flex-none">
<div className="space-y-3">
<div className="flex gap-2">
{/* Column 1: Start Dev / View Logs */}
<div className="flex gap-1 flex-1">
<Button
variant={runningDevServer ? 'destructive' : 'outline'}
size="xs"
@@ -568,7 +221,7 @@ function CurrentAttempt({
) : (
<>
<Play className="h-3 w-3" />
Dev
Start Dev
</>
)}
</Button>
@@ -582,7 +235,7 @@ function CurrentAttempt({
variant="outline"
size="xs"
onClick={handleViewDevServerLogs}
className="gap-1"
className="gap-1 px-2"
>
<ScrollText className="h-3 w-3" />
</Button>
@@ -594,81 +247,9 @@ function CurrentAttempt({
</TooltipProvider>
)}
</div>
{/* Git Operations */}
{selectedAttempt && branchStatus && !mergeInfo.hasMergedPR && (
<>
{(branchStatus.commits_behind ?? 0) > 0 && (
<Button
onClick={handleRebaseClick}
disabled={rebasing || isAttemptRunning || hasConflicts}
variant="outline"
size="xs"
className="border-orange-300 text-orange-700 hover:bg-orange-50 gap-1"
>
<RefreshCw
className={`h-3 w-3 ${rebasing ? 'animate-spin' : ''}`}
/>
{rebasing ? 'Rebasing...' : `Rebase`}
</Button>
)}
<>
<Button
onClick={handlePRButtonClick}
disabled={
creatingPR ||
pushing ||
Boolean((branchStatus.commits_behind ?? 0) > 0) ||
isAttemptRunning ||
hasConflicts ||
(mergeInfo.hasOpenPR &&
branchStatus.remote_commits_ahead === 0) ||
((branchStatus.commits_ahead ?? 0) === 0 &&
(branchStatus.remote_commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
variant="outline"
size="xs"
className="border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-500 hover:bg-blue-50 dark:hover:bg-transparent dark:hover:text-blue-400 dark:hover:border-blue-400 gap-1 min-w-[120px]"
>
<GitPullRequest className="h-3 w-3" />
{mergeInfo.hasOpenPR
? pushSuccess
? 'Pushed!'
: pushing
? 'Pushing...'
: branchStatus.remote_commits_ahead === 0
? 'Push to PR'
: branchStatus.remote_commits_ahead === 1
? 'Push 1 commit'
: `Push ${branchStatus.remote_commits_ahead || 0} commits`
: creatingPR
? 'Creating...'
: 'Create PR'}
</Button>
<Button
onClick={handleMergeClick}
disabled={
mergeInfo.hasOpenPR ||
merging ||
hasConflicts ||
Boolean((branchStatus.commits_behind ?? 0) > 0) ||
isAttemptRunning ||
((branchStatus.commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
size="xs"
className="bg-green-600 hover:bg-green-700 dark:bg-green-900 dark:hover:bg-green-700 gap-1 min-w-[120px]"
>
<GitBranchIcon className="h-3 w-3" />
{mergeSuccess ? 'Merged!' : merging ? 'Merging...' : 'Merge'}
</Button>
</>
</>
)}
<div className="flex gap-2 @md:flex-none">
{/* Column 2: New Attempt + History (shared flex-1) */}
<div className="flex gap-1 flex-1">
{isStopping || isAttemptRunning ? (
<Button
variant="destructive"
@@ -685,20 +266,25 @@ function CurrentAttempt({
variant="outline"
size="xs"
onClick={handleEnterCreateAttemptMode}
className="gap-1 flex-1"
className={`gap-1 ${taskAttempts.length > 1 ? 'flex-1' : 'flex-1'}`}
>
<Plus className="h-4 w-4" />
New Attempt
</Button>
)}
{taskAttempts.length > 1 && (
{taskAttempts.length > 1 && !isStopping && !isAttemptRunning && (
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="xs" className="gap-1">
<History className="h-3 w-4" />
<Button
variant="outline"
size="xs"
className="gap-1 px-2"
>
<History className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -731,11 +317,13 @@ function CurrentAttempt({
</DropdownMenu>
)}
</div>
{/* Column 3: Create Subtask */}
<Button
onClick={handleCreateSubtaskClick}
variant="outline"
size="xs"
className="gap-1 min-w-[120px]"
className="gap-1 flex-1"
>
<GitFork className="h-3 w-3" />
Create Subtask

View File

@@ -0,0 +1,508 @@
import {
ArrowRight,
GitBranch as GitBranchIcon,
GitPullRequest,
RefreshCw,
Settings,
AlertTriangle,
CheckCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button.tsx';
import { Card } from '@/components/ui/card';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx';
import { useMemo, useState } from 'react';
import type {
BranchStatus,
GitBranch,
TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types';
import { useRebase } from '@/hooks/useRebase';
import { useMerge } from '@/hooks/useMerge';
import { usePush } from '@/hooks/usePush';
import { useChangeTargetBranch } from '@/hooks/useChangeTargetBranch';
import NiceModal from '@ebay/nice-modal-react';
import { Err } from '@/lib/api';
import type { GitOperationError } from 'shared/types';
import { showModal } from '@/lib/modals';
import { useTranslation } from 'react-i18next';
interface GitOperationsProps {
selectedAttempt: TaskAttempt;
task: TaskWithAttemptStatus;
projectId: string;
branchStatus: BranchStatus | null;
branches: GitBranch[];
isAttemptRunning: boolean;
setError: (error: string | null) => void;
selectedBranch: string | null;
}
function GitOperations({
selectedAttempt,
task,
projectId,
branchStatus,
branches,
isAttemptRunning,
setError,
selectedBranch,
}: GitOperationsProps) {
const { t } = useTranslation('tasks');
// Git operation hooks
const rebaseMutation = useRebase(selectedAttempt.id, projectId);
const mergeMutation = useMerge(selectedAttempt.id);
const pushMutation = usePush(selectedAttempt.id);
const changeTargetBranchMutation = useChangeTargetBranch(
selectedAttempt.id,
projectId
);
const isChangingTargetBranch = changeTargetBranchMutation.isPending;
// Git status calculations
const hasConflictsCalculated = useMemo(
() => Boolean((branchStatus?.conflicted_files?.length ?? 0) > 0),
[branchStatus?.conflicted_files]
);
// Local state for git operations
const [merging, setMerging] = useState(false);
const [pushing, setPushing] = useState(false);
const [rebasing, setRebasing] = useState(false);
const [mergeSuccess, setMergeSuccess] = useState(false);
const [pushSuccess, setPushSuccess] = useState(false);
// Target branch change handlers
const handleChangeTargetBranchClick = async (newBranch: string) => {
await changeTargetBranchMutation
.mutateAsync(newBranch)
.then(() => setError(null))
.catch((error) => {
setError(error.message || t('git.errors.changeTargetBranch'));
});
};
const handleChangeTargetBranchDialogOpen = async () => {
try {
const result = await showModal<{
action: 'confirmed' | 'canceled';
branchName: string;
}>('change-target-branch-dialog', {
branches,
isChangingTargetBranch: isChangingTargetBranch,
});
if (result.action === 'confirmed' && result.branchName) {
await handleChangeTargetBranchClick(result.branchName);
}
} catch (error) {
// User cancelled - do nothing
}
};
// Memoize merge status information to avoid repeated calculations
const mergeInfo = useMemo(() => {
if (!branchStatus?.merges)
return {
hasOpenPR: false,
openPR: null,
hasMergedPR: false,
mergedPR: null,
hasMerged: false,
latestMerge: null,
};
const openPR = branchStatus.merges.find(
(m: any) => m.type === 'pr' && m.pr_info.status === 'open'
);
const mergedPR = branchStatus.merges.find(
(m: any) => m.type === 'pr' && m.pr_info.status === 'merged'
);
const merges = branchStatus.merges.filter(
(m: any) =>
m.type === 'direct' ||
(m.type === 'pr' && m.pr_info.status === 'merged')
);
return {
hasOpenPR: !!openPR,
openPR,
hasMergedPR: !!mergedPR,
mergedPR,
hasMerged: merges.length > 0,
latestMerge: branchStatus.merges[0] || null, // Most recent merge
};
}, [branchStatus?.merges]);
const mergeButtonLabel = useMemo(() => {
if (mergeSuccess) return t('git.states.merged');
if (merging) return t('git.states.merging');
return t('git.states.merge');
}, [mergeSuccess, merging, t]);
const rebaseButtonLabel = useMemo(() => {
if (rebasing) return t('git.states.rebasing');
return t('git.states.rebase');
}, [rebasing, t]);
const handleMergeClick = async () => {
// Directly perform merge without checking branch status
await performMerge();
};
const handlePushClick = async () => {
try {
setPushing(true);
await pushMutation.mutateAsync();
setError(null); // Clear any previous errors on success
setPushSuccess(true);
setTimeout(() => setPushSuccess(false), 2000);
} catch (error: any) {
setError(error.message || t('git.errors.pushChanges'));
} finally {
setPushing(false);
}
};
const performMerge = async () => {
try {
setMerging(true);
await mergeMutation.mutateAsync();
setError(null); // Clear any previous errors on success
setMergeSuccess(true);
setTimeout(() => setMergeSuccess(false), 2000);
} catch (error) {
// @ts-expect-error it is type ApiError
setError(error.message || t('git.errors.mergeChanges'));
} finally {
setMerging(false);
}
};
const handleRebaseWithNewBranchAndUpstream = async (
newBaseBranch: string,
selectedUpstream: string
) => {
setRebasing(true);
await rebaseMutation
.mutateAsync({
newBaseBranch: newBaseBranch,
oldBaseBranch: selectedUpstream,
})
.then(() => setError(null))
.catch((err: Err<GitOperationError>) => {
const data = err?.error;
const isConflict =
data?.type === 'merge_conflicts' ||
data?.type === 'rebase_in_progress';
if (!isConflict) setError(err.message || t('git.errors.rebaseBranch'));
});
setRebasing(false);
};
const handleRebaseDialogOpen = async () => {
try {
const defaultTargetBranch = selectedAttempt.target_branch;
const result = await showModal<{
action: 'confirmed' | 'canceled';
branchName?: string;
upstreamBranch?: string;
}>('rebase-dialog', {
branches,
isRebasing: rebasing,
initialTargetBranch: defaultTargetBranch,
initialUpstreamBranch: defaultTargetBranch,
});
if (
result.action === 'confirmed' &&
result.branchName &&
result.upstreamBranch
) {
await handleRebaseWithNewBranchAndUpstream(
result.branchName,
result.upstreamBranch
);
}
} catch (error) {
// User cancelled - do nothing
}
};
const handlePRButtonClick = async () => {
// If PR already exists, push to it
if (mergeInfo.hasOpenPR) {
await handlePushClick();
return;
}
NiceModal.show('create-pr', {
attempt: selectedAttempt,
task,
projectId,
});
};
if (!branchStatus || mergeInfo.hasMergedPR) {
return null;
}
return (
<div>
<Card className="bg-background p-3 border border-dashed text-sm">
Git
</Card>
<div className="p-3 space-y-3">
{/* Branch Flow with Status Below */}
<div className="space-y-1 py-2">
{/* Labels Row */}
<div className="flex gap-4">
{/* Task Branch Label - Left Column */}
<div className="flex flex-1 justify-start">
<span className="text-xs text-muted-foreground">
{t('git.labels.taskBranch')}
</span>
</div>
{/* Center Column - Empty */}
{/* Target Branch Label - Right Column */}
<div className="flex flex-1 justify-end">
<span className="text-xs text-muted-foreground">
{t('rebase.dialog.targetLabel')}
</span>
</div>
</div>
{/* Branches Row */}
<div className="flex flex-1 gap-4 items-center">
{/* Task Branch - Left Column */}
<div className="flex flex-1 items-center justify-start gap-1.5 min-w-0">
<GitBranchIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{selectedAttempt.branch}
</span>
</div>
{/* Arrow - Center Column */}
<div className="flex justify-center">
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</div>
{/* Target Branch - Right Column */}
<div className="flex flex-1 items-center justify-end gap-1.5 min-w-0">
<GitBranchIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{branchStatus?.target_branch_name ||
selectedBranch ||
t('git.branch.current')}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={handleChangeTargetBranchDialogOpen}
disabled={isAttemptRunning || hasConflictsCalculated}
className="h-4 w-4 p-0 hover:bg-muted ml-1"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('branches.changeTarget.dialog.title')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Bottom Row: Status Information */}
<div className="flex gap-4">
<div className="flex-1 flex justify-start">
{(() => {
const commitsAhead = branchStatus?.commits_ahead ?? 0;
const showAhead = commitsAhead > 0;
if (showAhead) {
return (
<span className="text-xs font-medium text-success">
{commitsAhead}{' '}
{t('git.status.commits', { count: commitsAhead })}{' '}
{t('git.status.ahead')}
</span>
);
}
return null;
})()}
</div>
<div className="flex justify-center">
{(() => {
const commitsAhead = branchStatus?.commits_ahead ?? 0;
const commitsBehind = branchStatus?.commits_behind ?? 0;
const showAhead = commitsAhead > 0;
const showBehind = commitsBehind > 0;
// Handle special states (PR, conflicts, etc.) - center under arrow
if (hasConflictsCalculated) {
return (
<div className="flex items-center gap-1 text-warning">
<AlertTriangle className="h-3 w-3" />
<span className="text-xs font-medium">
{t('git.status.conflicts')}
</span>
</div>
);
}
if (branchStatus?.is_rebase_in_progress) {
return (
<div className="flex items-center gap-1 text-warning">
<RefreshCw className="h-3 w-3 animate-spin" />
<span className="text-xs font-medium">
{t('git.states.rebasing')}
</span>
</div>
);
}
// Check for merged PR
if (mergeInfo.hasMergedPR) {
return (
<div className="flex items-center gap-1 text-success">
<CheckCircle className="h-3 w-3" />
<span className="text-xs font-medium">
{t('git.states.merged')}
</span>
</div>
);
}
// Check for open PR - center under arrow
if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') {
const prMerge = mergeInfo.openPR;
return (
<button
onClick={() => window.open(prMerge.pr_info.url, '_blank')}
className="flex items-center gap-1 text-info hover:text-info hover:underline"
>
<GitPullRequest className="h-3 w-3" />
<span className="text-xs font-medium">
PR #{Number(prMerge.pr_info.number)}
</span>
</button>
);
}
// If showing ahead/behind, don't show anything in center
if (showAhead || showBehind) {
return null;
}
// Default: up to date - center under arrow
return (
<span className="text-xs text-muted-foreground">
{t('git.status.upToDate')}
</span>
);
})()}
</div>
<div className="flex-1 flex justify-end">
{(() => {
const commitsBehind = branchStatus?.commits_behind ?? 0;
const showBehind = commitsBehind > 0;
if (showBehind) {
return (
<span className="text-xs font-medium text-warning">
{commitsBehind}{' '}
{t('git.status.commits', { count: commitsBehind })}{' '}
{t('git.status.behind')}
</span>
);
}
return null;
})()}
</div>
</div>
</div>
{/* Git Operations */}
<div className="flex gap-2">
<Button
onClick={handleMergeClick}
disabled={
mergeInfo.hasOpenPR ||
merging ||
hasConflictsCalculated ||
Boolean((branchStatus.commits_behind ?? 0) > 0) ||
isAttemptRunning ||
((branchStatus.commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
variant="outline"
size="xs"
className="border-success text-success hover:bg-success gap-1 flex-1"
>
<GitBranchIcon className="h-3 w-3" />
{mergeButtonLabel}
</Button>
<Button
onClick={handlePRButtonClick}
disabled={
pushing ||
Boolean((branchStatus.commits_behind ?? 0) > 0) ||
isAttemptRunning ||
hasConflictsCalculated ||
(mergeInfo.hasOpenPR &&
branchStatus.remote_commits_ahead === 0) ||
((branchStatus.commits_ahead ?? 0) === 0 &&
(branchStatus.remote_commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
variant="outline"
size="xs"
className="border-info text-info hover:bg-info gap-1 flex-1"
>
<GitPullRequest className="h-3 w-3" />
{mergeInfo.hasOpenPR
? pushSuccess
? t('git.states.pushed')
: pushing
? t('git.states.pushing')
: t('git.states.push')
: t('git.states.createPr')}
</Button>
<Button
onClick={handleRebaseDialogOpen}
disabled={
rebasing ||
isAttemptRunning ||
hasConflictsCalculated ||
(branchStatus.commits_behind ?? 0) === 0
}
variant="outline"
size="xs"
className="border-warning text-warning hover:bg-warning gap-1 flex-1"
>
<RefreshCw
className={`h-3 w-3 ${rebasing ? 'animate-spin' : ''}`}
/>
{rebaseButtonLabel}
</Button>
</div>
</div>
</div>
);
}
export default GitOperations;

View File

@@ -45,7 +45,7 @@ export function FollowUpConflictSection({
<>
<ConflictBanner
attemptBranch={attemptBranch}
baseBranch={branchStatus.base_branch_name}
baseBranch={branchStatus.target_branch_name}
conflictedFiles={branchStatus.conflicted_files || []}
op={op}
onResolve={onResolve}

View File

@@ -3,6 +3,7 @@ export { useAttemptExecution } from './useAttemptExecution';
export { useOpenInEditor } from './useOpenInEditor';
export { useDevServer } from './useDevServer';
export { useRebase } from './useRebase';
export { useChangeTargetBranch } from './useChangeTargetBranch';
export { useMerge } from './useMerge';
export { usePush } from './usePush';
export { useKeyboardShortcut } from './useKeyboardShortcut';

View File

@@ -0,0 +1,52 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { attemptsApi } from '@/lib/api';
import type {
ChangeTargetBranchRequest,
ChangeTargetBranchResponse,
} from 'shared/types';
export function useChangeTargetBranch(
attemptId: string | undefined,
projectId: string | undefined,
onSuccess?: (data: ChangeTargetBranchResponse) => void,
onError?: (err: unknown) => void
) {
const queryClient = useQueryClient();
return useMutation<ChangeTargetBranchResponse, unknown, string>({
mutationFn: async (newTargetBranch) => {
if (!attemptId) {
throw new Error('Attempt id is not set');
}
const payload: ChangeTargetBranchRequest = {
new_target_branch: newTargetBranch,
};
return attemptsApi.change_target_branch(attemptId, payload);
},
onSuccess: (data) => {
if (attemptId) {
queryClient.invalidateQueries({
queryKey: ['branchStatus', attemptId],
});
}
if (projectId) {
queryClient.invalidateQueries({
queryKey: ['projectBranches', projectId],
});
}
onSuccess?.(data);
},
onError: (err) => {
console.error('Failed to change target branch:', err);
if (attemptId) {
queryClient.invalidateQueries({
queryKey: ['branchStatus', attemptId],
});
}
onError?.(err);
},
});
}

View File

@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { attemptsApi, Result } from '@/lib/api';
import type { GitOperationError } from 'shared/types';
import type { RebaseTaskAttemptRequest } from 'shared/types';
import type { GitOperationError } from 'shared/types';
export function useRebase(
attemptId: string | undefined,
@@ -11,14 +11,22 @@ export function useRebase(
) {
const queryClient = useQueryClient();
return useMutation<void, Result<void, GitOperationError>, string | undefined>(
type RebaseMutationArgs = {
newBaseBranch?: string;
oldBaseBranch?: string;
};
return useMutation<void, Result<void, GitOperationError>, RebaseMutationArgs>(
{
mutationFn: (newBaseBranch?: string) => {
mutationFn: (args) => {
if (!attemptId) return Promise.resolve();
const { newBaseBranch, oldBaseBranch } = args ?? {};
const data: RebaseTaskAttemptRequest = {
new_base_branch: newBaseBranch || null,
old_base_branch: oldBaseBranch ?? null,
new_base_branch: newBaseBranch ?? null,
};
return attemptsApi.rebase(attemptId, data).then((res) => {
if (!res.success) {
// Propagate typed failure Result for caller to handle (no manual ApiError construction)

View File

@@ -7,5 +7,85 @@
},
"actions": {
"addTask": "Add task"
},
"rebase": {
"common": {
"action": "Rebase",
"inProgress": "Rebasing...",
"withTarget": "Rebase onto {{branch}}"
},
"dialog": {
"title": "Rebase Task Attempt",
"description": "Choose a new base branch to rebase this task attempt onto.",
"upstreamLabel": "Upstream Branch",
"upstreamPlaceholder": "Select an upstream branch",
"targetLabel": "Target Branch",
"targetPlaceholder": "Select a target branch",
"advanced": "Advanced"
},
"status": {
"inProgress": "Rebase in progress{{counts}}",
"needed": "Rebase needed{{dirty}}{{counts}}",
"dirtyMarker": " (dirty)"
}
},
"branches": {
"changeTarget": {
"dialog": {
"title": "Change target branch",
"description": "Choose a new target branch for the task attempt.",
"placeholder": "Select a target branch",
"action": "Change Branch",
"inProgress": "Changing..."
}
}
},
"attempt": {
"labels": {
"attempt": "Attempt",
"agent": "Agent",
"branch": "Branch",
"diffs": "Diffs"
},
"actions": {
"openInIde": "Open in IDE",
"openMenu": "Open menu",
"startDevServer": "Start dev server",
"stopDevServer": "Stop dev server"
}
},
"git": {
"labels": {
"taskBranch": "Task Branch"
},
"branch": {
"current": "current"
},
"status": {
"commits_one": "commit",
"commits_other": "commits",
"conflicts": "Conflicts",
"upToDate": "Up to date",
"ahead": "ahead",
"behind": "behind"
},
"states": {
"merged": "Merged!",
"merging": "Merging...",
"merge": "Merge",
"rebasing": "Rebasing...",
"rebase": "Rebase",
"pushed": "Pushed!",
"pushing": "Pushing...",
"push": "Push",
"creating": "Creating...",
"createPr": "Create PR"
},
"errors": {
"changeTargetBranch": "Failed to change target branch",
"pushChanges": "Failed to push changes",
"mergeChanges": "Failed to merge changes",
"rebaseBranch": "Failed to rebase branch"
}
}
}

View File

@@ -7,5 +7,85 @@
},
"actions": {
"addTask": "Agregar tarea"
},
"rebase": {
"common": {
"action": "Rebase",
"inProgress": "Rebaseando...",
"withTarget": "Rebase sobre {{branch}}"
},
"dialog": {
"title": "Rebase del intento de tarea",
"description": "Elige una nueva rama base para hacer rebase de este intento de tarea.",
"upstreamLabel": "Rama upstream",
"upstreamPlaceholder": "Selecciona una rama upstream",
"targetLabel": "Rama de destino",
"targetPlaceholder": "Selecciona una rama de destino",
"advanced": "Avanzado"
},
"status": {
"inProgress": "Rebase en progreso{{counts}}",
"needed": "Rebase necesario{{dirty}}{{counts}}",
"dirtyMarker": " (sucio)"
}
},
"branches": {
"changeTarget": {
"dialog": {
"title": "Cambiar rama de destino",
"description": "Elige una nueva rama de destino para el intento de tarea.",
"placeholder": "Selecciona una rama de destino",
"action": "Cambiar rama",
"inProgress": "Cambiando..."
}
}
},
"attempt": {
"labels": {
"attempt": "Intento",
"agent": "Agente",
"branch": "Rama",
"diffs": "Diferencias"
},
"actions": {
"openInIde": "Abrir en IDE",
"openMenu": "Abrir menú",
"startDevServer": "Iniciar servidor de desarrollo",
"stopDevServer": "Detener servidor de desarrollo"
}
},
"git": {
"labels": {
"taskBranch": "Rama de tarea"
},
"branch": {
"current": "actual"
},
"status": {
"commits_one": "commit",
"commits_other": "commits",
"conflicts": "Conflictos",
"upToDate": "Al día",
"ahead": "adelante",
"behind": "atrás"
},
"states": {
"merged": "¡Fusionado!",
"merging": "Fusionando...",
"merge": "Fusionar",
"rebasing": "Rebaseando...",
"rebase": "Rebase",
"pushed": "¡Enviado!",
"pushing": "Enviando...",
"push": "Enviar",
"creating": "Creando...",
"createPr": "Crear PR"
},
"errors": {
"changeTargetBranch": "Error al cambiar rama de destino",
"pushChanges": "Error al enviar cambios",
"mergeChanges": "Error al fusionar cambios",
"rebaseBranch": "Error al hacer rebase de la rama"
}
}
}

View File

@@ -7,5 +7,85 @@
},
"actions": {
"addTask": "タスクを追加"
},
"rebase": {
"common": {
"action": "リベース",
"inProgress": "リベース中...",
"withTarget": "{{branch}} にリベース"
},
"dialog": {
"title": "タスク試行をリベース",
"description": "このタスク試行をリベースする新しいベースブランチを選択してください。",
"upstreamLabel": "アップストリームブランチ",
"upstreamPlaceholder": "アップストリームブランチを選択",
"targetLabel": "ターゲットブランチ",
"targetPlaceholder": "ターゲットブランチを選択",
"advanced": "詳細設定"
},
"status": {
"inProgress": "リベース進行中{{counts}}",
"needed": "リベースが必要です{{dirty}}{{counts}}",
"dirtyMarker": " (未コミットあり)"
}
},
"branches": {
"changeTarget": {
"dialog": {
"title": "ターゲットブランチを変更",
"description": "タスク試行の新しいターゲットブランチを選択してください。",
"placeholder": "ターゲットブランチを選択",
"action": "ブランチを変更",
"inProgress": "変更中..."
}
}
},
"attempt": {
"labels": {
"attempt": "試行",
"agent": "エージェント",
"branch": "ブランチ",
"diffs": "差分"
},
"actions": {
"openInIde": "IDEで開く",
"openMenu": "メニューを開く",
"startDevServer": "開発サーバーを開始",
"stopDevServer": "開発サーバーを停止"
}
},
"git": {
"labels": {
"taskBranch": "タスクブランチ"
},
"branch": {
"current": "現在"
},
"status": {
"commits_one": "コミット",
"commits_other": "コミット",
"conflicts": "競合",
"upToDate": "最新",
"ahead": "先行",
"behind": "遅れ"
},
"states": {
"merged": "マージ完了!",
"merging": "マージ中...",
"merge": "マージ",
"rebasing": "リベース中...",
"rebase": "リベース",
"pushed": "プッシュ完了!",
"pushing": "プッシュ中...",
"push": "プッシュ",
"creating": "作成中...",
"createPr": "PRを作成"
},
"errors": {
"changeTargetBranch": "ターゲットブランチの変更に失敗しました",
"pushChanges": "変更のプッシュに失敗しました",
"mergeChanges": "変更のマージに失敗しました",
"rebaseBranch": "ブランチのリベースに失敗しました"
}
}
}

View File

@@ -22,7 +22,6 @@ import {
GitBranch,
Project,
CreateProject,
RebaseTaskAttemptRequest,
RepositoryInfo,
SearchResult,
Task,
@@ -43,6 +42,9 @@ import {
UpdateFollowUpDraftRequest,
GitOperationError,
ApprovalResponse,
RebaseTaskAttemptRequest,
ChangeTargetBranchRequest,
ChangeTargetBranchResponse,
} from 'shared/types';
// Re-export types for convenience
@@ -493,6 +495,20 @@ export const attemptsApi = {
return handleApiResponseAsResult<void, GitOperationError>(response);
},
change_target_branch: async (
attemptId: string,
data: ChangeTargetBranchRequest
): Promise<ChangeTargetBranchResponse> => {
const response = await makeRequest(
`/api/task-attempts/${attemptId}/change-target-branch`,
{
method: 'POST',
body: JSON.stringify(data),
}
);
return handleApiResponse<ChangeTargetBranchResponse>(response);
},
abortConflicts: async (attemptId: string): Promise<void> => {
const response = await makeRequest(
`/api/task-attempts/${attemptId}/conflicts/abort`,

View File

@@ -24,6 +24,7 @@ import {
DeleteTaskConfirmationDialog,
FolderPickerDialog,
TaskTemplateEditDialog,
ChangeTargetBranchDialog,
RebaseDialog,
CreateConfigurationDialog,
DeleteConfigurationDialog,
@@ -46,6 +47,7 @@ NiceModal.register('task-form', TaskFormDialog);
NiceModal.register('editor-selection', EditorSelectionDialog);
NiceModal.register('folder-picker', FolderPickerDialog);
NiceModal.register('task-template-edit', TaskTemplateEditDialog);
NiceModal.register('change-target-branch-dialog', ChangeTargetBranchDialog);
NiceModal.register('rebase-dialog', RebaseDialog);
NiceModal.register('create-configuration', CreateConfigurationDialog);
NiceModal.register('delete-configuration', DeleteConfigurationDialog);