diff --git a/crates/executors/src/logs/mod.rs b/crates/executors/src/logs/mod.rs index 440b689e..d6616831 100644 --- a/crates/executors/src/logs/mod.rs +++ b/crates/executors/src/logs/mod.rs @@ -65,6 +65,10 @@ pub enum NormalizedEntryType { ErrorMessage, Thinking, Loading, + NextAction { + failed: bool, + execution_processes: usize, + }, } #[derive(Debug, Clone, Serialize, Deserialize, TS)] diff --git a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx index 13cad66a..fe6b67a2 100644 --- a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx +++ b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx @@ -29,6 +29,7 @@ import { import RawLogText from '../common/RawLogText'; import UserMessage from './UserMessage'; import PendingApprovalEntry from './PendingApprovalEntry'; +import { NextActionCard } from './NextActionCard'; import { cn } from '@/lib/utils'; import { useRetryUi } from '@/contexts/RetryUiContext'; @@ -38,6 +39,7 @@ type Props = { diffDeletable?: boolean; executionProcessId?: string; taskAttempt?: TaskAttempt; + task?: any; }; type FileEditAction = Extract; @@ -603,6 +605,7 @@ function DisplayConversationEntry({ expansionKey, executionProcessId, taskAttempt, + task, }: Props) { const { t } = useTranslation('common'); const isNormalizedEntry = ( @@ -779,6 +782,20 @@ function DisplayConversationEntry({ ); } + if (entry.entry_type.type === 'next_action') { + return ( +
+ +
+ ); + } + return (
diff --git a/frontend/src/components/NormalizedConversation/NextActionCard.tsx b/frontend/src/components/NormalizedConversation/NextActionCard.tsx new file mode 100644 index 00000000..c2a8e7dd --- /dev/null +++ b/frontend/src/components/NormalizedConversation/NextActionCard.tsx @@ -0,0 +1,311 @@ +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Play, + Pause, + Terminal, + FileDiff, + Copy, + Check, + GitBranch, +} from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import NiceModal from '@ebay/nice-modal-react'; +import { useOpenInEditor } from '@/hooks/useOpenInEditor'; +import { useDiffSummary } from '@/hooks/useDiffSummary'; +import { useDevServer } from '@/hooks/useDevServer'; +import { Button } from '@/components/ui/button'; +import { IdeIcon } from '@/components/ide/IdeIcon'; +import { useUserSystem } from '@/components/config-provider'; +import { getIdeName } from '@/components/ide/IdeIcon'; +import { useProject } from '@/contexts/project-context'; +import { useQuery } from '@tanstack/react-query'; +import { attemptsApi } from '@/lib/api'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +type NextActionCardProps = { + attemptId?: string; + containerRef?: string | null; + failed: boolean; + execution_processes: number; + task?: any; +}; + +export function NextActionCard({ + attemptId, + containerRef, + failed, + execution_processes, + task, +}: NextActionCardProps) { + const { t } = useTranslation('tasks'); + const { config } = useUserSystem(); + const { project } = useProject(); + const navigate = useNavigate(); + const [copied, setCopied] = useState(false); + + const { data: attempt } = useQuery({ + queryKey: ['attempt', attemptId], + queryFn: () => attemptsApi.get(attemptId!), + enabled: !!attemptId && failed, + }); + + const openInEditor = useOpenInEditor(attemptId); + const { fileCount, added, deleted, error } = useDiffSummary( + attemptId ?? null + ); + const { + start, + stop, + isStarting, + isStopping, + runningDevServer, + latestDevServerProcess, + } = useDevServer(attemptId); + + const projectHasDevScript = Boolean(project?.dev_script); + + const handleCopy = useCallback(async () => { + if (!containerRef) return; + + try { + await navigator.clipboard.writeText(containerRef); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.warn('Copy to clipboard failed:', err); + } + }, [containerRef]); + + const handleOpenInEditor = useCallback(() => { + openInEditor(); + }, [openInEditor]); + + const handleViewLogs = useCallback(() => { + if (attemptId) { + NiceModal.show('view-processes', { + attemptId, + initialProcessId: latestDevServerProcess?.id, + }); + } + }, [attemptId, latestDevServerProcess?.id]); + + const handleOpenDiffs = useCallback(() => { + navigate({ search: '?view=diffs' }); + }, [navigate]); + + const handleTryAgain = useCallback(() => { + if (!attempt?.task_id) return; + NiceModal.show('create-attempt', { + taskId: attempt.task_id, + latestAttempt: attemptId, + }); + }, [attempt?.task_id, attemptId]); + + const handleGitActions = useCallback(() => { + if (!attemptId) return; + NiceModal.show('git-actions', { + attemptId, + task, + projectId: project?.id, + }); + }, [attemptId, task, project?.id]); + + const editorName = getIdeName(config?.editor?.editor_type); + + // Necessary to prevent this component being displayed beyond fold within Virtualised List + if ((!failed || execution_processes > 2) && fileCount === 0) { + return
; + } + + return ( + +
+
+ + {t('attempt.labels.summaryAndActions')} + +
+
+ {/* Left: Diff summary */} + {!error && ( + + )} + +
+ + {/* Try Again button */} + {failed && execution_processes <= 2 && ( + + )} + + {/* Right: Icon buttons */} + {fileCount > 0 && ( +
+ + + + + {t('attempt.diffs')} + + + {containerRef && ( + + + + + + {copied ? t('attempt.copied') : t('attempt.clickToCopy')} + + + )} + + + + + + + {t('attempt.openInEditor', { editor: editorName })} + + + + + + + + + + + {!projectHasDevScript + ? t('attempt.devScriptMissingTooltip') + : runningDevServer + ? t('attempt.pauseDev') + : t('attempt.startDev')} + + + + {latestDevServerProcess && ( + + + + + {t('attempt.viewDevLogs')} + + )} + + + + + + {t('attempt.gitActions')} + +
+ )} +
+
+ + ); +} diff --git a/frontend/src/components/dialogs/index.ts b/frontend/src/components/dialogs/index.ts index 3d98a9d9..1ac65861 100644 --- a/frontend/src/components/dialogs/index.ts +++ b/frontend/src/components/dialogs/index.ts @@ -61,6 +61,10 @@ export { ViewProcessesDialog, type ViewProcessesDialogProps, } from './tasks/ViewProcessesDialog'; +export { + GitActionsDialog, + type GitActionsDialogProps, +} from './tasks/GitActionsDialog'; // Settings dialogs export { diff --git a/frontend/src/components/dialogs/tasks/GitActionsDialog.tsx b/frontend/src/components/dialogs/tasks/GitActionsDialog.tsx new file mode 100644 index 00000000..4b2217b6 --- /dev/null +++ b/frontend/src/components/dialogs/tasks/GitActionsDialog.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ExternalLink, GitPullRequest } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Loader } from '@/components/ui/loader'; +import GitOperations from '@/components/tasks/Toolbar/GitOperations'; +import { useTaskAttempt } from '@/hooks/useTaskAttempt'; +import { useBranchStatus, useAttemptExecution } from '@/hooks'; +import { useProject } from '@/contexts/project-context'; +import { ExecutionProcessesProvider } from '@/contexts/ExecutionProcessesContext'; +import { projectsApi } from '@/lib/api'; +import type { + GitBranch, + TaskAttempt, + TaskWithAttemptStatus, +} from 'shared/types'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; + +export interface GitActionsDialogProps { + attemptId: string; + task?: TaskWithAttemptStatus; + projectId?: string; +} + +interface GitActionsDialogContentProps { + attempt: TaskAttempt; + task: TaskWithAttemptStatus; + projectId: string; + branches: GitBranch[]; + gitError: string | null; + setGitError: (error: string | null) => void; +} + +function GitActionsDialogContent({ + attempt, + task, + projectId, + branches, + gitError, + setGitError, +}: GitActionsDialogContentProps) { + const { t } = useTranslation('tasks'); + const { data: branchStatus } = useBranchStatus(attempt.id); + const { isAttemptRunning } = useAttemptExecution(attempt.id); + + const mergedPR = branchStatus?.merges?.find( + (m) => m.type === 'pr' && m.pr_info?.status === 'merged' + ); + + if (mergedPR && mergedPR.type === 'pr') { + return ( +
+
+ + {t('git.actions.prMerged', { + number: mergedPR.pr_info.number || '', + })} + + {mergedPR.pr_info.url && ( + + + {t('git.pr.number', { + number: Number(mergedPR.pr_info.number), + })} + + + )} +
+
+ ); + } + + return ( +
+ {gitError && ( +
+ {gitError} +
+ )} + +
+ ); +} + +export const GitActionsDialog = NiceModal.create( + ({ attemptId, task, projectId: providedProjectId }) => { + const modal = useModal(); + const { t } = useTranslation('tasks'); + const { project } = useProject(); + + const effectiveProjectId = providedProjectId ?? project?.id; + const { data: attempt } = useTaskAttempt(attemptId); + + const [branches, setBranches] = useState([]); + const [gitError, setGitError] = useState(null); + const [loadingBranches, setLoadingBranches] = useState(true); + + useEffect(() => { + if (!effectiveProjectId) return; + setLoadingBranches(true); + projectsApi + .getBranches(effectiveProjectId) + .then(setBranches) + .catch(() => setBranches([])) + .finally(() => setLoadingBranches(false)); + }, [effectiveProjectId]); + + const handleOpenChange = (open: boolean) => { + if (!open) { + modal.hide(); + } + }; + + const isLoading = + !attempt || !effectiveProjectId || loadingBranches || !task; + + return ( + + + + {t('git.actions.title')} + + + {isLoading ? ( +
+ +
+ ) : ( + + + + )} +
+
+ ); + } +); diff --git a/frontend/src/components/dialogs/tasks/ViewProcessesDialog.tsx b/frontend/src/components/dialogs/tasks/ViewProcessesDialog.tsx index 55224fb4..0d463a9e 100644 --- a/frontend/src/components/dialogs/tasks/ViewProcessesDialog.tsx +++ b/frontend/src/components/dialogs/tasks/ViewProcessesDialog.tsx @@ -11,10 +11,11 @@ import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext'; export interface ViewProcessesDialogProps { attemptId: string; + initialProcessId?: string | null; } export const ViewProcessesDialog = NiceModal.create( - ({ attemptId }) => { + ({ attemptId, initialProcessId }) => { const { t } = useTranslation('tasks'); const modal = useModal(); @@ -43,7 +44,7 @@ export const ViewProcessesDialog = NiceModal.create( {t('viewProcessesDialog.title')}
- +
diff --git a/frontend/src/components/logs/VirtualizedList.tsx b/frontend/src/components/logs/VirtualizedList.tsx index b2673dc7..7d0a6c55 100644 --- a/frontend/src/components/logs/VirtualizedList.tsx +++ b/frontend/src/components/logs/VirtualizedList.tsx @@ -16,15 +16,17 @@ import { useConversationHistory, } from '@/hooks/useConversationHistory'; import { Loader2 } from 'lucide-react'; -import { TaskAttempt } from 'shared/types'; +import { TaskAttempt, TaskWithAttemptStatus } from 'shared/types'; import { ApprovalFormProvider } from '@/contexts/ApprovalFormContext'; interface VirtualizedListProps { attempt: TaskAttempt; + task?: TaskWithAttemptStatus; } interface MessageListContext { attempt: TaskAttempt; + task?: TaskWithAttemptStatus; } const INITIAL_TOP_ITEM = { index: 'LAST' as const, align: 'end' as const }; @@ -45,6 +47,7 @@ const ItemContent: VirtuosoMessageListProps< MessageListContext >['ItemContent'] = ({ data, context }) => { const attempt = context?.attempt; + const task = context?.task; if (data.type === 'STDOUT') { return

{data.content}

; @@ -59,6 +62,7 @@ const ItemContent: VirtuosoMessageListProps< entry={data.content} executionProcessId={data.executionProcessId} taskAttempt={attempt} + task={task} /> ); } @@ -71,7 +75,7 @@ const computeItemKey: VirtuosoMessageListProps< MessageListContext >['computeItemKey'] = ({ data }) => `l-${data.patchKey}`; -const VirtualizedList = ({ attempt }: VirtualizedListProps) => { +const VirtualizedList = ({ attempt, task }: VirtualizedListProps) => { const [channelData, setChannelData] = useState | null>(null); const [loading, setLoading] = useState(true); @@ -105,7 +109,10 @@ const VirtualizedList = ({ attempt }: VirtualizedListProps) => { useConversationHistory({ attempt, onEntriesUpdated }); const messageListRef = useRef(null); - const messageListContext = useMemo(() => ({ attempt }), [attempt]); + const messageListContext = useMemo( + () => ({ attempt, task }), + [attempt, task] + ); return ( diff --git a/frontend/src/components/panels/TaskAttemptPanel.tsx b/frontend/src/components/panels/TaskAttemptPanel.tsx index 3cd2f658..2f19bd68 100644 --- a/frontend/src/components/panels/TaskAttemptPanel.tsx +++ b/frontend/src/components/panels/TaskAttemptPanel.tsx @@ -28,7 +28,9 @@ const TaskAttemptPanel = ({ {children({ - logs: , + logs: ( + + ), followUp: ( void; selectedBranch: string | null; + layout?: 'horizontal' | 'vertical'; } export type GitOperationsInputs = Omit; @@ -54,6 +55,7 @@ function GitOperations({ isAttemptRunning, setError, selectedBranch, + layout = 'horizontal', }: GitOperationsProps) { const { t } = useTranslation('tasks'); @@ -268,9 +270,23 @@ function GitOperations({ return null; } + const isVertical = layout === 'vertical'; + + const containerClasses = isVertical + ? 'grid grid-cols-1 items-start gap-3 overflow-hidden' + : 'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 overflow-hidden'; + + const settingsBtnClasses = isVertical + ? 'inline-flex h-5 w-5 p-0 hover:bg-muted' + : 'hidden md:inline-flex h-5 w-5 p-0 hover:bg-muted'; + + const actionsClasses = isVertical + ? 'flex flex-wrap items-center gap-2' + : 'shrink-0 flex flex-wrap items-center gap-2 overflow-y-hidden overflow-x-visible max-h-8'; + return (
-
+
{/* Left: Branch flow */}
{/* Task branch chip */} @@ -319,7 +335,7 @@ function GitOperations({ size="xs" onClick={handleChangeTargetBranchDialogOpen} disabled={isAttemptRunning || hasConflictsCalculated} - className="hidden md:inline-flex h-5 w-5 p-0 hover:bg-muted" + className={settingsBtnClasses} aria-label={t('branches.changeTarget.dialog.title')} > @@ -421,9 +437,9 @@ function GitOperations({ })()}
- {/* Right: Actions (compact, right-aligned) */} + {/* Right: Actions */} {branchStatus && ( -
+