Next actions (#1082)
* 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 <alex@bloop.ai>
This commit is contained in:
committed by
GitHub
parent
f88daa4826
commit
6fc7410b28
@@ -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<ActionType, { action: 'file_edit' }>;
|
||||
@@ -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 (
|
||||
<div className="px-4 py-2 text-sm">
|
||||
<NextActionCard
|
||||
attemptId={taskAttempt?.id}
|
||||
containerRef={taskAttempt?.container_ref}
|
||||
failed={entry.entry_type.failed}
|
||||
execution_processes={entry.entry_type.execution_processes}
|
||||
task={task}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 text-sm">
|
||||
<div className={getContentClassName(entryType)}>
|
||||
|
||||
@@ -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 <div className="h-24"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="pt-4 pb-8">
|
||||
<div
|
||||
className={`px-3 py-1 text-background flex ${failed ? 'bg-destructive' : 'bg-foreground'}`}
|
||||
>
|
||||
<span className="font-semibold flex-1">
|
||||
{t('attempt.labels.summaryAndActions')}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`border px-3 py-2 flex items-center gap-3 min-w-0 ${failed ? 'border-destructive' : 'border-foreground'}`}
|
||||
>
|
||||
{/* Left: Diff summary */}
|
||||
{!error && (
|
||||
<button
|
||||
onClick={handleOpenDiffs}
|
||||
className="flex items-center gap-1.5 text-sm shrink-0 cursor-pointer hover:underline transition-all"
|
||||
aria-label={t('attempt.diffs')}
|
||||
>
|
||||
<span>{t('diff.filesChanged', { count: fileCount })}</span>
|
||||
<span className="opacity-50">•</span>
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
+{added}
|
||||
</span>
|
||||
<span className="opacity-50">•</span>
|
||||
<span className="text-red-600 dark:text-red-400">-{deleted}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Try Again button */}
|
||||
{failed && execution_processes <= 2 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleTryAgain}
|
||||
disabled={!attempt?.task_id}
|
||||
className="text-sm"
|
||||
aria-label={t('attempt.tryAgain')}
|
||||
>
|
||||
{t('attempt.tryAgain')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Right: Icon buttons */}
|
||||
{fileCount > 0 && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleOpenDiffs}
|
||||
aria-label={t('attempt.diffs')}
|
||||
>
|
||||
<FileDiff className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('attempt.diffs')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{containerRef && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleCopy}
|
||||
aria-label={t('attempt.clickToCopy')}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{copied ? t('attempt.copied') : t('attempt.clickToCopy')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleOpenInEditor}
|
||||
disabled={!attemptId}
|
||||
aria-label={t('attempt.openInEditor', {
|
||||
editor: editorName,
|
||||
})}
|
||||
>
|
||||
<IdeIcon
|
||||
editorType={config?.editor?.editor_type}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('attempt.openInEditor', { editor: editorName })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={runningDevServer ? () => stop() : () => start()}
|
||||
disabled={
|
||||
(runningDevServer ? isStopping : isStarting) ||
|
||||
!attemptId ||
|
||||
!projectHasDevScript
|
||||
}
|
||||
aria-label={
|
||||
runningDevServer
|
||||
? t('attempt.pauseDev')
|
||||
: t('attempt.startDev')
|
||||
}
|
||||
>
|
||||
{runningDevServer ? (
|
||||
<Pause className="h-3.5 w-3.5 text-destructive" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!projectHasDevScript
|
||||
? t('attempt.devScriptMissingTooltip')
|
||||
: runningDevServer
|
||||
? t('attempt.pauseDev')
|
||||
: t('attempt.startDev')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{latestDevServerProcess && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleViewLogs}
|
||||
disabled={!attemptId}
|
||||
aria-label={t('attempt.viewDevLogs')}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('attempt.viewDevLogs')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleGitActions}
|
||||
disabled={!attemptId}
|
||||
aria-label={t('attempt.gitActions')}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('attempt.gitActions')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,10 @@ export {
|
||||
ViewProcessesDialog,
|
||||
type ViewProcessesDialogProps,
|
||||
} from './tasks/ViewProcessesDialog';
|
||||
export {
|
||||
GitActionsDialog,
|
||||
type GitActionsDialogProps,
|
||||
} from './tasks/GitActionsDialog';
|
||||
|
||||
// Settings dialogs
|
||||
export {
|
||||
|
||||
164
frontend/src/components/dialogs/tasks/GitActionsDialog.tsx
Normal file
164
frontend/src/components/dialogs/tasks/GitActionsDialog.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t('git.actions.prMerged', {
|
||||
number: mergedPR.pr_info.number || '',
|
||||
})}
|
||||
</span>
|
||||
{mergedPR.pr_info.url && (
|
||||
<a
|
||||
href={mergedPR.pr_info.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
{t('git.pr.number', {
|
||||
number: Number(mergedPR.pr_info.number),
|
||||
})}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{gitError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-destructive text-sm">
|
||||
{gitError}
|
||||
</div>
|
||||
)}
|
||||
<GitOperations
|
||||
selectedAttempt={attempt}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
branchStatus={branchStatus ?? null}
|
||||
branches={branches}
|
||||
isAttemptRunning={isAttemptRunning}
|
||||
setError={setGitError}
|
||||
selectedBranch={branchStatus?.target_branch_name ?? null}
|
||||
layout="vertical"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GitActionsDialog = NiceModal.create<GitActionsDialogProps>(
|
||||
({ 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<GitBranch[]>([]);
|
||||
const [gitError, setGitError] = useState<string | null>(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 (
|
||||
<Dialog open={modal.visible} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('git.actions.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8">
|
||||
<Loader size={24} />
|
||||
</div>
|
||||
) : (
|
||||
<ExecutionProcessesProvider key={attempt.id} attemptId={attempt.id}>
|
||||
<GitActionsDialogContent
|
||||
attempt={attempt}
|
||||
task={task}
|
||||
projectId={effectiveProjectId}
|
||||
branches={branches}
|
||||
gitError={gitError}
|
||||
setGitError={setGitError}
|
||||
/>
|
||||
</ExecutionProcessesProvider>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -11,10 +11,11 @@ import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext';
|
||||
|
||||
export interface ViewProcessesDialogProps {
|
||||
attemptId: string;
|
||||
initialProcessId?: string | null;
|
||||
}
|
||||
|
||||
export const ViewProcessesDialog = NiceModal.create<ViewProcessesDialogProps>(
|
||||
({ attemptId }) => {
|
||||
({ attemptId, initialProcessId }) => {
|
||||
const { t } = useTranslation('tasks');
|
||||
const modal = useModal();
|
||||
|
||||
@@ -43,7 +44,7 @@ export const ViewProcessesDialog = NiceModal.create<ViewProcessesDialogProps>(
|
||||
<DialogTitle>{t('viewProcessesDialog.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-[75vh] flex flex-col min-h-0 min-w-0">
|
||||
<ProcessSelectionProvider>
|
||||
<ProcessSelectionProvider initialProcessId={initialProcessId}>
|
||||
<ProcessesTab attemptId={attemptId} />
|
||||
</ProcessSelectionProvider>
|
||||
</div>
|
||||
|
||||
@@ -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 <p>{data.content}</p>;
|
||||
@@ -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<DataWithScrollModifier<PatchTypeWithKey> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -105,7 +109,10 @@ const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
|
||||
useConversationHistory({ attempt, onEntriesUpdated });
|
||||
|
||||
const messageListRef = useRef<VirtuosoMessageListMethods | null>(null);
|
||||
const messageListContext = useMemo(() => ({ attempt }), [attempt]);
|
||||
const messageListContext = useMemo(
|
||||
() => ({ attempt, task }),
|
||||
[attempt, task]
|
||||
);
|
||||
|
||||
return (
|
||||
<ApprovalFormProvider>
|
||||
|
||||
@@ -28,7 +28,9 @@ const TaskAttemptPanel = ({
|
||||
<EntriesProvider key={attempt.id}>
|
||||
<RetryUiProvider attemptId={attempt.id}>
|
||||
{children({
|
||||
logs: <VirtualizedList key={attempt.id} attempt={attempt} />,
|
||||
logs: (
|
||||
<VirtualizedList key={attempt.id} attempt={attempt} task={task} />
|
||||
),
|
||||
followUp: (
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface GitOperationsProps {
|
||||
isAttemptRunning: boolean;
|
||||
setError: (error: string | null) => void;
|
||||
selectedBranch: string | null;
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
export type GitOperationsInputs = Omit<GitOperationsProps, 'selectedAttempt'>;
|
||||
@@ -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 (
|
||||
<div className="w-full border-b py-2">
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 overflow-hidden">
|
||||
<div className={containerClasses}>
|
||||
{/* Left: Branch flow */}
|
||||
<div className="flex items-center gap-2 min-w-0 shrink-0 overflow-hidden">
|
||||
{/* 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')}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
@@ -421,9 +437,9 @@ function GitOperations({
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions (compact, right-aligned) */}
|
||||
{/* Right: Actions */}
|
||||
{branchStatus && (
|
||||
<div className="shrink-0 flex flex-wrap items-center gap-2 overflow-y-hidden overflow-x-visible max-h-8">
|
||||
<div className={actionsClasses}>
|
||||
<Button
|
||||
onClick={handleMergeClick}
|
||||
disabled={
|
||||
|
||||
@@ -81,6 +81,16 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleGitActions = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!attempt?.id || !task) return;
|
||||
NiceModal.show('git-actions', {
|
||||
attemptId: attempt.id,
|
||||
task,
|
||||
projectId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
@@ -118,6 +128,12 @@ export function ActionsDropdown({ task, attempt }: ActionsDropdownProps) {
|
||||
>
|
||||
{t('actionsMenu.createSubtask')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!attempt?.id || !task}
|
||||
onClick={handleGitActions}
|
||||
>
|
||||
{t('actionsMenu.gitActions')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,13 +10,15 @@ const ProcessSelectionContext =
|
||||
|
||||
interface ProcessSelectionProviderProps {
|
||||
children: ReactNode;
|
||||
initialProcessId?: string | null;
|
||||
}
|
||||
|
||||
export function ProcessSelectionProvider({
|
||||
children,
|
||||
initialProcessId = null,
|
||||
}: ProcessSelectionProviderProps) {
|
||||
const [selectedProcessId, setSelectedProcessId] = useState<string | null>(
|
||||
null
|
||||
initialProcessId
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -50,6 +50,37 @@ interface UseConversationHistoryResult {}
|
||||
const MIN_INITIAL_ENTRIES = 10;
|
||||
const REMAINING_BATCH_SIZE = 50;
|
||||
|
||||
const loadingPatch: PatchTypeWithKey = {
|
||||
type: 'NORMALIZED_ENTRY',
|
||||
content: {
|
||||
entry_type: {
|
||||
type: 'loading',
|
||||
},
|
||||
content: '',
|
||||
timestamp: null,
|
||||
},
|
||||
patchKey: 'loading',
|
||||
executionProcessId: '',
|
||||
};
|
||||
|
||||
const nextActionPatch: (
|
||||
failed: boolean,
|
||||
execution_processes: number
|
||||
) => PatchTypeWithKey = (failed, execution_processes) => ({
|
||||
type: 'NORMALIZED_ENTRY',
|
||||
content: {
|
||||
entry_type: {
|
||||
type: 'next_action',
|
||||
failed: failed,
|
||||
execution_processes: execution_processes,
|
||||
},
|
||||
content: '',
|
||||
timestamp: null,
|
||||
},
|
||||
patchKey: 'next_action',
|
||||
executionProcessId: '',
|
||||
});
|
||||
|
||||
export const useConversationHistory = ({
|
||||
attempt,
|
||||
onEntriesUpdated,
|
||||
@@ -197,22 +228,14 @@ export const useConversationHistory = ({
|
||||
.flatMap((p) => p.entries);
|
||||
};
|
||||
|
||||
const loadingPatch: PatchTypeWithKey = {
|
||||
type: 'NORMALIZED_ENTRY',
|
||||
content: {
|
||||
entry_type: {
|
||||
type: 'loading',
|
||||
},
|
||||
content: '',
|
||||
timestamp: null,
|
||||
},
|
||||
patchKey: 'loading',
|
||||
executionProcessId: '',
|
||||
};
|
||||
|
||||
const flattenEntriesForEmit = (
|
||||
executionProcessState: ExecutionProcessStateStore
|
||||
): PatchTypeWithKey[] => {
|
||||
// Flags to control Next Action bar emit
|
||||
let hasPendingApproval = false;
|
||||
let hasRunningProcess = false;
|
||||
let lastProcessFailedOrKilled = false;
|
||||
|
||||
// Create user messages + tool calls for setup/cleanup scripts
|
||||
const allEntries = Object.values(executionProcessState)
|
||||
.sort(
|
||||
@@ -222,7 +245,7 @@ export const useConversationHistory = ({
|
||||
).getTime() -
|
||||
new Date(b.executionProcess.created_at as unknown as string).getTime()
|
||||
)
|
||||
.flatMap((p) => {
|
||||
.flatMap((p, index) => {
|
||||
const entries: PatchTypeWithKey[] = [];
|
||||
if (
|
||||
p.executionProcess.executor_action.typ.type ===
|
||||
@@ -265,10 +288,31 @@ export const useConversationHistory = ({
|
||||
);
|
||||
});
|
||||
|
||||
if (hasPendingApprovalEntry) {
|
||||
hasPendingApproval = true;
|
||||
}
|
||||
|
||||
entries.push(...entriesExcludingUser);
|
||||
|
||||
const liveProcessStatus = getLiveExecutionProcess(
|
||||
p.executionProcess.id
|
||||
)?.status;
|
||||
const isProcessRunning =
|
||||
getLiveExecutionProcess(p.executionProcess.id)?.status ===
|
||||
ExecutionProcessStatus.running;
|
||||
liveProcessStatus === ExecutionProcessStatus.running;
|
||||
const processFailedOrKilled =
|
||||
liveProcessStatus === ExecutionProcessStatus.failed ||
|
||||
liveProcessStatus === ExecutionProcessStatus.killed;
|
||||
|
||||
if (isProcessRunning) {
|
||||
hasRunningProcess = true;
|
||||
}
|
||||
|
||||
if (
|
||||
processFailedOrKilled &&
|
||||
index === Object.keys(executionProcessState).length - 1
|
||||
) {
|
||||
lastProcessFailedOrKilled = true;
|
||||
}
|
||||
|
||||
if (isProcessRunning && !hasPendingApprovalEntry) {
|
||||
entries.push(loadingPatch);
|
||||
@@ -293,6 +337,18 @@ export const useConversationHistory = ({
|
||||
p.executionProcess.id
|
||||
);
|
||||
|
||||
if (executionProcess?.status === ExecutionProcessStatus.running) {
|
||||
hasRunningProcess = true;
|
||||
}
|
||||
|
||||
if (
|
||||
(executionProcess?.status === ExecutionProcessStatus.failed ||
|
||||
executionProcess?.status === ExecutionProcessStatus.killed) &&
|
||||
index === Object.keys(executionProcessState).length - 1
|
||||
) {
|
||||
lastProcessFailedOrKilled = true;
|
||||
}
|
||||
|
||||
const exitCode = Number(executionProcess?.exit_code) || 0;
|
||||
const exit_status: CommandExitStatus | null =
|
||||
executionProcess?.status === 'running'
|
||||
@@ -344,6 +400,16 @@ export const useConversationHistory = ({
|
||||
return entries;
|
||||
});
|
||||
|
||||
// Emit the next action bar if no process running
|
||||
if (!hasRunningProcess && !hasPendingApproval) {
|
||||
allEntries.push(
|
||||
nextActionPatch(
|
||||
lastProcessFailedOrKilled,
|
||||
Object.keys(executionProcessState).length
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return allEntries;
|
||||
};
|
||||
|
||||
|
||||
@@ -159,14 +159,16 @@
|
||||
"attempt": "Attempt",
|
||||
"agent": "Agent",
|
||||
"branch": "Branch",
|
||||
"diffs": "Diffs"
|
||||
"diffs": "Diffs",
|
||||
"summaryAndActions": "Summary & Actions"
|
||||
},
|
||||
"agent": "Agent",
|
||||
"path": "Path",
|
||||
"openInEditor": "Open in {{editor}}",
|
||||
"openInEditor": "See changes in {{editor}}",
|
||||
"copied": "Copied!",
|
||||
"clickToCopy": "Click to copy worktree path",
|
||||
"clickToCopy": "Copy worktree path",
|
||||
"stopDev": "Stop Dev",
|
||||
"pauseDev": "Pause Dev",
|
||||
"startDev": "Start Dev",
|
||||
"viewDevLogs": "View dev server logs",
|
||||
"stopping": "Stopping...",
|
||||
@@ -175,7 +177,10 @@
|
||||
"viewHistory": "View attempt history",
|
||||
"createSubtask": "Create Subtask",
|
||||
"preview": "Preview",
|
||||
"diffs": "Diffs"
|
||||
"diffs": "Diffs",
|
||||
"gitActions": "Git Actions",
|
||||
"tryAgain": "Try Again",
|
||||
"devScriptMissingTooltip": "To start the dev server, add a dev script to this project"
|
||||
},
|
||||
"git": {
|
||||
"labels": {
|
||||
@@ -213,6 +218,10 @@
|
||||
"pr": {
|
||||
"open": "Open PR #{{number}}",
|
||||
"number": "PR #{{number}}"
|
||||
},
|
||||
"actions": {
|
||||
"title": "Git Actions",
|
||||
"prMerged": "PR #{{number}} is already merged"
|
||||
}
|
||||
},
|
||||
"createAttemptDialog": {
|
||||
@@ -238,6 +247,7 @@
|
||||
"viewProcesses": "View processes",
|
||||
"createNewAttempt": "Create new attempt",
|
||||
"createSubtask": "Create subtask",
|
||||
"gitActions": "Git actions",
|
||||
"task": "Task",
|
||||
"duplicate": "Duplicate"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"createNewAttempt": "Create new attempt",
|
||||
"createSubtask": "Create subtask",
|
||||
"duplicate": "Duplicate",
|
||||
"gitActions": "Acciones de Git",
|
||||
"openInIde": "Open attempt in IDE",
|
||||
"task": "Task",
|
||||
"viewProcesses": "View processes"
|
||||
@@ -19,26 +20,31 @@
|
||||
"stopDevServer": "Detener servidor de desarrollo"
|
||||
},
|
||||
"agent": "Agent",
|
||||
"clickToCopy": "Click to copy worktree path",
|
||||
"clickToCopy": "Copiar ruta del worktree",
|
||||
"copied": "Copied!",
|
||||
"createSubtask": "Create Subtask",
|
||||
"diffs": "Diffs",
|
||||
"gitActions": "Acciones de Git",
|
||||
"labels": {
|
||||
"agent": "Agente",
|
||||
"attempt": "Intento",
|
||||
"branch": "Rama",
|
||||
"diffs": "Diferencias"
|
||||
"diffs": "Diferencias",
|
||||
"summaryAndActions": "Resumen y Acciones"
|
||||
},
|
||||
"newAttempt": "New Attempt",
|
||||
"openInEditor": "Open in {{editor}}",
|
||||
"path": "Path",
|
||||
"pauseDev": "Pausar Dev",
|
||||
"preview": "Preview",
|
||||
"startDev": "Start Dev",
|
||||
"stopAttempt": "Stop Attempt",
|
||||
"stopDev": "Stop Dev",
|
||||
"stopping": "Stopping...",
|
||||
"viewDevLogs": "View dev server logs",
|
||||
"viewHistory": "View attempt history"
|
||||
"viewHistory": "View attempt history",
|
||||
"tryAgain": "Try Again",
|
||||
"devScriptMissingTooltip": "To start the dev server, add a dev script to this project"
|
||||
},
|
||||
"attemptHeaderActions": {
|
||||
"diffs": "Diffs",
|
||||
@@ -109,6 +115,10 @@
|
||||
"number": "PR #{{number}}",
|
||||
"open": "Open PR #{{number}}"
|
||||
},
|
||||
"actions": {
|
||||
"title": "Acciones de Git",
|
||||
"prMerged": "PR #{{number}} ya está fusionado"
|
||||
},
|
||||
"states": {
|
||||
"createPr": "Crear PR",
|
||||
"creating": "Creando...",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"createNewAttempt": "Create new attempt",
|
||||
"createSubtask": "Create subtask",
|
||||
"duplicate": "Duplicate",
|
||||
"gitActions": "Gitアクション",
|
||||
"openInIde": "Open attempt in IDE",
|
||||
"task": "Task",
|
||||
"viewProcesses": "View processes"
|
||||
@@ -19,26 +20,31 @@
|
||||
"stopDevServer": "開発サーバーを停止"
|
||||
},
|
||||
"agent": "Agent",
|
||||
"clickToCopy": "Click to copy worktree path",
|
||||
"clickToCopy": "ワークツリーパスをコピー",
|
||||
"copied": "Copied!",
|
||||
"createSubtask": "Create Subtask",
|
||||
"diffs": "Diffs",
|
||||
"gitActions": "Gitアクション",
|
||||
"labels": {
|
||||
"agent": "エージェント",
|
||||
"attempt": "試行",
|
||||
"branch": "ブランチ",
|
||||
"diffs": "差分"
|
||||
"diffs": "差分",
|
||||
"summaryAndActions": "概要とアクション"
|
||||
},
|
||||
"newAttempt": "New Attempt",
|
||||
"openInEditor": "Open in {{editor}}",
|
||||
"path": "Path",
|
||||
"pauseDev": "Dev を一時停止",
|
||||
"preview": "Preview",
|
||||
"startDev": "Start Dev",
|
||||
"stopAttempt": "Stop Attempt",
|
||||
"stopDev": "Stop Dev",
|
||||
"stopping": "Stopping...",
|
||||
"viewDevLogs": "View dev server logs",
|
||||
"viewHistory": "View attempt history"
|
||||
"viewHistory": "View attempt history",
|
||||
"tryAgain": "Try Again",
|
||||
"devScriptMissingTooltip": "To start the dev server, add a dev script to this project"
|
||||
},
|
||||
"attemptHeaderActions": {
|
||||
"diffs": "Diffs",
|
||||
@@ -109,6 +115,10 @@
|
||||
"number": "PR #{{number}}",
|
||||
"open": "Open PR #{{number}}"
|
||||
},
|
||||
"actions": {
|
||||
"title": "Gitアクション",
|
||||
"prMerged": "PR #{{number}} は既にマージされています"
|
||||
},
|
||||
"states": {
|
||||
"createPr": "PRを作成",
|
||||
"creating": "作成中...",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"createNewAttempt": "Create new attempt",
|
||||
"createSubtask": "Create subtask",
|
||||
"duplicate": "Duplicate",
|
||||
"gitActions": "Git 작업",
|
||||
"openInIde": "Open attempt in IDE",
|
||||
"task": "Task",
|
||||
"viewProcesses": "View processes"
|
||||
@@ -19,26 +20,31 @@
|
||||
"stopDevServer": "개발 서버 중지"
|
||||
},
|
||||
"agent": "Agent",
|
||||
"clickToCopy": "Click to copy worktree path",
|
||||
"clickToCopy": "작업 트리 경로 복사",
|
||||
"copied": "Copied!",
|
||||
"createSubtask": "Create Subtask",
|
||||
"diffs": "Diffs",
|
||||
"gitActions": "Git 작업",
|
||||
"labels": {
|
||||
"agent": "에이전트",
|
||||
"attempt": "시도",
|
||||
"branch": "브랜치",
|
||||
"diffs": "변경사항"
|
||||
"diffs": "변경사항",
|
||||
"summaryAndActions": "요약 및 작업"
|
||||
},
|
||||
"newAttempt": "New Attempt",
|
||||
"openInEditor": "Open in {{editor}}",
|
||||
"path": "Path",
|
||||
"pauseDev": "Dev 일시정지",
|
||||
"preview": "Preview",
|
||||
"startDev": "Start Dev",
|
||||
"stopAttempt": "Stop Attempt",
|
||||
"stopDev": "Stop Dev",
|
||||
"stopping": "Stopping...",
|
||||
"viewDevLogs": "View dev server logs",
|
||||
"viewHistory": "View attempt history"
|
||||
"viewHistory": "View attempt history",
|
||||
"tryAgain": "Try Again",
|
||||
"devScriptMissingTooltip": "To start the dev server, add a dev script to this project"
|
||||
},
|
||||
"attemptHeaderActions": {
|
||||
"diffs": "Diffs",
|
||||
@@ -109,6 +115,10 @@
|
||||
"number": "PR #{{number}}",
|
||||
"open": "Open PR #{{number}}"
|
||||
},
|
||||
"actions": {
|
||||
"title": "Git 작업",
|
||||
"prMerged": "PR #{{number}}은(는) 이미 병합되었습니다"
|
||||
},
|
||||
"states": {
|
||||
"createPr": "PR 생성",
|
||||
"creating": "생성 중...",
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ProjectEditorSelectionDialog,
|
||||
RestoreLogsDialog,
|
||||
ViewProcessesDialog,
|
||||
GitActionsDialog,
|
||||
} from './components/dialogs';
|
||||
import { CreateAttemptDialog } from './components/dialogs/tasks/CreateAttemptDialog';
|
||||
|
||||
@@ -61,6 +62,7 @@ NiceModal.register('project-editor-selection', ProjectEditorSelectionDialog);
|
||||
NiceModal.register('restore-logs', RestoreLogsDialog);
|
||||
NiceModal.register('view-processes', ViewProcessesDialog);
|
||||
NiceModal.register('create-attempt', CreateAttemptDialog);
|
||||
NiceModal.register('git-actions', GitActionsDialog);
|
||||
|
||||
import {
|
||||
useLocation,
|
||||
|
||||
Reference in New Issue
Block a user