feat: simplified workflow editor view with Simple/Advanced/List modes

Adds a simplified graphical node editor as the workflow editor's default
view: a modern vertical auto-laid-out React Flow canvas with insert-on-edge
"+" affordances and a searchable, categorized add-step dialog (node kinds +
fragments + step templates). A segmented Simple/Advanced/List switch
(persisted in localStorage) keeps the full advanced canvas untouched and
retains the old compact row editor as the List fallback. Mobile's graph tab
gains the touch-friendly simplified canvas with the row list as fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-11 00:31:28 -07:00
parent 28ad1fd5f3
commit bcbd97cc1c
19 changed files with 2743 additions and 149 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a simplified workflow editor view with a modern vertical canvas, plus Simple/Advanced/List mode toggle.
category: feature
dev: New WorkflowSimpleCanvas + WorkflowAddStepModal components; view mode persists in localStorage (`fusion:wf-editor-view-mode`, mobile `fusion:wf-mobile-graph-style`); insert-on-edge helpers live in workflow-simple-layout.ts. The old "Show simple editor" compact layout is now the List mode; the advanced canvas is unchanged.

View File

@@ -130,7 +130,7 @@ describe("component CSS hygiene scan regressions", () => {
".plugin-registry-retry:focus-visible",
],
"WorkflowNodeEditor.css": [
".wf-layout-toggle:focus-visible",
".wf-view-mode-option:focus-visible",
".wf-template-option:focus-visible",
".wf-ai-prompt:focus-visible",
".wf-mobile-tab:focus-visible",

View File

@@ -0,0 +1,228 @@
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
Add-step dialog for the simplified workflow view. Centered card on desktop,
full-width bottom sheet on mobile (mirrors the app's mobile modal pattern) so
the same dialog serves both breakpoints.
*/
.wf-add-step-overlay {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg) 62%, transparent);
backdrop-filter: blur(2px);
}
.wf-add-step-dialog {
display: flex;
flex-direction: column;
width: min(680px, calc(100vw - 32px));
max-height: min(72vh, 640px);
border-radius: var(--radius-xl);
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.wf-add-step-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 10px;
}
.wf-add-step-header h3 {
margin: 0;
font-size: 0.95rem;
color: var(--text);
}
.wf-add-step-search {
display: flex;
align-items: center;
gap: 8px;
margin: 0 16px 12px;
padding: 8px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-muted);
}
.wf-add-step-search:focus-within {
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
.wf-add-step-search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text);
font-size: 0.85rem;
}
.wf-add-step-conflict {
margin: 0 16px 10px;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--color-error) 45%, var(--border));
background: var(--status-error-bg);
color: var(--color-error);
font-size: 0.75rem;
}
.wf-add-step-body {
flex: 1;
overflow-y: auto;
padding: 0 16px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.wf-add-step-section h4 {
margin: 0 0 8px;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.wf-add-step-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
}
.wf-add-step-option {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px;
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
background: var(--surface-subtle);
color: var(--text);
text-align: left;
cursor: pointer;
transition:
background var(--transition-fast),
border-color var(--transition-fast);
}
.wf-add-step-option:hover {
background: var(--surface-hover);
border-color: var(--border-strong);
}
.wf-add-step-option:focus-visible {
outline: none;
border-color: var(--todo);
box-shadow: var(--focus-ring);
}
.wf-add-step-option-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-md);
color: #fff;
background: var(--surface-emphasis);
}
.wf-add-step-option--agent .wf-add-step-option-chip {
background: color-mix(in srgb, var(--ws-quality) 85%, var(--bg));
}
.wf-add-step-option--automation .wf-add-step-option-chip {
background: color-mix(in srgb, var(--ws-teal) 85%, var(--bg));
}
.wf-add-step-option--flow .wf-add-step-option-chip {
background: color-mix(in srgb, var(--color-merged) 85%, var(--bg));
}
.wf-add-step-option--template .wf-add-step-option-chip {
background: color-mix(in srgb, var(--triage) 80%, var(--bg));
}
.wf-add-step-option-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.wf-add-step-option-label {
font-size: 0.82rem;
font-weight: 600;
}
.wf-add-step-option-desc {
font-size: 0.7rem;
color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.wf-add-step-template-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.wf-add-step-optional {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 40px;
padding: 3px 9px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-subtle);
background: transparent;
color: var(--text-muted);
font-size: 0.68rem;
cursor: pointer;
}
.wf-add-step-optional:hover {
border-color: var(--border-strong);
color: var(--text);
}
.wf-add-step-empty {
margin: 6px 2px;
font-size: 0.8rem;
color: var(--text-muted);
}
@media (max-width: 768px), (max-height: 480px) {
.wf-add-step-overlay {
align-items: flex-end;
}
.wf-add-step-dialog {
width: 100vw;
max-height: 82dvh;
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
border-bottom: none;
}
.wf-add-step-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,298 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Search, Puzzle, ToggleRight } from "lucide-react";
import type { WorkflowDefinition, WorkflowStepTemplate } from "@fusion/core";
import type { WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { nodeHelpFor } from "./nodes/node-help";
import "./WorkflowAddStepModal.css";
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
The simplified workflow view's add-step surface. Requirements:
- One searchable dialog covering EVERYTHING the advanced palette + templates
toolbar offers (node kinds, fragments, Fusion step templates, plugin
templates) so the simple view loses no add capability.
- Node kinds are grouped into human categories (Agent steps / Automation /
Flow control) with one-line descriptions sourced from the node help
registry, because the flat 16-button advanced palette is the main thing
users found hard to use.
- When inserting INSIDE a container (foreach/loop/optional-group child
edge), container kinds are hidden — containers cannot nest.
*/
export interface AddStepPaletteEntry {
kind: WorkflowEditorNodeKind;
label: string;
icon: React.ComponentType<{ size?: number | string; "aria-hidden"?: boolean | "true" | "false" }>;
presetConfig?: Record<string, unknown>;
}
const CONTAINER_KINDS: ReadonlySet<string> = new Set(["foreach", "loop", "optional-group"]);
const AGENT_KINDS: ReadonlySet<string> = new Set(["prompt", "ask-user", "gate", "step-review"]);
const AUTOMATION_KINDS: ReadonlySet<string> = new Set(["script", "code", "notify", "parse-steps"]);
export interface WorkflowAddStepModalProps {
open: boolean;
onClose: () => void;
palette: AddStepPaletteEntry[];
/** Hide container kinds (insert target is inside a container). */
disallowContainers?: boolean;
fragments: WorkflowDefinition[];
stepTemplates: WorkflowStepTemplate[];
pluginTemplates: Array<{ pluginId: string; template: WorkflowStepTemplate }>;
/** Persistent seam-duplication conflict notice (mirrors the toolbar's). */
templateConflict?: string | null;
onPickPalette: (entry: AddStepPaletteEntry) => void;
onPickFragment: (fragment: WorkflowDefinition) => void;
onPickStepTemplate: (template: WorkflowStepTemplate) => void;
onPickStepTemplateAsOptionalGroup: (template: WorkflowStepTemplate) => void;
}
export function WorkflowAddStepModal({
open,
onClose,
palette,
disallowContainers = false,
fragments,
stepTemplates,
pluginTemplates,
templateConflict,
onPickPalette,
onPickFragment,
onPickStepTemplate,
onPickStepTemplateAsOptionalGroup,
}: WorkflowAddStepModalProps) {
const { t } = useTranslation("app");
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setQuery("");
// Focus after the dialog paints.
const id = window.setTimeout(() => searchRef.current?.focus(), 0);
return () => window.clearTimeout(id);
}
}, [open]);
const q = query.trim().toLowerCase();
const categories = useMemo(() => {
const eligible = palette.filter((entry) => !(disallowContainers && CONTAINER_KINDS.has(entry.kind)));
const withHelp = eligible.map((entry) => ({
entry,
description: nodeHelpFor(entry.kind)?.summary ?? "",
}));
const matches = (item: { entry: AddStepPaletteEntry; description: string }) =>
!q || item.entry.label.toLowerCase().includes(q) || item.entry.kind.includes(q) || item.description.toLowerCase().includes(q);
return [
{
id: "agent",
label: t("workflowNodes.addStepAgentSteps", "Agent steps"),
items: withHelp.filter((item) => AGENT_KINDS.has(item.entry.kind)).filter(matches),
},
{
id: "automation",
label: t("workflowNodes.addStepAutomation", "Automation"),
items: withHelp.filter((item) => AUTOMATION_KINDS.has(item.entry.kind)).filter(matches),
},
{
id: "flow",
label: t("workflowNodes.addStepFlowControl", "Flow control"),
items: withHelp
.filter((item) => !AGENT_KINDS.has(item.entry.kind) && !AUTOMATION_KINDS.has(item.entry.kind))
.filter(matches),
},
].filter((category) => category.items.length > 0);
}, [palette, disallowContainers, q, t]);
const filteredFragments = useMemo(
() => fragments.filter((f) => !q || f.name.toLowerCase().includes(q)),
[fragments, q],
);
const filteredStepTemplates = useMemo(
() => stepTemplates.filter((s) => !q || s.name.toLowerCase().includes(q)),
[stepTemplates, q],
);
const filteredPluginTemplates = useMemo(
() => pluginTemplates.filter((p) => !q || p.template.name.toLowerCase().includes(q)),
[pluginTemplates, q],
);
const hasTemplates =
filteredFragments.length > 0 || filteredStepTemplates.length > 0 || filteredPluginTemplates.length > 0;
const hasAnyResult = categories.length > 0 || hasTemplates;
if (!open) return null;
return (
<div
className="wf-add-step-overlay"
data-testid="wf-add-step-modal"
role="presentation"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
}}
>
<div
className="wf-add-step-dialog"
role="dialog"
aria-modal="true"
aria-label={t("workflowNodes.addStepTitle", "Add a step")}
onClick={(e) => e.stopPropagation()}
>
<header className="wf-add-step-header">
<h3>{t("workflowNodes.addStepTitle", "Add a step")}</h3>
<button
type="button"
className="btn-icon wf-add-step-close"
aria-label={t("common.close", "Close")}
onClick={onClose}
>
<X size={16} />
</button>
</header>
<div className="wf-add-step-search">
<Search size={14} aria-hidden />
<input
ref={searchRef}
type="text"
className="wf-add-step-search-input"
data-testid="wf-add-step-search"
value={query}
placeholder={t("workflowNodes.addStepSearchPlaceholder", "Search steps and templates…")}
aria-label={t("workflowNodes.addStepSearchLabel", "Search steps")}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{templateConflict && (
<div className="wf-add-step-conflict" role="alert" data-testid="wf-add-step-conflict">
{t(
"workflowNodes.templateSeamConflict",
'This fragment duplicates the "{{seam}}" seam already on the canvas, so it can\'t be inserted.',
{ seam: templateConflict },
)}
</div>
)}
<div className="wf-add-step-body">
{categories.map((category) => (
<section key={category.id} className="wf-add-step-section">
<h4>{category.label}</h4>
<div className="wf-add-step-grid">
{category.items.map(({ entry, description }) => {
const Icon = entry.icon;
return (
<button
key={entry.label}
type="button"
className={`wf-add-step-option wf-add-step-option--${category.id}`}
data-testid={`wf-add-step-${entry.kind}-${entry.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
onClick={() => onPickPalette(entry)}
>
<span className="wf-add-step-option-chip" aria-hidden>
<Icon size={16} />
</span>
<span className="wf-add-step-option-text">
<span className="wf-add-step-option-label">{entry.label}</span>
{description ? <span className="wf-add-step-option-desc">{description}</span> : null}
</span>
</button>
);
})}
</div>
</section>
))}
{hasTemplates && (
<section className="wf-add-step-section">
<h4>{t("workflowNodes.templatesSection", "Templates")}</h4>
<div className="wf-add-step-grid">
{filteredFragments.map((fragment) => (
<button
key={fragment.id}
type="button"
className="wf-add-step-option wf-add-step-option--template"
data-testid={`wf-add-step-fragment-${fragment.id}`}
onClick={() => onPickFragment(fragment)}
>
<span className="wf-add-step-option-chip" aria-hidden>
<Puzzle size={16} />
</span>
<span className="wf-add-step-option-text">
<span className="wf-add-step-option-label">{fragment.name}</span>
<span className="wf-add-step-option-desc">
{t("workflowNodes.addStepFragmentDesc", "Workflow fragment")}
</span>
</span>
</button>
))}
{filteredStepTemplates.map((template) => (
<div key={template.id} className="wf-add-step-template-row">
<button
type="button"
className="wf-add-step-option wf-add-step-option--template"
data-testid={`wf-add-step-tpl-${template.id}`}
onClick={() => onPickStepTemplate(template)}
>
<span className="wf-add-step-option-chip" aria-hidden>
<Puzzle size={16} />
</span>
<span className="wf-add-step-option-text">
<span className="wf-add-step-option-label">{template.name}</span>
{template.description ? (
<span className="wf-add-step-option-desc">{template.description}</span>
) : null}
</span>
</button>
{!disallowContainers && (
<button
type="button"
className="wf-add-step-optional"
data-testid={`wf-add-step-tpl-${template.id}-optional-group`}
title={t("workflowNodes.insertTemplateAsOptionalGroup", "Insert {{name}} as optional group", {
name: template.name,
})}
aria-label={t("workflowNodes.insertTemplateAsOptionalGroup", "Insert {{name}} as optional group", {
name: template.name,
})}
onClick={() => onPickStepTemplateAsOptionalGroup(template)}
>
<ToggleRight size={13} aria-hidden />
<span>{t("workflowNodes.asOptionalGroup", "as optional group")}</span>
</button>
)}
</div>
))}
{filteredPluginTemplates.map(({ pluginId, template }) => (
<button
key={`${pluginId}:${template.id}`}
type="button"
className="wf-add-step-option wf-add-step-option--template"
data-testid={`wf-add-step-plugin-tpl-${template.id}`}
onClick={() => onPickStepTemplate(template)}
>
<span className="wf-add-step-option-chip" aria-hidden>
<Puzzle size={16} />
</span>
<span className="wf-add-step-option-text">
<span className="wf-add-step-option-label">{template.name}</span>
<span className="wf-add-step-option-desc">{pluginId}</span>
</span>
</button>
))}
</div>
</section>
)}
{!hasAnyResult && (
<p className="wf-add-step-empty" data-testid="wf-add-step-empty">
{t("workflowNodes.addStepNoMatches", "No steps match “{{query}}”.", { query })}
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -797,30 +797,110 @@ Flow's SVG attributes and makes the graph preview read blank.
display: none;
}
.wf-layout-toggle {
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
Segmented three-mode view switch (Simple / Advanced / List) replacing the old
boolean .wf-layout-toggle. Also reused by the mobile graph-style toggle
(Graph / List) so both switches read as one control family.
*/
.wf-view-mode-toggle,
.wf-mobile-graph-style-toggle {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background: var(--bg-secondary);
}
.wf-mobile-graph-style-toggle {
margin: var(--space-sm) var(--space-md) 0;
}
.wf-view-mode-option {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
width: fit-content;
min-height: 30px;
padding: var(--space-xs) var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
min-height: 26px;
padding: var(--space-xs) 10px;
border: none;
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 0.8rem;
font-size: 0.78rem;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
}
.wf-layout-toggle:hover {
background: var(--bg-tertiary);
.wf-view-mode-option:hover {
color: var(--text);
}
.wf-layout-toggle:focus-visible {
.wf-view-mode-option--active {
background: var(--surface-emphasis);
color: var(--text);
box-shadow: var(--shadow-sm);
}
.wf-view-mode-option:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
outline-offset: 1px;
}
/* Simplified-view toolbar: a single quiet row (Add step + common actions). */
.wf-editor-toolbar--simple {
justify-content: space-between;
}
.wf-editor-toolbar--simple .wf-simple-toolbar-add {
border-color: color-mix(in srgb, var(--todo) 45%, var(--border));
color: var(--text);
}
.wf-editor-toolbar--simple .wf-simple-toolbar-add:hover {
border-color: var(--todo);
background: color-mix(in srgb, var(--todo) 12%, var(--surface));
}
/* Mobile graph tab: the simplified canvas needs a bounded, non-scrolling
region inside the scrollable mobile panel (WorkflowSimpleCanvas fills it
with position:absolute). */
.wf-mobile-simple-canvas {
position: relative;
flex: 1 1 auto;
min-height: max(340px, 55dvh);
margin: var(--space-sm) var(--space-md) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
Simple-view inspector polish: the shared node/edge inspector gets a roomier,
card-like presentation in the simplified view (wider pane, soft section
cards) without touching its advanced-view layout or any field markup.
*/
.wf-editor-inspector--simple {
width: 320px;
padding: var(--space-lg);
gap: var(--space-md);
background: var(--surface);
}
.wf-editor-inspector--simple h3 {
font-size: 1rem;
}
.wf-editor-inspector--simple .wf-inspector-fields {
padding: var(--space-md);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface-subtle);
}
.wf-editor-body--simple-layout :where(.wf-editor-readonly-banner),

View File

@@ -91,6 +91,9 @@ import {
FOREACH_CHILD_Y,
} from "./workflow-flow-mapping";
import { autoLayout, applyAutoLayout } from "./workflow-auto-layout";
import { insertNodeOnEdge, findAppendEdgeId } from "./workflow-simple-layout";
import { WorkflowSimpleCanvas } from "./WorkflowSimpleCanvas";
import { WorkflowAddStepModal, type AddStepPaletteEntry } from "./WorkflowAddStepModal";
import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
@@ -107,6 +110,27 @@ import {
} from "./workflow-mobile-graph";
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
The desktop workflow editor now has THREE view modes over the same node/edge
state, replacing the old boolean simple-editor toggle:
- "simple" — the simplified graphical node editor (default): vertical
auto-laid-out canvas, "+" insert-on-edge, add-step dialog, no palette
toolbar or column bands. Cleaner for the common tasks (adding and
configuring nodes).
- "advanced" — the full canvas: palette toolbar, templates, drag/connect,
column swimlanes, minimap, auto-layout, import/export. Nothing removed.
- "list" — the pre-existing row-list simple editor, kept as an even
simpler fallback; it reuses the mobile shell exactly as before.
The choice persists per-browser in localStorage. Mobile keeps its tab shell
(forced by viewport) and gets its own graph-style toggle: the simplified
canvas by default with the row list as fallback.
*/
type WorkflowEditorViewMode = "simple" | "advanced" | "list";
const viewModeStorageKey = "fusion:wf-editor-view-mode";
type MobileGraphStyle = "canvas" | "list";
const mobileGraphStyleStorageKey = "fusion:wf-mobile-graph-style";
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: dropped the "optional-steps" mobile
// panel — the declaration authoring surface is retired (optional-group nodes now).
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
@@ -802,11 +826,49 @@ function InnerEditor({
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
const [miniMapCollapsed, setMiniMapCollapsed] = useState(false);
const [compactLayoutEnabled, setCompactLayoutEnabled] = useState(false);
// FNXC:WorkflowSimpleView 2026-07-10-12:00: three-mode desktop view state
// (see the WorkflowEditorViewMode note above). Persisted so operators land
// back in the mode they work in; "simple" is the default for everyone else.
const [viewMode, setViewMode] = useState<WorkflowEditorViewMode>(() => {
try {
const stored = localStorage.getItem(viewModeStorageKey);
if (stored === "simple" || stored === "advanced" || stored === "list") return stored;
} catch {
// localStorage unavailable: non-fatal.
}
return "simple";
});
useEffect(() => {
try {
localStorage.setItem(viewModeStorageKey, viewMode);
} catch {
// localStorage unavailable (private mode / SSR): non-fatal.
}
}, [viewMode]);
// Mobile graph tab presentation: simplified canvas (default) or row list.
const [mobileGraphStyle, setMobileGraphStyle] = useState<MobileGraphStyle>(() => {
try {
const stored = localStorage.getItem(mobileGraphStyleStorageKey);
if (stored === "canvas" || stored === "list") return stored;
} catch {
// localStorage unavailable: non-fatal.
}
return "canvas";
});
useEffect(() => {
try {
localStorage.setItem(mobileGraphStyleStorageKey, mobileGraphStyle);
} catch {
// localStorage unavailable (private mode / SSR): non-fatal.
}
}, [mobileGraphStyle]);
const [mobilePanel, setMobilePanel] = useState<MobileWorkflowPanel>(() =>
initialPanel === "settings" ? "settings" : "graph",
);
const simpleLayoutEnabled = isMobileMode || compactLayoutEnabled;
// "list" keeps the pre-existing compact (mobile-shell) presentation.
const simpleLayoutEnabled = isMobileMode || viewMode === "list";
// Desktop simplified graphical view (not the list fallback, not mobile).
const simpleViewEnabled = !simpleLayoutEnabled && viewMode === "simple";
const { t } = useTranslation("app");
const { confirm } = useConfirm();
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
@@ -1606,6 +1668,85 @@ function InnerEditor({
[isBuiltin, nodes, edges, activeWorkflow, setNodes, setEdges],
);
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
Add-step dialog target for the simplified view. `edgeId` set = insert ON that
edge (source→new→target rewiring via insertNodeOnEdge); null = free append
(the "+ Add step" pill), which prefers the unambiguous edge into `end` and
falls back to the classic free-floating addNode. `insideContainer` hides
container kinds from the dialog because containers cannot nest.
*/
const [addStepTarget, setAddStepTarget] = useState<{
edgeId: string | null;
insideContainer: boolean;
} | null>(null);
const openInsertOnEdge = useCallback(
(edgeId: string) => {
if (isBuiltin) return;
const edge = edges.find((e) => e.id === edgeId);
const source = edge ? nodes.find((n) => n.id === edge.source) : undefined;
const target = edge ? nodes.find((n) => n.id === edge.target) : undefined;
const insideContainer = !!source?.parentId && source.parentId === target?.parentId;
setAddStepTarget({ edgeId, insideContainer });
},
[isBuiltin, edges, nodes],
);
const openAddStep = useCallback(() => {
if (isBuiltin) return;
setAddStepTarget({ edgeId: findAppendEdgeId(nodes, edges), insideContainer: false });
}, [isBuiltin, nodes, edges]);
const containerChildLabelFor = useCallback(
(kind: WorkflowEditorNodeKind) =>
kind === "foreach"
? t("workflowNodes.stepExecuteLabel", "Step execute")
: kind === "optional-group"
? t("workflowNodes.optionalGroupStepLabel", "Optional step")
: t("workflowNodes.loopStepLabel", "Loop step"),
[t],
);
/** Insert on the targeted edge when one is set (falling back to addNode when
* the edge disappeared, e.g. deleted while the dialog was open). */
const insertFromAddStep = useCallback(
(kind: WorkflowEditorNodeKind, label: string, presetConfig?: Record<string, unknown>) => {
if (addStepTarget?.edgeId) {
const result = insertNodeOnEdge(nodes, edges, addStepTarget.edgeId, {
kind,
label,
presetConfig,
containerChildLabel: containerChildLabelFor(kind),
});
if (result) {
setNodes(result.nodes);
setEdges(result.edges);
setSelectedNodeId(result.newNodeId);
setSelectedEdgeId(null);
setAddStepTarget(null);
return;
}
}
addNode(kind, label, presetConfig);
setAddStepTarget(null);
},
[addStepTarget, nodes, edges, setNodes, setEdges, addNode, containerChildLabelFor],
);
const handleAddStepPalettePick = useCallback(
(entry: AddStepPaletteEntry) => insertFromAddStep(entry.kind, entry.label, entry.presetConfig),
[insertFromAddStep],
);
const handleAddStepTemplatePick = useCallback(
(tpl: WorkflowStepTemplate) => {
const { kind, label, config } = stepTemplateToNode(tpl);
insertFromAddStep(kind, label, config);
},
[insertFromAddStep],
);
// Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions
// only; bands and foreach template children are left in place. Marks the
// editor dirty automatically via the layout serialization in isDirty.
@@ -2321,6 +2462,8 @@ function InnerEditor({
}),
[models, agents, skills],
);
// Column id → display name for the simplified view's per-node column chips.
const columnNameMap = useMemo(() => new Map(columns.map((c) => [c.id, c.name])), [columns]);
const mobileConnectionTargetsBySource = useMemo(() => {
const targetNodes = nodesForRender
.filter((node) => !isColumnBandNode(node.id) && node.data.kind !== "start")
@@ -2604,7 +2747,7 @@ function InnerEditor({
<div
className={`wf-editor-body${workflowListStageOpen ? " wf-editor-body--list-stage" : " wf-editor-body--editor-stage"}${
simpleLayoutEnabled ? " wf-editor-body--simple-layout" : ""
}${mobileNodeDetailStage ? " wf-editor-body--mobile-node-detail" : ""}${
}${simpleViewEnabled ? " wf-editor-body--simple-view" : ""}${mobileNodeDetailStage ? " wf-editor-body--mobile-node-detail" : ""}${
mobileEdgeDetailStage ? " wf-editor-body--mobile-edge-detail" : ""
}${sidebarCollapsed ? " wf-editor-body--sidebar-collapsed" : ""}`}
>
@@ -2700,7 +2843,10 @@ 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 && !simpleLayoutEnabled && (
{/* FNXC:WorkflowSimpleView 2026-07-10-12:00: columns/fields/settings
authoring panels are advanced-view chrome; the simplified view
hides them (still one click away via the Advanced toggle). */}
{activeWorkflow && !simpleLayoutEnabled && !simpleViewEnabled && (
<div className="wf-sidebar-panels">
<section className="wf-sidebar-section" data-testid="wf-sidebar-columns-section">
<button
@@ -2899,20 +3045,35 @@ function InnerEditor({
</button>
)}
{!isMobileMode && (
<button
type="button"
className="wf-layout-toggle"
data-testid="wf-layout-toggle"
aria-pressed={compactLayoutEnabled}
onClick={() => setCompactLayoutEnabled((enabled) => !enabled)}
/* FNXC:WorkflowSimpleView 2026-07-10-12:00: segmented three-mode
switch (Simple / Advanced / List) replacing the old boolean
simple-editor toggle; List is the old simple editor. */
<div
className="wf-view-mode-toggle"
role="group"
data-testid="wf-view-mode-toggle"
aria-label={t("workflows.viewModeLabel", "Editor view")}
>
{compactLayoutEnabled ? <LayoutGrid size={14} /> : <ListChecks size={14} />}
<span>
{compactLayoutEnabled
? t("workflows.showCanvasEditor", "Show canvas editor")
: t("workflows.showSimpleEditor", "Show simple editor")}
</span>
</button>
{(
[
["simple", Workflow, t("workflows.viewModeSimple", "Simple")],
["advanced", LayoutGrid, t("workflows.viewModeAdvanced", "Advanced")],
["list", ListChecks, t("workflows.viewModeList", "List")],
] as Array<[WorkflowEditorViewMode, typeof Workflow, string]>
).map(([mode, Icon, label]) => (
<button
key={mode}
type="button"
className={`wf-view-mode-option${viewMode === mode ? " wf-view-mode-option--active" : ""}`}
data-testid={`wf-view-mode-${mode}`}
aria-pressed={viewMode === mode}
onClick={() => setViewMode(mode)}
>
<Icon size={13} aria-hidden />
<span>{label}</span>
</button>
))}
</div>
)}
</div>
{lifecycleWarnings.length > 0 && (
@@ -2958,23 +3119,89 @@ function InnerEditor({
<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);
setInspectorCollapsed(false);
}}
onSelectEdge={(id) => {
setSelectedEdgeId(id);
setSelectedNodeId(null);
}}
onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection}
canReorder={!isBuiltin}
onMoveNode={onMoveSimpleNode}
/>
<>
{/* FNXC:WorkflowSimpleView 2026-07-10-12:00: mobile
graph tab offers the simplified touch canvas
(default) with the row list kept as the even
simpler fallback. Desktop "list" mode reuses
this shell and keeps the row list only. */}
{isMobileMode && (
<div
className="wf-mobile-graph-style-toggle"
role="group"
data-testid="wf-mobile-graph-style-toggle"
aria-label={t("workflowNodes.mobileGraphStyleLabel", "Graph presentation")}
>
{(
[
["canvas", t("workflowNodes.mobileGraphStyleCanvas", "Graph")],
["list", t("workflowNodes.mobileGraphStyleList", "List")],
] as Array<[MobileGraphStyle, string]>
).map(([style, label]) => (
<button
key={style}
type="button"
className={`wf-view-mode-option${mobileGraphStyle === style ? " wf-view-mode-option--active" : ""}`}
data-testid={`wf-mobile-graph-style-${style}`}
aria-pressed={mobileGraphStyle === style}
onClick={() => setMobileGraphStyle(style)}
>
{label}
</button>
))}
</div>
)}
{isMobileMode && mobileGraphStyle === "canvas" ? (
<div className="wf-mobile-simple-canvas" data-testid="wf-mobile-simple-canvas">
<WorkflowEditorCatalogContext.Provider value={catalogs}>
<WorkflowSimpleCanvas
nodes={nodesForRender}
edges={edges}
columnNames={columnNameMap}
editable={!isBuiltin}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={(id) => {
setSelectedNodeId(id);
setSelectedEdgeId(null);
setInspectorCollapsed(false);
}}
onSelectEdge={(id) => {
setSelectedEdgeId(id);
setSelectedNodeId(null);
}}
onClearSelection={() => {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}}
onInsertOnEdge={openInsertOnEdge}
onAddStep={openAddStep}
onBeforeDelete={onBeforeDelete}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
/>
</WorkflowEditorCatalogContext.Provider>
</div>
) : (
<MobileWorkflowGraphView
rows={mobileGraphRows}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={(id) => {
setSelectedNodeId(id);
setSelectedEdgeId(null);
setInspectorCollapsed(false);
}}
onSelectEdge={(id) => {
setSelectedEdgeId(id);
setSelectedNodeId(null);
}}
onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection}
canReorder={!isBuiltin}
onMoveNode={onMoveSimpleNode}
/>
)}
</>
)}
{mobilePanel === "add" && (
@@ -3256,6 +3483,96 @@ function InnerEditor({
<Plus size={13} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
</button>
</div>
) : simpleViewEnabled ? (
/* FNXC:WorkflowSimpleView 2026-07-10-12:00: simplified-view
toolbar — the flat 16-button palette moves into the
searchable add-step dialog; the toolbar keeps only the
common actions (Add step, Design with AI, Delete, Save).
Import/Export/Auto-layout stay in the Advanced view. */
<div className="wf-editor-toolbar wf-editor-toolbar--simple" data-testid="wf-simple-toolbar">
<button
type="button"
className="wf-editor-action wf-simple-toolbar-add"
data-testid="wf-simple-toolbar-add-step"
onClick={openAddStep}
>
<Plus size={13} /> {t("workflowNodes.simpleAddStep", "Add step")}
</button>
<div className="wf-editor-actions">
<div className="wf-ai-edit-wrap">
<button
className="wf-editor-action"
data-testid="wf-simple-ai-edit"
aria-expanded={aiPanelOpen}
onClick={() => {
setAiPanelOpen((o) => !o);
setAiEditError(null);
}}
>
<Sparkles size={13} /> {t("workflows.aiEdit", "Design with AI")}
</button>
{aiPanelOpen && (
<div
className="wf-ai-panel"
data-testid="wf-simple-ai-panel"
role="dialog"
aria-busy={aiEditBusy}
aria-label={t("workflows.aiEdit", "Design with AI")}
>
<textarea
className="wf-ai-prompt"
data-testid="wf-simple-ai-edit-prompt"
rows={3}
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-simple-ai-edit-error">
{aiEditError}
</p>
)}
<div className="wf-ai-actions">
<button
type="button"
className="btn btn-primary wf-ai-submit"
data-testid="wf-simple-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"
data-testid="wf-simple-ai-edit-cancel"
onClick={handleAiEditCancel}
>
{t("common.cancel", "Cancel")}
</button>
)}
</div>
</div>
)}
</div>
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> {t("common.delete", "Delete")}
</button>
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />}{" "}
{t("common.save", "Save")}
</button>
</div>
</div>
) : (
<div className="wf-editor-toolbar">
<div className="wf-editor-palette">
@@ -3375,7 +3692,7 @@ function InnerEditor({
</div>
)}
{hasAnyTemplate && (
{hasAnyTemplate && !simpleViewEnabled && (
<section
className="wf-templates"
data-testid="wf-palette-templates"
@@ -3557,6 +3874,43 @@ function InnerEditor({
</div>
)}
{simpleViewEnabled ? (
/* FNXC:WorkflowSimpleView 2026-07-10-12:00: the simplified
graphical canvas replaces the advanced React Flow canvas;
both edit the same nodes/edges state, so selection,
deletion, the inspector, validation badges and Save all
behave identically. */
<div className="wf-editor-canvas wf-editor-canvas--simple" ref={canvasRef} tabIndex={-1}>
<WorkflowEditorCatalogContext.Provider value={catalogs}>
<WorkflowSimpleCanvas
nodes={nodesForRender}
edges={edges}
columnNames={columnNameMap}
editable={!isBuiltin}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={(id) => {
setSelectedNodeId(id);
setSelectedEdgeId(null);
setInspectorCollapsed(false);
}}
onSelectEdge={(id) => {
setSelectedEdgeId(id);
setSelectedNodeId(null);
}}
onClearSelection={() => {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}}
onInsertOnEdge={openInsertOnEdge}
onAddStep={openAddStep}
onBeforeDelete={onBeforeDelete}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
/>
</WorkflowEditorCatalogContext.Provider>
</div>
) : (
<div className="wf-editor-canvas" ref={canvasRef} tabIndex={-1}>
{isMobileMode &&
inspectorCollapsed &&
@@ -3641,6 +3995,7 @@ function InnerEditor({
</ReactFlow>
</WorkflowEditorCatalogContext.Provider>
</div>
)}
</>
) : (
<div className="wf-editor-empty wf-editor-canvas-empty wf-editor-onboard">
@@ -3668,7 +4023,10 @@ function InnerEditor({
{/* FNXC:WorkflowSimpleEditor 2026-06-29-23:21: Simple editor row and pencil affordances must open the same node details in desktop compact and mobile presentations. Do not suppress this inspector only because the canvas is hidden; mobile collapse still closes the detail stage and end nodes remain non-inspectable. */}
{selectedNodeHasInspector &&
!(isMobileMode && inspectorCollapsed) && (
<aside className="wf-editor-inspector" data-testid="wf-node-inspector">
<aside
className={`wf-editor-inspector${simpleViewEnabled ? " wf-editor-inspector--simple" : ""}`}
data-testid="wf-node-inspector"
>
<div className="wf-inspector-heading">
{/* FNXC:WorkflowEditor 2026-06-21-10:00: Heading shows the node-kind title (from the help registry) so the pane names what is selected, falling back to the generic "Node" label. */}
<h3>{selectedNodeHelp?.title ?? t("workflowNodes.nodeInspector", "Node")}</h3>
@@ -5003,7 +5361,10 @@ function InnerEditor({
)}
{selectedEdge && (
<aside className="wf-editor-inspector" data-testid="wf-edge-inspector">
<aside
className={`wf-editor-inspector${simpleViewEnabled ? " wf-editor-inspector--simple" : ""}`}
data-testid="wf-edge-inspector"
>
<div className="wf-inspector-heading">
<h3>{t("workflowNodes.edgeInspector", "Edge")}</h3>
{isMobileMode && (
@@ -5105,6 +5466,25 @@ function InnerEditor({
onClose={closeCreateDialog}
/>
)}
<WorkflowAddStepModal
open={addStepTarget !== null}
onClose={() => setAddStepTarget(null)}
palette={PALETTE}
disallowContainers={addStepTarget?.insideContainer === true}
fragments={templateGroups.fragmentEntries}
stepTemplates={templateGroups.stepEntries}
pluginTemplates={templateGroups.pluginEntries}
templateConflict={templateConflict}
onPickPalette={handleAddStepPalettePick}
onPickFragment={(fragment) => {
if (handleInsertFragment(fragment)) setAddStepTarget(null);
}}
onPickStepTemplate={handleAddStepTemplatePick}
onPickStepTemplateAsOptionalGroup={(tpl) => {
handleInsertStepTemplateAsOptionalGroup(tpl);
setAddStepTarget(null);
}}
/>
</div>
);
return (

View File

@@ -0,0 +1,381 @@
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
Simplified workflow view styling. Requirements encoded here:
- A modern node-card look competitive with leading workflow builders: soft
rounded cards, a colored icon chip per node family (agent / automation /
flow control / merge), quiet default edges, and a clear selected ring.
- All colors derive from the dashboard theme tokens so light/dark and color
themes cascade without per-theme overrides.
- Touch-first hit targets: the edge "+" insert button and the "Add step"
pill are ≥32px so the same canvas serves the mobile graph view.
*/
.wf-simple-canvas {
position: absolute;
inset: 0;
}
.wf-simple-canvas .react-flow {
background: var(--bg);
}
.wf-simple-canvas-bg {
color: color-mix(in srgb, var(--text-dim) 55%, transparent);
}
/* ── Node cards ─────────────────────────────────────────────────────────── */
.wf-simple-node {
display: flex;
align-items: flex-start;
gap: 10px;
width: 260px;
min-height: 64px;
box-sizing: border-box;
padding: 12px;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--card);
box-shadow: var(--shadow-sm);
cursor: pointer;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast);
}
.wf-simple-node:hover {
background: var(--card-hover);
border-color: var(--border-strong);
}
.wf-simple-node--selected,
.wf-simple-node--selected:hover {
border-color: var(--todo);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--todo) 35%, transparent),
var(--shadow-md);
}
.wf-simple-node--error {
border-color: var(--color-error);
}
.wf-simple-node-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
color: #fff;
}
/* Node family accents (icon chip background + a subtle left accent). */
.wf-simple-node--agent .wf-simple-node-chip {
background: color-mix(in srgb, var(--ws-quality) 85%, var(--bg));
}
.wf-simple-node--automation .wf-simple-node-chip {
background: color-mix(in srgb, var(--ws-teal) 85%, var(--bg));
}
.wf-simple-node--flow .wf-simple-node-chip,
.wf-simple-node-chip--flow {
background: color-mix(in srgb, var(--color-merged) 85%, var(--bg));
}
.wf-simple-node--merge .wf-simple-node-chip {
background: color-mix(in srgb, var(--ws-success) 80%, var(--bg));
}
.wf-simple-node-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.wf-simple-node-title-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.wf-simple-node-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wf-simple-node-badge {
flex: 0 0 auto;
font-size: 0.62rem;
line-height: 1;
padding: 3px 6px;
border-radius: var(--radius-pill);
background: var(--surface-emphasis);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.wf-simple-node-summary {
font-size: 0.72rem;
color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.wf-simple-node-column {
align-self: flex-start;
font-size: 0.64rem;
padding: 2px 7px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-subtle);
background: var(--surface-subtle);
color: var(--text-muted);
}
.wf-simple-node-error {
flex: 0 0 auto;
color: var(--color-error);
display: inline-flex;
align-items: center;
}
/* Start/end terminals: compact pills so the flow's boundaries read as quiet
markers, not steps. */
.wf-simple-terminal {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
background: var(--surface-emphasis);
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 600;
}
.wf-simple-terminal--start {
border-color: color-mix(in srgb, var(--ws-success) 45%, var(--border));
color: var(--ws-success);
}
.wf-simple-terminal--end {
border-color: color-mix(in srgb, var(--done) 45%, var(--border));
}
.wf-simple-terminal.wf-simple-node--selected {
border-color: var(--todo);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--todo) 35%, transparent);
}
/* Containers (foreach/loop/optional-group): dashed region, children render
inside as regular cards. */
.wf-simple-container {
width: 100%;
height: 100%;
box-sizing: border-box;
border-radius: var(--radius-xl);
border: 1.5px dashed color-mix(in srgb, var(--color-merged) 55%, var(--border));
background: color-mix(in srgb, var(--color-merged) 6%, transparent);
padding: 10px 12px;
cursor: pointer;
}
.wf-simple-container.wf-simple-node--selected {
border-color: var(--todo);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--todo) 30%, transparent);
}
.wf-simple-container-header {
display: flex;
align-items: center;
gap: 8px;
}
.wf-simple-container-header .wf-simple-node-chip {
width: 26px;
height: 26px;
}
.wf-simple-container-empty {
margin-top: 14px;
text-align: center;
font-size: 0.75rem;
color: var(--text-dim);
}
/* Handles exist for edge anchoring only — never interactive in simple view. */
.wf-simple-handle {
opacity: 0;
width: 6px;
height: 6px;
pointer-events: none;
}
/* ── Edges ──────────────────────────────────────────────────────────────── */
.wf-simple-edge-path {
stroke: var(--border-strong);
stroke-width: 1.75;
}
.wf-simple-edge-path--selected {
stroke: var(--todo);
stroke-width: 2.25;
}
.wf-simple-edge-path--failure {
stroke: color-mix(in srgb, var(--color-error) 70%, var(--border));
stroke-dasharray: 6 4;
}
.wf-simple-edge-path--rework {
stroke: color-mix(in srgb, var(--accent, #7c5cbf) 75%, var(--border));
stroke-dasharray: 4 4;
}
.wf-simple-edge-label {
position: absolute;
display: flex;
align-items: center;
gap: 6px;
pointer-events: all;
z-index: 5;
}
.wf-simple-edge-chip {
font-size: 0.66rem;
line-height: 1;
padding: 4px 8px;
border-radius: var(--radius-pill);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
white-space: nowrap;
}
.wf-simple-edge-chip--failure {
border-color: color-mix(in srgb, var(--color-error) 50%, var(--border));
color: var(--color-error);
}
.wf-simple-edge-chip--rework {
border-color: color-mix(in srgb, var(--accent, #7c5cbf) 50%, var(--border));
color: var(--accent, #7c5cbf);
}
/* The + insert affordance: faint at rest so it's discoverable without hover,
full-strength on hover/focus; larger and fully opaque on coarse (touch)
pointers so mobile users can find and hit it. */
.wf-simple-edge-insert {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
cursor: pointer;
opacity: 0.55;
transition:
opacity var(--transition-fast),
transform var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.wf-simple-edge-insert:focus-visible {
opacity: 1;
}
.wf-simple-edge-insert:hover {
opacity: 1;
transform: scale(1.05);
border-color: var(--todo);
color: var(--todo);
box-shadow: var(--focus-ring);
}
@media (pointer: coarse) {
.wf-simple-edge-insert {
opacity: 1;
width: 32px;
height: 32px;
}
}
/* ── Canvas chrome ──────────────────────────────────────────────────────── */
.wf-simple-controls.react-flow__controls {
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-md);
}
.wf-simple-controls .react-flow__controls-button {
background: var(--surface);
border-bottom: 1px solid var(--border-subtle);
color: var(--text-muted);
width: 28px;
height: 28px;
}
.wf-simple-controls .react-flow__controls-button:hover {
background: var(--surface-hover);
color: var(--text);
}
.wf-simple-controls .react-flow__controls-button svg {
fill: currentColor;
}
.wf-simple-add-panel {
margin-bottom: 18px;
}
.wf-simple-add-step {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 18px;
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--todo) 45%, var(--border));
background: color-mix(in srgb, var(--todo) 14%, var(--surface));
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
box-shadow: var(--shadow-md);
transition:
background var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.wf-simple-add-step:hover {
background: color-mix(in srgb, var(--todo) 24%, var(--surface));
border-color: var(--todo);
box-shadow: var(--focus-ring), var(--shadow-md);
}
@media (max-width: 768px), (max-height: 480px) {
.wf-simple-node {
width: 240px;
}
.wf-simple-add-step {
padding: 11px 20px;
}
}

View File

@@ -0,0 +1,492 @@
import { createContext, useContext, useEffect, useMemo, useRef } from "react";
import {
ReactFlow,
ReactFlowProvider,
useReactFlow,
useNodesInitialized,
Background,
BackgroundVariant,
Controls,
Panel,
EdgeLabelRenderer,
BaseEdge,
getSmoothStepPath,
Position,
Handle,
type Node as FlowNode,
type Edge as FlowEdge,
type NodeProps,
type EdgeProps,
type OnBeforeDelete,
type OnNodesDelete,
type OnEdgesDelete,
} from "@xyflow/react";
import { useTranslation } from "react-i18next";
import {
Play,
Flag,
MessageSquare,
Terminal,
Shield,
GitMerge,
PauseCircle,
Split,
Merge,
Repeat,
ToggleRight,
ClipboardCheck,
ListChecks,
Code2,
Bell,
HelpCircle,
DoorOpen,
Plus,
AlertTriangle,
} from "lucide-react";
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { nodeConfigSummary } from "./nodes/node-summary";
import { useWorkflowEditorCatalogs } from "./nodes/WorkflowEditorCatalogContext";
import { isColumnBandNode, isVisualOnlyWorkflowEdge } from "./workflow-flow-mapping";
import { simpleVerticalLayout, edgeSupportsSimpleInsert } from "./workflow-simple-layout";
import "./WorkflowSimpleCanvas.css";
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
WorkflowSimpleCanvas is the simplified graphical node editor: a modern,
vertical, auto-laid-out React Flow rendering of the SAME editor node/edge
state the advanced canvas edits. Requirements it encodes:
- Common tasks (adding + configuring nodes) must be one-tap: every eligible
edge shows a "+" insert affordance, clicking a node opens the shared
inspector, and a persistent "+ Add step" pill covers the append case.
- No free-form dragging: layout is derived (simpleVerticalLayout) and
display-only, so the simple view can never corrupt the advanced canvas's
manually authored positions or a v2 workflow's column placement.
- Column swimlane bands, the minimap, and drag-to-connect handles are
advanced-canvas chrome and intentionally absent here; column membership
surfaces as a per-node chip instead.
- Touch-friendly: pan/zoom gestures only, large hit targets, works as the
mobile graph view (mobile keeps the row-list view as an even simpler
fallback).
*/
const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
start: Play,
end: Flag,
prompt: MessageSquare,
script: Terminal,
gate: Shield,
merge: GitMerge,
hold: PauseCircle,
split: Split,
join: Merge,
foreach: Repeat,
loop: Repeat,
"optional-group": ToggleRight,
"step-review": ClipboardCheck,
"parse-steps": ListChecks,
code: Code2,
notify: Bell,
"ask-user": HelpCircle,
"exit-gate": DoorOpen,
};
/** Visual family per kind — drives the icon chip / accent color. */
export function simpleNodeFamily(kind: WorkflowEditorNodeKind): "terminal" | "agent" | "automation" | "flow" | "merge" {
switch (kind) {
case "start":
case "end":
return "terminal";
case "prompt":
case "ask-user":
case "gate":
case "step-review":
return "agent";
case "script":
case "code":
case "notify":
case "parse-steps":
return "automation";
case "merge":
return "merge";
default:
return "flow";
}
}
interface SimpleNodeDisplayData extends WorkflowFlowNodeData {
/** True for template children inside a container: horizontal handle flow. */
__simpleChild?: boolean;
/** Resolved column name chip (v2 workflows). */
__columnName?: string;
}
function SimpleStepNode({ data, selected }: NodeProps) {
const { t } = useTranslation("app");
const catalogs = useWorkflowEditorCatalogs();
const d = data as SimpleNodeDisplayData;
const kind = d.kind;
const Icon = KIND_ICON[kind] ?? MessageSquare;
const family = simpleNodeFamily(kind);
const summary = nodeConfigSummary(d, catalogs, t);
const horizontal = d.__simpleChild === true;
const targetPos = horizontal ? Position.Left : Position.Top;
const sourcePos = horizontal ? Position.Right : Position.Bottom;
const seam = kind === "prompt" ? (d.config?.seam as string | undefined) : undefined;
if (kind === "start" || kind === "end") {
return (
<div
className={`wf-simple-terminal wf-simple-terminal--${kind}${selected ? " wf-simple-node--selected" : ""}`}
data-testid={`wf-simple-node-${kind}`}
>
{kind !== "start" && <Handle type="target" position={targetPos} className="wf-simple-handle" />}
<Icon size={13} aria-hidden />
<span>{d.label || kind}</span>
{kind !== "end" && <Handle type="source" position={sourcePos} className="wf-simple-handle" />}
</div>
);
}
return (
<div
className={`wf-simple-node wf-simple-node--${family}${selected ? " wf-simple-node--selected" : ""}${d.errorBadge ? " wf-simple-node--error" : ""}`}
data-testid={`wf-simple-node-${kind}`}
>
<Handle type="target" position={targetPos} className="wf-simple-handle" />
<span className="wf-simple-node-chip" aria-hidden>
<Icon size={16} />
</span>
<span className="wf-simple-node-body">
<span className="wf-simple-node-title-row">
<span className="wf-simple-node-label">{d.label || kind}</span>
{kind === "gate" && <span className="wf-simple-node-badge">{t("workflowNodes.gateBadge", "gate")}</span>}
{seam === "step-execute" && <span className="wf-simple-node-badge">{t("workflowNodes.stepBadge", "step")}</span>}
</span>
{summary ? (
<span className="wf-simple-node-summary" title={summary}>
{summary}
</span>
) : null}
{d.__columnName ? <span className="wf-simple-node-column">{d.__columnName}</span> : null}
</span>
{d.errorBadge ? (
<span className="wf-simple-node-error" role="alert" title={d.errorBadge}>
<AlertTriangle size={13} aria-hidden />
</span>
) : null}
<Handle type="source" position={sourcePos} className="wf-simple-handle" />
</div>
);
}
function SimpleContainerNode({ data, selected }: NodeProps) {
const { t } = useTranslation("app");
const d = data as SimpleNodeDisplayData;
const Icon = KIND_ICON[d.kind] ?? Repeat;
const badge =
d.kind === "optional-group"
? d.config?.defaultOn === true
? t("workflowNodes.optionalGroupDefaultOn", "default on")
: t("workflowNodes.optionalGroupDefaultOff", "default off")
: d.kind === "loop"
? `${(d.config?.maxIterations as number | undefined) ?? 3}x`
: ((d.config?.mode as string | undefined) ?? "sequential");
return (
<div
className={`wf-simple-container${selected ? " wf-simple-node--selected" : ""}${d.errorBadge ? " wf-simple-node--error" : ""}`}
data-testid={`wf-simple-node-${d.kind}`}
>
<Handle type="target" position={Position.Top} className="wf-simple-handle" />
<div className="wf-simple-container-header">
<span className="wf-simple-node-chip wf-simple-node-chip--flow" aria-hidden>
<Icon size={14} />
</span>
<span className="wf-simple-node-label">{d.label || d.kind}</span>
<span className="wf-simple-node-badge">{badge}</span>
{d.errorBadge ? (
<span className="wf-simple-node-error" role="alert" title={d.errorBadge}>
<AlertTriangle size={13} aria-hidden />
</span>
) : null}
</div>
{d.templateEmpty === true && (
<div className="wf-simple-container-empty">{d.emptyHint || t("workflowNodes.simpleContainerEmpty", "No steps inside yet")}</div>
)}
<Handle type="source" position={Position.Bottom} className="wf-simple-handle" />
</div>
);
}
const simpleNodeTypes = {
start: SimpleStepNode,
end: SimpleStepNode,
prompt: SimpleStepNode,
script: SimpleStepNode,
gate: SimpleStepNode,
merge: SimpleStepNode,
hold: SimpleStepNode,
split: SimpleStepNode,
join: SimpleStepNode,
"step-review": SimpleStepNode,
"parse-steps": SimpleStepNode,
code: SimpleStepNode,
notify: SimpleStepNode,
"ask-user": SimpleStepNode,
"exit-gate": SimpleStepNode,
foreach: SimpleContainerNode,
loop: SimpleContainerNode,
"optional-group": SimpleContainerNode,
};
interface SimpleEdgeData {
condition?: string;
kind?: string;
__insertable?: boolean;
[key: string]: unknown;
}
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
The simple edge renders the routing condition as a chip only when it carries
signal (anything but the default "success"), keeping the default path visually
quiet. The "+" button is the primary add-node entry point of the simple view.
React Flow edgeTypes must stay referentially stable (recreating the map
re-mounts every edge), so the per-render onInsertOnEdge callback reaches the
edge component through this context rather than through edge props.
*/
const SimpleCanvasInsertContext = createContext<(edgeId: string) => void>(() => {});
function SimpleInsertEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected, label }: EdgeProps) {
const { t } = useTranslation("app");
const onInsertOnEdge = useContext(SimpleCanvasInsertContext);
const d = (data ?? {}) as SimpleEdgeData;
const [path, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 12,
});
const condition = d.condition ?? "success";
const isRework = d.kind === "rework";
const showChip = isRework || condition !== "success";
const chipText = isRework ? `${String(label ?? condition)}` : String(label ?? condition);
return (
<>
<BaseEdge
id={id}
path={path}
className={`wf-simple-edge-path${selected ? " wf-simple-edge-path--selected" : ""}${isRework ? " wf-simple-edge-path--rework" : ""}${condition === "failure" ? " wf-simple-edge-path--failure" : ""}`}
/>
<EdgeLabelRenderer>
<div
className="wf-simple-edge-label nodrag nopan"
style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)` }}
>
{showChip && (
<span className={`wf-simple-edge-chip${condition === "failure" ? " wf-simple-edge-chip--failure" : ""}${isRework ? " wf-simple-edge-chip--rework" : ""}`}>
{chipText}
</span>
)}
{d.__insertable === true && (
<button
type="button"
className="wf-simple-edge-insert"
data-testid={`wf-simple-insert-${id}`}
aria-label={t("workflowNodes.simpleInsertStep", "Insert step here")}
title={t("workflowNodes.simpleInsertStep", "Insert step here")}
onClick={(event) => {
event.stopPropagation();
onInsertOnEdge(id);
}}
>
<Plus size={14} aria-hidden />
</button>
)}
</div>
</EdgeLabelRenderer>
</>
);
}
const simpleEdgeTypes = { "wf-simple": SimpleInsertEdge };
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
React Flow's `fitView` prop applies only on init, but the editor loads the
workflow (and the user can switch workflows) AFTER the canvas mounts — and the
derived vertical layout centers x around 0, so without a refit the graph sits
off-screen to the left. Refit whenever the set of rendered nodes changes
(workflow switch, add/insert/delete), on the next frame so React Flow has
measured the new nodes.
*/
function SimpleCanvasAutoFit({
signature,
containerRef,
}: {
signature: string;
containerRef: React.RefObject<HTMLDivElement | null>;
}) {
const { fitView } = useReactFlow();
// Nodes have no dimensions until React Flow measures them; fitView before
// that is a no-op (seen as a mis-centered mobile canvas on first mount).
const nodesInitialized = useNodesInitialized();
useEffect(() => {
if (!nodesInitialized) return;
let frame = requestAnimationFrame(() => {
void fitView({ padding: 0.15, maxZoom: 1 });
});
// Second pass: container children and fonts can measure after the first
// fit (and mobile stage transitions animate the container), shifting the
// graph bounds without a node-set change. One settled refit covers it.
const settle = window.setTimeout(() => {
void fitView({ padding: 0.15, maxZoom: 1 });
}, 250);
// The canvas region also resizes without a graph change (inspector
// opening/closing, sidebar collapse, window resize) — refit then too so
// the flow stays centered instead of drifting off-screen.
const el = containerRef.current;
const observer =
typeof ResizeObserver === "undefined" || !el
? null
: new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
void fitView({ padding: 0.15, maxZoom: 1 });
});
});
if (observer && el) observer.observe(el);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(settle);
observer?.disconnect();
};
}, [signature, fitView, containerRef, nodesInitialized]);
return null;
}
export interface WorkflowSimpleCanvasProps {
nodes: FlowNode<WorkflowFlowNodeData>[];
edges: FlowEdge[];
/** Column id → display name (v2). Empty map for v1 workflows. */
columnNames: Map<string, string>;
editable: boolean;
selectedNodeId: string | null;
selectedEdgeId: string | null;
onSelectNode: (id: string) => void;
onSelectEdge: (id: string) => void;
onClearSelection: () => void;
/** Open the add-step dialog targeting this edge. */
onInsertOnEdge: (edgeId: string) => void;
/** Open the add-step dialog for a free append. */
onAddStep: () => void;
onBeforeDelete?: OnBeforeDelete<FlowNode<WorkflowFlowNodeData>, FlowEdge>;
onNodesDelete?: OnNodesDelete<FlowNode<WorkflowFlowNodeData>>;
onEdgesDelete?: OnEdgesDelete<FlowEdge>;
}
export function WorkflowSimpleCanvas({
nodes,
edges,
columnNames,
editable,
selectedNodeId,
selectedEdgeId,
onSelectNode,
onSelectEdge,
onClearSelection,
onInsertOnEdge,
onAddStep,
onBeforeDelete,
onNodesDelete,
onEdgesDelete,
}: WorkflowSimpleCanvasProps) {
const { t } = useTranslation("app");
const containerRef = useRef<HTMLDivElement | null>(null);
const displayNodes = useMemo(() => {
const positions = simpleVerticalLayout(nodes, edges);
return nodes
.filter((n) => !isColumnBandNode(n.id))
.map((n) => {
const pos = positions.get(n.id);
const data: SimpleNodeDisplayData = {
...(n.data as WorkflowFlowNodeData),
__simpleChild: !!n.parentId,
__columnName: n.data.column ? columnNames.get(n.data.column) : undefined,
};
return {
...n,
data,
position: pos ?? n.position,
selected: n.id === selectedNodeId,
draggable: false,
connectable: false,
};
});
}, [nodes, edges, columnNames, selectedNodeId]);
const displayEdges = useMemo(
() =>
edges
.filter((e) => !isVisualOnlyWorkflowEdge(e))
.map((e) => ({
...e,
type: "wf-simple",
selected: e.id === selectedEdgeId,
data: {
...(e.data ?? {}),
__insertable: editable && edgeSupportsSimpleInsert(e),
},
})),
[edges, editable, selectedEdgeId],
);
return (
<div className="wf-simple-canvas" data-testid="wf-simple-canvas" ref={containerRef}>
{/* FNXC:WorkflowSimpleView 2026-07-10-12:00: OWN provider, deliberately
nested inside the editor's ReactFlowProvider. The editor keeps its
advanced canvas mounted (CSS-hidden) in list/mobile presentations;
sharing one store between two ReactFlow instances corrupts node
measurements and breaks fitView on the visible one. */}
<ReactFlowProvider>
<SimpleCanvasInsertContext.Provider value={onInsertOnEdge}>
<ReactFlow
nodes={displayNodes}
edges={displayEdges}
nodeTypes={simpleNodeTypes}
edgeTypes={simpleEdgeTypes}
nodesDraggable={false}
nodesConnectable={false}
deleteKeyCode={editable ? ["Backspace", "Delete"] : null}
onBeforeDelete={onBeforeDelete}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onNodeClick={(_, node) => onSelectNode(node.id)}
onEdgeClick={(_, edge) => onSelectEdge(edge.id)}
onPaneClick={onClearSelection}
fitView
fitViewOptions={{ padding: 0.15, maxZoom: 1 }}
minZoom={0.25}
maxZoom={1.75}
proOptions={{ hideAttribution: false }}
>
<SimpleCanvasAutoFit signature={displayNodes.map((n) => n.id).join("|")} containerRef={containerRef} />
<Background variant={BackgroundVariant.Dots} gap={22} size={1.5} className="wf-simple-canvas-bg" />
<Controls showInteractive={false} className="wf-simple-controls" />
{editable && (
<Panel position="bottom-center" className="wf-simple-add-panel">
<button type="button" className="wf-simple-add-step" data-testid="wf-simple-add-step" onClick={onAddStep}>
<Plus size={15} aria-hidden />
<span>{t("workflowNodes.simpleAddStep", "Add step")}</span>
</button>
</Panel>
)}
</ReactFlow>
</SimpleCanvasInsertContext.Provider>
</ReactFlowProvider>
</div>
);
}

View File

@@ -74,6 +74,10 @@ function promptDef(): WorkflowDefinition {
describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
beforeEach(() => {
// FNXC:WorkflowSimpleView 2026-07-10-12:00: this suite drives the advanced
// canvas (wf-node-prompt cards); pin the persisted view mode since the
// editor now defaults to the simplified view.
localStorage.setItem("fusion:wf-editor-view-mode", "advanced");
vi.mocked(fetchWorkflows).mockResolvedValue([promptDef()]);
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue([]);
@@ -102,6 +106,7 @@ describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
});
afterEach(() => {
localStorage.removeItem("fusion:wf-editor-view-mode");
cleanup();
vi.clearAllMocks();
vi.unstubAllGlobals();

View File

@@ -498,6 +498,23 @@ describe("workflow-flow-mapping", () => {
});
});
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
The editor now defaults to the simplified view ("simple"); this legacy suite
was authored against the advanced canvas and the row-list (old "simple
editor") behaviors, so it pins the persisted view-mode keys per test. The
simplified view has its own dedicated suite below ("simplified view modes").
*/
beforeEach(() => {
localStorage.setItem("fusion:wf-editor-view-mode", "advanced");
localStorage.setItem("fusion:wf-mobile-graph-style", "list");
});
afterEach(() => {
localStorage.removeItem("fusion:wf-editor-view-mode");
localStorage.removeItem("fusion:wf-mobile-graph-style");
});
describe("WorkflowNodeEditor", () => {
beforeEach(() => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
@@ -609,7 +626,7 @@ describe("WorkflowNodeEditor", () => {
expect(document.body.querySelector(".react-flow__minimap")).toBeTruthy();
});
it("lets desktop users switch to the simple graph layout and back", async () => {
it("lets desktop users switch to the list layout and back", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
@@ -617,17 +634,17 @@ describe("WorkflowNodeEditor", () => {
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
expect(screen.queryByTestId("wf-mobile-shell")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
expect(await screen.findByTestId("wf-mobile-shell")).toBeInTheDocument();
expect(screen.getByTestId("wf-mobile-tab-graph")).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "start start" })).toBeInTheDocument();
expect(screen.getByTestId("wf-layout-toggle")).toHaveTextContent("Show canvas editor");
expect(screen.getByTestId("wf-view-mode-list")).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-advanced"));
await waitFor(() => expect(screen.queryByTestId("wf-mobile-shell")).not.toBeInTheDocument());
expect(screen.getByTestId("wf-layout-toggle")).toHaveTextContent("Show simple editor");
expect(screen.getByTestId("wf-view-mode-advanced")).toHaveAttribute("aria-pressed", "true");
});
it("surfaces the full styled simple-editor affordance set at desktop width", async () => {
@@ -636,7 +653,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
const shell = await screen.findByTestId("wf-mobile-shell");
for (const panel of ["graph", "add", "settings", "fields", "columns", "actions"]) {
@@ -666,7 +683,7 @@ describe("WorkflowNodeEditor", () => {
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
const shell = await screen.findByTestId("wf-mobile-shell");
expect(screen.queryByTestId("wf-layout-toggle")).not.toBeInTheDocument();
expect(screen.queryByTestId("wf-view-mode-toggle")).not.toBeInTheDocument();
assertSimpleEditorTabScrollOwner(shell, 375);
});
@@ -698,7 +715,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Plain connect");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
await screen.findByTestId("wf-mobile-shell");
fireEvent.click(await screen.findByTestId("mobile-wf-connect-draft"));
@@ -729,7 +746,7 @@ describe("WorkflowNodeEditor", () => {
await screen.findByText("Save");
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
await screen.findByTestId("wf-mobile-shell");
fireEvent.click(await screen.findByTestId("mobile-wf-connect-merge"));
@@ -778,7 +795,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Default coding workflow");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
await screen.findByTestId("wf-mobile-shell");
fireEvent.click(screen.getByTestId("wf-mobile-tab-actions"));
@@ -821,7 +838,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
const shell = await screen.findByTestId("wf-mobile-shell");
expect(screen.getByTestId("wf-mobile-tab-actions")).toBeInTheDocument();
@@ -946,7 +963,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
const lintRow = await screen.findByTestId("mobile-wf-node-lint");
fireEvent.click(within(lintRow).getAllByRole("button")[0]);
@@ -966,7 +983,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
fireEvent.click(within(await screen.findByTestId("mobile-wf-node-start")).getAllByRole("button")[0]);
const startInspector = await screen.findByTestId("wf-node-inspector");
@@ -1230,7 +1247,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
fireEvent.click(within(await screen.findByTestId("mobile-wf-node-model")).getAllByRole("button")[0]);
let inspector = await screen.findByTestId("wf-node-inspector");
@@ -4043,3 +4060,119 @@ describe("WorkflowNodeEditor — U6 column agents", () => {
expect(note).toHaveTextContent("Reviewer");
});
});
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
Simplified graphical view coverage: default mode, mode persistence, the
add-step dialog (palette + search), insert-on-edge affordances, read-only
built-in gating, and the mobile canvas/list graph-style toggle. Surface
enumeration for the new affordances: desktop simple toolbar, edge "+"
buttons, the add-step dialog, the segmented view switch, and both mobile
graph styles.
*/
describe("WorkflowNodeEditor simplified view modes", () => {
beforeEach(() => {
// Exercise the REAL defaults (simple view / mobile canvas style) instead
// of the legacy-suite pins from the file-level hook.
localStorage.removeItem("fusion:wf-editor-view-mode");
localStorage.removeItem("fusion:wf-mobile-graph-style");
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("defaults desktop to the simplified graphical view", async () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-simple-canvas")).toBeInTheDocument();
expect(screen.getByTestId("wf-view-mode-simple")).toHaveAttribute("aria-pressed", "true");
// Advanced-only chrome is absent: palette buttons, templates, minimap toggle.
expect(document.querySelector(".wf-palette-btn")).toBeNull();
expect(screen.queryByTestId("wf-palette-templates")).not.toBeInTheDocument();
expect(screen.queryByTestId("wf-minimap-toggle")).not.toBeInTheDocument();
// The simplified toolbar keeps the common actions.
expect(screen.getByTestId("wf-simple-toolbar-add-step")).toBeInTheDocument();
expect(screen.getByTestId("wf-simple-ai-edit")).toBeInTheDocument();
expect(screen.getByText("Save")).toBeInTheDocument();
// Node cards render on the simplified canvas.
expect(await screen.findByTestId("wf-simple-node-gate")).toBeInTheDocument();
expect(screen.getByTestId("wf-simple-node-start")).toBeInTheDocument();
});
it("persists the chosen view mode and honors it on remount", async () => {
const first = render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-simple-canvas");
fireEvent.click(screen.getByTestId("wf-view-mode-advanced"));
await waitFor(() => expect(screen.queryByTestId("wf-simple-canvas")).not.toBeInTheDocument());
expect(localStorage.getItem("fusion:wf-editor-view-mode")).toBe("advanced");
expect(document.querySelector(".wf-palette-btn")).not.toBeNull();
first.unmount();
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-workflow-name");
expect(screen.getByTestId("wf-view-mode-advanced")).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByTestId("wf-simple-canvas")).not.toBeInTheDocument();
});
it("opens the searchable add-step dialog and adds the picked node", async () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-simple-canvas");
fireEvent.click(screen.getByTestId("wf-simple-toolbar-add-step"));
const dialog = await screen.findByTestId("wf-add-step-modal");
expect(within(dialog).getByText("Agent steps")).toBeInTheDocument();
expect(within(dialog).getByText("Automation")).toBeInTheDocument();
expect(within(dialog).getByText("Flow control")).toBeInTheDocument();
// Search narrows the catalog.
fireEvent.change(within(dialog).getByTestId("wf-add-step-search"), { target: { value: "script" } });
expect(within(dialog).queryByTestId("wf-add-step-prompt-prompt")).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByTestId("wf-add-step-script-script"));
await waitFor(() => expect(screen.queryByTestId("wf-add-step-modal")).not.toBeInTheDocument());
// def() has an unambiguous edge into end, so the pick inserts there and
// the new node lands selected with the inspector open.
expect(await screen.findByTestId("wf-node-inspector")).toBeInTheDocument();
expect(await screen.findByTestId("wf-simple-node-script")).toBeInTheDocument();
});
/* NOTE: the per-edge "+" button itself cannot render under jsdom — React
Flow only mounts edge components once nodes have measured dimensions.
Its insert behavior is covered by insertNodeOnEdge unit tests
(workflow-simple-layout.test.ts) and the toolbar-pick test above, which
exercises the same insertFromAddStep path end-to-end. */
it("keeps built-in workflows read-only in the simplified view", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-simple-canvas")).toBeInTheDocument();
expect(screen.getByTestId("wf-readonly-banner")).toBeInTheDocument();
expect(screen.queryByTestId("wf-simple-toolbar-add-step")).not.toBeInTheDocument();
expect(screen.queryByTestId("wf-simple-add-step")).not.toBeInTheDocument();
expect(document.querySelector('[data-testid^="wf-simple-insert-"]')).toBeNull();
});
it("defaults the mobile graph tab to the simplified canvas with a list fallback", async () => {
mockWorkflowEditorViewport("mobile");
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
await screen.findByTestId("wf-mobile-shell");
expect(await screen.findByTestId("wf-mobile-simple-canvas")).toBeInTheDocument();
expect(screen.getByTestId("wf-mobile-graph-style-canvas")).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByTestId("mobile-wf-graph")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("wf-mobile-graph-style-list"));
expect(await screen.findByTestId("mobile-wf-graph")).toBeInTheDocument();
expect(screen.queryByTestId("wf-mobile-simple-canvas")).not.toBeInTheDocument();
expect(localStorage.getItem("fusion:wf-mobile-graph-style")).toBe("list");
});
});

View File

@@ -0,0 +1,205 @@
import { describe, it, expect } from "vitest";
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
import {
simpleVerticalLayout,
edgeSupportsSimpleInsert,
insertNodeOnEdge,
findAppendEdgeId,
SIMPLE_NODE_WIDTH,
} from "../workflow-simple-layout";
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
Unit coverage for the simplified view's derived vertical layout and its
insert-on-edge rewiring — the two pure invariants the simple canvas depends
on: (1) display layout never mutates input nodes, (2) inserting on an edge
preserves the inbound routing condition and the source node's column band y.
*/
type N = FlowNode<WorkflowFlowNodeData>;
function node(id: string, kind: WorkflowFlowNodeData["kind"], x = 0, y = 0, extra: Partial<N> = {}): N {
return {
id,
type: kind,
position: { x, y },
data: { kind, label: id, config: {} },
...extra,
};
}
function edge(id: string, source: string, target: string, condition = "success", kind?: string): FlowEdge {
return { id, source, target, data: { condition, kind } };
}
const linearNodes = (): N[] => [
node("start", "start", 0, 0),
node("a", "prompt", 300, 0),
node("b", "script", 600, 0),
node("end", "end", 900, 0),
];
const linearEdges = (): FlowEdge[] => [
edge("e1", "start", "a"),
edge("e2", "a", "b"),
edge("e3", "b", "end"),
];
describe("simpleVerticalLayout", () => {
it("stacks a linear graph top-to-bottom in topology order", () => {
const positions = simpleVerticalLayout(linearNodes(), linearEdges());
const y = (id: string) => positions.get(id)!.y;
expect(y("start")).toBeLessThan(y("a"));
expect(y("a")).toBeLessThan(y("b"));
expect(y("b")).toBeLessThan(y("end"));
});
it("places same-layer branch siblings side by side, ordered by canvas x", () => {
const nodes = [
node("start", "start"),
node("split", "split", 200, 0),
node("right", "prompt", 700, 0),
node("left", "prompt", 400, 0),
node("end", "end", 900, 0),
];
const edges = [
edge("e1", "start", "split"),
edge("e2", "split", "left"),
edge("e3", "split", "right"),
edge("e4", "left", "end"),
edge("e5", "right", "end"),
];
const positions = simpleVerticalLayout(nodes, edges);
expect(positions.get("left")!.y).toBe(positions.get("right")!.y);
// "left" has the smaller advanced-canvas x, so it stays the left sibling.
expect(positions.get("left")!.x).toBeLessThan(positions.get("right")!.x);
expect(positions.get("right")!.x - positions.get("left")!.x).toBeGreaterThanOrEqual(SIMPLE_NODE_WIDTH);
});
it("skips container template children and column band nodes", () => {
const nodes = [
node("start", "start"),
node("group", "foreach", 300, 0, { style: { width: 560, height: 220 } }),
node("child", "prompt", 30, 56, { parentId: "group" }),
node("__col__:col-1", "start", 0, 0),
node("end", "end", 600, 0),
];
const edges = [edge("e1", "start", "group"), edge("e2", "group", "end")];
const positions = simpleVerticalLayout(nodes, edges);
expect(positions.has("child")).toBe(false);
expect(positions.has("__col__:col-1")).toBe(false);
expect(positions.has("group")).toBe(true);
});
it("does not mutate the input nodes (display-only layout)", () => {
const nodes = linearNodes();
const before = nodes.map((n) => ({ ...n.position }));
simpleVerticalLayout(nodes, linearEdges());
expect(nodes.map((n) => ({ ...n.position }))).toEqual(before);
});
});
describe("edgeSupportsSimpleInsert", () => {
it("accepts plain forward edges and rejects rework/visual-only edges", () => {
expect(edgeSupportsSimpleInsert(edge("e", "a", "b"))).toBe(true);
expect(edgeSupportsSimpleInsert(edge("e", "a", "b", "failure"))).toBe(true);
expect(edgeSupportsSimpleInsert(edge("e", "a", "b", "success", "rework"))).toBe(false);
expect(
edgeSupportsSimpleInsert({
id: "e",
source: "a",
target: "b",
data: { condition: "entry", visualOnly: "template-boundary" },
}),
).toBe(false);
});
});
describe("insertNodeOnEdge", () => {
it("rewires source→new→target preserving the inbound condition", () => {
const nodes = linearNodes();
const edges = [edge("e1", "start", "a"), edge("e2", "a", "b", "failure"), edge("e3", "b", "end")];
const result = insertNodeOnEdge(nodes, edges, "e2", { kind: "script", label: "Lint" });
expect(result).not.toBeNull();
const { nodes: nextNodes, edges: nextEdges, newNodeId } = result!;
expect(nextNodes.some((n) => n.id === newNodeId && n.data.kind === "script")).toBe(true);
expect(nextEdges.find((e) => e.id === "e2")).toBeUndefined();
const inbound = nextEdges.find((e) => e.source === "a" && e.target === newNodeId);
const outbound = nextEdges.find((e) => e.source === newNodeId && e.target === "b");
expect(inbound?.data?.condition).toBe("failure");
expect(outbound?.data?.condition).toBe("success");
});
it("places the new node at the source's y so its column band is preserved", () => {
const nodes = [
node("start", "start", 0, 120),
node("a", "prompt", 300, 120),
node("end", "end", 900, 480),
];
const edges = [edge("e1", "start", "a"), edge("e2", "a", "end")];
const result = insertNodeOnEdge(nodes, edges, "e2", { kind: "prompt", label: "Review" });
const inserted = result!.nodes.find((n) => n.id === result!.newNodeId)!;
expect(inserted.position.y).toBe(120 + 8);
});
it("seeds a template child when inserting a container kind", () => {
const result = insertNodeOnEdge(linearNodes(), linearEdges(), "e2", {
kind: "loop",
label: "Retry loop",
presetConfig: { maxIterations: 3 },
containerChildLabel: "Loop step",
});
const group = result!.nodes.find((n) => n.id === result!.newNodeId)!;
expect(group.data.kind).toBe("loop");
const child = result!.nodes.find((n) => n.parentId === group.id);
expect(child).toBeDefined();
expect(child!.data.label).toBe("Loop step");
});
it("inserts a sibling child (same parentId) on a template-internal edge", () => {
const nodes = [
...linearNodes(),
node("group", "foreach", 300, 300, { style: { width: 560, height: 220 } }),
node("c1", "prompt", 30, 56, { parentId: "group" }),
node("c2", "prompt", 300, 56, { parentId: "group" }),
];
const edges = [...linearEdges(), edge("t1", "c1", "c2")];
const result = insertNodeOnEdge(nodes, edges, "t1", { kind: "prompt", label: "Middle" });
const inserted = result!.nodes.find((n) => n.id === result!.newNodeId)!;
expect(inserted.parentId).toBe("group");
});
it("rejects container kinds on template-internal edges (no nesting)", () => {
const nodes = [
...linearNodes(),
node("group", "foreach", 300, 300, { style: { width: 560, height: 220 } }),
node("c1", "prompt", 30, 56, { parentId: "group" }),
node("c2", "prompt", 300, 56, { parentId: "group" }),
];
const edges = [...linearEdges(), edge("t1", "c1", "c2")];
expect(insertNodeOnEdge(nodes, edges, "t1", { kind: "loop", label: "Loop" })).toBeNull();
});
it("returns null for rework edges and unknown edge ids", () => {
const edges = [edge("r1", "b", "a", "failure", "rework")];
expect(insertNodeOnEdge(linearNodes(), edges, "r1", { kind: "prompt", label: "X" })).toBeNull();
expect(insertNodeOnEdge(linearNodes(), edges, "missing", { kind: "prompt", label: "X" })).toBeNull();
});
});
describe("findAppendEdgeId", () => {
it("returns the single edge into end", () => {
expect(findAppendEdgeId(linearNodes(), linearEdges())).toBe("e3");
});
it("returns null when multiple edges enter end (ambiguous)", () => {
const edges = [...linearEdges(), edge("e4", "a", "end", "failure")];
expect(findAppendEdgeId(linearNodes(), edges)).toBeNull();
});
it("returns null when the graph has no end target", () => {
const nodes = [node("start", "start"), node("a", "prompt")];
expect(findAppendEdgeId(nodes, [edge("e1", "start", "a")])).toBeNull();
});
});

View File

@@ -65,8 +65,13 @@ function layoutNodeWidth(node: LayoutNode): number {
* Cycle-safe: a per-node depth cap plus a visited guard bounds the relaxation so
* non-rework cycles (should not occur, but be defensive) cannot loop forever.
* Nodes unreachable from start land in a trailing layer (max + 1).
*
* FNXC:WorkflowSimpleView 2026-07-10-12:00:
* Exported so the simplified graph view's vertical display layout
* (workflow-simple-layout.ts) shares the exact same layering semantics as the
* canvas auto-layout instead of re-deriving a drifting copy.
*/
function layerNodes(nodeIds: string[], edges: FlowEdge[]): Map<string, number> {
export function layerNodes(nodeIds: string[], edges: FlowEdge[]): Map<string, number> {
const idSet = new Set(nodeIds);
// Adjacency over non-rework edges whose endpoints are both layoutable.
const adj = new Map<string, string[]>();

View File

@@ -0,0 +1,284 @@
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { layerNodes } from "./workflow-auto-layout";
import {
isColumnBandNode,
isVisualOnlyWorkflowEdge,
foreachChildFlowId,
newNodeId,
refreshTemplateContainerVisualBoundaries,
shortConditionLabel,
edgeClassName,
WF_EDGE_INTERACTION_WIDTH,
FOREACH_GROUP_WIDTH,
FOREACH_GROUP_HEIGHT,
FOREACH_CHILD_X,
FOREACH_CHILD_Y,
} from "./workflow-flow-mapping";
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
The simplified workflow view renders the SAME node/edge state as the advanced
canvas but with a derived, display-only vertical layout: users of the simple
view never drag nodes, so readable structure must come from topology alone.
Positions computed here are NOT written back into editor state — the persisted
WorkflowDefinition.layout (advanced-canvas manual positions) stays untouched
when someone merely views a workflow in simple mode, so toggling views never
dirties a workflow.
FNXC:WorkflowSimpleView 2026-07-10-12:00:
Node insertion in the simple view happens on edges ("+" between two steps)
because the simple canvas has no drag-to-connect and no free placement. The
insert helper rewires source→new→target while PRESERVING the original edge's
condition on the inbound edge (so outcome/failure routing authored upstream
survives) and creating a default success edge out of the new node. The new
node's REAL (advanced-canvas) position is placed at the source node's y so a
v2 column-banded workflow keeps the node inside the source's column band and
the save-time unplaced-node gate does not fire for simple-view authors who
cannot drag nodes into bands.
*/
type LayoutNode = FlowNode<WorkflowFlowNodeData>;
/** Card footprint of a simple-view step node (kept in sync with
* WorkflowSimpleCanvas.css `.wf-simple-node` sizing). */
export const SIMPLE_NODE_WIDTH = 260;
export const SIMPLE_NODE_HEIGHT = 84;
/** Vertical gap between layers — roomy enough for the edge "+" affordance. */
export const SIMPLE_LAYER_GAP_Y = 72;
/** Horizontal gap between siblings within one layer. */
export const SIMPLE_SIBLING_GAP_X = 48;
function isSimpleLayoutable(node: LayoutNode): boolean {
if (isColumnBandNode(node.id)) return false;
if (node.parentId) return false;
return true;
}
/** Estimated rendered width of the start/end terminal pills (they render as
* compact pills, not full cards — see .wf-simple-terminal). Keeping the
* layout estimate close to the real footprint keeps pills centered over
* their neighbors so vertical edges run straight. */
export const SIMPLE_TERMINAL_WIDTH = 110;
function nodeWidth(node: LayoutNode): number {
const width = node.style?.width;
if (typeof width === "number") return width;
if (node.data.kind === "start" || node.data.kind === "end") return SIMPLE_TERMINAL_WIDTH;
return SIMPLE_NODE_WIDTH;
}
function nodeHeight(node: LayoutNode): number {
const height = node.style?.height;
return typeof height === "number" ? height : SIMPLE_NODE_HEIGHT;
}
export type SimpleLayoutPositions = Map<string, { x: number; y: number }>;
/**
* Compute display positions for a top-to-bottom simplified rendering of the
* graph. Layering reuses the canvas auto-layout's longest-path algorithm;
* within a layer siblings are centered horizontally around x=0, ordered by
* their advanced-canvas x (then id) so the simple view's branch order stays
* stable and familiar. Container groups (foreach/loop/optional-group) keep
* their own width/height and their template children keep parent-relative
* positions (untouched here — children are excluded like auto-layout does).
*/
export function simpleVerticalLayout(nodes: LayoutNode[], edges: FlowEdge[]): SimpleLayoutPositions {
const layoutables = nodes.filter(isSimpleLayoutable);
const ids = layoutables.map((n) => n.id);
const byId = new Map(layoutables.map((n) => [n.id, n]));
const layer = layerNodes(ids, edges);
const layers = new Map<number, string[]>();
for (const id of ids) {
const l = layer.get(id) ?? 0;
const arr = layers.get(l);
if (arr) arr.push(id);
else layers.set(l, [id]);
}
const positions: SimpleLayoutPositions = new Map();
const sortedLayerIndexes = [...layers.keys()].sort((a, b) => a - b);
let y = 0;
for (const layerIndex of sortedLayerIndexes) {
const layerIds = [...(layers.get(layerIndex) ?? [])].sort((a, b) => {
const na = byId.get(a)!;
const nb = byId.get(b)!;
if (na.position.x !== nb.position.x) return na.position.x - nb.position.x;
return a < b ? -1 : a > b ? 1 : 0;
});
const totalWidth =
layerIds.reduce((sum, id) => sum + nodeWidth(byId.get(id)!), 0) +
SIMPLE_SIBLING_GAP_X * Math.max(0, layerIds.length - 1);
let x = -totalWidth / 2;
let layerHeight = 0;
for (const id of layerIds) {
const node = byId.get(id)!;
positions.set(id, { x, y });
x += nodeWidth(node) + SIMPLE_SIBLING_GAP_X;
layerHeight = Math.max(layerHeight, nodeHeight(node));
}
y += layerHeight + SIMPLE_LAYER_GAP_Y;
}
return positions;
}
/** True when the simple view should offer a "+" insert affordance on this
* edge: a real (non-chrome) forward edge between existing nodes. Rework
* edges are excluded — inserting "between" a rework loop-back has no clear
* semantics and is an advanced-canvas job. */
export function edgeSupportsSimpleInsert(edge: FlowEdge): boolean {
if (isVisualOnlyWorkflowEdge(edge)) return false;
if ((edge.data?.kind as string | undefined) === "rework") return false;
return true;
}
export interface SimpleInsertSpec {
kind: WorkflowEditorNodeKind;
label: string;
presetConfig?: Record<string, unknown>;
/** Localized label for the seeded template child of container kinds. */
containerChildLabel?: string;
}
export interface SimpleInsertResult {
nodes: LayoutNode[];
edges: FlowEdge[];
newNodeId: string;
}
const CONTAINER_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set([
"foreach",
"loop",
"optional-group",
]);
function uniqueEdgeId(): string {
// newNodeId() is already globally unique per session; prefix keeps edge ids
// visually distinct from node ids in devtools/tests.
return `e-${newNodeId()}`;
}
/**
* Insert a new node "on" an existing edge: source→target becomes
* source→new→target. Returns null when the edge cannot host an insert
* (chrome/rework edge, missing endpoints, or a container kind dropped inside
* another container). The caller owns read-only gating (built-ins).
*/
export function insertNodeOnEdge(
nodes: LayoutNode[],
edges: FlowEdge[],
edgeId: string,
spec: SimpleInsertSpec,
): SimpleInsertResult | null {
const edge = edges.find((e) => e.id === edgeId);
if (!edge || !edgeSupportsSimpleInsert(edge)) return null;
const source = nodes.find((n) => n.id === edge.source);
const target = nodes.find((n) => n.id === edge.target);
if (!source || !target) return null;
// Template-child edge (both endpoints inside the same container): insert a
// sibling child. Containers cannot nest.
const insideContainer = !!source.parentId && source.parentId === target.parentId;
if (CONTAINER_KINDS.has(spec.kind) && insideContainer) return null;
const id = newNodeId();
const baseConfig: Record<string, unknown> = spec.kind === "gate" ? { gateMode: "gate" } : {};
const config = spec.presetConfig ? { ...baseConfig, ...spec.presetConfig } : baseConfig;
// FNXC:WorkflowSimpleView 2026-07-10-12:00:
// Real position: midpoint x (staggered so repeat inserts don't stack), but
// the SOURCE node's y — same column band as the source in v2 workflows, so
// the node is never born "unplaced" for authors who can't drag it.
const position = insideContainer
? {
x: (source.position.x + target.position.x) / 2 + 12,
y: (source.position.y + target.position.y) / 2 + 12,
}
: {
x: (source.position.x + target.position.x) / 2 + 24,
y: source.position.y + 8,
};
const newNodes: LayoutNode[] = [];
if (CONTAINER_KINDS.has(spec.kind)) {
// Mirror the palette addNode container seeding: group + one seeded child.
const childId = foreachChildFlowId(id, newNodeId());
const childConfig = spec.kind === "foreach" ? { seam: "step-execute" } : { prompt: "" };
newNodes.push(
{
id,
type: spec.kind,
position,
data: { kind: spec.kind, label: spec.label, config, templateEmpty: false },
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
deletable: true,
},
{
id: childId,
type: "prompt",
position: { x: FOREACH_CHILD_X, y: FOREACH_CHILD_Y },
parentId: id,
extent: "parent",
data: {
kind: "prompt",
label: spec.containerChildLabel ?? "Step",
config: childConfig,
},
deletable: true,
},
);
} else {
newNodes.push({
id,
type: spec.kind,
position,
...(insideContainer ? { parentId: source.parentId, extent: "parent" as const } : {}),
data: { kind: spec.kind, label: spec.label, config },
deletable: true,
});
}
const inboundCondition = (edge.data?.condition as string | undefined) ?? "success";
const inbound: FlowEdge = {
id: uniqueEdgeId(),
source: edge.source,
target: id,
label: shortConditionLabel(inboundCondition),
data: { condition: inboundCondition, kind: undefined },
className: edgeClassName(inboundCondition, false),
interactionWidth: WF_EDGE_INTERACTION_WIDTH,
};
const outbound: FlowEdge = {
id: uniqueEdgeId(),
source: id,
target: edge.target,
label: shortConditionLabel("success"),
data: { condition: "success", kind: undefined },
className: edgeClassName("success", false),
interactionWidth: WF_EDGE_INTERACTION_WIDTH,
};
const nextNodes = [...nodes, ...newNodes];
const nextEdges = [...edges.filter((e) => e.id !== edgeId), inbound, outbound];
const refreshed = refreshTemplateContainerVisualBoundaries(nextNodes, nextEdges);
return { nodes: refreshed.nodes, edges: refreshed.edges, newNodeId: id };
}
/**
* The simple view's "+ Add step" (no specific edge): insert before the `end`
* node when a single wiring point is unambiguous, otherwise signal the caller
* to fall back to a free-floating addNode. Returns the edge id to insert on,
* or null when no suitable edge exists.
*/
export function findAppendEdgeId(nodes: LayoutNode[], edges: FlowEdge[]): string | null {
const intoEnd = edges.filter(
(e) => e.target === "end" && edgeSupportsSimpleInsert(e) && nodes.some((n) => n.id === e.source),
);
if (intoEnd.length === 1) return intoEnd[0].id;
return null;
}

View File

@@ -8644,12 +8644,22 @@
"widgetDefault": "Default"
},
"workflowNodes": {
"addStepAgentSteps": "Agent steps",
"addStepAutomation": "Automation",
"addStepFlowControl": "Flow control",
"addStepFragmentDesc": "Workflow fragment",
"addStepNoMatches": "No steps match “{{query}}”.",
"addStepSearchLabel": "Search steps",
"addStepSearchPlaceholder": "Search steps and templates…",
"addStepTitle": "Add a step",
"advisory": "Advisory",
"autoLayout": "Auto-layout",
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
"codeSource": "Source (TypeScript)",
"codeTimeout": "Timeout (ms)",
"collapseInspector": "Collapse",
"conditionFailure": "failure",
"conditionSuccess": "success",
"cycleBlocked": "That connection would create a cycle — only rework edges inside a for-each template may loop back",
"deleteEdge": "Delete edge",
"deleteNode": "Delete node",
@@ -8702,9 +8712,13 @@
"mobileConnectTarget": "Target node",
"mobileGraph": "Graph",
"mobileGraphEmpty": "No graph nodes yet.",
"mobileGraphStyleCanvas": "Graph",
"mobileGraphStyleLabel": "Graph presentation",
"mobileGraphStyleList": "List",
"mobileMoveDown": "Move down",
"mobileMoveUp": "Move up",
"mobileNodeKinds": "Node types",
"nodeInspector": "Node",
"notifyCustom": "Custom",
"notifyCustomEvent": "Custom event",
"notifyEvent": "Event type",
@@ -8714,6 +8728,7 @@
"parseArtifact": "Artifact",
"parseParser": "Parser",
"quorumN": "Quorum count (n)",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
"releaseCapacity": "Downstream capacity",
"releaseCondition": "Release condition",
"releaseDependency": "Dependency complete",
@@ -8726,6 +8741,9 @@
"reviewPlan": "Plan review",
"reviewType": "Review type",
"showInspector": "Show node details",
"simpleAddStep": "Add step",
"simpleContainerEmpty": "No steps inside yet",
"simpleInsertStep": "Insert step here",
"splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
"startEntryColumn": "Entry column",
"startEntryColumnAuto": "— Auto (first column)",
@@ -8740,11 +8758,7 @@
"templatesPluginSteps": "Plugin steps",
"templatesSection": "Templates",
"timeoutMs": "{{timeout}}ms",
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out.",
"conditionFailure": "failure",
"conditionSuccess": "success",
"nodeInspector": "Node",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out."
},
"workflows": {
"aiEdit": "Design with AI",
@@ -8760,6 +8774,7 @@
"backToWorkflowList": "Back to workflows",
"clickToEditDescription": "Click to edit description",
"clickToRename": "Click to rename",
"closeEditor": "Close workflow editor",
"created": "Created workflow \"{{name}}\"",
"createDescription": "Description (optional)",
"createFailed": "Failed to create workflow",
@@ -8776,6 +8791,8 @@
"discardConfirm": "Discard",
"discardMessage": "You have unsaved changes to this workflow. Discard them?",
"discardTitle": "Discard unsaved changes?",
"duplicatedEditable": "Duplicated to \"{{name}}\" — editable",
"duplicateFailed": "Failed to duplicate workflow",
"duplicateToCustomize": "Duplicate to customize",
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
"emptyTitle": "No workflow selected",
@@ -8789,17 +8806,18 @@
"importInvalidJson": "That file isn't valid JSON.",
"importStripped": "Auto-approval flags were removed from imported nodes",
"importTooltip": "Import a workflow from a JSON file",
"loadFailed": "Failed to load workflows",
"loading": "Loading…",
"migrationNotice": "Your legacy workflow steps were converted — find them as templates in the palette and as the \"Migrated steps\" workflow.",
"mobileEditorNav": "Workflow editor sections",
"mobileSelectNote": "Select a workflow to edit.",
"nameLabel": "Workflow name",
"newWorkflow": "New workflow",
"noneYet": "No workflows yet.",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "Workflow saved",
"savedNotCompilable": "Workflow saved but cannot be compiled",
"saveFailed": "Failed to save workflow",
"showCanvasEditor": "Show canvas editor",
"showSimpleEditor": "Show simple editor",
"templateBlank": "Blank",
"templateBlankDescription": "Start from an empty start → end graph.",
"templateCopyName": "{{name}} copy",
@@ -8808,13 +8826,11 @@
"templatePickerLabel": "Start from",
"templateSectionBuiltin": "Built-in workflows",
"templateSectionYours": "Your workflows",
"closeEditor": "Close workflow editor",
"duplicatedEditable": "Duplicated to \"{{name}}\" — editable",
"duplicateFailed": "Failed to duplicate workflow",
"loadFailed": "Failed to load workflows",
"loading": "Loading…",
"noneYet": "No workflows yet.",
"title": "Workflows"
"title": "Workflows",
"viewModeAdvanced": "Advanced",
"viewModeLabel": "Editor view",
"viewModeList": "List",
"viewModeSimple": "Simple"
},
"workflowSelector": {
"switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?",

View File

@@ -8633,12 +8633,22 @@
"widgetDefault": "Predeterminado"
},
"workflowNodes": {
"addStepAgentSteps": "",
"addStepAutomation": "",
"addStepFlowControl": "",
"addStepFragmentDesc": "",
"addStepNoMatches": "",
"addStepSearchLabel": "",
"addStepSearchPlaceholder": "",
"addStepTitle": "",
"advisory": "",
"autoLayout": "Diseño automático",
"codeNote": "Ejecuta TypeScript en un entorno aislado. La sintaxis se valida al guardar.",
"codeSource": "Origen (TypeScript)",
"codeTimeout": "Tiempo de espera (ms)",
"collapseInspector": "",
"conditionFailure": "",
"conditionSuccess": "",
"cycleBlocked": "Esa conexión crearía un ciclo — solo las conexiones de revisión dentro de una plantilla for-each pueden hacer bucles de retorno",
"deleteEdge": "Eliminar conexión",
"deleteNode": "Eliminar nodo",
@@ -8691,9 +8701,13 @@
"mobileConnectTarget": "Nodo de destino",
"mobileGraph": "",
"mobileGraphEmpty": "",
"mobileGraphStyleCanvas": "",
"mobileGraphStyleLabel": "",
"mobileGraphStyleList": "",
"mobileMoveDown": "",
"mobileMoveUp": "",
"mobileNodeKinds": "",
"nodeInspector": "",
"notifyCustom": "",
"notifyCustomEvent": "",
"notifyEvent": "",
@@ -8703,6 +8717,7 @@
"parseArtifact": "Artifact",
"parseParser": "Parser",
"quorumN": "Recuento de quórum (n)",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
"releaseCapacity": "Capacidad posterior",
"releaseCondition": "Condición de liberación",
"releaseDependency": "Dependencia completada",
@@ -8715,6 +8730,9 @@
"reviewPlan": "Plan review",
"reviewType": "Review type",
"showInspector": "",
"simpleAddStep": "",
"simpleContainerEmpty": "",
"simpleInsertStep": "",
"splitNote": "Las ramas se ejecutan de forma concurrente desde este nodo. No se permiten uniones de ejecución y fusión dentro de una rama.",
"startEntryColumn": "",
"startEntryColumnAuto": "",
@@ -8729,11 +8747,7 @@
"templatesPluginSteps": "Pasos de plugin",
"templatesSection": "Plantillas",
"timeoutMs": "",
"trivialGraphHint": "Este flujo de trabajo solo ejecuta inicio → fin. Añade pasos desde la paleta superior para construirlo.",
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
"trivialGraphHint": "Este flujo de trabajo solo ejecuta inicio → fin. Añade pasos desde la paleta superior para construirlo."
},
"workflows": {
"aiEdit": "Diseñar con IA",
@@ -8749,6 +8763,7 @@
"backToWorkflowList": "",
"clickToEditDescription": "Haz clic para editar la descripción",
"clickToRename": "Haz clic para renombrar",
"closeEditor": "",
"created": "Flujo de trabajo «{{name}}» creado",
"createDescription": "Descripción (opcional)",
"createFailed": "Error al crear el flujo de trabajo",
@@ -8765,6 +8780,8 @@
"discardConfirm": "Descartar",
"discardMessage": "Tienes cambios no guardados en este flujo de trabajo. ¿Descartarlos?",
"discardTitle": "¿Descartar cambios no guardados?",
"duplicatedEditable": "",
"duplicateFailed": "",
"duplicateToCustomize": "",
"emptyDescription": "Los flujos de trabajo orquestan los pasos y compuertas que se ejecutan alrededor de la ejecución de tareas. Crea uno para comenzar a organizar ese flujo.",
"emptyTitle": "Ningún flujo de trabajo seleccionado",
@@ -8778,17 +8795,18 @@
"importInvalidJson": "Ese archivo no es JSON válido.",
"importStripped": "Se eliminaron los indicadores de aprobación automática de los nodos importados",
"importTooltip": "Importar un flujo de trabajo desde un archivo JSON",
"loadFailed": "",
"loading": "",
"migrationNotice": "Los pasos del flujo de trabajo heredado fueron convertidos — encuéntralos como plantillas en la paleta y como el flujo de trabajo «Pasos migrados».",
"mobileEditorNav": "",
"mobileSelectNote": "",
"nameLabel": "Nombre del flujo de trabajo",
"newWorkflow": "Nuevo flujo de trabajo",
"noneYet": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",
"showCanvasEditor": "",
"showSimpleEditor": "",
"templateBlank": "En blanco",
"templateBlankDescription": "Comenzar desde un grafo vacío inicio → fin.",
"templateCopyName": "Copia de {{name}}",
@@ -8797,13 +8815,11 @@
"templatePickerLabel": "Comenzar desde",
"templateSectionBuiltin": "Flujos de trabajo integrados",
"templateSectionYours": "Tus flujos de trabajo",
"closeEditor": "",
"duplicatedEditable": "",
"duplicateFailed": "",
"loadFailed": "",
"loading": "",
"noneYet": "",
"title": ""
"title": "",
"viewModeAdvanced": "",
"viewModeLabel": "",
"viewModeList": "",
"viewModeSimple": ""
},
"workflowSelector": {
"switchActiveMessage": "",

View File

@@ -8634,12 +8634,22 @@
"widgetDefault": "Par défaut"
},
"workflowNodes": {
"addStepAgentSteps": "",
"addStepAutomation": "",
"addStepFlowControl": "",
"addStepFragmentDesc": "",
"addStepNoMatches": "",
"addStepSearchLabel": "",
"addStepSearchPlaceholder": "",
"addStepTitle": "",
"advisory": "",
"autoLayout": "Disposition automatique",
"codeNote": "Exécute du TypeScript en bac à sable. La syntaxe est validée à l’enregistrement.",
"codeSource": "Source (TypeScript)",
"codeTimeout": "Délai d’expiration (ms)",
"collapseInspector": "",
"conditionFailure": "",
"conditionSuccess": "",
"cycleBlocked": "Cette connexion créerait un cycle — seules les arêtes de reprise à l'intérieur d'un modèle for-each peuvent boucler en arrière",
"deleteEdge": "Supprimer l'arête",
"deleteNode": "Supprimer le nœud",
@@ -8692,9 +8702,13 @@
"mobileConnectTarget": "Nœud cible",
"mobileGraph": "",
"mobileGraphEmpty": "",
"mobileGraphStyleCanvas": "",
"mobileGraphStyleLabel": "",
"mobileGraphStyleList": "",
"mobileMoveDown": "",
"mobileMoveUp": "",
"mobileNodeKinds": "",
"nodeInspector": "",
"notifyCustom": "",
"notifyCustomEvent": "",
"notifyEvent": "",
@@ -8704,6 +8718,7 @@
"parseArtifact": "Artifact",
"parseParser": "Parser",
"quorumN": "Nombre de quorum (n)",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
"releaseCapacity": "Capacité en aval",
"releaseCondition": "Condition de libération",
"releaseDependency": "Dépendance terminée",
@@ -8716,6 +8731,9 @@
"reviewPlan": "Plan review",
"reviewType": "Review type",
"showInspector": "",
"simpleAddStep": "",
"simpleContainerEmpty": "",
"simpleInsertStep": "",
"splitNote": "Les branches s’exécutent simultanément depuis ce nœud. Les jointures d’exécution et de fusion ne sont pas autorisées dans une branche.",
"startEntryColumn": "",
"startEntryColumnAuto": "",
@@ -8730,11 +8748,7 @@
"templatesPluginSteps": "Étapes de plugin",
"templatesSection": "Modèles",
"timeoutMs": "",
"trivialGraphHint": "Ce workflow ne fait qu'exécuter début → fin. Ajoutez des étapes depuis la palette ci-dessus pour le développer.",
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
"trivialGraphHint": "Ce workflow ne fait qu'exécuter début → fin. Ajoutez des étapes depuis la palette ci-dessus pour le développer."
},
"workflows": {
"aiEdit": "Concevoir avec l'IA",
@@ -8750,6 +8764,7 @@
"backToWorkflowList": "",
"clickToEditDescription": "Cliquez pour modifier la description",
"clickToRename": "Cliquez pour renommer",
"closeEditor": "",
"created": "Workflow « {{name}} » créé",
"createDescription": "Description (facultatif)",
"createFailed": "Impossible de créer le workflow",
@@ -8766,6 +8781,8 @@
"discardConfirm": "Abandonner",
"discardMessage": "Vous avez des modifications non enregistrées sur ce workflow. Les abandonner ?",
"discardTitle": "Abandonner les modifications non enregistrées ?",
"duplicatedEditable": "",
"duplicateFailed": "",
"duplicateToCustomize": "",
"emptyDescription": "Les workflows orchestrent les étapes et les portails qui s'exécutent autour de l'exécution des tâches. Créez-en un pour commencer à organiser ce flux.",
"emptyTitle": "Aucun workflow sélectionné",
@@ -8779,17 +8796,18 @@
"importInvalidJson": "Ce fichier n'est pas un JSON valide.",
"importStripped": "Les indicateurs d'approbation automatique ont été supprimés des nœuds importés",
"importTooltip": "Importer un workflow depuis un fichier JSON",
"loadFailed": "",
"loading": "",
"migrationNotice": "Vos anciennes étapes de workflow ont été converties — retrouvez-les sous forme de modèles dans la palette et sous le workflow « Étapes migrées ».",
"mobileEditorNav": "",
"mobileSelectNote": "",
"nameLabel": "Nom du workflow",
"newWorkflow": "Nouveau workflow",
"noneYet": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",
"showCanvasEditor": "",
"showSimpleEditor": "",
"templateBlank": "Vierge",
"templateBlankDescription": "Partez d'un graphe vide début → fin.",
"templateCopyName": "{{name}} (copie)",
@@ -8798,13 +8816,11 @@
"templatePickerLabel": "Partir de",
"templateSectionBuiltin": "Workflows intégrés",
"templateSectionYours": "Vos workflows",
"closeEditor": "",
"duplicatedEditable": "",
"duplicateFailed": "",
"loadFailed": "",
"loading": "",
"noneYet": "",
"title": ""
"title": "",
"viewModeAdvanced": "",
"viewModeLabel": "",
"viewModeList": "",
"viewModeSimple": ""
},
"workflowSelector": {
"switchActiveMessage": "",

View File

@@ -8633,12 +8633,22 @@
"widgetDefault": "기본값"
},
"workflowNodes": {
"addStepAgentSteps": "",
"addStepAutomation": "",
"addStepFlowControl": "",
"addStepFragmentDesc": "",
"addStepNoMatches": "",
"addStepSearchLabel": "",
"addStepSearchPlaceholder": "",
"addStepTitle": "",
"advisory": "",
"autoLayout": "자동 배치",
"codeNote": "샌드박스에서 TypeScript를 실행합니다. 구문은 저장 시 검증됩니다.",
"codeSource": "소스(TypeScript)",
"codeTimeout": "제한 시간(ms)",
"collapseInspector": "",
"conditionFailure": "",
"conditionSuccess": "",
"cycleBlocked": "이 연결은 순환을 만듭니다 — for-each 템플릿 내부의 재작업 엣지만 되돌아올 수 있습니다",
"deleteEdge": "엣지 삭제",
"deleteNode": "노드 삭제",
@@ -8691,9 +8701,13 @@
"mobileConnectTarget": "대상 노드",
"mobileGraph": "",
"mobileGraphEmpty": "",
"mobileGraphStyleCanvas": "",
"mobileGraphStyleLabel": "",
"mobileGraphStyleList": "",
"mobileMoveDown": "",
"mobileMoveUp": "",
"mobileNodeKinds": "",
"nodeInspector": "",
"notifyCustom": "",
"notifyCustomEvent": "",
"notifyEvent": "",
@@ -8703,6 +8717,7 @@
"parseArtifact": "Artifact",
"parseParser": "Parser",
"quorumN": "정족수 개수(n)",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
"releaseCapacity": "다운스트림 용량",
"releaseCondition": "릴리스 조건",
"releaseDependency": "종속성 완료",
@@ -8715,6 +8730,9 @@
"reviewPlan": "Plan review",
"reviewType": "Review type",
"showInspector": "",
"simpleAddStep": "",
"simpleContainerEmpty": "",
"simpleInsertStep": "",
"splitNote": "분기는 이 노드에서 동시에 실행됩니다. 분기 내에서는 실행 및 병합 이음새가 허용되지 않습니다.",
"startEntryColumn": "",
"startEntryColumnAuto": "",
@@ -8729,11 +8747,7 @@
"templatesPluginSteps": "플러그인 단계",
"templatesSection": "템플릿",
"timeoutMs": "",
"trivialGraphHint": "이 워크플로는 시작 → 끝만 실행합니다. 위 팔레트에서 단계를 추가하여 구성하세요.",
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
"trivialGraphHint": "이 워크플로는 시작 → 끝만 실행합니다. 위 팔레트에서 단계를 추가하여 구성하세요."
},
"workflows": {
"aiEdit": "AI로 디자인",
@@ -8749,6 +8763,7 @@
"backToWorkflowList": "",
"clickToEditDescription": "클릭하여 설명 편집",
"clickToRename": "클릭하여 이름 변경",
"closeEditor": "",
"created": "워크플로 \"{{name}}\"을(를) 만들었습니다",
"createDescription": "설명 (선택 사항)",
"createFailed": "워크플로 만들기에 실패했습니다",
@@ -8765,6 +8780,8 @@
"discardConfirm": "버리기",
"discardMessage": "이 워크플로에 저장하지 않은 변경 사항이 있습니다. 버릴까요?",
"discardTitle": "저장하지 않은 변경 사항을 버릴까요?",
"duplicatedEditable": "",
"duplicateFailed": "",
"duplicateToCustomize": "",
"emptyDescription": "워크플로는 작업 실행 전후에 실행되는 단계와 게이트를 조율합니다. 하나 만들어 그 흐름을 구성해 보세요.",
"emptyTitle": "선택된 워크플로 없음",
@@ -8778,17 +8795,18 @@
"importInvalidJson": "이 파일은 유효한 JSON이 아닙니다.",
"importStripped": "가져온 노드에서 자동 승인 플래그가 제거되었습니다",
"importTooltip": "JSON 파일에서 워크플로 가져오기",
"loadFailed": "",
"loading": "",
"migrationNotice": "이전 워크플로 단계가 변환되었습니다 — 팔레트의 템플릿과 \"마이그레이션된 단계\" 워크플로에서 찾을 수 있습니다.",
"mobileEditorNav": "",
"mobileSelectNote": "",
"nameLabel": "워크플로 이름",
"newWorkflow": "새 워크플로",
"noneYet": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",
"showCanvasEditor": "",
"showSimpleEditor": "",
"templateBlank": "빈 워크플로",
"templateBlankDescription": "빈 시작 → 끝 그래프에서 시작합니다.",
"templateCopyName": "{{name}} 복사본",
@@ -8797,13 +8815,11 @@
"templatePickerLabel": "시작점",
"templateSectionBuiltin": "기본 제공 워크플로",
"templateSectionYours": "내 워크플로",
"closeEditor": "",
"duplicatedEditable": "",
"duplicateFailed": "",
"loadFailed": "",
"loading": "",
"noneYet": "",
"title": ""
"title": "",
"viewModeAdvanced": "",
"viewModeLabel": "",
"viewModeList": "",
"viewModeSimple": ""
},
"workflowSelector": {
"switchActiveMessage": "",

View File

@@ -8633,12 +8633,22 @@
"widgetDefault": "默认"
},
"workflowNodes": {
"addStepAgentSteps": "",
"addStepAutomation": "",
"addStepFlowControl": "",
"addStepFragmentDesc": "",
"addStepNoMatches": "",
"addStepSearchLabel": "",
"addStepSearchPlaceholder": "",
"addStepTitle": "",
"advisory": "",
"autoLayout": "自动布局",
"codeNote": "在沙箱中运行 TypeScript。语法在保存时校验。",
"codeSource": "源代码(TypeScript)",
"codeTimeout": "超时(毫秒)",
"collapseInspector": "",
"conditionFailure": "",
"conditionSuccess": "",
"cycleBlocked": "此连接会产生循环——只有 for-each 模板内的返工连线才允许回环",
"deleteEdge": "删除连线",
"deleteNode": "删除节点",
@@ -8691,9 +8701,13 @@
"mobileConnectTarget": "目标节点",
"mobileGraph": "",
"mobileGraphEmpty": "",
"mobileGraphStyleCanvas": "",
"mobileGraphStyleLabel": "",
"mobileGraphStyleList": "",
"mobileMoveDown": "",
"mobileMoveUp": "",
"mobileNodeKinds": "",
"nodeInspector": "",
"notifyCustom": "",
"notifyCustomEvent": "",
"notifyEvent": "",
@@ -8703,6 +8717,7 @@
"parseArtifact": "Artifact",
"parseParser": "Parser",
"quorumN": "法定数(n)",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
"releaseCapacity": "下游容量",
"releaseCondition": "释放条件",
"releaseDependency": "依赖完成",
@@ -8715,6 +8730,9 @@
"reviewPlan": "Plan review",
"reviewType": "Review type",
"showInspector": "",
"simpleAddStep": "",
"simpleContainerEmpty": "",
"simpleInsertStep": "",
"splitNote": "分支从此节点并发运行。分支内不允许执行和合并接缝。",
"startEntryColumn": "",
"startEntryColumnAuto": "",
@@ -8729,11 +8747,7 @@
"templatesPluginSteps": "插件步骤",
"templatesSection": "模板",
"timeoutMs": "",
"trivialGraphHint": "此工作流仅运行开始→结束。从上方面板添加步骤以构建工作流。",
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
"trivialGraphHint": "此工作流仅运行开始→结束。从上方面板添加步骤以构建工作流。"
},
"workflows": {
"aiEdit": "用 AI 设计",
@@ -8749,6 +8763,7 @@
"backToWorkflowList": "",
"clickToEditDescription": "点击编辑描述",
"clickToRename": "点击重命名",
"closeEditor": "",
"created": "已创建工作流“{{name}}”",
"createDescription": "描述(可选)",
"createFailed": "创建工作流失败",
@@ -8765,6 +8780,8 @@
"discardConfirm": "放弃",
"discardMessage": "此工作流有未保存的更改,要放弃吗?",
"discardTitle": "放弃未保存的更改?",
"duplicatedEditable": "",
"duplicateFailed": "",
"duplicateToCustomize": "",
"emptyDescription": "工作流编排任务执行前后运行的步骤和关卡。创建一个以开始安排流程。",
"emptyTitle": "未选择工作流",
@@ -8778,17 +8795,18 @@
"importInvalidJson": "该文件不是有效的 JSON。",
"importStripped": "已从导入的节点中移除自动审批标志",
"importTooltip": "从 JSON 文件导入工作流",
"loadFailed": "",
"loading": "",
"migrationNotice": "您的旧版工作流步骤已转换——在面板模板和“已迁移步骤”工作流中查找。",
"mobileEditorNav": "",
"mobileSelectNote": "",
"nameLabel": "工作流名称",
"newWorkflow": "新建工作流",
"noneYet": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",
"showCanvasEditor": "",
"showSimpleEditor": "",
"templateBlank": "空白",
"templateBlankDescription": "从空的开始→结束图开始。",
"templateCopyName": "{{name}} 副本",
@@ -8797,13 +8815,11 @@
"templatePickerLabel": "从…开始",
"templateSectionBuiltin": "内置工作流",
"templateSectionYours": "我的工作流",
"closeEditor": "",
"duplicatedEditable": "",
"duplicateFailed": "",
"loadFailed": "",
"loading": "",
"noneYet": "",
"title": ""
"title": "",
"viewModeAdvanced": "",
"viewModeLabel": "",
"viewModeList": "",
"viewModeSimple": ""
},
"workflowSelector": {
"switchActiveMessage": "",

View File

@@ -8633,12 +8633,22 @@
"widgetDefault": "預設"
},
"workflowNodes": {
"addStepAgentSteps": "",
"addStepAutomation": "",
"addStepFlowControl": "",
"addStepFragmentDesc": "",
"addStepNoMatches": "",
"addStepSearchLabel": "",
"addStepSearchPlaceholder": "",
"addStepTitle": "",
"advisory": "",
"autoLayout": "自動排版",
"codeNote": "在沙箱中執行 TypeScript。語法會在儲存時驗證。",
"codeSource": "原始碼(TypeScript)",
"codeTimeout": "逾時(毫秒)",
"collapseInspector": "",
"conditionFailure": "",
"conditionSuccess": "",
"cycleBlocked": "該連線會形成循環 — 只有 for-each 範本內的重做邊才能回繞",
"deleteEdge": "刪除邊",
"deleteNode": "刪除節點",
@@ -8691,9 +8701,13 @@
"mobileConnectTarget": "目標節點",
"mobileGraph": "",
"mobileGraphEmpty": "",
"mobileGraphStyleCanvas": "",
"mobileGraphStyleLabel": "",
"mobileGraphStyleList": "",
"mobileMoveDown": "",
"mobileMoveUp": "",
"mobileNodeKinds": "",
"nodeInspector": "",
"notifyCustom": "",
"notifyCustomEvent": "",
"notifyEvent": "",
@@ -8703,6 +8717,7 @@
"parseArtifact": "Artifact",
"parseParser": "Parser",
"quorumN": "法定人數(n)",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
"releaseCapacity": "下游容量",
"releaseCondition": "釋放條件",
"releaseDependency": "相依完成",
@@ -8715,6 +8730,9 @@
"reviewPlan": "Plan review",
"reviewType": "Review type",
"showInspector": "",
"simpleAddStep": "",
"simpleContainerEmpty": "",
"simpleInsertStep": "",
"splitNote": "分支從此節點並行執行。分支內不允許執行與合併接縫。",
"startEntryColumn": "",
"startEntryColumnAuto": "",
@@ -8729,11 +8747,7 @@
"templatesPluginSteps": "外掛步驟",
"templatesSection": "範本",
"timeoutMs": "",
"trivialGraphHint": "這個工作流程只會執行 start → end。請從上方的選盤新增步驟來建構它。",
"conditionFailure": "",
"conditionSuccess": "",
"nodeInspector": "",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
"trivialGraphHint": "這個工作流程只會執行 start → end。請從上方的選盤新增步驟來建構它。"
},
"workflows": {
"aiEdit": "使用 AI 設計",
@@ -8749,6 +8763,7 @@
"backToWorkflowList": "",
"clickToEditDescription": "按一下以編輯說明",
"clickToRename": "按一下以重新命名",
"closeEditor": "",
"created": "已建立工作流程「{{name}}」",
"createDescription": "說明(選填)",
"createFailed": "建立工作流程失敗",
@@ -8765,6 +8780,8 @@
"discardConfirm": "捨棄",
"discardMessage": "你對這個工作流程有未儲存的變更。要捨棄它們嗎?",
"discardTitle": "要捨棄未儲存的變更嗎?",
"duplicatedEditable": "",
"duplicateFailed": "",
"duplicateToCustomize": "",
"emptyDescription": "工作流程會協調任務執行前後所執行的步驟與閘門。建立一個來開始安排流程。",
"emptyTitle": "未選取工作流程",
@@ -8778,17 +8795,18 @@
"importInvalidJson": "該檔案不是有效的 JSON。",
"importStripped": "已從匯入的節點移除自動核准旗標",
"importTooltip": "從 JSON 檔案匯入工作流程",
"loadFailed": "",
"loading": "",
"migrationNotice": "你的舊版工作流程步驟已轉換 — 可在選盤中以範本形式找到它們,以及名為「已遷移步驟」的工作流程。",
"mobileEditorNav": "",
"mobileSelectNote": "",
"nameLabel": "工作流程名稱",
"newWorkflow": "新工作流程",
"noneYet": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "",
"savedNotCompilable": "",
"saveFailed": "",
"showCanvasEditor": "",
"showSimpleEditor": "",
"templateBlank": "空白",
"templateBlankDescription": "從空的 start → end 圖形開始。",
"templateCopyName": "{{name}} 複本",
@@ -8797,13 +8815,11 @@
"templatePickerLabel": "起始來源",
"templateSectionBuiltin": "內建工作流程",
"templateSectionYours": "你的工作流程",
"closeEditor": "",
"duplicatedEditable": "",
"duplicateFailed": "",
"loadFailed": "",
"loading": "",
"noneYet": "",
"title": ""
"title": "",
"viewModeAdvanced": "",
"viewModeLabel": "",
"viewModeList": "",
"viewModeSimple": ""
},
"workflowSelector": {
"switchActiveMessage": "",