feat: redesign mobile workflow editor

This commit is contained in:
gsxdsm
2026-06-09 08:30:35 -07:00
parent 9417c81498
commit c1a723134e
12 changed files with 1391 additions and 4 deletions

View File

@@ -0,0 +1,138 @@
.mobile-wf-graph {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm);
overflow-y: auto;
}
.mobile-wf-node-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.mobile-wf-node-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-xs);
padding-left: calc(var(--mobile-wf-depth, 0) * var(--space-md));
}
.mobile-wf-node-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-sm);
min-width: 0;
min-height: var(--wf-editor-touch-target, 44px);
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text);
text-align: left;
cursor: pointer;
}
.mobile-wf-node-row--selected .mobile-wf-node-main {
border-color: var(--accent, var(--ws-info));
box-shadow: var(--focus-ring);
}
.mobile-wf-node-kind {
min-width: 4.5rem;
max-width: 6rem;
padding: 2px var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 0.7rem;
text-align: center;
overflow-wrap: anywhere;
}
.mobile-wf-node-text {
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
}
.mobile-wf-node-title,
.mobile-wf-node-summary,
.mobile-wf-edge-target {
min-width: 0;
overflow-wrap: anywhere;
}
.mobile-wf-node-title {
color: var(--text);
font-size: 0.9rem;
font-weight: 600;
}
.mobile-wf-node-summary {
color: var(--text-muted);
font-size: 0.78rem;
}
.mobile-wf-node-expand {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--wf-editor-touch-target, 44px);
min-height: var(--wf-editor-touch-target, 44px);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text);
cursor: pointer;
}
.mobile-wf-node-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
padding-left: calc((var(--mobile-wf-depth, 0) * var(--space-md)) + var(--space-xs));
}
.mobile-wf-column-chip,
.mobile-wf-edge-chip {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 34px;
max-width: 100%;
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text-muted);
font-size: 0.75rem;
}
.mobile-wf-edge-chip {
color: var(--text);
cursor: pointer;
}
.mobile-wf-edge-chip--selected {
border-color: var(--accent, var(--ws-info));
}
.mobile-wf-edge-target {
color: var(--text-muted);
}
.mobile-wf-node-children {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.mobile-wf-graph-empty {
padding: var(--space-lg);
color: var(--text-muted);
text-align: center;
}

View File

@@ -0,0 +1,138 @@
import { ChevronDown, ChevronRight, GitBranch, Pencil } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { MobileWorkflowNodeSummary } from "./workflow-mobile-graph";
import "./MobileWorkflowGraphView.css";
interface MobileWorkflowGraphViewProps {
rows: MobileWorkflowNodeSummary[];
selectedNodeId?: string | null;
selectedEdgeId?: string | null;
onSelectNode: (id: string) => void;
onSelectEdge: (id: string) => void;
}
function NodeRow({
row,
depth,
selectedNodeId,
selectedEdgeId,
onSelectNode,
onSelectEdge,
}: {
row: MobileWorkflowNodeSummary;
depth: number;
selectedNodeId?: string | null;
selectedEdgeId?: string | null;
onSelectNode: (id: string) => void;
onSelectEdge: (id: string) => void;
}) {
const { t } = useTranslation("app");
const hasChildren = row.children.length > 0;
const [expanded, setExpanded] = useState(depth === 0);
const selected = selectedNodeId === row.id;
return (
<div className="mobile-wf-node-group">
<div
className={`mobile-wf-node-row${selected ? " mobile-wf-node-row--selected" : ""}`}
style={{ ["--mobile-wf-depth" as string]: String(depth) }}
data-testid={`mobile-wf-node-${row.id}`}
>
<button
type="button"
className="mobile-wf-node-main"
onClick={() => onSelectNode(row.id)}
aria-current={selected ? "true" : undefined}
>
<span className="mobile-wf-node-kind">{row.kind}</span>
<span className="mobile-wf-node-text">
<span className="mobile-wf-node-title">{row.label}</span>
{row.summary ? <span className="mobile-wf-node-summary">{row.summary}</span> : null}
</span>
{row.editable ? <Pencil size={14} aria-hidden /> : null}
</button>
{hasChildren ? (
<button
type="button"
className="mobile-wf-node-expand"
aria-expanded={expanded}
aria-label={expanded ? t("common.collapse", "Collapse") : t("common.expand", "Expand")}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : null}
</div>
{(row.columnName || row.outgoing.length > 0) && (
<div
className="mobile-wf-node-meta"
style={{ ["--mobile-wf-depth" as string]: String(depth) }}
>
{row.columnName ? <span className="mobile-wf-column-chip">{row.columnName}</span> : null}
{row.outgoing.map((edge) => (
<button
key={edge.id}
type="button"
className={`mobile-wf-edge-chip${selectedEdgeId === edge.id ? " mobile-wf-edge-chip--selected" : ""}`}
data-testid={`mobile-wf-edge-${edge.id}`}
onClick={() => onSelectEdge(edge.id)}
>
<GitBranch size={12} aria-hidden />
<span>{edge.label}</span>
<span className="mobile-wf-edge-target">{edge.targetLabel}</span>
</button>
))}
</div>
)}
{hasChildren && expanded ? (
<div className="mobile-wf-node-children">
{row.children.map((child) => (
<NodeRow
key={child.id}
row={child}
depth={depth + 1}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={onSelectNode}
onSelectEdge={onSelectEdge}
/>
))}
</div>
) : null}
</div>
);
}
export function MobileWorkflowGraphView({
rows,
selectedNodeId,
selectedEdgeId,
onSelectNode,
onSelectEdge,
}: MobileWorkflowGraphViewProps) {
const { t } = useTranslation("app");
if (rows.length === 0) {
return (
<div className="mobile-wf-graph-empty" data-testid="mobile-wf-graph-empty">
{t("workflowNodes.mobileGraphEmpty", "No graph nodes yet.")}
</div>
);
}
return (
<div className="mobile-wf-graph" data-testid="mobile-wf-graph">
{rows.map((row) => (
<NodeRow
key={row.id}
row={row}
depth={0}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={onSelectNode}
onSelectEdge={onSelectEdge}
/>
))}
</div>
);
}

View File

@@ -545,6 +545,10 @@
position: relative;
}
.wf-mobile-shell {
display: none;
}
.wf-editor-inspector {
display: flex;
flex-direction: column;
@@ -1327,6 +1331,122 @@
overflow: hidden;
}
.wf-editor-body--editor-stage .wf-editor-readonly-banner:not(.wf-mobile-shell),
.wf-editor-body--editor-stage .wf-editor-toolbar:not(.wf-mobile-shell),
.wf-editor-body--editor-stage .wf-templates:not(.wf-mobile-shell),
.wf-editor-body--editor-stage .wf-editor-canvas:not(.wf-mobile-shell) {
display: none;
}
.wf-mobile-shell {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
border-top: 1px solid var(--border);
overflow: hidden;
}
.wf-mobile-tabs {
display: flex;
gap: var(--space-xs);
padding: var(--space-sm);
overflow-x: auto;
border-bottom: 1px solid var(--border);
}
.wf-mobile-tab {
flex: 0 0 auto;
min-height: var(--wf-editor-touch-target);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text);
cursor: pointer;
}
.wf-mobile-tab--active {
border-color: var(--accent, var(--ws-info));
background: var(--bg-tertiary);
}
.wf-mobile-panel {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.wf-mobile-add,
.wf-mobile-actions,
.wf-mobile-destination {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm);
}
.wf-mobile-add-section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.wf-mobile-add-section h3,
.wf-mobile-template-group h4 {
margin: 0;
color: var(--text);
font-size: 0.85rem;
}
.wf-mobile-add-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-xs);
}
.wf-mobile-add-option,
.wf-mobile-template-option {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-xs);
min-width: 0;
min-height: var(--wf-editor-touch-target);
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
color: var(--text);
cursor: pointer;
text-align: left;
overflow-wrap: anywhere;
}
.wf-mobile-template-filter {
width: 100%;
}
.wf-mobile-template-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.wf-mobile-actions .wf-editor-action,
.wf-mobile-actions .wf-editor-delete,
.wf-mobile-actions .wf-editor-save {
justify-content: center;
min-height: var(--wf-editor-touch-target);
}
.wf-mobile-ai-panel {
position: static;
inset: auto;
width: 100%;
box-shadow: none;
}
.wf-editor-canvas .react-flow,
.wf-editor-canvas .react-flow__renderer,
.wf-editor-canvas .react-flow__pane,

View File

@@ -82,8 +82,11 @@ import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
import { buildMobileWorkflowGraph } from "./workflow-mobile-graph";
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
const seam = typeof config?.seam === "string" ? config.seam : "";
@@ -690,6 +693,9 @@ function InnerEditor({
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
const [mobilePanel, setMobilePanel] = useState<MobileWorkflowPanel>(() =>
initialPanel === "settings" ? "settings" : "graph",
);
const { t } = useTranslation("app");
const { confirm } = useConfirm();
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
@@ -932,6 +938,10 @@ function InnerEditor({
if (selectedNodeId) setInspectorCollapsed(false);
}, [selectedNodeId]);
useEffect(() => {
if (initialPanel === "settings") setMobilePanel("settings");
}, [initialPanel]);
// U9/R8: fragment definitions surface from the loaded workflow list (kind ===
// "fragment"); they are excluded from the sidebar workflow list elsewhere.
const fragments = useMemo(
@@ -1324,11 +1334,11 @@ function InnerEditor({
// offset from the canvas origin.
const handleInsertFragment = useCallback(
(fragment: WorkflowDefinition) => {
if (isBuiltin) return;
if (isBuiltin) return false;
const conflicts = fragmentSeamConflicts(fragment.ir, nodes);
if (conflicts.length > 0) {
setTemplateConflict(conflicts.join(", "));
return;
return false;
}
setTemplateConflict(null);
const result = insertFragment(
@@ -1341,6 +1351,7 @@ function InnerEditor({
setNodes(result.nodes);
setEdges(result.edges);
setSelectedNodeId(result.insertedNodeIds[0] ?? null);
return true;
},
[isBuiltin, nodes, edges, setNodes, setEdges],
);
@@ -1931,6 +1942,10 @@ function InnerEditor({
}),
[models, agents, skills],
);
const mobileGraphRows = useMemo(
() => buildMobileWorkflowGraph(nodesForRender, edges, columns, catalogs, t),
[nodesForRender, edges, columns, catalogs, t],
);
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
@@ -2208,7 +2223,7 @@ function InnerEditor({
workflow is active (read-only gating preserved via isBuiltin). The
disclosure button serves as the section header; the panels' own
internal <h3> is suppressed via CSS to avoid a double header. */}
{activeWorkflow && (
{activeWorkflow && !isMobileViewport && (
<div className="wf-sidebar-panels">
<section className="wf-sidebar-section" data-testid="wf-sidebar-columns-section">
<button
@@ -2378,6 +2393,282 @@ function InnerEditor({
</button>
)}
</div>
{isMobileViewport && (
<div className="wf-mobile-shell" data-testid="wf-mobile-shell">
<nav className="wf-mobile-tabs" aria-label={t("workflows.mobileEditorNav", "Workflow editor sections")}>
{([
["graph", t("workflowNodes.mobileGraph", "Graph")],
["add", t("workflowNodes.mobileAdd", "Add")],
["settings", t("workflowSettings.title", "Settings")],
["fields", t("workflowFields.title", "Fields")],
["columns", t("workflowColumns.title", "Columns")],
["actions", t("workflowNodes.mobileActions", "Actions")],
] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => (
<button
key={panel}
type="button"
className={`wf-mobile-tab${mobilePanel === panel ? " wf-mobile-tab--active" : ""}`}
aria-current={mobilePanel === panel ? "page" : undefined}
data-testid={`wf-mobile-tab-${panel}`}
onClick={() => setMobilePanel(panel)}
>
{label}
</button>
))}
</nav>
<div className="wf-mobile-panel" data-testid={`wf-mobile-panel-${mobilePanel}`}>
{mobilePanel === "graph" && (
<MobileWorkflowGraphView
rows={mobileGraphRows}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={(id) => {
setSelectedNodeId(id);
setSelectedEdgeId(null);
}}
onSelectEdge={(id) => {
setSelectedEdgeId(id);
setSelectedNodeId(null);
}}
/>
)}
{mobilePanel === "add" && (
<div className="wf-mobile-add">
{isBuiltin ? (
<p className="wf-inspector-note wf-inspector-note--info">
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
</p>
) : (
<>
<section className="wf-mobile-add-section">
<h3>{t("workflowNodes.mobileNodeKinds", "Node types")}</h3>
<div className="wf-mobile-add-grid">
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
<button
key={label}
type="button"
className="wf-mobile-add-option"
data-testid={`wf-mobile-add-${kind}-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
onClick={() => {
addNode(kind, label, presetConfig);
setMobilePanel("graph");
}}
>
<Icon size={16} aria-hidden />
<span>{label}</span>
</button>
))}
</div>
</section>
{hasAnyTemplate && (
<section className="wf-mobile-add-section">
<h3>{t("workflowNodes.templatesSection", "Templates")}</h3>
{templateTotalCount > 8 && (
<input
type="text"
className="wf-templates-filter wf-mobile-template-filter"
data-testid="wf-mobile-template-filter"
value={templateFilter}
onChange={(e) => setTemplateFilter(e.target.value)}
placeholder={t("workflowNodes.templateFilterPlaceholder", "Filter templates")}
aria-label={t("workflowNodes.templateFilterLabel", "Filter templates")}
/>
)}
{templateConflict && (
<div className="wf-templates-conflict" role="alert" data-testid="wf-mobile-tpl-conflict">
{t(
"workflowNodes.templateSeamConflict",
'This fragment duplicates the "{{seam}}" seam already on the canvas, so it can\'t be inserted.',
{ seam: templateConflict },
)}
</div>
)}
{templateGroups.fragmentEntries.length > 0 && (
<div className="wf-mobile-template-group">
<h4>{t("workflowNodes.templatesFragments", "Fragments")}</h4>
{templateGroups.fragmentEntries.map((f) => (
<button
key={f.id}
type="button"
className="wf-mobile-template-option"
data-testid={`wf-mobile-tpl-fragment-${f.id}`}
onClick={() => {
if (handleInsertFragment(f)) setMobilePanel("graph");
}}
>
{f.name}
</button>
))}
</div>
)}
{templateGroups.stepEntries.length > 0 && (
<div className="wf-mobile-template-group">
<h4>{t("workflowNodes.templatesBuiltinSteps", "Built-in steps")}</h4>
{templateGroups.stepEntries.map((s) => (
<button
key={s.id}
type="button"
className="wf-mobile-template-option"
data-testid={`wf-mobile-tpl-step-${s.id}`}
onClick={() => {
handleInsertStepTemplate(s);
setMobilePanel("graph");
}}
>
{s.name}
</button>
))}
</div>
)}
{templateGroups.pluginEntries.length > 0 && (
<div className="wf-mobile-template-group">
<h4>{t("workflowNodes.templatesPluginSteps", "Plugin steps")}</h4>
{templateGroups.pluginEntries.map(({ pluginId, template }) => (
<button
key={`${pluginId}:${template.id}`}
type="button"
className="wf-mobile-template-option"
data-testid={`wf-mobile-tpl-plugin-${template.id}`}
onClick={() => {
handleInsertStepTemplate(template);
setMobilePanel("graph");
}}
>
<span>{template.name}</span>
<span className="wf-templates-badge">{pluginId}</span>
</button>
))}
</div>
)}
</section>
)}
</>
)}
</div>
)}
{mobilePanel === "settings" && activeWorkflow && (
<div ref={settingsPanelRef} className="wf-mobile-destination">
<WorkflowSettingsPanel
workflowId={activeWorkflow.id}
settings={settings}
onChange={setSettings}
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
initialTab="values"
/>
</div>
)}
{mobilePanel === "fields" && (
<div className="wf-mobile-destination">
<WorkflowFieldsPanel
fields={fields}
onChange={setFields}
readOnly={isBuiltin}
addToast={addToast}
/>
</div>
)}
{mobilePanel === "columns" && (
<div className="wf-mobile-destination">
<WorkflowColumnPanel
columns={columns}
onChange={setColumns}
violations={columnViolations}
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
columnAgentsEnabled={columnAgentsEnabled}
/>
</div>
)}
{mobilePanel === "actions" && (
<div className="wf-mobile-actions">
{isBuiltin ? (
<>
<p className="wf-inspector-note wf-inspector-note--info">
{t("workflows.readOnlyBuiltin", "Read-only built-in workflow")}
</p>
<button className="wf-editor-action" data-testid="wf-mobile-export" onClick={handleExport}>
<Download size={15} /> {t("workflows.export", "Export")}
</button>
<button className="wf-editor-save wf-editor-duplicate-primary" data-testid="wf-mobile-duplicate" onClick={handleDuplicate}>
<Plus size={15} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
</button>
</>
) : (
<>
<button className="wf-editor-save" data-testid="wf-mobile-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={15} className="wf-spin" /> : <Save size={15} />}
{t("common.save", "Save")}
</button>
<button className="wf-editor-action" data-testid="wf-mobile-ai-edit" onClick={() => setAiPanelOpen((o) => !o)}>
<Sparkles size={15} /> {t("workflows.aiEdit", "Design with AI")}
</button>
{aiPanelOpen && (
<div className="wf-ai-panel wf-mobile-ai-panel" data-testid="wf-mobile-ai-panel" role="dialog" aria-busy={aiEditBusy}>
<textarea
className="wf-ai-prompt"
data-testid="wf-mobile-ai-edit-prompt"
rows={4}
value={aiEditPrompt}
disabled={aiEditBusy}
placeholder={t(
"workflows.aiPromptPlaceholder",
"e.g. Run lint and tests before merge, then post a changelog comment after merge",
)}
onChange={(e) => {
setAiEditPrompt(e.target.value);
if (aiEditError) setAiEditError(null);
}}
/>
{aiEditError && (
<p className="wf-create-error" role="alert" data-testid="wf-mobile-ai-edit-error">
{aiEditError}
</p>
)}
<div className="wf-ai-actions">
<button
type="button"
className="btn btn-primary wf-ai-submit"
data-testid="wf-mobile-ai-edit-submit"
disabled={aiEditBusy}
onClick={() => void handleAiEditSubmit()}
>
{aiEditBusy ? <Loader2 size={13} className="wf-spin" /> : <Sparkles size={13} />}
{t("workflows.aiSubmit", "Design with AI")}
</button>
{aiEditBusy && (
<button type="button" className="btn wf-ai-cancel" onClick={handleAiEditCancel}>
{t("common.cancel", "Cancel")}
</button>
)}
</div>
</div>
)}
<button className="wf-editor-action" data-testid="wf-mobile-auto-layout" onClick={handleAutoLayout}>
<LayoutGrid size={15} /> {t("workflowNodes.autoLayout", "Auto-layout")}
</button>
<button className="wf-editor-action" data-testid="wf-mobile-export" onClick={handleExport} disabled={isDirty}>
<Download size={15} /> {t("workflows.export", "Export")}
</button>
<button className="wf-editor-delete" data-testid="wf-mobile-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={15} /> {t("common.delete", "Delete")}
</button>
</>
)}
</div>
)}
</div>
</div>
)}
{isBuiltin ? (
// Read-only built-in: a banner *replaces* the save/edit toolbar
// (not an overlay); the canvas below stays inspectable.

View File

@@ -76,6 +76,11 @@
width: 100%;
}
.workflow-selector select,
.workflow-selector-manage {
width: 100%;
}
.workflow-selector-manage {
text-align: center;
}

View File

@@ -0,0 +1,75 @@
import { render, screen, fireEvent, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MobileWorkflowGraphView } from "../MobileWorkflowGraphView";
import type { MobileWorkflowNodeSummary } from "../workflow-mobile-graph";
const rows: MobileWorkflowNodeSummary[] = [
{
id: "start",
label: "Start",
kind: "start",
summary: "",
editable: false,
outgoing: [{ id: "e1", source: "start", target: "prompt", targetLabel: "Prompt", label: "success" }],
children: [],
},
{
id: "loop",
label: "Review loop",
kind: "loop",
summary: "3x",
editable: true,
outgoing: [],
children: [
{
id: "loop::child",
label: "Loop step",
kind: "prompt",
summary: "Not configured",
editable: true,
parentId: "loop",
templateLocalId: "child",
outgoing: [],
children: [],
},
],
},
];
describe("MobileWorkflowGraphView", () => {
it("renders graph rows and selects nodes and edges", () => {
const onSelectNode = vi.fn();
const onSelectEdge = vi.fn();
render(
<MobileWorkflowGraphView
rows={rows}
selectedNodeId={null}
selectedEdgeId={null}
onSelectNode={onSelectNode}
onSelectEdge={onSelectEdge}
/>,
);
fireEvent.click(within(screen.getByTestId("mobile-wf-node-start")).getByRole("button", { name: /start/i }));
expect(onSelectNode).toHaveBeenCalledWith("start");
fireEvent.click(screen.getByTestId("mobile-wf-edge-e1"));
expect(onSelectEdge).toHaveBeenCalledWith("e1");
});
it("expands grouped template children", () => {
render(
<MobileWorkflowGraphView
rows={rows}
selectedNodeId="loop"
selectedEdgeId={null}
onSelectNode={() => {}}
onSelectEdge={() => {}}
/>,
);
expect(screen.getByTestId("mobile-wf-node-loop::child")).toBeInTheDocument();
fireEvent.click(within(screen.getByTestId("mobile-wf-node-loop")).getByRole("button", { name: /collapse/i }));
expect(screen.queryByTestId("mobile-wf-node-loop::child")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
import { buildMobileWorkflowGraph } from "../workflow-mobile-graph";
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
import { columnBandNodeId, foreachChildFlowId } from "../workflow-flow-mapping";
function node(
id: string,
kind: WorkflowFlowNodeData["kind"],
x: number,
y: number,
extra: Partial<FlowNode<WorkflowFlowNodeData>> = {},
): FlowNode<WorkflowFlowNodeData> {
return {
id,
type: kind,
position: { x, y },
data: { kind, label: id, ...(extra.data ?? {}) },
...extra,
};
}
function edge(id: string, source: string, target: string, condition = "success"): FlowEdge {
return {
id,
source,
target,
label: condition,
data: { condition },
};
}
describe("buildMobileWorkflowGraph", () => {
it("returns ordered linear rows with outgoing edge destinations", () => {
const rows = buildMobileWorkflowGraph(
[
node("end", "end", 300, 0),
node("start", "start", 0, 0),
node("lint", "gate", 150, 0, { data: { kind: "gate", label: "Lint", config: { gateMode: "gate" } } }),
],
[edge("e1", "start", "lint"), edge("e2", "lint", "end")],
);
expect(rows.map((row) => row.id)).toEqual(["start", "lint", "end"]);
expect(rows[0].outgoing[0]).toMatchObject({ target: "lint", targetLabel: "Lint" });
expect(rows[1].summary).toBe("Gate (blocks)");
});
it("preserves branch edges and column labels while ignoring column band nodes", () => {
const rows = buildMobileWorkflowGraph(
[
node(columnBandNodeId("todo"), "start", -40, 0, {
type: "group",
data: { kind: "start", label: "Todo", column: "todo" },
}),
node("split", "split", 0, 0, { data: { kind: "split", label: "Split", column: "todo" } }),
node("a", "prompt", 160, 20, { data: { kind: "prompt", label: "A", column: "todo" } }),
node("b", "script", 160, 90, { data: { kind: "script", label: "B", column: "todo" } }),
],
[edge("e1", "split", "a", "success"), edge("e2", "split", "b", "failure")],
[{ id: "todo", name: "Todo", traits: [] }],
);
expect(rows.map((row) => row.id)).toEqual(["split", "a", "b"]);
expect(rows[0].columnName).toBe("Todo");
expect(rows[0].outgoing.map((out) => [out.label, out.targetLabel])).toEqual([
["success", "A"],
["failure", "B"],
]);
});
it("nests foreach template children without exposing local ids as top-level rows", () => {
const childId = foreachChildFlowId("each", "step");
const rows = buildMobileWorkflowGraph(
[
node("each", "foreach", 0, 0, { data: { kind: "foreach", label: "Each step", config: { mode: "parallel" } } }),
node(childId, "prompt", 20, 60, {
parentId: "each",
data: { kind: "prompt", label: "Run step", config: { seam: "step-execute" } },
}),
],
[edge("e-child", childId, childId, "outcome:revise")],
);
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe("each");
expect(rows[0].children).toHaveLength(1);
expect(rows[0].children[0]).toMatchObject({
id: childId,
templateLocalId: "step",
label: "Run step",
});
});
});

View File

@@ -0,0 +1,112 @@
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
import type { WorkflowIrColumn } from "@fusion/core";
import type { WorkflowFlowNodeData } from "./nodes/WorkflowNodeTypes";
import {
columnIdFromBandNode,
isColumnBandNode,
templateNodeIdFromChild,
} from "./workflow-flow-mapping";
import { nodeConfigSummary, type NodeSummaryCatalogs, type SummaryTranslate } from "./nodes/node-summary";
export interface MobileWorkflowEdgeSummary {
id: string;
source: string;
target: string;
targetLabel: string;
label: string;
kind?: string;
}
export interface MobileWorkflowNodeSummary {
id: string;
label: string;
kind: WorkflowFlowNodeData["kind"];
summary: string;
columnName?: string;
editable: boolean;
parentId?: string;
templateLocalId?: string;
outgoing: MobileWorkflowEdgeSummary[];
children: MobileWorkflowNodeSummary[];
}
function edgeLabel(edge: FlowEdge): string {
if (typeof edge.label === "string" && edge.label.trim()) return edge.label;
return String(edge.data?.condition ?? "success");
}
function nodeDisplayLabel(node: FlowNode<WorkflowFlowNodeData>): string {
return node.data.label || node.id;
}
function nodeSortValue(node: FlowNode<WorkflowFlowNodeData>): number {
return Math.round(node.position.y) * 100000 + Math.round(node.position.x);
}
function buildColumnNameMap(columns: WorkflowIrColumn[], nodes: FlowNode<WorkflowFlowNodeData>[]) {
const names = new Map(columns.map((column) => [column.id, column.name || column.id]));
for (const node of nodes) {
if (!isColumnBandNode(node.id)) continue;
const id = columnIdFromBandNode(node.id);
if (!names.has(id)) names.set(id, node.data.label || id);
}
return names;
}
export function buildMobileWorkflowGraph(
nodes: FlowNode<WorkflowFlowNodeData>[],
edges: FlowEdge[],
columns: WorkflowIrColumn[] = [],
catalogs: NodeSummaryCatalogs = {},
t?: SummaryTranslate,
): MobileWorkflowNodeSummary[] {
const columnNames = buildColumnNameMap(columns, nodes);
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const childNodesByParent = new Map<string, FlowNode<WorkflowFlowNodeData>[]>();
for (const node of nodes) {
if (!node.parentId) continue;
const list = childNodesByParent.get(node.parentId) ?? [];
list.push(node);
childNodesByParent.set(node.parentId, list);
}
for (const list of childNodesByParent.values()) {
list.sort((a, b) => nodeSortValue(a) - nodeSortValue(b));
}
const summarizeEdge = (edge: FlowEdge): MobileWorkflowEdgeSummary => {
const target = nodesById.get(edge.target);
return {
id: edge.id,
source: edge.source,
target: edge.target,
targetLabel: target ? nodeDisplayLabel(target) : edge.target,
label: edgeLabel(edge),
kind: typeof edge.data?.kind === "string" ? edge.data.kind : undefined,
};
};
const summarizeNode = (node: FlowNode<WorkflowFlowNodeData>): MobileWorkflowNodeSummary => {
const children = (childNodesByParent.get(node.id) ?? []).map(summarizeNode);
const columnId = node.data.column;
return {
id: node.id,
label: nodeDisplayLabel(node),
kind: node.data.kind,
summary: nodeConfigSummary(node.data, catalogs, t),
columnName: columnId ? columnNames.get(columnId) ?? columnId : undefined,
editable: node.data.kind !== "start" && node.data.kind !== "end" && !isColumnBandNode(node.id),
parentId: node.parentId,
templateLocalId: node.parentId ? templateNodeIdFromChild(node.parentId, node.id) : undefined,
outgoing: edges.filter((edge) => edge.source === node.id).map(summarizeEdge),
children,
};
};
const topLevelNodes = nodes
.filter((node) => !node.parentId && !isColumnBandNode(node.id))
.sort((a, b) => nodeSortValue(a) - nodeSortValue(b));
return topLevelNodes.map(summarizeNode);
}