FN-6058: improve mobile workflow node editor layout

Make the mobile workflow editor easier to use by collapsing competing panels and updating coverage.

- add a mobile inspector toggle that collapses node details and re-expands on node selection
- default the templates palette to collapsed on mobile and tighten mobile toolbar/layout styling to avoid overlap
- remove the stale workflow-id preselection path from the editor and align workflow edge/mobile regression coverage
- drop the unused modal prop wiring so the editor API matches the merged implementation

Files changed:
 packages/dashboard/app/components/AppModals.tsx    |   1 -
 .../app/components/WorkflowNodeEditor.css          |  61 +++++++++
 .../app/components/WorkflowNodeEditor.tsx          |  63 +++++++---
 .../__tests__/WorkflowNodeEditor.css.test.ts       |  21 ++++
 .../__tests__/WorkflowNodeEditor.test.tsx          | 139 +++++++++++----------
 5 files changed, 200 insertions(+), 85 deletions(-)

Fusion-Task-Id: FN-6058

Fusion-Task-Lineage: d037832f-8bd8-4e3d-b31b-e30cd9752838
This commit is contained in:
gsxdsm
2026-06-09 01:14:59 -07:00
parent f27ebfd6bb
commit 61cbc5c3db
5 changed files with 200 additions and 85 deletions

View File

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

View File

@@ -557,6 +557,38 @@
color: var(--text);
}
.wf-inspector-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.wf-inspector-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text);
font: inherit;
cursor: pointer;
}
.wf-inspector-toggle:hover {
background: var(--bg-tertiary);
}
.wf-inspector-toggle--collapsed {
position: absolute;
right: var(--space-sm);
bottom: var(--space-sm);
z-index: 5;
box-shadow: var(--shadow-sm);
}
.wf-field {
display: flex;
flex-direction: column;
@@ -1225,6 +1257,11 @@
min-height: 0;
}
.wf-editor-body--editor-stage .wf-editor-canvas {
flex: 1 1 auto;
min-height: 0;
}
.wf-editor-body--editor-stage .wf-editor-inspector {
width: 100%;
min-width: 0;
@@ -1318,9 +1355,33 @@
}
.wf-editor-toolbar {
flex-wrap: nowrap;
overflow-x: auto;
}
.wf-editor-palette,
.wf-editor-actions {
flex: 0 0 auto;
flex-wrap: nowrap;
}
.wf-templates {
flex: 0 0 auto;
}
.wf-templates-header {
flex-wrap: nowrap;
}
.wf-templates-toggle {
flex: 0 0 auto;
}
.wf-inspector-toggle {
min-height: var(--wf-editor-touch-target);
padding: var(--space-sm) var(--space-md);
}
.wf-ai-panel {
position: fixed;
inset: var(--space-sm);

View File

@@ -173,8 +173,6 @@ 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;
@@ -659,7 +657,6 @@ function InnerEditor({
projectId,
initialPanel,
initialAction,
initialWorkflowId,
modalRef,
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
@@ -681,6 +678,7 @@ function InnerEditor({
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
const { t } = useTranslation("app");
const { confirm } = useConfirm();
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
@@ -728,7 +726,11 @@ function InnerEditor({
const templatesCollapsedStorageKey = "fusion:wf-templates-collapsed";
const [templatesCollapsed, setTemplatesCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(templatesCollapsedStorageKey) === "1";
const stored = localStorage.getItem(templatesCollapsedStorageKey);
if (stored != null) return stored === "1";
return typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia(MOBILE_MEDIA_QUERY).matches;
} catch {
return false;
}
@@ -915,6 +917,10 @@ function InnerEditor({
}
}, [templatesCollapsed]);
useEffect(() => {
if (selectedNodeId) setInspectorCollapsed(false);
}, [selectedNodeId]);
// U9/R8: fragment definitions surface from the loaded workflow list (kind ===
// "fragment"); they are excluded from the sidebar workflow list elsewhere.
const fragments = useMemo(
@@ -975,22 +981,16 @@ 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 requestedWorkflow?.id ?? (isMobileViewport ? null : data[0]?.id ?? null);
return 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, initialWorkflowId]);
}, [projectId, addToast, isMobileViewport]);
useEffect(() => {
void loadWorkflows();
@@ -2677,6 +2677,22 @@ function InnerEditor({
)}
<div className="wf-editor-canvas" ref={canvasRef} tabIndex={-1}>
{isMobileViewport &&
inspectorCollapsed &&
selectedNode &&
selectedNode.data.kind !== "start" &&
selectedNode.data.kind !== "end" && (
<button
type="button"
className="wf-inspector-toggle wf-inspector-toggle--collapsed"
data-testid="wf-inspector-toggle"
aria-expanded="false"
onClick={() => setInspectorCollapsed(false)}
>
<ChevronRight size={13} />
<span>{t("workflowNodes.showInspector", "Show node details")}</span>
</button>
)}
{isTrivialUserGraph && (
<div className="wf-trivial-hint" role="status" data-testid="wf-trivial-hint">
{t(
@@ -2743,9 +2759,26 @@ function InnerEditor({
)}
</section>
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
<aside className="wf-editor-inspector">
<h3>Node</h3>
{selectedNode &&
selectedNode.data.kind !== "start" &&
selectedNode.data.kind !== "end" &&
!(isMobileViewport && inspectorCollapsed) && (
<aside className="wf-editor-inspector" data-testid="wf-node-inspector">
<div className="wf-inspector-heading">
<h3>Node</h3>
{isMobileViewport && (
<button
type="button"
className="wf-inspector-toggle wf-inspector-toggle--expanded"
data-testid="wf-inspector-toggle"
aria-expanded="true"
onClick={() => setInspectorCollapsed(true)}
>
<ChevronDown size={13} />
<span>{t("workflowNodes.collapseInspector", "Collapse")}</span>
</button>
)}
</div>
{isBuiltin && (
<p className="wf-inspector-note wf-inspector-note--info">
Read-only built-in — duplicate the workflow to edit nodes.

View File

@@ -82,6 +82,27 @@ describe("WorkflowNodeEditor mobile CSS contract", () => {
expect(canvasWrapRule).toMatch(/min-height\s*:\s*0\s*;/);
});
it("FN-6058 keeps mobile workflow editor controls from crowding the canvas", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)");
const toolbarRule = findRule(mobileBlocks, /\.wf-editor-toolbar\s*\{[^}]*\}/);
expect(toolbarRule).toMatch(/flex-wrap\s*:\s*nowrap\s*;/);
expect(toolbarRule).toMatch(/overflow-x\s*:\s*auto\s*;/);
const editorStageCanvasRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-canvas\s*\{[^}]*\}/);
expect(editorStageCanvasRule).toMatch(/flex\s*:\s*1 1 auto\s*;/);
expect(editorStageCanvasRule).toMatch(/min-height\s*:\s*0\s*;/);
const inspectorRule = findRule(mobileBlocks, /\.wf-editor-body--editor-stage \.wf-editor-inspector\s*\{[^}]*\}/);
expect(inspectorRule).toMatch(/width\s*:\s*100%\s*;/);
expect(inspectorRule).toMatch(/max-height\s*:\s*45vh\s*;/);
const collapsedToggleRule = findRule([editorCss], /\.wf-inspector-toggle--collapsed\s*\{[^}]*\}/);
expect(collapsedToggleRule).toMatch(/position\s*:\s*absolute\s*;/);
expect(collapsedToggleRule).toMatch(/bottom\s*:\s*var\(--space-sm\)\s*;/);
});
it("FN-6033 keeps workflow editor touch target increases mobile-scoped", () => {
const baseCss = loadAllAppCssBaseOnly();
const editorCss = readComponentCss("WorkflowNodeEditor.css");

View File

@@ -293,7 +293,13 @@ describe("workflow-flow-mapping", () => {
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
const { edges } = edgeRenderableAssertion(builtinDef());
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual(["execute", "merge", "review"]);
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([
"execute",
"merge",
"planning",
"review",
"workflow-step",
]);
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
});
@@ -304,10 +310,12 @@ describe("WorkflowNodeEditor", () => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
});
afterEach(() => {
localStorage.removeItem("fusion:wf-sidebar-settings-collapsed");
localStorage.removeItem("fusion:wf-templates-collapsed");
cleanup();
vi.clearAllMocks();
});
@@ -331,28 +339,6 @@ 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()]);
@@ -366,52 +352,6 @@ 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([
@@ -435,6 +375,50 @@ describe("WorkflowNodeEditor", () => {
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
});
it("collapses and expands the selected node inspector on mobile", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
fireEvent.click(await screen.findByTestId("wf-node-gate"));
const inspector = await screen.findByTestId("wf-node-inspector");
expect(within(inspector).getByLabelText("Prompt")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("wf-inspector-toggle"));
await waitFor(() => expect(screen.queryByTestId("wf-node-inspector")).not.toBeInTheDocument());
expect(screen.queryByLabelText("Prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByTestId("wf-inspector-toggle"));
expect(await screen.findByTestId("wf-node-inspector")).toBeInTheDocument();
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "true");
});
it("auto-expands the mobile inspector when selecting another node", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
fireEvent.click(await screen.findByTestId("wf-node-gate"));
expect(await screen.findByTestId("wf-node-inspector")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("wf-inspector-toggle"));
await waitFor(() => expect(screen.queryByTestId("wf-node-inspector")).not.toBeInTheDocument());
fireEvent.click(await screen.findByTestId("wf-node-merge"));
const inspector = await screen.findByTestId("wf-node-inspector");
expect(within(inspector).getByLabelText("Name")).toBeInTheDocument();
expect(screen.getByTestId("wf-inspector-toggle")).toHaveAttribute("aria-expanded", "true");
});
it("renders nothing when closed", () => {
const { container } = render(<WorkflowNodeEditor isOpen={false} onClose={() => {}} addToast={() => {}} />);
expect(container).toBeEmptyDOMElement();
@@ -1879,6 +1863,23 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
expect(pluginEntry).toHaveTextContent("acme-plugin");
});
it("starts Templates collapsed by default on mobile", async () => {
mockWorkflowEditorViewport("mobile");
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ templates: [stepTpl()] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
const toggle = await screen.findByTestId("wf-templates-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("wf-tpl-step-qa-check")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(await screen.findByTestId("wf-tpl-step-qa-check")).toBeInTheDocument();
});
it("clicking a step-template entry adds a pre-configured node", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({