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:
gsxdsm
2026-04-04 11:31:22 -07:00
parent 9cf5528bc8
commit 039633ab9e
11 changed files with 318 additions and 16 deletions

View File

@@ -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 } : {})}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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