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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 · </span>
|
||||
<span className="text-secondary-foreground">
|
||||
{t('attempt.labels.attempt')} ·{' '}
|
||||
</span>
|
||||
{attemptNumber}/{totalAttempts}
|
||||
</p>
|
||||
<p className="shrink-0 whitespace-nowrap">
|
||||
<span className="text-secondary-foreground">Agent · </span>
|
||||
<span className="text-secondary-foreground">
|
||||
{t('attempt.labels.agent')} ·{' '}
|
||||
</span>
|
||||
{selectedAttempt?.executor}
|
||||
</p>
|
||||
{selectedAttempt?.branch && (
|
||||
<p className="flex-1 min-w-0 truncate">
|
||||
<span className="text-secondary-foreground">Branch · </span>
|
||||
<span className="text-secondary-foreground">
|
||||
{t('attempt.labels.branch')} ·{' '}
|
||||
</span>
|
||||
{selectedAttempt.branch}
|
||||
</p>
|
||||
)}
|
||||
@@ -157,7 +165,7 @@ export function AttemptHeaderCard({
|
||||
className="h-4 p-0"
|
||||
onClick={onJumpToDiffFullScreen}
|
||||
>
|
||||
Diffs
|
||||
{t('attempt.labels.diffs')}
|
||||
</Button>{' '}
|
||||
· <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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
508
frontend/src/components/tasks/Toolbar/GitOperations.tsx
Normal file
508
frontend/src/components/tasks/Toolbar/GitOperations.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
52
frontend/src/hooks/useChangeTargetBranch.ts
Normal file
52
frontend/src/hooks/useChangeTargetBranch.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "ブランチのリベースに失敗しました"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user