fix: stabilize workflow board and loop recovery
This commit is contained in:
5
.changeset/workflow-board-list-layout.md
Normal file
5
.changeset/workflow-board-list-layout.md
Normal 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.
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user