fix: stabilize workflow board and loop recovery

This commit is contained in:
gsxdsm
2026-06-07 22:48:28 -07:00
parent e1a244adf6
commit e138971030
33 changed files with 1372 additions and 321 deletions

View File

@@ -1244,6 +1244,11 @@ function AppInner() {
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
}, [modalManager, pushNav]);
const openCreateWorkflowWithNav = useCallback(() => {
modalManager.openWorkflowEditor("create");
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
}, [modalManager, pushNav]);
const openUsageWithNav = useCallback((anchorRect?: DOMRect | null) => {
modalManager.openUsage(anchorRect);
pushNav({ type: "modal", close: modalManager.closeUsage });
@@ -1742,6 +1747,8 @@ function AppInner() {
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
prAuthAvailable={prAuthAvailable}
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onCreateWorkflow={openCreateWorkflowWithNav}
/>
</PageErrorBoundary>
);
@@ -1778,6 +1785,7 @@ function AppInner() {
searchQuery={searchQuery}
lastFetchTimeMs={lastFetchTimeMs}
prAuthAvailable={prAuthAvailable}
onCreateWorkflow={openCreateWorkflowWithNav}
/>
</PageErrorBoundary>
);

View File

@@ -54,6 +54,9 @@
flex-direction: column;
flex: 1;
height: 100%;
width: 100%;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}

View File

@@ -385,6 +385,7 @@ export function AppModals({
addToast={addToast}
projectId={projectId}
initialPanel={modalManager.workflowEditorInitialPanel}
initialAction={modalManager.workflowEditorInitialAction}
/>
</Suspense>
</ModalErrorBoundary>

View File

@@ -2,18 +2,16 @@ import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIss
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn } from "./taskSorting";
import { Column } from "./Column";
import { Lane } from "./Lane";
import "./Lane.css";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
import { Pencil, Plus } from "lucide-react";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { subscribeSse } from "../sse-bus";
/** localStorage key for persisted lane collapse state (per project). */
const LANE_COLLAPSE_STORAGE_KEY = "kb-dashboard-lane-collapsed";
interface BoardProps {
tasks: Task[];
projectId?: string;
@@ -68,6 +66,10 @@ interface BoardProps {
lastFetchTimeMs?: number;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
/** Opens the workflow editor modal. */
onOpenWorkflowEditor?: () => void;
/** Opens the workflow editor to create a new workflow. */
onCreateWorkflow?: () => void;
}
@@ -87,7 +89,7 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
return true;
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
@@ -271,18 +273,8 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
// Fetch board-workflows metadata. When the flag is OFF the server returns
// { flagEnabled: false } and we render the legacy single-lane board below.
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const draggingTaskIdRef = useRef<string | null>(null);
const [collapsedLanes, setCollapsedLanes] = useState<ReadonlySet<string>>(() => {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(LANE_COLLAPSE_STORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === "string"));
} catch {
/* ignore corrupt persisted state */
}
return new Set();
});
// Fetch board workflow lanes for the project. Deliberately NOT keyed on
// `tasks` — that refetched on every SSE tick. Instead we refetch on project
@@ -328,22 +320,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
};
}, [projectId]);
const handleToggleLaneCollapse = useCallback((workflowId: string) => {
setCollapsedLanes((prev) => {
const next = new Set(prev);
if (next.has(workflowId)) next.delete(workflowId);
else next.add(workflowId);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(LANE_COLLAPSE_STORAGE_KEY, JSON.stringify([...next]));
} catch {
/* ignore quota / serialization errors */
}
}
return next;
});
}, []);
const handlePromote = useCallback(async (taskId: string) => {
await promoteTask(taskId, projectId);
}, [projectId]);
@@ -352,33 +328,65 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
const flagOn = boardWorkflows?.flagEnabled === true;
// Group visible tasks into lanes by resolved workflow (null → default lane).
const lanes = useMemo(() => {
if (!boardWorkflows || !flagOn) return [];
const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows;
const byId = new Map(workflows.map((w) => [w.id, w] as const));
const tasksByWorkflow = new Map<string, Task[]>();
for (const task of tasks) {
// Archived cards are excluded from lanes (archived columns are hidden).
if (task.column === "archived") continue;
const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId;
(tasksByWorkflow.get(workflowId) ?? tasksByWorkflow.set(workflowId, []).get(workflowId)!).push(task);
}
const result: Array<{ workflow: typeof workflows[number]; tasks: Task[] }> = [];
for (const [workflowId, laneTasks] of tasksByWorkflow) {
const workflow = byId.get(workflowId);
if (!workflow) continue;
if (laneTasks.length === 0) continue; // zero-card lanes hidden
result.push({ workflow, tasks: laneTasks });
}
// Default lane first; then by workflow name for stable ordering.
result.sort((a, b) => {
if (a.workflow.id === defaultWorkflowId) return -1;
if (b.workflow.id === defaultWorkflowId) return 1;
return a.workflow.name.localeCompare(b.workflow.name);
const workflowMode = flagOn && Boolean(boardWorkflows?.workflows.length);
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
if (!workflowMode || !boardWorkflows) return [];
return [...boardWorkflows.workflows].sort((a, b) => {
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
return a.name.localeCompare(b.name);
});
return result;
}, [boardWorkflows, flagOn, tasks]);
}, [boardWorkflows, workflowMode]);
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
if (!workflowMode) return null;
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
?? workflowOptions[0]
?? null;
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
useEffect(() => {
if (!workflowMode) {
setSelectedWorkflowId(null);
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
setSelectedWorkflowId(selectedWorkflow.id);
}
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
const selectedWorkflowTasks = useMemo(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
return tasks.filter((task) => {
if (task.column === "archived") return false;
const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
return workflowId === selectedWorkflow.id;
});
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
const selectedWorkflowColumns = useMemo(() => {
if (!selectedWorkflow) return [];
return selectedWorkflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
}, [selectedWorkflow]);
const selectedWorkflowCreateColumnId = useMemo(() => {
return selectedWorkflowColumns.find((column) => column.flags.intake && !column.flags.archived)?.id
?? selectedWorkflowColumns.find((column) => !column.flags.archived)?.id;
}, [selectedWorkflowColumns]);
const selectedWorkflowTasksByColumn = useMemo(() => {
const grouped: Record<string, Task[]> = {};
if (!selectedWorkflow) return grouped;
for (const column of selectedWorkflow.columns) grouped[column.id] = [];
for (const task of selectedWorkflowTasks) {
(grouped[task.column] ??= []).push(task);
}
for (const column of selectedWorkflow.columns) {
grouped[column.id] = sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType);
}
return grouped;
}, [selectedWorkflow, selectedWorkflowTasks]);
// Card-placed field defs grouped by workflow id (U13/KTD-14). Only recomputes
// when the board-workflows payload changes, not on every SSE task tick.
@@ -441,66 +449,112 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
// messages. We do NOT eagerly call `/api/github/batch-status` on board load.
if (flagOn) {
if (workflowMode && selectedWorkflow) {
return (
<main
className="board board-lanes"
id="board"
ref={boardRef}
onDragStart={(e) => {
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
if (id) draggingTaskIdRef.current = id;
}}
onDragEnd={() => {
draggingTaskIdRef.current = null;
}}
>
{lanes.map(({ workflow, tasks: laneTasks }) => (
<Lane
key={workflow.id}
workflow={workflow}
tasks={laneTasks}
collapsed={collapsedLanes.has(workflow.id)}
onToggleCollapse={handleToggleLaneCollapse}
projectId={projectId}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={canDropTask}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
onQuickCreate={onQuickCreate}
onNewTask={onNewTask}
autoMerge={autoMerge}
onToggleAutoMerge={onToggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
/>
))}
</main>
<div className="board-workflow-view">
{workflowOptions.length > 1 && (
<div className="board-workflow-toolbar">
<label className="list-workflow-selector board-workflow-selector">
<span>Workflow</span>
<select
className="select list-workflow-select"
value={selectedWorkflow.id}
onChange={(event) => setSelectedWorkflowId(event.target.value)}
aria-label="Select workflow"
>
{workflowOptions.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>
))}
</select>
</label>
{onCreateWorkflow && (
<button
type="button"
className="btn btn-icon btn-sm board-workflow-create-btn"
onClick={onCreateWorkflow}
title="New workflow"
aria-label="New workflow"
>
<Plus size={15} />
</button>
)}
{onOpenWorkflowEditor && (
<button
type="button"
className="btn btn-icon btn-sm board-workflow-edit-btn"
onClick={onOpenWorkflowEditor}
title="Edit workflows"
aria-label="Edit workflows"
>
<Pencil size={15} />
</button>
)}
</div>
)}
<main
className="board board-workflow-columns"
id="board"
ref={boardRef}
onDragStart={(e) => {
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
if (id) draggingTaskIdRef.current = id;
}}
onDragEnd={() => {
draggingTaskIdRef.current = null;
}}
>
{selectedWorkflowColumns.map((columnDef) => {
const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
return (
<Column
key={columnDef.id}
column={columnDef.id as ColumnType}
workflowMode
workflowId={selectedWorkflow.id}
columnDisplayName={columnDef.name}
columnFlags={columnDef.flags}
tasks={selectedWorkflowTasksByColumn[columnDef.id] ?? []}
allTasks={selectedWorkflowTasks}
projectId={projectId}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={(taskId) => canDropTask(taskId, columnDef.id, selectedWorkflow.id)}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
{...(isCreateColumn ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker ? { onToggleAutoMerge } : {})}
/>
);
})}
</main>
</div>
);
}

View File

@@ -5,6 +5,9 @@
flex: 1;
width: 100%;
height: 100%;
width: 100%;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}

View File

@@ -151,6 +151,8 @@ interface ColumnProps {
* column behavior (label, bulk actions, archived detection) from legacy
* literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */
workflowMode?: boolean;
/** Workflow id for column-aware task creation in workflow mode. */
workflowId?: string;
/** Display name for this column, from the workflow definition. */
columnDisplayName?: string;
/** Resolved trait flags for this column (workflow mode). */
@@ -170,7 +172,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -374,6 +376,27 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
}, [shouldPaginate, tasks, visibleTaskCount]);
const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length);
const canCreateInColumn = Boolean(
onQuickCreate &&
!isArchived &&
(workflowMode || column === "triage"),
);
const handleQuickCreate = useCallback(
(input: TaskCreateInput) => {
if (!onQuickCreate) return Promise.resolve();
if (workflowMode) {
const explicitWorkflowId = workflowId?.startsWith("builtin:") ? undefined : workflowId;
return onQuickCreate({
...input,
column,
...(explicitWorkflowId ? { workflowId: explicitWorkflowId } : {}),
});
}
return onQuickCreate(input);
},
[column, onQuickCreate, workflowId, workflowMode],
);
const handleLoadMore = useCallback(() => {
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
@@ -420,6 +443,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review";
const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn;
const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo;
const columnLabelText = workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column];
const handlePauseAll = useCallback(async () => {
if (!onPauseTask) return;
@@ -429,7 +453,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const confirmed = await confirm({
title: t("column.stopAllTitle", "Stop All Tasks"),
message: t("column.stopAllMessage", "Stop all {{count}} {{columnLabel}} task{{plural}}?", { count: pauseEligibleCount, columnLabel: COLUMN_LABELS[column].toLowerCase(), plural: pauseEligibleCount === 1 ? "" : "s" }),
message: t("column.stopAllMessage", "Stop all {{count}} {{columnLabel}} task{{plural}}?", { count: pauseEligibleCount, columnLabel: columnLabelText.toLowerCase(), plural: pauseEligibleCount === 1 ? "" : "s" }),
danger: true,
});
if (!confirmed) return;
@@ -449,7 +473,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
} finally {
setIsPausingAll(false);
}
}, [onPauseTask, pauseEligibleCount, column, pauseEligibleTasks, addToast, confirm]);
}, [onPauseTask, pauseEligibleCount, columnLabelText, pauseEligibleTasks, addToast, confirm, t]);
const handleMoveAllToTodo = useCallback(async () => {
setIsMenuOpen(false);
@@ -457,7 +481,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const confirmed = await confirm({
title: t("column.moveAllToTodoTitle", "Move All to Todo"),
message: t("column.moveAllToTodoMessage", "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", { count: tasks.length, columnLabel: COLUMN_LABELS[column].toLowerCase(), plural: tasks.length === 1 ? "" : "s" }),
message: t("column.moveAllToTodoMessage", "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", { count: tasks.length, columnLabel: columnLabelText.toLowerCase(), plural: tasks.length === 1 ? "" : "s" }),
});
if (!confirmed) return;
@@ -502,7 +526,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
} finally {
setIsMovingAllToTodo(false);
}
}, [tasks, column, onMoveTask, addToast, confirm]);
}, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]);
const handleArchiveAll = useCallback(async () => {
if (!onArchiveAllDone) return;
@@ -650,9 +674,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
)}
{!isCollapsed && (
<div className="column-body">
{(workflowMode ? Boolean(columnFlags?.intake) : column === "triage") && onQuickCreate && (
{canCreateInColumn && (
<QuickEntryBox
onCreate={onQuickCreate}
onCreate={handleQuickCreate}
addToast={addToast}
tasks={allTasks ?? []}
availableModels={availableModels}

View File

@@ -3,6 +3,9 @@
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
@@ -303,8 +306,7 @@
display: flex;
flex-direction: column;
gap: var(--space-md);
margin: 0 auto;
width: min(100%, 880px);
width: 100%;
}
.documents-content-header {
@@ -354,8 +356,7 @@
}
.documents-task-list-wrap {
width: min(100%, 960px);
margin: 0 auto;
width: 100%;
}
.documents-view-list {
@@ -732,4 +733,3 @@
min-height: 36px;
}
}

View File

@@ -10,13 +10,63 @@
min-height: 0;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--board-padding, 12px);
box-sizing: border-box;
}
.board-workflow-view {
display: flex;
flex-direction: column;
flex: 1 1 auto;
width: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.board-workflow-toolbar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-xl);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.board-workflow-selector {
margin-left: auto;
}
.board-workflow-create-btn,
.board-workflow-edit-btn {
flex: 0 0 auto;
}
.board.board-workflow-columns {
flex: 1 1 auto;
display: flex;
flex-direction: row;
align-items: stretch;
width: 100%;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
}
.board.board-workflow-columns > .column {
flex: 1 0 300px;
min-width: 300px;
scroll-snap-align: center;
}
.lane {
display: flex;
flex-direction: column;
flex: 0 0 auto;
width: 100%;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg, 10px);
@@ -96,3 +146,25 @@
display: flex;
flex-direction: column;
}
@media (max-width: 1024px) {
.board.board-lanes {
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
scroll-snap-type: none;
}
.board.board-lanes > .lane {
width: 100%;
min-width: 0;
flex-shrink: 0;
}
.board.board-workflow-columns {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
}
}

View File

@@ -84,6 +84,10 @@ function LaneComponent(props: LaneProps) {
() => workflow.columns.filter((col) => !col.flags.archived && !col.flags.hiddenFromBoard),
[workflow.columns],
);
const createColumnId = useMemo(() => (
visibleColumns.find((col) => col.flags.intake && !col.flags.archived)?.id
?? visibleColumns.find((col) => !col.flags.archived)?.id
), [visibleColumns]);
// Group + sort tasks by column id (stable per render).
const tasksByColumn = useMemo(() => {
@@ -157,11 +161,14 @@ function LaneComponent(props: LaneProps) {
</div>
{!collapsed && (
<div className="lane-columns" ref={laneRef}>
{visibleColumns.map((col) => (
{visibleColumns.map((col) => {
const isCreateColumn = col.id === createColumnId;
return (
<Column
key={col.id}
column={col.id as ColumnType}
workflowMode
workflowId={workflow.id}
columnDisplayName={col.name}
columnFlags={col.flags}
tasks={tasksByColumn[col.id] ?? []}
@@ -197,10 +204,11 @@ function LaneComponent(props: LaneProps) {
blockerFanoutMap={props.blockerFanoutMap}
prAuthAvailable={props.prAuthAvailable}
autoMerge={props.autoMerge}
{...(col.flags.intake ? { onQuickCreate: props.onQuickCreate, onNewTask: props.onNewTask, onPlanningMode: props.onPlanningMode, onSubtaskBreakdown: props.onSubtaskBreakdown } : {})}
{...(isCreateColumn ? { onQuickCreate: props.onQuickCreate, onNewTask: props.onNewTask, onPlanningMode: props.onPlanningMode, onSubtaskBreakdown: props.onSubtaskBreakdown } : {})}
{...(col.flags.mergeBlocker ? { onToggleAutoMerge: props.onToggleAutoMerge } : {})}
/>
))}
);
})}
</div>
)}
</section>

View File

@@ -32,6 +32,30 @@
gap: var(--space-sm);
}
.list-workflow-control {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
}
.list-workflow-selector {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
}
.list-workflow-select {
min-width: 160px;
max-width: 240px;
min-height: 30px;
font-size: 12px;
border-radius: var(--radius-sm);
}
.list-sidebar-controls__actions .list-new-task-action {
margin-left: auto;
}
@@ -1129,4 +1153,3 @@
min-height: 36px;
}
}

View File

@@ -2,14 +2,14 @@ import "./ListView.css";
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react";
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive, Plus } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core";
import { COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
import { useColumnLabel } from "../i18n/labels";
import { sortTasksForDisplayColumn } from "./taskSorting";
import { batchUpdateTaskModels, fetchNodes, fetchTaskDetail } from "../api";
import { batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes, fetchTaskDetail } from "../api";
import { TaskDetailContent } from "./TaskDetailModal";
import type { ModelInfo, NodeInfo } from "../api";
import type { BoardWorkflowColumn, BoardWorkflowDefinition, BoardWorkflowsPayload, ModelInfo, NodeInfo } from "../api";
import { QuickEntryBox } from "./QuickEntryBox";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { NodeHealthDot } from "./NodeHealthDot";
@@ -20,6 +20,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
import { subscribeSse } from "../sse-bus";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
@@ -110,14 +111,12 @@ function readStaleOnlyFilter(projectId?: string): boolean {
return false;
}
function readCollapsedSections(projectId?: string): Set<Column> {
function readCollapsedSections(projectId?: string): Set<ColumnId> {
try {
const saved = getScopedItem("kb-dashboard-list-collapsed", projectId);
if (saved) {
const parsed = JSON.parse(saved) as Column[];
const validColumns = parsed.filter((col): col is Column =>
COLUMNS.includes(col as Column)
);
const parsed = JSON.parse(saved) as unknown[];
const validColumns = parsed.filter((col): col is ColumnId => typeof col === "string");
if (validColumns.length > 0) {
return new Set(validColumns);
}
@@ -126,7 +125,7 @@ function readCollapsedSections(projectId?: string): Set<Column> {
// Invalid localStorage data - fall through to default
}
return new Set<Column>();
return new Set<ColumnId>();
}
function readSelectedTaskIds(projectId?: string): Set<string> {
@@ -187,7 +186,7 @@ function clampSidebarWidth(width: number, containerWidth: number): number {
interface ListViewProps {
tasks: Task[];
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onDeleteTask: (id: string, options?: {
removeDependencyReferences?: boolean;
@@ -234,8 +233,22 @@ interface ListViewProps {
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
prAuthAvailable?: boolean;
onCreateWorkflow?: () => void;
}
const LEGACY_LIST_COLUMNS: BoardWorkflowColumn[] = COLUMNS.map((column) => ({
id: column,
name: column,
flags: {
intake: column === "triage",
countsTowardWip: column === "in-progress",
mergeBlocker: column === "in-review",
complete: column === "done",
archived: column === "archived",
hold: column === "todo",
},
}));
function shouldShowTaskProgress(task: Task): boolean {
return task.status === "executing" || task.column === "in-progress";
}
@@ -283,14 +296,17 @@ export function ListView({
searchQuery = "",
lastFetchTimeMs,
prAuthAvailable,
onCreateWorkflow,
}: ListViewProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
const [selectedColumn, setSelectedColumn] = useState<Column | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<ColumnId | null>(null);
const [selectedColumn, setSelectedColumn] = useState<ColumnId | null>(null);
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const { confirm, confirmWithChoice } = useConfirm();
@@ -304,7 +320,7 @@ export function ListView({
const [stalePausedReviewOnlyFilter, setStalePausedReviewOnlyFilter] = useState<boolean>(false);
// Collapsed sections state - initialize from localStorage
const [collapsedSections, setCollapsedSections] = useState<Set<Column>>(() =>
const [collapsedSections, setCollapsedSections] = useState<Set<ColumnId>>(() =>
readCollapsedSections(projectId),
);
@@ -342,12 +358,19 @@ export function ListView({
const [bulkEditEnabled, setBulkEditEnabled] = useState(false);
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(() => readSelectedTaskIds(projectId));
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(() => readSelectedTaskId(projectId));
const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState<Task | TaskDetail | null>(null);
const [selectedTaskSnapshot, setSelectedTaskSnapshot] = useState<Task | TaskDetail | null>(() => {
const persistedSelection = readSelectedTaskId(projectId);
return persistedSelection ? tasks.find((task) => task.id === persistedSelection) ?? null : null;
});
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readSidebarWidth(projectId));
const splitLayoutRef = useRef<HTMLDivElement>(null);
const splitSidebarRef = useRef<HTMLDivElement>(null);
const previousStorageProjectIdRef = useRef(projectId);
const boardWorkflowsFetchSeqRef = useRef(0);
useEffect(() => {
if (previousStorageProjectIdRef.current === projectId) return;
previousStorageProjectIdRef.current = projectId;
setVisibleColumns(readVisibleColumns(projectId));
setHideDoneTasks(readHideDoneTasks(projectId));
setStaleOnlyFilter(readStaleOnlyFilter(projectId));
@@ -362,6 +385,41 @@ export function ListView({
setSidebarWidth(readSidebarWidth(projectId));
}, [projectId, tasks]);
useEffect(() => {
const runFetch = () => {
const seq = ++boardWorkflowsFetchSeqRef.current;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload);
})
.catch(() => {
if (seq === boardWorkflowsFetchSeqRef.current) {
setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
}
});
};
runFetch();
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") runFetch();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"workflow:created": runFetch,
"workflow:updated": runFetch,
"workflow:deleted": runFetch,
},
});
return () => {
boardWorkflowsFetchSeqRef.current++;
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
unsubscribe();
};
}, [projectId]);
// Persist selection to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
@@ -392,6 +450,7 @@ export function ListView({
if (!previous || previous.id !== selectedTaskId) {
return liveTask;
}
if (previous === liveTask) return previous;
return { ...previous, ...liveTask };
});
}, [selectedTaskId, tasks]);
@@ -488,6 +547,100 @@ export function ListView({
});
}, []);
const workflowMode = boardWorkflows?.flagEnabled === true && boardWorkflows.workflows.length > 0;
const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
if (!workflowMode || !boardWorkflows) return [];
return [...boardWorkflows.workflows].sort((a, b) => {
if (a.id === boardWorkflows.defaultWorkflowId) return -1;
if (b.id === boardWorkflows.defaultWorkflowId) return 1;
return a.name.localeCompare(b.name);
});
}, [boardWorkflows, workflowMode]);
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
if (!workflowMode) return null;
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
?? workflowOptions.find((workflow) => workflow.id === boardWorkflows?.defaultWorkflowId)
?? workflowOptions[0]
?? null;
}, [boardWorkflows?.defaultWorkflowId, selectedWorkflowId, workflowMode, workflowOptions]);
useEffect(() => {
if (!workflowMode) {
setSelectedWorkflowId(null);
return;
}
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
setSelectedWorkflowId(selectedWorkflow.id);
}
}, [selectedWorkflow, selectedWorkflowId, workflowMode]);
useEffect(() => {
setSelectedColumn(null);
}, [selectedWorkflowId]);
const listColumns = useMemo<BoardWorkflowColumn[]>(() => {
if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS;
return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard);
}, [selectedWorkflow, workflowMode]);
const columnNameById = useMemo(() => {
const map = new Map<ColumnId, string>();
for (const column of listColumns) {
map.set(column.id, workflowMode ? column.name : columnLabel(column.id));
}
return map;
}, [columnLabel, listColumns, workflowMode]);
const columnFlagsById = useMemo(() => {
const map = new Map<ColumnId, BoardWorkflowColumn["flags"]>();
for (const column of listColumns) {
map.set(column.id, column.flags);
}
return map;
}, [listColumns]);
const getListColumnLabel = useCallback((column: ColumnId): string => {
return columnNameById.get(column) ?? columnLabel(column);
}, [columnLabel, columnNameById]);
const isArchivedColumn = useCallback((column: ColumnId): boolean => {
return workflowMode ? Boolean(columnFlagsById.get(column)?.archived) : column === "archived";
}, [columnFlagsById, workflowMode]);
const isCompleteColumn = useCallback((column: ColumnId): boolean => {
return workflowMode ? Boolean(columnFlagsById.get(column)?.complete) : column === "done";
}, [columnFlagsById, workflowMode]);
const selectedWorkflowTaskIds = useMemo(() => {
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
const ids = new Set<string>();
for (const task of tasks) {
const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
if (workflowId === selectedWorkflow.id) ids.add(task.id);
}
return ids;
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
const createTargetColumn = useMemo(() => {
const target = listColumns.find((column) => column.flags.intake && !column.flags.archived)
?? listColumns.find((column) => !column.flags.archived);
return target?.id;
}, [listColumns]);
const handleListQuickCreate = useCallback((input: TaskCreateInput) => {
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
if (workflowMode && selectedWorkflow && createTargetColumn) {
const explicitWorkflowId = selectedWorkflow.id.startsWith("builtin:") ? undefined : selectedWorkflow.id;
return create({
...input,
column: input.column ?? createTargetColumn,
workflowId: input.workflowId ?? explicitWorkflowId,
});
}
return create(input);
}, [addToast, createTargetColumn, onQuickCreate, selectedWorkflow, t, workflowMode]);
// Column display labels
const COLUMN_LABELS_MAP: Record<ListColumn, string> = {
@@ -509,11 +662,11 @@ export function ListView({
setSortDirection("asc");
}, [sortField]);
const handleColumnFilter = useCallback((column: Column) => {
const handleColumnFilter = useCallback((column: ColumnId) => {
setSelectedColumn((prev) => (prev === column ? null : column));
}, []);
const toggleSection = useCallback((column: Column) => {
const toggleSection = useCallback((column: ColumnId) => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(column)) {
@@ -540,10 +693,20 @@ export function ListView({
)
: [...tasks];
if (selectedWorkflowTaskIds) {
filtered = filtered.filter((task) => selectedWorkflowTaskIds.has(task.id));
}
const hiddenCompletedColumns = new Set(
listColumns
.filter((column) => column.flags.complete || column.flags.archived)
.map((column) => column.id),
);
// Then filter out done and archived tasks if hideDoneTasks is enabled
// BUT only when no specific column is selected (strict hide semantics)
if (hideDoneTasks && !selectedColumn) {
filtered = filtered.filter((t) => t.column !== "done" && t.column !== "archived");
filtered = filtered.filter((t) => !hiddenCompletedColumns.has(t.column));
}
// Then apply stale-only filter if selected
@@ -559,27 +722,22 @@ export function ListView({
? filtered.filter((t) => t.column === selectedColumn)
: filtered;
const groups: Record<Column, Task[]> = {
triage: [],
todo: [],
"in-progress": [],
"in-review": [],
done: [],
archived: [],
};
const groups: Record<string, Task[]> = {};
for (const column of listColumns) groups[column.id] = [];
columnFiltered.forEach((task) => {
const column = isColumn(task.column) ? task.column : DEFAULT_COLUMN;
groups[column].push(task);
const column = workflowMode ? task.column : (isColumn(task.column) ? task.column : DEFAULT_COLUMN);
if (groups[column]) groups[column].push(task);
});
for (const column of COLUMNS) {
for (const column of listColumns) {
const columnId = column.id;
if (!sortField) {
groups[column] = sortTasksForDisplayColumn(groups[column], column);
groups[columnId] = sortTasksForDisplayColumn(groups[columnId], columnId as Column);
continue;
}
groups[column] = [...groups[column]].sort((a, b) => {
groups[columnId] = [...groups[columnId]].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "title":
@@ -599,7 +757,7 @@ export function ListView({
});
}
return groups;
}, [tasks, searchQuery, sortField, sortDirection, hideDoneTasks, staleOnlyFilter, stalePausedReviewOnlyFilter, selectedColumn]);
}, [tasks, searchQuery, selectedWorkflowTaskIds, listColumns, workflowMode, hideDoneTasks, selectedColumn, staleOnlyFilter, stalePausedReviewOnlyFilter, sortField, sortDirection]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
@@ -608,8 +766,16 @@ export function ListView({
// Calculate done and archived task counts for stats display
const completedTaskCount = useMemo(() => {
return tasks.filter((t) => t.column === "done" || t.column === "archived").length;
}, [tasks]);
const completedColumns = new Set(
listColumns
.filter((column) => column.flags.complete || column.flags.archived)
.map((column) => column.id),
);
return tasks.filter((task) => {
if (selectedWorkflowTaskIds && !selectedWorkflowTaskIds.has(task.id)) return false;
return completedColumns.has(task.column);
}).length;
}, [listColumns, selectedWorkflowTaskIds, tasks]);
// Calculate hidden done+archived tasks count
const hiddenCompletedCount = useMemo(() => {
@@ -622,7 +788,7 @@ export function ListView({
const toggleSelectAll = useCallback(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived") // Can't bulk edit archived
.filter((t) => !isArchivedColumn(t.column)) // Can't bulk edit archived
.map((t) => t.id);
setSelectedTaskIds((prev) => {
@@ -637,26 +803,26 @@ export function ListView({
return new Set([...prev, ...visibleTaskIds]);
}
});
}, [groupedTasks]);
}, [groupedTasks, isArchivedColumn]);
// Check if all visible tasks are selected
const isSelectAll = useMemo(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived");
.filter((t) => !isArchivedColumn(t.column));
if (visibleTaskIds.length === 0) return false;
return visibleTaskIds.every((t) => selectedTaskIds.has(t.id));
}, [groupedTasks, selectedTaskIds]);
}, [groupedTasks, isArchivedColumn, selectedTaskIds]);
// Check if some (but not all) visible tasks are selected
const isSelectIndeterminate = useMemo(() => {
const visibleTaskIds = Object.values(groupedTasks)
.flat()
.filter((t) => t.column !== "archived");
.filter((t) => !isArchivedColumn(t.column));
if (visibleTaskIds.length === 0) return false;
const selectedCount = visibleTaskIds.filter((t) => selectedTaskIds.has(t.id)).length;
return selectedCount > 0 && selectedCount < visibleTaskIds.length;
}, [groupedTasks, selectedTaskIds]);
}, [groupedTasks, isArchivedColumn, selectedTaskIds]);
// Bulk edit state and handlers (must be after groupedTasks and clearSelection definition)
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
@@ -1018,7 +1184,7 @@ export function ListView({
const selectedTasks = Array.from(selectedTaskIds)
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
const actionableTasks = selectedTasks.filter((task) => task.column === "done");
const actionableTasks = selectedTasks.filter((task) => isCompleteColumn(task.column));
const skippedCount = selectedTasks.length - actionableTasks.length;
if (actionableTasks.length === 0) {
@@ -1092,14 +1258,14 @@ export function ListView({
t("listView.bulkArchiveSummary", "Archived {{archived}} · {{skipped}} skipped · {{failed}} failed", { archived: archivedIds.length, skipped: skippedCount, failed: failedIds.length }),
failedIds.length > 0 ? "error" : "success",
);
}, [addToast, confirm, onArchiveTask, selectedTaskIds, tasks]);
}, [addToast, confirm, isCompleteColumn, onArchiveTask, selectedTaskIds, tasks]);
const handleApplyBulkUpdate = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
const taskIds = Array.from(selectedTaskIds).filter((id) => {
const task = tasks.find((t) => t.id === id);
return task && task.column !== "archived";
return task && !isArchivedColumn(task.column);
});
if (taskIds.length === 0) {
@@ -1189,7 +1355,7 @@ export function ListView({
} finally {
setIsApplying(false);
}
}, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, onTasksUpdated]);
}, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, isArchivedColumn, onTasksUpdated]);
const handleRowClick = useCallback(
(task: Task) => {
@@ -1326,7 +1492,7 @@ export function ListView({
}, [isMobile, sidebarWidth]);
const handleColumnDragOver = useCallback(
(e: React.DragEvent, column: Column) => {
(e: React.DragEvent, column: ColumnId) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverColumn(column);
@@ -1339,14 +1505,14 @@ export function ListView({
}, []);
const handleColumnDrop = useCallback(
async (e: React.DragEvent, column: Column) => {
async (e: React.DragEvent, column: ColumnId) => {
e.preventDefault();
setDragOverColumn(null);
const taskId = e.dataTransfer.getData("text/plain");
if (!taskId) return;
// Prevent dropping into archived column
if (column === "archived") {
if (column === "archived" || columnFlagsById.get(column)?.archived) {
addToast(t("listView.archiveViaButton", "Tasks can only be archived via the archive button"), "error");
return;
}
@@ -1386,7 +1552,7 @@ export function ListView({
addToast(getErrorMessage(err), "error");
}
},
[onMoveTask, addToast, tasks, confirm]
[addToast, columnFlagsById, confirm, onMoveTask, tasks, t]
);
const getSortIcon = (field: SortField) => {
@@ -1398,6 +1564,40 @@ export function ListView({
);
};
const renderWorkflowSelector = () => {
if (!workflowMode || workflowOptions.length <= 1 || !selectedWorkflow) return null;
return (
<div className="list-workflow-control">
<label className="list-workflow-selector">
<span>{t("listView.workflowLabel", "Workflow")}</span>
<select
className="select list-workflow-select"
value={selectedWorkflow.id}
onChange={(event) => setSelectedWorkflowId(event.target.value)}
aria-label={t("listView.workflowSelectLabel", "Select workflow")}
>
{workflowOptions.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>
))}
</select>
</label>
{onCreateWorkflow && (
<button
type="button"
className="btn btn-icon btn-sm list-workflow-create-btn"
onClick={onCreateWorkflow}
title={t("workflows.newWorkflow", "New workflow")}
aria-label={t("workflows.newWorkflow", "New workflow")}
>
<Plus size={15} />
</button>
)}
</div>
);
};
const renderViewOptionsPanel = (panelId: string) => (
<div id={panelId} className="list-view-options-panel">
<div className="list-view-options-columns">
@@ -1447,9 +1647,12 @@ export function ListView({
{stalePausedReviewOnlyFilter ? t("listView.showAll", "Show all") : t("listView.stalePausedReview", "Stale paused review")}
</button>
<div className="list-drop-zones list-drop-zones--sidebar">
{COLUMNS.map((column) => {
const totalCount = tasks.filter((t) => t.column === column).length;
const isCompletedColumn = column === "done" || column === "archived";
{listColumns.map((columnDef) => {
const column = columnDef.id;
const totalCount = selectedWorkflowTaskIds
? tasks.filter((task) => task.column === column && selectedWorkflowTaskIds.has(task.id)).length
: tasks.filter((task) => task.column === column).length;
const isCompletedColumn = Boolean(columnDef.flags.complete || columnDef.flags.archived);
const visibleCount = hideDoneTasks && isCompletedColumn ? 0 : totalCount;
const showPartial = hideDoneTasks && isCompletedColumn && totalCount > 0;
@@ -1463,8 +1666,8 @@ export function ListView({
onDrop={(e) => handleColumnDrop(e, column)}
data-column={column}
>
<span className={`list-section-dot dot-${column}`} />
<span className="drop-zone-label">{columnLabel(column)}</span>
<span className={`list-section-dot dot-${column}`} style={{ backgroundColor: columnColor(column) }} />
<span className="drop-zone-label">{getListColumnLabel(column)}</span>
<span className="drop-zone-count">
{showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
</span>
@@ -1564,6 +1767,7 @@ export function ListView({
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
</button>
{renderWorkflowSelector()}
<button
className="btn btn-sm list-view-options-toggle"
onClick={() => setViewOptionsOpen((prev) => !prev)}
@@ -1580,7 +1784,7 @@ export function ListView({
) : null}
<div className="list-stats">
{selectedColumn
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: columnLabel(selectedColumn) })
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
</div>
</div>
@@ -1615,13 +1819,14 @@ export function ListView({
<div className="list-sidebar-controls__header">
<p className="list-stats">
{selectedColumn
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: columnLabel(selectedColumn) })
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
{hiddenCompletedCount > 0 && !selectedColumn && (
<span className="list-stats-hidden"> ({t("listView.hidden", "{{count}} hidden", { count: hiddenCompletedCount })})</span>
)}
</p>
<div className="list-sidebar-controls__actions">
{renderWorkflowSelector()}
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
</button>
@@ -1634,7 +1839,7 @@ export function ListView({
<div className="list-sidebar-summary-chips">
{selectedColumn ? (
<button className="btn btn-sm" onClick={clearColumnFilter} aria-label={t("listView.clearColumnFilter", "Clear column filter")}>
{t("listView.filterChip", "Filter: {{column}}", { column: columnLabel(selectedColumn) })}
{t("listView.filterChip", "Filter: {{column}}", { column: getListColumnLabel(selectedColumn) })}
</button>
) : null}
{hideDoneTasks ? <span className="list-sidebar-chip">{t("listView.doneHiddenChip", "Done hidden")}</span> : null}
@@ -1665,7 +1870,7 @@ export function ListView({
)}
<div className="list-quick-entry-above-table">
<QuickEntryBox
onCreate={onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"))}
onCreate={handleListQuickCreate}
addToast={addToast}
tasks={tasks}
availableModels={availableModels}
@@ -1695,9 +1900,10 @@ export function ListView({
</div>
) : isMobile ? (
<div className="list-cards">
{COLUMNS.map((column) => {
{listColumns.map((columnDef) => {
const column = columnDef.id;
if (selectedColumn && column !== selectedColumn) return null;
if (hideDoneTasks && (column === "done" || column === "archived") && !selectedColumn) return null;
if (hideDoneTasks && (columnDef.flags.complete || columnDef.flags.archived) && !selectedColumn) return null;
const columnTasks = groupedTasks[column];
const isEmpty = columnTasks.length === 0;
@@ -1724,8 +1930,8 @@ export function ListView({
size={14}
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
/>
<span className={`list-section-dot dot-${column}`} />
<span className="list-section-title">{columnLabel(column)}</span>
<span className={`list-section-dot dot-${column}`} style={{ backgroundColor: columnColor(column) }} />
<span className="list-section-title">{getListColumnLabel(column)}</span>
<span className="list-section-count">{columnTasks.length}</span>
</div>
@@ -1882,12 +2088,13 @@ export function ListView({
</tr>
</thead>
<tbody>
{COLUMNS.map((column) => {
{listColumns.map((columnDef) => {
const column = columnDef.id;
// When column filter is active, only show the selected column
if (selectedColumn && column !== selectedColumn) return null;
// Skip done and archived column sections when hideDoneTasks is enabled (unless it's the selected column)
if (hideDoneTasks && (column === "done" || column === "archived") && !selectedColumn) return null;
if (hideDoneTasks && (columnDef.flags.complete || columnDef.flags.archived) && !selectedColumn) return null;
const columnTasks = groupedTasks[column];
const isEmpty = columnTasks.length === 0;
@@ -1910,8 +2117,8 @@ export function ListView({
size={14}
className={`list-section-chevron${!isCollapsed ? " list-section-chevron--expanded" : ""}`}
/>
<span className={`list-section-dot dot-${column}`} />
<span className="list-section-title">{columnLabel(column)}</span>
<span className={`list-section-dot dot-${column}`} style={{ backgroundColor: columnColor(column) }} />
<span className="list-section-title">{getListColumnLabel(column)}</span>
<span className="list-section-count">{columnTasks.length}</span>
</th>
</tr>
@@ -2019,7 +2226,7 @@ export function ListView({
color: columnColor(task.column),
}}
>
{columnLabel(task.column)}
{getListColumnLabel(task.column)}
</span>
</td>
)}

View File

@@ -635,6 +635,9 @@
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
}
@@ -666,15 +669,27 @@
}
.mailbox-view .mailbox-split-resize-handle {
position: relative;
width: var(--space-sm);
flex-shrink: 0;
cursor: col-resize;
background: color-mix(in srgb, var(--border) 70%, transparent);
background: transparent;
touch-action: none;
transition: background var(--transition-fast);
border-radius: var(--radius-sm);
}
.mailbox-view .mailbox-split-resize-handle:hover,
.mailbox-view .mailbox-split-resize-handle:focus-visible {
.mailbox-view .mailbox-split-resize-handle::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: var(--space-xs);
transform: translateX(-50%);
}
.mailbox-view .mailbox-split-resize-handle:hover::before,
.mailbox-view .mailbox-split-resize-handle:active::before {
background: color-mix(in srgb, var(--todo) 35%, transparent);
}

View File

@@ -584,6 +584,7 @@
flex: 1;
min-height: 0;
min-width: 0;
width: 100%;
overflow: hidden;
}
@@ -598,4 +599,3 @@
display: none;
}
}

View File

@@ -259,6 +259,10 @@
min-width: 0;
}
.wf-editor-mobile-back {
display: none;
}
.wf-editor-canvas-empty {
flex: 1;
justify-content: center;
@@ -1185,14 +1189,55 @@
flex-direction: column;
}
.wf-editor-sidebar {
.wf-editor-body--list-stage .wf-editor-sidebar {
display: flex;
width: 100%;
max-height: 30vh;
flex: 1 1 auto;
max-height: none;
border-right: none;
border-bottom: 1px solid var(--border);
border-bottom: none;
overflow-y: auto;
}
.wf-editor-body--list-stage .wf-editor-canvas-wrap,
.wf-editor-body--list-stage .wf-editor-inspector {
display: none;
}
.wf-editor-body--editor-stage .wf-editor-sidebar {
display: none;
}
.wf-editor-body--editor-stage .wf-editor-canvas-wrap {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.wf-editor-body--editor-stage .wf-editor-inspector {
width: 100%;
min-width: 0;
max-height: 45vh;
border-left: none;
border-top: 1px solid var(--border);
overflow-y: auto;
}
.wf-editor-mobile-back {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: var(--space-xs);
margin: var(--space-sm) var(--space-sm) 0;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 0.8rem;
cursor: pointer;
}
.wf-editor-sidebar .wf-column-panel {
width: 100%;
min-width: 0;
@@ -1211,7 +1256,7 @@
.wf-editor-canvas-wrap {
flex: 1 1 auto;
min-height: 40vh;
min-height: 0;
}
.wf-template-list {

View File

@@ -15,7 +15,7 @@ import {
type Edge as FlowEdge,
} from "@xyflow/react";
import { useTranslation } from "react-i18next";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, Library, Sparkles } from "lucide-react";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles } from "lucide-react";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
@@ -84,6 +84,24 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
const seam = typeof config?.seam === "string" ? config.seam : "";
switch (seam) {
case "planning":
return "Generate the task plan and write the planning artifact that downstream workflow nodes consume.";
case "execute":
return "Run Fusion's standard implementation prompt for this task, generated from the task spec, current project context, workflow fields, and available tools.";
case "step-execute":
return "Run Fusion's step implementation prompt for the active planned step in the task worktree.";
case "review":
return "Run Fusion's review boundary for completed implementation work and route the task based on the review result.";
case "merge":
return "Run Fusion's merge boundary: verify merge readiness, apply the configured merge strategy, and update the final task state.";
default:
return "";
}
}
/** Adapter descriptor served by GET /api/cli-agents (U15). */
interface CliAdapterDescriptorView {
id: string;
@@ -152,6 +170,8 @@ interface WorkflowNodeEditorProps {
* mount (U6/U9: redirect stubs link here via a `?panel=settings` param read by
* the editor's mount site). */
initialPanel?: "settings";
/** When "create" the editor opens with the new-workflow dialog active. */
initialAction?: "create";
}
let nodeSeq = 0;
@@ -633,10 +653,15 @@ function InnerEditor({
addToast,
projectId,
initialPanel,
initialAction,
modalRef,
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [workflowListStageOpen, setWorkflowListStageOpen] = useState(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(max-width: 768px)").matches;
});
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
@@ -652,7 +677,7 @@ function InnerEditor({
const { confirm } = useConfirm();
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
// "New workflow" button (NewTaskModal focus pattern).
const [createOpen, setCreateOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(initialAction === "create");
const newWorkflowBtnRef = useRef<HTMLButtonElement>(null);
// Inline-editable name/description (KTD-10). `name`/`description` mirror the
// active workflow and are persisted through handleSave; `editingName`/
@@ -1784,6 +1809,13 @@ function InnerEditor({
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null;
const selectedNodePromptValue =
selectedNode && (selectedNode.data.kind === "prompt" || selectedNode.data.kind === "gate")
? String(
selectedNode.data.config?.prompt
?? (isBuiltin ? builtinSeamPrompt(selectedNode.data.config as Record<string, unknown> | undefined) : ""),
)
: "";
// The edge inspector renders different controls per source-node kind (KTD-2):
// step-review → verdict controls; prompt/script/gate/code/foreach →
// success/failure select; everything else → a read-only condition note.
@@ -1975,8 +2007,14 @@ function InnerEditor({
// before the active workflow changes (cancel keeps the current selection).
const requestSwitch = useCallback(
(id: string) => {
if (id === activeId) return;
guardedDismiss(() => setActiveId(id));
if (id === activeId) {
setWorkflowListStageOpen(false);
return;
}
guardedDismiss(() => {
setActiveId(id);
setWorkflowListStageOpen(false);
});
},
[guardedDismiss, activeId],
);
@@ -2050,7 +2088,7 @@ function InnerEditor({
</div>
) : null}
<div className="wf-editor-body">
<div className={`wf-editor-body${workflowListStageOpen ? " wf-editor-body--list-stage" : " wf-editor-body--editor-stage"}`}>
<aside className="wf-editor-sidebar">
<button
className="wf-editor-new"
@@ -2190,6 +2228,7 @@ function InnerEditor({
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
initialTab="values"
/>
</div>
)}
@@ -2199,6 +2238,15 @@ function InnerEditor({
</aside>
<section className="wf-editor-canvas-wrap">
<button
type="button"
className="wf-editor-mobile-back"
onClick={() => setWorkflowListStageOpen(true)}
aria-label={t("workflows.backToWorkflowList", "Back to workflows")}
>
<ChevronLeft size={16} />
<span>{t("common.back", "Back")}</span>
</button>
{activeWorkflow ? (
<>
{/* Inline name + description strip (KTD-10). Built-ins render as
@@ -2681,7 +2729,7 @@ function InnerEditor({
<span>Prompt</span>
<textarea
rows={5}
value={String(selectedNode.data.config?.prompt ?? "")}
value={selectedNodePromptValue}
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
/>
</label>

View File

@@ -19,6 +19,7 @@
padding: var(--space-md);
border-left: 1px solid var(--border);
overflow-y: auto;
color: var(--text);
}
.wf-settings-panel-header {
@@ -46,7 +47,7 @@
}
.wf-settings-tab.is-active {
color: var(--text-primary, #fff);
color: var(--text);
border-bottom-color: var(--accent, #4f7cff);
}
@@ -137,7 +138,7 @@
.wf-setting-id-static {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-tertiary);
color: var(--text-muted);
background: var(--surface-2, rgba(255, 255, 255, 0.04));
padding: 1px 6px;
border-radius: var(--radius-sm);
@@ -181,7 +182,7 @@
.wf-setting-sub > span {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
color: var(--text-muted);
}
.wf-setting--checkbox {
@@ -203,7 +204,7 @@
.wf-setting-options-label {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
color: var(--text-muted);
}
.wf-setting-option-row {
@@ -233,7 +234,7 @@
}
.wf-setting-color-swatch.is-active {
outline: 2px solid var(--text-primary, #fff);
outline: 2px solid var(--text);
outline-offset: 1px;
}
@@ -333,7 +334,7 @@
.wf-settings-orphan-id {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-tertiary);
color: var(--text-muted);
}
.wf-settings-orphan-value {

View File

@@ -69,6 +69,7 @@ interface WorkflowSettingsPanelProps {
* no active project (Values tab shows a requires-project state). */
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
initialTab?: "definitions" | "values";
}
const SETTING_TYPES: WorkflowSettingType[] = [
@@ -816,9 +817,12 @@ export function WorkflowSettingsPanel({
readOnly,
projectId,
addToast,
initialTab,
}: WorkflowSettingsPanelProps) {
const { t } = useTranslation("app");
const [tab, setTab] = useState<"definitions" | "values">(() => (settings.length > 0 ? "values" : "definitions"));
const [tab, setTab] = useState<"definitions" | "values">(
() => initialTab ?? (settings.length > 0 ? "values" : "definitions"),
);
// Bind the projectId active when the panel first mounted for this workflow.
// The Values tab uses this bound id; a later change to `projectId` surfaces a

View File

@@ -7,6 +7,8 @@ import { COLUMNS } from "@fusion/core";
import type { Task } from "@fusion/core";
const fetchBatchMock = vi.fn();
const pendingWorkflowSteps = () => new Promise<never>(() => {});
const fetchWorkflowStepsMock = vi.fn().mockImplementation(pendingWorkflowSteps);
vi.mock("../../hooks/useBatchBadgeFetch", () => ({
useBatchBadgeFetch: vi.fn(() => ({
@@ -17,18 +19,12 @@ vi.mock("../../hooks/useBatchBadgeFetch", () => ({
})),
}));
const fetchBoardWorkflowsMock = vi.fn().mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
const pendingBoardWorkflows = () => new Promise<never>(() => {});
const fetchBoardWorkflowsMock = vi.fn().mockImplementation(pendingBoardWorkflows);
const promoteTaskMock = vi.fn().mockResolvedValue({});
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn().mockResolvedValue([
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
]),
fetchWorkflowSteps: (...args: unknown[]) => fetchWorkflowStepsMock(...args),
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
promoteTask: (...args: unknown[]) => promoteTaskMock(...args),
}));
@@ -52,10 +48,10 @@ const columnRenderCounts: Record<string, number> = {};
// Mock child components so we only test Board's own rendering
vi.mock("../Column", () => ({
Column: React.memo(({ column, tasks, onToggleCollapse, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
Column: React.memo(({ column, tasks, onToggleCollapse, onQuickCreate, onNewTask, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, workflowStepNameLookup }: { column: string; tasks: Task[]; onToggleCollapse?: () => void; onQuickCreate?: unknown; onNewTask?: unknown; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; isSearchActive?: boolean; workflowStepNameLookup?: ReadonlyMap<string, string> }) => {
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return (
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-workflow-lookup-size={String(workflowStepNameLookup?.size ?? 0)}>
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
</div>
);
@@ -94,16 +90,13 @@ const noopAsync = () => Promise.resolve({} as any);
beforeEach(() => {
fetchBatchMock.mockReset();
fetchWorkflowStepsMock.mockReset();
fetchWorkflowStepsMock.mockImplementation(pendingWorkflowSteps);
promoteTaskMock.mockClear();
subscribeSseMock.mockClear();
for (const key of Object.keys(sseHandlers)) delete sseHandlers[key];
fetchBoardWorkflowsMock.mockReset();
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
fetchBoardWorkflowsMock.mockImplementation(pendingBoardWorkflows);
try {
window.localStorage.clear();
} catch {
@@ -219,6 +212,9 @@ describe("Board", () => {
});
it("forwards board-level workflow name lookup to columns", async () => {
fetchWorkflowStepsMock.mockResolvedValue([
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
]);
renderBoard();
await waitFor(() => {
@@ -926,7 +922,12 @@ describe("Board", () => {
}
it("flag OFF renders the legacy single-lane board byte-identically", async () => {
// Default mock: flagEnabled false.
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
// Let the board-workflows fetch resolve (flagEnabled:false) so the async
// state settle is wrapped and the legacy board stays the rendered output.
@@ -940,16 +941,38 @@ describe("Board", () => {
expect(screen.queryByTestId(/^lane-/)).toBeNull();
});
it("tasks with no selection render in the default lane (R16)", async () => {
it("tasks with no selection render in the default selected workflow", async () => {
enableFlag({ "FN-1": "builtin:coding", "FN-2": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "in-progress" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
const lane = screen.getByTestId("lane-builtin:coding");
expect(JSON.parse(lane.getAttribute("data-lane-task-ids") || "[]").sort()).toEqual(["FN-1", "FN-2"]);
expect(lane.getAttribute("data-lane-count")).toBe("2");
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
expect(JSON.parse(screen.getByTestId("column-in-progress").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-2"]);
expect(screen.queryByLabelText("Select workflow")).toBeNull();
});
it("each card appears in exactly one lane over a mixed fixture", async () => {
it("puts create controls on the workflow intake column instead of the first visible column", async () => {
const workflow = {
id: "wf-intake-second",
name: "Intake second",
columns: [
{ id: "queue", name: "Queue", flags: { hold: true } },
{ id: "idea", name: "Idea", flags: { intake: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
};
enableFlag({ "FN-1": workflow.id }, [workflow]);
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "queue" })] });
await waitFor(() => expect(screen.getByTestId("column-queue")).toBeDefined());
expect(screen.getByTestId("column-queue").getAttribute("data-has-new-task")).toBe("no");
expect(screen.getByTestId("column-queue").getAttribute("data-has-quick-create")).toBe("no");
expect(screen.getByTestId("column-idea").getAttribute("data-has-new-task")).toBe("yes");
expect(screen.getByTestId("column-idea").getAttribute("data-has-quick-create")).toBe("yes");
});
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {
const onCreateWorkflow = vi.fn();
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom", "FN-3": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
@@ -960,57 +983,75 @@ describe("Board", () => {
mkTask({ id: "FN-2", column: "intake" }),
mkTask({ id: "FN-3", column: "intake" }),
],
onCreateWorkflow,
});
await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined());
const defaultLaneIds = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]");
const customLaneIds = JSON.parse(screen.getByTestId("lane-wf-custom").getAttribute("data-lane-task-ids") || "[]");
const all = [...defaultLaneIds, ...customLaneIds].sort();
expect(all).toEqual(["FN-1", "FN-2", "FN-3"]);
// No id appears twice.
expect(new Set(all).size).toBe(all.length);
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
expect(selector.value).toBe("builtin:coding");
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
expect(screen.queryByTestId("column-intake")).toBeNull();
fireEvent.change(selector, { target: { value: "wf-custom" } });
await waitFor(() => expect(screen.getByTestId("column-intake")).toBeDefined());
expect(JSON.parse(screen.getByTestId("column-intake").getAttribute("data-tasks") || "[]").map((task: Task) => task.id).sort()).toEqual(["FN-2", "FN-3"]);
expect(screen.queryByTestId("column-todo")).toBeNull();
});
it("hides zero-card lanes and puts the default lane first", async () => {
it("keeps the default workflow first in the dropdown even when another workflow has cards", async () => {
enableFlag(
{ "FN-2": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
);
// Only the custom workflow has a card; the default lane has none → hidden.
renderBoard({ tasks: [mkTask({ id: "FN-2", column: "intake" })] });
await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined());
expect(screen.queryByTestId("lane-builtin:coding")).toBeNull();
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
const options = [...selector.options].map((option) => option.value);
expect(options).toEqual(["builtin:coding", "wf-custom"]);
expect(selector.value).toBe("builtin:coding");
expect(screen.queryByTestId("column-intake")).toBeNull();
});
it("orders the default lane first when both have cards", async () => {
it("orders the default workflow first when workflow payload order differs", async () => {
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
[CUSTOM_WORKFLOW, DEFAULT_WORKFLOW],
);
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
const lanes = screen.getAllByTestId(/^lane-/);
expect(lanes[0].getAttribute("data-testid")).toBe("lane-builtin:coding");
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
expect([...selector.options].map((option) => option.value)).toEqual(["builtin:coding", "wf-custom"]);
});
it("excludes archived cards from lanes", async () => {
it("excludes archived cards from the selected workflow board", async () => {
enableFlag({ "FN-1": "builtin:coding", "FN-9": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-9", column: "archived" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
const ids = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]");
await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
const ids = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id);
expect(ids).toEqual(["FN-1"]);
expect(screen.queryByTestId("column-archived")).toBeNull();
});
it("persists lane collapse state to localStorage", async () => {
window.localStorage.setItem("kb-dashboard-lane-collapsed", JSON.stringify(["builtin:coding"]));
enableFlag({ "FN-1": "builtin:coding" });
it("renders selected workflow columns as direct children of the horizontal board", async () => {
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
expect(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-collapsed")).toBe("true");
await waitFor(() => expect(screen.getByRole("main").className).toContain("board-workflow-columns"));
expect([...screen.getByRole("main").children].map((child) => child.getAttribute("data-testid"))).toEqual([
"column-triage",
"column-todo",
"column-in-progress",
"column-in-review",
"column-done",
]);
});
});
describe("workflow:updated SSE invalidation (#1406)", () => {
it("re-fetches board-workflows when a workflow:updated SSE event arrives", async () => {
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
renderBoard({ projectId: "proj-1" });
// Initial mount fetch.
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1));

View File

@@ -91,6 +91,15 @@ describe("Lane", () => {
expect(headings).not.toContain("Archived");
});
it("renders creation controls only in the first visible column", () => {
render(<Lane {...baseProps()} onQuickCreate={vi.fn()} onNewTask={vi.fn()} />);
expect(screen.getAllByTestId("quick-entry-box")).toHaveLength(1);
expect(screen.getAllByText("+ New Task")).toHaveLength(1);
expect(screen.getByTestId("quick-entry-box").closest("[data-column]")?.getAttribute("data-column")).toBe("triage");
expect(screen.getByText("+ New Task").closest("[data-column]")?.getAttribute("data-column")).toBe("triage");
});
it("collapses the lane (hides columns) when collapsed", () => {
render(<Lane {...baseProps()} collapsed />);
expect(screen.queryByText("Triage")).toBeNull();

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { useState } from "react";
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ListView } from "../ListView";
import type { Task, TaskDetail } from "@fusion/core";
@@ -22,12 +23,135 @@ vi.mock("../../api", () => ({
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn().mockResolvedValue([]),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
fetchNodes: vi.fn(() => new Promise(() => {})),
fetchBoardWorkflows: vi.fn(() => new Promise(() => {})),
api: vi.fn().mockResolvedValue({ sessions: [] }),
}));
import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";
const listViewSseHandlers: Record<string, (event?: unknown) => void> = {};
const subscribeSseMock = vi.fn(
(_url: string, opts: { events?: Record<string, (event?: unknown) => void> }) => {
for (const [name, handler] of Object.entries(opts.events ?? {})) {
listViewSseHandlers[name] = handler;
}
return () => {};
},
);
vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: unknown[]) => (subscribeSseMock as (...a: unknown[]) => () => void)(...args),
}));
vi.mock("../QuickEntryBox", () => ({
QuickEntryBox: ({
onCreate,
addToast,
}: {
onCreate?: (input: { description: string }) => Promise<unknown>;
addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void;
}) => {
const [value, setValue] = useState("");
const [expanded, setExpanded] = useState(false);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const submit = async () => {
const description = value.trim();
if (!description || !onCreate) return;
try {
await onCreate({ description });
setValue("");
} catch (err) {
addToast(err instanceof Error ? err.message : "Failed to create task", "error");
}
};
return (
<div className="quick-entry-box" data-testid="quick-entry-box">
<textarea
className="quick-entry-input"
data-testid="quick-entry-input"
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submit();
}
}}
/>
<button
type="button"
data-testid="quick-entry-toggle"
aria-expanded={expanded}
onClick={() => setExpanded((next) => !next)}
>
Toggle
</button>
<div id="quick-entry-controls" data-testid="quick-entry-actions" hidden={!expanded}>
<button
type="button"
data-testid="quick-entry-models"
onClick={() => setModelMenuOpen((next) => !next)}
>
Models
</button>
<button type="button" data-testid="quick-entry-deps">Deps</button>
<button type="button" data-testid="quick-entry-save" onClick={() => void submit()}>
Save
</button>
</div>
{modelMenuOpen ? (
<div data-testid="model-nested-menu">
<button type="button">Plan</button>
<button type="button">Executor</button>
<button type="button">Reviewer</button>
</div>
) : null}
</div>
);
},
}));
vi.mock("../TaskDetailModal", () => ({
TaskDetailContent: ({
task,
onOpenDetail,
}: {
task: Task | TaskDetail;
onOpenDetail?: (task: Task | TaskDetail) => void;
}) => (
<div data-testid="task-detail-content">
<span>{task.id}</span>
{(task.dependencies ?? []).map((dependencyId) => (
<button
key={dependencyId}
type="button"
role="link"
onClick={() =>
onOpenDetail?.({
id: dependencyId,
title: dependencyId,
description: dependencyId,
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
status: "pending",
paused: false,
log: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
prompt: `# ${dependencyId}`,
} as TaskDetail)
}
>
{dependencyId}
</button>
))}
</div>
),
}));
import { fetchTaskDetail, batchUpdateTaskModels, fetchBoardWorkflows, fetchNodes } from "../../api";
const mockConfirm = vi.fn();
const mockConfirmWithChoice = vi.fn();
@@ -99,14 +223,22 @@ const renderListView = (
if (options.openViewOptions ?? true) {
const viewOptionsToggle = screen.queryByRole("button", { name: /view options/i });
if (viewOptionsToggle) {
fireEvent.click(viewOptionsToggle);
act(() => {
fireEvent.click(viewOptionsToggle);
});
}
}
return result;
};
const clickInAct = (element: Element) => {
act(() => {
fireEvent.click(element);
});
};
const enterBulkEditMode = () => {
fireEvent.click(screen.getByRole("button", { name: "Bulk Edit" }));
clickInAct(screen.getByRole("button", { name: "Bulk Edit" }));
};
const showAllColumnsByDefault = () => {
@@ -176,12 +308,16 @@ function mockDesktopViewport() {
describe("ListView", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchNodes).mockImplementation(() => new Promise(() => {}));
vi.mocked(fetchBoardWorkflows).mockImplementation(() => new Promise(() => {}));
vi.mocked(fetchTaskDetail).mockResolvedValue({
...createMockTask(),
prompt: "# Detail",
} as TaskDetail);
mockConfirm.mockReset();
mockConfirmWithChoice.mockReset();
subscribeSseMock.mockClear();
for (const key of Object.keys(listViewSseHandlers)) delete listViewSseHandlers[key];
localStorage.clear();
ensureMatchMedia();
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
@@ -398,9 +534,10 @@ describe("ListView", () => {
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
expect(row?.className).toContain("list-row--selected");
await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", TEST_PROJECT_ID);
expect(screen.getByTestId("list-split-detail-content")).toBeInTheDocument();
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-001");
});
expect(fetchTaskDetail).not.toHaveBeenCalled();
viewportSpy.mockRestore();
});
@@ -439,6 +576,92 @@ describe("ListView", () => {
viewportSpy.mockRestore();
});
it("refreshes workflow columns when workflow metadata SSE arrives", async () => {
vi.mocked(fetchBoardWorkflows)
.mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom" },
})
.mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "ready", name: "Ready", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom" },
});
renderListView({
tasks: [createMockTask({ id: "FN-001", column: "backlog", title: "Workflow task" })],
});
await waitFor(() => expect(screen.queryAllByText("Backlog").length).toBeGreaterThan(0));
expect(typeof listViewSseHandlers["workflow:updated"]).toBe("function");
await act(async () => {
listViewSseHandlers["workflow:updated"]?.();
});
await waitFor(() => expect(screen.queryAllByText("Ready").length).toBeGreaterThan(0));
expect(screen.queryAllByText("Backlog")).toHaveLength(0);
});
it("shows a new-workflow action next to the workflow selector", async () => {
const onCreateWorkflow = vi.fn();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [
{ id: "triage", name: "Triage", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
},
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "backlog", name: "Backlog", flags: { intake: true } },
{ id: "complete", name: "Complete", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "builtin:coding" },
});
renderListView({
tasks: [createMockTask({ id: "FN-001", column: "triage", title: "Workflow task" })],
onCreateWorkflow,
});
await screen.findByLabelText("Select workflow");
const createButtons = screen.getAllByRole("button", { name: "New workflow" });
expect(createButtons.length).toBeGreaterThan(0);
fireEvent.click(createButtons[0]);
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
});
it("keeps embedded selection visible when filters hide the selected row", async () => {
const viewportSpy = mockDesktopViewport();
const tasks = [
@@ -480,16 +703,6 @@ describe("ListView", () => {
const tasks = [createMockTask({ id: "FN-001", title: "Parent Task", dependencies: ["FN-002"] })];
const mockOnOpenDetail = vi.fn();
vi.mocked(fetchTaskDetail)
.mockResolvedValueOnce({
...tasks[0],
prompt: "# Parent detail",
} as TaskDetail)
.mockResolvedValueOnce({
...createMockTask({ id: "FN-002", title: "Child Task" }),
prompt: "# Child detail",
} as TaskDetail);
renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
fireEvent.click(screen.getByText("FN-001").closest("tr")!);
@@ -502,9 +715,10 @@ describe("ListView", () => {
await waitFor(() => {
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-002");
expect(fetchTaskDetail).toHaveBeenNthCalledWith(2, "FN-002", TEST_PROJECT_ID);
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-002");
});
expect(fetchTaskDetail).not.toHaveBeenCalled();
expect(mockOnOpenDetail).not.toHaveBeenCalled();
viewportSpy.mockRestore();
});
@@ -519,8 +733,8 @@ describe("ListView", () => {
enterBulkEditMode();
const checkbox = within(row).getByRole("checkbox", { name: "Select FN-001" });
fireEvent.click(checkbox);
fireEvent.click(row);
clickInAct(checkbox);
clickInAct(row);
await waitFor(() => {
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toContain("FN-001");
@@ -2448,6 +2662,10 @@ describe("ListView Collapsible Sections", () => {
describe("ListView - Bulk Selection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchNodes).mockImplementation(() => new Promise(() => {}));
vi.mocked(fetchBoardWorkflows).mockImplementation(() => new Promise(() => {}));
subscribeSseMock.mockClear();
for (const key of Object.keys(listViewSseHandlers)) delete listViewSseHandlers[key];
localStorage.clear();
ensureMatchMedia();
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
@@ -2519,7 +2737,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
clickInAct(checkbox);
expect(screen.getByText("1 selected")).toBeDefined();
});
@@ -2532,11 +2750,11 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
clickInAct(checkbox);
expect(screen.getByText("1 selected")).toBeDefined();
const clearButton = screen.getByRole("button", { name: /^1 selected$/i });
fireEvent.click(clearButton);
clickInAct(clearButton);
expect(screen.queryByText("1 selected")).toBeNull();
});
@@ -2550,7 +2768,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode();
const selectAllCheckbox = screen.getByLabelText("Select all visible tasks");
fireEvent.click(selectAllCheckbox);
clickInAct(selectAllCheckbox);
expect(screen.getByRole("button", { name: /^2 selected$/i })).toBeDefined();
});
@@ -2580,7 +2798,7 @@ describe("ListView - Bulk Selection", () => {
// Select a task to show bulk edit toolbar with dropdowns
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
clickInAct(checkbox);
expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined();
});
@@ -2603,7 +2821,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
clickInAct(checkbox);
expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined();
});
@@ -2626,7 +2844,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
clickInAct(checkbox);
const applyButton = screen.getByText("Apply");
expect(applyButton).toBeDisabled();
@@ -2765,6 +2983,43 @@ describe("ListView - Bulk Selection", () => {
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 1 skipped · 0 failed", "success");
});
it("archives workflow complete-column tasks in bulk selection", async () => {
const user = userEvent.setup();
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "wf-custom",
workflows: [
{
id: "wf-custom",
name: "Custom",
columns: [
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
],
},
],
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom" },
});
const tasks = [
createMockTask({ id: "FN-001", column: "shipped" }),
createMockTask({ id: "FN-002", column: "doing" }),
];
const onArchiveTask = vi.fn(async () => createMockTask());
mockConfirm.mockResolvedValueOnce(true);
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
renderListView({ tasks, onArchiveTask });
enterBulkEditMode();
await waitFor(() => expect(screen.queryAllByText("Shipped").length).toBeGreaterThan(0));
await user.click(screen.getByRole("button", { name: /^archive selected$/i }));
await waitFor(() => {
expect(onArchiveTask).toHaveBeenCalledTimes(1);
expect(onArchiveTask).toHaveBeenCalledWith("FN-001");
});
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 1 skipped · 0 failed", "success");
});
it("shows error summary when pause has failures", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", paused: false })];
@@ -2978,7 +3233,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox);
clickInAct(checkbox);
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toBe('["FN-001"]');
});
@@ -2993,7 +3248,7 @@ describe("ListView - Bulk Selection", () => {
const checkboxes = screen.getAllByLabelText(/Select FN-/);
// Select only first task
fireEvent.click(checkboxes[0]);
clickInAct(checkboxes[0]);
// Header checkbox should be indeterminate (partially selected)
const headerCheckbox = screen.getByLabelText("Select all visible tasks") as HTMLInputElement;
@@ -3100,7 +3355,7 @@ describe("ListView - Bulk Selection", () => {
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
enterBulkEditMode();
fireEvent.click(screen.getByLabelText("Select FN-001"));
clickInAct(screen.getByLabelText("Select FN-001"));
expect(await screen.findByLabelText("Node Override")).toBeInTheDocument();
expect(await screen.findByRole("option", { name: "● Node One (Online)" })).toBeInTheDocument();
@@ -3112,7 +3367,7 @@ describe("ListView - Bulk Selection", () => {
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
enterBulkEditMode();
fireEvent.click(screen.getByLabelText("Select FN-001"));
clickInAct(screen.getByLabelText("Select FN-001"));
expect(await screen.findByRole("option", { name: "○ Node Two (Offline)" })).toBeInTheDocument();
});
@@ -3177,7 +3432,7 @@ describe("ListView - Bulk Selection", () => {
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
enterBulkEditMode();
fireEvent.click(screen.getByLabelText("Select FN-001"));
clickInAct(screen.getByLabelText("Select FN-001"));
expect(await screen.findByRole("button", { name: "Apply" })).toBeDisabled();
});
@@ -3403,7 +3658,7 @@ describe("ListView - Bulk Selection", () => {
});
enterBulkEditMode();
fireEvent.click(screen.getByLabelText("Select FN-002"));
clickInAct(screen.getByLabelText("Select FN-002"));
expect((screen.getByLabelText("Select FN-001") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Select FN-002") as HTMLInputElement).checked).toBe(true);

View File

@@ -3,7 +3,7 @@ import { render, screen, waitFor, cleanup, within } from "@testing-library/react
import type { WorkflowDefinition, Settings } from "@fusion/core";
import type { Agent } from "../../api";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, foreachChildFlowId } from "../workflow-flow-mapping";
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflows: vi.fn(),
@@ -39,6 +39,8 @@ vi.mock("../../api", () => ({
fetchSettings: vi.fn(),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }),
updateWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }),
}));
import { fireEvent } from "@testing-library/react";
@@ -119,8 +121,16 @@ function v2Def(): WorkflowDefinition {
}
function builtinDef(): WorkflowDefinition {
const d = v2Def();
return { ...d, id: "builtin:coding", name: "Default coding workflow", description: "Ships with Fusion" };
return {
id: "builtin:coding",
kind: "workflow",
name: "Default coding workflow",
description: "Ships with Fusion",
ir: BUILTIN_CODING_WORKFLOW_IR,
layout: {},
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
function fragmentDef(): WorkflowDefinition {
@@ -840,6 +850,31 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
expect(approve).toMatchObject({ from: "step-review", to: "step-done" });
expect(approve?.kind).toBeUndefined();
});
it("shows the built-in seam prompt text in the read-only node inspector", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-readonly-banner");
const promptNodes = await screen.findAllByTestId("wf-node-prompt");
const executeNode = promptNodes.find((node) => within(node).queryByText("Execute"));
expect(executeNode).toBeTruthy();
fireEvent.click(executeNode!);
expect((screen.getByLabelText("Prompt") as HTMLTextAreaElement).value).toContain(
"Fusion's standard implementation prompt",
);
});
it("opens workflow settings on the Values tab from the editor sidebar", async () => {
localStorage.setItem("fusion:wf-sidebar-settings-collapsed", "0");
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} projectId="p1" />);
await screen.findByTestId("wf-settings-values");
expect(screen.getByTestId("wf-settings-tab-values")).toHaveAttribute("aria-selected", "true");
});
});
// ── U2: edge-condition authoring (compile-banner split) ─────────────────────
@@ -932,6 +967,13 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir
expect(screen.getByTestId("wf-create-dialog")).toBeInTheDocument();
});
it("can open directly into the create dialog", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialAction="create" />);
expect(await screen.findByTestId("wf-create-dialog")).toBeInTheDocument();
});
it("creates and activates a workflow on a valid submit", async () => {
const addToast = vi.fn();
vi.mocked(fetchWorkflows).mockResolvedValue([]);

View File

@@ -56,6 +56,8 @@ export interface ModalManager {
workflowEditorOpen: boolean;
/** When the workflow editor opens, which internal panel to pre-select (U9 redirect stubs). */
workflowEditorInitialPanel?: "settings";
/** When the workflow editor opens, which modal action to start. */
workflowEditorInitialAction?: "create";
agentsOpen: boolean;
scriptsOpen: boolean;
setupWizardOpen: boolean;
@@ -118,7 +120,7 @@ export interface ModalManager {
openGitManager: () => void;
closeGitManager: () => void;
openWorkflowEditor: (initialPanel?: "settings") => void;
openWorkflowEditor: (initialPanelOrAction?: "settings" | "create") => void;
closeWorkflowEditor: () => void;
openAgents: () => void;
@@ -178,6 +180,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowEditorOpen, setWorkflowEditorOpen] = useState(false);
const [workflowEditorInitialPanel, setWorkflowEditorInitialPanel] = useState<"settings" | undefined>(undefined);
const [workflowEditorInitialAction, setWorkflowEditorInitialAction] = useState<"create" | undefined>(undefined);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
@@ -343,13 +346,15 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
const openWorkflowEditor = useCallback((initialPanel?: "settings") => {
setWorkflowEditorInitialPanel(initialPanel);
const openWorkflowEditor = useCallback((initialPanelOrAction?: "settings" | "create") => {
setWorkflowEditorInitialPanel(initialPanelOrAction === "settings" ? "settings" : undefined);
setWorkflowEditorInitialAction(initialPanelOrAction === "create" ? "create" : undefined);
setWorkflowEditorOpen(true);
}, []);
const closeWorkflowEditor = useCallback(() => {
setWorkflowEditorOpen(false);
setWorkflowEditorInitialPanel(undefined);
setWorkflowEditorInitialAction(undefined);
}, []);
const openAgents = useCallback(() => setAgentsOpen(true), []);
@@ -418,6 +423,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
gitManagerOpen,
workflowEditorOpen,
workflowEditorInitialPanel,
workflowEditorInitialAction,
agentsOpen,
scriptsOpen,
setupWizardOpen,

View File

@@ -34,7 +34,7 @@ export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandler
const handleBoardQuickCreate = useCallback(
async (input: TaskCreateInput): Promise<Task> => {
return createTask({ ...input, column: "triage", source: { sourceType: "dashboard_ui" } });
return createTask({ ...input, column: input.column ?? "triage", source: { sourceType: "dashboard_ui" } });
},
[createTask],
);

View File

@@ -500,7 +500,7 @@ export function useTasks(options?: UseTasksOptions) {
const moveTask = useCallback(async (
id: string,
column: Column,
column: ColumnId,
optionsOrPosition?: { preserveProgress?: boolean } | number,
): Promise<Task> => {
return normalizeTask(await api.moveTask(id, column, projectId, optionsOrPosition));

View File

@@ -42,6 +42,9 @@ function makeStore(opts: {
async getWorkflowDefinition(id: string) {
return opts.defs?.[id];
},
async listWorkflowDefinitions() {
return Object.values(opts.defs ?? {});
},
};
}
@@ -71,6 +74,24 @@ describe("buildBoardWorkflowsPayload", () => {
"done",
"archived",
]);
expect(defaultWf!.columns.map((c) => c.name)).toEqual([
"Triage",
"Todo",
"In Progress",
"In Review",
"Done",
"Archived",
]);
});
it("includes user-defined workflows even when no visible task references them", async () => {
const store = makeStore({
flagOn: true,
selections: {},
defs: { "wf-custom": CUSTOM },
});
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1"]);
expect(payload.workflows.map((w) => w.id).sort()).toEqual([DEFAULT_WORKFLOW_LANE_ID, "wf-custom"]);
});
it("describes a custom workflow's columns with resolved trait flags", async () => {

View File

@@ -44,6 +44,23 @@ function makeFakeAgent(text: string) {
return { factory, captured };
}
function makeNonStreamingFakeAgent(text: string) {
const captured: { systemPrompt?: string; userPrompt?: string } = {};
const factory: any = async (opts: any) => {
captured.systemPrompt = opts.systemPrompt;
const session = {
state: { messages: [] as Array<{ role: string; content: string }> },
async prompt(userPrompt: string) {
captured.userPrompt = userPrompt;
this.state.messages.push({ role: "assistant", content: text });
},
dispose() {},
};
return { session };
};
return { factory, captured };
}
/** A fake agent whose prompt rejects; tracks whether dispose() was called so we
* can assert the route releases the session even when the model turn throws. */
function makeRejectingAgent() {
@@ -161,6 +178,17 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => {
expect(res.body.strippedApprovalFlags).toBe(false);
});
it("non-streaming agent session without .on() → reads last assistant message", async () => {
const { factory, captured } = makeNonStreamingFakeAgent(JSON.stringify(linearIr()));
__setCreateFnAgentForDesign(factory);
const res = await postJson("/api/workflows/design", { prompt: "a coding flow" });
expect(res.status).toBe(200);
expect(res.body.interpreterOnly).toBe(false);
expect(res.body.ir.nodes).toHaveLength(3);
expect(captured.userPrompt).toContain("Design a workflow");
});
it("fenced + prose-wrapped JSON → still extracted and 200", async () => {
const wrapped = `Sure! Here is your workflow:\n\n\`\`\`json\n${JSON.stringify(linearIr())}\n\`\`\`\n\nLet me know if you want changes.`;
const { factory } = makeFakeAgent(wrapped);

View File

@@ -68,16 +68,30 @@ export interface BoardWorkflowsPayload {
taskWorkflowIds: Record<string, string>;
}
const BUILTIN_WORKFLOW_COLUMN_LABELS: Record<string, string> = {
triage: "Triage",
todo: "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
};
function toV2(ir: WorkflowIr): WorkflowIrV2 | undefined {
return ir.version === "v2" ? ir : undefined;
}
function describeColumns(ir: WorkflowIr): BoardWorkflowColumn[] {
function displayColumnName(id: string, name: string, canonicalizeLifecycle: boolean): string {
if (!canonicalizeLifecycle) return name;
return BUILTIN_WORKFLOW_COLUMN_LABELS[id] ?? name;
}
function describeColumns(ir: WorkflowIr, canonicalizeLifecycle = false): BoardWorkflowColumn[] {
const v2 = toV2(ir);
if (!v2) return [];
return v2.columns.map((col) => ({
id: col.id,
name: col.name,
name: displayColumnName(col.id, col.name, canonicalizeLifecycle),
flags: resolveColumnFlags(col),
}));
}
@@ -102,7 +116,7 @@ async function describeWorkflow(
const ir = await resolveWorkflowIrById(store, workflowId);
const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name;
const fields = describeFields(ir);
return { id: workflowId, name, columns: describeColumns(ir), ...(fields ? { fields } : {}) };
return { id: workflowId, name, columns: describeColumns(ir, true), ...(fields ? { fields } : {}) };
}
// Custom workflow: fetch the definition once and derive both IR and name from
// it (previously getWorkflowDefinition was called twice per workflow).
@@ -129,7 +143,7 @@ async function describeWorkflow(
* can return early and the client renders the legacy board.
*/
export async function buildBoardWorkflowsPayload(
store: Pick<TaskStore, "getWorkflowDefinition" | "getTaskWorkflowSelection" | "getSettings">,
store: Pick<TaskStore, "getWorkflowDefinition" | "getTaskWorkflowSelection" | "getSettings" | "listWorkflowDefinitions">,
taskIds: string[],
settingsOverride?: Pick<Settings, "experimentalFeatures">,
): Promise<BoardWorkflowsPayload> {
@@ -163,6 +177,17 @@ export async function buildBoardWorkflowsPayload(
// resolves it (and the client's default-lane-first ordering is stable).
referenced.add(DEFAULT_WORKFLOW_LANE_ID);
try {
const definitions = await store.listWorkflowDefinitions();
for (const definition of definitions) {
if (definition.kind === "fragment") continue;
referenced.add(definition.id);
}
} catch {
// Older/partial test stores may not expose definition listing; the referenced
// workflow set above is still sufficient for task rendering.
}
const workflows: BoardWorkflowDefinition[] = [];
for (const workflowId of referenced) {
workflows.push(await describeWorkflow(store, workflowId));

View File

@@ -23,12 +23,14 @@ export function __resetCreateFnAgentForDesign(): void {
createFnAgentForDesign = engineCreateFnAgent;
}
/** Minimal session shape used by the one-shot design turn (mirrors the refine
* route's RefineAgentSession): subscribe to text deltas, prompt once, dispose. */
/** Minimal session shape used by the one-shot design turn. Some agent backends
* stream text deltas, while CLI-agent-backed sessions only expose messages on
* state after prompt() resolves. */
interface DesignAgentSession {
on(event: "text", listener: (delta: string) => void): void;
on?: (event: "text", listener: (delta: string) => void) => void;
prompt(text: string): Promise<void>;
dispose(): void;
dispose?: () => void;
state?: { messages?: unknown };
}
/** Max design prompt length (chars). Over → 400 (mirrors the bounded prompts on
@@ -824,7 +826,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
const designSession = session as unknown as DesignAgentSession;
let output = "";
designSession.on("text", (delta: string) => {
designSession.on?.("text", (delta: string) => {
output += delta;
});
@@ -834,7 +836,11 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
try {
await designSession.prompt(userPrompt);
} finally {
designSession.dispose();
designSession.dispose?.();
}
if (!output.trim()) {
output = extractLastAssistantText(designSession.state?.messages);
}
// Extract JSON (handles fences/prose) → JSON.parse → parseWorkflowIr.
@@ -888,6 +894,39 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
});
}
function extractLastAssistantText(messages: unknown): string {
interface AgentMessage {
role?: unknown;
type?: unknown;
content?: unknown;
}
const lastMessage = (Array.isArray(messages) ? messages : [])
.filter((message): message is AgentMessage => Boolean(message) && typeof message === "object")
.filter((message) => message.role === "assistant" || message.type === "assistant")
.pop();
const content = lastMessage?.content;
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content
.filter((part): part is { type: "text"; text: string } => (
Boolean(part) &&
typeof part === "object" &&
(part as { type?: unknown }).type === "text" &&
typeof (part as { text?: unknown }).text === "string"
))
.map((part) => part.text)
.join("")
.trim();
}
return "";
}
/** Validate trait availability for an imported IR exactly as the store does on
* create (v1 IRs with no columns are a no-op). Kept local to the import route so
* the 422 fires BEFORE any write — the store would also reject, but importing

View File

@@ -1154,6 +1154,40 @@ describe("StuckTaskDetector", () => {
vi.useRealTimers();
});
it("suppresses another loop classification while accepted recovery is pending", async () => {
const onLoopDetected = vi.fn().mockResolvedValue(true);
const customDetector = new StuckTaskDetector(store, { onLoopDetected });
const session = createMockSession();
vi.useFakeTimers({ shouldAdvanceTime: true });
customDetector.trackTask("FN-201", session);
vi.advanceTimersByTime(61000);
for (let i = 0; i < 80; i++) {
customDetector.recordActivity("FN-201");
}
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("loop");
await customDetector.killAndRetry("FN-201", 60000);
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBeNull();
customDetector.markLoopObserved("FN-201");
for (let i = 0; i < 25; i++) {
customDetector.recordIgnoredStepUpdate("FN-201");
}
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("no-progress-churn");
customDetector.recordProgress("FN-201");
vi.advanceTimersByTime(61000);
for (let i = 0; i < 80; i++) {
customDetector.recordActivity("FN-201");
}
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("loop");
vi.useRealTimers();
});
it("does NOT call onLoopDetected when reason is inactivity", async () => {
const onLoopDetected = vi.fn().mockResolvedValue(true);
const onStuck = vi.fn();

View File

@@ -163,6 +163,25 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
expect(result.reason).toMatch(/workflow-missing/);
});
it("resolves built-in workflow selections without requiring the store to return a definition", async () => {
const calls: string[] = [];
const store: WorkflowGraphRunnerStore = {
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: async () => undefined,
};
const runner = new WorkflowGraphTaskRunner({
store,
seams: recordingSeams(calls),
runCustomNode: async () => ({ outcome: "success" }),
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["execute", "review", "merge"]);
expect(result.reason).toBeUndefined();
});
it("falls back (never strands the task) when the interpreter throws", async () => {
// Malformed graph: edge references unknown node → WorkflowIrError inside run().
const badIr: WorkflowIr = {

View File

@@ -419,6 +419,15 @@ export class StuckTaskDetector {
return "no-progress-churn";
}
// After onLoopDetected accepts compact-and-resume, the executor owns the
// recovery prompt. Do not immediately classify the same stale timestamps as
// another loop before the executor has a chance to emit fresh progress. The
// deterministic no-progress-churn terminal path above still has to fire if
// the recovered session keeps hammering rejected step updates.
if (entry.recoveryInProgress) {
return null;
}
// Check loop — active but not making progress, with enough activity to be a real loop
if (noProgressMs >= timeoutMs && entry.activitySinceProgress >= LOOP_ACTIVITY_THRESHOLD) {
return "loop";

View File

@@ -1,5 +1,5 @@
import type { Settings, TaskDetail, WorkflowDefinition } from "@fusion/core";
import { isExperimentalFeatureEnabled } from "@fusion/core";
import { getBuiltinWorkflow, isBuiltinWorkflowId, isExperimentalFeatureEnabled } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js";
import type {
@@ -146,7 +146,9 @@ export class WorkflowGraphTaskRunner {
let definition: WorkflowDefinition | undefined;
try {
definition = await this.deps.store.getWorkflowDefinition(selection.workflowId);
definition = isBuiltinWorkflowId(selection.workflowId)
? getBuiltinWorkflow(selection.workflowId)
: await this.deps.store.getWorkflowDefinition(selection.workflowId);
} catch (err) {
return this.fallBack(task.id, `workflow-load-error: ${err instanceof Error ? err.message : String(err)}`);
}