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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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