From 6fc7410b2855edd9c28342be2fe7414a51bde957 Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Thu, 23 Oct 2025 17:43:37 +0100 Subject: [PATCH] Next actions (#1082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Scaffold * Create next action bar (vibe-kanban 1fd0bc9a) There's a placeholder NextActionCard in frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx - We should check for the diff summary frontend/src/hooks/useDiffSummary.ts - If there is a diff, then render a summary box - The summary box should contain: - The diff summary - Whether dev server is running for this task attempt - Controls to start, stop and view logs (in processes popup) for dev server frontend/src/hooks/useDevServer.ts - Button to open task attempt in IDE frontend/src/components/ide/OpenInIdeButton.tsx * simplify error * styles * i18n * hide dev server controls if no dev server configured * tooltips * fmt * Feedback on next actions (vibe-kanban 7ff2f1b0) frontend/src/components/NormalizedConversation/NextActionCard.tsx - File changed and the +/- should be clickable and take you to diffs - Tooltip for editor should say "See changes in VS Code" (or something that make it clearer that this opens the worktree) * WIP failed variant for next action * fail styling * Create new attempt button (vibe-kanban 4ee265a2) Please add a "create new attempt" button to frontend/src/components/NormalizedConversation/NextActionCard.tsx This should be a text button "Try Again" and only show when failed = true * Git actions dialog (vibe-kanban 328ec790) frontend/src/components/tasks/Toolbar/GitOperations.tsx I want these actions to be available in a dialog that's triggerable from: - Dropdown menu in attempt header frontend/src/pages/project-tasks.tsx - a new icon in frontend/src/components/NormalizedConversation/NextActionCard.tsx * Change dev server (vibe-kanban 08df620f) Instead of hiding if no dev script, show as disabled and change the tooltip to "To start the dev server, add a dev script to this project" frontend/src/components/NormalizedConversation/NextActionCard.tsx * i18n (vibe-kanban 0e07797b) Look for any missing i18n strings in frontend/src/components/NormalizedConversation/NextActionCard.tsx and frontend/src/components/dialogs/tasks/GitActionsDialog.tsx * Done! I've successfully fixed the i18n issues. The script `scripts/check-i18n.sh` was running correctly, but it was failing because there were missing translation keys in the non-English locales (Spanish, Japanese, and Korean). (#1093) ## What was fixed: The script checks that all translation keys in the English locale file exist in all other locale files. There were 4 missing keys related to the new Git Actions feature: 1. `actionsMenu.gitActions` 2. `attempt.gitActions` 3. `git.actions.title` 4. `git.actions.prMerged` I added appropriate translations for these keys to all three locale files: - **Spanish (es)**: "Acciones de Git" and "PR #{{number}} ya está fusionado" - **Japanese (ja)**: "Gitアクション" and "PR #{{number}} は既にマージされています" - **Korean (ko)**: "Git 작업" and "PR #{{number}}은(는) 이미 병합되었습니다" The i18n check now passes all three validation steps: - ✅ No new literal strings introduced - ✅ No duplicate keys found in JSON files - ✅ Translation keys are consistent across locales * hide try again if more than 2 execution processes --------- Co-authored-by: Alex Netsch --- crates/executors/src/logs/mod.rs | 4 + .../DisplayConversationEntry.tsx | 17 + .../NormalizedConversation/NextActionCard.tsx | 311 ++++++++++++++++++ frontend/src/components/dialogs/index.ts | 4 + .../dialogs/tasks/GitActionsDialog.tsx | 164 +++++++++ .../dialogs/tasks/ViewProcessesDialog.tsx | 5 +- .../src/components/logs/VirtualizedList.tsx | 13 +- .../components/panels/TaskAttemptPanel.tsx | 4 +- .../tasks/Toolbar/GitOperations.tsx | 24 +- .../src/components/ui/ActionsDropdown.tsx | 16 + .../src/contexts/ProcessSelectionContext.tsx | 4 +- frontend/src/hooks/useConversationHistory.ts | 98 +++++- frontend/src/i18n/locales/en/tasks.json | 18 +- frontend/src/i18n/locales/es/tasks.json | 16 +- frontend/src/i18n/locales/ja/tasks.json | 16 +- frontend/src/i18n/locales/ko/tasks.json | 16 +- frontend/src/main.tsx | 2 + shared/types.ts | 2 +- 18 files changed, 693 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/NormalizedConversation/NextActionCard.tsx create mode 100644 frontend/src/components/dialogs/tasks/GitActionsDialog.tsx 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 && ( -
+