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", ".plugin-registry-retry:focus-visible",
], ],
"WorkflowNodeEditor.css": [ "WorkflowNodeEditor.css": [
".wf-layout-toggle:focus-visible", ".wf-view-mode-option:focus-visible",
".wf-template-option:focus-visible", ".wf-template-option:focus-visible",
".wf-ai-prompt:focus-visible", ".wf-ai-prompt:focus-visible",
".wf-mobile-tab: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; 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; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-xs); gap: var(--space-xs);
width: fit-content; min-height: 26px;
min-height: 30px; padding: var(--space-xs) 10px;
padding: var(--space-xs) var(--space-sm); border: none;
border: 1px solid var(--border); border-radius: var(--radius-pill);
border-radius: var(--radius-sm); background: transparent;
background: var(--bg-secondary);
color: var(--text-muted); color: var(--text-muted);
font: inherit; font: inherit;
font-size: 0.8rem; font-size: 0.78rem;
cursor: pointer; cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast);
} }
.wf-layout-toggle:hover { .wf-view-mode-option:hover {
background: var(--bg-tertiary);
color: var(--text); 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: 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), .wf-editor-body--simple-layout :where(.wf-editor-readonly-banner),

View File

@@ -91,6 +91,9 @@ import {
FOREACH_CHILD_Y, FOREACH_CHILD_Y,
} from "./workflow-flow-mapping"; } from "./workflow-flow-mapping";
import { autoLayout, applyAutoLayout } from "./workflow-auto-layout"; 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 { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel"; import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
@@ -107,6 +110,27 @@ import {
} from "./workflow-mobile-graph"; } from "./workflow-mobile-graph";
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; 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 // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: dropped the "optional-steps" mobile
// panel — the declaration authoring surface is retired (optional-group nodes now). // panel — the declaration authoring surface is retired (optional-group nodes now).
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions"; type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
@@ -802,11 +826,49 @@ function InnerEditor({
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null); const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [inspectorCollapsed, setInspectorCollapsed] = useState(false); const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
const [miniMapCollapsed, setMiniMapCollapsed] = 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>(() => const [mobilePanel, setMobilePanel] = useState<MobileWorkflowPanel>(() =>
initialPanel === "settings" ? "settings" : "graph", 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 { t } = useTranslation("app");
const { confirm } = useConfirm(); const { confirm } = useConfirm();
// Create-workflow dialog (KTD-7) open state + focus-return ref to the // Create-workflow dialog (KTD-7) open state + focus-return ref to the
@@ -1606,6 +1668,85 @@ function InnerEditor({
[isBuiltin, nodes, edges, activeWorkflow, setNodes, setEdges], [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 // Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions
// only; bands and foreach template children are left in place. Marks the // only; bands and foreach template children are left in place. Marks the
// editor dirty automatically via the layout serialization in isDirty. // editor dirty automatically via the layout serialization in isDirty.
@@ -2321,6 +2462,8 @@ function InnerEditor({
}), }),
[models, agents, skills], [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 mobileConnectionTargetsBySource = useMemo(() => {
const targetNodes = nodesForRender const targetNodes = nodesForRender
.filter((node) => !isColumnBandNode(node.id) && node.data.kind !== "start") .filter((node) => !isColumnBandNode(node.id) && node.data.kind !== "start")
@@ -2604,7 +2747,7 @@ function InnerEditor({
<div <div
className={`wf-editor-body${workflowListStageOpen ? " wf-editor-body--list-stage" : " wf-editor-body--editor-stage"}${ className={`wf-editor-body${workflowListStageOpen ? " wf-editor-body--list-stage" : " wf-editor-body--editor-stage"}${
simpleLayoutEnabled ? " wf-editor-body--simple-layout" : "" 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" : "" mobileEdgeDetailStage ? " wf-editor-body--mobile-edge-detail" : ""
}${sidebarCollapsed ? " wf-editor-body--sidebar-collapsed" : ""}`} }${sidebarCollapsed ? " wf-editor-body--sidebar-collapsed" : ""}`}
> >
@@ -2700,7 +2843,10 @@ function InnerEditor({
workflow is active (read-only gating preserved via isBuiltin). The workflow is active (read-only gating preserved via isBuiltin). The
disclosure button serves as the section header; the panels' own disclosure button serves as the section header; the panels' own
internal <h3> is suppressed via CSS to avoid a double header. */} 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"> <div className="wf-sidebar-panels">
<section className="wf-sidebar-section" data-testid="wf-sidebar-columns-section"> <section className="wf-sidebar-section" data-testid="wf-sidebar-columns-section">
<button <button
@@ -2899,20 +3045,35 @@ function InnerEditor({
</button> </button>
)} )}
{!isMobileMode && ( {!isMobileMode && (
<button /* FNXC:WorkflowSimpleView 2026-07-10-12:00: segmented three-mode
type="button" switch (Simple / Advanced / List) replacing the old boolean
className="wf-layout-toggle" simple-editor toggle; List is the old simple editor. */
data-testid="wf-layout-toggle" <div
aria-pressed={compactLayoutEnabled} className="wf-view-mode-toggle"
onClick={() => setCompactLayoutEnabled((enabled) => !enabled)} role="group"
data-testid="wf-view-mode-toggle"
aria-label={t("workflows.viewModeLabel", "Editor view")}
> >
{compactLayoutEnabled ? <LayoutGrid size={14} /> : <ListChecks size={14} />} {(
<span> [
{compactLayoutEnabled ["simple", Workflow, t("workflows.viewModeSimple", "Simple")],
? t("workflows.showCanvasEditor", "Show canvas editor") ["advanced", LayoutGrid, t("workflows.viewModeAdvanced", "Advanced")],
: t("workflows.showSimpleEditor", "Show simple editor")} ["list", ListChecks, t("workflows.viewModeList", "List")],
</span> ] as Array<[WorkflowEditorViewMode, typeof Workflow, string]>
</button> ).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> </div>
{lifecycleWarnings.length > 0 && ( {lifecycleWarnings.length > 0 && (
@@ -2958,23 +3119,89 @@ function InnerEditor({
<div className="wf-mobile-panel" data-testid={`wf-mobile-panel-${mobilePanel}`}> <div className="wf-mobile-panel" data-testid={`wf-mobile-panel-${mobilePanel}`}>
{mobilePanel === "graph" && ( {mobilePanel === "graph" && (
<MobileWorkflowGraphView <>
rows={mobileGraphRows} {/* FNXC:WorkflowSimpleView 2026-07-10-12:00: mobile
selectedNodeId={selectedNodeId} graph tab offers the simplified touch canvas
selectedEdgeId={selectedEdgeId} (default) with the row list kept as the even
onSelectNode={(id) => { simpler fallback. Desktop "list" mode reuses
setSelectedNodeId(id); this shell and keeps the row list only. */}
setSelectedEdgeId(null); {isMobileMode && (
setInspectorCollapsed(false); <div
}} className="wf-mobile-graph-style-toggle"
onSelectEdge={(id) => { role="group"
setSelectedEdgeId(id); data-testid="wf-mobile-graph-style-toggle"
setSelectedNodeId(null); aria-label={t("workflowNodes.mobileGraphStyleLabel", "Graph presentation")}
}} >
onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection} {(
canReorder={!isBuiltin} [
onMoveNode={onMoveSimpleNode} ["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" && ( {mobilePanel === "add" && (
@@ -3256,6 +3483,96 @@ function InnerEditor({
<Plus size={13} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")} <Plus size={13} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
</button> </button>
</div> </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-toolbar">
<div className="wf-editor-palette"> <div className="wf-editor-palette">
@@ -3375,7 +3692,7 @@ function InnerEditor({
</div> </div>
)} )}
{hasAnyTemplate && ( {hasAnyTemplate && !simpleViewEnabled && (
<section <section
className="wf-templates" className="wf-templates"
data-testid="wf-palette-templates" data-testid="wf-palette-templates"
@@ -3557,6 +3874,43 @@ function InnerEditor({
</div> </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}> <div className="wf-editor-canvas" ref={canvasRef} tabIndex={-1}>
{isMobileMode && {isMobileMode &&
inspectorCollapsed && inspectorCollapsed &&
@@ -3641,6 +3995,7 @@ function InnerEditor({
</ReactFlow> </ReactFlow>
</WorkflowEditorCatalogContext.Provider> </WorkflowEditorCatalogContext.Provider>
</div> </div>
)}
</> </>
) : ( ) : (
<div className="wf-editor-empty wf-editor-canvas-empty wf-editor-onboard"> <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. */} {/* 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 && {selectedNodeHasInspector &&
!(isMobileMode && inspectorCollapsed) && ( !(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"> <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. */} {/* 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> <h3>{selectedNodeHelp?.title ?? t("workflowNodes.nodeInspector", "Node")}</h3>
@@ -5003,7 +5361,10 @@ function InnerEditor({
)} )}
{selectedEdge && ( {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"> <div className="wf-inspector-heading">
<h3>{t("workflowNodes.edgeInspector", "Edge")}</h3> <h3>{t("workflowNodes.edgeInspector", "Edge")}</h3>
{isMobileMode && ( {isMobileMode && (
@@ -5105,6 +5466,25 @@ function InnerEditor({
onClose={closeCreateDialog} 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> </div>
); );
return ( 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)", () => { describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
beforeEach(() => { 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(fetchWorkflows).mockResolvedValue([promptDef()]);
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG); vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue([]); vi.mocked(fetchStepParsers).mockResolvedValue([]);
@@ -102,6 +106,7 @@ describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
}); });
afterEach(() => { afterEach(() => {
localStorage.removeItem("fusion:wf-editor-view-mode");
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
vi.unstubAllGlobals(); 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", () => { describe("WorkflowNodeEditor", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(fetchWorkflows).mockResolvedValue([]); vi.mocked(fetchWorkflows).mockResolvedValue([]);
@@ -609,7 +626,7 @@ describe("WorkflowNodeEditor", () => {
expect(document.body.querySelector(".react-flow__minimap")).toBeTruthy(); 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()]); vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
@@ -617,17 +634,17 @@ describe("WorkflowNodeEditor", () => {
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA"); expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
expect(screen.queryByTestId("wf-mobile-shell")).not.toBeInTheDocument(); 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(await screen.findByTestId("wf-mobile-shell")).toBeInTheDocument();
expect(screen.getByTestId("wf-mobile-tab-graph")).toHaveAttribute("aria-current", "page"); expect(screen.getByTestId("wf-mobile-tab-graph")).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("button", { name: "start start" })).toBeInTheDocument(); 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()); 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 () => { it("surfaces the full styled simple-editor affordance set at desktop width", async () => {
@@ -636,7 +653,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA"); 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"); const shell = await screen.findByTestId("wf-mobile-shell");
for (const panel of ["graph", "add", "settings", "fields", "columns", "actions"]) { for (const panel of ["graph", "add", "settings", "fields", "columns", "actions"]) {
@@ -666,7 +683,7 @@ describe("WorkflowNodeEditor", () => {
fireEvent.click(await screen.findByRole("button", { name: "QA" })); fireEvent.click(await screen.findByRole("button", { name: "QA" }));
const shell = await screen.findByTestId("wf-mobile-shell"); 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); assertSimpleEditorTabScrollOwner(shell, 375);
}); });
@@ -698,7 +715,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Plain connect"); 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"); await screen.findByTestId("wf-mobile-shell");
fireEvent.click(await screen.findByTestId("mobile-wf-connect-draft")); fireEvent.click(await screen.findByTestId("mobile-wf-connect-draft"));
@@ -729,7 +746,7 @@ describe("WorkflowNodeEditor", () => {
await screen.findByText("Save"); await screen.findByText("Save");
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA"); 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"); await screen.findByTestId("wf-mobile-shell");
fireEvent.click(await screen.findByTestId("mobile-wf-connect-merge")); fireEvent.click(await screen.findByTestId("mobile-wf-connect-merge"));
@@ -778,7 +795,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Default coding workflow"); 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"); await screen.findByTestId("wf-mobile-shell");
fireEvent.click(screen.getByTestId("wf-mobile-tab-actions")); fireEvent.click(screen.getByTestId("wf-mobile-tab-actions"));
@@ -821,7 +838,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA"); 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"); const shell = await screen.findByTestId("wf-mobile-shell");
expect(screen.getByTestId("wf-mobile-tab-actions")).toBeInTheDocument(); expect(screen.getByTestId("wf-mobile-tab-actions")).toBeInTheDocument();
@@ -946,7 +963,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save"); 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"); const lintRow = await screen.findByTestId("mobile-wf-node-lint");
fireEvent.click(within(lintRow).getAllByRole("button")[0]); fireEvent.click(within(lintRow).getAllByRole("button")[0]);
@@ -966,7 +983,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save"); 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]); fireEvent.click(within(await screen.findByTestId("mobile-wf-node-start")).getAllByRole("button")[0]);
const startInspector = await screen.findByTestId("wf-node-inspector"); const startInspector = await screen.findByTestId("wf-node-inspector");
@@ -1230,7 +1247,7 @@ describe("WorkflowNodeEditor", () => {
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />); render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save"); 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]); fireEvent.click(within(await screen.findByTestId("mobile-wf-node-model")).getAllByRole("button")[0]);
let inspector = await screen.findByTestId("wf-node-inspector"); let inspector = await screen.findByTestId("wf-node-inspector");
@@ -4043,3 +4060,119 @@ describe("WorkflowNodeEditor — U6 column agents", () => {
expect(note).toHaveTextContent("Reviewer"); 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 * 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. * non-rework cycles (should not occur, but be defensive) cannot loop forever.
* Nodes unreachable from start land in a trailing layer (max + 1). * 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); const idSet = new Set(nodeIds);
// Adjacency over non-rework edges whose endpoints are both layoutable. // Adjacency over non-rework edges whose endpoints are both layoutable.
const adj = new Map<string, string[]>(); 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" "widgetDefault": "Default"
}, },
"workflowNodes": { "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", "advisory": "Advisory",
"autoLayout": "Auto-layout", "autoLayout": "Auto-layout",
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.", "codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
"codeSource": "Source (TypeScript)", "codeSource": "Source (TypeScript)",
"codeTimeout": "Timeout (ms)", "codeTimeout": "Timeout (ms)",
"collapseInspector": "Collapse", "collapseInspector": "Collapse",
"conditionFailure": "failure",
"conditionSuccess": "success",
"cycleBlocked": "That connection would create a cycle — only rework edges inside a for-each template may loop back", "cycleBlocked": "That connection would create a cycle — only rework edges inside a for-each template may loop back",
"deleteEdge": "Delete edge", "deleteEdge": "Delete edge",
"deleteNode": "Delete node", "deleteNode": "Delete node",
@@ -8702,9 +8712,13 @@
"mobileConnectTarget": "Target node", "mobileConnectTarget": "Target node",
"mobileGraph": "Graph", "mobileGraph": "Graph",
"mobileGraphEmpty": "No graph nodes yet.", "mobileGraphEmpty": "No graph nodes yet.",
"mobileGraphStyleCanvas": "Graph",
"mobileGraphStyleLabel": "Graph presentation",
"mobileGraphStyleList": "List",
"mobileMoveDown": "Move down", "mobileMoveDown": "Move down",
"mobileMoveUp": "Move up", "mobileMoveUp": "Move up",
"mobileNodeKinds": "Node types", "mobileNodeKinds": "Node types",
"nodeInspector": "Node",
"notifyCustom": "Custom", "notifyCustom": "Custom",
"notifyCustomEvent": "Custom event", "notifyCustomEvent": "Custom event",
"notifyEvent": "Event type", "notifyEvent": "Event type",
@@ -8714,6 +8728,7 @@
"parseArtifact": "Artifact", "parseArtifact": "Artifact",
"parseParser": "Parser", "parseParser": "Parser",
"quorumN": "Quorum count (n)", "quorumN": "Quorum count (n)",
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
"releaseCapacity": "Downstream capacity", "releaseCapacity": "Downstream capacity",
"releaseCondition": "Release condition", "releaseCondition": "Release condition",
"releaseDependency": "Dependency complete", "releaseDependency": "Dependency complete",
@@ -8726,6 +8741,9 @@
"reviewPlan": "Plan review", "reviewPlan": "Plan review",
"reviewType": "Review type", "reviewType": "Review type",
"showInspector": "Show node details", "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.", "splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
"startEntryColumn": "Entry column", "startEntryColumn": "Entry column",
"startEntryColumnAuto": "— Auto (first column)", "startEntryColumnAuto": "— Auto (first column)",
@@ -8740,11 +8758,7 @@
"templatesPluginSteps": "Plugin steps", "templatesPluginSteps": "Plugin steps",
"templatesSection": "Templates", "templatesSection": "Templates",
"timeoutMs": "{{timeout}}ms", "timeoutMs": "{{timeout}}ms",
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out.", "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."
}, },
"workflows": { "workflows": {
"aiEdit": "Design with AI", "aiEdit": "Design with AI",
@@ -8760,6 +8774,7 @@
"backToWorkflowList": "Back to workflows", "backToWorkflowList": "Back to workflows",
"clickToEditDescription": "Click to edit description", "clickToEditDescription": "Click to edit description",
"clickToRename": "Click to rename", "clickToRename": "Click to rename",
"closeEditor": "Close workflow editor",
"created": "Created workflow \"{{name}}\"", "created": "Created workflow \"{{name}}\"",
"createDescription": "Description (optional)", "createDescription": "Description (optional)",
"createFailed": "Failed to create workflow", "createFailed": "Failed to create workflow",
@@ -8776,6 +8791,8 @@
"discardConfirm": "Discard", "discardConfirm": "Discard",
"discardMessage": "You have unsaved changes to this workflow. Discard them?", "discardMessage": "You have unsaved changes to this workflow. Discard them?",
"discardTitle": "Discard unsaved changes?", "discardTitle": "Discard unsaved changes?",
"duplicatedEditable": "Duplicated to \"{{name}}\" — editable",
"duplicateFailed": "Failed to duplicate workflow",
"duplicateToCustomize": "Duplicate to customize", "duplicateToCustomize": "Duplicate to customize",
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.", "emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
"emptyTitle": "No workflow selected", "emptyTitle": "No workflow selected",
@@ -8789,17 +8806,18 @@
"importInvalidJson": "That file isn't valid JSON.", "importInvalidJson": "That file isn't valid JSON.",
"importStripped": "Auto-approval flags were removed from imported nodes", "importStripped": "Auto-approval flags were removed from imported nodes",
"importTooltip": "Import a workflow from a JSON file", "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.", "migrationNotice": "Your legacy workflow steps were converted — find them as templates in the palette and as the \"Migrated steps\" workflow.",
"mobileEditorNav": "Workflow editor sections", "mobileEditorNav": "Workflow editor sections",
"mobileSelectNote": "Select a workflow to edit.", "mobileSelectNote": "Select a workflow to edit.",
"nameLabel": "Workflow name", "nameLabel": "Workflow name",
"newWorkflow": "New workflow", "newWorkflow": "New workflow",
"noneYet": "No workflows yet.",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.", "readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "Workflow saved", "saved": "Workflow saved",
"savedNotCompilable": "Workflow saved but cannot be compiled", "savedNotCompilable": "Workflow saved but cannot be compiled",
"saveFailed": "Failed to save workflow", "saveFailed": "Failed to save workflow",
"showCanvasEditor": "Show canvas editor",
"showSimpleEditor": "Show simple editor",
"templateBlank": "Blank", "templateBlank": "Blank",
"templateBlankDescription": "Start from an empty start → end graph.", "templateBlankDescription": "Start from an empty start → end graph.",
"templateCopyName": "{{name}} copy", "templateCopyName": "{{name}} copy",
@@ -8808,13 +8826,11 @@
"templatePickerLabel": "Start from", "templatePickerLabel": "Start from",
"templateSectionBuiltin": "Built-in workflows", "templateSectionBuiltin": "Built-in workflows",
"templateSectionYours": "Your workflows", "templateSectionYours": "Your workflows",
"closeEditor": "Close workflow editor", "title": "Workflows",
"duplicatedEditable": "Duplicated to \"{{name}}\" — editable", "viewModeAdvanced": "Advanced",
"duplicateFailed": "Failed to duplicate workflow", "viewModeLabel": "Editor view",
"loadFailed": "Failed to load workflows", "viewModeList": "List",
"loading": "Loading…", "viewModeSimple": "Simple"
"noneYet": "No workflows yet.",
"title": "Workflows"
}, },
"workflowSelector": { "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?", "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" "widgetDefault": "Predeterminado"
}, },
"workflowNodes": { "workflowNodes": {
"addStepAgentSteps": "",
"addStepAutomation": "",
"addStepFlowControl": "",
"addStepFragmentDesc": "",
"addStepNoMatches": "",
"addStepSearchLabel": "",
"addStepSearchPlaceholder": "",
"addStepTitle": "",
"advisory": "", "advisory": "",
"autoLayout": "Diseño automático", "autoLayout": "Diseño automático",
"codeNote": "Ejecuta TypeScript en un entorno aislado. La sintaxis se valida al guardar.", "codeNote": "Ejecuta TypeScript en un entorno aislado. La sintaxis se valida al guardar.",
"codeSource": "Origen (TypeScript)", "codeSource": "Origen (TypeScript)",
"codeTimeout": "Tiempo de espera (ms)", "codeTimeout": "Tiempo de espera (ms)",
"collapseInspector": "", "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", "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", "deleteEdge": "Eliminar conexión",
"deleteNode": "Eliminar nodo", "deleteNode": "Eliminar nodo",
@@ -8691,9 +8701,13 @@
"mobileConnectTarget": "Nodo de destino", "mobileConnectTarget": "Nodo de destino",
"mobileGraph": "", "mobileGraph": "",
"mobileGraphEmpty": "", "mobileGraphEmpty": "",
"mobileGraphStyleCanvas": "",
"mobileGraphStyleLabel": "",
"mobileGraphStyleList": "",
"mobileMoveDown": "", "mobileMoveDown": "",
"mobileMoveUp": "", "mobileMoveUp": "",
"mobileNodeKinds": "", "mobileNodeKinds": "",
"nodeInspector": "",
"notifyCustom": "", "notifyCustom": "",
"notifyCustomEvent": "", "notifyCustomEvent": "",
"notifyEvent": "", "notifyEvent": "",
@@ -8703,6 +8717,7 @@
"parseArtifact": "Artifact", "parseArtifact": "Artifact",
"parseParser": "Parser", "parseParser": "Parser",
"quorumN": "Recuento de quórum (n)", "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", "releaseCapacity": "Capacidad posterior",
"releaseCondition": "Condición de liberación", "releaseCondition": "Condición de liberación",
"releaseDependency": "Dependencia completada", "releaseDependency": "Dependencia completada",
@@ -8715,6 +8730,9 @@
"reviewPlan": "Plan review", "reviewPlan": "Plan review",
"reviewType": "Review type", "reviewType": "Review type",
"showInspector": "", "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.", "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": "", "startEntryColumn": "",
"startEntryColumnAuto": "", "startEntryColumnAuto": "",
@@ -8729,11 +8747,7 @@
"templatesPluginSteps": "Pasos de plugin", "templatesPluginSteps": "Pasos de plugin",
"templatesSection": "Plantillas", "templatesSection": "Plantillas",
"timeoutMs": "", "timeoutMs": "",
"trivialGraphHint": "Este flujo de trabajo solo ejecuta inicio → fin. Añade pasos desde la paleta superior para construirlo.", "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."
}, },
"workflows": { "workflows": {
"aiEdit": "Diseñar con IA", "aiEdit": "Diseñar con IA",
@@ -8749,6 +8763,7 @@
"backToWorkflowList": "", "backToWorkflowList": "",
"clickToEditDescription": "Haz clic para editar la descripción", "clickToEditDescription": "Haz clic para editar la descripción",
"clickToRename": "Haz clic para renombrar", "clickToRename": "Haz clic para renombrar",
"closeEditor": "",
"created": "Flujo de trabajo «{{name}}» creado", "created": "Flujo de trabajo «{{name}}» creado",
"createDescription": "Descripción (opcional)", "createDescription": "Descripción (opcional)",
"createFailed": "Error al crear el flujo de trabajo", "createFailed": "Error al crear el flujo de trabajo",
@@ -8765,6 +8780,8 @@
"discardConfirm": "Descartar", "discardConfirm": "Descartar",
"discardMessage": "Tienes cambios no guardados en este flujo de trabajo. ¿Descartarlos?", "discardMessage": "Tienes cambios no guardados en este flujo de trabajo. ¿Descartarlos?",
"discardTitle": "¿Descartar cambios no guardados?", "discardTitle": "¿Descartar cambios no guardados?",
"duplicatedEditable": "",
"duplicateFailed": "",
"duplicateToCustomize": "", "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.", "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", "emptyTitle": "Ningún flujo de trabajo seleccionado",
@@ -8778,17 +8795,18 @@
"importInvalidJson": "Ese archivo no es JSON válido.", "importInvalidJson": "Ese archivo no es JSON válido.",
"importStripped": "Se eliminaron los indicadores de aprobación automática de los nodos importados", "importStripped": "Se eliminaron los indicadores de aprobación automática de los nodos importados",
"importTooltip": "Importar un flujo de trabajo desde un archivo JSON", "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».", "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": "", "mobileEditorNav": "",
"mobileSelectNote": "", "mobileSelectNote": "",
"nameLabel": "Nombre del flujo de trabajo", "nameLabel": "Nombre del flujo de trabajo",
"newWorkflow": "Nuevo flujo de trabajo", "newWorkflow": "Nuevo flujo de trabajo",
"noneYet": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.", "readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "", "saved": "",
"savedNotCompilable": "", "savedNotCompilable": "",
"saveFailed": "", "saveFailed": "",
"showCanvasEditor": "",
"showSimpleEditor": "",
"templateBlank": "En blanco", "templateBlank": "En blanco",
"templateBlankDescription": "Comenzar desde un grafo vacío inicio → fin.", "templateBlankDescription": "Comenzar desde un grafo vacío inicio → fin.",
"templateCopyName": "Copia de {{name}}", "templateCopyName": "Copia de {{name}}",
@@ -8797,13 +8815,11 @@
"templatePickerLabel": "Comenzar desde", "templatePickerLabel": "Comenzar desde",
"templateSectionBuiltin": "Flujos de trabajo integrados", "templateSectionBuiltin": "Flujos de trabajo integrados",
"templateSectionYours": "Tus flujos de trabajo", "templateSectionYours": "Tus flujos de trabajo",
"closeEditor": "", "title": "",
"duplicatedEditable": "", "viewModeAdvanced": "",
"duplicateFailed": "", "viewModeLabel": "",
"loadFailed": "", "viewModeList": "",
"loading": "", "viewModeSimple": ""
"noneYet": "",
"title": ""
}, },
"workflowSelector": { "workflowSelector": {
"switchActiveMessage": "", "switchActiveMessage": "",

View File

@@ -8634,12 +8634,22 @@
"widgetDefault": "Par défaut" "widgetDefault": "Par défaut"
}, },
"workflowNodes": { "workflowNodes": {
"addStepAgentSteps": "",
"addStepAutomation": "",
"addStepFlowControl": "",
"addStepFragmentDesc": "",
"addStepNoMatches": "",
"addStepSearchLabel": "",
"addStepSearchPlaceholder": "",
"addStepTitle": "",
"advisory": "", "advisory": "",
"autoLayout": "Disposition automatique", "autoLayout": "Disposition automatique",
"codeNote": "Exécute du TypeScript en bac à sable. La syntaxe est validée à l’enregistrement.", "codeNote": "Exécute du TypeScript en bac à sable. La syntaxe est validée à l’enregistrement.",
"codeSource": "Source (TypeScript)", "codeSource": "Source (TypeScript)",
"codeTimeout": "Délai d’expiration (ms)", "codeTimeout": "Délai d’expiration (ms)",
"collapseInspector": "", "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", "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", "deleteEdge": "Supprimer l'arête",
"deleteNode": "Supprimer le nœud", "deleteNode": "Supprimer le nœud",
@@ -8692,9 +8702,13 @@
"mobileConnectTarget": "Nœud cible", "mobileConnectTarget": "Nœud cible",
"mobileGraph": "", "mobileGraph": "",
"mobileGraphEmpty": "", "mobileGraphEmpty": "",
"mobileGraphStyleCanvas": "",
"mobileGraphStyleLabel": "",
"mobileGraphStyleList": "",
"mobileMoveDown": "", "mobileMoveDown": "",
"mobileMoveUp": "", "mobileMoveUp": "",
"mobileNodeKinds": "", "mobileNodeKinds": "",
"nodeInspector": "",
"notifyCustom": "", "notifyCustom": "",
"notifyCustomEvent": "", "notifyCustomEvent": "",
"notifyEvent": "", "notifyEvent": "",
@@ -8704,6 +8718,7 @@
"parseArtifact": "Artifact", "parseArtifact": "Artifact",
"parseParser": "Parser", "parseParser": "Parser",
"quorumN": "Nombre de quorum (n)", "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", "releaseCapacity": "Capacité en aval",
"releaseCondition": "Condition de libération", "releaseCondition": "Condition de libération",
"releaseDependency": "Dépendance terminée", "releaseDependency": "Dépendance terminée",
@@ -8716,6 +8731,9 @@
"reviewPlan": "Plan review", "reviewPlan": "Plan review",
"reviewType": "Review type", "reviewType": "Review type",
"showInspector": "", "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.", "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": "", "startEntryColumn": "",
"startEntryColumnAuto": "", "startEntryColumnAuto": "",
@@ -8730,11 +8748,7 @@
"templatesPluginSteps": "Étapes de plugin", "templatesPluginSteps": "Étapes de plugin",
"templatesSection": "Modèles", "templatesSection": "Modèles",
"timeoutMs": "", "timeoutMs": "",
"trivialGraphHint": "Ce workflow ne fait qu'exécuter début → fin. Ajoutez des étapes depuis la palette ci-dessus pour le développer.", "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."
}, },
"workflows": { "workflows": {
"aiEdit": "Concevoir avec l'IA", "aiEdit": "Concevoir avec l'IA",
@@ -8750,6 +8764,7 @@
"backToWorkflowList": "", "backToWorkflowList": "",
"clickToEditDescription": "Cliquez pour modifier la description", "clickToEditDescription": "Cliquez pour modifier la description",
"clickToRename": "Cliquez pour renommer", "clickToRename": "Cliquez pour renommer",
"closeEditor": "",
"created": "Workflow « {{name}} » créé", "created": "Workflow « {{name}} » créé",
"createDescription": "Description (facultatif)", "createDescription": "Description (facultatif)",
"createFailed": "Impossible de créer le workflow", "createFailed": "Impossible de créer le workflow",
@@ -8766,6 +8781,8 @@
"discardConfirm": "Abandonner", "discardConfirm": "Abandonner",
"discardMessage": "Vous avez des modifications non enregistrées sur ce workflow. Les abandonner ?", "discardMessage": "Vous avez des modifications non enregistrées sur ce workflow. Les abandonner ?",
"discardTitle": "Abandonner les modifications non enregistrées ?", "discardTitle": "Abandonner les modifications non enregistrées ?",
"duplicatedEditable": "",
"duplicateFailed": "",
"duplicateToCustomize": "", "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.", "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é", "emptyTitle": "Aucun workflow sélectionné",
@@ -8779,17 +8796,18 @@
"importInvalidJson": "Ce fichier n'est pas un JSON valide.", "importInvalidJson": "Ce fichier n'est pas un JSON valide.",
"importStripped": "Les indicateurs d'approbation automatique ont été supprimés des nœuds importés", "importStripped": "Les indicateurs d'approbation automatique ont été supprimés des nœuds importés",
"importTooltip": "Importer un workflow depuis un fichier JSON", "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 ».", "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": "", "mobileEditorNav": "",
"mobileSelectNote": "", "mobileSelectNote": "",
"nameLabel": "Nom du workflow", "nameLabel": "Nom du workflow",
"newWorkflow": "Nouveau workflow", "newWorkflow": "Nouveau workflow",
"noneYet": "",
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.", "readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
"saved": "", "saved": "",
"savedNotCompilable": "", "savedNotCompilable": "",
"saveFailed": "", "saveFailed": "",
"showCanvasEditor": "",
"showSimpleEditor": "",
"templateBlank": "Vierge", "templateBlank": "Vierge",
"templateBlankDescription": "Partez d'un graphe vide début → fin.", "templateBlankDescription": "Partez d'un graphe vide début → fin.",
"templateCopyName": "{{name}} (copie)", "templateCopyName": "{{name}} (copie)",
@@ -8798,13 +8816,11 @@
"templatePickerLabel": "Partir de", "templatePickerLabel": "Partir de",
"templateSectionBuiltin": "Workflows intégrés", "templateSectionBuiltin": "Workflows intégrés",
"templateSectionYours": "Vos workflows", "templateSectionYours": "Vos workflows",
"closeEditor": "", "title": "",
"duplicatedEditable": "", "viewModeAdvanced": "",
"duplicateFailed": "", "viewModeLabel": "",
"loadFailed": "", "viewModeList": "",
"loading": "", "viewModeSimple": ""
"noneYet": "",
"title": ""
}, },
"workflowSelector": { "workflowSelector": {
"switchActiveMessage": "", "switchActiveMessage": "",

View File

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

View File

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

View File

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