diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index 2fb92fe56c..efb2c06f24 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -533,6 +533,84 @@ color: var(--bg); } +/* Inline name + description strip (KTD-10). */ +.wf-name-strip { + display: flex; + align-items: baseline; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-sm); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.wf-workflow-name, +.wf-workflow-name--readonly { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + background: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 2px var(--space-xs); + cursor: pointer; + text-align: left; +} + +.wf-workflow-name:hover { + background: var(--bg-tertiary); +} + +.wf-workflow-name--readonly { + cursor: default; +} + +.wf-workflow-name-input { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 2px var(--space-xs); +} + +.wf-workflow-description, +.wf-workflow-description--readonly { + font-size: 0.78rem; + color: var(--text-tertiary); + background: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 2px var(--space-xs); + cursor: pointer; + text-align: left; +} + +.wf-workflow-description:hover { + background: var(--bg-tertiary); +} + +.wf-workflow-description--readonly { + cursor: default; +} + +.wf-workflow-description-input { + font-size: 0.78rem; + color: var(--text); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 2px var(--space-xs); + min-width: 220px; +} + +/* Create-workflow dialog (KTD-7). */ +.wf-create-error { + margin: var(--space-xs) 0 0; + font-size: 0.8rem; + color: var(--ws-error); +} + .wf-column-panel { display: flex; flex-direction: column; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 327d036aa3..262386195e 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -32,6 +32,7 @@ import type { Agent } from "../api"; import type { DiscoveredSkill } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; +import { useConfirm } from "../hooks/useConfirm"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext"; @@ -86,6 +87,29 @@ function parseModelDropdownValue(value: string): { provider: string; modelId: st return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1) }; } +/** Normalized serialization of the editor's authoring state for dirty tracking + * (U4). Serializes nodes/edges through flowToIr (so mapping-layer defaults are + * materialized identically on the loaded and live sides) plus the editor-owned + * name/description and the resulting layout (auto-layout/drag position changes + * count as dirty). Returns a stable JSON string for cheap equality. */ +function serializeGraph( + name: string, + description: string, + nodes: FlowNode[], + edges: FlowEdge[], + columns: WorkflowIrColumn[], + fields: WorkflowFieldDefinition[], +): string { + const { ir, layout } = flowToIr( + name, + nodes, + edges, + columns.length ? columns : undefined, + fields.length ? fields : undefined, + ); + return JSON.stringify({ name, description, ir, layout }); +} + interface WorkflowNodeEditorProps { isOpen: boolean; onClose: () => void; @@ -124,6 +148,128 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, ]; +/** Local create-workflow dialog (KTD-7). Built on the shared `.modal` primitives + * (precedent: NewTaskModal). Owns its own name/description/error state; the + * parent supplies an async `onCreate` that performs the createWorkflow call and + * throws on failure so the dialog can surface server rejections inline without + * losing the typed input. Escape/overlay close (no dirty state of its own). */ +function CreateWorkflowDialog({ + onCreate, + onClose, +}: { + onCreate: (name: string, description: string) => Promise; + onClose: () => void; +}) { + const { t } = useTranslation("app"); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const nameRef = useRef(null); + + useEffect(() => { + nameRef.current?.focus(); + }, []); + + const overlayProps = useOverlayDismiss(onClose); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) { + setError(t("workflows.createNameRequired", "Enter a workflow name")); + return; + } + setSubmitting(true); + setError(null); + try { + await onCreate(trimmed, description.trim()); + // Success path closes the dialog from the parent. + } catch (err) { + setError(getErrorMessage(err) || t("workflows.createFailed", "Failed to create workflow")); + setSubmitting(false); + } + }, + [name, description, onCreate, t], + ); + + return ( +
+
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }} + > +
+

{t("workflows.createTitle", "New workflow")}

+ +
+
+
+ +