11"use client" ;
22
3- import { useState } from "react" ;
3+ import { useMemo , useState } from "react" ;
44
55import { Button } from "@/components/ui/button" ;
66import { WorkflowGraph } from "@/components/workflow-graph" ;
77import { API_ROUTES } from "@/lib/api-routes" ;
88import type { ApprovalWorkflow , StepChange } from "@/lib/approval-workflow" ;
9+ import {
10+ validateWorkflow ,
11+ isActivatable ,
12+ type WorkflowIssue ,
13+ } from "@/lib/workflow-validate" ;
914
1015/**
1116 * The conversational workflow editor — the layer competitors don't have.
1217 *
13- * You type what you want ("above $10k add a CFO approval") and the agent proposes
14- * a rewrite. Nothing is applied until you APPROVE: the graph shows the proposal
15- * with the diff (added / changed / removed), and you either keep it (the proposal
16- * becomes the current workflow) or revert (discard it). The current workflow is
17- * the only thing that would ever drive the pipeline — the proposal is preview-only.
18+ * You type what you want ("above $10k add a CFO approval") and a structured-output
19+ * model maps it to one edit op, which deterministic code applies to PROPOSE a
20+ * rewrite (it's plain-language editing, not an open-ended agent). Nothing applies
21+ * until you APPROVE: the graph shows the proposal with the diff (added / changed /
22+ * removed), and you keep it (the proposal becomes current) or revert (discard it).
23+ * The current workflow is the only thing that drives the pipeline — proposal is
24+ * preview-only.
1825 *
1926 * Holds two workflows: `current` (the approved one) and an optional `proposal`
2027 * (a pending edit). Reads POST /api/workflow/edit.
@@ -83,14 +90,30 @@ export const WorkflowEditor = ({
8390 }
8491 } ;
8592
93+ // Validate whatever's on screen (the proposal if pending, else the live one).
94+ // Errors block applying a proposal; warnings are surfaced but don't block.
95+ const shown = proposal ?. proposed ?? current ;
96+ const issues = useMemo ( ( ) => validateWorkflow ( shown ) , [ shown ] ) ;
97+ const canApply = isActivatable ( issues ) ;
98+
8699 const approve = ( ) => {
87- if ( ! proposal ) return ;
100+ if ( ! proposal || ! isActivatable ( validateWorkflow ( proposal . proposed ) ) )
101+ return ;
88102 setCurrent ( proposal . proposed ) ; // the proposal becomes live
89103 setProposal ( null ) ;
90104 } ;
91105 const revert = ( ) => {
92106 setProposal ( null ) ; // discard — current is untouched
93107 } ;
108+ // Reset = restore the originally-derived workflow (NOT recompute — that's the
109+ // left pane's "Re-run discovery"). Instant, deterministic.
110+ const isEdited = current !== initial ;
111+ const reset = ( ) => {
112+ setCurrent ( initial ) ;
113+ setProposal ( null ) ;
114+ setChips ( suggestions ) ;
115+ setError ( null ) ;
116+ } ;
94117
95118 const changedCount = proposal
96119 ? proposal . changes . filter ( ( c ) => c . kind !== "unchanged" ) . length
@@ -106,12 +129,16 @@ export const WorkflowEditor = ({
106129 < WorkflowGraph
107130 workflow = { proposal . proposed }
108131 changes = { proposal . changes }
132+ issues = { issues }
109133 />
110134 ) : (
111- < WorkflowGraph workflow = { current } />
135+ < WorkflowGraph workflow = { current } issues = { issues } />
112136 ) }
113137 </ div >
114138
139+ { /* Validation summary — "sound" or the list of issues (errors block apply). */ }
140+ < ValidationPanel issues = { issues } />
141+
115142 { /* Pending-edit bar: approve / revert */ }
116143 { proposal && (
117144 < div className = "flex items-center justify-between gap-2 rounded-xl bg-accent-soft/60 px-3.5 py-2.5 ring-1 ring-inset ring-accent/15" >
@@ -123,7 +150,12 @@ export const WorkflowEditor = ({
123150 < Button variant = "ghost" size = "sm" onClick = { revert } >
124151 Revert
125152 </ Button >
126- < Button size = "sm" onClick = { approve } >
153+ < Button
154+ size = "sm"
155+ onClick = { approve }
156+ disabled = { ! canApply }
157+ title = { canApply ? undefined : "Fix the errors before applying" }
158+ >
127159 Approve
128160 </ Button >
129161 </ div >
@@ -173,9 +205,26 @@ export const WorkflowEditor = ({
173205 value = { instruction }
174206 onChange = { ( e ) => setInstruction ( e . target . value ) }
175207 disabled = { busy }
176- placeholder = "Tell the agent how to change the workflow …"
208+ placeholder = "Describe a change in plain language …"
177209 className = "h-10 flex-1 rounded-lg bg-surface px-3.5 text-[13px] text-ink shadow-card outline-none ring-1 ring-inset ring-line-strong transition-shadow placeholder:text-faint focus:ring-2 focus:ring-accent-ring disabled:opacity-60"
178210 />
211+ { isEdited && ! proposal && (
212+ < Button
213+ type = "button"
214+ variant = "ghost"
215+ onClick = { ( ) => {
216+ if (
217+ window . confirm (
218+ "Discard all edits and restore the workflow the agent first derived?" ,
219+ )
220+ )
221+ reset ( ) ;
222+ } }
223+ title = "Restore the originally derived workflow"
224+ >
225+ Reset
226+ </ Button >
227+ ) }
179228 < Button
180229 type = "submit"
181230 loading = { busy }
@@ -189,6 +238,56 @@ export const WorkflowEditor = ({
189238 ) ;
190239} ;
191240
241+ /**
242+ * The validation summary. "Sound" when there's nothing to flag; otherwise the list
243+ * of errors (red, block applying) and warnings (amber, control best-practices). This
244+ * is what shows the tool understands the workflow, not just draws it.
245+ */
246+ const ValidationPanel = ( { issues } : { issues : WorkflowIssue [ ] } ) => {
247+ if ( issues . length === 0 ) {
248+ return (
249+ < div className = "flex items-center gap-1.5 rounded-xl bg-ok-soft/50 px-3.5 py-2 text-[12px] font-medium text-ok ring-1 ring-inset ring-ok-line/60" >
250+ < span aria-hidden > ✓</ span > Sound — passes the approval-control checks
251+ </ div >
252+ ) ;
253+ }
254+ const errors = issues . filter ( ( i ) => i . severity === "error" ) ;
255+ const warnings = issues . filter ( ( i ) => i . severity === "warning" ) ;
256+ return (
257+ < div className = "space-y-1.5 rounded-xl bg-subtle/60 px-3 py-2.5 ring-1 ring-inset ring-line" >
258+ < div className = "flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-faint" >
259+ Checks
260+ { errors . length > 0 && (
261+ < span className = "rounded-full bg-danger-soft px-1.5 py-0.5 text-danger" >
262+ { errors . length } to fix
263+ </ span >
264+ ) }
265+ { warnings . length > 0 && (
266+ < span className = "rounded-full bg-warn-soft px-1.5 py-0.5 text-warn" >
267+ { warnings . length } { warnings . length === 1 ? "warning" : "warnings" }
268+ </ span >
269+ ) }
270+ </ div >
271+ < ul className = "space-y-1" >
272+ { issues . map ( ( iss , i ) => (
273+ < li
274+ key = { `${ iss . code } -${ i } ` }
275+ className = "flex gap-2 text-[12px] leading-snug text-ink/90"
276+ >
277+ < span
278+ aria-hidden
279+ className = { iss . severity === "error" ? "text-danger" : "text-warn" }
280+ >
281+ { iss . severity === "error" ? "✕" : "⚠" }
282+ </ span >
283+ < span > { iss . message } </ span >
284+ </ li >
285+ ) ) }
286+ </ ul >
287+ </ div >
288+ ) ;
289+ } ;
290+
192291/** A small four-point spark, marking the AI-generated suggestions. */
193292const SparkIcon = ( ) => {
194293 return (
0 commit comments