diff --git a/frontend/src/components/ui-new/actions/index.ts b/frontend/src/components/ui-new/actions/index.ts index 7f4e4e42..a3cd6366 100644 --- a/frontend/src/components/ui-new/actions/index.ts +++ b/frontend/src/components/ui-new/actions/index.ts @@ -43,6 +43,7 @@ import { workspaceSummaryKeys } from '@/components/ui-new/hooks/useWorkspaces'; import { ConfirmDialog } from '@/components/ui-new/dialogs/ConfirmDialog'; import { ChangeTargetDialog } from '@/components/ui-new/dialogs/ChangeTargetDialog'; import { RebaseDialog } from '@/components/ui-new/dialogs/RebaseDialog'; +import { ResolveConflictsDialog } from '@/components/ui-new/dialogs/ResolveConflictsDialog'; import { RenameWorkspaceDialog } from '@/components/ui-new/dialogs/RenameWorkspaceDialog'; import { CreatePRDialog } from '@/components/dialogs/tasks/CreatePRDialog'; import { getIdeName } from '@/components/ide/IdeIcon'; @@ -680,6 +681,59 @@ export const Actions = { requiresTarget: 'git', isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos, execute: async (ctx, workspaceId, repoId) => { + // Check for existing conflicts first + const branchStatus = await attemptsApi.getBranchStatus(workspaceId); + const repoStatus = branchStatus?.find((s) => s.repo_id === repoId); + const hasConflicts = + repoStatus?.is_rebase_in_progress || + (repoStatus?.conflicted_files?.length ?? 0) > 0; + + if (hasConflicts && repoStatus) { + // Show resolve conflicts dialog + const workspace = await getWorkspace(ctx.queryClient, workspaceId); + const result = await ResolveConflictsDialog.show({ + workspaceId, + conflictOp: repoStatus.conflict_op ?? 'merge', + sourceBranch: workspace.branch, + targetBranch: repoStatus.target_branch_name, + conflictedFiles: repoStatus.conflicted_files ?? [], + repoName: repoStatus.repo_name, + }); + + if (result.action === 'resolved') { + invalidateWorkspaceQueries(ctx.queryClient, workspaceId); + } + return; + } + + // Check if branch is behind - need to rebase first + const commitsBehind = repoStatus?.commits_behind ?? 0; + if (commitsBehind > 0) { + // Prompt user to rebase first + const confirmRebase = await ConfirmDialog.show({ + title: 'Rebase Required', + message: `Your branch is ${commitsBehind} commit${commitsBehind === 1 ? '' : 's'} behind the target branch. Would you like to rebase first?`, + confirmText: 'Rebase', + cancelText: 'Cancel', + }); + + if (confirmRebase === 'confirmed') { + // Trigger the rebase action + const repos = await attemptsApi.getRepos(workspaceId); + const repo = repos.find((r) => r.id === repoId); + if (!repo) throw new Error('Repository not found'); + + const branches = await repoApi.getBranches(repoId); + await RebaseDialog.show({ + attemptId: workspaceId, + repoId, + branches, + initialTargetBranch: repo.target_branch, + }); + } + return; + } + const confirmResult = await ConfirmDialog.show({ title: 'Merge Branch', message: @@ -701,7 +755,32 @@ export const Actions = { icon: ArrowsClockwiseIcon, requiresTarget: 'git', isVisible: (ctx) => ctx.hasWorkspace && ctx.hasGitRepos, - execute: async (_ctx, workspaceId, repoId) => { + execute: async (ctx, workspaceId, repoId) => { + // Check for existing conflicts first + const branchStatus = await attemptsApi.getBranchStatus(workspaceId); + const repoStatus = branchStatus?.find((s) => s.repo_id === repoId); + const hasConflicts = + repoStatus?.is_rebase_in_progress || + (repoStatus?.conflicted_files?.length ?? 0) > 0; + + if (hasConflicts && repoStatus) { + // Show resolve conflicts dialog + const workspace = await getWorkspace(ctx.queryClient, workspaceId); + const result = await ResolveConflictsDialog.show({ + workspaceId, + conflictOp: repoStatus.conflict_op ?? 'rebase', + sourceBranch: workspace.branch, + targetBranch: repoStatus.target_branch_name, + conflictedFiles: repoStatus.conflicted_files ?? [], + repoName: repoStatus.repo_name, + }); + + if (result.action === 'resolved') { + invalidateWorkspaceQueries(ctx.queryClient, workspaceId); + } + return; + } + const repos = await attemptsApi.getRepos(workspaceId); const repo = repos.find((r) => r.id === repoId); if (!repo) throw new Error('Repository not found'); diff --git a/frontend/src/components/ui-new/dialogs/RebaseDialog.tsx b/frontend/src/components/ui-new/dialogs/RebaseDialog.tsx index d87b6a79..bfc70bb1 100644 --- a/frontend/src/components/ui-new/dialogs/RebaseDialog.tsx +++ b/frontend/src/components/ui-new/dialogs/RebaseDialog.tsx @@ -11,11 +11,14 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import BranchSelector from '@/components/tasks/BranchSelector'; -import type { GitBranch } from 'shared/types'; +import type { GitBranch, GitOperationError } from 'shared/types'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { defineModal } from '@/lib/modals'; import { GitOperationsProvider } from '@/contexts/GitOperationsContext'; import { useGitOperations } from '@/hooks/useGitOperations'; +import { useAttempt } from '@/hooks/useAttempt'; +import { attemptsApi, type Result } from '@/lib/api'; +import { ResolveConflictsDialog } from './ResolveConflictsDialog'; export interface RebaseDialogProps { attemptId: string; @@ -49,6 +52,7 @@ function RebaseDialogContent({ const [error, setError] = useState(null); const git = useGitOperations(attemptId, repoId); + const { data: workspace } = useAttempt(attemptId); useEffect(() => { if (initialTargetBranch) { @@ -69,6 +73,36 @@ function RebaseDialogContent({ }); modal.hide(); } catch (err) { + // Check if this is a conflict error (Result type with success=false) + const resultErr = err as Result | undefined; + const errorType = + resultErr && !resultErr.success ? resultErr.error?.type : undefined; + + if ( + errorType === 'merge_conflicts' || + errorType === 'rebase_in_progress' + ) { + // Hide this dialog and show the resolve conflicts dialog + modal.hide(); + + // Fetch fresh branch status to get conflict details + const branchStatus = await attemptsApi.getBranchStatus(attemptId); + const repoStatus = branchStatus?.find((s) => s.repo_id === repoId); + + if (repoStatus) { + await ResolveConflictsDialog.show({ + workspaceId: attemptId, + conflictOp: repoStatus.conflict_op ?? 'rebase', + sourceBranch: workspace?.branch ?? null, + targetBranch: repoStatus.target_branch_name, + conflictedFiles: repoStatus.conflicted_files ?? [], + repoName: repoStatus.repo_name, + }); + } + return; + } + + // Handle other errors let message = 'Failed to rebase'; if (err && typeof err === 'object') { // Handle Result structure diff --git a/frontend/src/components/ui-new/dialogs/ResolveConflictsDialog.tsx b/frontend/src/components/ui-new/dialogs/ResolveConflictsDialog.tsx new file mode 100644 index 00000000..4c09d308 --- /dev/null +++ b/frontend/src/components/ui-new/dialogs/ResolveConflictsDialog.tsx @@ -0,0 +1,306 @@ +import { useState, useCallback, useMemo } 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 { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { AgentSelector } from '@/components/tasks/AgentSelector'; +import { ConfigSelector } from '@/components/tasks/ConfigSelector'; +import { useUserSystem } from '@/components/ConfigProvider'; +import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import { sessionsApi } from '@/lib/api'; +import { useQueryClient } from '@tanstack/react-query'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { defineModal } from '@/lib/modals'; +import { buildResolveConflictsInstructions } from '@/lib/conflicts'; +import type { + BaseCodingAgent, + ExecutorProfileId, + ConflictOp, +} from 'shared/types'; + +export interface ResolveConflictsDialogProps { + workspaceId: string; + conflictOp: ConflictOp; + sourceBranch: string | null; + targetBranch: string; + conflictedFiles: string[]; + repoName?: string; +} + +export type ResolveConflictsDialogResult = + | { action: 'resolved'; sessionId?: string } + | { action: 'cancelled' }; + +const ResolveConflictsDialogImpl = + NiceModal.create( + ({ + workspaceId, + conflictOp, + sourceBranch, + targetBranch, + conflictedFiles, + repoName, + }) => { + const modal = useModal(); + const queryClient = useQueryClient(); + const { profiles, config } = useUserSystem(); + const { sessions, selectedSession, selectedSessionId, selectSession } = + useWorkspaceContext(); + const { t } = useTranslation(['tasks', 'common']); + + const resolvedSession = useMemo(() => { + if (!selectedSessionId) return selectedSession ?? null; + return ( + sessions.find((session) => session.id === selectedSessionId) ?? + selectedSession ?? + null + ); + }, [sessions, selectedSessionId, selectedSession]); + const sessionExecutor = + resolvedSession?.executor as BaseCodingAgent | null; + + const resolvedDefaultProfile = useMemo(() => { + if (sessionExecutor) { + const variant = + config?.executor_profile?.executor === sessionExecutor + ? config.executor_profile.variant + : null; + return { executor: sessionExecutor, variant }; + } + return config?.executor_profile ?? null; + }, [sessionExecutor, config?.executor_profile]); + + // Default to creating a new session if no existing session + const [createNewSession, setCreateNewSession] = + useState(!selectedSessionId); + const [userSelectedProfile, setUserSelectedProfile] = + useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const effectiveProfile = userSelectedProfile ?? resolvedDefaultProfile; + const canSubmit = Boolean(effectiveProfile && !isSubmitting); + + // Build the conflict resolution instructions + const conflictInstructions = useMemo( + () => + buildResolveConflictsInstructions( + sourceBranch, + targetBranch, + conflictedFiles, + conflictOp, + repoName + ), + [sourceBranch, targetBranch, conflictedFiles, conflictOp, repoName] + ); + + const handleSubmit = useCallback(async () => { + if (!effectiveProfile) return; + + setIsSubmitting(true); + setError(null); + + try { + let targetSessionId = selectedSessionId; + const creatingNewSession = createNewSession || !selectedSessionId; + + // Create new session if user selected that option or no existing session + if (creatingNewSession) { + const session = await sessionsApi.create({ + workspace_id: workspaceId, + executor: effectiveProfile.executor, + }); + targetSessionId = session.id; + } + + if (!targetSessionId) { + setError('Failed to create session'); + setIsSubmitting(false); + return; + } + + // Send follow-up with conflict resolution instructions + await sessionsApi.followUp(targetSessionId, { + prompt: conflictInstructions, + variant: effectiveProfile.variant, + retry_process_id: null, + force_when_dirty: null, + perform_git_reset: null, + }); + + // Invalidate queries and wait for them to complete + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['workspaceSessions', workspaceId], + }), + queryClient.invalidateQueries({ + queryKey: ['processes', workspaceId], + }), + queryClient.invalidateQueries({ + queryKey: ['branchStatus', workspaceId], + }), + ]); + + // Navigate to the new session if one was created + // Do this after queries are refreshed so the session exists in the list + if (creatingNewSession && targetSessionId) { + selectSession(targetSessionId); + } + + modal.resolve({ + action: 'resolved', + sessionId: creatingNewSession ? targetSessionId : undefined, + } as ResolveConflictsDialogResult); + modal.hide(); + } catch (err) { + console.error('Failed to resolve conflicts:', err); + setError('Failed to start conflict resolution. Please try again.'); + } finally { + setIsSubmitting(false); + } + }, [ + effectiveProfile, + selectedSessionId, + createNewSession, + workspaceId, + conflictInstructions, + queryClient, + selectSession, + modal, + ]); + + const handleCancel = useCallback(() => { + modal.resolve({ action: 'cancelled' } as ResolveConflictsDialogResult); + modal.hide(); + }, [modal]); + + const handleOpenChange = (open: boolean) => { + if (!open) handleCancel(); + }; + + const handleNewSessionChange = (checked: boolean) => { + setCreateNewSession(checked); + // Reset to default profile when toggling back to existing session + if (!checked && resolvedDefaultProfile) { + setUserSelectedProfile(resolvedDefaultProfile); + } + }; + + const hasExistingSession = Boolean(selectedSessionId); + + return ( + + + + + {t('resolveConflicts.dialog.title', 'Resolve Conflicts')} + + + {t( + 'resolveConflicts.dialog.description', + 'Conflicts were detected. Choose how you want the agent to resolve them.' + )} + + + +
+ {/* Conflict summary */} +
+

+ {t('resolveConflicts.dialog.filesWithConflicts', { + count: conflictedFiles.length, + })} +

+ {conflictedFiles.length > 0 && ( +
    + {conflictedFiles.slice(0, 5).map((file) => ( +
  • + {file} +
  • + ))} + {conflictedFiles.length > 5 && ( +
  • + {t('resolveConflicts.dialog.andMore', { + count: conflictedFiles.length - 5, + })} +
  • + )} +
+ )} +
+ + {error &&
{error}
} + + {/* Agent/profile selector - only show when creating new session */} + {profiles && createNewSession && ( +
+ + +
+ )} +
+ + + +
+ {hasExistingSession && ( +
+ + +
+ )} + +
+
+
+
+ ); + } + ); + +export const ResolveConflictsDialog = defineModal< + ResolveConflictsDialogProps, + ResolveConflictsDialogResult +>(ResolveConflictsDialogImpl); diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index b79029e2..20f74f24 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -492,6 +492,20 @@ "includeGitContextDescription": "Tells the agent how to view all changes made on this branch", "newSession": "New Session" }, + "resolveConflicts": { + "dialog": { + "title": "Resolve Conflicts", + "description": "Conflicts were detected. Choose how you want the agent to resolve them.", + "sessionLabel": "Session", + "existingSession": "Continue in current session", + "newSession": "Start a new session", + "resolve": "Resolve Conflicts", + "resolving": "Starting...", + "filesWithConflicts_one": "{{count}} file has conflicts", + "filesWithConflicts_other": "{{count}} files have conflicts", + "andMore": "...and {{count}} more" + } + }, "stopShareDialog": { "title": "Stop Sharing Task", "description": "Stop sharing \"{{title}}\" with your organization?", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index 57e80d52..555e0581 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -89,6 +89,20 @@ "setupHelpText": "{{agent}} no está configurado correctamente. Haz clic en 'Ejecutar Configuración' para instalarlo e iniciar sesión.", "devScriptMissingTooltip": "To start the dev server, add a dev script to this project" }, + "resolveConflicts": { + "dialog": { + "title": "Resolver Conflictos", + "description": "Se detectaron conflictos. Elige cómo quieres que el agente los resuelva.", + "sessionLabel": "Sesión", + "existingSession": "Continuar en la sesión actual", + "newSession": "Nueva sesión", + "resolve": "Resolver Conflictos", + "resolving": "Iniciando...", + "filesWithConflicts_one": "{{count}} archivo tiene conflictos", + "filesWithConflicts_other": "{{count}} archivos tienen conflictos", + "andMore": "...y {{count}} más" + } + }, "stopShareDialog": { "title": "Detener uso compartido de la tarea", "description": "¿Detener el uso compartido de \"{{title}}\" con tu organización?", diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index d32ba42e..6d4a1abe 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -89,6 +89,20 @@ "setupHelpText": "{{agent}}が正しく設定されていません。「セットアップを実行」をクリックしてインストールとログインを行ってください。", "devScriptMissingTooltip": "To start the dev server, add a dev script to this project" }, + "resolveConflicts": { + "dialog": { + "title": "競合を解決", + "description": "競合が検出されました。エージェントにどのように解決させるか選択してください。", + "sessionLabel": "セッション", + "existingSession": "現在のセッションで続行", + "newSession": "新規セッション", + "resolve": "競合を解決", + "resolving": "開始中...", + "filesWithConflicts_one": "{{count}}件のファイルに競合があります", + "filesWithConflicts_other": "{{count}}件のファイルに競合があります", + "andMore": "...他{{count}}件" + } + }, "stopShareDialog": { "title": "タスクの共有を停止", "description": "「{{title}}」の共有を組織向けに停止しますか?", diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index 8a8f0ae1..3f0ee4a0 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -89,6 +89,20 @@ "setupHelpText": "{{agent}}이(가) 올바르게 설정되지 않았습니다. '설정 실행'을 클릭하여 설치하고 로그인하세요.", "devScriptMissingTooltip": "To start the dev server, add a dev script to this project" }, + "resolveConflicts": { + "dialog": { + "title": "충돌 해결", + "description": "충돌이 감지되었습니다. 에이전트가 어떻게 해결하기를 원하는지 선택하세요.", + "sessionLabel": "세션", + "existingSession": "현재 세션에서 계속", + "newSession": "새 세션", + "resolve": "충돌 해결", + "resolving": "시작 중...", + "filesWithConflicts_one": "{{count}}개 파일에 충돌이 있습니다", + "filesWithConflicts_other": "{{count}}개 파일에 충돌이 있습니다", + "andMore": "...외 {{count}}개" + } + }, "stopShareDialog": { "title": "작업 공유 중지", "description": "\"{{title}}\" 작업의 공유를 조직에서 중지하시겠습니까?", diff --git a/frontend/src/i18n/locales/zh-Hans/tasks.json b/frontend/src/i18n/locales/zh-Hans/tasks.json index 059e8b53..f9f04ff9 100644 --- a/frontend/src/i18n/locales/zh-Hans/tasks.json +++ b/frontend/src/i18n/locales/zh-Hans/tasks.json @@ -425,6 +425,20 @@ "includeGitContextDescription": "告诉代理如何查看此分支上的所有更改", "newSession": "新会话" }, + "resolveConflicts": { + "dialog": { + "title": "解决冲突", + "description": "检测到冲突。选择您希望代理如何解决它们。", + "sessionLabel": "会话", + "existingSession": "在当前会话中继续", + "newSession": "新会话", + "resolve": "解决冲突", + "resolving": "开始中...", + "filesWithConflicts_one": "{{count}} 个文件有冲突", + "filesWithConflicts_other": "{{count}} 个文件有冲突", + "andMore": "...还有 {{count}} 个" + } + }, "stopShareDialog": { "title": "停止共享任务", "description": "停止与您的组织共享 {{title}} ?", diff --git a/frontend/src/i18n/locales/zh-Hant/tasks.json b/frontend/src/i18n/locales/zh-Hant/tasks.json index 40ac4668..fa7181a9 100644 --- a/frontend/src/i18n/locales/zh-Hant/tasks.json +++ b/frontend/src/i18n/locales/zh-Hant/tasks.json @@ -425,6 +425,20 @@ "includeGitContextDescription": "告訴代理如何查看此分支上的所有變更", "newSession": "新工作階段" }, + "resolveConflicts": { + "dialog": { + "title": "解決衝突", + "description": "偵測到衝突。選擇您希望代理如何解決它們。", + "sessionLabel": "工作階段", + "existingSession": "在目前工作階段中繼續", + "newSession": "新工作階段", + "resolve": "解決衝突", + "resolving": "開始中...", + "filesWithConflicts_one": "{{count}} 個檔案有衝突", + "filesWithConflicts_other": "{{count}} 個檔案有衝突", + "andMore": "...還有 {{count}} 個" + } + }, "stopShareDialog": { "title": "停止分享任務", "description": "停止與您的組織分享 {{title}}?",