FN-6037: open selected workflow editor on mobile

Open the board workflow editor directly to the selected workflow, including on mobile.

- pass the selected workflow id from the board toolbar through app modal state into the workflow editor
- preselect the requested workflow when loading workflows, while preserving settings/create entry points and mobile fallback behavior
- cover the new workflow targeting behavior in board, modal manager, and workflow editor tests and document the mobile editor flow

Files changed:
 docs/dashboard-guide.md                            |  3 +-
 packages/dashboard/app/App.tsx                     |  4 +-
 packages/dashboard/app/components/AppModals.tsx    |  1 +
 packages/dashboard/app/components/Board.tsx        |  6 +-
 packages/dashboard/app/components/WorkflowNodeEditor.tsx          | 13 ++++-
 packages/dashboard/app/components/__tests__/Board.test.tsx        |  4 ++
 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx | 68 ++++++++++++++++++++++
 packages/dashboard/app/hooks/__tests__/useModalManager.test.ts    | 42 +++++++++++++
 packages/dashboard/app/hooks/useModalManager.ts    | 16 +++--

Fusion-Task-Id: FN-6037

Fusion-Task-Lineage: e983e699-5376-434f-af03-39ce5a300029
This commit is contained in:
gsxdsm
2026-06-09 00:27:51 -07:00
parent 815ec58abd
commit 53f3638b12
9 changed files with 145 additions and 12 deletions

View File

@@ -104,6 +104,7 @@ The workflow editor opens as a full-screen modal editor for authoring custom wor
Navigation:
- Open a task or board surface that shows the workflow selector, then choose **Manage…**
- From the board workflow toolbar, use the edit workflow button beside the selector to open the currently selected workflow directly when one is selected.
Behavior:
- Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels
@@ -111,7 +112,7 @@ Behavior:
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those dropdown controls write workflow setting values for the active default workflow.
- On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet, opens to the workflow list with no workflow preselected, prompts users to select a workflow to edit, and uses larger workflow-editor touch targets so each section remains scrollable and usable on phones
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available.
- The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens
## Planning Mode

View File

@@ -1239,8 +1239,8 @@ function AppInner() {
pushNav({ type: "modal", close: modalManager.closeScripts });
}, [modalManager, pushNav]);
const openWorkflowEditorWithNav = useCallback(() => {
modalManager.openWorkflowEditor();
const openWorkflowEditorWithNav = useCallback((workflowId?: string) => {
modalManager.openWorkflowEditor(undefined, workflowId);
pushNav({ type: "modal", close: modalManager.closeWorkflowEditor });
}, [modalManager, pushNav]);

View File

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

View File

@@ -67,8 +67,8 @@ interface BoardProps {
lastFetchTimeMs?: number;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
/** Opens the workflow editor modal. */
onOpenWorkflowEditor?: () => void;
/** Opens the workflow editor modal, optionally focused on a workflow id. */
onOpenWorkflowEditor?: (workflowId?: string) => void;
/** Opens the workflow editor to create a new workflow. */
onCreateWorkflow?: () => void;
}
@@ -460,7 +460,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
<button
type="button"
className="btn btn-icon btn-sm board-workflow-edit-btn"
onClick={onOpenWorkflowEditor}
onClick={() => onOpenWorkflowEditor(selectedWorkflow.id)}
title="Edit workflows"
aria-label="Edit workflows"
>

View File

@@ -173,6 +173,8 @@ interface WorkflowNodeEditorProps {
initialPanel?: "settings";
/** When "create" the editor opens with the new-workflow dialog active. */
initialAction?: "create";
/** Workflow id to pre-select when opening directly into an existing workflow. */
initialWorkflowId?: string;
}
let nodeSeq = 0;
@@ -657,6 +659,7 @@ function InnerEditor({
projectId,
initialPanel,
initialAction,
initialWorkflowId,
modalRef,
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
@@ -972,16 +975,22 @@ function InnerEditor({
try {
const data = await fetchWorkflows(projectId);
setWorkflows(data);
const requestedWorkflow = initialWorkflowId
? data.find((workflow) => workflow.id === initialWorkflowId)
: undefined;
setActiveId((prev) => {
if (prev && data.some((workflow) => workflow.id === prev)) return prev;
return isMobileViewport ? null : data[0]?.id ?? null;
return requestedWorkflow?.id ?? (isMobileViewport ? null : data[0]?.id ?? null);
});
if (requestedWorkflow && isMobileViewport) {
setWorkflowListStageOpen(false);
}
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load workflows", "error");
} finally {
setLoading(false);
}
}, [projectId, addToast, isMobileViewport]);
}, [projectId, addToast, isMobileViewport, initialWorkflowId]);
useEffect(() => {
void loadWorkflows();

View File

@@ -1012,6 +1012,7 @@ describe("Board", () => {
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("builtin:coding");
});
it("preserves workflow toolbar partial action visibility", async () => {
@@ -1065,6 +1066,9 @@ describe("Board", () => {
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();
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
expect(onOpenWorkflowEditor).toHaveBeenCalledWith("wf-custom");
});
it("keeps the default workflow first in the dropdown even when another workflow has cards", async () => {

View File

@@ -331,6 +331,28 @@ describe("WorkflowNodeEditor", () => {
expect(screen.getAllByRole("button", { name: "QA" })[0]).toHaveClass("active");
});
it("preselects the requested workflow id on desktop instead of the first workflow", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-002" />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Custom");
const workflowItems = document.querySelectorAll(".wf-editor-list-item");
expect(workflowItems[0]).toHaveTextContent("QA");
expect(workflowItems[0]).not.toHaveClass("active");
expect(workflowItems[1]).toHaveTextContent("Custom");
expect(workflowItems[1]).toHaveClass("active");
});
it("falls back to the first desktop workflow when the requested workflow id is missing", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-deleted" />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
expect(screen.getAllByRole("button", { name: "QA" })[0]).toHaveClass("active");
});
it("opens populated mobile workflows on the list with no preselected workflow", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
@@ -344,6 +366,52 @@ describe("WorkflowNodeEditor", () => {
expect(screen.getByRole("button", { name: "Custom" })).not.toHaveClass("active");
});
it("opens the requested workflow id directly on mobile and bypasses the select stage", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-002" />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Custom");
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
const workflowItems = document.querySelectorAll(".wf-editor-list-item");
expect(workflowItems[0]).toHaveTextContent("QA");
expect(workflowItems[0]).not.toHaveClass("active");
expect(workflowItems[1]).toHaveTextContent("Custom");
expect(workflowItems[1]).toHaveClass("active");
});
it("keeps the mobile list fallback when the requested workflow id is missing", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-deleted" />);
expect(await screen.findByTestId("wf-mobile-select-note")).toHaveTextContent("Select a workflow to edit.");
expect(screen.getByText(/No workflow selected/i)).toBeInTheDocument();
expect(screen.queryByTestId("wf-workflow-name")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Custom" })).not.toHaveClass("active");
});
it("selects the requested workflow id on mobile even when workflow names are duplicated", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([
{ ...def(), id: "WF-DUP-A", name: "QA" },
{ ...v2Def(), id: "WF-DUP-B", name: "QA" },
]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} initialWorkflowId="WF-DUP-B" />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
const qaButtons = document.querySelectorAll(".wf-editor-list-item");
expect(qaButtons).toHaveLength(2);
expect(qaButtons[0]).toHaveTextContent("QA");
expect(qaButtons[0]).not.toHaveClass("active");
expect(qaButtons[1]).toHaveTextContent("QA");
expect(qaButtons[1]).toHaveClass("active");
});
it("selects by workflow id on mobile even when workflow names are duplicated", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([

View File

@@ -344,4 +344,46 @@ describe("useModalManager", () => {
expect(result.current.detailTask?.id).toBe("FN-B");
expect(result.current.detailTask?.title).toBe("renamed");
});
it("tracks a target workflow id for normal workflow editor opens and resets it on close", () => {
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [] }),
);
act(() => {
result.current.openWorkflowEditor(undefined, "WF-selected");
});
expect(result.current.workflowEditorOpen).toBe(true);
expect(result.current.workflowEditorInitialPanel).toBeUndefined();
expect(result.current.workflowEditorInitialAction).toBeUndefined();
expect(result.current.workflowEditorInitialWorkflowId).toBe("WF-selected");
act(() => {
result.current.closeWorkflowEditor();
});
expect(result.current.workflowEditorOpen).toBe(false);
expect(result.current.workflowEditorInitialWorkflowId).toBeUndefined();
});
it("keeps workflow editor settings and create modes distinct from target workflow opens", () => {
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [] }),
);
act(() => {
result.current.openWorkflowEditor("settings", "WF-ignored");
});
expect(result.current.workflowEditorInitialPanel).toBe("settings");
expect(result.current.workflowEditorInitialAction).toBeUndefined();
expect(result.current.workflowEditorInitialWorkflowId).toBeUndefined();
act(() => {
result.current.openWorkflowEditor("create", "WF-ignored");
});
expect(result.current.workflowEditorInitialPanel).toBeUndefined();
expect(result.current.workflowEditorInitialAction).toBe("create");
expect(result.current.workflowEditorInitialWorkflowId).toBeUndefined();
});
});

View File

@@ -58,6 +58,8 @@ export interface ModalManager {
workflowEditorInitialPanel?: "settings";
/** When the workflow editor opens, which modal action to start. */
workflowEditorInitialAction?: "create";
/** When the workflow editor opens for editing, which workflow id to pre-select. */
workflowEditorInitialWorkflowId?: string;
agentsOpen: boolean;
scriptsOpen: boolean;
setupWizardOpen: boolean;
@@ -120,7 +122,7 @@ export interface ModalManager {
openGitManager: () => void;
closeGitManager: () => void;
openWorkflowEditor: (initialPanelOrAction?: "settings" | "create") => void;
openWorkflowEditor: (initialPanelOrAction?: "settings" | "create", initialWorkflowId?: string) => void;
closeWorkflowEditor: () => void;
openAgents: () => void;
@@ -181,6 +183,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [workflowEditorOpen, setWorkflowEditorOpen] = useState(false);
const [workflowEditorInitialPanel, setWorkflowEditorInitialPanel] = useState<"settings" | undefined>(undefined);
const [workflowEditorInitialAction, setWorkflowEditorInitialAction] = useState<"create" | undefined>(undefined);
const [workflowEditorInitialWorkflowId, setWorkflowEditorInitialWorkflowId] = useState<string | undefined>(undefined);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
@@ -346,15 +349,19 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const openGitManager = useCallback(() => setGitManagerOpen(true), []);
const closeGitManager = useCallback(() => setGitManagerOpen(false), []);
const openWorkflowEditor = useCallback((initialPanelOrAction?: "settings" | "create") => {
setWorkflowEditorInitialPanel(initialPanelOrAction === "settings" ? "settings" : undefined);
setWorkflowEditorInitialAction(initialPanelOrAction === "create" ? "create" : undefined);
const openWorkflowEditor = useCallback((initialPanelOrAction?: "settings" | "create", initialWorkflowId?: string) => {
const isSettingsOpen = initialPanelOrAction === "settings";
const isCreateOpen = initialPanelOrAction === "create";
setWorkflowEditorInitialPanel(isSettingsOpen ? "settings" : undefined);
setWorkflowEditorInitialAction(isCreateOpen ? "create" : undefined);
setWorkflowEditorInitialWorkflowId(!isSettingsOpen && !isCreateOpen ? initialWorkflowId : undefined);
setWorkflowEditorOpen(true);
}, []);
const closeWorkflowEditor = useCallback(() => {
setWorkflowEditorOpen(false);
setWorkflowEditorInitialPanel(undefined);
setWorkflowEditorInitialAction(undefined);
setWorkflowEditorInitialWorkflowId(undefined);
}, []);
const openAgents = useCallback(() => setAgentsOpen(true), []);
@@ -424,6 +431,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
workflowEditorOpen,
workflowEditorInitialPanel,
workflowEditorInitialAction,
workflowEditorInitialWorkflowId,
agentsOpen,
scriptsOpen,
setupWizardOpen,