feat(FN-822): add visible stuck indicators on board and list with unified detection
- Unify stuck-task derivation using project settings (taskStuckTimeoutMs) instead of hardcoded values - Thread taskStuckTimeoutMs through Board, Column, WorktreeGroup, and TaskCard components - Add visual stuck indicator with pulsing animation on in-progress task cards - Resolve merge conflicts with isSearchActive prop from main (both props coexist) - Add taskStuck utility unit tests and update README with stuck indicator docs
This commit is contained in:
@@ -40,6 +40,8 @@ interface BoardProps {
|
||||
favoriteModels?: string[];
|
||||
onToggleFavorite?: (provider: string) => void;
|
||||
onToggleModelFavorite?: (modelId: string) => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
}
|
||||
|
||||
function sortTasksForColumn(tasks: Task[]): Task[] {
|
||||
@@ -58,7 +60,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite }: BoardProps) {
|
||||
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -175,6 +177,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
isSearchActive={isSearchActive}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
|
||||
@@ -53,9 +53,11 @@ interface ColumnProps {
|
||||
onToggleModelFavorite?: (modelId: string) => void;
|
||||
/** When true, search is active — bypass pagination so all matching tasks are visible. */
|
||||
isSearchActive?: boolean;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
@@ -225,6 +227,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -244,6 +247,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
/>
|
||||
))}
|
||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -56,6 +57,8 @@ interface ListViewProps {
|
||||
projectId?: string;
|
||||
/** Project name for display (optional) */
|
||||
projectName?: string;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
}
|
||||
|
||||
function getStepProgress(steps: TaskStep[]): string {
|
||||
@@ -88,6 +91,7 @@ export function ListView({
|
||||
onTasksUpdated,
|
||||
projectId,
|
||||
projectName,
|
||||
taskStuckTimeoutMs,
|
||||
}: ListViewProps) {
|
||||
const [sortField, setSortField] = useState<SortField>("id");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
@@ -849,10 +853,12 @@ export function ListView({
|
||||
columnTasks.map((task) => {
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs);
|
||||
const isAgentActive =
|
||||
!globalPaused &&
|
||||
!isFailed &&
|
||||
!isPaused &&
|
||||
!isStuckState &&
|
||||
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
|
||||
@@ -860,8 +866,10 @@ export function ListView({
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
|
||||
isAgentActive ? " agent-active" : ""
|
||||
}${isDragging ? " dragging" : ""}`}
|
||||
isStuckState ? " stuck" : ""
|
||||
}${isAgentActive ? " agent-active" : ""}${
|
||||
isDragging ? " dragging" : ""
|
||||
}`}
|
||||
onClick={() => handleRowClick(task)}
|
||||
draggable={!isPaused}
|
||||
onDragStart={(e) => handleDragStart(e, task)}
|
||||
@@ -891,7 +899,11 @@ export function ListView({
|
||||
)}
|
||||
{visibleColumns.has("status") && (
|
||||
<td className="list-cell">
|
||||
{task.status ? (
|
||||
{isStuckState ? (
|
||||
<span className="list-status-badge stuck">
|
||||
Stuck
|
||||
</span>
|
||||
) : task.status ? (
|
||||
<span
|
||||
className={`list-status-badge${isFailed ? " failed" : ""}${
|
||||
isAgentActive ? " pulsing" : ""
|
||||
|
||||
@@ -7,6 +7,7 @@ import { pickPreferredBadge } from "./TaskCardBadge";
|
||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||
import { useSessionFiles } from "../hooks/useSessionFiles";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -45,6 +46,8 @@ interface TaskCardProps {
|
||||
onArchiveTask?: (id: string) => Promise<Task>;
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
}
|
||||
|
||||
function areTaskBadgeInfosEqual(
|
||||
@@ -82,6 +85,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previous.queued === next.queued &&
|
||||
previous.projectId === next.projectId &&
|
||||
previous.globalPaused === next.globalPaused &&
|
||||
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
|
||||
previous.onOpenDetail === next.onOpenDetail &&
|
||||
previous.addToast === next.addToast &&
|
||||
previous.onUpdateTask === next.onUpdateTask &&
|
||||
@@ -130,6 +134,7 @@ function TaskCardComponent({
|
||||
onArchiveTask,
|
||||
onUnarchiveTask,
|
||||
onOpenFilesForTask,
|
||||
taskStuckTimeoutMs,
|
||||
}: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
@@ -315,9 +320,10 @@ function TaskCardComponent({
|
||||
|
||||
const isFailed = task.status === "failed";
|
||||
const isPaused = task.paused === true;
|
||||
const isStuck = isTaskStuck(task, taskStuckTimeoutMs);
|
||||
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
||||
const isArchived = task.column === "archived";
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived
|
||||
|
||||
// Check if this card can be edited inline
|
||||
@@ -498,7 +504,7 @@ function TaskCardComponent({
|
||||
setShowSteps((current) => !current);
|
||||
}, []);
|
||||
|
||||
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${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" : ""}`;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
@@ -560,7 +566,7 @@ function TaskCardComponent({
|
||||
)}
|
||||
{!isPaused && task.status && task.status !== "queued" && (
|
||||
<span
|
||||
className={`card-status-badge${isAwaitingApproval ? " awaiting-approval" : ""}${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}${isFailed ? " failed" : ""}`}
|
||||
className={`card-status-badge${isAwaitingApproval ? " awaiting-approval" : ""}${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}${isFailed ? " failed" : ""}${isStuck ? " stuck" : ""}`}
|
||||
style={isAwaitingApproval
|
||||
? { background: "rgba(210,153,34,0.2)", color: "var(--triage)" }
|
||||
: isFailed
|
||||
@@ -568,7 +574,12 @@ function TaskCardComponent({
|
||||
: { background: COLUMN_COLOR_MAP[task.column], color: COLUMN_TEXT_COLOR_MAP[task.column] }
|
||||
}
|
||||
>
|
||||
{isAwaitingApproval ? "Awaiting Approval" : task.status}
|
||||
{isStuck ? "Stuck" : isAwaitingApproval ? "Awaiting Approval" : task.status}
|
||||
</span>
|
||||
)}
|
||||
{isStuck && (isPaused || !task.status || task.status === "queued") && (
|
||||
<span className="card-status-badge stuck">
|
||||
Stuck
|
||||
</span>
|
||||
)}
|
||||
{hasGitHubBadge && (
|
||||
|
||||
@@ -17,6 +17,8 @@ interface WorktreeGroupProps {
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
}
|
||||
|
||||
function WorktreeGroupComponent({
|
||||
@@ -29,6 +31,7 @@ function WorktreeGroupComponent({
|
||||
globalPaused,
|
||||
onUpdateTask,
|
||||
onOpenFilesForTask,
|
||||
taskStuckTimeoutMs,
|
||||
}: WorktreeGroupProps) {
|
||||
return (
|
||||
<div className="worktree-group">
|
||||
@@ -39,7 +42,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} onOpenFilesForTask={onOpenFilesForTask} />
|
||||
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onOpenFilesForTask={onOpenFilesForTask} taskStuckTimeoutMs={taskStuckTimeoutMs} />
|
||||
))}
|
||||
{queuedTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -52,6 +55,7 @@ function WorktreeGroupComponent({
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onOpenFilesForTask={onOpenFilesForTask}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -316,6 +316,63 @@ describe("ListView", () => {
|
||||
expect(row?.className).not.toContain("agent-active");
|
||||
});
|
||||
|
||||
it("renders stuck indicator when task is stuck and timeout is set", () => {
|
||||
const staleTime = new Date(Date.now() - 600000).toISOString();
|
||||
const tasks = [
|
||||
createMockTask({
|
||||
id: "FN-001",
|
||||
status: "executing",
|
||||
column: "in-progress",
|
||||
updatedAt: staleTime,
|
||||
}),
|
||||
];
|
||||
|
||||
renderListView({ tasks, taskStuckTimeoutMs: 600000 });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr");
|
||||
expect(row?.className).toContain("stuck");
|
||||
|
||||
const statusBadge = screen.getByText("Stuck");
|
||||
expect(statusBadge.className).toContain("stuck");
|
||||
});
|
||||
|
||||
it("does not render stuck indicator when taskStuckTimeoutMs is undefined", () => {
|
||||
const staleTime = new Date(Date.now() - 600000).toISOString();
|
||||
const tasks = [
|
||||
createMockTask({
|
||||
id: "FN-001",
|
||||
status: "executing",
|
||||
column: "in-progress",
|
||||
updatedAt: staleTime,
|
||||
}),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr");
|
||||
expect(row?.className).not.toContain("stuck");
|
||||
expect(screen.getByText("executing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stuck indicator takes precedence over agent-active", () => {
|
||||
const staleTime = new Date(Date.now() - 600000).toISOString();
|
||||
const tasks = [
|
||||
createMockTask({
|
||||
id: "FN-001",
|
||||
status: "executing",
|
||||
column: "in-progress",
|
||||
updatedAt: staleTime,
|
||||
}),
|
||||
];
|
||||
|
||||
renderListView({ tasks, taskStuckTimeoutMs: 600000, globalPaused: false });
|
||||
|
||||
const row = screen.getByText("FN-001").closest("tr");
|
||||
expect(row?.className).toContain("stuck");
|
||||
expect(row?.className).not.toContain("agent-active");
|
||||
expect(screen.getByText("Stuck")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders column badges with correct colors", () => {
|
||||
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ beforeEach(() => {
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
||||
|
||||
/** Mirrors the cardClass computation from TaskCard.tsx */
|
||||
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column; globalPaused?: boolean }): string {
|
||||
const { dragging = false, queued = false, status, column = "todo", globalPaused } = opts;
|
||||
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column; globalPaused?: boolean; isStuck?: boolean; isPaused?: boolean; isAwaitingApproval?: boolean }): string {
|
||||
const { dragging = false, queued = false, status, column = "todo", globalPaused, isStuck = false, isPaused = false, isAwaitingApproval = false } = opts;
|
||||
const isFailed = status === "failed";
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
|
||||
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
|
||||
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}`;
|
||||
}
|
||||
|
||||
describe("TaskCard memoization", () => {
|
||||
@@ -418,6 +418,36 @@ describe("TaskCard failed status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard stuck status", () => {
|
||||
it("applies 'stuck' class to card when task is stuck", () => {
|
||||
const cls = computeCardClass({ isStuck: true, column: "in-progress", status: "executing" });
|
||||
expect(cls).toContain("stuck");
|
||||
});
|
||||
|
||||
it("does NOT apply 'stuck' class when task is not stuck", () => {
|
||||
const cls = computeCardClass({ column: "in-progress", status: "executing" });
|
||||
expect(cls).not.toContain("stuck");
|
||||
});
|
||||
|
||||
it("stuck takes precedence over agent-active", () => {
|
||||
const cls = computeCardClass({ isStuck: true, column: "in-progress", status: "executing" });
|
||||
expect(cls).toContain("stuck");
|
||||
expect(cls).not.toContain("agent-active");
|
||||
});
|
||||
|
||||
it("stuck and failed can coexist (stuck appears in class list)", () => {
|
||||
const cls = computeCardClass({ isStuck: true, status: "failed", column: "in-progress" });
|
||||
expect(cls).toContain("stuck");
|
||||
expect(cls).toContain("failed");
|
||||
});
|
||||
|
||||
it("stuck and paused can coexist", () => {
|
||||
const cls = computeCardClass({ isStuck: true, isPaused: true, column: "in-progress" });
|
||||
expect(cls).toContain("stuck");
|
||||
expect(cls).toContain("paused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard dependency tooltip", () => {
|
||||
/** Mirrors the data-tooltip computation from TaskCard.tsx */
|
||||
function computeDepTooltip(dependencies: string[]): string | undefined {
|
||||
|
||||
Reference in New Issue
Block a user