FN-6046: add collapsible workflow selectors

Add collapsible workflow selectors for board workflow controls and inline task creation.

- add a collapsible board workflow toolbar with persisted per-project state
- make the inline create workflow selector collapsible and store its expanded state per project
- update workflow selector styling, storage key allowlist, and regression coverage for collapse behavior

Files changed:
 packages/dashboard/app/components/Board.tsx        | 104 +++++++++++++--------
 .../dashboard/app/components/InlineCreateCard.css  |   9 ++
 .../dashboard/app/components/InlineCreateCard.tsx  |   3 +
 packages/dashboard/app/components/Lane.css         |  23 +++++
 .../dashboard/app/components/WorkflowSelector.css  |  20 +++-
 .../dashboard/app/components/WorkflowSelector.tsx  |  81 ++++++++++++++--
 .../app/components/__tests__/Board.test.tsx        | 101 ++++++++++++++++++--
 .../components/__tests__/InlineCreateCard.test.tsx |  57 ++++++++++-
 .../components/__tests__/WorkflowSelector.test.tsx |  73 ++++++++++++++-
 .../app/utils/__tests__/projectStorage.test.ts     |   4 +-
 packages/dashboard/app/utils/projectStorage.ts     |   2 +
 11 files changed, 420 insertions(+), 57 deletions(-)

Fusion-Task-Id: FN-6046

Fusion-Task-Lineage: 07cbe7a8-5740-479d-94a3-b178c72a5273
This commit is contained in:
gsxdsm
2026-06-09 02:09:31 -07:00
parent 4b4c32dc18
commit 4366a933da
11 changed files with 420 additions and 57 deletions

View File

@@ -5,11 +5,12 @@ import { Column } from "./Column";
import "./Lane.css";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { Pencil, Plus } from "lucide-react";
import { ChevronDown, ChevronRight, Pencil, Plus } from "lucide-react";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowDefinition, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { subscribeSse } from "../sse-bus";
import { getBoardCanDropTaskRejection } from "./boardCanDropTask";
@@ -92,6 +93,7 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
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 [workflowToolbarCollapsed, setWorkflowToolbarCollapsed] = useState<boolean>(() => getScopedItem("kb-dashboard-board-workflow-collapsed", projectId) === "1");
const archivedLoadedRef = useRef(false);
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
const boardRef = useRef<HTMLElement | null>(null);
@@ -109,6 +111,12 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
archived: [],
});
useEffect(() => {
if (typeof window !== "undefined") {
setScopedItem("kb-dashboard-board-workflow-collapsed", workflowToolbarCollapsed ? "1" : "0", projectId);
}
}, [workflowToolbarCollapsed, projectId]);
useEffect(() => {
recordResumeEvent({
view: "Board",
@@ -461,45 +469,61 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
return (
<div className="board-workflow-view">
{(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && (
<div className="board-workflow-toolbar">
{workflowOptions.length > 1 && (
<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>
)}
{onOpenWorkflowEditor && (
<button
type="button"
className="btn btn-icon btn-sm board-workflow-edit-btn"
onClick={() => onOpenWorkflowEditor(selectedWorkflow.id)}
title="Edit workflows"
aria-label="Edit workflows"
>
<Pencil size={15} />
</button>
)}
{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>
<div className="board-workflow-toolbar" data-collapsed={workflowToolbarCollapsed || undefined}>
<button
type="button"
className="btn btn-icon btn-sm board-workflow-collapse-toggle"
onClick={() => setWorkflowToolbarCollapsed((c) => !c)}
aria-expanded={!workflowToolbarCollapsed}
aria-label={workflowToolbarCollapsed ? "Expand workflow toolbar" : "Collapse workflow toolbar"}
data-testid="board-workflow-collapse-toggle"
>
{workflowToolbarCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</button>
{workflowToolbarCollapsed ? (
<span className="board-workflow-collapsed-label">Workflow</span>
) : (
<>
{workflowOptions.length > 1 && (
<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>
)}
{onOpenWorkflowEditor && (
<button
type="button"
className="btn btn-icon btn-sm board-workflow-edit-btn"
onClick={() => onOpenWorkflowEditor(selectedWorkflow.id)}
title="Edit workflows"
aria-label="Edit workflows"
>
<Pencil size={15} />
</button>
)}
{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>
)}
</>
)}
</div>
)}

View File

@@ -141,6 +141,11 @@
min-width: 0;
}
.inline-create-controls .workflow-selector {
flex: 0 1 auto;
min-width: 0;
}
.inline-create-actions {
display: flex;
align-items: center;
@@ -288,6 +293,10 @@
gap: var(--space-xs);
}
.inline-create-controls .workflow-selector {
width: 100%;
}
.inline-create-controls .btn {
min-height: 36px;
font-size: 12px;

View File

@@ -18,6 +18,7 @@ import { WorkflowSelector } from "./WorkflowSelector";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const STORAGE_KEY = "kb-inline-create-text";
const WORKFLOW_SELECTOR_COLLAPSE_STORAGE_KEY = "kb-board-workflow-selector-collapsed";
interface PendingImage {
file: File;
@@ -1036,6 +1037,8 @@ export function InlineCreateCard({
addToast={addToast}
label="Workflow"
disabled={submitting}
collapsible
collapseStorageKey={WORKFLOW_SELECTOR_COLLAPSE_STORAGE_KEY}
/>
<label className="inline-create-priority-wrap" htmlFor="inline-create-priority-select">

View File

@@ -36,6 +36,21 @@
background: var(--surface);
}
.board-workflow-toolbar[data-collapsed] {
justify-content: flex-start;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-md);
}
.board-workflow-collapse-toggle {
flex: 0 0 auto;
}
.board-workflow-collapsed-label {
font-size: 0.8rem;
color: var(--text-muted);
}
.board-workflow-selector {
margin-left: auto;
}
@@ -160,3 +175,11 @@
scroll-snap-type: x proximity;
}
}
@media (max-width: 768px) {
.board-workflow-toolbar[data-collapsed] {
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
}
}

View File

@@ -20,6 +20,23 @@
gap: var(--space-xs);
}
.workflow-selector-title {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.workflow-selector-toggle {
flex-shrink: 0;
}
.workflow-selector-collapsed-button {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
white-space: nowrap;
}
.workflow-selector select {
padding: var(--space-xs) var(--space-sm);
background: var(--bg-secondary);
@@ -54,7 +71,8 @@
}
.workflow-selector select,
.workflow-selector-manage {
.workflow-selector-manage,
.workflow-selector-collapsed-button {
width: 100%;
}

View File

@@ -1,12 +1,13 @@
import "./WorkflowSelector.css";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { Workflow as WorkflowIcon } from "lucide-react";
import { ChevronDown, ChevronRight, Workflow as WorkflowIcon } from "lucide-react";
import type { WorkflowDefinition } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { fetchWorkflow, fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
interface WorkflowSelectorProps {
/** Currently selected workflow id, or null for none. */
@@ -19,6 +20,12 @@ interface WorkflowSelectorProps {
label?: string;
/** Optional affordance to open the graph editor. */
onManage?: () => void;
/** Render an opt-in collapsed/expanded shell around the selector. */
collapsible?: boolean;
/** Base projectStorage key used to persist collapsible state. */
collapseStorageKey?: string;
/** Label shown when the selector is collapsed. */
collapsedLabel?: string;
/**
* U9: when the task whose workflow is being switched has an active session,
* switching aborts that session and re-homes the card into the new workflow's
@@ -36,13 +43,40 @@ export function WorkflowSelector({
disabled,
label = "Workflow",
onManage,
collapsible = false,
collapseStorageKey,
collapsedLabel = "Workflow",
hasActiveSession,
}: WorkflowSelectorProps) {
const { t } = useTranslation("app");
const selectId = useId();
const { confirm } = useConfirm();
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [loading, setLoading] = useState(false);
const [applying, setApplying] = useState(false);
const [collapsed, setCollapsed] = useState(() => {
if (!collapsible || !collapseStorageKey) return false;
return getScopedItem(collapseStorageKey, projectId) === "true";
});
useEffect(() => {
if (!collapsible || !collapseStorageKey) {
setCollapsed(false);
return;
}
setCollapsed(getScopedItem(collapseStorageKey, projectId) === "true");
}, [collapsible, collapseStorageKey, projectId]);
const setPersistedCollapsed = useCallback(
(nextCollapsed: boolean) => {
setCollapsed(nextCollapsed);
if (collapsible && collapseStorageKey) {
setScopedItem(collapseStorageKey, nextCollapsed ? "true" : "false", projectId);
}
},
[collapsible, collapseStorageKey, projectId],
);
useEffect(() => {
let cancelled = false;
@@ -100,13 +134,46 @@ export function WorkflowSelector({
[onChange, addToast, hasActiveSession, confirm, t],
);
if (collapsible && collapsed) {
return (
<div className="workflow-selector workflow-selector--collapsed" data-testid="workflow-selector">
<button
type="button"
className="btn btn-sm workflow-selector-toggle workflow-selector-collapsed-button"
aria-expanded="false"
aria-controls={selectId}
onClick={() => setPersistedCollapsed(false)}
>
<ChevronRight size={14} aria-hidden />
<WorkflowIcon size={14} aria-hidden />
{collapsedLabel}
</button>
</div>
);
}
return (
<div className="workflow-selector" data-testid="workflow-selector">
<label className="workflow-selector-label">
<span className="workflow-selector-label-text">
<WorkflowIcon size={14} aria-hidden /> {label}
</span>
<div className="workflow-selector-label">
<div className="workflow-selector-label-text">
{collapsible && (
<button
type="button"
className="btn btn-icon btn-sm workflow-selector-toggle"
aria-expanded="true"
aria-controls={selectId}
aria-label={t("workflowSelector.collapse", "Collapse workflow selector")}
onClick={() => setPersistedCollapsed(true)}
>
<ChevronDown size={14} aria-hidden />
</button>
)}
<label htmlFor={selectId} className="workflow-selector-title">
<WorkflowIcon size={14} aria-hidden /> {label}
</label>
</div>
<select
id={selectId}
value={value ?? ""}
disabled={disabled || loading || applying}
onChange={(e) => void handleChange(e.target.value)}
@@ -118,7 +185,7 @@ export function WorkflowSelector({
</option>
))}
</select>
</label>
</div>
{onManage && (
<button type="button" className="workflow-selector-manage" onClick={onManage}>
Manage…

View File

@@ -1,5 +1,5 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { Board } from "../Board";
import { COLUMNS } from "@fusion/core";
@@ -72,6 +72,8 @@ vi.mock("../Lane", () => ({
),
}));
const BOARD_WORKFLOW_COLLAPSED_KEY = "kb-dashboard-board-workflow-collapsed";
const DEFAULT_WORKFLOW = {
id: "builtin:coding",
name: "Coding (built-in)",
@@ -88,6 +90,14 @@ const DEFAULT_WORKFLOW = {
const noop = () => {};
const noopAsync = () => Promise.resolve({} as any);
function clearBoardTestStorage() {
try {
window.localStorage.clear();
} catch {
/* jsdom localStorage */
}
}
beforeEach(() => {
fetchBatchMock.mockReset();
fetchWorkflowStepsMock.mockReset();
@@ -97,16 +107,16 @@ beforeEach(() => {
for (const key of Object.keys(sseHandlers)) delete sseHandlers[key];
fetchBoardWorkflowsMock.mockReset();
fetchBoardWorkflowsMock.mockImplementation(pendingBoardWorkflows);
try {
window.localStorage.clear();
} catch {
/* jsdom localStorage */
}
clearBoardTestStorage();
for (const key of Object.keys(columnRenderCounts)) {
delete columnRenderCounts[key];
}
});
afterEach(() => {
clearBoardTestStorage();
});
function createBoardProps(overrides = {}) {
return {
tasks: [],
@@ -1036,6 +1046,85 @@ describe("Board", () => {
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
});
describe("workflow toolbar collapsible", () => {
function renderWorkflowToolbarBoard() {
const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn();
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
);
renderBoard({
tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })],
onCreateWorkflow,
onOpenWorkflowEditor,
});
return { onCreateWorkflow, onOpenWorkflowEditor };
}
it("defaults to expanded (not collapsed)", async () => {
renderWorkflowToolbarBoard();
expect(await screen.findByLabelText("Select workflow")).toBeDefined();
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("true");
});
it("clicking the toggle collapses the toolbar", async () => {
renderWorkflowToolbarBoard();
await screen.findByLabelText("Select workflow");
fireEvent.click(screen.getByTestId("board-workflow-collapse-toggle"));
expect(screen.queryByLabelText("Select workflow")).toBeNull();
expect(screen.queryByRole("button", { name: "New workflow" })).toBeNull();
expect(screen.queryByRole("button", { name: "Edit workflows" })).toBeNull();
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("false");
expect(screen.getByText("Workflow")).toBeDefined();
});
it("clicking the toggle again expands the toolbar", async () => {
renderWorkflowToolbarBoard();
await screen.findByLabelText("Select workflow");
const toggle = screen.getByTestId("board-workflow-collapse-toggle");
fireEvent.click(toggle);
expect(screen.queryByLabelText("Select workflow")).toBeNull();
fireEvent.click(toggle);
expect(screen.getByLabelText("Select workflow")).toBeDefined();
expect(screen.getByRole("button", { name: "New workflow" })).toBeDefined();
expect(screen.getByRole("button", { name: "Edit workflows" })).toBeDefined();
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
it("persists collapsed state to localStorage", async () => {
renderWorkflowToolbarBoard();
await screen.findByLabelText("Select workflow");
const toggle = screen.getByTestId("board-workflow-collapse-toggle");
fireEvent.click(toggle);
expect(window.localStorage.getItem(BOARD_WORKFLOW_COLLAPSED_KEY)).toBe("1");
fireEvent.click(toggle);
expect(window.localStorage.getItem(BOARD_WORKFLOW_COLLAPSED_KEY)).toBe("0");
});
it("reads collapsed state from localStorage on mount", async () => {
window.localStorage.setItem(BOARD_WORKFLOW_COLLAPSED_KEY, "1");
renderWorkflowToolbarBoard();
await waitFor(() => expect(screen.getByTestId("board-workflow-collapse-toggle")).toBeDefined());
expect(screen.queryByLabelText("Select workflow")).toBeNull();
expect(screen.queryByRole("button", { name: "New workflow" })).toBeNull();
expect(screen.queryByRole("button", { name: "Edit workflows" })).toBeNull();
expect(screen.getByTestId("board-workflow-collapse-toggle").getAttribute("aria-expanded")).toBe("false");
expect(screen.getByText("Workflow")).toBeDefined();
});
});
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {
const onCreateWorkflow = vi.fn();
const onOpenWorkflowEditor = vi.fn();

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { InlineCreateCard } from "../InlineCreateCard";
import type { Task, Column } from "@fusion/core";
import { fetchModels, fetchSettings, fetchAgents, checkDuplicateTasks } from "../../api";
import { fetchModels, fetchSettings, fetchAgents, checkDuplicateTasks, fetchWorkflows } from "../../api";
import { useNodes } from "../../hooks/useNodes";
import type { ModelInfo } from "../../api";
import { scopedKey } from "../../utils/projectStorage";
@@ -20,6 +20,7 @@ vi.mock("lucide-react", () => ({
ListTree: () => null,
Zap: () => null,
ChevronDown: () => null,
ChevronRight: () => null,
ChevronUp: () => null,
Bot: () => null,
Server: () => null,
@@ -134,6 +135,8 @@ vi.mock("../../api", () => ({
const TEST_PROJECT_ID = "proj-123";
const INLINE_CREATE_STORAGE_KEY = scopedKey("kb-inline-create-text", TEST_PROJECT_ID);
const WORKFLOW_SELECTOR_STORAGE_KEY = "kb-board-workflow-selector-collapsed";
const SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY = scopedKey(WORKFLOW_SELECTOR_STORAGE_KEY, TEST_PROJECT_ID);
const MOCK_MODELS: ModelInfo[] = [
{
@@ -227,6 +230,58 @@ beforeEach(() => {
groupOverlappingFiles: true,
autoMerge: true,
});
vi.mocked(fetchWorkflows).mockResolvedValue([
{ id: "wf-a", name: "Workflow A" },
{ id: "wf-b", name: "Workflow B" },
]);
});
describe("InlineCreateCard workflow selector", () => {
it("opts the board inline-create workflow selector into collapsible mode by default", async () => {
renderCard();
expandCard();
expect(screen.getByRole("button", { name: /Collapse workflow selector/i })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
await waitFor(() => expect(screen.getByRole("option", { name: "Workflow A" })).toBeDefined());
});
it("restores persisted collapsed workflow selector state for the current project", () => {
localStorage.setItem(SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY, "true");
renderCard();
expandCard();
const collapsedButton = screen.getByRole("button", { name: /^Workflow$/i });
expect(collapsedButton).toHaveAttribute("aria-expanded", "false");
expect(collapsedButton).toHaveClass("workflow-selector-collapsed-button");
expect(screen.queryByRole("combobox", { name: /Workflow/i })).toBeNull();
});
it("persists collapsed and expanded workflow selector state under the project-scoped key", () => {
renderCard();
expandCard();
fireEvent.click(screen.getByRole("button", { name: /Collapse workflow selector/i }));
expect(localStorage.getItem(SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY)).toBe("true");
expect(screen.getByRole("button", { name: /^Workflow$/i })).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: /^Workflow$/i }));
expect(localStorage.getItem(SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY)).toBe("false");
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
});
it("keeps project workflow selector preferences isolated between projects", () => {
localStorage.setItem(SCOPED_WORKFLOW_SELECTOR_STORAGE_KEY, "true");
renderCard([], { projectId: "other-project" });
expandCard();
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
expect(screen.queryByRole("button", { name: /^Workflow$/i })).toBeNull();
});
});
describe("InlineCreateCard textarea width (FN-1608)", () => {

View File

@@ -2,8 +2,13 @@ import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { WorkflowSelector } from "../WorkflowSelector";
import { scopedKey } from "../../utils/projectStorage";
vi.mock("lucide-react", () => ({ Workflow: () => null }));
vi.mock("lucide-react", () => ({
Workflow: () => null,
ChevronDown: () => null,
ChevronRight: () => null,
}));
const fetchWorkflowsMock = vi.fn();
const fetchWorkflowMock = vi.fn();
@@ -18,6 +23,7 @@ const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }) }));
beforeEach(() => {
localStorage.clear();
mockConfirm.mockReset();
fetchWorkflowsMock.mockReset();
fetchWorkflowMock.mockReset();
@@ -28,6 +34,71 @@ beforeEach(() => {
fetchWorkflowMock.mockResolvedValue({ id: "builtin:hidden", name: "Hidden built-in" });
});
describe("WorkflowSelector collapsible mode", () => {
const storageKey = "kb-board-workflow-selector-collapsed";
it("defaults to expanded when no persisted collapsed state exists", async () => {
render(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} projectId="project-a" />);
expect(screen.getByRole("button", { name: /Collapse workflow selector/i })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
await waitFor(() => expect(screen.getByRole("option", { name: "Workflow A" })).toBeDefined());
});
it("collapses and expands while persisting state by project", async () => {
render(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} projectId="project-a" />);
fireEvent.click(screen.getByRole("button", { name: /Collapse workflow selector/i }));
expect(screen.getByRole("button", { name: /^Workflow$/i })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("combobox")).toBeNull();
expect(localStorage.getItem(scopedKey(storageKey, "project-a"))).toBe("true");
fireEvent.click(screen.getByRole("button", { name: /^Workflow$/i }));
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
expect(localStorage.getItem(scopedKey(storageKey, "project-a"))).toBe("false");
});
it("restores persisted collapsed state for the current project only", () => {
localStorage.setItem(scopedKey(storageKey, "project-a"), "true");
const { rerender } = render(
<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} projectId="project-a" />,
);
expect(screen.getByRole("button", { name: /^Workflow$/i })).toBeDefined();
expect(screen.queryByRole("combobox")).toBeNull();
rerender(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} projectId="project-b" />);
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDefined();
});
it("keeps the select disabled while loading and when disabled is passed", async () => {
fetchWorkflowsMock.mockReturnValue(new Promise(() => undefined));
const { rerender } = render(
<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} />,
);
expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDisabled();
fetchWorkflowsMock.mockResolvedValue([]);
rerender(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} disabled />);
await waitFor(() => expect(screen.getByRole("combobox", { name: /Workflow/i })).toBeDisabled());
});
it("renders the None option when the workflow list is empty", async () => {
fetchWorkflowsMock.mockResolvedValue([]);
render(<WorkflowSelector value={null} onChange={vi.fn()} collapsible collapseStorageKey={storageKey} />);
await waitFor(() => expect(screen.getByRole("option", { name: "None" })).toBeDefined());
expect(screen.queryByRole("option", { name: "Workflow A" })).toBeNull();
});
});
describe("WorkflowSelector switch-with-active-session confirm (U9)", () => {
it("shows the abort-warning confirm and applies the switch when confirmed", async () => {
mockConfirm.mockResolvedValue(true);

View File

@@ -84,8 +84,10 @@ describe("projectStorage", () => {
"kb-dashboard-list-selected-task",
"kb-dashboard-list-sidebar-width",
"kb-dashboard-mailbox-sidebar-width",
"kb-dashboard-board-workflow-collapsed",
"kb-quick-entry-text",
"kb-inline-create-text",
"kb-board-workflow-selector-collapsed",
"fn-agent-view",
"kb-terminal-tabs",
"kb-planning-last-description",
@@ -103,7 +105,7 @@ describe("projectStorage", () => {
"fusion-plugin-dependency-graph:positions",
]),
);
expect(PROJECT_STORAGE_KEYS).toHaveLength(25);
expect(PROJECT_STORAGE_KEYS).toHaveLength(27);
});
it("stores branch filter values as scoped strings per project", () => {

View File

@@ -17,8 +17,10 @@ export const PROJECT_STORAGE_KEYS: string[] = [
"kb-dashboard-list-selected-task",
"kb-dashboard-list-sidebar-width",
"kb-dashboard-mailbox-sidebar-width",
"kb-dashboard-board-workflow-collapsed",
"kb-quick-entry-text",
"kb-inline-create-text",
"kb-board-workflow-selector-collapsed",
"fn-agent-view",
"kb-terminal-tabs",
"kb-planning-last-description",