feat(FN-2874): add failed-task retry action to dashboard cards
- Wire onRetryTask from App through Board/Column/WorktreeGroup/ListView into TaskCard - Add a Retry button with loading/disabled state to failed task error boxes in TaskCard - Update failed-card styles to use design tokens and add retry button visual states - Add TaskCard tests covering retry visibility, callback invocation, loading, and error toast behavior - Add a patch changeset for @runfusion/fusion documenting the dashboard retry shortcut
This commit is contained in:
5
.changeset/fn-2874-retry-button.md
Normal file
5
.changeset/fn-2874-retry-button.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a retry button to failed task error boxes on dashboard task cards so users can retry directly from the card without opening task details.
|
||||||
@@ -732,6 +732,7 @@ function AppInner() {
|
|||||||
onToggleAutoMerge={toggleAutoMerge}
|
onToggleAutoMerge={toggleAutoMerge}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
onUpdateTask={updateTask}
|
onUpdateTask={updateTask}
|
||||||
|
onRetryTask={retryTask}
|
||||||
onArchiveTask={archiveTask}
|
onArchiveTask={archiveTask}
|
||||||
onUnarchiveTask={unarchiveTask}
|
onUnarchiveTask={unarchiveTask}
|
||||||
onDeleteTask={deleteTask}
|
onDeleteTask={deleteTask}
|
||||||
@@ -759,6 +760,7 @@ function AppInner() {
|
|||||||
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
||||||
projectId={currentProject?.id}
|
projectId={currentProject?.id}
|
||||||
onMoveTask={moveTask}
|
onMoveTask={moveTask}
|
||||||
|
onRetryTask={retryTask}
|
||||||
onOpenDetail={modalManager.openDetailTask}
|
onOpenDetail={modalManager.openDetailTask}
|
||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface BoardProps {
|
|||||||
id: string,
|
id: string,
|
||||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||||
) => Promise<Task>;
|
) => Promise<Task>;
|
||||||
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string) => Promise<Task>;
|
onArchiveTask?: (id: string) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||||
onDeleteTask?: (id: string) => Promise<Task>;
|
onDeleteTask?: (id: string) => Promise<Task>;
|
||||||
@@ -78,7 +79,7 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
|
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
|
||||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
const archivedLoadedRef = useRef(false);
|
const archivedLoadedRef = useRef(false);
|
||||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||||
@@ -207,6 +208,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
|||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
onUpdateTask={onUpdateTask}
|
onUpdateTask={onUpdateTask}
|
||||||
|
onRetryTask={onRetryTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface ColumnProps {
|
|||||||
id: string,
|
id: string,
|
||||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||||
) => Promise<Task>;
|
) => Promise<Task>;
|
||||||
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onArchiveTask?: (id: string) => Promise<Task>;
|
onArchiveTask?: (id: string) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||||
onDeleteTask?: (id: string) => Promise<Task>;
|
onDeleteTask?: (id: string) => Promise<Task>;
|
||||||
@@ -67,7 +68,7 @@ interface ColumnProps {
|
|||||||
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
workflowStepNameLookup?: ReadonlyMap<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
|
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
@@ -422,6 +423,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
onUpdateTask={onUpdateTask}
|
onUpdateTask={onUpdateTask}
|
||||||
|
onRetryTask={onRetryTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
onOpenMission={onOpenMission}
|
onOpenMission={onOpenMission}
|
||||||
@@ -443,6 +445,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
onUpdateTask={onUpdateTask}
|
onUpdateTask={onUpdateTask}
|
||||||
|
onRetryTask={onRetryTask}
|
||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ function readSelectedTaskIds(projectId?: string): Set<string> {
|
|||||||
interface ListViewProps {
|
interface ListViewProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||||
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType) => void;
|
||||||
globalPaused?: boolean;
|
globalPaused?: boolean;
|
||||||
|
|||||||
@@ -972,7 +972,7 @@ export function QuickChatFAB({
|
|||||||
return "Select a model to start chatting";
|
return "Select a model to start chatting";
|
||||||
}, [chatMode, selectedAgent, selectedModelTag]);
|
}, [chatMode, selectedAgent, selectedModelTag]);
|
||||||
|
|
||||||
const inputDisabled = !hasChatTarget || !activeSession || sessionsLoading;
|
const inputDisabled = !hasChatTarget || !activeSession;
|
||||||
|
|
||||||
const pendingPreview = pendingMessage.length > 50
|
const pendingPreview = pendingMessage.length > 50
|
||||||
? `${pendingMessage.slice(0, 50)}…`
|
? `${pendingMessage.slice(0, 50)}…`
|
||||||
|
|||||||
@@ -246,12 +246,12 @@
|
|||||||
.card-error {
|
.card-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 6px;
|
gap: var(--space-xs);
|
||||||
margin: 6px 0;
|
margin: var(--space-xs) 0;
|
||||||
padding: 6px var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
background: rgba(218, 54, 51, 0.1);
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error-dark) 25%, transparent);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--color-error-dark);
|
color: var(--color-error-dark);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -266,6 +266,38 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-error-retry-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: var(--space-xs) calc(var(--space-sm) - (var(--space-xs) / 2));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error-dark) 20%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
|
color: var(--color-error-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-error-retry-btn:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--color-error) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-error-retry-btn:focus-visible {
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-error-retry-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-error-retry-btn {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Awaiting manual plan approval: amber left border + subtle pulsing background to signal human review needed.
|
/* Awaiting manual plan approval: amber left border + subtle pulsing background to signal human review needed.
|
||||||
Distinct from agent-active (purple glow), failed (red left border + error block), and paused (dimmed opacity). */
|
Distinct from agent-active (purple glow), failed (red left border + error block), and paused (dimmed opacity). */
|
||||||
.card.awaiting-approval {
|
.card.awaiting-approval {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./TaskCard.css";
|
import "./TaskCard.css";
|
||||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2 } from "lucide-react";
|
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw } from "lucide-react";
|
||||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority } from "@fusion/core";
|
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority } from "@fusion/core";
|
||||||
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
||||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||||
@@ -184,6 +184,7 @@ interface TaskCardProps {
|
|||||||
onArchiveTask?: (id: string) => Promise<Task>;
|
onArchiveTask?: (id: string) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||||
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
|
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
|
||||||
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
@@ -326,6 +327,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previous.onArchiveTask === next.onArchiveTask &&
|
previous.onArchiveTask === next.onArchiveTask &&
|
||||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||||
previous.onDeleteTask === next.onDeleteTask &&
|
previous.onDeleteTask === next.onDeleteTask &&
|
||||||
|
previous.onRetryTask === next.onRetryTask &&
|
||||||
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
||||||
previous.onOpenMission === next.onOpenMission &&
|
previous.onOpenMission === next.onOpenMission &&
|
||||||
previous.onMoveTask === next.onMoveTask &&
|
previous.onMoveTask === next.onMoveTask &&
|
||||||
@@ -380,6 +382,7 @@ function TaskCardComponent({
|
|||||||
onArchiveTask,
|
onArchiveTask,
|
||||||
onUnarchiveTask,
|
onUnarchiveTask,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
|
onRetryTask,
|
||||||
onOpenDetailWithTab,
|
onOpenDetailWithTab,
|
||||||
taskStuckTimeoutMs,
|
taskStuckTimeoutMs,
|
||||||
onOpenMission,
|
onOpenMission,
|
||||||
@@ -399,6 +402,7 @@ function TaskCardComponent({
|
|||||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||||
const [agentName, setAgentName] = useState<string | null>(null);
|
const [agentName, setAgentName] = useState<string | null>(null);
|
||||||
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
|
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -985,6 +989,20 @@ function TaskCardComponent({
|
|||||||
});
|
});
|
||||||
}, [addToast, onMoveTask, task.id]);
|
}, [addToast, onMoveTask, task.id]);
|
||||||
|
|
||||||
|
const handleRetryTask = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!onRetryTask || isRetrying) return;
|
||||||
|
|
||||||
|
setIsRetrying(true);
|
||||||
|
try {
|
||||||
|
await onRetryTask(task.id);
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to retry ${task.id}: ${getErrorMessage(err)}`, "error");
|
||||||
|
} finally {
|
||||||
|
setIsRetrying(false);
|
||||||
|
}
|
||||||
|
}, [addToast, isRetrying, onRetryTask, task.id]);
|
||||||
|
|
||||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
||||||
|
|
||||||
const filesChangedButton = (() => {
|
const filesChangedButton = (() => {
|
||||||
@@ -1275,6 +1293,17 @@ function TaskCardComponent({
|
|||||||
<div className="card-error" title={task.error}>
|
<div className="card-error" title={task.error}>
|
||||||
<span className="card-error-icon">⚠</span>
|
<span className="card-error-icon">⚠</span>
|
||||||
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
||||||
|
{onRetryTask && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm card-error-retry-btn"
|
||||||
|
onClick={handleRetryTask}
|
||||||
|
disabled={isRetrying}
|
||||||
|
>
|
||||||
|
<RotateCw size={12} />
|
||||||
|
{isRetrying ? "Retrying…" : "Retry"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="card-title" title={task.title || task.description || undefined}>
|
<div className="card-title" title={task.title || task.description || undefined}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface WorktreeGroupProps {
|
|||||||
id: string,
|
id: string,
|
||||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||||
) => Promise<Task>;
|
) => Promise<Task>;
|
||||||
|
onRetryTask?: (id: string) => Promise<Task>;
|
||||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
@@ -36,6 +37,7 @@ function WorktreeGroupComponent({
|
|||||||
addToast,
|
addToast,
|
||||||
globalPaused,
|
globalPaused,
|
||||||
onUpdateTask,
|
onUpdateTask,
|
||||||
|
onRetryTask,
|
||||||
onOpenDetailWithTab,
|
onOpenDetailWithTab,
|
||||||
taskStuckTimeoutMs,
|
taskStuckTimeoutMs,
|
||||||
onOpenMission,
|
onOpenMission,
|
||||||
@@ -51,7 +53,7 @@ function WorktreeGroupComponent({
|
|||||||
<span className="worktree-label">{label}</span>
|
<span className="worktree-label">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{activeTasks.map((task) => (
|
{activeTasks.map((task) => (
|
||||||
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} />
|
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} />
|
||||||
))}
|
))}
|
||||||
{queuedTasks.map((task) => (
|
{queuedTasks.map((task) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -63,6 +65,7 @@ function WorktreeGroupComponent({
|
|||||||
addToast={addToast}
|
addToast={addToast}
|
||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
onUpdateTask={onUpdateTask}
|
onUpdateTask={onUpdateTask}
|
||||||
|
onRetryTask={onRetryTask}
|
||||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
onOpenMission={onOpenMission}
|
onOpenMission={onOpenMission}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
Target: () => null,
|
Target: () => null,
|
||||||
Bot: () => null,
|
Bot: () => null,
|
||||||
Trash2: () => null,
|
Trash2: () => null,
|
||||||
|
RotateCw: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../ProviderIcon", () => ({
|
vi.mock("../ProviderIcon", () => ({
|
||||||
@@ -119,6 +120,79 @@ describe("TaskCard", () => {
|
|||||||
expect(container.querySelector(".card-execution-mode-badge")?.textContent).toBe("Fast");
|
expect(container.querySelector(".card-execution-mode-badge")?.textContent).toBe("Fast");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("retry button on failed tasks", () => {
|
||||||
|
it("renders when task is failed and onRetryTask is provided", () => {
|
||||||
|
const onRetryTask = vi.fn(async () => ({}) as Task);
|
||||||
|
render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "todo", status: "failed", error: "Executor crashed" })}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
onRetryTask={onRetryTask}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render for non-failed tasks", () => {
|
||||||
|
const onRetryTask = vi.fn(async () => ({}) as Task);
|
||||||
|
render(
|
||||||
|
<TaskCard task={makeTask({ column: "todo", status: "done", error: "Executor crashed" })} onOpenDetail={noop} addToast={noop} onRetryTask={onRetryTask} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRetryTask with task id", async () => {
|
||||||
|
const onRetryTask = vi.fn(async () => ({}) as Task);
|
||||||
|
render(
|
||||||
|
<TaskCard task={makeTask({ column: "todo", status: "failed", error: "Executor crashed" })} onOpenDetail={noop} addToast={noop} onRetryTask={onRetryTask} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
await waitFor(() => expect(onRetryTask).toHaveBeenCalledWith("FN-001"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading and disabled state while retry is in progress", async () => {
|
||||||
|
let resolveRetry: ((value: Task) => void) | null = null;
|
||||||
|
const onRetryTask = vi.fn(() => new Promise<Task>((resolve) => { resolveRetry = resolve; }));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard task={makeTask({ column: "todo", status: "failed", error: "Executor crashed" })} onOpenDetail={noop} addToast={noop} onRetryTask={onRetryTask} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "Retry" }) as HTMLButtonElement;
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Retrying…" })).toBeDefined();
|
||||||
|
expect(button.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveRetry?.({} as Task);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Retry" })).toBeDefined());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toast when retry fails", async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
const onRetryTask = vi.fn(async () => {
|
||||||
|
throw new Error("network down");
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskCard task={makeTask({ column: "todo", status: "failed", error: "Executor crashed" })} onOpenDetail={noop} addToast={addToast} onRetryTask={onRetryTask} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Failed to retry FN-001: network down", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders unified progress counts for task steps + workflow checks", () => {
|
it("renders unified progress counts for task steps + workflow checks", () => {
|
||||||
render(
|
render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
|
|||||||
@@ -295,8 +295,9 @@ export function useQuickChat(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear old messages immediately so stale conversation doesn't briefly flash
|
// Clear old session/messages immediately so stale conversation doesn't briefly flash
|
||||||
// while the new session loads
|
// and input remains disabled until the new target session is ready.
|
||||||
|
setActiveSession(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
|
||||||
// New chat target — initialize session
|
// New chat target — initialize session
|
||||||
@@ -331,6 +332,7 @@ export function useQuickChat(
|
|||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setActiveSession(null);
|
||||||
|
|
||||||
setSessionsLoading(true);
|
setSessionsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user