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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix workflow board/list workflow selection, custom workflow task creation controls, workflow editor defaults, built-in workflow node prompt display, and executor handling for built-in workflow runs.

View File

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

View File

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

View File

@@ -385,6 +385,7 @@ export function AppModals({
addToast={addToast} addToast={addToast}
projectId={projectId} projectId={projectId}
initialPanel={modalManager.workflowEditorInitialPanel} initialPanel={modalManager.workflowEditorInitialPanel}
initialAction={modalManager.workflowEditorInitialAction}
/> />
</Suspense> </Suspense>
</ModalErrorBoundary> </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 { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn } from "./taskSorting"; import { sortTasksForDisplayColumn } from "./taskSorting";
import { Column } from "./Column"; import { Column } from "./Column";
import { Lane } from "./Lane"; import "./Lane.css";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react"; 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 { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { subscribeSse } from "../sse-bus"; 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 { interface BoardProps {
tasks: Task[]; tasks: Task[];
projectId?: string; projectId?: string;
@@ -68,6 +66,10 @@ interface BoardProps {
lastFetchTimeMs?: number; lastFetchTimeMs?: number;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */ /** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean; 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; 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 [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false); const archivedLoadedRef = useRef(false);
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP); 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 // Fetch board-workflows metadata. When the flag is OFF the server returns
// { flagEnabled: false } and we render the legacy single-lane board below. // { flagEnabled: false } and we render the legacy single-lane board below.
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null); const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const draggingTaskIdRef = useRef<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 // Fetch board workflow lanes for the project. Deliberately NOT keyed on
// `tasks` — that refetched on every SSE tick. Instead we refetch on project // `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]); }, [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) => { const handlePromote = useCallback(async (taskId: string) => {
await promoteTask(taskId, projectId); await promoteTask(taskId, projectId);
}, [projectId]); }, [projectId]);
@@ -352,33 +328,65 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
const flagOn = boardWorkflows?.flagEnabled === true; const flagOn = boardWorkflows?.flagEnabled === true;
// Group visible tasks into lanes by resolved workflow (null → default lane). const workflowMode = flagOn && Boolean(boardWorkflows?.workflows.length);
const lanes = useMemo(() => { const workflowOptions = useMemo<BoardWorkflowDefinition[]>(() => {
if (!boardWorkflows || !flagOn) return []; if (!workflowMode || !boardWorkflows) return [];
const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows; return [...boardWorkflows.workflows].sort((a, b) => {
const byId = new Map(workflows.map((w) => [w.id, w] as const)); if (a.id === boardWorkflows.defaultWorkflowId) return -1;
const tasksByWorkflow = new Map<string, Task[]>(); if (b.id === boardWorkflows.defaultWorkflowId) return 1;
for (const task of tasks) { return a.name.localeCompare(b.name);
// 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);
}); });
return result; }, [boardWorkflows, workflowMode]);
}, [boardWorkflows, flagOn, tasks]);
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 // 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. // when the board-workflows payload changes, not on every SSE task tick.
@@ -441,10 +449,52 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
// messages. We do NOT eagerly call `/api/github/batch-status` on board load. // messages. We do NOT eagerly call `/api/github/batch-status` on board load.
if (flagOn) { if (workflowMode && selectedWorkflow) {
return ( return (
<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 <main
className="board board-lanes" className="board board-workflow-columns"
id="board" id="board"
ref={boardRef} ref={boardRef}
onDragStart={(e) => { onDragStart={(e) => {
@@ -455,27 +505,28 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
draggingTaskIdRef.current = null; draggingTaskIdRef.current = null;
}} }}
> >
{lanes.map(({ workflow, tasks: laneTasks }) => ( {selectedWorkflowColumns.map((columnDef) => {
<Lane const isCreateColumn = columnDef.id === selectedWorkflowCreateColumnId;
key={workflow.id} return (
workflow={workflow} <Column
tasks={laneTasks} key={columnDef.id}
collapsed={collapsedLanes.has(workflow.id)} column={columnDef.id as ColumnType}
onToggleCollapse={handleToggleLaneCollapse} workflowMode
workflowId={selectedWorkflow.id}
columnDisplayName={columnDef.name}
columnFlags={columnDef.flags}
tasks={selectedWorkflowTasksByColumn[columnDef.id] ?? []}
allTasks={selectedWorkflowTasks}
projectId={projectId} projectId={projectId}
maxConcurrent={maxConcurrent} maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask} onMoveTask={onMoveTask}
onPromote={handlePromote} onPromote={handlePromote}
canDropTask={canDropTask} canDropTask={(taskId) => canDropTask(taskId, columnDef.id, selectedWorkflow.id)}
getDraggingTaskId={getDraggingTaskId} getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask} onPauseTask={onPauseTask}
onOpenDetail={onOpenDetail} onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal} onOpenGroupModal={onOpenGroupModal}
addToast={addToast} addToast={addToast}
onQuickCreate={onQuickCreate}
onNewTask={onNewTask}
autoMerge={autoMerge}
onToggleAutoMerge={onToggleAutoMerge}
globalPaused={globalPaused} globalPaused={globalPaused}
onUpdateTask={onUpdateTask} onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask} onRetryTask={onRetryTask}
@@ -483,8 +534,6 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
onUnarchiveTask={onUnarchiveTask} onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
availableModels={availableModels} availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
onOpenDetailWithTab={onOpenDetailWithTab} onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders} favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels} favoriteModels={favoriteModels}
@@ -498,9 +547,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
taskCardFieldDefs={taskCardFieldDefs} taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap} blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable} prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
{...(isCreateColumn ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(columnDef.flags.mergeBlocker ? { onToggleAutoMerge } : {})}
/> />
))} );
})}
</main> </main>
</div>
); );
} }

View File

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

View File

@@ -151,6 +151,8 @@ interface ColumnProps {
* column behavior (label, bulk actions, archived detection) from legacy * column behavior (label, bulk actions, archived detection) from legacy
* literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */ * literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */
workflowMode?: boolean; workflowMode?: boolean;
/** Workflow id for column-aware task creation in workflow mode. */
workflowId?: string;
/** Display name for this column, from the workflow definition. */ /** Display name for this column, from the workflow definition. */
columnDisplayName?: string; columnDisplayName?: string;
/** Resolved trait flags for this column (workflow mode). */ /** Resolved trait flags for this column (workflow mode). */
@@ -170,7 +172,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null; 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"); const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it // Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection // scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -374,6 +376,27 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
}, [shouldPaginate, tasks, visibleTaskCount]); }, [shouldPaginate, tasks, visibleTaskCount]);
const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length); 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(() => { const handleLoadMore = useCallback(() => {
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length)); 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 isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review";
const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn; const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn;
const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo; const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo;
const columnLabelText = workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column];
const handlePauseAll = useCallback(async () => { const handlePauseAll = useCallback(async () => {
if (!onPauseTask) return; if (!onPauseTask) return;
@@ -429,7 +453,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const confirmed = await confirm({ const confirmed = await confirm({
title: t("column.stopAllTitle", "Stop All Tasks"), 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, danger: true,
}); });
if (!confirmed) return; if (!confirmed) return;
@@ -449,7 +473,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
} finally { } finally {
setIsPausingAll(false); setIsPausingAll(false);
} }
}, [onPauseTask, pauseEligibleCount, column, pauseEligibleTasks, addToast, confirm]); }, [onPauseTask, pauseEligibleCount, columnLabelText, pauseEligibleTasks, addToast, confirm, t]);
const handleMoveAllToTodo = useCallback(async () => { const handleMoveAllToTodo = useCallback(async () => {
setIsMenuOpen(false); setIsMenuOpen(false);
@@ -457,7 +481,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
const confirmed = await confirm({ const confirmed = await confirm({
title: t("column.moveAllToTodoTitle", "Move All to Todo"), 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; if (!confirmed) return;
@@ -502,7 +526,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
} finally { } finally {
setIsMovingAllToTodo(false); setIsMovingAllToTodo(false);
} }
}, [tasks, column, onMoveTask, addToast, confirm]); }, [tasks, columnLabelText, onMoveTask, addToast, confirm, t]);
const handleArchiveAll = useCallback(async () => { const handleArchiveAll = useCallback(async () => {
if (!onArchiveAllDone) return; if (!onArchiveAllDone) return;
@@ -650,9 +674,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
)} )}
{!isCollapsed && ( {!isCollapsed && (
<div className="column-body"> <div className="column-body">
{(workflowMode ? Boolean(columnFlags?.intake) : column === "triage") && onQuickCreate && ( {canCreateInColumn && (
<QuickEntryBox <QuickEntryBox
onCreate={onQuickCreate} onCreate={handleQuickCreate}
addToast={addToast} addToast={addToast}
tasks={allTasks ?? []} tasks={allTasks ?? []}
availableModels={availableModels} availableModels={availableModels}

View File

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

View File

@@ -10,13 +10,63 @@
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
padding: var(--board-padding, 12px); padding: var(--board-padding, 12px);
box-sizing: border-box; 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 { .lane {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 0 0 auto;
width: 100%;
min-width: 0; min-width: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg, 10px); border-radius: var(--radius-lg, 10px);
@@ -96,3 +146,25 @@
display: flex; display: flex;
flex-direction: column; 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.filter((col) => !col.flags.archived && !col.flags.hiddenFromBoard),
[workflow.columns], [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). // Group + sort tasks by column id (stable per render).
const tasksByColumn = useMemo(() => { const tasksByColumn = useMemo(() => {
@@ -157,11 +161,14 @@ function LaneComponent(props: LaneProps) {
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="lane-columns" ref={laneRef}> <div className="lane-columns" ref={laneRef}>
{visibleColumns.map((col) => ( {visibleColumns.map((col) => {
const isCreateColumn = col.id === createColumnId;
return (
<Column <Column
key={col.id} key={col.id}
column={col.id as ColumnType} column={col.id as ColumnType}
workflowMode workflowMode
workflowId={workflow.id}
columnDisplayName={col.name} columnDisplayName={col.name}
columnFlags={col.flags} columnFlags={col.flags}
tasks={tasksByColumn[col.id] ?? []} tasks={tasksByColumn[col.id] ?? []}
@@ -197,10 +204,11 @@ function LaneComponent(props: LaneProps) {
blockerFanoutMap={props.blockerFanoutMap} blockerFanoutMap={props.blockerFanoutMap}
prAuthAvailable={props.prAuthAvailable} prAuthAvailable={props.prAuthAvailable}
autoMerge={props.autoMerge} 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 } : {})} {...(col.flags.mergeBlocker ? { onToggleAutoMerge: props.onToggleAutoMerge } : {})}
/> />
))} );
})}
</div> </div>
)} )}
</section> </section>

View File

@@ -32,6 +32,30 @@
gap: var(--space-sm); 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 { .list-sidebar-controls__actions .list-new-task-action {
margin-left: auto; margin-left: auto;
} }
@@ -1129,4 +1153,3 @@
min-height: 36px; min-height: 36px;
} }
} }

View File

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

View File

@@ -635,6 +635,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 100%;
flex: 1 1 auto;
min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@@ -666,15 +669,27 @@
} }
.mailbox-view .mailbox-split-resize-handle { .mailbox-view .mailbox-split-resize-handle {
position: relative;
width: var(--space-sm); width: var(--space-sm);
flex-shrink: 0;
cursor: col-resize; cursor: col-resize;
background: color-mix(in srgb, var(--border) 70%, transparent); background: transparent;
touch-action: none;
transition: background var(--transition-fast); transition: background var(--transition-fast);
border-radius: var(--radius-sm);
} }
.mailbox-view .mailbox-split-resize-handle:hover, .mailbox-view .mailbox-split-resize-handle::before {
.mailbox-view .mailbox-split-resize-handle:focus-visible { 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); background: color-mix(in srgb, var(--todo) 35%, transparent);
} }

View File

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

View File

@@ -259,6 +259,10 @@
min-width: 0; min-width: 0;
} }
.wf-editor-mobile-back {
display: none;
}
.wf-editor-canvas-empty { .wf-editor-canvas-empty {
flex: 1; flex: 1;
justify-content: center; justify-content: center;
@@ -1185,14 +1189,55 @@
flex-direction: column; flex-direction: column;
} }
.wf-editor-sidebar { .wf-editor-body--list-stage .wf-editor-sidebar {
display: flex;
width: 100%; width: 100%;
max-height: 30vh; flex: 1 1 auto;
max-height: none;
border-right: none; border-right: none;
border-bottom: 1px solid var(--border); border-bottom: none;
overflow-y: auto; 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 { .wf-editor-sidebar .wf-column-panel {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -1211,7 +1256,7 @@
.wf-editor-canvas-wrap { .wf-editor-canvas-wrap {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 40vh; min-height: 0;
} }
.wf-template-list { .wf-template-list {

View File

@@ -15,7 +15,7 @@ import {
type Edge as FlowEdge, type Edge as FlowEdge,
} from "@xyflow/react"; } from "@xyflow/react";
import { useTranslation } from "react-i18next"; 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 type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core";
import { getErrorMessage } from "@fusion/core"; import { getErrorMessage } from "@fusion/core";
import { import {
@@ -84,6 +84,24 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; 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). */ /** Adapter descriptor served by GET /api/cli-agents (U15). */
interface CliAdapterDescriptorView { interface CliAdapterDescriptorView {
id: string; id: string;
@@ -152,6 +170,8 @@ interface WorkflowNodeEditorProps {
* mount (U6/U9: redirect stubs link here via a `?panel=settings` param read by * mount (U6/U9: redirect stubs link here via a `?panel=settings` param read by
* the editor's mount site). */ * the editor's mount site). */
initialPanel?: "settings"; initialPanel?: "settings";
/** When "create" the editor opens with the new-workflow dialog active. */
initialAction?: "create";
} }
let nodeSeq = 0; let nodeSeq = 0;
@@ -633,10 +653,15 @@ function InnerEditor({
addToast, addToast,
projectId, projectId,
initialPanel, initialPanel,
initialAction,
modalRef, modalRef,
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) { }: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]); const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [activeId, setActiveId] = useState<string | null>(null); 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 [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
@@ -652,7 +677,7 @@ function InnerEditor({
const { confirm } = useConfirm(); const { confirm } = useConfirm();
// Create-workflow dialog (KTD-7) open state + focus-return ref to the // Create-workflow dialog (KTD-7) open state + focus-return ref to the
// "New workflow" button (NewTaskModal focus pattern). // "New workflow" button (NewTaskModal focus pattern).
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(initialAction === "create");
const newWorkflowBtnRef = useRef<HTMLButtonElement>(null); const newWorkflowBtnRef = useRef<HTMLButtonElement>(null);
// Inline-editable name/description (KTD-10). `name`/`description` mirror the // Inline-editable name/description (KTD-10). `name`/`description` mirror the
// active workflow and are persisted through handleSave; `editingName`/ // active workflow and are persisted through handleSave; `editingName`/
@@ -1784,6 +1809,13 @@ function InnerEditor({
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null; const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? 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): // The edge inspector renders different controls per source-node kind (KTD-2):
// step-review → verdict controls; prompt/script/gate/code/foreach → // step-review → verdict controls; prompt/script/gate/code/foreach →
// success/failure select; everything else → a read-only condition note. // 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). // before the active workflow changes (cancel keeps the current selection).
const requestSwitch = useCallback( const requestSwitch = useCallback(
(id: string) => { (id: string) => {
if (id === activeId) return; if (id === activeId) {
guardedDismiss(() => setActiveId(id)); setWorkflowListStageOpen(false);
return;
}
guardedDismiss(() => {
setActiveId(id);
setWorkflowListStageOpen(false);
});
}, },
[guardedDismiss, activeId], [guardedDismiss, activeId],
); );
@@ -2050,7 +2088,7 @@ function InnerEditor({
</div> </div>
) : null} ) : 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"> <aside className="wf-editor-sidebar">
<button <button
className="wf-editor-new" className="wf-editor-new"
@@ -2190,6 +2228,7 @@ function InnerEditor({
readOnly={isBuiltin} readOnly={isBuiltin}
projectId={projectId} projectId={projectId}
addToast={addToast} addToast={addToast}
initialTab="values"
/> />
</div> </div>
)} )}
@@ -2199,6 +2238,15 @@ function InnerEditor({
</aside> </aside>
<section className="wf-editor-canvas-wrap"> <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 ? ( {activeWorkflow ? (
<> <>
{/* Inline name + description strip (KTD-10). Built-ins render as {/* Inline name + description strip (KTD-10). Built-ins render as
@@ -2681,7 +2729,7 @@ function InnerEditor({
<span>Prompt</span> <span>Prompt</span>
<textarea <textarea
rows={5} rows={5}
value={String(selectedNode.data.config?.prompt ?? "")} value={selectedNodePromptValue}
onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })} onChange={(e) => updateSelectedData({ config: { prompt: e.target.value } })}
/> />
</label> </label>

View File

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

View File

@@ -69,6 +69,7 @@ interface WorkflowSettingsPanelProps {
* no active project (Values tab shows a requires-project state). */ * no active project (Values tab shows a requires-project state). */
projectId?: string; projectId?: string;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
initialTab?: "definitions" | "values";
} }
const SETTING_TYPES: WorkflowSettingType[] = [ const SETTING_TYPES: WorkflowSettingType[] = [
@@ -816,9 +817,12 @@ export function WorkflowSettingsPanel({
readOnly, readOnly,
projectId, projectId,
addToast, addToast,
initialTab,
}: WorkflowSettingsPanelProps) { }: WorkflowSettingsPanelProps) {
const { t } = useTranslation("app"); 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. // 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 // 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"; import type { Task } from "@fusion/core";
const fetchBatchMock = vi.fn(); const fetchBatchMock = vi.fn();
const pendingWorkflowSteps = () => new Promise<never>(() => {});
const fetchWorkflowStepsMock = vi.fn().mockImplementation(pendingWorkflowSteps);
vi.mock("../../hooks/useBatchBadgeFetch", () => ({ vi.mock("../../hooks/useBatchBadgeFetch", () => ({
useBatchBadgeFetch: vi.fn(() => ({ useBatchBadgeFetch: vi.fn(() => ({
@@ -17,18 +19,12 @@ vi.mock("../../hooks/useBatchBadgeFetch", () => ({
})), })),
})); }));
const fetchBoardWorkflowsMock = vi.fn().mockResolvedValue({ const pendingBoardWorkflows = () => new Promise<never>(() => {});
flagEnabled: false, const fetchBoardWorkflowsMock = vi.fn().mockImplementation(pendingBoardWorkflows);
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
const promoteTaskMock = vi.fn().mockResolvedValue({}); const promoteTaskMock = vi.fn().mockResolvedValue({});
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn().mockResolvedValue([ fetchWorkflowSteps: (...args: unknown[]) => fetchWorkflowStepsMock(...args),
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
]),
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args), fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
promoteTask: (...args: unknown[]) => promoteTaskMock(...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 // Mock child components so we only test Board's own rendering
vi.mock("../Column", () => ({ 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; columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
return ( 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>} {onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
</div> </div>
); );
@@ -94,16 +90,13 @@ const noopAsync = () => Promise.resolve({} as any);
beforeEach(() => { beforeEach(() => {
fetchBatchMock.mockReset(); fetchBatchMock.mockReset();
fetchWorkflowStepsMock.mockReset();
fetchWorkflowStepsMock.mockImplementation(pendingWorkflowSteps);
promoteTaskMock.mockClear(); promoteTaskMock.mockClear();
subscribeSseMock.mockClear(); subscribeSseMock.mockClear();
for (const key of Object.keys(sseHandlers)) delete sseHandlers[key]; for (const key of Object.keys(sseHandlers)) delete sseHandlers[key];
fetchBoardWorkflowsMock.mockReset(); fetchBoardWorkflowsMock.mockReset();
fetchBoardWorkflowsMock.mockResolvedValue({ fetchBoardWorkflowsMock.mockImplementation(pendingBoardWorkflows);
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
try { try {
window.localStorage.clear(); window.localStorage.clear();
} catch { } catch {
@@ -219,6 +212,9 @@ describe("Board", () => {
}); });
it("forwards board-level workflow name lookup to columns", async () => { it("forwards board-level workflow name lookup to columns", async () => {
fetchWorkflowStepsMock.mockResolvedValue([
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
]);
renderBoard(); renderBoard();
await waitFor(() => { await waitFor(() => {
@@ -926,7 +922,12 @@ describe("Board", () => {
} }
it("flag OFF renders the legacy single-lane board byte-identically", async () => { 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" })] }); renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
// Let the board-workflows fetch resolve (flagEnabled:false) so the async // Let the board-workflows fetch resolve (flagEnabled:false) so the async
// state settle is wrapped and the legacy board stays the rendered output. // state settle is wrapped and the legacy board stays the rendered output.
@@ -940,16 +941,38 @@ describe("Board", () => {
expect(screen.queryByTestId(/^lane-/)).toBeNull(); 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" }); enableFlag({ "FN-1": "builtin:coding", "FN-2": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "in-progress" })] }); renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "in-progress" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined()); await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
const lane = screen.getByTestId("lane-builtin:coding"); expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
expect(JSON.parse(lane.getAttribute("data-lane-task-ids") || "[]").sort()).toEqual(["FN-1", "FN-2"]); expect(JSON.parse(screen.getByTestId("column-in-progress").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-2"]);
expect(lane.getAttribute("data-lane-count")).toBe("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( enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom", "FN-3": "wf-custom" }, { "FN-1": "builtin:coding", "FN-2": "wf-custom", "FN-3": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
@@ -960,57 +983,75 @@ describe("Board", () => {
mkTask({ id: "FN-2", column: "intake" }), mkTask({ id: "FN-2", column: "intake" }),
mkTask({ id: "FN-3", column: "intake" }), mkTask({ id: "FN-3", column: "intake" }),
], ],
onCreateWorkflow,
}); });
await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined()); const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
const defaultLaneIds = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]"); expect(selector.value).toBe("builtin:coding");
const customLaneIds = JSON.parse(screen.getByTestId("lane-wf-custom").getAttribute("data-lane-task-ids") || "[]"); fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
const all = [...defaultLaneIds, ...customLaneIds].sort(); expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
expect(all).toEqual(["FN-1", "FN-2", "FN-3"]); expect(JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id)).toEqual(["FN-1"]);
// No id appears twice. expect(screen.queryByTestId("column-intake")).toBeNull();
expect(new Set(all).size).toBe(all.length);
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( enableFlag(
{ "FN-2": "wf-custom" }, { "FN-2": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], [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" })] }); renderBoard({ tasks: [mkTask({ id: "FN-2", column: "intake" })] });
await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined()); const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
expect(screen.queryByTestId("lane-builtin:coding")).toBeNull(); 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( enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" }, { "FN-1": "builtin:coding", "FN-2": "wf-custom" },
[CUSTOM_WORKFLOW, DEFAULT_WORKFLOW], [CUSTOM_WORKFLOW, DEFAULT_WORKFLOW],
); );
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] }); renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined()); const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
const lanes = screen.getAllByTestId(/^lane-/); expect([...selector.options].map((option) => option.value)).toEqual(["builtin:coding", "wf-custom"]);
expect(lanes[0].getAttribute("data-testid")).toBe("lane-builtin:coding");
}); });
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" }); enableFlag({ "FN-1": "builtin:coding", "FN-9": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-9", column: "archived" })] }); renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-9", column: "archived" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined()); await waitFor(() => expect(screen.getByTestId("column-todo")).toBeDefined());
const ids = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]"); const ids = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]").map((task: Task) => task.id);
expect(ids).toEqual(["FN-1"]); expect(ids).toEqual(["FN-1"]);
expect(screen.queryByTestId("column-archived")).toBeNull();
}); });
it("persists lane collapse state to localStorage", async () => { it("renders selected workflow columns as direct children of the horizontal board", async () => {
window.localStorage.setItem("kb-dashboard-lane-collapsed", JSON.stringify(["builtin:coding"])); enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
enableFlag({ "FN-1": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" })] }); renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined()); await waitFor(() => expect(screen.getByRole("main").className).toContain("board-workflow-columns"));
expect(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-collapsed")).toBe("true"); 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)", () => { describe("workflow:updated SSE invalidation (#1406)", () => {
it("re-fetches board-workflows when a workflow:updated SSE event arrives", async () => { 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" }); renderBoard({ projectId: "proj-1" });
// Initial mount fetch. // Initial mount fetch.
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1)); await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1));

View File

@@ -91,6 +91,15 @@ describe("Lane", () => {
expect(headings).not.toContain("Archived"); 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", () => { it("collapses the lane (hides columns) when collapsed", () => {
render(<Lane {...baseProps()} collapsed />); render(<Lane {...baseProps()} collapsed />);
expect(screen.queryByText("Triage")).toBeNull(); expect(screen.queryByText("Triage")).toBeNull();

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest"; 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 userEvent from "@testing-library/user-event";
import { ListView } from "../ListView"; import { ListView } from "../ListView";
import type { Task, TaskDetail } from "@fusion/core"; import type { Task, TaskDetail } from "@fusion/core";
@@ -22,12 +23,135 @@ vi.mock("../../api", () => ({
fetchGlobalSettings: vi.fn().mockResolvedValue({}), fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchTaskDetail: vi.fn(), fetchTaskDetail: vi.fn(),
batchUpdateTaskModels: vi.fn(), batchUpdateTaskModels: vi.fn(),
fetchNodes: vi.fn().mockResolvedValue([]), fetchNodes: vi.fn(() => new Promise(() => {})),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }), fetchBoardWorkflows: vi.fn(() => new Promise(() => {})),
api: vi.fn().mockResolvedValue({ sessions: [] }), 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 mockConfirm = vi.fn();
const mockConfirmWithChoice = vi.fn(); const mockConfirmWithChoice = vi.fn();
@@ -99,14 +223,22 @@ const renderListView = (
if (options.openViewOptions ?? true) { if (options.openViewOptions ?? true) {
const viewOptionsToggle = screen.queryByRole("button", { name: /view options/i }); const viewOptionsToggle = screen.queryByRole("button", { name: /view options/i });
if (viewOptionsToggle) { if (viewOptionsToggle) {
act(() => {
fireEvent.click(viewOptionsToggle); fireEvent.click(viewOptionsToggle);
});
} }
} }
return result; return result;
}; };
const clickInAct = (element: Element) => {
act(() => {
fireEvent.click(element);
});
};
const enterBulkEditMode = () => { const enterBulkEditMode = () => {
fireEvent.click(screen.getByRole("button", { name: "Bulk Edit" })); clickInAct(screen.getByRole("button", { name: "Bulk Edit" }));
}; };
const showAllColumnsByDefault = () => { const showAllColumnsByDefault = () => {
@@ -176,12 +308,16 @@ function mockDesktopViewport() {
describe("ListView", () => { describe("ListView", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(fetchNodes).mockImplementation(() => new Promise(() => {}));
vi.mocked(fetchBoardWorkflows).mockImplementation(() => new Promise(() => {}));
vi.mocked(fetchTaskDetail).mockResolvedValue({ vi.mocked(fetchTaskDetail).mockResolvedValue({
...createMockTask(), ...createMockTask(),
prompt: "# Detail", prompt: "# Detail",
} as TaskDetail); } as TaskDetail);
mockConfirm.mockReset(); mockConfirm.mockReset();
mockConfirmWithChoice.mockReset(); mockConfirmWithChoice.mockReset();
subscribeSseMock.mockClear();
for (const key of Object.keys(listViewSseHandlers)) delete listViewSseHandlers[key];
localStorage.clear(); localStorage.clear();
ensureMatchMedia(); ensureMatchMedia();
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ 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(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-001");
expect(row?.className).toContain("list-row--selected"); expect(row?.className).toContain("list-row--selected");
await waitFor(() => { await waitFor(() => {
expect(fetchTaskDetail).toHaveBeenCalledWith("FN-001", TEST_PROJECT_ID);
expect(screen.getByTestId("list-split-detail-content")).toBeInTheDocument(); expect(screen.getByTestId("list-split-detail-content")).toBeInTheDocument();
expect(screen.getByTestId("task-detail-content")).toHaveTextContent("FN-001");
}); });
expect(fetchTaskDetail).not.toHaveBeenCalled();
viewportSpy.mockRestore(); viewportSpy.mockRestore();
}); });
@@ -439,6 +576,92 @@ describe("ListView", () => {
viewportSpy.mockRestore(); 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 () => { it("keeps embedded selection visible when filters hide the selected row", async () => {
const viewportSpy = mockDesktopViewport(); const viewportSpy = mockDesktopViewport();
const tasks = [ const tasks = [
@@ -480,16 +703,6 @@ describe("ListView", () => {
const tasks = [createMockTask({ id: "FN-001", title: "Parent Task", dependencies: ["FN-002"] })]; const tasks = [createMockTask({ id: "FN-001", title: "Parent Task", dependencies: ["FN-002"] })];
const mockOnOpenDetail = vi.fn(); 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 }); renderListView({ tasks, onOpenDetail: mockOnOpenDetail });
fireEvent.click(screen.getByText("FN-001").closest("tr")!); fireEvent.click(screen.getByText("FN-001").closest("tr")!);
@@ -502,9 +715,10 @@ describe("ListView", () => {
await waitFor(() => { await waitFor(() => {
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-list-selected-task"))).toBe("FN-002"); 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(); expect(mockOnOpenDetail).not.toHaveBeenCalled();
viewportSpy.mockRestore(); viewportSpy.mockRestore();
}); });
@@ -519,8 +733,8 @@ describe("ListView", () => {
enterBulkEditMode(); enterBulkEditMode();
const checkbox = within(row).getByRole("checkbox", { name: "Select FN-001" }); const checkbox = within(row).getByRole("checkbox", { name: "Select FN-001" });
fireEvent.click(checkbox); clickInAct(checkbox);
fireEvent.click(row); clickInAct(row);
await waitFor(() => { await waitFor(() => {
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toContain("FN-001"); expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toContain("FN-001");
@@ -2448,6 +2662,10 @@ describe("ListView Collapsible Sections", () => {
describe("ListView - Bulk Selection", () => { describe("ListView - Bulk Selection", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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(); localStorage.clear();
ensureMatchMedia(); ensureMatchMedia();
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
@@ -2519,7 +2737,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode(); enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001"); const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox); clickInAct(checkbox);
expect(screen.getByText("1 selected")).toBeDefined(); expect(screen.getByText("1 selected")).toBeDefined();
}); });
@@ -2532,11 +2750,11 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode(); enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001"); const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox); clickInAct(checkbox);
expect(screen.getByText("1 selected")).toBeDefined(); expect(screen.getByText("1 selected")).toBeDefined();
const clearButton = screen.getByRole("button", { name: /^1 selected$/i }); const clearButton = screen.getByRole("button", { name: /^1 selected$/i });
fireEvent.click(clearButton); clickInAct(clearButton);
expect(screen.queryByText("1 selected")).toBeNull(); expect(screen.queryByText("1 selected")).toBeNull();
}); });
@@ -2550,7 +2768,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode(); enterBulkEditMode();
const selectAllCheckbox = screen.getByLabelText("Select all visible tasks"); const selectAllCheckbox = screen.getByLabelText("Select all visible tasks");
fireEvent.click(selectAllCheckbox); clickInAct(selectAllCheckbox);
expect(screen.getByRole("button", { name: /^2 selected$/i })).toBeDefined(); 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 // Select a task to show bulk edit toolbar with dropdowns
const checkbox = screen.getByLabelText("Select FN-001"); const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox); clickInAct(checkbox);
expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined(); expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined();
}); });
@@ -2603,7 +2821,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode(); enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001"); const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox); clickInAct(checkbox);
expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined(); expect(screen.getByText("Bulk Edit Models & Node:")).toBeDefined();
}); });
@@ -2626,7 +2844,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode(); enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001"); const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox); clickInAct(checkbox);
const applyButton = screen.getByText("Apply"); const applyButton = screen.getByText("Apply");
expect(applyButton).toBeDisabled(); expect(applyButton).toBeDisabled();
@@ -2765,6 +2983,43 @@ describe("ListView - Bulk Selection", () => {
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 1 skipped · 0 failed", "success"); 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 () => { it("shows error summary when pause has failures", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", paused: false })]; const tasks = [createMockTask({ id: "FN-001", paused: false })];
@@ -2978,7 +3233,7 @@ describe("ListView - Bulk Selection", () => {
enterBulkEditMode(); enterBulkEditMode();
const checkbox = screen.getByLabelText("Select FN-001"); const checkbox = screen.getByLabelText("Select FN-001");
fireEvent.click(checkbox); clickInAct(checkbox);
expect(localStorage.getItem(scopedStorageKey("kb-dashboard-selected-tasks"))).toBe('["FN-001"]'); 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-/); const checkboxes = screen.getAllByLabelText(/Select FN-/);
// Select only first task // Select only first task
fireEvent.click(checkboxes[0]); clickInAct(checkboxes[0]);
// Header checkbox should be indeterminate (partially selected) // Header checkbox should be indeterminate (partially selected)
const headerCheckbox = screen.getByLabelText("Select all visible tasks") as HTMLInputElement; 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} />); render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
enterBulkEditMode(); enterBulkEditMode();
fireEvent.click(screen.getByLabelText("Select FN-001")); clickInAct(screen.getByLabelText("Select FN-001"));
expect(await screen.findByLabelText("Node Override")).toBeInTheDocument(); expect(await screen.findByLabelText("Node Override")).toBeInTheDocument();
expect(await screen.findByRole("option", { name: "● Node One (Online)" })).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} />); render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
enterBulkEditMode(); enterBulkEditMode();
fireEvent.click(screen.getByLabelText("Select FN-001")); clickInAct(screen.getByLabelText("Select FN-001"));
expect(await screen.findByRole("option", { name: "○ Node Two (Offline)" })).toBeInTheDocument(); 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} />); render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
enterBulkEditMode(); enterBulkEditMode();
fireEvent.click(screen.getByLabelText("Select FN-001")); clickInAct(screen.getByLabelText("Select FN-001"));
expect(await screen.findByRole("button", { name: "Apply" })).toBeDisabled(); expect(await screen.findByRole("button", { name: "Apply" })).toBeDisabled();
}); });
@@ -3403,7 +3658,7 @@ describe("ListView - Bulk Selection", () => {
}); });
enterBulkEditMode(); 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-001") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Select FN-002") 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 { WorkflowDefinition, Settings } from "@fusion/core";
import type { Agent } from "../../api"; import type { Agent } from "../../api";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, foreachChildFlowId } from "../workflow-flow-mapping"; 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", () => ({ vi.mock("../../api", () => ({
fetchWorkflows: vi.fn(), fetchWorkflows: vi.fn(),
@@ -39,6 +39,8 @@ vi.mock("../../api", () => ({
fetchSettings: vi.fn(), fetchSettings: vi.fn(),
updateSettings: vi.fn(), updateSettings: vi.fn(),
updateGlobalSettings: 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"; import { fireEvent } from "@testing-library/react";
@@ -119,8 +121,16 @@ function v2Def(): WorkflowDefinition {
} }
function builtinDef(): WorkflowDefinition { function builtinDef(): WorkflowDefinition {
const d = v2Def(); return {
return { ...d, id: "builtin:coding", name: "Default coding workflow", description: "Ships with Fusion" }; 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 { 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).toMatchObject({ from: "step-review", to: "step-done" });
expect(approve?.kind).toBeUndefined(); 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) ───────────────────── // ── 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(); 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 () => { it("creates and activates a workflow on a valid submit", async () => {
const addToast = vi.fn(); const addToast = vi.fn();
vi.mocked(fetchWorkflows).mockResolvedValue([]); vi.mocked(fetchWorkflows).mockResolvedValue([]);

View File

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

View File

@@ -34,7 +34,7 @@ export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandler
const handleBoardQuickCreate = useCallback( const handleBoardQuickCreate = useCallback(
async (input: TaskCreateInput): Promise<Task> => { 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], [createTask],
); );

View File

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

View File

@@ -42,6 +42,9 @@ function makeStore(opts: {
async getWorkflowDefinition(id: string) { async getWorkflowDefinition(id: string) {
return opts.defs?.[id]; return opts.defs?.[id];
}, },
async listWorkflowDefinitions() {
return Object.values(opts.defs ?? {});
},
}; };
} }
@@ -71,6 +74,24 @@ describe("buildBoardWorkflowsPayload", () => {
"done", "done",
"archived", "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 () => { 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 }; 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 /** 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. */ * can assert the route releases the session even when the model turn throws. */
function makeRejectingAgent() { function makeRejectingAgent() {
@@ -161,6 +178,17 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => {
expect(res.body.strippedApprovalFlags).toBe(false); 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 () => { 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 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); const { factory } = makeFakeAgent(wrapped);

View File

@@ -68,16 +68,30 @@ export interface BoardWorkflowsPayload {
taskWorkflowIds: Record<string, string>; 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 { function toV2(ir: WorkflowIr): WorkflowIrV2 | undefined {
return ir.version === "v2" ? ir : 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); const v2 = toV2(ir);
if (!v2) return []; if (!v2) return [];
return v2.columns.map((col) => ({ return v2.columns.map((col) => ({
id: col.id, id: col.id,
name: col.name, name: displayColumnName(col.id, col.name, canonicalizeLifecycle),
flags: resolveColumnFlags(col), flags: resolveColumnFlags(col),
})); }));
} }
@@ -102,7 +116,7 @@ async function describeWorkflow(
const ir = await resolveWorkflowIrById(store, workflowId); const ir = await resolveWorkflowIrById(store, workflowId);
const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name; const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name;
const fields = describeFields(ir); 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 // Custom workflow: fetch the definition once and derive both IR and name from
// it (previously getWorkflowDefinition was called twice per workflow). // 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. * can return early and the client renders the legacy board.
*/ */
export async function buildBoardWorkflowsPayload( export async function buildBoardWorkflowsPayload(
store: Pick<TaskStore, "getWorkflowDefinition" | "getTaskWorkflowSelection" | "getSettings">, store: Pick<TaskStore, "getWorkflowDefinition" | "getTaskWorkflowSelection" | "getSettings" | "listWorkflowDefinitions">,
taskIds: string[], taskIds: string[],
settingsOverride?: Pick<Settings, "experimentalFeatures">, settingsOverride?: Pick<Settings, "experimentalFeatures">,
): Promise<BoardWorkflowsPayload> { ): Promise<BoardWorkflowsPayload> {
@@ -163,6 +177,17 @@ export async function buildBoardWorkflowsPayload(
// resolves it (and the client's default-lane-first ordering is stable). // resolves it (and the client's default-lane-first ordering is stable).
referenced.add(DEFAULT_WORKFLOW_LANE_ID); 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[] = []; const workflows: BoardWorkflowDefinition[] = [];
for (const workflowId of referenced) { for (const workflowId of referenced) {
workflows.push(await describeWorkflow(store, workflowId)); workflows.push(await describeWorkflow(store, workflowId));

View File

@@ -23,12 +23,14 @@ export function __resetCreateFnAgentForDesign(): void {
createFnAgentForDesign = engineCreateFnAgent; createFnAgentForDesign = engineCreateFnAgent;
} }
/** Minimal session shape used by the one-shot design turn (mirrors the refine /** Minimal session shape used by the one-shot design turn. Some agent backends
* route's RefineAgentSession): subscribe to text deltas, prompt once, dispose. */ * stream text deltas, while CLI-agent-backed sessions only expose messages on
* state after prompt() resolves. */
interface DesignAgentSession { interface DesignAgentSession {
on(event: "text", listener: (delta: string) => void): void; on?: (event: "text", listener: (delta: string) => void) => void;
prompt(text: string): Promise<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 /** 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; const designSession = session as unknown as DesignAgentSession;
let output = ""; let output = "";
designSession.on("text", (delta: string) => { designSession.on?.("text", (delta: string) => {
output += delta; output += delta;
}); });
@@ -834,7 +836,11 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
try { try {
await designSession.prompt(userPrompt); await designSession.prompt(userPrompt);
} finally { } finally {
designSession.dispose(); designSession.dispose?.();
}
if (!output.trim()) {
output = extractLastAssistantText(designSession.state?.messages);
} }
// Extract JSON (handles fences/prose) → JSON.parse → parseWorkflowIr. // 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 /** 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 * 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 * the 422 fires BEFORE any write — the store would also reject, but importing

View File

@@ -1154,6 +1154,40 @@ describe("StuckTaskDetector", () => {
vi.useRealTimers(); 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 () => { it("does NOT call onLoopDetected when reason is inactivity", async () => {
const onLoopDetected = vi.fn().mockResolvedValue(true); const onLoopDetected = vi.fn().mockResolvedValue(true);
const onStuck = vi.fn(); const onStuck = vi.fn();

View File

@@ -163,6 +163,25 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
expect(result.reason).toMatch(/workflow-missing/); 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 () => { it("falls back (never strands the task) when the interpreter throws", async () => {
// Malformed graph: edge references unknown node → WorkflowIrError inside run(). // Malformed graph: edge references unknown node → WorkflowIrError inside run().
const badIr: WorkflowIr = { const badIr: WorkflowIr = {

View File

@@ -419,6 +419,15 @@ export class StuckTaskDetector {
return "no-progress-churn"; 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 // Check loop — active but not making progress, with enough activity to be a real loop
if (noProgressMs >= timeoutMs && entry.activitySinceProgress >= LOOP_ACTIVITY_THRESHOLD) { if (noProgressMs >= timeoutMs && entry.activitySinceProgress >= LOOP_ACTIVITY_THRESHOLD) {
return "loop"; return "loop";

View File

@@ -1,5 +1,5 @@
import type { Settings, TaskDetail, WorkflowDefinition } from "@fusion/core"; 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 { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js";
import type { import type {
@@ -146,7 +146,9 @@ export class WorkflowGraphTaskRunner {
let definition: WorkflowDefinition | undefined; let definition: WorkflowDefinition | undefined;
try { 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) { } catch (err) {
return this.fallBack(task.id, `workflow-load-error: ${err instanceof Error ? err.message : String(err)}`); return this.fallBack(task.id, `workflow-load-error: ${err instanceof Error ? err.message : String(err)}`);
} }