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:
@@ -23,6 +23,7 @@ interface BoardProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask?: (id: string) => Promise<Task>;
|
||||
@@ -78,7 +79,7 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
|
||||
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 archivedLoadedRef = useRef(false);
|
||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||
@@ -207,6 +208,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onRetryTask={onRetryTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
|
||||
@@ -34,6 +34,7 @@ interface ColumnProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask?: (id: string) => Promise<Task>;
|
||||
@@ -67,7 +68,7 @@ interface ColumnProps {
|
||||
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 [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -422,6 +423,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onRetryTask={onRetryTask}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
@@ -443,6 +445,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onRetryTask={onRetryTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
|
||||
@@ -118,6 +118,7 @@ function readSelectedTaskIds(projectId?: string): Set<string> {
|
||||
interface ListViewProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetail: (task: Task | TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
|
||||
@@ -972,7 +972,7 @@ export function QuickChatFAB({
|
||||
return "Select a model to start chatting";
|
||||
}, [chatMode, selectedAgent, selectedModelTag]);
|
||||
|
||||
const inputDisabled = !hasChatTarget || !activeSession || sessionsLoading;
|
||||
const inputDisabled = !hasChatTarget || !activeSession;
|
||||
|
||||
const pendingPreview = pendingMessage.length > 50
|
||||
? `${pendingMessage.slice(0, 50)}…`
|
||||
|
||||
@@ -246,12 +246,12 @@
|
||||
.card-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin: 6px 0;
|
||||
padding: 6px var(--space-sm);
|
||||
background: rgba(218, 54, 51, 0.1);
|
||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
||||
border-radius: var(--radius);
|
||||
gap: var(--space-xs);
|
||||
margin: var(--space-xs) 0;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-error-dark) 25%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--color-error-dark);
|
||||
line-height: 1.4;
|
||||
@@ -266,6 +266,38 @@
|
||||
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.
|
||||
Distinct from agent-active (purple glow), failed (red left border + error block), and paused (dimmed opacity). */
|
||||
.card.awaiting-approval {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./TaskCard.css";
|
||||
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 { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
|
||||
@@ -184,6 +184,7 @@ interface TaskCardProps {
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
@@ -326,6 +327,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.onArchiveTask === next.onArchiveTask &&
|
||||
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
||||
previous.onDeleteTask === next.onDeleteTask &&
|
||||
previous.onRetryTask === next.onRetryTask &&
|
||||
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
|
||||
previous.onOpenMission === next.onOpenMission &&
|
||||
previous.onMoveTask === next.onMoveTask &&
|
||||
@@ -380,6 +382,7 @@ function TaskCardComponent({
|
||||
onArchiveTask,
|
||||
onUnarchiveTask,
|
||||
onDeleteTask,
|
||||
onRetryTask,
|
||||
onOpenDetailWithTab,
|
||||
taskStuckTimeoutMs,
|
||||
onOpenMission,
|
||||
@@ -399,6 +402,7 @@ function TaskCardComponent({
|
||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||
const [agentName, setAgentName] = useState<string | null>(null);
|
||||
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [timeIndicatorNowMs, setTimeIndicatorNowMs] = useState(() => Date.now());
|
||||
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -985,6 +989,20 @@ function TaskCardComponent({
|
||||
});
|
||||
}, [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 filesChangedButton = (() => {
|
||||
@@ -1275,6 +1293,17 @@ function TaskCardComponent({
|
||||
<div className="card-error" title={task.error}>
|
||||
<span className="card-error-icon">⚠</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 className="card-title" title={task.title || task.description || undefined}>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface WorktreeGroupProps {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
@@ -36,6 +37,7 @@ function WorktreeGroupComponent({
|
||||
addToast,
|
||||
globalPaused,
|
||||
onUpdateTask,
|
||||
onRetryTask,
|
||||
onOpenDetailWithTab,
|
||||
taskStuckTimeoutMs,
|
||||
onOpenMission,
|
||||
@@ -51,7 +53,7 @@ function WorktreeGroupComponent({
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
{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) => (
|
||||
<TaskCard
|
||||
@@ -63,6 +65,7 @@ function WorktreeGroupComponent({
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onRetryTask={onRetryTask}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
onOpenMission={onOpenMission}
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("lucide-react", () => ({
|
||||
Target: () => null,
|
||||
Bot: () => null,
|
||||
Trash2: () => null,
|
||||
RotateCw: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
@@ -119,6 +120,79 @@ describe("TaskCard", () => {
|
||||
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", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
|
||||
Reference in New Issue
Block a user