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:
7
.changeset/simplified-workflow-view.md
Normal file
7
.changeset/simplified-workflow-view.md
Normal 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.
|
||||
@@ -130,7 +130,7 @@ describe("component CSS hygiene scan regressions", () => {
|
||||
".plugin-registry-retry:focus-visible",
|
||||
],
|
||||
"WorkflowNodeEditor.css": [
|
||||
".wf-layout-toggle:focus-visible",
|
||||
".wf-view-mode-option:focus-visible",
|
||||
".wf-template-option:focus-visible",
|
||||
".wf-ai-prompt:focus-visible",
|
||||
".wf-mobile-tab:focus-visible",
|
||||
|
||||
228
packages/dashboard/app/components/WorkflowAddStepModal.css
Normal file
228
packages/dashboard/app/components/WorkflowAddStepModal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
298
packages/dashboard/app/components/WorkflowAddStepModal.tsx
Normal file
298
packages/dashboard/app/components/WorkflowAddStepModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -797,30 +797,110 @@ Flow's SVG attributes and makes the graph preview read blank.
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-layout-toggle {
|
||||
/*
|
||||
FNXC:WorkflowSimpleView 2026-07-10-12:00:
|
||||
Segmented three-mode view switch (Simple / Advanced / List) replacing the old
|
||||
boolean .wf-layout-toggle. Also reused by the mobile graph-style toggle
|
||||
(Graph / List) so both switches read as one control family.
|
||||
*/
|
||||
.wf-view-mode-toggle,
|
||||
.wf-mobile-graph-style-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.wf-mobile-graph-style-toggle {
|
||||
margin: var(--space-sm) var(--space-md) 0;
|
||||
}
|
||||
|
||||
.wf-view-mode-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
width: fit-content;
|
||||
min-height: 30px;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
min-height: 26px;
|
||||
padding: var(--space-xs) 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.wf-layout-toggle:hover {
|
||||
background: var(--bg-tertiary);
|
||||
.wf-view-mode-option:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-layout-toggle:focus-visible {
|
||||
.wf-view-mode-option--active {
|
||||
background: var(--surface-emphasis);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.wf-view-mode-option:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Simplified-view toolbar: a single quiet row (Add step + common actions). */
|
||||
.wf-editor-toolbar--simple {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.wf-editor-toolbar--simple .wf-simple-toolbar-add {
|
||||
border-color: color-mix(in srgb, var(--todo) 45%, var(--border));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-editor-toolbar--simple .wf-simple-toolbar-add:hover {
|
||||
border-color: var(--todo);
|
||||
background: color-mix(in srgb, var(--todo) 12%, var(--surface));
|
||||
}
|
||||
|
||||
/* Mobile graph tab: the simplified canvas needs a bounded, non-scrolling
|
||||
region inside the scrollable mobile panel (WorkflowSimpleCanvas fills it
|
||||
with position:absolute). */
|
||||
.wf-mobile-simple-canvas {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: max(340px, 55dvh);
|
||||
margin: var(--space-sm) var(--space-md) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSimpleView 2026-07-10-12:00:
|
||||
Simple-view inspector polish: the shared node/edge inspector gets a roomier,
|
||||
card-like presentation in the simplified view (wider pane, soft section
|
||||
cards) without touching its advanced-view layout or any field markup.
|
||||
*/
|
||||
.wf-editor-inspector--simple {
|
||||
width: 320px;
|
||||
padding: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.wf-editor-inspector--simple h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wf-editor-inspector--simple .wf-inspector-fields {
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.wf-editor-body--simple-layout :where(.wf-editor-readonly-banner),
|
||||
|
||||
@@ -91,6 +91,9 @@ import {
|
||||
FOREACH_CHILD_Y,
|
||||
} from "./workflow-flow-mapping";
|
||||
import { autoLayout, applyAutoLayout } from "./workflow-auto-layout";
|
||||
import { insertNodeOnEdge, findAppendEdgeId } from "./workflow-simple-layout";
|
||||
import { WorkflowSimpleCanvas } from "./WorkflowSimpleCanvas";
|
||||
import { WorkflowAddStepModal, type AddStepPaletteEntry } from "./WorkflowAddStepModal";
|
||||
import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
|
||||
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
||||
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||
@@ -107,6 +110,27 @@ import {
|
||||
} from "./workflow-mobile-graph";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSimpleView 2026-07-10-12:00:
|
||||
The desktop workflow editor now has THREE view modes over the same node/edge
|
||||
state, replacing the old boolean simple-editor toggle:
|
||||
- "simple" — the simplified graphical node editor (default): vertical
|
||||
auto-laid-out canvas, "+" insert-on-edge, add-step dialog, no palette
|
||||
toolbar or column bands. Cleaner for the common tasks (adding and
|
||||
configuring nodes).
|
||||
- "advanced" — the full canvas: palette toolbar, templates, drag/connect,
|
||||
column swimlanes, minimap, auto-layout, import/export. Nothing removed.
|
||||
- "list" — the pre-existing row-list simple editor, kept as an even
|
||||
simpler fallback; it reuses the mobile shell exactly as before.
|
||||
The choice persists per-browser in localStorage. Mobile keeps its tab shell
|
||||
(forced by viewport) and gets its own graph-style toggle: the simplified
|
||||
canvas by default with the row list as fallback.
|
||||
*/
|
||||
type WorkflowEditorViewMode = "simple" | "advanced" | "list";
|
||||
const viewModeStorageKey = "fusion:wf-editor-view-mode";
|
||||
type MobileGraphStyle = "canvas" | "list";
|
||||
const mobileGraphStyleStorageKey = "fusion:wf-mobile-graph-style";
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: dropped the "optional-steps" mobile
|
||||
// panel — the declaration authoring surface is retired (optional-group nodes now).
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||
@@ -802,11 +826,49 @@ function InnerEditor({
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
|
||||
const [miniMapCollapsed, setMiniMapCollapsed] = useState(false);
|
||||
const [compactLayoutEnabled, setCompactLayoutEnabled] = useState(false);
|
||||
// FNXC:WorkflowSimpleView 2026-07-10-12:00: three-mode desktop view state
|
||||
// (see the WorkflowEditorViewMode note above). Persisted so operators land
|
||||
// back in the mode they work in; "simple" is the default for everyone else.
|
||||
const [viewMode, setViewMode] = useState<WorkflowEditorViewMode>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(viewModeStorageKey);
|
||||
if (stored === "simple" || stored === "advanced" || stored === "list") return stored;
|
||||
} catch {
|
||||
// localStorage unavailable: non-fatal.
|
||||
}
|
||||
return "simple";
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(viewModeStorageKey, viewMode);
|
||||
} catch {
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [viewMode]);
|
||||
// Mobile graph tab presentation: simplified canvas (default) or row list.
|
||||
const [mobileGraphStyle, setMobileGraphStyle] = useState<MobileGraphStyle>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(mobileGraphStyleStorageKey);
|
||||
if (stored === "canvas" || stored === "list") return stored;
|
||||
} catch {
|
||||
// localStorage unavailable: non-fatal.
|
||||
}
|
||||
return "canvas";
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(mobileGraphStyleStorageKey, mobileGraphStyle);
|
||||
} catch {
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [mobileGraphStyle]);
|
||||
const [mobilePanel, setMobilePanel] = useState<MobileWorkflowPanel>(() =>
|
||||
initialPanel === "settings" ? "settings" : "graph",
|
||||
);
|
||||
const simpleLayoutEnabled = isMobileMode || compactLayoutEnabled;
|
||||
// "list" keeps the pre-existing compact (mobile-shell) presentation.
|
||||
const simpleLayoutEnabled = isMobileMode || viewMode === "list";
|
||||
// Desktop simplified graphical view (not the list fallback, not mobile).
|
||||
const simpleViewEnabled = !simpleLayoutEnabled && viewMode === "simple";
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
|
||||
@@ -1606,6 +1668,85 @@ function InnerEditor({
|
||||
[isBuiltin, nodes, edges, activeWorkflow, setNodes, setEdges],
|
||||
);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSimpleView 2026-07-10-12:00:
|
||||
Add-step dialog target for the simplified view. `edgeId` set = insert ON that
|
||||
edge (source→new→target rewiring via insertNodeOnEdge); null = free append
|
||||
(the "+ Add step" pill), which prefers the unambiguous edge into `end` and
|
||||
falls back to the classic free-floating addNode. `insideContainer` hides
|
||||
container kinds from the dialog because containers cannot nest.
|
||||
*/
|
||||
const [addStepTarget, setAddStepTarget] = useState<{
|
||||
edgeId: string | null;
|
||||
insideContainer: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const openInsertOnEdge = useCallback(
|
||||
(edgeId: string) => {
|
||||
if (isBuiltin) return;
|
||||
const edge = edges.find((e) => e.id === edgeId);
|
||||
const source = edge ? nodes.find((n) => n.id === edge.source) : undefined;
|
||||
const target = edge ? nodes.find((n) => n.id === edge.target) : undefined;
|
||||
const insideContainer = !!source?.parentId && source.parentId === target?.parentId;
|
||||
setAddStepTarget({ edgeId, insideContainer });
|
||||
},
|
||||
[isBuiltin, edges, nodes],
|
||||
);
|
||||
|
||||
const openAddStep = useCallback(() => {
|
||||
if (isBuiltin) return;
|
||||
setAddStepTarget({ edgeId: findAppendEdgeId(nodes, edges), insideContainer: false });
|
||||
}, [isBuiltin, nodes, edges]);
|
||||
|
||||
const containerChildLabelFor = useCallback(
|
||||
(kind: WorkflowEditorNodeKind) =>
|
||||
kind === "foreach"
|
||||
? t("workflowNodes.stepExecuteLabel", "Step execute")
|
||||
: kind === "optional-group"
|
||||
? t("workflowNodes.optionalGroupStepLabel", "Optional step")
|
||||
: t("workflowNodes.loopStepLabel", "Loop step"),
|
||||
[t],
|
||||
);
|
||||
|
||||
/** Insert on the targeted edge when one is set (falling back to addNode when
|
||||
* the edge disappeared, e.g. deleted while the dialog was open). */
|
||||
const insertFromAddStep = useCallback(
|
||||
(kind: WorkflowEditorNodeKind, label: string, presetConfig?: Record<string, unknown>) => {
|
||||
if (addStepTarget?.edgeId) {
|
||||
const result = insertNodeOnEdge(nodes, edges, addStepTarget.edgeId, {
|
||||
kind,
|
||||
label,
|
||||
presetConfig,
|
||||
containerChildLabel: containerChildLabelFor(kind),
|
||||
});
|
||||
if (result) {
|
||||
setNodes(result.nodes);
|
||||
setEdges(result.edges);
|
||||
setSelectedNodeId(result.newNodeId);
|
||||
setSelectedEdgeId(null);
|
||||
setAddStepTarget(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
addNode(kind, label, presetConfig);
|
||||
setAddStepTarget(null);
|
||||
},
|
||||
[addStepTarget, nodes, edges, setNodes, setEdges, addNode, containerChildLabelFor],
|
||||
);
|
||||
|
||||
const handleAddStepPalettePick = useCallback(
|
||||
(entry: AddStepPaletteEntry) => insertFromAddStep(entry.kind, entry.label, entry.presetConfig),
|
||||
[insertFromAddStep],
|
||||
);
|
||||
|
||||
const handleAddStepTemplatePick = useCallback(
|
||||
(tpl: WorkflowStepTemplate) => {
|
||||
const { kind, label, config } = stepTemplateToNode(tpl);
|
||||
insertFromAddStep(kind, label, config);
|
||||
},
|
||||
[insertFromAddStep],
|
||||
);
|
||||
|
||||
// Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions
|
||||
// only; bands and foreach template children are left in place. Marks the
|
||||
// editor dirty automatically via the layout serialization in isDirty.
|
||||
@@ -2321,6 +2462,8 @@ function InnerEditor({
|
||||
}),
|
||||
[models, agents, skills],
|
||||
);
|
||||
// Column id → display name for the simplified view's per-node column chips.
|
||||
const columnNameMap = useMemo(() => new Map(columns.map((c) => [c.id, c.name])), [columns]);
|
||||
const mobileConnectionTargetsBySource = useMemo(() => {
|
||||
const targetNodes = nodesForRender
|
||||
.filter((node) => !isColumnBandNode(node.id) && node.data.kind !== "start")
|
||||
@@ -2604,7 +2747,7 @@ function InnerEditor({
|
||||
<div
|
||||
className={`wf-editor-body${workflowListStageOpen ? " wf-editor-body--list-stage" : " wf-editor-body--editor-stage"}${
|
||||
simpleLayoutEnabled ? " wf-editor-body--simple-layout" : ""
|
||||
}${mobileNodeDetailStage ? " wf-editor-body--mobile-node-detail" : ""}${
|
||||
}${simpleViewEnabled ? " wf-editor-body--simple-view" : ""}${mobileNodeDetailStage ? " wf-editor-body--mobile-node-detail" : ""}${
|
||||
mobileEdgeDetailStage ? " wf-editor-body--mobile-edge-detail" : ""
|
||||
}${sidebarCollapsed ? " wf-editor-body--sidebar-collapsed" : ""}`}
|
||||
>
|
||||
@@ -2700,7 +2843,10 @@ function InnerEditor({
|
||||
workflow is active (read-only gating preserved via isBuiltin). The
|
||||
disclosure button serves as the section header; the panels' own
|
||||
internal <h3> is suppressed via CSS to avoid a double header. */}
|
||||
{activeWorkflow && !simpleLayoutEnabled && (
|
||||
{/* FNXC:WorkflowSimpleView 2026-07-10-12:00: columns/fields/settings
|
||||
authoring panels are advanced-view chrome; the simplified view
|
||||
hides them (still one click away via the Advanced toggle). */}
|
||||
{activeWorkflow && !simpleLayoutEnabled && !simpleViewEnabled && (
|
||||
<div className="wf-sidebar-panels">
|
||||
<section className="wf-sidebar-section" data-testid="wf-sidebar-columns-section">
|
||||
<button
|
||||
@@ -2899,20 +3045,35 @@ function InnerEditor({
|
||||
</button>
|
||||
)}
|
||||
{!isMobileMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="wf-layout-toggle"
|
||||
data-testid="wf-layout-toggle"
|
||||
aria-pressed={compactLayoutEnabled}
|
||||
onClick={() => setCompactLayoutEnabled((enabled) => !enabled)}
|
||||
/* FNXC:WorkflowSimpleView 2026-07-10-12:00: segmented three-mode
|
||||
switch (Simple / Advanced / List) replacing the old boolean
|
||||
simple-editor toggle; List is the old simple editor. */
|
||||
<div
|
||||
className="wf-view-mode-toggle"
|
||||
role="group"
|
||||
data-testid="wf-view-mode-toggle"
|
||||
aria-label={t("workflows.viewModeLabel", "Editor view")}
|
||||
>
|
||||
{compactLayoutEnabled ? <LayoutGrid size={14} /> : <ListChecks size={14} />}
|
||||
<span>
|
||||
{compactLayoutEnabled
|
||||
? t("workflows.showCanvasEditor", "Show canvas editor")
|
||||
: t("workflows.showSimpleEditor", "Show simple editor")}
|
||||
</span>
|
||||
</button>
|
||||
{(
|
||||
[
|
||||
["simple", Workflow, t("workflows.viewModeSimple", "Simple")],
|
||||
["advanced", LayoutGrid, t("workflows.viewModeAdvanced", "Advanced")],
|
||||
["list", ListChecks, t("workflows.viewModeList", "List")],
|
||||
] as Array<[WorkflowEditorViewMode, typeof Workflow, string]>
|
||||
).map(([mode, Icon, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`wf-view-mode-option${viewMode === mode ? " wf-view-mode-option--active" : ""}`}
|
||||
data-testid={`wf-view-mode-${mode}`}
|
||||
aria-pressed={viewMode === mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={13} aria-hidden />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lifecycleWarnings.length > 0 && (
|
||||
@@ -2958,23 +3119,89 @@ function InnerEditor({
|
||||
|
||||
<div className="wf-mobile-panel" data-testid={`wf-mobile-panel-${mobilePanel}`}>
|
||||
{mobilePanel === "graph" && (
|
||||
<MobileWorkflowGraphView
|
||||
rows={mobileGraphRows}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={(id) => {
|
||||
setSelectedNodeId(id);
|
||||
setSelectedEdgeId(null);
|
||||
setInspectorCollapsed(false);
|
||||
}}
|
||||
onSelectEdge={(id) => {
|
||||
setSelectedEdgeId(id);
|
||||
setSelectedNodeId(null);
|
||||
}}
|
||||
onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection}
|
||||
canReorder={!isBuiltin}
|
||||
onMoveNode={onMoveSimpleNode}
|
||||
/>
|
||||
<>
|
||||
{/* FNXC:WorkflowSimpleView 2026-07-10-12:00: mobile
|
||||
graph tab offers the simplified touch canvas
|
||||
(default) with the row list kept as the even
|
||||
simpler fallback. Desktop "list" mode reuses
|
||||
this shell and keeps the row list only. */}
|
||||
{isMobileMode && (
|
||||
<div
|
||||
className="wf-mobile-graph-style-toggle"
|
||||
role="group"
|
||||
data-testid="wf-mobile-graph-style-toggle"
|
||||
aria-label={t("workflowNodes.mobileGraphStyleLabel", "Graph presentation")}
|
||||
>
|
||||
{(
|
||||
[
|
||||
["canvas", t("workflowNodes.mobileGraphStyleCanvas", "Graph")],
|
||||
["list", t("workflowNodes.mobileGraphStyleList", "List")],
|
||||
] as Array<[MobileGraphStyle, string]>
|
||||
).map(([style, label]) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
className={`wf-view-mode-option${mobileGraphStyle === style ? " wf-view-mode-option--active" : ""}`}
|
||||
data-testid={`wf-mobile-graph-style-${style}`}
|
||||
aria-pressed={mobileGraphStyle === style}
|
||||
onClick={() => setMobileGraphStyle(style)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isMobileMode && mobileGraphStyle === "canvas" ? (
|
||||
<div className="wf-mobile-simple-canvas" data-testid="wf-mobile-simple-canvas">
|
||||
<WorkflowEditorCatalogContext.Provider value={catalogs}>
|
||||
<WorkflowSimpleCanvas
|
||||
nodes={nodesForRender}
|
||||
edges={edges}
|
||||
columnNames={columnNameMap}
|
||||
editable={!isBuiltin}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={(id) => {
|
||||
setSelectedNodeId(id);
|
||||
setSelectedEdgeId(null);
|
||||
setInspectorCollapsed(false);
|
||||
}}
|
||||
onSelectEdge={(id) => {
|
||||
setSelectedEdgeId(id);
|
||||
setSelectedNodeId(null);
|
||||
}}
|
||||
onClearSelection={() => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}}
|
||||
onInsertOnEdge={openInsertOnEdge}
|
||||
onAddStep={openAddStep}
|
||||
onBeforeDelete={onBeforeDelete}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
/>
|
||||
</WorkflowEditorCatalogContext.Provider>
|
||||
</div>
|
||||
) : (
|
||||
<MobileWorkflowGraphView
|
||||
rows={mobileGraphRows}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={(id) => {
|
||||
setSelectedNodeId(id);
|
||||
setSelectedEdgeId(null);
|
||||
setInspectorCollapsed(false);
|
||||
}}
|
||||
onSelectEdge={(id) => {
|
||||
setSelectedEdgeId(id);
|
||||
setSelectedNodeId(null);
|
||||
}}
|
||||
onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection}
|
||||
canReorder={!isBuiltin}
|
||||
onMoveNode={onMoveSimpleNode}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mobilePanel === "add" && (
|
||||
@@ -3256,6 +3483,96 @@ function InnerEditor({
|
||||
<Plus size={13} /> {t("workflows.duplicateToCustomize", "Duplicate to customize")}
|
||||
</button>
|
||||
</div>
|
||||
) : simpleViewEnabled ? (
|
||||
/* FNXC:WorkflowSimpleView 2026-07-10-12:00: simplified-view
|
||||
toolbar — the flat 16-button palette moves into the
|
||||
searchable add-step dialog; the toolbar keeps only the
|
||||
common actions (Add step, Design with AI, Delete, Save).
|
||||
Import/Export/Auto-layout stay in the Advanced view. */
|
||||
<div className="wf-editor-toolbar wf-editor-toolbar--simple" data-testid="wf-simple-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-editor-action wf-simple-toolbar-add"
|
||||
data-testid="wf-simple-toolbar-add-step"
|
||||
onClick={openAddStep}
|
||||
>
|
||||
<Plus size={13} /> {t("workflowNodes.simpleAddStep", "Add step")}
|
||||
</button>
|
||||
<div className="wf-editor-actions">
|
||||
<div className="wf-ai-edit-wrap">
|
||||
<button
|
||||
className="wf-editor-action"
|
||||
data-testid="wf-simple-ai-edit"
|
||||
aria-expanded={aiPanelOpen}
|
||||
onClick={() => {
|
||||
setAiPanelOpen((o) => !o);
|
||||
setAiEditError(null);
|
||||
}}
|
||||
>
|
||||
<Sparkles size={13} /> {t("workflows.aiEdit", "Design with AI")}
|
||||
</button>
|
||||
{aiPanelOpen && (
|
||||
<div
|
||||
className="wf-ai-panel"
|
||||
data-testid="wf-simple-ai-panel"
|
||||
role="dialog"
|
||||
aria-busy={aiEditBusy}
|
||||
aria-label={t("workflows.aiEdit", "Design with AI")}
|
||||
>
|
||||
<textarea
|
||||
className="wf-ai-prompt"
|
||||
data-testid="wf-simple-ai-edit-prompt"
|
||||
rows={3}
|
||||
value={aiEditPrompt}
|
||||
disabled={aiEditBusy}
|
||||
placeholder={t(
|
||||
"workflows.aiPromptPlaceholder",
|
||||
"e.g. Run lint and tests before merge, then post a changelog comment after merge",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setAiEditPrompt(e.target.value);
|
||||
if (aiEditError) setAiEditError(null);
|
||||
}}
|
||||
/>
|
||||
{aiEditError && (
|
||||
<p className="wf-create-error" role="alert" data-testid="wf-simple-ai-edit-error">
|
||||
{aiEditError}
|
||||
</p>
|
||||
)}
|
||||
<div className="wf-ai-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary wf-ai-submit"
|
||||
data-testid="wf-simple-ai-edit-submit"
|
||||
disabled={aiEditBusy}
|
||||
onClick={() => void handleAiEditSubmit()}
|
||||
>
|
||||
{aiEditBusy ? <Loader2 size={13} className="wf-spin" /> : <Sparkles size={13} />}{" "}
|
||||
{t("workflows.aiSubmit", "Design with AI")}
|
||||
</button>
|
||||
{aiEditBusy && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn wf-ai-cancel"
|
||||
data-testid="wf-simple-ai-edit-cancel"
|
||||
onClick={handleAiEditCancel}
|
||||
>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
|
||||
<Trash2 size={13} /> {t("common.delete", "Delete")}
|
||||
</button>
|
||||
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />}{" "}
|
||||
{t("common.save", "Save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wf-editor-toolbar">
|
||||
<div className="wf-editor-palette">
|
||||
@@ -3375,7 +3692,7 @@ function InnerEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyTemplate && (
|
||||
{hasAnyTemplate && !simpleViewEnabled && (
|
||||
<section
|
||||
className="wf-templates"
|
||||
data-testid="wf-palette-templates"
|
||||
@@ -3557,6 +3874,43 @@ function InnerEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simpleViewEnabled ? (
|
||||
/* FNXC:WorkflowSimpleView 2026-07-10-12:00: the simplified
|
||||
graphical canvas replaces the advanced React Flow canvas;
|
||||
both edit the same nodes/edges state, so selection,
|
||||
deletion, the inspector, validation badges and Save all
|
||||
behave identically. */
|
||||
<div className="wf-editor-canvas wf-editor-canvas--simple" ref={canvasRef} tabIndex={-1}>
|
||||
<WorkflowEditorCatalogContext.Provider value={catalogs}>
|
||||
<WorkflowSimpleCanvas
|
||||
nodes={nodesForRender}
|
||||
edges={edges}
|
||||
columnNames={columnNameMap}
|
||||
editable={!isBuiltin}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={(id) => {
|
||||
setSelectedNodeId(id);
|
||||
setSelectedEdgeId(null);
|
||||
setInspectorCollapsed(false);
|
||||
}}
|
||||
onSelectEdge={(id) => {
|
||||
setSelectedEdgeId(id);
|
||||
setSelectedNodeId(null);
|
||||
}}
|
||||
onClearSelection={() => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}}
|
||||
onInsertOnEdge={openInsertOnEdge}
|
||||
onAddStep={openAddStep}
|
||||
onBeforeDelete={onBeforeDelete}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
/>
|
||||
</WorkflowEditorCatalogContext.Provider>
|
||||
</div>
|
||||
) : (
|
||||
<div className="wf-editor-canvas" ref={canvasRef} tabIndex={-1}>
|
||||
{isMobileMode &&
|
||||
inspectorCollapsed &&
|
||||
@@ -3641,6 +3995,7 @@ function InnerEditor({
|
||||
</ReactFlow>
|
||||
</WorkflowEditorCatalogContext.Provider>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="wf-editor-empty wf-editor-canvas-empty wf-editor-onboard">
|
||||
@@ -3668,7 +4023,10 @@ function InnerEditor({
|
||||
{/* FNXC:WorkflowSimpleEditor 2026-06-29-23:21: Simple editor row and pencil affordances must open the same node details in desktop compact and mobile presentations. Do not suppress this inspector only because the canvas is hidden; mobile collapse still closes the detail stage and end nodes remain non-inspectable. */}
|
||||
{selectedNodeHasInspector &&
|
||||
!(isMobileMode && inspectorCollapsed) && (
|
||||
<aside className="wf-editor-inspector" data-testid="wf-node-inspector">
|
||||
<aside
|
||||
className={`wf-editor-inspector${simpleViewEnabled ? " wf-editor-inspector--simple" : ""}`}
|
||||
data-testid="wf-node-inspector"
|
||||
>
|
||||
<div className="wf-inspector-heading">
|
||||
{/* FNXC:WorkflowEditor 2026-06-21-10:00: Heading shows the node-kind title (from the help registry) so the pane names what is selected, falling back to the generic "Node" label. */}
|
||||
<h3>{selectedNodeHelp?.title ?? t("workflowNodes.nodeInspector", "Node")}</h3>
|
||||
@@ -5003,7 +5361,10 @@ function InnerEditor({
|
||||
)}
|
||||
|
||||
{selectedEdge && (
|
||||
<aside className="wf-editor-inspector" data-testid="wf-edge-inspector">
|
||||
<aside
|
||||
className={`wf-editor-inspector${simpleViewEnabled ? " wf-editor-inspector--simple" : ""}`}
|
||||
data-testid="wf-edge-inspector"
|
||||
>
|
||||
<div className="wf-inspector-heading">
|
||||
<h3>{t("workflowNodes.edgeInspector", "Edge")}</h3>
|
||||
{isMobileMode && (
|
||||
@@ -5105,6 +5466,25 @@ function InnerEditor({
|
||||
onClose={closeCreateDialog}
|
||||
/>
|
||||
)}
|
||||
<WorkflowAddStepModal
|
||||
open={addStepTarget !== null}
|
||||
onClose={() => setAddStepTarget(null)}
|
||||
palette={PALETTE}
|
||||
disallowContainers={addStepTarget?.insideContainer === true}
|
||||
fragments={templateGroups.fragmentEntries}
|
||||
stepTemplates={templateGroups.stepEntries}
|
||||
pluginTemplates={templateGroups.pluginEntries}
|
||||
templateConflict={templateConflict}
|
||||
onPickPalette={handleAddStepPalettePick}
|
||||
onPickFragment={(fragment) => {
|
||||
if (handleInsertFragment(fragment)) setAddStepTarget(null);
|
||||
}}
|
||||
onPickStepTemplate={handleAddStepTemplatePick}
|
||||
onPickStepTemplateAsOptionalGroup={(tpl) => {
|
||||
handleInsertStepTemplateAsOptionalGroup(tpl);
|
||||
setAddStepTarget(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
|
||||
381
packages/dashboard/app/components/WorkflowSimpleCanvas.css
Normal file
381
packages/dashboard/app/components/WorkflowSimpleCanvas.css
Normal 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;
|
||||
}
|
||||
}
|
||||
492
packages/dashboard/app/components/WorkflowSimpleCanvas.tsx
Normal file
492
packages/dashboard/app/components/WorkflowSimpleCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,10 @@ function promptDef(): WorkflowDefinition {
|
||||
|
||||
describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
|
||||
beforeEach(() => {
|
||||
// FNXC:WorkflowSimpleView 2026-07-10-12:00: this suite drives the advanced
|
||||
// canvas (wf-node-prompt cards); pin the persisted view mode since the
|
||||
// editor now defaults to the simplified view.
|
||||
localStorage.setItem("fusion:wf-editor-view-mode", "advanced");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([promptDef()]);
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue([]);
|
||||
@@ -102,6 +106,7 @@ describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("fusion:wf-editor-view-mode");
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
@@ -498,6 +498,23 @@ describe("workflow-flow-mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSimpleView 2026-07-10-12:00:
|
||||
The editor now defaults to the simplified view ("simple"); this legacy suite
|
||||
was authored against the advanced canvas and the row-list (old "simple
|
||||
editor") behaviors, so it pins the persisted view-mode keys per test. The
|
||||
simplified view has its own dedicated suite below ("simplified view modes").
|
||||
*/
|
||||
beforeEach(() => {
|
||||
localStorage.setItem("fusion:wf-editor-view-mode", "advanced");
|
||||
localStorage.setItem("fusion:wf-mobile-graph-style", "list");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("fusion:wf-editor-view-mode");
|
||||
localStorage.removeItem("fusion:wf-mobile-graph-style");
|
||||
});
|
||||
|
||||
describe("WorkflowNodeEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
@@ -609,7 +626,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(document.body.querySelector(".react-flow__minimap")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lets desktop users switch to the simple graph layout and back", async () => {
|
||||
it("lets desktop users switch to the list layout and back", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
@@ -617,17 +634,17 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
expect(screen.queryByTestId("wf-mobile-shell")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
|
||||
expect(await screen.findByTestId("wf-mobile-shell")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-mobile-tab-graph")).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByRole("button", { name: "start start" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-layout-toggle")).toHaveTextContent("Show canvas editor");
|
||||
expect(screen.getByTestId("wf-view-mode-list")).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-advanced"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-mobile-shell")).not.toBeInTheDocument());
|
||||
expect(screen.getByTestId("wf-layout-toggle")).toHaveTextContent("Show simple editor");
|
||||
expect(screen.getByTestId("wf-view-mode-advanced")).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("surfaces the full styled simple-editor affordance set at desktop width", async () => {
|
||||
@@ -636,7 +653,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
|
||||
const shell = await screen.findByTestId("wf-mobile-shell");
|
||||
for (const panel of ["graph", "add", "settings", "fields", "columns", "actions"]) {
|
||||
@@ -666,7 +683,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
|
||||
|
||||
const shell = await screen.findByTestId("wf-mobile-shell");
|
||||
expect(screen.queryByTestId("wf-layout-toggle")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-view-mode-toggle")).not.toBeInTheDocument();
|
||||
assertSimpleEditorTabScrollOwner(shell, 375);
|
||||
});
|
||||
|
||||
@@ -698,7 +715,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Plain connect");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
await screen.findByTestId("wf-mobile-shell");
|
||||
|
||||
fireEvent.click(await screen.findByTestId("mobile-wf-connect-draft"));
|
||||
@@ -729,7 +746,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
|
||||
await screen.findByText("Save");
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
await screen.findByTestId("wf-mobile-shell");
|
||||
|
||||
fireEvent.click(await screen.findByTestId("mobile-wf-connect-merge"));
|
||||
@@ -778,7 +795,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Default coding workflow");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
await screen.findByTestId("wf-mobile-shell");
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-mobile-tab-actions"));
|
||||
@@ -821,7 +838,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("QA");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
|
||||
const shell = await screen.findByTestId("wf-mobile-shell");
|
||||
expect(screen.getByTestId("wf-mobile-tab-actions")).toBeInTheDocument();
|
||||
@@ -946,7 +963,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
const lintRow = await screen.findByTestId("mobile-wf-node-lint");
|
||||
fireEvent.click(within(lintRow).getAllByRole("button")[0]);
|
||||
|
||||
@@ -966,7 +983,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
fireEvent.click(within(await screen.findByTestId("mobile-wf-node-start")).getAllByRole("button")[0]);
|
||||
|
||||
const startInspector = await screen.findByTestId("wf-node-inspector");
|
||||
@@ -1230,7 +1247,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-list"));
|
||||
|
||||
fireEvent.click(within(await screen.findByTestId("mobile-wf-node-model")).getAllByRole("button")[0]);
|
||||
let inspector = await screen.findByTestId("wf-node-inspector");
|
||||
@@ -4043,3 +4060,119 @@ describe("WorkflowNodeEditor — U6 column agents", () => {
|
||||
expect(note).toHaveTextContent("Reviewer");
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSimpleView 2026-07-10-12:00:
|
||||
Simplified graphical view coverage: default mode, mode persistence, the
|
||||
add-step dialog (palette + search), insert-on-edge affordances, read-only
|
||||
built-in gating, and the mobile canvas/list graph-style toggle. Surface
|
||||
enumeration for the new affordances: desktop simple toolbar, edge "+"
|
||||
buttons, the add-step dialog, the segmented view switch, and both mobile
|
||||
graph styles.
|
||||
*/
|
||||
describe("WorkflowNodeEditor simplified view modes", () => {
|
||||
beforeEach(() => {
|
||||
// Exercise the REAL defaults (simple view / mobile canvas style) instead
|
||||
// of the legacy-suite pins from the file-level hook.
|
||||
localStorage.removeItem("fusion:wf-editor-view-mode");
|
||||
localStorage.removeItem("fusion:wf-mobile-graph-style");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults desktop to the simplified graphical view", async () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
expect(await screen.findByTestId("wf-simple-canvas")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-view-mode-simple")).toHaveAttribute("aria-pressed", "true");
|
||||
// Advanced-only chrome is absent: palette buttons, templates, minimap toggle.
|
||||
expect(document.querySelector(".wf-palette-btn")).toBeNull();
|
||||
expect(screen.queryByTestId("wf-palette-templates")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-minimap-toggle")).not.toBeInTheDocument();
|
||||
// The simplified toolbar keeps the common actions.
|
||||
expect(screen.getByTestId("wf-simple-toolbar-add-step")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-simple-ai-edit")).toBeInTheDocument();
|
||||
expect(screen.getByText("Save")).toBeInTheDocument();
|
||||
// Node cards render on the simplified canvas.
|
||||
expect(await screen.findByTestId("wf-simple-node-gate")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-simple-node-start")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists the chosen view mode and honors it on remount", async () => {
|
||||
const first = render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-simple-canvas");
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-view-mode-advanced"));
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-simple-canvas")).not.toBeInTheDocument());
|
||||
expect(localStorage.getItem("fusion:wf-editor-view-mode")).toBe("advanced");
|
||||
expect(document.querySelector(".wf-palette-btn")).not.toBeNull();
|
||||
|
||||
first.unmount();
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-workflow-name");
|
||||
expect(screen.getByTestId("wf-view-mode-advanced")).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.queryByTestId("wf-simple-canvas")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the searchable add-step dialog and adds the picked node", async () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-simple-canvas");
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-simple-toolbar-add-step"));
|
||||
const dialog = await screen.findByTestId("wf-add-step-modal");
|
||||
expect(within(dialog).getByText("Agent steps")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Automation")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Flow control")).toBeInTheDocument();
|
||||
|
||||
// Search narrows the catalog.
|
||||
fireEvent.change(within(dialog).getByTestId("wf-add-step-search"), { target: { value: "script" } });
|
||||
expect(within(dialog).queryByTestId("wf-add-step-prompt-prompt")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByTestId("wf-add-step-script-script"));
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-add-step-modal")).not.toBeInTheDocument());
|
||||
// def() has an unambiguous edge into end, so the pick inserts there and
|
||||
// the new node lands selected with the inspector open.
|
||||
expect(await screen.findByTestId("wf-node-inspector")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("wf-simple-node-script")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/* NOTE: the per-edge "+" button itself cannot render under jsdom — React
|
||||
Flow only mounts edge components once nodes have measured dimensions.
|
||||
Its insert behavior is covered by insertNodeOnEdge unit tests
|
||||
(workflow-simple-layout.test.ts) and the toolbar-pick test above, which
|
||||
exercises the same insertFromAddStep path end-to-end. */
|
||||
|
||||
it("keeps built-in workflows read-only in the simplified view", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
expect(await screen.findByTestId("wf-simple-canvas")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-readonly-banner")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-simple-toolbar-add-step")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-simple-add-step")).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-testid^="wf-simple-insert-"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults the mobile graph tab to the simplified canvas with a list fallback", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
|
||||
|
||||
await screen.findByTestId("wf-mobile-shell");
|
||||
expect(await screen.findByTestId("wf-mobile-simple-canvas")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-mobile-graph-style-canvas")).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.queryByTestId("mobile-wf-graph")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("wf-mobile-graph-style-list"));
|
||||
expect(await screen.findByTestId("mobile-wf-graph")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-mobile-simple-canvas")).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem("fusion:wf-mobile-graph-style")).toBe("list");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -65,8 +65,13 @@ function layoutNodeWidth(node: LayoutNode): number {
|
||||
* Cycle-safe: a per-node depth cap plus a visited guard bounds the relaxation so
|
||||
* non-rework cycles (should not occur, but be defensive) cannot loop forever.
|
||||
* Nodes unreachable from start land in a trailing layer (max + 1).
|
||||
*
|
||||
* FNXC:WorkflowSimpleView 2026-07-10-12:00:
|
||||
* Exported so the simplified graph view's vertical display layout
|
||||
* (workflow-simple-layout.ts) shares the exact same layering semantics as the
|
||||
* canvas auto-layout instead of re-deriving a drifting copy.
|
||||
*/
|
||||
function layerNodes(nodeIds: string[], edges: FlowEdge[]): Map<string, number> {
|
||||
export function layerNodes(nodeIds: string[], edges: FlowEdge[]): Map<string, number> {
|
||||
const idSet = new Set(nodeIds);
|
||||
// Adjacency over non-rework edges whose endpoints are both layoutable.
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
284
packages/dashboard/app/components/workflow-simple-layout.ts
Normal file
284
packages/dashboard/app/components/workflow-simple-layout.ts
Normal 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;
|
||||
}
|
||||
@@ -8644,12 +8644,22 @@
|
||||
"widgetDefault": "Default"
|
||||
},
|
||||
"workflowNodes": {
|
||||
"addStepAgentSteps": "Agent steps",
|
||||
"addStepAutomation": "Automation",
|
||||
"addStepFlowControl": "Flow control",
|
||||
"addStepFragmentDesc": "Workflow fragment",
|
||||
"addStepNoMatches": "No steps match “{{query}}”.",
|
||||
"addStepSearchLabel": "Search steps",
|
||||
"addStepSearchPlaceholder": "Search steps and templates…",
|
||||
"addStepTitle": "Add a step",
|
||||
"advisory": "Advisory",
|
||||
"autoLayout": "Auto-layout",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
"collapseInspector": "Collapse",
|
||||
"conditionFailure": "failure",
|
||||
"conditionSuccess": "success",
|
||||
"cycleBlocked": "That connection would create a cycle — only rework edges inside a for-each template may loop back",
|
||||
"deleteEdge": "Delete edge",
|
||||
"deleteNode": "Delete node",
|
||||
@@ -8702,9 +8712,13 @@
|
||||
"mobileConnectTarget": "Target node",
|
||||
"mobileGraph": "Graph",
|
||||
"mobileGraphEmpty": "No graph nodes yet.",
|
||||
"mobileGraphStyleCanvas": "Graph",
|
||||
"mobileGraphStyleLabel": "Graph presentation",
|
||||
"mobileGraphStyleList": "List",
|
||||
"mobileMoveDown": "Move down",
|
||||
"mobileMoveUp": "Move up",
|
||||
"mobileNodeKinds": "Node types",
|
||||
"nodeInspector": "Node",
|
||||
"notifyCustom": "Custom",
|
||||
"notifyCustomEvent": "Custom event",
|
||||
"notifyEvent": "Event type",
|
||||
@@ -8714,6 +8728,7 @@
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "Quorum count (n)",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
|
||||
"releaseCapacity": "Downstream capacity",
|
||||
"releaseCondition": "Release condition",
|
||||
"releaseDependency": "Dependency complete",
|
||||
@@ -8726,6 +8741,9 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"showInspector": "Show node details",
|
||||
"simpleAddStep": "Add step",
|
||||
"simpleContainerEmpty": "No steps inside yet",
|
||||
"simpleInsertStep": "Insert step here",
|
||||
"splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
|
||||
"startEntryColumn": "Entry column",
|
||||
"startEntryColumnAuto": "— Auto (first column)",
|
||||
@@ -8740,11 +8758,7 @@
|
||||
"templatesPluginSteps": "Plugin steps",
|
||||
"templatesSection": "Templates",
|
||||
"timeoutMs": "{{timeout}}ms",
|
||||
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out.",
|
||||
"conditionFailure": "failure",
|
||||
"conditionSuccess": "success",
|
||||
"nodeInspector": "Node",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
|
||||
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out."
|
||||
},
|
||||
"workflows": {
|
||||
"aiEdit": "Design with AI",
|
||||
@@ -8760,6 +8774,7 @@
|
||||
"backToWorkflowList": "Back to workflows",
|
||||
"clickToEditDescription": "Click to edit description",
|
||||
"clickToRename": "Click to rename",
|
||||
"closeEditor": "Close workflow editor",
|
||||
"created": "Created workflow \"{{name}}\"",
|
||||
"createDescription": "Description (optional)",
|
||||
"createFailed": "Failed to create workflow",
|
||||
@@ -8776,6 +8791,8 @@
|
||||
"discardConfirm": "Discard",
|
||||
"discardMessage": "You have unsaved changes to this workflow. Discard them?",
|
||||
"discardTitle": "Discard unsaved changes?",
|
||||
"duplicatedEditable": "Duplicated to \"{{name}}\" — editable",
|
||||
"duplicateFailed": "Failed to duplicate workflow",
|
||||
"duplicateToCustomize": "Duplicate to customize",
|
||||
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
|
||||
"emptyTitle": "No workflow selected",
|
||||
@@ -8789,17 +8806,18 @@
|
||||
"importInvalidJson": "That file isn't valid JSON.",
|
||||
"importStripped": "Auto-approval flags were removed from imported nodes",
|
||||
"importTooltip": "Import a workflow from a JSON file",
|
||||
"loadFailed": "Failed to load workflows",
|
||||
"loading": "Loading…",
|
||||
"migrationNotice": "Your legacy workflow steps were converted — find them as templates in the palette and as the \"Migrated steps\" workflow.",
|
||||
"mobileEditorNav": "Workflow editor sections",
|
||||
"mobileSelectNote": "Select a workflow to edit.",
|
||||
"nameLabel": "Workflow name",
|
||||
"newWorkflow": "New workflow",
|
||||
"noneYet": "No workflows yet.",
|
||||
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
|
||||
"saved": "Workflow saved",
|
||||
"savedNotCompilable": "Workflow saved but cannot be compiled",
|
||||
"saveFailed": "Failed to save workflow",
|
||||
"showCanvasEditor": "Show canvas editor",
|
||||
"showSimpleEditor": "Show simple editor",
|
||||
"templateBlank": "Blank",
|
||||
"templateBlankDescription": "Start from an empty start → end graph.",
|
||||
"templateCopyName": "{{name}} copy",
|
||||
@@ -8808,13 +8826,11 @@
|
||||
"templatePickerLabel": "Start from",
|
||||
"templateSectionBuiltin": "Built-in workflows",
|
||||
"templateSectionYours": "Your workflows",
|
||||
"closeEditor": "Close workflow editor",
|
||||
"duplicatedEditable": "Duplicated to \"{{name}}\" — editable",
|
||||
"duplicateFailed": "Failed to duplicate workflow",
|
||||
"loadFailed": "Failed to load workflows",
|
||||
"loading": "Loading…",
|
||||
"noneYet": "No workflows yet.",
|
||||
"title": "Workflows"
|
||||
"title": "Workflows",
|
||||
"viewModeAdvanced": "Advanced",
|
||||
"viewModeLabel": "Editor view",
|
||||
"viewModeList": "List",
|
||||
"viewModeSimple": "Simple"
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?",
|
||||
|
||||
@@ -8633,12 +8633,22 @@
|
||||
"widgetDefault": "Predeterminado"
|
||||
},
|
||||
"workflowNodes": {
|
||||
"addStepAgentSteps": "",
|
||||
"addStepAutomation": "",
|
||||
"addStepFlowControl": "",
|
||||
"addStepFragmentDesc": "",
|
||||
"addStepNoMatches": "",
|
||||
"addStepSearchLabel": "",
|
||||
"addStepSearchPlaceholder": "",
|
||||
"addStepTitle": "",
|
||||
"advisory": "",
|
||||
"autoLayout": "Diseño automático",
|
||||
"codeNote": "Ejecuta TypeScript en un entorno aislado. La sintaxis se valida al guardar.",
|
||||
"codeSource": "Origen (TypeScript)",
|
||||
"codeTimeout": "Tiempo de espera (ms)",
|
||||
"collapseInspector": "",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"cycleBlocked": "Esa conexión crearía un ciclo — solo las conexiones de revisión dentro de una plantilla for-each pueden hacer bucles de retorno",
|
||||
"deleteEdge": "Eliminar conexión",
|
||||
"deleteNode": "Eliminar nodo",
|
||||
@@ -8691,9 +8701,13 @@
|
||||
"mobileConnectTarget": "Nodo de destino",
|
||||
"mobileGraph": "",
|
||||
"mobileGraphEmpty": "",
|
||||
"mobileGraphStyleCanvas": "",
|
||||
"mobileGraphStyleLabel": "",
|
||||
"mobileGraphStyleList": "",
|
||||
"mobileMoveDown": "",
|
||||
"mobileMoveUp": "",
|
||||
"mobileNodeKinds": "",
|
||||
"nodeInspector": "",
|
||||
"notifyCustom": "",
|
||||
"notifyCustomEvent": "",
|
||||
"notifyEvent": "",
|
||||
@@ -8703,6 +8717,7 @@
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "Recuento de quórum (n)",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
|
||||
"releaseCapacity": "Capacidad posterior",
|
||||
"releaseCondition": "Condición de liberación",
|
||||
"releaseDependency": "Dependencia completada",
|
||||
@@ -8715,6 +8730,9 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"showInspector": "",
|
||||
"simpleAddStep": "",
|
||||
"simpleContainerEmpty": "",
|
||||
"simpleInsertStep": "",
|
||||
"splitNote": "Las ramas se ejecutan de forma concurrente desde este nodo. No se permiten uniones de ejecución y fusión dentro de una rama.",
|
||||
"startEntryColumn": "",
|
||||
"startEntryColumnAuto": "",
|
||||
@@ -8729,11 +8747,7 @@
|
||||
"templatesPluginSteps": "Pasos de plugin",
|
||||
"templatesSection": "Plantillas",
|
||||
"timeoutMs": "",
|
||||
"trivialGraphHint": "Este flujo de trabajo solo ejecuta inicio → fin. Añade pasos desde la paleta superior para construirlo.",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"nodeInspector": "",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
|
||||
"trivialGraphHint": "Este flujo de trabajo solo ejecuta inicio → fin. Añade pasos desde la paleta superior para construirlo."
|
||||
},
|
||||
"workflows": {
|
||||
"aiEdit": "Diseñar con IA",
|
||||
@@ -8749,6 +8763,7 @@
|
||||
"backToWorkflowList": "",
|
||||
"clickToEditDescription": "Haz clic para editar la descripción",
|
||||
"clickToRename": "Haz clic para renombrar",
|
||||
"closeEditor": "",
|
||||
"created": "Flujo de trabajo «{{name}}» creado",
|
||||
"createDescription": "Descripción (opcional)",
|
||||
"createFailed": "Error al crear el flujo de trabajo",
|
||||
@@ -8765,6 +8780,8 @@
|
||||
"discardConfirm": "Descartar",
|
||||
"discardMessage": "Tienes cambios no guardados en este flujo de trabajo. ¿Descartarlos?",
|
||||
"discardTitle": "¿Descartar cambios no guardados?",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "Los flujos de trabajo orquestan los pasos y compuertas que se ejecutan alrededor de la ejecución de tareas. Crea uno para comenzar a organizar ese flujo.",
|
||||
"emptyTitle": "Ningún flujo de trabajo seleccionado",
|
||||
@@ -8778,17 +8795,18 @@
|
||||
"importInvalidJson": "Ese archivo no es JSON válido.",
|
||||
"importStripped": "Se eliminaron los indicadores de aprobación automática de los nodos importados",
|
||||
"importTooltip": "Importar un flujo de trabajo desde un archivo JSON",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"migrationNotice": "Los pasos del flujo de trabajo heredado fueron convertidos — encuéntralos como plantillas en la paleta y como el flujo de trabajo «Pasos migrados».",
|
||||
"mobileEditorNav": "",
|
||||
"mobileSelectNote": "",
|
||||
"nameLabel": "Nombre del flujo de trabajo",
|
||||
"newWorkflow": "Nuevo flujo de trabajo",
|
||||
"noneYet": "",
|
||||
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"showCanvasEditor": "",
|
||||
"showSimpleEditor": "",
|
||||
"templateBlank": "En blanco",
|
||||
"templateBlankDescription": "Comenzar desde un grafo vacío inicio → fin.",
|
||||
"templateCopyName": "Copia de {{name}}",
|
||||
@@ -8797,13 +8815,11 @@
|
||||
"templatePickerLabel": "Comenzar desde",
|
||||
"templateSectionBuiltin": "Flujos de trabajo integrados",
|
||||
"templateSectionYours": "Tus flujos de trabajo",
|
||||
"closeEditor": "",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"noneYet": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"viewModeAdvanced": "",
|
||||
"viewModeLabel": "",
|
||||
"viewModeList": "",
|
||||
"viewModeSimple": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -8634,12 +8634,22 @@
|
||||
"widgetDefault": "Par défaut"
|
||||
},
|
||||
"workflowNodes": {
|
||||
"addStepAgentSteps": "",
|
||||
"addStepAutomation": "",
|
||||
"addStepFlowControl": "",
|
||||
"addStepFragmentDesc": "",
|
||||
"addStepNoMatches": "",
|
||||
"addStepSearchLabel": "",
|
||||
"addStepSearchPlaceholder": "",
|
||||
"addStepTitle": "",
|
||||
"advisory": "",
|
||||
"autoLayout": "Disposition automatique",
|
||||
"codeNote": "Exécute du TypeScript en bac à sable. La syntaxe est validée à l’enregistrement.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Délai d’expiration (ms)",
|
||||
"collapseInspector": "",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"cycleBlocked": "Cette connexion créerait un cycle — seules les arêtes de reprise à l'intérieur d'un modèle for-each peuvent boucler en arrière",
|
||||
"deleteEdge": "Supprimer l'arête",
|
||||
"deleteNode": "Supprimer le nœud",
|
||||
@@ -8692,9 +8702,13 @@
|
||||
"mobileConnectTarget": "Nœud cible",
|
||||
"mobileGraph": "",
|
||||
"mobileGraphEmpty": "",
|
||||
"mobileGraphStyleCanvas": "",
|
||||
"mobileGraphStyleLabel": "",
|
||||
"mobileGraphStyleList": "",
|
||||
"mobileMoveDown": "",
|
||||
"mobileMoveUp": "",
|
||||
"mobileNodeKinds": "",
|
||||
"nodeInspector": "",
|
||||
"notifyCustom": "",
|
||||
"notifyCustomEvent": "",
|
||||
"notifyEvent": "",
|
||||
@@ -8704,6 +8718,7 @@
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "Nombre de quorum (n)",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
|
||||
"releaseCapacity": "Capacité en aval",
|
||||
"releaseCondition": "Condition de libération",
|
||||
"releaseDependency": "Dépendance terminée",
|
||||
@@ -8716,6 +8731,9 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"showInspector": "",
|
||||
"simpleAddStep": "",
|
||||
"simpleContainerEmpty": "",
|
||||
"simpleInsertStep": "",
|
||||
"splitNote": "Les branches s’exécutent simultanément depuis ce nœud. Les jointures d’exécution et de fusion ne sont pas autorisées dans une branche.",
|
||||
"startEntryColumn": "",
|
||||
"startEntryColumnAuto": "",
|
||||
@@ -8730,11 +8748,7 @@
|
||||
"templatesPluginSteps": "Étapes de plugin",
|
||||
"templatesSection": "Modèles",
|
||||
"timeoutMs": "",
|
||||
"trivialGraphHint": "Ce workflow ne fait qu'exécuter début → fin. Ajoutez des étapes depuis la palette ci-dessus pour le développer.",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"nodeInspector": "",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
|
||||
"trivialGraphHint": "Ce workflow ne fait qu'exécuter début → fin. Ajoutez des étapes depuis la palette ci-dessus pour le développer."
|
||||
},
|
||||
"workflows": {
|
||||
"aiEdit": "Concevoir avec l'IA",
|
||||
@@ -8750,6 +8764,7 @@
|
||||
"backToWorkflowList": "",
|
||||
"clickToEditDescription": "Cliquez pour modifier la description",
|
||||
"clickToRename": "Cliquez pour renommer",
|
||||
"closeEditor": "",
|
||||
"created": "Workflow « {{name}} » créé",
|
||||
"createDescription": "Description (facultatif)",
|
||||
"createFailed": "Impossible de créer le workflow",
|
||||
@@ -8766,6 +8781,8 @@
|
||||
"discardConfirm": "Abandonner",
|
||||
"discardMessage": "Vous avez des modifications non enregistrées sur ce workflow. Les abandonner ?",
|
||||
"discardTitle": "Abandonner les modifications non enregistrées ?",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "Les workflows orchestrent les étapes et les portails qui s'exécutent autour de l'exécution des tâches. Créez-en un pour commencer à organiser ce flux.",
|
||||
"emptyTitle": "Aucun workflow sélectionné",
|
||||
@@ -8779,17 +8796,18 @@
|
||||
"importInvalidJson": "Ce fichier n'est pas un JSON valide.",
|
||||
"importStripped": "Les indicateurs d'approbation automatique ont été supprimés des nœuds importés",
|
||||
"importTooltip": "Importer un workflow depuis un fichier JSON",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"migrationNotice": "Vos anciennes étapes de workflow ont été converties — retrouvez-les sous forme de modèles dans la palette et sous le workflow « Étapes migrées ».",
|
||||
"mobileEditorNav": "",
|
||||
"mobileSelectNote": "",
|
||||
"nameLabel": "Nom du workflow",
|
||||
"newWorkflow": "Nouveau workflow",
|
||||
"noneYet": "",
|
||||
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"showCanvasEditor": "",
|
||||
"showSimpleEditor": "",
|
||||
"templateBlank": "Vierge",
|
||||
"templateBlankDescription": "Partez d'un graphe vide début → fin.",
|
||||
"templateCopyName": "{{name}} (copie)",
|
||||
@@ -8798,13 +8816,11 @@
|
||||
"templatePickerLabel": "Partir de",
|
||||
"templateSectionBuiltin": "Workflows intégrés",
|
||||
"templateSectionYours": "Vos workflows",
|
||||
"closeEditor": "",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"noneYet": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"viewModeAdvanced": "",
|
||||
"viewModeLabel": "",
|
||||
"viewModeList": "",
|
||||
"viewModeSimple": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -8633,12 +8633,22 @@
|
||||
"widgetDefault": "기본값"
|
||||
},
|
||||
"workflowNodes": {
|
||||
"addStepAgentSteps": "",
|
||||
"addStepAutomation": "",
|
||||
"addStepFlowControl": "",
|
||||
"addStepFragmentDesc": "",
|
||||
"addStepNoMatches": "",
|
||||
"addStepSearchLabel": "",
|
||||
"addStepSearchPlaceholder": "",
|
||||
"addStepTitle": "",
|
||||
"advisory": "",
|
||||
"autoLayout": "자동 배치",
|
||||
"codeNote": "샌드박스에서 TypeScript를 실행합니다. 구문은 저장 시 검증됩니다.",
|
||||
"codeSource": "소스(TypeScript)",
|
||||
"codeTimeout": "제한 시간(ms)",
|
||||
"collapseInspector": "",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"cycleBlocked": "이 연결은 순환을 만듭니다 — for-each 템플릿 내부의 재작업 엣지만 되돌아올 수 있습니다",
|
||||
"deleteEdge": "엣지 삭제",
|
||||
"deleteNode": "노드 삭제",
|
||||
@@ -8691,9 +8701,13 @@
|
||||
"mobileConnectTarget": "대상 노드",
|
||||
"mobileGraph": "",
|
||||
"mobileGraphEmpty": "",
|
||||
"mobileGraphStyleCanvas": "",
|
||||
"mobileGraphStyleLabel": "",
|
||||
"mobileGraphStyleList": "",
|
||||
"mobileMoveDown": "",
|
||||
"mobileMoveUp": "",
|
||||
"mobileNodeKinds": "",
|
||||
"nodeInspector": "",
|
||||
"notifyCustom": "",
|
||||
"notifyCustomEvent": "",
|
||||
"notifyEvent": "",
|
||||
@@ -8703,6 +8717,7 @@
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "정족수 개수(n)",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
|
||||
"releaseCapacity": "다운스트림 용량",
|
||||
"releaseCondition": "릴리스 조건",
|
||||
"releaseDependency": "종속성 완료",
|
||||
@@ -8715,6 +8730,9 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"showInspector": "",
|
||||
"simpleAddStep": "",
|
||||
"simpleContainerEmpty": "",
|
||||
"simpleInsertStep": "",
|
||||
"splitNote": "분기는 이 노드에서 동시에 실행됩니다. 분기 내에서는 실행 및 병합 이음새가 허용되지 않습니다.",
|
||||
"startEntryColumn": "",
|
||||
"startEntryColumnAuto": "",
|
||||
@@ -8729,11 +8747,7 @@
|
||||
"templatesPluginSteps": "플러그인 단계",
|
||||
"templatesSection": "템플릿",
|
||||
"timeoutMs": "",
|
||||
"trivialGraphHint": "이 워크플로는 시작 → 끝만 실행합니다. 위 팔레트에서 단계를 추가하여 구성하세요.",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"nodeInspector": "",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
|
||||
"trivialGraphHint": "이 워크플로는 시작 → 끝만 실행합니다. 위 팔레트에서 단계를 추가하여 구성하세요."
|
||||
},
|
||||
"workflows": {
|
||||
"aiEdit": "AI로 디자인",
|
||||
@@ -8749,6 +8763,7 @@
|
||||
"backToWorkflowList": "",
|
||||
"clickToEditDescription": "클릭하여 설명 편집",
|
||||
"clickToRename": "클릭하여 이름 변경",
|
||||
"closeEditor": "",
|
||||
"created": "워크플로 \"{{name}}\"을(를) 만들었습니다",
|
||||
"createDescription": "설명 (선택 사항)",
|
||||
"createFailed": "워크플로 만들기에 실패했습니다",
|
||||
@@ -8765,6 +8780,8 @@
|
||||
"discardConfirm": "버리기",
|
||||
"discardMessage": "이 워크플로에 저장하지 않은 변경 사항이 있습니다. 버릴까요?",
|
||||
"discardTitle": "저장하지 않은 변경 사항을 버릴까요?",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "워크플로는 작업 실행 전후에 실행되는 단계와 게이트를 조율합니다. 하나 만들어 그 흐름을 구성해 보세요.",
|
||||
"emptyTitle": "선택된 워크플로 없음",
|
||||
@@ -8778,17 +8795,18 @@
|
||||
"importInvalidJson": "이 파일은 유효한 JSON이 아닙니다.",
|
||||
"importStripped": "가져온 노드에서 자동 승인 플래그가 제거되었습니다",
|
||||
"importTooltip": "JSON 파일에서 워크플로 가져오기",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"migrationNotice": "이전 워크플로 단계가 변환되었습니다 — 팔레트의 템플릿과 \"마이그레이션된 단계\" 워크플로에서 찾을 수 있습니다.",
|
||||
"mobileEditorNav": "",
|
||||
"mobileSelectNote": "",
|
||||
"nameLabel": "워크플로 이름",
|
||||
"newWorkflow": "새 워크플로",
|
||||
"noneYet": "",
|
||||
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"showCanvasEditor": "",
|
||||
"showSimpleEditor": "",
|
||||
"templateBlank": "빈 워크플로",
|
||||
"templateBlankDescription": "빈 시작 → 끝 그래프에서 시작합니다.",
|
||||
"templateCopyName": "{{name}} 복사본",
|
||||
@@ -8797,13 +8815,11 @@
|
||||
"templatePickerLabel": "시작점",
|
||||
"templateSectionBuiltin": "기본 제공 워크플로",
|
||||
"templateSectionYours": "내 워크플로",
|
||||
"closeEditor": "",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"noneYet": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"viewModeAdvanced": "",
|
||||
"viewModeLabel": "",
|
||||
"viewModeList": "",
|
||||
"viewModeSimple": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -8633,12 +8633,22 @@
|
||||
"widgetDefault": "默认"
|
||||
},
|
||||
"workflowNodes": {
|
||||
"addStepAgentSteps": "",
|
||||
"addStepAutomation": "",
|
||||
"addStepFlowControl": "",
|
||||
"addStepFragmentDesc": "",
|
||||
"addStepNoMatches": "",
|
||||
"addStepSearchLabel": "",
|
||||
"addStepSearchPlaceholder": "",
|
||||
"addStepTitle": "",
|
||||
"advisory": "",
|
||||
"autoLayout": "自动布局",
|
||||
"codeNote": "在沙箱中运行 TypeScript。语法在保存时校验。",
|
||||
"codeSource": "源代码(TypeScript)",
|
||||
"codeTimeout": "超时(毫秒)",
|
||||
"collapseInspector": "",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"cycleBlocked": "此连接会产生循环——只有 for-each 模板内的返工连线才允许回环",
|
||||
"deleteEdge": "删除连线",
|
||||
"deleteNode": "删除节点",
|
||||
@@ -8691,9 +8701,13 @@
|
||||
"mobileConnectTarget": "目标节点",
|
||||
"mobileGraph": "",
|
||||
"mobileGraphEmpty": "",
|
||||
"mobileGraphStyleCanvas": "",
|
||||
"mobileGraphStyleLabel": "",
|
||||
"mobileGraphStyleList": "",
|
||||
"mobileMoveDown": "",
|
||||
"mobileMoveUp": "",
|
||||
"mobileNodeKinds": "",
|
||||
"nodeInspector": "",
|
||||
"notifyCustom": "",
|
||||
"notifyCustomEvent": "",
|
||||
"notifyEvent": "",
|
||||
@@ -8703,6 +8717,7 @@
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "法定数(n)",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
|
||||
"releaseCapacity": "下游容量",
|
||||
"releaseCondition": "释放条件",
|
||||
"releaseDependency": "依赖完成",
|
||||
@@ -8715,6 +8730,9 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"showInspector": "",
|
||||
"simpleAddStep": "",
|
||||
"simpleContainerEmpty": "",
|
||||
"simpleInsertStep": "",
|
||||
"splitNote": "分支从此节点并发运行。分支内不允许执行和合并接缝。",
|
||||
"startEntryColumn": "",
|
||||
"startEntryColumnAuto": "",
|
||||
@@ -8729,11 +8747,7 @@
|
||||
"templatesPluginSteps": "插件步骤",
|
||||
"templatesSection": "模板",
|
||||
"timeoutMs": "",
|
||||
"trivialGraphHint": "此工作流仅运行开始→结束。从上方面板添加步骤以构建工作流。",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"nodeInspector": "",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
|
||||
"trivialGraphHint": "此工作流仅运行开始→结束。从上方面板添加步骤以构建工作流。"
|
||||
},
|
||||
"workflows": {
|
||||
"aiEdit": "用 AI 设计",
|
||||
@@ -8749,6 +8763,7 @@
|
||||
"backToWorkflowList": "",
|
||||
"clickToEditDescription": "点击编辑描述",
|
||||
"clickToRename": "点击重命名",
|
||||
"closeEditor": "",
|
||||
"created": "已创建工作流“{{name}}”",
|
||||
"createDescription": "描述(可选)",
|
||||
"createFailed": "创建工作流失败",
|
||||
@@ -8765,6 +8780,8 @@
|
||||
"discardConfirm": "放弃",
|
||||
"discardMessage": "此工作流有未保存的更改,要放弃吗?",
|
||||
"discardTitle": "放弃未保存的更改?",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "工作流编排任务执行前后运行的步骤和关卡。创建一个以开始安排流程。",
|
||||
"emptyTitle": "未选择工作流",
|
||||
@@ -8778,17 +8795,18 @@
|
||||
"importInvalidJson": "该文件不是有效的 JSON。",
|
||||
"importStripped": "已从导入的节点中移除自动审批标志",
|
||||
"importTooltip": "从 JSON 文件导入工作流",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"migrationNotice": "您的旧版工作流步骤已转换——在面板模板和“已迁移步骤”工作流中查找。",
|
||||
"mobileEditorNav": "",
|
||||
"mobileSelectNote": "",
|
||||
"nameLabel": "工作流名称",
|
||||
"newWorkflow": "新建工作流",
|
||||
"noneYet": "",
|
||||
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"showCanvasEditor": "",
|
||||
"showSimpleEditor": "",
|
||||
"templateBlank": "空白",
|
||||
"templateBlankDescription": "从空的开始→结束图开始。",
|
||||
"templateCopyName": "{{name}} 副本",
|
||||
@@ -8797,13 +8815,11 @@
|
||||
"templatePickerLabel": "从…开始",
|
||||
"templateSectionBuiltin": "内置工作流",
|
||||
"templateSectionYours": "我的工作流",
|
||||
"closeEditor": "",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"noneYet": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"viewModeAdvanced": "",
|
||||
"viewModeLabel": "",
|
||||
"viewModeList": "",
|
||||
"viewModeSimple": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -8633,12 +8633,22 @@
|
||||
"widgetDefault": "預設"
|
||||
},
|
||||
"workflowNodes": {
|
||||
"addStepAgentSteps": "",
|
||||
"addStepAutomation": "",
|
||||
"addStepFlowControl": "",
|
||||
"addStepFragmentDesc": "",
|
||||
"addStepNoMatches": "",
|
||||
"addStepSearchLabel": "",
|
||||
"addStepSearchPlaceholder": "",
|
||||
"addStepTitle": "",
|
||||
"advisory": "",
|
||||
"autoLayout": "自動排版",
|
||||
"codeNote": "在沙箱中執行 TypeScript。語法會在儲存時驗證。",
|
||||
"codeSource": "原始碼(TypeScript)",
|
||||
"codeTimeout": "逾時(毫秒)",
|
||||
"collapseInspector": "",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"cycleBlocked": "該連線會形成循環 — 只有 for-each 範本內的重做邊才能回繞",
|
||||
"deleteEdge": "刪除邊",
|
||||
"deleteNode": "刪除節點",
|
||||
@@ -8691,9 +8701,13 @@
|
||||
"mobileConnectTarget": "目標節點",
|
||||
"mobileGraph": "",
|
||||
"mobileGraphEmpty": "",
|
||||
"mobileGraphStyleCanvas": "",
|
||||
"mobileGraphStyleLabel": "",
|
||||
"mobileGraphStyleList": "",
|
||||
"mobileMoveDown": "",
|
||||
"mobileMoveUp": "",
|
||||
"mobileNodeKinds": "",
|
||||
"nodeInspector": "",
|
||||
"notifyCustom": "",
|
||||
"notifyCustomEvent": "",
|
||||
"notifyEvent": "",
|
||||
@@ -8703,6 +8717,7 @@
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "法定人數(n)",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here.",
|
||||
"releaseCapacity": "下游容量",
|
||||
"releaseCondition": "釋放條件",
|
||||
"releaseDependency": "相依完成",
|
||||
@@ -8715,6 +8730,9 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"showInspector": "",
|
||||
"simpleAddStep": "",
|
||||
"simpleContainerEmpty": "",
|
||||
"simpleInsertStep": "",
|
||||
"splitNote": "分支從此節點並行執行。分支內不允許執行與合併接縫。",
|
||||
"startEntryColumn": "",
|
||||
"startEntryColumnAuto": "",
|
||||
@@ -8729,11 +8747,7 @@
|
||||
"templatesPluginSteps": "外掛步驟",
|
||||
"templatesSection": "範本",
|
||||
"timeoutMs": "",
|
||||
"trivialGraphHint": "這個工作流程只會執行 start → end。請從上方的選盤新增步驟來建構它。",
|
||||
"conditionFailure": "",
|
||||
"conditionSuccess": "",
|
||||
"nodeInspector": "",
|
||||
"readOnlyDuplicateToEdit": "Built-in structure is read-only — prompts on prompt and gate nodes can be edited here."
|
||||
"trivialGraphHint": "這個工作流程只會執行 start → end。請從上方的選盤新增步驟來建構它。"
|
||||
},
|
||||
"workflows": {
|
||||
"aiEdit": "使用 AI 設計",
|
||||
@@ -8749,6 +8763,7 @@
|
||||
"backToWorkflowList": "",
|
||||
"clickToEditDescription": "按一下以編輯說明",
|
||||
"clickToRename": "按一下以重新命名",
|
||||
"closeEditor": "",
|
||||
"created": "已建立工作流程「{{name}}」",
|
||||
"createDescription": "說明(選填)",
|
||||
"createFailed": "建立工作流程失敗",
|
||||
@@ -8765,6 +8780,8 @@
|
||||
"discardConfirm": "捨棄",
|
||||
"discardMessage": "你對這個工作流程有未儲存的變更。要捨棄它們嗎?",
|
||||
"discardTitle": "要捨棄未儲存的變更嗎?",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "工作流程會協調任務執行前後所執行的步驟與閘門。建立一個來開始安排流程。",
|
||||
"emptyTitle": "未選取工作流程",
|
||||
@@ -8778,17 +8795,18 @@
|
||||
"importInvalidJson": "該檔案不是有效的 JSON。",
|
||||
"importStripped": "已從匯入的節點移除自動核准旗標",
|
||||
"importTooltip": "從 JSON 檔案匯入工作流程",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"migrationNotice": "你的舊版工作流程步驟已轉換 — 可在選盤中以範本形式找到它們,以及名為「已遷移步驟」的工作流程。",
|
||||
"mobileEditorNav": "",
|
||||
"mobileSelectNote": "",
|
||||
"nameLabel": "工作流程名稱",
|
||||
"newWorkflow": "新工作流程",
|
||||
"noneYet": "",
|
||||
"readOnlyBuiltin": "Built-in workflow: structure is read-only, prompts are editable.",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"showCanvasEditor": "",
|
||||
"showSimpleEditor": "",
|
||||
"templateBlank": "空白",
|
||||
"templateBlankDescription": "從空的 start → end 圖形開始。",
|
||||
"templateCopyName": "{{name}} 複本",
|
||||
@@ -8797,13 +8815,11 @@
|
||||
"templatePickerLabel": "起始來源",
|
||||
"templateSectionBuiltin": "內建工作流程",
|
||||
"templateSectionYours": "你的工作流程",
|
||||
"closeEditor": "",
|
||||
"duplicatedEditable": "",
|
||||
"duplicateFailed": "",
|
||||
"loadFailed": "",
|
||||
"loading": "",
|
||||
"noneYet": "",
|
||||
"title": ""
|
||||
"title": "",
|
||||
"viewModeAdvanced": "",
|
||||
"viewModeLabel": "",
|
||||
"viewModeList": "",
|
||||
"viewModeSimple": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
Reference in New Issue
Block a user