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