FN-6033: fix mobile workflow editor selection and touch targets

Keep the workflow editor mobile-first selection flow usable and improve tap targets on phones.

- start the mobile workflow editor on the workflow list with no preselected workflow and show a select-a-workflow prompt
- keep mobile list/editor stage transitions in sync after create, duplicate, import, and delete actions
- increase workflow editor mobile touch targets and padding without changing desktop sizing
- add regression coverage for mobile viewport selection behavior and CSS touch-target scoping
- update the dashboard guide to document the mobile workflow editor behavior

Files changed:
docs/dashboard-guide.md                            |  2 +-
packages/dashboard/app/components/WorkflowNodeEditor.css          | 56 +++++++++++++++++-
packages/dashboard/app/components/WorkflowNodeEditor.tsx          | 24 ++++++--
packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts       | 36 ++++++++++++
packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx          | 68 ++++++++++++++++++++++

Fusion-Task-Id: FN-6033

Fusion-Task-Lineage: c9da29ef-c099-4e14-8671-24995106c78c
This commit is contained in:
gsxdsm
2026-06-08 12:37:36 -07:00
parent 7d8ae5333e
commit 5976b63fda
5 changed files with 180 additions and 6 deletions

View File

@@ -111,7 +111,7 @@ Behavior:
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. 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 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 and stacks the sidebar, canvas, inspector, and settings/authoring panels vertically so each section remains scrollable and usable on phones
- 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
- 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

@@ -252,6 +252,15 @@
font-size: 0.85rem;
}
.wf-editor-select-note {
padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--ws-info) 8%, var(--bg-secondary));
border: 1px solid var(--ws-info);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 0.85rem;
}
.wf-editor-canvas-wrap {
display: flex;
flex-direction: column;
@@ -1160,6 +1169,8 @@
.wf-editor-modal,
.wf-create-modal {
--wf-editor-touch-target: calc(var(--space-xl) + var(--space-lg) + var(--space-xs));
width: 100vw;
min-width: 0;
max-width: 100vw;
@@ -1228,8 +1239,9 @@
align-items: center;
align-self: flex-start;
gap: var(--space-xs);
min-height: var(--wf-editor-touch-target);
margin: var(--space-sm) var(--space-sm) 0;
padding: 5px 8px;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
@@ -1259,6 +1271,48 @@
min-height: 0;
}
.wf-editor-list-item,
.wf-editor-new,
.wf-editor-import,
.wf-sidebar-section-toggle,
.wf-palette-btn,
.wf-editor-action,
.wf-editor-delete,
.wf-editor-save,
.wf-templates-toggle,
.wf-templates-entry,
.wf-template-option,
.wf-ai-toggle,
.wf-workflow-name,
.wf-workflow-name-input,
.wf-column-panel button,
.wf-fields-panel button,
.wf-settings-panel button {
min-height: var(--wf-editor-touch-target);
}
.wf-editor-list-item,
.wf-editor-new,
.wf-editor-import,
.wf-palette-btn,
.wf-editor-action,
.wf-editor-delete,
.wf-editor-save,
.wf-templates-toggle,
.wf-templates-entry,
.wf-ai-toggle {
padding: var(--space-sm) var(--space-md);
}
.wf-field input,
.wf-field textarea,
.wf-field select,
.wf-templates-filter,
.wf-ai-prompt {
min-height: var(--wf-editor-touch-target);
padding: var(--space-sm) var(--space-md);
}
.wf-template-list {
max-height: 40vh;
}

View File

@@ -43,6 +43,7 @@ import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useConfirm } from "../hooks/useConfirm";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useAppSettings } from "../hooks/useAppSettings";
import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
import type { NodeSummaryCatalogs } from "./nodes/node-summary";
@@ -660,9 +661,11 @@ function InnerEditor({
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const viewportMode = useViewportMode();
const isMobileViewport = viewportMode === "mobile";
const [workflowListStageOpen, setWorkflowListStageOpen] = useState(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(max-width: 768px)").matches;
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
});
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -969,13 +972,16 @@ function InnerEditor({
try {
const data = await fetchWorkflows(projectId);
setWorkflows(data);
setActiveId((prev) => prev ?? data[0]?.id ?? null);
setActiveId((prev) => {
if (prev && data.some((workflow) => workflow.id === prev)) return prev;
return isMobileViewport ? null : data[0]?.id ?? null;
});
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load workflows", "error");
} finally {
setLoading(false);
}
}, [projectId, addToast]);
}, [projectId, addToast, isMobileViewport]);
useEffect(() => {
void loadWorkflows();
@@ -1061,6 +1067,7 @@ function InnerEditor({
const result = await importWorkflow(envelope, projectId);
await loadWorkflows();
setActiveId(result.workflow.id);
setWorkflowListStageOpen(false);
addToast(
t("workflows.imported", 'Imported workflow "{{name}}"', { name: result.workflow.name }),
"success",
@@ -1567,6 +1574,7 @@ function InnerEditor({
);
setWorkflows((ws) => [...ws, created]);
setActiveId(created.id);
setWorkflowListStageOpen(false);
addToast(t("workflows.created", 'Created workflow "{{name}}"', { name: created.name }), "success");
closeCreateDialog();
},
@@ -1599,6 +1607,7 @@ function InnerEditor({
pendingInterpreterOnlyRef.current = result.interpreterOnly;
setWorkflows((ws) => [...ws, created]);
setActiveId(created.id);
setWorkflowListStageOpen(false);
addToast(t("workflows.created", 'Created workflow "{{name}}"', { name: created.name }), "success");
if (result.strippedApprovalFlags) {
addToast(
@@ -1627,11 +1636,12 @@ function InnerEditor({
await deleteWorkflow(activeWorkflow.id, projectId);
setWorkflows((ws) => ws.filter((w) => w.id !== activeWorkflow.id));
setActiveId(null);
if (isMobileViewport) setWorkflowListStageOpen(true);
addToast(t("workflows.deleted", "Workflow deleted"), "success");
} catch (err) {
addToast(getErrorMessage(err) || t("workflows.deleteFailed", "Failed to delete workflow"), "error");
}
}, [activeWorkflow, projectId, addToast, confirm, t]);
}, [activeWorkflow, projectId, addToast, confirm, t, isMobileViewport]);
const handleDuplicate = useCallback(async () => {
if (!activeWorkflow) return;
@@ -1647,6 +1657,7 @@ function InnerEditor({
);
setWorkflows((ws) => [...ws, created]);
setActiveId(created.id);
setWorkflowListStageOpen(false);
addToast(`Duplicated to "${created.name}" — editable`, "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to duplicate workflow", "error");
@@ -2146,6 +2157,11 @@ function InnerEditor({
))}
</div>
)}
{isMobileViewport && workflows.length > 0 && !activeWorkflow ? (
<div className="wf-editor-select-note" data-testid="wf-mobile-select-note">
{t("workflows.mobileSelectNote", "Select a workflow to edit.")}
</div>
) : null}
{loading ? (
<div className="wf-editor-empty">
<Loader2 size={16} className="wf-spin" /> Loading…

View File

@@ -82,6 +82,42 @@ describe("WorkflowNodeEditor mobile CSS contract", () => {
expect(canvasWrapRule).toMatch(/min-height\s*:\s*0\s*;/);
});
it("FN-6033 keeps workflow editor touch target increases mobile-scoped", () => {
const baseCss = loadAllAppCssBaseOnly();
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
const modalRule = findRule(mobileBlocks, /\.wf-editor-modal,\s*\.wf-create-modal\s*\{[^}]*\}/);
expect(modalRule).toMatch(/--wf-editor-touch-target\s*:\s*calc\(var\(--space-xl\) \+ var\(--space-lg\) \+ var\(--space-xs\)\)\s*;/);
const listAndActionRule = findRule(
mobileBlocks,
/\.wf-editor-list-item,\s*\.wf-editor-new,\s*\.wf-editor-import,[^}]*\.wf-settings-panel button\s*\{[^}]*\}/,
);
expect(listAndActionRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
const editorButtonsRule = findRule(
mobileBlocks,
/\.wf-editor-list-item,\s*\.wf-editor-new,\s*\.wf-editor-import,[^}]*\.wf-ai-toggle\s*\{[^}]*\}/,
);
expect(editorButtonsRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
const inlineControlsRule = findRule(
mobileBlocks,
/\.wf-field input,\s*\.wf-field textarea,\s*\.wf-field select,[^}]*\.wf-ai-prompt\s*\{[^}]*\}/,
);
expect(inlineControlsRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
expect(inlineControlsRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
const mobileBackRule = findRule(mobileBlocks, /\.wf-editor-mobile-back\s*\{[^}]*\}/);
expect(mobileBackRule).toMatch(/min-height\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
expect(mobileBackRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-md\)\s*;/);
const desktopListRule = findRule([baseCss], /\.wf-editor-list-item\s*\{[^}]*\}/);
expect(desktopListRule).not.toMatch(/min-height\s*:/);
expect(editorCss).not.toMatch(/@media \(max-width: 768px\)\s*\{[^}]*\.btn\s*\{/s);
});
it("FN-5992 covers create dialog and AI panel mobile overlays", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");

View File

@@ -81,12 +81,33 @@ import type { WorkflowStepTemplate } from "@fusion/core";
import { beforeEach as viBeforeEach } from "vitest";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
function mockWorkflowEditorViewport(mode: "desktop" | "mobile" | "tablet" = "desktop") {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches:
(mode === "mobile" && (query === MOBILE_MEDIA_QUERY || query === "(max-width: 768px)")) ||
(mode === "tablet" && query === "(min-width: 769px) and (max-width: 1024px)"),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
// useAppSettings (threaded into the editor for the column-agent flag gate)
// fetches config + settings on mount via the mocked api module. Default both to
// resolved empties for every test so the real hook never rejects; column-agent
// tests override fetchSettings to flip the flags on. fetchAgents defaults empty.
viBeforeEach(() => {
mockWorkflowEditorViewport("desktop");
vi.mocked(fetchConfig).mockResolvedValue({ maxConcurrent: 2, rootDir: "." });
vi.mocked(fetchSettings).mockResolvedValue({} as never);
vi.mocked(fetchAgents).mockResolvedValue([]);
@@ -297,6 +318,53 @@ describe("WorkflowNodeEditor", () => {
await waitFor(() => expect(screen.getByText(/No workflows yet/i)).toBeInTheDocument());
expect(screen.getByText(/No workflow selected/i)).toBeInTheDocument();
expect(screen.getByTestId("wf-empty-create")).toBeInTheDocument();
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
});
it("preselects the first populated workflow on desktop", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def(), v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
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()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
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: "QA" })).not.toHaveClass("active");
expect(screen.getByRole("button", { name: "Custom" })).not.toHaveClass("active");
});
it("selects by 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={() => {}} />);
await screen.findByTestId("wf-mobile-select-note");
const qaButtons = screen.getAllByRole("button", { name: "QA" });
expect(qaButtons).toHaveLength(2);
expect(qaButtons[0]).not.toHaveClass("active");
expect(qaButtons[1]).not.toHaveClass("active");
fireEvent.click(qaButtons[1]);
await waitFor(() => expect(qaButtons[1]).toHaveClass("active"));
expect(qaButtons[0]).not.toHaveClass("active");
expect(screen.queryByTestId("wf-mobile-select-note")).not.toBeInTheDocument();
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
});
it("renders nothing when closed", () => {