diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 954c63209e..13fe5d076f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 545a364220..80663155cd 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -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; } diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 95573a79bb..633bada50b 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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 & { modalRef: React.RefObject }) { const [workflows, setWorkflows] = useState([]); const [activeId, setActiveId] = useState(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({ ))} )} + {isMobileViewport && workflows.length > 0 && !activeWorkflow ? ( +
+ {t("workflows.mobileSelectNote", "Select a workflow to edit.")} +
+ ) : null} {loading ? (
Loading… diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts index 9e3babacbe..1e78f108d3 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts @@ -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)"); diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index bcb685d145..8d4c94afdf 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -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( {}} 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( {}} 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( {}} 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", () => {