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**:
|
**Statistics Displayed**:
|
||||||
- **Running**: Count of tasks currently in "in-progress" column with pulsing animation when > 0
|
- **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)
|
- **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
|
- **Queued**: Count of tasks in "todo" column
|
||||||
- **In Review**: Count of tasks in "in-review" column
|
- **In Review**: Count of tasks in "in-review" column
|
||||||
- **Executor State**: Current state badge (Idle/Running/Paused)
|
- **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
|
- **Running**: Engine active with running tasks
|
||||||
|
|
||||||
**Features**:
|
**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
|
- **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)
|
- 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
|
- 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**:
|
**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.
|
- `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
|
### 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.
|
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}
|
favoriteModels={favoriteModels}
|
||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onToggleModelFavorite={handleToggleModelFavorite}
|
onToggleModelFavorite={handleToggleModelFavorite}
|
||||||
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -583,6 +584,7 @@ function AppInner() {
|
|||||||
favoriteModels={favoriteModels}
|
favoriteModels={favoriteModels}
|
||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onToggleModelFavorite={handleToggleModelFavorite}
|
onToggleModelFavorite={handleToggleModelFavorite}
|
||||||
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ interface BoardProps {
|
|||||||
favoriteModels?: string[];
|
favoriteModels?: string[];
|
||||||
onToggleFavorite?: (provider: string) => void;
|
onToggleFavorite?: (provider: string) => void;
|
||||||
onToggleModelFavorite?: (modelId: string) => void;
|
onToggleModelFavorite?: (modelId: string) => void;
|
||||||
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
|
taskStuckTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTasksForColumn(tasks: Task[]): Task[] {
|
function sortTasksForColumn(tasks: Task[]): Task[] {
|
||||||
@@ -58,7 +60,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
|||||||
return previous.every((task, index) => task === next[index]);
|
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 [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -175,6 +177,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
|||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleModelFavorite={onToggleModelFavorite}
|
onToggleModelFavorite={onToggleModelFavorite}
|
||||||
isSearchActive={isSearchActive}
|
isSearchActive={isSearchActive}
|
||||||
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ interface ColumnProps {
|
|||||||
onToggleModelFavorite?: (modelId: string) => void;
|
onToggleModelFavorite?: (modelId: string) => void;
|
||||||
/** When true, search is active — bypass pagination so all matching tasks are visible. */
|
/** When true, search is active — bypass pagination so all matching tasks are visible. */
|
||||||
isSearchActive?: boolean;
|
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 [dragOver, setDragOver] = useState(false);
|
||||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||||
@@ -225,6 +227,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
onUpdateTask={onUpdateTask}
|
onUpdateTask={onUpdateTask}
|
||||||
onOpenFilesForTask={onOpenFilesForTask}
|
onOpenFilesForTask={onOpenFilesForTask}
|
||||||
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
@@ -244,6 +247,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
|||||||
onArchiveTask={onArchiveTask}
|
onArchiveTask={onArchiveTask}
|
||||||
onUnarchiveTask={onUnarchiveTask}
|
onUnarchiveTask={onUnarchiveTask}
|
||||||
onOpenFilesForTask={onOpenFilesForTask}
|
onOpenFilesForTask={onOpenFilesForTask}
|
||||||
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
|
|||||||
import type { ModelInfo } from "../api";
|
import type { ModelInfo } from "../api";
|
||||||
import { QuickEntryBox } from "./QuickEntryBox";
|
import { QuickEntryBox } from "./QuickEntryBox";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
import { isTaskStuck } from "../utils/taskStuck";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||||
@@ -56,6 +57,8 @@ interface ListViewProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
/** Project name for display (optional) */
|
/** Project name for display (optional) */
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
|
taskStuckTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStepProgress(steps: TaskStep[]): string {
|
function getStepProgress(steps: TaskStep[]): string {
|
||||||
@@ -88,6 +91,7 @@ export function ListView({
|
|||||||
onTasksUpdated,
|
onTasksUpdated,
|
||||||
projectId,
|
projectId,
|
||||||
projectName,
|
projectName,
|
||||||
|
taskStuckTimeoutMs,
|
||||||
}: ListViewProps) {
|
}: ListViewProps) {
|
||||||
const [sortField, setSortField] = useState<SortField>("id");
|
const [sortField, setSortField] = useState<SortField>("id");
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||||
@@ -849,10 +853,12 @@ export function ListView({
|
|||||||
columnTasks.map((task) => {
|
columnTasks.map((task) => {
|
||||||
const isFailed = task.status === "failed";
|
const isFailed = task.status === "failed";
|
||||||
const isPaused = task.paused === true;
|
const isPaused = task.paused === true;
|
||||||
|
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs);
|
||||||
const isAgentActive =
|
const isAgentActive =
|
||||||
!globalPaused &&
|
!globalPaused &&
|
||||||
!isFailed &&
|
!isFailed &&
|
||||||
!isPaused &&
|
!isPaused &&
|
||||||
|
!isStuckState &&
|
||||||
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
||||||
const isDragging = draggingTaskId === task.id;
|
const isDragging = draggingTaskId === task.id;
|
||||||
|
|
||||||
@@ -860,8 +866,10 @@ export function ListView({
|
|||||||
<tr
|
<tr
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
|
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
|
||||||
isAgentActive ? " agent-active" : ""
|
isStuckState ? " stuck" : ""
|
||||||
}${isDragging ? " dragging" : ""}`}
|
}${isAgentActive ? " agent-active" : ""}${
|
||||||
|
isDragging ? " dragging" : ""
|
||||||
|
}`}
|
||||||
onClick={() => handleRowClick(task)}
|
onClick={() => handleRowClick(task)}
|
||||||
draggable={!isPaused}
|
draggable={!isPaused}
|
||||||
onDragStart={(e) => handleDragStart(e, task)}
|
onDragStart={(e) => handleDragStart(e, task)}
|
||||||
@@ -891,7 +899,11 @@ export function ListView({
|
|||||||
)}
|
)}
|
||||||
{visibleColumns.has("status") && (
|
{visibleColumns.has("status") && (
|
||||||
<td className="list-cell">
|
<td className="list-cell">
|
||||||
{task.status ? (
|
{isStuckState ? (
|
||||||
|
<span className="list-status-badge stuck">
|
||||||
|
Stuck
|
||||||
|
</span>
|
||||||
|
) : task.status ? (
|
||||||
<span
|
<span
|
||||||
className={`list-status-badge${isFailed ? " failed" : ""}${
|
className={`list-status-badge${isFailed ? " failed" : ""}${
|
||||||
isAgentActive ? " pulsing" : ""
|
isAgentActive ? " pulsing" : ""
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { pickPreferredBadge } from "./TaskCardBadge";
|
|||||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||||
import { useSessionFiles } from "../hooks/useSessionFiles";
|
import { useSessionFiles } from "../hooks/useSessionFiles";
|
||||||
|
import { isTaskStuck } from "../utils/taskStuck";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
|
||||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||||
@@ -45,6 +46,8 @@ interface TaskCardProps {
|
|||||||
onArchiveTask?: (id: string) => Promise<Task>;
|
onArchiveTask?: (id: string) => Promise<Task>;
|
||||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||||
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
|
taskStuckTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areTaskBadgeInfosEqual(
|
function areTaskBadgeInfosEqual(
|
||||||
@@ -82,6 +85,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previous.queued === next.queued &&
|
previous.queued === next.queued &&
|
||||||
previous.projectId === next.projectId &&
|
previous.projectId === next.projectId &&
|
||||||
previous.globalPaused === next.globalPaused &&
|
previous.globalPaused === next.globalPaused &&
|
||||||
|
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
|
||||||
previous.onOpenDetail === next.onOpenDetail &&
|
previous.onOpenDetail === next.onOpenDetail &&
|
||||||
previous.addToast === next.addToast &&
|
previous.addToast === next.addToast &&
|
||||||
previous.onUpdateTask === next.onUpdateTask &&
|
previous.onUpdateTask === next.onUpdateTask &&
|
||||||
@@ -130,6 +134,7 @@ function TaskCardComponent({
|
|||||||
onArchiveTask,
|
onArchiveTask,
|
||||||
onUnarchiveTask,
|
onUnarchiveTask,
|
||||||
onOpenFilesForTask,
|
onOpenFilesForTask,
|
||||||
|
taskStuckTimeoutMs,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [fileDragOver, setFileDragOver] = useState(false);
|
const [fileDragOver, setFileDragOver] = useState(false);
|
||||||
@@ -315,9 +320,10 @@ function TaskCardComponent({
|
|||||||
|
|
||||||
const isFailed = task.status === "failed";
|
const isFailed = task.status === "failed";
|
||||||
const isPaused = task.paused === true;
|
const isPaused = task.paused === true;
|
||||||
|
const isStuck = isTaskStuck(task, taskStuckTimeoutMs);
|
||||||
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
||||||
const isArchived = task.column === "archived";
|
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
|
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived
|
||||||
|
|
||||||
// Check if this card can be edited inline
|
// Check if this card can be edited inline
|
||||||
@@ -498,7 +504,7 @@ function TaskCardComponent({
|
|||||||
setShowSteps((current) => !current);
|
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) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
@@ -560,7 +566,7 @@ function TaskCardComponent({
|
|||||||
)}
|
)}
|
||||||
{!isPaused && task.status && task.status !== "queued" && (
|
{!isPaused && task.status && task.status !== "queued" && (
|
||||||
<span
|
<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
|
style={isAwaitingApproval
|
||||||
? { background: "rgba(210,153,34,0.2)", color: "var(--triage)" }
|
? { background: "rgba(210,153,34,0.2)", color: "var(--triage)" }
|
||||||
: isFailed
|
: isFailed
|
||||||
@@ -568,7 +574,12 @@ function TaskCardComponent({
|
|||||||
: { background: COLUMN_COLOR_MAP[task.column], color: COLUMN_TEXT_COLOR_MAP[task.column] }
|
: { 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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasGitHubBadge && (
|
{hasGitHubBadge && (
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface WorktreeGroupProps {
|
|||||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||||
) => Promise<Task>;
|
) => Promise<Task>;
|
||||||
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
|
||||||
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
|
taskStuckTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorktreeGroupComponent({
|
function WorktreeGroupComponent({
|
||||||
@@ -29,6 +31,7 @@ function WorktreeGroupComponent({
|
|||||||
globalPaused,
|
globalPaused,
|
||||||
onUpdateTask,
|
onUpdateTask,
|
||||||
onOpenFilesForTask,
|
onOpenFilesForTask,
|
||||||
|
taskStuckTimeoutMs,
|
||||||
}: WorktreeGroupProps) {
|
}: WorktreeGroupProps) {
|
||||||
return (
|
return (
|
||||||
<div className="worktree-group">
|
<div className="worktree-group">
|
||||||
@@ -39,7 +42,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} 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) => (
|
{queuedTasks.map((task) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -52,6 +55,7 @@ function WorktreeGroupComponent({
|
|||||||
globalPaused={globalPaused}
|
globalPaused={globalPaused}
|
||||||
onUpdateTask={onUpdateTask}
|
onUpdateTask={onUpdateTask}
|
||||||
onOpenFilesForTask={onOpenFilesForTask}
|
onOpenFilesForTask={onOpenFilesForTask}
|
||||||
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -316,6 +316,63 @@ describe("ListView", () => {
|
|||||||
expect(row?.className).not.toContain("agent-active");
|
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", () => {
|
it("renders column badges with correct colors", () => {
|
||||||
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
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"]);
|
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
||||||
|
|
||||||
/** Mirrors the cardClass computation from TaskCard.tsx */
|
/** Mirrors the cardClass computation from TaskCard.tsx */
|
||||||
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column; globalPaused?: boolean }): string {
|
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 } = opts;
|
const { dragging = false, queued = false, status, column = "todo", globalPaused, isStuck = false, isPaused = false, isAwaitingApproval = false } = opts;
|
||||||
const isFailed = status === "failed";
|
const isFailed = status === "failed";
|
||||||
const isAgentActive = !globalPaused && !queued && !isFailed && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
|
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" : ""}`;
|
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TaskCard memoization", () => {
|
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", () => {
|
describe("TaskCard dependency tooltip", () => {
|
||||||
/** Mirrors the data-tooltip computation from TaskCard.tsx */
|
/** Mirrors the data-tooltip computation from TaskCard.tsx */
|
||||||
function computeDepTooltip(dependencies: string[]): string | undefined {
|
function computeDepTooltip(dependencies: string[]): string | undefined {
|
||||||
|
|||||||
@@ -934,6 +934,26 @@ body {
|
|||||||
color: var(--color-error-dark);
|
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 */
|
/* Size badge: positioned in card header, subtle color coding for effort estimation */
|
||||||
.card-size-badge {
|
.card-size-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -6421,6 +6441,11 @@ body {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-row.stuck {
|
||||||
|
border-left: 3px solid var(--triage);
|
||||||
|
background: rgba(210, 153, 34, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
.list-row.agent-active {
|
.list-row.agent-active {
|
||||||
border-left: 3px solid var(--in-progress);
|
border-left: 3px solid var(--in-progress);
|
||||||
animation: list-agent-glow 2.5s ease-in-out infinite;
|
animation: list-agent-glow 2.5s ease-in-out infinite;
|
||||||
@@ -6488,6 +6513,12 @@ body {
|
|||||||
color: var(--color-error-dark);
|
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 {
|
.list-status-badge.pulsing {
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
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