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:
Fusion
2026-04-28 09:37:23 -07:00
committed by gsxdsm
parent 6b7d151f5f
commit 7227b8786b
11 changed files with 166 additions and 13 deletions

View File

@@ -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}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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)}`

View File

@@ -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 {

View File

@@ -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}>

View File

@@ -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}

View File

@@ -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