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:
@@ -74,7 +74,7 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
|
||||
**Statistics Displayed**:
|
||||
- **Running**: Count of tasks currently in "in-progress" column with pulsing animation when > 0
|
||||
- **Blocked**: Count of tasks with `blockedBy` field set (a single task ID string indicating file-overlap blocking)
|
||||
- **Stuck**: Count of tasks in "in-progress" with no activity for > 10 minutes (shown only when > 0)
|
||||
- **Stuck**: Count of tasks in "in-progress" with no activity for longer than the project's `taskStuckTimeoutMs` setting (shown only when > 0 and the setting is enabled). Uses the same `isTaskStuck()` predicate as task cards and list rows, so the footer count always matches the visible stuck indicators on the board
|
||||
- **Queued**: Count of tasks in "todo" column
|
||||
- **In Review**: Count of tasks in "in-review" column
|
||||
- **Executor State**: Current state badge (Idle/Running/Paused)
|
||||
@@ -86,7 +86,7 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
|
||||
- **Running**: Engine active with running tasks
|
||||
|
||||
**Features**:
|
||||
- **Shared task list**: Task counts are derived from the same task list used by the board and list views, so the footer always matches the board state exactly
|
||||
- **Shared task list**: Task counts are derived from the same task list used by the board and list views, so the footer always matches the board state exactly. Stuck task detection uses a shared `isTaskStuck()` utility (see `utils/taskStuck.ts`) so the footer count and individual card/row indicators are always consistent
|
||||
- **Footer-safe layout**: Project-view content (board, list view, agents view) automatically reserves space for the fixed footer using a CSS custom property (`--executor-footer-height`). The `project-content--with-footer` wrapper class sets this token to 36px on desktop and 32px on mobile, ensuring all content remains fully visible and scrollable above the status bar
|
||||
- Real-time updates via 5-second polling for executor state (globalPause, enginePaused, maxConcurrent)
|
||||
- Responsive design: collapses labels on mobile screens (<768px); footer height reduces from 36px to 32px
|
||||
@@ -97,6 +97,12 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
|
||||
**API Endpoint**:
|
||||
- `GET /api/executor/stats` - Returns `globalPause`, `enginePaused`, `maxConcurrent`, and `lastActivityAt` for state derivation. Column-based counts (running, blocked, stuck, queued, in-review) are derived client-side from the shared task list.
|
||||
|
||||
**Stuck Task Indicators**:
|
||||
When `taskStuckTimeoutMs` is configured in project settings, stuck tasks are visually labeled on both board cards and list rows using the same `isTaskStuck()` predicate as the footer count:
|
||||
- **Board cards**: A pulsing amber "Stuck" badge replaces the normal status badge, and the card gets a left border highlight
|
||||
- **List rows**: A "Stuck" label appears in the status cell, and the row gets a left border highlight
|
||||
- **Consistency**: The footer stuck count, card stuck badge, and list stuck label all use the identical `isTaskStuck(task, taskStuckTimeoutMs)` check from `utils/taskStuck.ts`, so the footer count is always explainable by counting visible stuck indicators
|
||||
|
||||
### Agents View
|
||||
Manage AI agents with a dedicated control surface accessible from the main dashboard navigation. All agent surfaces (AgentsView, AgentListModal, AgentDetailView) share consistent token-based styling using dashboard design tokens (`--surface`, `--card`, `--border`, `--text`, `--color-success`, `--color-error`, etc.) and locally defined state color tokens (`--state-idle-*`, `--state-active-*`, `--state-paused-*`, `--state-error-*`) for theme-aware rendering.
|
||||
|
||||
|
||||
@@ -561,6 +561,7 @@ function AppInner() {
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -583,6 +584,7 @@ function AppInner() {
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -934,6 +934,26 @@ body {
|
||||
color: var(--color-error-dark);
|
||||
}
|
||||
|
||||
.card-status-badge.stuck {
|
||||
background: rgba(210, 153, 34, 0.2);
|
||||
color: var(--triage);
|
||||
animation: stuck-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.card.stuck {
|
||||
border-left: 3px solid var(--triage);
|
||||
background: rgba(210, 153, 34, 0.06);
|
||||
}
|
||||
|
||||
@keyframes stuck-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
/* Size badge: positioned in card header, subtle color coding for effort estimation */
|
||||
.card-size-badge {
|
||||
display: inline-flex;
|
||||
@@ -6421,6 +6441,11 @@ body {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.list-row.stuck {
|
||||
border-left: 3px solid var(--triage);
|
||||
background: rgba(210, 153, 34, 0.06);
|
||||
}
|
||||
|
||||
.list-row.agent-active {
|
||||
border-left: 3px solid var(--in-progress);
|
||||
animation: list-agent-glow 2.5s ease-in-out infinite;
|
||||
@@ -6488,6 +6513,12 @@ body {
|
||||
color: var(--color-error-dark);
|
||||
}
|
||||
|
||||
.list-status-badge.stuck {
|
||||
background: rgba(210, 153, 34, 0.2);
|
||||
color: var(--triage);
|
||||
animation: stuck-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.list-status-badge.pulsing {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
142
packages/dashboard/app/utils/taskStuck.test.ts
Normal file
142
packages/dashboard/app/utils/taskStuck.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { isTaskStuck, countStuckTasks } from "./taskStuck";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
const createTask = (overrides: Partial<Task> = {}): Task =>
|
||||
({
|
||||
id: "FN-001",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
}) as Task;
|
||||
|
||||
describe("isTaskStuck", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns false when timeout is undefined (disabled)", () => {
|
||||
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
||||
expect(isTaskStuck(task, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when timeout is 0", () => {
|
||||
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
||||
expect(isTaskStuck(task, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when timeout is negative", () => {
|
||||
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
|
||||
expect(isTaskStuck(task, -1)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-in-progress tasks", () => {
|
||||
const task = createTask({ column: "todo", updatedAt: "2026-04-04T06:00:00Z" });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for recent in-progress tasks within timeout", () => {
|
||||
const recent = new Date(Date.now() - 300000).toISOString(); // 5 minutes ago
|
||||
const task = createTask({ updatedAt: recent });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false); // 10 minute timeout
|
||||
});
|
||||
|
||||
it("returns true for stale in-progress tasks exceeding timeout", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString(); // just over 10 minutes
|
||||
const task = createTask({ updatedAt: stale });
|
||||
expect(isTaskStuck(task, 600000)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for malformed updatedAt", () => {
|
||||
const task = createTask({ updatedAt: "not-a-date" });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty updatedAt", () => {
|
||||
const task = createTask({ updatedAt: "" });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles tasks in triage column", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const task = createTask({ column: "triage", updatedAt: stale });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles tasks in done column", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const task = createTask({ column: "done", updatedAt: stale });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true exactly at timeout boundary (greater than)", () => {
|
||||
const boundary = new Date(Date.now() - 600001).toISOString();
|
||||
const task = createTask({ updatedAt: boundary });
|
||||
expect(isTaskStuck(task, 600000)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false exactly at timeout boundary (equal)", () => {
|
||||
const boundary = new Date(Date.now() - 600000).toISOString();
|
||||
const task = createTask({ updatedAt: boundary });
|
||||
expect(isTaskStuck(task, 600000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countStuckTasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns 0 when timeout is undefined", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const tasks = [createTask({ updatedAt: stale })];
|
||||
expect(countStuckTasks(tasks, undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when timeout is 0", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const tasks = [createTask({ updatedAt: stale })];
|
||||
expect(countStuckTasks(tasks, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts only stuck tasks", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const recent = new Date(Date.now() - 300000).toISOString();
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", updatedAt: stale }), // stuck
|
||||
createTask({ id: "FN-002", updatedAt: recent }), // not stuck
|
||||
createTask({ id: "FN-003", column: "todo", updatedAt: stale }), // not in-progress
|
||||
];
|
||||
expect(countStuckTasks(tasks, 600000)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for empty task list", () => {
|
||||
expect(countStuckTasks([], 600000)).toBe(0);
|
||||
});
|
||||
|
||||
it("counts multiple stuck tasks", () => {
|
||||
const stale = new Date(Date.now() - 600001).toISOString();
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", updatedAt: stale }),
|
||||
createTask({ id: "FN-002", updatedAt: stale }),
|
||||
];
|
||||
expect(countStuckTasks(tasks, 600000)).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user