Files
fusion/packages/dashboard/app/components/WorkflowAddStepModal.tsx
gsxdsm 4679c4960f Address PR review feedback (#2006)
- fix: fragments are hidden in the add-step dialog when the target edge is
  inside a foreach/loop/optional-group (they expand to top-level subgraphs
  and cannot splice into a template-child edge), and
  spliceInsertedSubgraphOnEdge now refuses container-internal edges as a
  second line of defense (Greptile P1 x2).
- test: add-step modal container-target hiding, multi-entry/exit splice
  fan-out, internal-cycle entries fallback, ambiguous merge-inbound
  lifecycle fallback, and edge-targeted "as optional group" wiring
  (CodeRabbit nitpicks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:06:22 -07:00

303 lines
13 KiB
TypeScript

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 — and
FRAGMENTS are hidden too (PR #2006 review): fragments expand to top-level
subgraphs, so splicing one into a template-child edge would create
cross-boundary edges into the container. Step templates stay available
(they materialize a single prompt/script node, valid as a sibling child).
*/
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(
() => (disallowContainers ? [] : fragments.filter((f) => !q || f.name.toLowerCase().includes(q))),
[fragments, q, disallowContainers],
);
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>
);
}