Merge pull request #1703 from Runfusion/gsxdsm/workflow-optional-steps
feat(dashboard): workflow optional steps — authoring, full-modal parity, stepwise seam
This commit is contained in:
5
.changeset/workflow-optional-steps.md
Normal file
5
.changeset/workflow-optional-steps.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add workflow optional steps: workflows can declare optional step templates that tasks toggle on/off per task, with a workflow-level default. The built-in coding and stepwise-coding workflows expose agent browser verification as an optional step (the stepwise workflow gains a pre-merge workflow-step seam so enabled steps actually run). Optional steps are authorable in the node editor, preserved across node-editor saves, and selectable from a steps dropdown in both the quick-add card and the full New Task modal.
|
||||
@@ -0,0 +1,556 @@
|
||||
---
|
||||
title: "feat: Workflow optional steps — node-editor authoring, full-modal parity, stepwise declaration"
|
||||
status: active
|
||||
date: 2026-06-20
|
||||
type: feat
|
||||
plan_id: 2026-06-20-001-feat-workflow-optional-steps-node-editor-modal
|
||||
---
|
||||
|
||||
# feat: Workflow optional steps — node-editor authoring, full-modal parity, stepwise declaration
|
||||
|
||||
## Summary
|
||||
|
||||
Workflows can declare **optional steps** — workflow-step templates (e.g. Browser Verification) that a
|
||||
task may toggle on/off per task, with a workflow-level `defaultOn` seeding the initial state. Most of
|
||||
the plumbing for this already exists on the `gsxdsm/workflow-optional-steps` branch: the IR type
|
||||
(`WorkflowOptionalStep` + `WorkflowIrV2.optionalSteps`), parse-time validation, the
|
||||
`resolveWorkflowOptionalSteps` resolver, the `GET /workflows/:id/optional-steps` route, the
|
||||
`fetchWorkflowOptionalSteps` API client, the executor's `enabledWorkflowSteps` execution path, the
|
||||
**inline quick-create card** toggles, and the **task-detail Workflow tab** edit toggles. The built-in
|
||||
**coding** workflow already declares `browser-verification` as its one optional step.
|
||||
|
||||
This plan closes the five remaining gaps:
|
||||
|
||||
1. The **built-in stepwise-coding workflow** does not yet support `browser-verification` as an optional
|
||||
step — and, unlike the coding workflow, its graph has **no `workflow-step` seam node**, so even
|
||||
declaring the optional step would create a dead toggle that never executes. Both the seam node and the
|
||||
declaration are needed.
|
||||
2. The **node editor's save path (`flowToIr`) silently drops `optionalSteps`** — editing any workflow in
|
||||
the visual editor and saving destroys its optional-step declaration (a real data-loss bug).
|
||||
3. The **node editor has no UI** to view or author optional steps (the user's explicit ask: "the node
|
||||
editor view also needs to show optional steps properly").
|
||||
4. The **full New Task modal** (`NewTaskModal` / `TaskForm`) lacks the optional-step toggles that the
|
||||
inline quick-create card already has, so the two creation surfaces are inconsistent.
|
||||
5. The **quick-add area** exposes optional steps only as inline chip toggle buttons; it should instead
|
||||
offer a dedicated **steps dropdown** for selecting/deselecting optional steps.
|
||||
|
||||
**Plan depth:** Standard. This is focused, additive work building on substantial existing scaffolding —
|
||||
no new persistence model, no executor changes.
|
||||
|
||||
---
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The optional-steps feature is ~70% built but has a correctness hole and two missing surfaces:
|
||||
|
||||
- **Data loss:** `flowToIr` (`packages/dashboard/app/components/workflow-flow-mapping.ts:435`)
|
||||
reconstructs the v2 IR from canvas nodes and re-attaches `fields` and `settings` from threaded
|
||||
arguments, but **never carries `optionalSteps`**. Any round-trip through the node editor (open custom
|
||||
workflow → save) strips the declaration. The built-in coding workflow is safe only because it is never
|
||||
re-serialized through the editor, but any user-authored or edited workflow loses optional steps on
|
||||
first save.
|
||||
- **Missing authoring surface:** `WorkflowNodeEditor.tsx` has zero optional-step awareness. There is no
|
||||
way to declare which step templates are optional, or set their `defaultOn`, from the visual editor —
|
||||
the only way today is hand-editing IR.
|
||||
- **Inconsistent creation surfaces:** `InlineCreateCard.tsx` fully implements optional-step toggles
|
||||
(fetches via `fetchWorkflowOptionalSteps`, seeds enabled set from `defaultOn`, sends
|
||||
`enabledWorkflowSteps` on submit). The richer `NewTaskModal` / `TaskForm` create path exposes only a
|
||||
whole-workflow dropdown.
|
||||
- **Incomplete built-in parity + missing seam:** the original request asks for browser verification to be
|
||||
optional in **both** the coding and stepwise-coding built-ins; only coding has it. Critically, the
|
||||
coding IR routes through a `workflow-step` seam node (`builtin-coding-workflow-ir.ts:69`,
|
||||
`execute → workflow-step → review`) which is the only thing that makes the graph call `runWorkflowSteps`
|
||||
(`workflow-node-handlers.ts:264`). The stepwise IR has no such node, so its `enabledWorkflowSteps` are
|
||||
never executed on the main graph path — making this an executor-graph change, not just a declaration.
|
||||
- **Quick-add chip UX (user-requested):** the quick-add card renders optional steps as inline chip
|
||||
toggles. The user explicitly asked for a dedicated **steps dropdown** in the quick-add area; R7/U5 honor
|
||||
that request (this is the stated goal behind R7, not an inferred redesign of a working surface).
|
||||
|
||||
The scope is bounded to authoring + display + creation-surface parity, **plus the stepwise seam node**
|
||||
needed to make the stepwise optional step actually run. The executor's `enabledWorkflowSteps` runtime path
|
||||
is wired and verified **for workflows that contain a `workflow-step` seam node** (coding); the resolver,
|
||||
route, and edit-after-creation Workflow tab are already wired and covered by existing tests. This plan does
|
||||
not change the executor itself beyond adding the stepwise seam node and a stepwise execution test.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **R1** — The built-in **stepwise-coding** workflow declares `browser-verification` as an optional step,
|
||||
default OFF, matching the coding workflow, **and runs it when enabled** — which requires adding a
|
||||
`workflow-step` seam node to the stepwise graph so `enabledWorkflowSteps` actually execute.
|
||||
- **R2** — Saving a workflow through the node editor **preserves** any declared `optionalSteps` (no
|
||||
round-trip data loss).
|
||||
- **R3** — The node editor provides an **Optional Steps** authoring panel where a workflow author can add
|
||||
or remove optional step declarations and set each one's `defaultOn` (on/off).
|
||||
- **R4** — The full **New Task modal** exposes the same optional-step toggles as the inline quick-create
|
||||
card: it loads the selected workflow's optional steps, seeds enabled state from `defaultOn`, and submits
|
||||
the chosen `enabledWorkflowSteps`.
|
||||
- **R5** — Unknown/stale optional-step template ids never crash any surface (resolver already drops them;
|
||||
the new UI must follow the same defensive posture).
|
||||
- **R6** — Legacy v1 workflows and workflows with no optional steps continue to serialize and render
|
||||
byte-identically (additive-only; `optionalSteps` omitted entirely when empty).
|
||||
- **R7** — The quick-add area presents optional steps as a multi-select **steps dropdown** (select /
|
||||
deselect), replacing the current inline chip toggles, while preserving the same `defaultOn` seeding and
|
||||
`enabledWorkflowSteps` submit behavior.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
The optional-steps declaration flows from the workflow IR through three independent consumer surfaces.
|
||||
The two new/fixed edges are the node-editor round-trip (U2/U3) and the full-modal create path (U4).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
IR["WorkflowIrV2.optionalSteps\n[{ templateId, defaultOn? }]"]
|
||||
IR -->|resolveWorkflowOptionalSteps| RES["ResolvedWorkflowOptionalStep[]\n(name, description, icon, phase, defaultOn)"]
|
||||
RES -->|GET /workflows/:id/optional-steps| API["fetchWorkflowOptionalSteps()"]
|
||||
|
||||
subgraph Authoring [Node editor — U2 + U3]
|
||||
NE["WorkflowNodeEditor state\n(optionalStepsOf)"] -->|flowToIr(... optionalSteps)| SAVE["saved IR\n(preserves optionalSteps)"]
|
||||
SAVE -.-> IR
|
||||
end
|
||||
|
||||
subgraph Create [Task creation]
|
||||
API --> INLINE["InlineCreateCard\n(U5 — chips → steps dropdown)"]
|
||||
API --> MODAL["NewTaskModal / TaskForm\n(U4 — new)"]
|
||||
INLINE --> ENABLED["enabledWorkflowSteps[] on createTask"]
|
||||
MODAL --> ENABLED
|
||||
end
|
||||
|
||||
subgraph Edit [Post-create]
|
||||
API --> WRT["WorkflowResultsTab\n(DONE)"]
|
||||
end
|
||||
|
||||
ENABLED --> SEAM["workflow-step seam node\n(coding: present · stepwise: ADDED in U1)"]
|
||||
SEAM --> EXEC["runWorkflowSteps()\n(runs enabledWorkflowSteps)"]
|
||||
```
|
||||
|
||||
Key boundary: the `optionalSteps` **declaration** is execution-inert — it only advertises which templates
|
||||
are toggleable and seeds per-task `enabledWorkflowSteps`. But execution of those steps is **not** free:
|
||||
`runWorkflowSteps` fires only when the graph reaches a `workflow-step` seam node
|
||||
(`workflow-node-handlers.ts:264`). The coding IR has one; the stepwise IR does not, so U1 adds it. With
|
||||
that one exception, the executor is untouched — the rest of the work is confined to declaration (core),
|
||||
serialization (dashboard mapping), and UI (dashboard components).
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **KTD-1 — Thread `optionalSteps` through `flowToIr` as an explicit parameter**, mirroring how `fields`
|
||||
and `settings` are threaded today (`workflow-flow-mapping.ts:435`). Optional steps are not graph nodes,
|
||||
so they cannot be reconstructed from the canvas; they must be passed alongside `columns`/`fields`/
|
||||
`settings` and re-attached to the v2 IR. Add an `optionalStepsOf(def)` reader mirroring `fieldsOf` /
|
||||
`settingsOf` so `WorkflowNodeEditor` can hydrate editor state from a loaded workflow.
|
||||
- **KTD-2 — Treat a non-empty `optionalSteps` as a v2 signal**, like `fields`/`settings`. A workflow with
|
||||
optional steps but no custom columns still serializes as v2 with synthesized default columns. Empty/
|
||||
absent `optionalSteps` must be omitted entirely from the serialized IR (never `optionalSteps: []` or
|
||||
`null`) to preserve R6 byte-identity for legacy graphs.
|
||||
- **KTD-3 — Author optional steps from a catalog of step templates, not free text.** The Optional Steps
|
||||
panel picks from `WORKFLOW_STEP_TEMPLATES` (plus plugin templates where available), storing only
|
||||
`{ templateId, defaultOn }`. Display metadata (name/description/icon/phase) is always resolved from the
|
||||
template catalog at render time — never duplicated into the IR — so the resolver stays the single source
|
||||
of truth and stale ids degrade gracefully (R5).
|
||||
- **KTD-4 — Reuse the inline card's create semantics in the full modal.** `TaskForm`/`NewTaskModal` should
|
||||
load optional steps for the selected workflow, seed the enabled set from `defaultOn`, re-seed when the
|
||||
workflow selection changes, and pass `enabledWorkflowSteps` only when non-empty (matching
|
||||
`InlineCreateCard.tsx:478`). This keeps both creation surfaces behaviorally identical.
|
||||
- **KTD-5 — `defaultOn` toggle is a per-declaration boolean stored on the IR**, distinct from the
|
||||
template's own `defaultOn`. The resolver already prefers the workflow declaration's `defaultOn` over the
|
||||
template default (`optionalStep.defaultOn ?? template.defaultOn ?? false`); the authoring UI writes the
|
||||
declaration-level value.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Add the `workflow-step` seam node + optional-step declaration to the stepwise-coding workflow
|
||||
|
||||
**Goal:** Make `browser-verification` a real, runnable optional step on the stepwise-coding workflow —
|
||||
both declared and actually executed when enabled (R1). This is more than a one-line declaration: the
|
||||
stepwise graph currently has no `workflow-step` seam node, so without one the toggle would be dead.
|
||||
|
||||
**Requirements:** R1, R6
|
||||
|
||||
**Dependencies:** none
|
||||
|
||||
**Files:**
|
||||
- `packages/core/src/builtin-stepwise-coding-workflow-ir.ts` (modify — add `workflow-step` seam node +
|
||||
rewire edges + `optionalSteps` declaration)
|
||||
- `packages/core/src/__tests__/workflow-optional-steps.test.ts` (modify — add stepwise resolver case)
|
||||
- `packages/engine/src/__tests__/stepwise-workflow-parity.test.ts` or a sibling engine test (modify/create
|
||||
— **execution-level** test that an enabled step actually runs)
|
||||
- Any stepwise IR snapshot/parity oracle fixture (update if a byte-identity snapshot exists — see R-5)
|
||||
|
||||
**Approach:**
|
||||
- Add a `workflow-step` seam node to the stepwise IR mirroring `builtin-coding-workflow-ir.ts:69`
|
||||
(`{ id: "workflow-step", kind: "prompt", column: ..., config: builtinPromptConfig("workflow-step",
|
||||
"Pre-merge workflow steps") }`), and rewire the success path so it sits between the foreach `steps`
|
||||
region and `review` (i.e. `steps → workflow-step → review`, plus the `workflow-step → end`
|
||||
outcome/failure edges that the coding IR carries at `:99-116`). Confirm placement is **after** the
|
||||
foreach completes (the seam reads task-level `enabledWorkflowSteps` once, not per step-instance — see
|
||||
R-5 note).
|
||||
- Add `optionalSteps: [{ templateId: "browser-verification" }]` to the stepwise IR (mirrors
|
||||
`builtin-coding-workflow-ir.ts:123`). No explicit `defaultOn` → resolves OFF.
|
||||
- Confirm the IR still passes `parseWorkflowIr`.
|
||||
|
||||
**Patterns to follow:** `builtin-coding-workflow-ir.ts` `workflow-step` node (`:69`) and its edges
|
||||
(`:97-116`); `optionalSteps` declaration (`:123`).
|
||||
|
||||
**Test scenarios:**
|
||||
- `resolveWorkflowOptionalSteps(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)` returns a single
|
||||
`browser-verification` entry (`name: "Browser Verification"`, `phase: "pre-merge"`, `defaultOn: false`).
|
||||
- The stepwise IR parses without error and contains exactly one `workflow-step` seam node on the success
|
||||
path between `steps` and `review`.
|
||||
- **Execution divergence (the critical test):** a stepwise-coding task with
|
||||
`enabledWorkflowSteps: ["browser-verification"]` actually invokes `runWorkflowSteps` and records a
|
||||
`workflowStepResults` entry for `browser-verification`; a sibling task with it OFF records none. This is
|
||||
the two-task divergence shape from the per-task-auto-merge learning — it guards against the dead-toggle
|
||||
failure that a resolver-only test would miss.
|
||||
- Foreach interaction: the `workflow-step` seam runs **once** post-foreach, not per step-instance (R-5).
|
||||
|
||||
**Verification:** `pnpm --filter @fusion/core test workflow-optional-steps` plus the engine execution
|
||||
test pass; the byte-identity parity oracle still passes (or its fixture is updated, R-5).
|
||||
|
||||
---
|
||||
|
||||
### U2. Preserve `optionalSteps` across the node-editor round-trip (`flowToIr`)
|
||||
|
||||
**Goal:** Fix the data-loss bug so saving a workflow through the node editor never drops its optional-step
|
||||
declaration (R2, R6). This is the correctness prerequisite for U3.
|
||||
|
||||
**Requirements:** R2, R6
|
||||
|
||||
**Dependencies:** none (independent of U1; pairs with U3)
|
||||
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/workflow-flow-mapping.ts` (modify — `flowToIr` signature + v2
|
||||
detection + re-attach; add `optionalStepsOf` reader)
|
||||
- `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` (modify/create — round-trip
|
||||
test)
|
||||
|
||||
**Approach:**
|
||||
- Extend `flowToIr(name, nodes, edges, columns?, fields?, settings?, optionalSteps?)` with a trailing
|
||||
optional `optionalSteps?: WorkflowOptionalStep[]` parameter (keep it trailing so existing call sites
|
||||
compile).
|
||||
- Include a non-empty `optionalSteps` in the v2 signal alongside `hasFields`/`hasSettings`
|
||||
(`workflow-flow-mapping.ts:454-456`), and re-attach it to the constructed v2 IR exactly where `fields`
|
||||
and `settings` are attached (`:544-561`). Omit entirely when empty/absent (R6).
|
||||
- Add `optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[]` mirroring `fieldsOf`/`settingsOf`
|
||||
(`:946`, `:960`) — returns a deep-ish copy for v2 IRs with `optionalSteps`, `[]` otherwise. Used by U3 to
|
||||
hydrate editor state.
|
||||
- **Also thread `optionalSteps` through `serializeGraph`** (`WorkflowNodeEditor.tsx:160-177`), the
|
||||
indirection that `flowToIr` is called through for dirty-tracking. `serializeGraph` is invoked by the
|
||||
dirty-check effect (`:1012`) and the load-baseline calls (`:1187`, `:1818`). If only the save path is
|
||||
threaded but `serializeGraph` is not, the loaded baseline and live snapshots both omit `optionalSteps`,
|
||||
so **editing optional steps never marks the editor dirty and the Save button never enables** — the user
|
||||
cannot persist the change. Give `serializeGraph` an `optionalSteps` parameter and pass the editor state
|
||||
at the dirty-check call and `optionalStepsOf(activeWorkflow)` at the baseline calls. (This belongs to
|
||||
U3's wiring but is called out here because it rides the same `flowToIr` seam.)
|
||||
|
||||
**Patterns to follow:** the `fields`/`settings` threading and `fieldsOf`/`settingsOf` readers in the same
|
||||
file are the exact template to clone.
|
||||
|
||||
**Technical design (directional, not spec):**
|
||||
```text
|
||||
flowToIr(..., optionalSteps?) {
|
||||
const hasOptional = Array.isArray(optionalSteps) && optionalSteps.length > 0
|
||||
const v2 = columns?.length || hasFields || hasSettings || hasOptional
|
||||
...
|
||||
if (v2) {
|
||||
if (hasFields) ir.fields = ...
|
||||
if (hasSettings) ir.settings = ...
|
||||
if (hasOptional) ir.optionalSteps = optionalSteps.map(o => ({ ...o })) // NEW
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Test scenarios:**
|
||||
- Round-trip: an IR with `optionalSteps: [{ templateId: "browser-verification", defaultOn: true }]` →
|
||||
`irToFlow` → `flowToIr(..., optionalStepsOf(def))` yields an IR whose `optionalSteps` equals the input.
|
||||
- A workflow with optional steps but no custom columns/fields/settings serializes as **v2** (not v1).
|
||||
- A workflow with no optional steps serializes **without** an `optionalSteps` key (no `[]`, no `null`) —
|
||||
legacy byte-identity preserved (R6).
|
||||
- `optionalStepsOf` returns `[]` for a v1 IR and for a v2 IR with no `optionalSteps`.
|
||||
- `optionalStepsOf` returns a copy (mutating the result does not mutate the source IR).
|
||||
|
||||
**Verification:** round-trip test green; existing `workflow-flow-mapping` tests unaffected.
|
||||
|
||||
---
|
||||
|
||||
### U3. Optional Steps authoring panel in the node editor
|
||||
|
||||
**Goal:** Let a workflow author add/remove optional-step declarations and set each one's `defaultOn` from
|
||||
the visual editor, and persist them via the U2 round-trip (R3, R5).
|
||||
|
||||
**Requirements:** R3, R5, R6
|
||||
|
||||
**Dependencies:** U2 (needs `flowToIr` to accept `optionalSteps` + `optionalStepsOf` to hydrate)
|
||||
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowNodeEditor.tsx` (modify — add `optionalSteps` state,
|
||||
hydrate via `optionalStepsOf` on **both** load paths, render the panel inline alongside Fields/Settings,
|
||||
thread into every `flowToIr`/`serializeGraph` call site)
|
||||
- `packages/dashboard/app/components/workflow-phase-badge.tsx` (create — extract `phaseBadge`; see below)
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx` (modify — round-trip + dirty +
|
||||
add/remove/toggle)
|
||||
|
||||
**Approach:**
|
||||
- Add `optionalSteps` editor state to `WorkflowNodeEditor`. **Hydrate via `optionalStepsOf(activeWorkflow)`
|
||||
on both load paths:** the primary load effect AND the fragment/AI-generate load path at `:1496-1497`.
|
||||
Note: that fragment path today calls only `columnsOf`/`fieldsOf` — it omits even `settingsOf` — so
|
||||
`optionalSteps` (and settings) must be added explicitly there, not copied from the existing two-call
|
||||
pattern. Missing this re-introduces the U2 data loss on the fragment path (load empty → save strips).
|
||||
- **Render the panel inline** in `WorkflowNodeEditor` as a third sibling alongside the existing Fields and
|
||||
Settings panels (same collapsible/expandable behavior and sidebar position, immediately below Settings).
|
||||
Do not extract a separate single-consumer `WorkflowOptionalStepsEditor` component/CSS file — co-locate
|
||||
the panel JSX with the Fields/Settings panels it mirrors. The panel is a list of declared optional steps,
|
||||
each showing the template's resolved name + phase chip + an on/off `defaultOn` control, an "Add optional
|
||||
step" picker sourced from `WORKFLOW_STEP_TEMPLATES` (filtering out already-declared ids), and a remove
|
||||
control per row. Store only `{ templateId, defaultOn }`; resolve display metadata from the catalog at
|
||||
render time (KTD-3).
|
||||
- Thread the editor's `optionalSteps` into **every** `flowToIr(...)` AND `serializeGraph(...)` call site
|
||||
(save `:1802`/`:1818`, dirty-check `:1012`, baselines `:1187`, fragment/preview) so no save or
|
||||
dirty-tracking route bypasses the param (see U2 / R-1).
|
||||
- **Unknown/stale template ids:** render a muted "Unknown step (`templateId`)" row with a remove control —
|
||||
do **not** silently skip them. Skipping would hide a declaration the user then can't remove without
|
||||
hand-editing IR; the muted row satisfies R5's "remain removable."
|
||||
- **Accessibility:** the `defaultOn` control is a labeled checkbox/switch with
|
||||
`aria-label="Default on for <step name>"` and a visible focus ring; the "Add optional step" picker
|
||||
inherits keyboard behavior from the editor's existing picker pattern.
|
||||
|
||||
**phaseBadge extraction (F3):** `phaseBadge` is currently a non-exported module-local in
|
||||
`WorkflowResultsTab.tsx:137` (takes a `t` arg). Extract it into a shared `workflow-phase-badge.tsx` helper
|
||||
and re-import it in `WorkflowResultsTab`, the node-editor panel, and the U5 dropdown — rather than
|
||||
duplicating the chip three times.
|
||||
|
||||
**Patterns to follow:** the existing Fields and Settings editor panels in the node editor (inline, not
|
||||
separate components) and `TaskFieldsSection.tsx` for list-of-typed-declarations UI.
|
||||
|
||||
**Test scenarios:**
|
||||
- Adding an optional step from the picker appends `{ templateId, defaultOn: false }`, removes it from the
|
||||
picker's available list, **and marks the editor dirty** (Save enables — guards the `serializeGraph` gap).
|
||||
- Toggling a row's `defaultOn` flips the stored boolean and marks dirty.
|
||||
- Removing a row deletes the declaration and returns the template to the picker.
|
||||
- Editing a workflow that already declares `browser-verification`, making no change, and saving yields an
|
||||
IR whose `optionalSteps` is unchanged (round-trip through the live editor, not just `flowToIr`).
|
||||
- A workflow loaded via the **fragment/generate path** preserves its `optionalSteps` on save (guards F2).
|
||||
- A loaded declaration with an unknown `templateId` renders a muted removable row without crashing (R5).
|
||||
- A workflow with zero optional steps saves without an `optionalSteps` key (R6).
|
||||
|
||||
**Verification:** new component tests green; node-editor save round-trip preserves declarations; manual
|
||||
real-browser check per the worktree dashboard recipe (see Risks).
|
||||
|
||||
---
|
||||
|
||||
### U4. Optional-step toggles in the full New Task modal
|
||||
|
||||
**Goal:** Bring `NewTaskModal` / `TaskForm` to parity with the inline quick-create card's optional-step
|
||||
toggles (R4, R5).
|
||||
|
||||
**Requirements:** R4, R5
|
||||
|
||||
**Dependencies:** **U5** — U4 consumes the shared `WorkflowOptionalStepsDropdown` built in U5 (see the
|
||||
ordering decision below). U1 only enriches which workflows expose steps.
|
||||
|
||||
**Ordering decision (resolves the U4/U5 contradiction):** U5 builds **one** shared
|
||||
`WorkflowOptionalStepsDropdown` component and lands first; U4 consumes it. U4 does **not** ship inline chip
|
||||
markup that would later need migration. Both creation surfaces (quick-add card, full modal) therefore
|
||||
present optional steps with the same dropdown interaction from day one — no divergent pickers.
|
||||
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/TaskForm.tsx` (modify — load optional steps for selected workflow,
|
||||
render the shared dropdown, expose enabled-set state)
|
||||
- `packages/dashboard/app/components/NewTaskModal.tsx` (modify — own the enabled-set state, include
|
||||
`enabledWorkflowSteps` in the create payload at `:243`)
|
||||
- `packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx` (modify/create)
|
||||
- `packages/dashboard/app/components/__tests__/TaskForm.test.tsx` (modify/create)
|
||||
|
||||
**Approach:**
|
||||
- In `TaskForm`, when a workflow is selected (dropdown gated by `onWorkflowIdChange`, `:1290`), call
|
||||
`fetchWorkflowOptionalSteps(effectiveWorkflowId, projectId)` and render the **`WorkflowOptionalStepsDropdown`
|
||||
(from U5)** directly below the workflow dropdown.
|
||||
- Seed the enabled set from steps where `defaultOn` is true; re-seed whenever the selected workflow changes
|
||||
(clear + refetch), matching `InlineCreateCard.tsx:272-284`.
|
||||
- Lift the enabled-set state to `NewTaskModal` (or expose via a `TaskForm` callback prop, consistent with
|
||||
the existing `onWorkflowIdChange` pattern) so the create handler includes
|
||||
`enabledWorkflowSteps: enabled.length ? enabled : undefined` in the `onCreateTask` payload (`:243`).
|
||||
- **Loading/empty states:** while the optional-steps fetch is in flight, render a loading affordance
|
||||
consistent with whatever `InlineCreateCard` shows (a skeleton/disabled trigger, not a layout jump);
|
||||
submit stays enabled. "No workflow", undefined selection, or a workflow with no optional steps → render
|
||||
nothing (render-nothing is the committed empty-state behavior shared with U5), no `enabledWorkflowSteps`
|
||||
sent. Fetch failure → usable form, no dropdown (defensive, R5), mirroring the inline card's `.catch`.
|
||||
|
||||
**Patterns to follow:** `InlineCreateCard.tsx` for the state/fetch/seed/submit wiring;
|
||||
`WorkflowOptionalStepsDropdown` (U5) for the presentation. Keep `data-testid` conventions.
|
||||
|
||||
**Test scenarios:**
|
||||
- Selecting a workflow with an optional `browser-verification` step renders the dropdown with the step
|
||||
OFF by default (`defaultOn` false).
|
||||
- A workflow whose optional step has `defaultOn: true` shows the step pre-selected and includes it in the
|
||||
create payload if left on.
|
||||
- Selecting a step and submitting sends `enabledWorkflowSteps: ["browser-verification"]` to `onCreateTask`.
|
||||
- Changing the workflow selection clears and refetches (no stale selection from the prior workflow).
|
||||
- While the fetch is pending, a loading affordance shows and submit remains enabled.
|
||||
- "No workflow" or a workflow with no optional steps renders no dropdown and omits `enabledWorkflowSteps`.
|
||||
- Fetch failure leaves the form usable with no dropdown (R5).
|
||||
|
||||
**Verification:** modal/form tests green; manual real-browser create with browser-verification selected
|
||||
produces a task whose detail Workflow tab shows the step enabled. Verify in a real mobile viewport (R-3).
|
||||
|
||||
---
|
||||
|
||||
### U5. Steps dropdown in the quick-add area
|
||||
|
||||
**Goal:** Build the shared multi-select **steps dropdown** and adopt it in the quick-add card, replacing
|
||||
the inline chip toggles (R7). This component is the single optional-step picker consumed by both the
|
||||
quick-add card (here) and the full modal (U4).
|
||||
|
||||
**Requirements:** R7, R5
|
||||
|
||||
**Dependencies:** none. **Lands before U4** (U4 consumes this component). Build the shared component with
|
||||
both consumers in mind from the start — there is no interim chip version.
|
||||
|
||||
**Files:**
|
||||
- `packages/dashboard/app/components/WorkflowOptionalStepsDropdown.tsx` (create — reusable multi-select
|
||||
dropdown over `ResolvedWorkflowOptionalStep[]`)
|
||||
- `packages/dashboard/app/components/WorkflowOptionalStepsDropdown.css` (create)
|
||||
- `packages/dashboard/app/components/InlineCreateCard.tsx` (modify — swap the chip-toggle block at
|
||||
`:1080-1101` for the dropdown; keep existing state/seeding/submit wiring)
|
||||
- `packages/dashboard/app/components/InlineCreateCard.css` (modify — remove now-unused chip styles)
|
||||
- `packages/dashboard/app/components/__tests__/WorkflowOptionalStepsDropdown.test.tsx` (create)
|
||||
- `packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx` (modify — assert dropdown
|
||||
behavior replaces chip behavior)
|
||||
|
||||
**Approach:**
|
||||
- Build `WorkflowOptionalStepsDropdown` as a controlled multi-select: props are the resolved optional
|
||||
steps, the enabled `templateId` set, and an `onToggle(templateId)` callback. The open panel lists each
|
||||
step with a checkbox, name, phase chip (shared `phaseBadge` from U3's extraction), and description. It
|
||||
owns only open/close UI state — selection stays lifted in the parent (`InlineCreateCard`'s existing
|
||||
`enabledOptionalStepIds`).
|
||||
- **Trigger label matrix (committed):** no steps available → render nothing; steps available, zero
|
||||
selected → `"Steps: none"`; N selected → `"Steps: N selected"`. Use the same strings in both consumers.
|
||||
- **Empty state (committed):** render **nothing** when the selected workflow has no optional steps
|
||||
(matches `InlineCreateCard`'s current no-chip-block behavior and U4's empty-state choice — both surfaces
|
||||
identical). No disabled-placeholder variant.
|
||||
- **Accessibility / keyboard (committed):** trigger is a button with `aria-haspopup` + `aria-expanded`;
|
||||
the panel is `role="listbox"` labeled via `aria-labelledby` pointing at the trigger; each option is
|
||||
`role="option"` with `aria-checked`. Escape closes and returns focus to the trigger; Enter/Space on the
|
||||
trigger toggles open; arrow keys move focus between options; outside-click closes. Inherit this behavior
|
||||
from `CustomModelDropdown.tsx` rather than re-implementing.
|
||||
- **Positioning:** render the panel via a portal (appended to `document.body`) so it is not clipped by the
|
||||
modal's `overflow` boundary when reused in U4. Verify it anchors to the trigger in both the card and the
|
||||
modal.
|
||||
- Swap the inline chip block in `InlineCreateCard` (`:1080-1101`) for the dropdown, passing the existing
|
||||
`optionalSteps`, `enabledOptionalStepIds`, and `toggleOptionalStep` (`:292`). Leave the fetch/seed/submit
|
||||
wiring (`:272-284`, `:478`) untouched — only the presentation changes. Unknown/stale ids are already
|
||||
filtered by the resolver (R5).
|
||||
|
||||
**Patterns to follow:** `CustomModelDropdown.tsx` for trigger+panel, portal positioning, keyboard nav, and
|
||||
outside-click-close; the shared `phaseBadge` (U3) for the phase chip; keep `InlineCreateCard`'s existing
|
||||
`data-testid` conventions so current tests migrate cleanly.
|
||||
|
||||
**Test scenarios:**
|
||||
- Trigger label reflects state: `"Steps: none"` with steps available and none selected; `"Steps: 1
|
||||
selected"` with one selected; renders nothing when no optional steps exist.
|
||||
- Opening the panel and checking a step calls `onToggle` and adds it; unchecking removes it.
|
||||
- A step with `defaultOn: true` shows pre-checked on first open (seeded by the parent).
|
||||
- Submitting the quick-add card with a step selected sends `enabledWorkflowSteps: ["browser-verification"]`
|
||||
(existing submit path unchanged).
|
||||
- Keyboard: Escape closes and refocuses the trigger; arrow keys move option focus; outside-click closes
|
||||
without losing selection.
|
||||
- The panel is not clipped when rendered inside an `overflow:hidden` container (portal positioning).
|
||||
|
||||
**Verification:** dropdown + inline-card tests green; manual real-browser check that the quick-add dropdown
|
||||
selects/deselects, keyboard nav works, and the created task reflects the chosen steps. Verify the open
|
||||
panel in a real mobile viewport (R-3).
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- Stepwise-coding built-in optional-step declaration (U1).
|
||||
- Node-editor round-trip preservation + authoring panel (U2, U3).
|
||||
- Full New Task modal optional-step parity (U4).
|
||||
- Quick-add steps dropdown replacing inline chips (U5).
|
||||
|
||||
**Already built (verify-only, no changes):**
|
||||
- IR types + parse validation (`workflow-ir-types.ts`, `workflow-ir.ts`).
|
||||
- `resolveWorkflowOptionalSteps` (`workflow-optional-steps.ts`) and its route/API client.
|
||||
- Inline quick-create card fetch/seed/submit wiring (`InlineCreateCard.tsx`) — presentation changes in U5,
|
||||
but the optional-steps load, `defaultOn` seeding, and submit payload are reused as-is.
|
||||
- Task-detail Workflow tab edit toggles (`WorkflowResultsTab.tsx`).
|
||||
- Executor `enabledWorkflowSteps` execution path — **but only for workflows that contain a `workflow-step`
|
||||
seam node** (coding). The stepwise workflow lacks one; U1 adds it. Do not treat the executor as fully
|
||||
"done" for stepwise until the U1 execution test passes.
|
||||
- POST/PATCH `/tasks` acceptance of `enabledWorkflowSteps`.
|
||||
|
||||
### Deferred to Follow-Up Work
|
||||
- Generalizing optional-step authoring to **plugin-contributed** step templates in the picker (use the
|
||||
built-in `WORKFLOW_STEP_TEMPLATES` catalog for now; plugin template merging can follow once the picker
|
||||
needs it).
|
||||
- A reusable "per-task workflow facet override" abstraction unifying optional steps, auto-merge, and
|
||||
column-agent overrides — strong `/ce-compound` candidate after this lands, not part of this change.
|
||||
|
||||
**Out of scope:**
|
||||
- Any executor/runtime behavior change. Optional steps remain execution-inert; the runtime already honors
|
||||
`enabledWorkflowSteps`.
|
||||
- New persistence/migrations. Optional steps ride the existing IR (`optionalSteps`) and the existing
|
||||
`tasks.enabledWorkflowSteps` column — no schema bump.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **R-1 — Missed `flowToIr` / `serializeGraph` call site (data loss OR dead Save button).** `flowToIr` is
|
||||
reached both directly (save `:1802`/`:1818`) and through `serializeGraph` (`:160-177`), which the
|
||||
dirty-check effect (`:1012`) and load baselines (`:1187`) call. A missed **save** site drops the
|
||||
declaration; a missed **serializeGraph** site means optional-step edits never mark the editor dirty, so
|
||||
Save never enables and the change can't be persisted. *Mitigation:* grep every `flowToIr(` AND
|
||||
`serializeGraph(` call in `WorkflowNodeEditor` and thread the param at each (save, dirty-check,
|
||||
baselines, fragment/preview); the U3 dirty + round-trip-through-editor tests guard both failure modes.
|
||||
- **R-2 — Stale dashboard bundle masks UI changes.** `fn dashboard` serves `packages/cli/dist/client`,
|
||||
not the dashboard worktree dist, so new editor/modal UI can appear "missing" when it's a stale bundle.
|
||||
*Mitigation:* verify with the worktree recipe — build all four packages, point `FUSION_CLIENT_DIR` at
|
||||
the dashboard worktree dist, run on a non-4040 port with `--dev` (never `fn daemon`/`serve`). Respect
|
||||
the port-4040 kill guards.
|
||||
(`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md`)
|
||||
- **R-3 — Mobile toggle layout regression.** Per-task toggle switches on the board have a history of
|
||||
real-browser-only mobile failures (document horizontal scroll → blank dashboard) invisible to jsdom.
|
||||
*Mitigation:* the new toggles live in the create modal/inline card and the node editor, not on board
|
||||
cards, but verify the modal toggles in a real mobile viewport.
|
||||
(`docs/solutions/ui-bugs/mobile-auto-merge-toggle-document-scroll-blank.md`)
|
||||
- **R-4 — v2-detection / byte-identity drift.** Incorrectly treating empty `optionalSteps` as a v2 signal
|
||||
would upgrade legacy v1 workflows and break R6 byte-identity. *Mitigation:* gate the v2 signal on
|
||||
**non-empty** `optionalSteps` and omit the key entirely when empty; cover with the U2 "no optional
|
||||
steps serializes without the key" test.
|
||||
- **R-5 — Stepwise IR is a byte-identity parity oracle; adding the seam node may shift snapshots.** The
|
||||
stepwise IR is documented as the `execute`-seam byte-identity parity oracle. Adding a `workflow-step`
|
||||
seam node (U1) changes the graph, so any snapshot/parity fixture asserting the stepwise node/edge set
|
||||
will need updating, and the foreach interaction must be confirmed (the seam must run **once** after the
|
||||
foreach completes, not per step-instance — the learnings flag subgraph-walking as the #1 pitfall).
|
||||
*Mitigation:* place the seam node after the foreach `steps` region on the success path; update the parity
|
||||
fixture deliberately; add the U1 foreach-interaction test.
|
||||
|
||||
---
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- Backend/core state map: `WorkflowOptionalStep` + `WorkflowIrV2.optionalSteps`
|
||||
(`packages/core/src/workflow-ir-types.ts:314-339`), resolver
|
||||
(`packages/core/src/workflow-optional-steps.ts`), coding declaration
|
||||
(`packages/core/src/builtin-coding-workflow-ir.ts:123`), executor path
|
||||
(`packages/engine/src/executor.ts` `executeWorkflowSteps`).
|
||||
- UI state map: inline card toggles (`packages/dashboard/app/components/InlineCreateCard.tsx:113-1101`),
|
||||
edit toggles (`packages/dashboard/app/components/WorkflowResultsTab.tsx:332-488`), workflow picker
|
||||
(`packages/dashboard/app/components/TaskForm.tsx:1290`), modal create payload
|
||||
(`packages/dashboard/app/components/NewTaskModal.tsx:243`), route
|
||||
(`packages/dashboard/src/routes/register-workflow-routes.ts:317-329`), serialization gap
|
||||
(`packages/dashboard/app/components/workflow-flow-mapping.ts:435-562`, `:946-963`).
|
||||
- Institutional learnings: per-entity override blast-radius and per-task auto-merge override precedent
|
||||
(`docs/solutions/architecture-patterns/per-entity-execution-principal-override-blast-radius.md`,
|
||||
`docs/solutions/logic-errors/per-task-auto-merge-override-ignored-by-trigger-gates.md`) — informed the
|
||||
decision to keep optional steps execution-inert rather than adding executor branches; worktree browser
|
||||
testing and mobile toggle gotchas as above.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: eslint-disable for react-hooks/exhaustive-deps fails CI lint (rule not registered)
|
||||
date: 2026-06-21
|
||||
category: docs/solutions/build-errors
|
||||
module: dashboard
|
||||
problem_type: build_error
|
||||
component: tooling
|
||||
symptoms:
|
||||
- "CI Lint job fails: Definition for rule 'react-hooks/exhaustive-deps' was not found react-hooks/exhaustive-deps"
|
||||
- "Local `pnpm test` (vitest) passes green while the PR's Lint check goes red"
|
||||
root_cause: config_error
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
tags: [eslint, react-hooks, exhaustive-deps, ci-lint, lint, flat-config]
|
||||
---
|
||||
|
||||
# eslint-disable for react-hooks/exhaustive-deps fails CI lint (rule not registered)
|
||||
|
||||
## Problem
|
||||
This repo's flat `eslint.config.mjs` does not register the `react-hooks/exhaustive-deps` rule. A `// eslint-disable-next-line react-hooks/exhaustive-deps` directive — the usual way to silence a deliberately-incomplete `useEffect` dependency array — therefore references a rule ESLint doesn't know, and `eslint .` treats the unknown rule name in a disable directive as a hard error. The PR's CI `Lint` job fails.
|
||||
|
||||
## Symptoms
|
||||
- CI `Lint` job (`pnpm lint` → `eslint .`) fails with: `Definition for rule 'react-hooks/exhaustive-deps' was not found react-hooks/exhaustive-deps`.
|
||||
- The failure does **not** reproduce under `pnpm test` / vitest — those never invoke ESLint, so the whole feature's test suite is green locally while the Lint check is red on the PR.
|
||||
- Often the only file flagged is the one that added the directive (e.g. a single `useEffect` with an intentionally trimmed dep array).
|
||||
|
||||
## What Didn't Work
|
||||
- Assuming a green local `pnpm test` meant the branch was CI-clean. Vitest runs through `tsx` and does not lint, so an eslint-only failure is invisible until CI (or an explicit `pnpm lint`) runs.
|
||||
- Treating it as a missing-dependency warning to satisfy. The error is not exhaustive-deps complaining about the dep array — it's ESLint rejecting the *disable directive* because the named rule isn't registered. Adding the "missing" dep would not silence it; only the directive itself is the problem.
|
||||
|
||||
## Solution
|
||||
Remove the `eslint-disable` directive. Because `react-hooks/exhaustive-deps` is not enforced in this config, the intentionally-incomplete dep array needs no suppression at all. Keep a plain explanatory comment for the human reader.
|
||||
|
||||
```tsx
|
||||
// Before — fails CI lint:
|
||||
// onEnabledWorkflowStepsChange intentionally omitted: a new identity each render
|
||||
// must not re-trigger the fetch/re-seed (would clobber user toggles).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onWorkflowIdChange, effectiveOptionalWorkflowId, projectId]);
|
||||
|
||||
// After — passes:
|
||||
// onEnabledWorkflowStepsChange intentionally omitted from deps: a new identity
|
||||
// each render must not re-trigger the fetch/re-seed (would clobber user toggles).
|
||||
// Callers must pass a stable callback (NewTaskModal passes a useState setter).
|
||||
}, [onWorkflowIdChange, effectiveOptionalWorkflowId, projectId]);
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
ESLint validates rule names referenced in inline disable directives against the rules actually registered by the active config. The flat `eslint.config.mjs` here does not load the `react-hooks` plugin's `exhaustive-deps` rule, so the name is unknown and the directive errors out. Removing the directive removes the dangling reference. There is no behavioral regression: with the rule unregistered, the incomplete dep array was never going to be flagged in the first place — the suppression was protecting against a check that doesn't run.
|
||||
|
||||
## Prevention
|
||||
- **Don't add `eslint-disable` for `react-hooks/exhaustive-deps` in this repo.** When deliberately omitting a dependency, document the intent in a plain comment and rely on the dep array as written. (If exhaustive-deps enforcement is ever wanted repo-wide, register the rule in `eslint.config.mjs` first — then the directive becomes valid.)
|
||||
- **Run `pnpm lint` (or `npx eslint <changed-files>`) before pushing.** A passing `pnpm test` does not cover lint — vitest and ESLint are independent gates, and the CI `Lint` job (`.github/workflows/pr-checks.yml`) is the first place an eslint-only error surfaces otherwise.
|
||||
- Before suppressing any rule with an inline directive, confirm the rule is actually registered in the active flat config; an unknown rule name in a disable directive is itself a lint error under `eslint .`.
|
||||
|
||||
## Related Issues
|
||||
- Surfaced and fixed during the workflow-optional-steps work (PR #1703), commit `e16d48910` "fix(ci): drop unknown react-hooks/exhaustive-deps disable directive in TaskForm".
|
||||
- Related to the broader "lint/typecheck gates are separate from vitest" gotcha: TS editor diagnostics and vitest both miss eslint-only failures in `packages/dashboard`.
|
||||
- `docs/solutions/architecture-patterns/thin-trusted-merge-gate.md` — establishes `Lint` as one of the four merge-blocking CI checks (Lint, Typecheck, Build, Gate). That CI topology is why this failure surfaces only on the PR: `pnpm test`/vitest is not one of those gates and never invokes ESLint.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js";
|
||||
import { resolveWorkflowOptionalSteps } from "../workflow-optional-steps.js";
|
||||
import type { WorkflowIr, WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
|
||||
@@ -41,6 +42,35 @@ describe("resolveWorkflowOptionalSteps", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves the builtin stepwise-coding browser verification optional step", () => {
|
||||
expect(resolveWorkflowOptionalSteps(BUILTIN_STEPWISE_CODING_WORKFLOW_IR)).toEqual([
|
||||
{
|
||||
templateId: "browser-verification",
|
||||
name: "Browser Verification",
|
||||
description: "Verify web application functionality using browser automation",
|
||||
icon: "globe",
|
||||
phase: "pre-merge",
|
||||
defaultOn: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("places a single workflow-step seam node between steps and review in stepwise", () => {
|
||||
const ir = BUILTIN_STEPWISE_CODING_WORKFLOW_IR;
|
||||
if (ir.version !== "v2") throw new Error("expected v2");
|
||||
const seamNodes = ir.nodes.filter(
|
||||
(n) => n.kind === "prompt" && n.config?.seam === "workflow-step",
|
||||
);
|
||||
expect(seamNodes).toHaveLength(1);
|
||||
// success path: steps -> workflow-step -> review
|
||||
expect(ir.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: "steps", to: "workflow-step", condition: "success" }),
|
||||
expect.objectContaining({ from: "workflow-step", to: "review", condition: "success" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips unknown template ids", () => {
|
||||
expect(
|
||||
resolveWorkflowOptionalSteps(v2([
|
||||
|
||||
@@ -123,6 +123,16 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
},
|
||||
// KTD-5: rework exhaustion escalates to a manual hold (a human releases it).
|
||||
{ id: "rework-hold", kind: "hold", column: "in-progress", config: { release: "manual" } },
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
// The stepwise workflow must actually run a task's enabled optional steps (e.g.
|
||||
// browser verification), so it needs the same pre-merge workflow-step seam the
|
||||
// coding workflow has — declaring the optional step without this node would be a
|
||||
// dead toggle. Pre-merge workflow-step seam (parity with builtin-coding-workflow-ir):
|
||||
// the ONLY node that makes the graph invoke `runWorkflowSteps`, so a per-task
|
||||
// `enabledWorkflowSteps` (e.g. the optional browser-verification step declared
|
||||
// below) actually executes. Runs ONCE after the foreach completes, between
|
||||
// implementation and review — not per step-instance.
|
||||
{ id: "workflow-step", kind: "prompt", column: "in-progress", config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps") },
|
||||
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
|
||||
{ id: "merge-gate", kind: "merge-gate", column: "in-review", config: { gate: "auto-merge" } },
|
||||
{ id: "merge-retry", kind: "retry-backoff", column: "in-review", config: { policy: "merge", maxAttempts: 3 } },
|
||||
@@ -153,10 +163,17 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
{ from: "parse", to: "steps", condition: "outcome:no-steps" },
|
||||
{ from: "parse", to: "end", condition: "failure" },
|
||||
{ from: "parse", to: "end", condition: "outcome:parse-error" },
|
||||
{ from: "steps", to: "review", condition: "success" },
|
||||
// KTD-5: bounded rework exhaustion → manual hold; release re-enters review.
|
||||
// Implementation complete → pre-merge workflow-step seam → review. Both the
|
||||
// normal foreach-success path and the rework-exhausted manual-release path flow
|
||||
// through the seam so enabled workflow steps run regardless of route.
|
||||
{ from: "steps", to: "workflow-step", condition: "success" },
|
||||
// KTD-5: bounded rework exhaustion → manual hold; release re-enters the seam.
|
||||
{ from: "steps", to: "rework-hold", condition: "outcome:rework-exhausted" },
|
||||
{ from: "rework-hold", to: "review", condition: "success" },
|
||||
{ from: "rework-hold", to: "workflow-step", condition: "success" },
|
||||
{ from: "workflow-step", to: "review", condition: "success" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" },
|
||||
{ from: "workflow-step", to: "end", condition: "outcome:deferred-paused" },
|
||||
{ from: "workflow-step", to: "end", condition: "failure" },
|
||||
{ from: "steps", to: "end", condition: "failure" },
|
||||
{ from: "review", to: "merge-gate", condition: "success" },
|
||||
{ from: "review", to: "end", condition: "failure" },
|
||||
@@ -176,6 +193,9 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
],
|
||||
// Workflow-settings (U1, R4): same moved-key catalog as the default builtin.
|
||||
settings: BUILTIN_WORKFLOW_SETTINGS,
|
||||
// Optional browser-verification step, parity with builtin-coding-workflow-ir.
|
||||
// Default OFF; runnable because the workflow-step seam node above is present.
|
||||
optionalSteps: [{ templateId: "browser-verification" }],
|
||||
};
|
||||
|
||||
export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr(
|
||||
|
||||
@@ -160,11 +160,6 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.inline-create-optional-step[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.inline-create-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
@@ -318,10 +313,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline-create-optional-step {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.inline-create-priority-select {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { WorkflowSelector } from "./WorkflowSelector";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const STORAGE_KEY = "kb-inline-create-text";
|
||||
@@ -1083,27 +1084,16 @@ export function InlineCreateCard({
|
||||
className="inline-create-optional-steps"
|
||||
aria-label={t("inline.optionalWorkflowSteps", "Optional workflow steps")}
|
||||
>
|
||||
{optionalSteps.map((step) => {
|
||||
const enabled = enabledOptionalStepIds.includes(step.templateId);
|
||||
const testId = step.templateId === "browser-verification"
|
||||
? "inline-create-browser-verification-toggle"
|
||||
: `inline-create-optional-step-${step.templateId}`;
|
||||
return (
|
||||
<button
|
||||
key={step.templateId}
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-optional-step"
|
||||
data-testid={testId}
|
||||
aria-pressed={enabled}
|
||||
onClick={() => toggleOptionalStep(step.templateId)}
|
||||
title={t("inline.toggleOptionalWorkflowStep", "Toggle optional workflow step: {{name}}", { name: step.name })}
|
||||
>
|
||||
{enabled
|
||||
? t("inline.optionalWorkflowStepChecked", "{{name}} ✓", { name: step.name })
|
||||
: step.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* FNXC:TaskCreation 2026-06-21-00:00:
|
||||
Inline quick-add uses the shared optional-steps dropdown so it matches
|
||||
the modal/quick-add keyboard + a11y behavior and toggles the same
|
||||
enabledWorkflowSteps set submitted on create. */}
|
||||
<WorkflowOptionalStepsDropdown
|
||||
steps={optionalSteps}
|
||||
enabledIds={enabledOptionalStepIds}
|
||||
onToggle={toggleOptionalStep}
|
||||
triggerTestId="inline-create-optional-steps-trigger"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
// `null` = explicit "No workflow", `string` = a specific workflow. Materialized
|
||||
// atomically at create time via the `workflowId` create parameter.
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
// Optional workflow steps the user opted into; TaskForm fetches + seeds these
|
||||
// from the selected workflow's defaultOn and lifts the enabled set up here.
|
||||
const [enabledWorkflowSteps, setEnabledWorkflowSteps] = useState<string[]>([]);
|
||||
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
|
||||
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
@@ -162,6 +165,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
dependencies.length > 0 ||
|
||||
pendingImages.length > 0 ||
|
||||
selectedWorkflowId !== undefined ||
|
||||
// Optional workflow steps the user toggled count as unsaved work. (Workflows
|
||||
// whose steps are defaultOn:false — today's only shipped step — seed an empty
|
||||
// set, so this stays false until the user actually opts a step in.)
|
||||
enabledWorkflowSteps.length > 0 ||
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
planningModel !== "" ||
|
||||
@@ -177,7 +184,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
githubTrackingEnabled ||
|
||||
githubRepoOverrideTrimmed !== "";
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
|
||||
}, [description, dependencies, pendingImages, selectedWorkflowId, enabledWorkflowSteps, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (hasDirtyState) {
|
||||
@@ -201,6 +208,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowId(undefined);
|
||||
setEnabledWorkflowSteps([]);
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setReviewLevel(undefined);
|
||||
@@ -242,6 +250,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
// - null → explicit "No workflow" (store skips default materialization)
|
||||
// - string → that workflow, materialized atomically at create time.
|
||||
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
|
||||
// Optional steps the user toggled on (omit when none so the store keeps its
|
||||
// default materialization behavior).
|
||||
...(enabledWorkflowSteps.length ? { enabledWorkflowSteps } : {}),
|
||||
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
|
||||
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
|
||||
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
|
||||
@@ -302,6 +313,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowId(undefined);
|
||||
setEnabledWorkflowSteps([]);
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setReviewLevel(undefined);
|
||||
@@ -319,7 +331,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, enabledWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -507,6 +519,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onSelectedPresetIdChange={setSelectedPresetId}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
onWorkflowIdChange={setSelectedWorkflowId}
|
||||
enabledWorkflowSteps={enabledWorkflowSteps}
|
||||
onEnabledWorkflowStepsChange={setEnabledWorkflowSteps}
|
||||
pendingImages={pendingImages}
|
||||
onImagesChange={setPendingImages}
|
||||
tasks={tasks}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition } from "@fusion/core";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition, type ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, fetchSettings, fetchWorkflows, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { fetchModels, fetchSettings, fetchWorkflows, fetchWorkflowOptionalSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
@@ -100,6 +101,11 @@ export interface TaskFormProps {
|
||||
// edit-mode workflow management lives in the task detail Workflow tab.
|
||||
selectedWorkflowId?: string | null;
|
||||
onWorkflowIdChange?: (workflowId: string | null) => void;
|
||||
// Optional workflow steps the task can opt into. TaskForm fetches + seeds these
|
||||
// from the selected workflow's `defaultOn` and lifts the enabled set to the
|
||||
// parent (which puts it in the create payload). Only active in create mode.
|
||||
enabledWorkflowSteps?: string[];
|
||||
onEnabledWorkflowStepsChange?: (ids: string[]) => void;
|
||||
|
||||
// Attachments
|
||||
pendingImages: PendingImage[];
|
||||
@@ -177,6 +183,8 @@ export function TaskForm({
|
||||
onSelectedPresetIdChange,
|
||||
selectedWorkflowId,
|
||||
onWorkflowIdChange,
|
||||
enabledWorkflowSteps,
|
||||
onEnabledWorkflowStepsChange,
|
||||
pendingImages,
|
||||
onImagesChange,
|
||||
tasks,
|
||||
@@ -236,6 +244,8 @@ export function TaskForm({
|
||||
// U6/R3: full workflow definitions for the picker (fragments excluded below).
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [workflowsLoading, setWorkflowsLoading] = useState(false);
|
||||
const [optionalSteps, setOptionalSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
|
||||
const [optionalStepsLoading, setOptionalStepsLoading] = useState(false);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [baseBranchOptions, setBaseBranchOptions] = useState<string[]>([]);
|
||||
const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false);
|
||||
@@ -286,6 +296,63 @@ export function TaskForm({
|
||||
.catch(() => setGlobalSettings(null));
|
||||
}, [isActive, projectId, onWorkflowIdChange]);
|
||||
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
// Creating a task should let the user opt into the selected workflow's optional
|
||||
// steps, seeded from each step's defaultOn. TaskForm fetches + seeds these in
|
||||
// create mode and lifts the enabled set to NewTaskModal for the create payload.
|
||||
// Optional workflow steps for the currently-selected workflow (create mode only).
|
||||
// `null` selection ("No workflow") → no steps; `undefined` → project default.
|
||||
const effectiveOptionalWorkflowId =
|
||||
selectedWorkflowId === null
|
||||
? null
|
||||
: (selectedWorkflowId ?? settings?.defaultWorkflowId ?? null);
|
||||
useEffect(() => {
|
||||
if (!onWorkflowIdChange) return; // edit mode: optional steps are managed in the Workflow tab.
|
||||
let cancelled = false;
|
||||
setOptionalSteps([]);
|
||||
if (!effectiveOptionalWorkflowId) {
|
||||
// Clear any in-flight loading state (a prior fetch may have been cancelled
|
||||
// mid-flight when switching to "No workflow"), so the loading row never sticks.
|
||||
setOptionalStepsLoading(false);
|
||||
onEnabledWorkflowStepsChange?.([]);
|
||||
return;
|
||||
}
|
||||
setOptionalStepsLoading(true);
|
||||
fetchWorkflowOptionalSteps(effectiveOptionalWorkflowId, projectId)
|
||||
.then((steps) => {
|
||||
if (cancelled) return;
|
||||
setOptionalSteps(steps);
|
||||
// Re-seed the enabled set from each step's defaultOn on every workflow change.
|
||||
onEnabledWorkflowStepsChange?.(steps.filter((s) => s.defaultOn).map((s) => s.templateId));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setOptionalSteps([]);
|
||||
onEnabledWorkflowStepsChange?.([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setOptionalStepsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// onEnabledWorkflowStepsChange intentionally omitted from deps: a new identity
|
||||
// each render must not re-trigger the fetch/re-seed (would clobber user toggles).
|
||||
// Callers must pass a stable callback (NewTaskModal passes a useState setter).
|
||||
}, [onWorkflowIdChange, effectiveOptionalWorkflowId, projectId]);
|
||||
|
||||
const enabledOptionalStepIds = enabledWorkflowSteps ?? [];
|
||||
const toggleOptionalStep = useCallback(
|
||||
(templateId: string) => {
|
||||
const current = enabledWorkflowSteps ?? [];
|
||||
const next = current.includes(templateId)
|
||||
? current.filter((id) => id !== templateId)
|
||||
: [...current, templateId];
|
||||
onEnabledWorkflowStepsChange?.(next);
|
||||
},
|
||||
[enabledWorkflowSteps, onEnabledWorkflowStepsChange],
|
||||
);
|
||||
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings, globalSettings);
|
||||
@@ -1341,6 +1408,23 @@ export function TaskForm({
|
||||
<small className="workflow-select-help" data-testid="task-workflow-help">
|
||||
{t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")}
|
||||
</small>
|
||||
{optionalStepsLoading ? (
|
||||
<small className="workflow-optional-steps-loading" data-testid="task-optional-steps-loading">
|
||||
{t("taskForm.optionalStepsLoading", "Loading optional steps…")}
|
||||
</small>
|
||||
) : (
|
||||
optionalSteps.length > 0 && (
|
||||
<div className="task-form-optional-steps" data-testid="task-form-optional-steps">
|
||||
<WorkflowOptionalStepsDropdown
|
||||
steps={optionalSteps}
|
||||
enabledIds={enabledOptionalStepIds}
|
||||
onToggle={toggleOptionalStep}
|
||||
disabled={disabled}
|
||||
triggerTestId="task-optional-steps-trigger"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
fetchWorkflows,
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
columnsOf,
|
||||
fieldsOf,
|
||||
settingsOf,
|
||||
optionalStepsOf,
|
||||
columnsToBandNodes,
|
||||
reconcileNodeColumns,
|
||||
strictColumnForY,
|
||||
@@ -87,6 +88,7 @@ import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
|
||||
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
||||
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||
import { WorkflowOptionalStepsPanel } from "./WorkflowOptionalStepsPanel";
|
||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
||||
@@ -98,7 +100,7 @@ import {
|
||||
} from "./workflow-mobile-graph";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "optional-steps" | "columns" | "actions";
|
||||
|
||||
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
|
||||
const seam = typeof config?.seam === "string" ? config.seam : "";
|
||||
@@ -165,6 +167,7 @@ function serializeGraph(
|
||||
columns: WorkflowIrColumn[],
|
||||
fields: WorkflowFieldDefinition[],
|
||||
settings: WorkflowSettingDefinition[],
|
||||
optionalSteps: WorkflowOptionalStep[],
|
||||
): string {
|
||||
const { ir, layout } = flowToIr(
|
||||
name,
|
||||
@@ -173,6 +176,7 @@ function serializeGraph(
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
settings.length ? settings : undefined,
|
||||
optionalSteps.length ? optionalSteps : undefined,
|
||||
);
|
||||
return JSON.stringify({ name, description, ir, layout });
|
||||
}
|
||||
@@ -740,6 +744,7 @@ function InnerEditor({
|
||||
// VALUES live per-project in the workflow_settings table (KTD-2) and are
|
||||
// managed by the panel's Values tab, not this declaration array.
|
||||
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([]);
|
||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>([]);
|
||||
// Ref to the settings panel so a `?panel=settings` deep link can scroll it
|
||||
// into view on mount (U6/U9 redirect stubs).
|
||||
const settingsPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -778,6 +783,7 @@ function InnerEditor({
|
||||
const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed";
|
||||
const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed";
|
||||
const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed";
|
||||
const optionalStepsCollapsedStorageKey = "fusion:wf-sidebar-optional-steps-collapsed";
|
||||
const [columnsCollapsed, setColumnsCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(columnsCollapsedStorageKey) === "1";
|
||||
@@ -799,6 +805,13 @@ function InnerEditor({
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(optionalStepsCollapsedStorageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(columnsCollapsedStorageKey, columnsCollapsed ? "1" : "0");
|
||||
@@ -820,6 +833,13 @@ function InnerEditor({
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [settingsCollapsed]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(optionalStepsCollapsedStorageKey, optionalStepsCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [optionalStepsCollapsed]);
|
||||
// React Flow instance for programmatic viewport control (auto-layout on load).
|
||||
const { setViewport } = useReactFlow();
|
||||
// Wrapper around <ReactFlow> so keyboard deletion can return focus to the
|
||||
@@ -1009,10 +1029,10 @@ function InnerEditor({
|
||||
if (isBuiltin) return false;
|
||||
if (!activeWorkflow || loadedSnapshotRef.current === null) return false;
|
||||
return (
|
||||
serializeGraph(name, description, nodes, edges, columns, fields, settings) !==
|
||||
serializeGraph(name, description, nodes, edges, columns, fields, settings, optionalSteps) !==
|
||||
loadedSnapshotRef.current
|
||||
);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -1160,6 +1180,7 @@ function InnerEditor({
|
||||
setColumns([]);
|
||||
setFields([]);
|
||||
setSettings([]);
|
||||
setOptionalSteps([]);
|
||||
setName("");
|
||||
setDescription("");
|
||||
loadedSnapshotRef.current = null;
|
||||
@@ -1169,6 +1190,7 @@ function InnerEditor({
|
||||
const loadedColumns = columnsOf(activeWorkflow);
|
||||
const loadedFields = fieldsOf(activeWorkflow);
|
||||
const loadedSettings = settingsOf(activeWorkflow);
|
||||
const loadedOptionalSteps = optionalStepsOf(activeWorkflow);
|
||||
// Auto-layout on load: compute tidy positions and apply them before the
|
||||
// first render so nodes are visible in the top-left viewport.
|
||||
const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns);
|
||||
@@ -1178,6 +1200,7 @@ function InnerEditor({
|
||||
setColumns(loadedColumns);
|
||||
setFields(loadedFields);
|
||||
setSettings(loadedSettings);
|
||||
setOptionalSteps(loadedOptionalSteps);
|
||||
setName(activeWorkflow.name);
|
||||
setDescription(activeWorkflow.description ?? "");
|
||||
setEditingName(false);
|
||||
@@ -1192,6 +1215,7 @@ function InnerEditor({
|
||||
loadedColumns,
|
||||
loadedFields,
|
||||
loadedSettings,
|
||||
loadedOptionalSteps,
|
||||
);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
@@ -1495,6 +1519,11 @@ function InnerEditor({
|
||||
setEdges(flow.edges);
|
||||
setColumns(columnsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setFields(fieldsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
// Hydrate settings + optionalSteps on the fragment/generate path too — it
|
||||
// previously dropped both, which silently lost the declarations on the next
|
||||
// save (the round-trip data loss U2 fixes for the primary load path).
|
||||
setSettings(settingsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setOptionalSteps(optionalStepsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setValidationError(null);
|
||||
@@ -1806,6 +1835,7 @@ function InnerEditor({
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
settings.length ? settings : undefined,
|
||||
optionalSteps.length ? optionalSteps : undefined,
|
||||
);
|
||||
// Include name/description in the PATCH only when they changed from the
|
||||
// loaded workflow (KTD-10 inline rename/description persist here).
|
||||
@@ -1823,6 +1853,7 @@ function InnerEditor({
|
||||
columns,
|
||||
fields,
|
||||
settings,
|
||||
optionalSteps,
|
||||
);
|
||||
setName(updated.name);
|
||||
setDescription(updated.description ?? "");
|
||||
@@ -1892,7 +1923,7 @@ function InnerEditor({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
|
||||
// Stamp the shared error-state badge onto offending nodes: unplaced step
|
||||
// nodes and any node the server flagged (seam-in-branch). One component
|
||||
@@ -2454,6 +2485,27 @@ function InnerEditor({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wf-sidebar-section" data-testid="wf-sidebar-optional-steps-section">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-sidebar-section-toggle"
|
||||
aria-expanded={!optionalStepsCollapsed}
|
||||
data-testid="wf-sidebar-optional-steps-toggle"
|
||||
onClick={() => setOptionalStepsCollapsed((c) => !c)}
|
||||
>
|
||||
{optionalStepsCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
||||
<span>{t("workflowOptionalSteps.title", "Optional steps")}</span>
|
||||
</button>
|
||||
{!optionalStepsCollapsed && (
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
onChange={setOptionalSteps}
|
||||
readOnly={isBuiltin}
|
||||
pluginTemplates={pluginTemplates.map((p) => p.template)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
@@ -2576,6 +2628,7 @@ function InnerEditor({
|
||||
["add", t("workflowNodes.mobileAdd", "Add")],
|
||||
["settings", t("workflowSettings.title", "Settings")],
|
||||
["fields", t("workflowFields.title", "Fields")],
|
||||
["optional-steps", t("workflowOptionalSteps.title", "Optional steps")],
|
||||
["columns", t("workflowColumns.title", "Columns")],
|
||||
["actions", t("workflowNodes.mobileActions", "Actions")],
|
||||
] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => (
|
||||
@@ -2754,6 +2807,17 @@ function InnerEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "optional-steps" && (
|
||||
<div className="wf-mobile-destination">
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
onChange={setOptionalSteps}
|
||||
readOnly={isBuiltin}
|
||||
pluginTemplates={pluginTemplates.map((p) => p.template)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "columns" && (
|
||||
<div className="wf-mobile-destination">
|
||||
<WorkflowColumnPanel
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/* WorkflowOptionalStepsDropdown — shared optional-step multi-select. The panel
|
||||
* renders through a portal (position: fixed) so it is not clipped inside a modal's
|
||||
* overflow boundary. */
|
||||
|
||||
.wf-optional-steps-dropdown {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--surface, transparent);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-panel {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
box-shadow: var(--shadow-md, 0 6px 20px rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-option:hover,
|
||||
.wf-optional-steps-dropdown-option.is-active {
|
||||
background: var(--surface-hover, rgba(127, 127, 127, 0.12));
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-option:focus-visible {
|
||||
outline: 2px solid var(--accent, #4f7cff);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-option-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-option-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wf-optional-steps-dropdown-option-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
* Users selecting optional workflow steps at task creation need one consistent
|
||||
* multi-select control across every creation surface, with full keyboard + screen-
|
||||
* reader support, so the quick-add card and the full New Task modal behave identically.
|
||||
*
|
||||
* WorkflowOptionalStepsDropdown — a controlled multi-select for a workflow's
|
||||
* optional steps, shared by the quick-add card (U5) and the full New Task modal
|
||||
* (U4) so both creation surfaces present the same interaction.
|
||||
*
|
||||
* Controlled: the parent owns the enabled set (`enabledIds`) and seeds it from
|
||||
* each step's `defaultOn`; this component owns only open/close UI state. The panel
|
||||
* renders through a portal so it is not clipped by a modal's overflow boundary.
|
||||
*
|
||||
* Empty state (committed): renders nothing when there are no optional steps —
|
||||
* matching the quick-add card's prior no-chip-block behavior and the modal's
|
||||
* empty-state choice, so both surfaces look identical.
|
||||
*
|
||||
* Accessibility: trigger has aria-haspopup/aria-expanded; the panel is a
|
||||
* role="listbox" labelled by the trigger; each option is a role="option" with
|
||||
* aria-checked. Escape closes and refocuses the trigger; arrow keys move the
|
||||
* active option; outside-click closes.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||
import { phaseBadge } from "./workflow-phase-badge";
|
||||
import "./WorkflowOptionalStepsDropdown.css";
|
||||
|
||||
interface WorkflowOptionalStepsDropdownProps {
|
||||
steps: ResolvedWorkflowOptionalStep[];
|
||||
enabledIds: string[];
|
||||
onToggle: (templateId: string) => void;
|
||||
disabled?: boolean;
|
||||
/** Test/styling hook applied to the trigger. */
|
||||
triggerTestId?: string;
|
||||
}
|
||||
|
||||
interface PanelPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function WorkflowOptionalStepsDropdown({
|
||||
steps,
|
||||
enabledIds,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
triggerTestId = "wf-optional-steps-dropdown-trigger",
|
||||
}: WorkflowOptionalStepsDropdownProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [position, setPosition] = useState<PanelPosition | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const labelId = useId();
|
||||
|
||||
const reposition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
setPosition({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}, []);
|
||||
|
||||
// Reposition on open and keep anchored during scroll/resize.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
reposition();
|
||||
const handle = () => reposition();
|
||||
window.addEventListener("resize", handle);
|
||||
window.addEventListener("scroll", handle, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handle);
|
||||
window.removeEventListener("scroll", handle, true);
|
||||
};
|
||||
}, [isOpen, reposition]);
|
||||
|
||||
// Outside-click closes (capture so it fires before the trigger's own handler).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||
setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
return () => document.removeEventListener("mousedown", onDocMouseDown);
|
||||
}, [isOpen]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Focus the active option when the panel opens or the active index changes —
|
||||
// driven by an effect (not an inline ref callback) so a re-render from toggling
|
||||
// a step does not steal focus back to the active option on every commit.
|
||||
useEffect(() => {
|
||||
if (isOpen && position) optionRefs.current[activeIndex]?.focus();
|
||||
}, [isOpen, position, activeIndex]);
|
||||
|
||||
// Empty state: render nothing (committed behavior, shared with the modal).
|
||||
if (steps.length === 0) return null;
|
||||
|
||||
const selectedCount = steps.filter((s) => enabledIds.includes(s.templateId)).length;
|
||||
const triggerLabel =
|
||||
selectedCount === 0
|
||||
? t("workflowOptionalSteps.triggerNone", "Steps: none")
|
||||
: t("workflowOptionalSteps.triggerCount", "Steps: {{count}} selected", { count: selectedCount });
|
||||
|
||||
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
||||
// ArrowUp also opens (ARIA listbox authoring guidance), landing on the last option.
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveIndex(e.key === "ArrowUp" ? steps.length - 1 : 0);
|
||||
}
|
||||
};
|
||||
|
||||
const onPanelKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
close();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, steps.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const step = steps[activeIndex];
|
||||
if (step) onToggle(step.templateId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wf-optional-steps-dropdown">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
id={labelId}
|
||||
className="wf-optional-steps-dropdown-trigger"
|
||||
data-testid={triggerTestId}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setIsOpen((o) => !o);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span>{triggerLabel}</span>
|
||||
<ChevronDown size={13} aria-hidden />
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
position &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="wf-optional-steps-dropdown-panel"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
aria-label={t("workflowOptionalSteps.title", "Optional steps")}
|
||||
data-testid="wf-optional-steps-dropdown-panel"
|
||||
style={{ top: position.top, left: position.left, minWidth: position.width }}
|
||||
onKeyDown={onPanelKeyDown}
|
||||
>
|
||||
{steps.map((step, i) => {
|
||||
const checked = enabledIds.includes(step.templateId);
|
||||
return (
|
||||
<div
|
||||
key={step.templateId}
|
||||
role="option"
|
||||
aria-checked={checked}
|
||||
tabIndex={i === activeIndex ? 0 : -1}
|
||||
ref={(el) => {
|
||||
optionRefs.current[i] = el;
|
||||
}}
|
||||
className={`wf-optional-steps-dropdown-option${i === activeIndex ? " is-active" : ""}`}
|
||||
data-testid={`wf-optional-steps-dropdown-option-${step.templateId}`}
|
||||
onClick={() => onToggle(step.templateId)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="wf-optional-steps-dropdown-option-body">
|
||||
<span className="wf-optional-steps-dropdown-option-name">
|
||||
{step.name}
|
||||
{phaseBadge(step.phase, step.templateId, "wf-optional-steps-dropdown-phase", t)}
|
||||
</span>
|
||||
{step.description && (
|
||||
<span className="wf-optional-steps-dropdown-option-desc">{step.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowOptionalStepsDropdown;
|
||||
107
packages/dashboard/app/components/WorkflowOptionalStepsPanel.css
Normal file
107
packages/dashboard/app/components/WorkflowOptionalStepsPanel.css
Normal file
@@ -0,0 +1,107 @@
|
||||
/* WorkflowOptionalStepsPanel — sibling of WorkflowFieldsPanel; mirrors its layout
|
||||
* so the optional-steps panel reads consistently alongside Fields/Settings. */
|
||||
|
||||
.wf-optional-steps-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.wf-optional-steps-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-hint,
|
||||
.wf-optional-steps-empty {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wf-optional-step-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.wf-optional-step-item.is-unknown {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wf-optional-step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wf-optional-step-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-optional-step-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wf-optional-step-name--unknown {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wf-optional-step-description {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-step-default {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wf-optional-step-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-optional-step-remove:hover:not(:disabled) {
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
.wf-optional-steps-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wf-optional-steps-add-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
177
packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx
Normal file
177
packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
* Workflow authors need to declare which step templates are optional and set each
|
||||
* one's defaultOn from the visual editor (persisted on the IR's `optionalSteps`
|
||||
* array) so optional steps are authorable without hand-editing IR.
|
||||
*
|
||||
* WorkflowOptionalStepsPanel — the workflow editor's optional-step authoring
|
||||
* surface. Sibling to {@link WorkflowFieldsPanel} / WorkflowSettingsPanel: lives
|
||||
* alongside the canvas in {@link WorkflowNodeEditor} and mutates the IR's
|
||||
* `optionalSteps` array through the same state/save flow (preserved across the
|
||||
* round-trip by `flowToIr`).
|
||||
*
|
||||
* A declaration is just `{ templateId, defaultOn? }`. Display metadata
|
||||
* (name/description/phase) is resolved from the built-in step-template catalog at
|
||||
* render time — never duplicated into the IR — so the resolver stays the single
|
||||
* source of truth. Unknown/stale template ids render a muted, still-removable row
|
||||
* rather than being silently dropped.
|
||||
*/
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { WORKFLOW_STEP_TEMPLATES, type WorkflowOptionalStep, type WorkflowStepTemplate } from "@fusion/core";
|
||||
import { phaseBadge } from "./workflow-phase-badge";
|
||||
import "./WorkflowOptionalStepsPanel.css";
|
||||
|
||||
interface WorkflowOptionalStepsPanelProps {
|
||||
optionalSteps: WorkflowOptionalStep[];
|
||||
onChange: (next: WorkflowOptionalStep[]) => void;
|
||||
readOnly: boolean;
|
||||
/** Plugin-contributed templates, merged into the catalog when available. */
|
||||
pluginTemplates?: WorkflowStepTemplate[];
|
||||
}
|
||||
|
||||
export function WorkflowOptionalStepsPanel({
|
||||
optionalSteps,
|
||||
onChange,
|
||||
readOnly,
|
||||
pluginTemplates = [],
|
||||
}: WorkflowOptionalStepsPanelProps) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const templatesById = useMemo(() => {
|
||||
const map = new Map<string, WorkflowStepTemplate>();
|
||||
for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl);
|
||||
return map;
|
||||
}, [pluginTemplates]);
|
||||
|
||||
const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]);
|
||||
|
||||
// Catalog entries not already declared — the "Add optional step" picker source.
|
||||
const available = useMemo(
|
||||
() => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)),
|
||||
[templatesById, declaredIds],
|
||||
);
|
||||
|
||||
const addStep = useCallback(
|
||||
(templateId: string) => {
|
||||
if (!templateId || declaredIds.has(templateId)) return;
|
||||
onChange([...optionalSteps, { templateId, defaultOn: false }]);
|
||||
},
|
||||
[optionalSteps, onChange, declaredIds],
|
||||
);
|
||||
|
||||
const removeStep = useCallback(
|
||||
(templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)),
|
||||
[optionalSteps, onChange],
|
||||
);
|
||||
|
||||
const toggleDefaultOn = useCallback(
|
||||
(templateId: string, defaultOn: boolean) =>
|
||||
onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))),
|
||||
[optionalSteps, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="wf-optional-steps-panel" data-testid="wf-optional-steps-panel">
|
||||
<header className="wf-optional-steps-header">
|
||||
<h3>{t("workflowOptionalSteps.title", "Optional steps")}</h3>
|
||||
<p className="wf-optional-steps-hint">
|
||||
{t(
|
||||
"workflowOptionalSteps.hint",
|
||||
"Steps a task can toggle on or off. Default sets the initial state for new tasks.",
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{optionalSteps.length === 0 ? (
|
||||
<p className="wf-optional-steps-empty">
|
||||
{t("workflowOptionalSteps.empty", "No optional steps. Add one to let tasks opt in or out.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="wf-optional-steps-list">
|
||||
{optionalSteps.map((step) => {
|
||||
const tpl = templatesById.get(step.templateId);
|
||||
const defaultOn = step.defaultOn ?? tpl?.defaultOn ?? false;
|
||||
return (
|
||||
<li
|
||||
key={step.templateId}
|
||||
className={`wf-optional-step-item${tpl ? "" : " is-unknown"}`}
|
||||
data-testid={`wf-optional-step-${step.templateId}`}
|
||||
>
|
||||
<div className="wf-optional-step-head">
|
||||
<div className="wf-optional-step-title">
|
||||
{tpl ? (
|
||||
<>
|
||||
<span className="wf-optional-step-name">{tpl.name}</span>
|
||||
{phaseBadge(tpl.phase ?? "pre-merge", step.templateId, "wf-optional-step-phase", t)}
|
||||
</>
|
||||
) : (
|
||||
<span className="wf-optional-step-name wf-optional-step-name--unknown">
|
||||
{t("workflowOptionalSteps.unknown", "Unknown step ({{id}})", { id: step.templateId })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-optional-step-remove"
|
||||
aria-label={t("workflowOptionalSteps.remove", "Remove optional step")}
|
||||
disabled={readOnly}
|
||||
onClick={() => removeStep(step.templateId)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{tpl?.description && (
|
||||
<p className="wf-optional-step-description">{tpl.description}</p>
|
||||
)}
|
||||
<label className="wf-optional-step-default">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={defaultOn}
|
||||
disabled={readOnly}
|
||||
aria-label={t("workflowOptionalSteps.defaultOnFor", "Default on for {{name}}", {
|
||||
name: tpl?.name ?? step.templateId,
|
||||
})}
|
||||
onChange={(e) => toggleDefaultOn(step.templateId, e.target.checked)}
|
||||
/>
|
||||
<span>{t("workflowOptionalSteps.defaultOn", "Default on")}</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{available.length > 0 && (
|
||||
<div className="wf-optional-steps-add">
|
||||
{/* Picker resets to placeholder after each add (value stays ""). */}
|
||||
<label className="wf-optional-steps-add-label" htmlFor="wf-optional-steps-add-select">
|
||||
<Plus size={13} /> {t("workflowOptionalSteps.add", "Add optional step")}
|
||||
</label>
|
||||
<select
|
||||
id="wf-optional-steps-add-select"
|
||||
data-testid="wf-optional-steps-add-select"
|
||||
value=""
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
addStep(e.target.value);
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("workflowOptionalSteps.addPlaceholder", "Select a step…")}
|
||||
</option>
|
||||
{available.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowOptionalStepsPanel;
|
||||
@@ -15,6 +15,7 @@ import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, Wor
|
||||
import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
||||
import { approveTaskWorkflowCli, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
|
||||
import { WorkflowSelector } from "./WorkflowSelector";
|
||||
import { phaseBadge } from "./workflow-phase-badge";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { irToFlow } from "./workflow-flow-mapping";
|
||||
@@ -134,17 +135,8 @@ function getOutputPreview(output: string): string {
|
||||
return `${lines.length} lines`;
|
||||
}
|
||||
|
||||
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string, t: ReturnType<typeof useTranslation>["t"]): ReactNode {
|
||||
const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge";
|
||||
return (
|
||||
<span
|
||||
className={`phase-badge ${phaseClass}`}
|
||||
data-testid={`${prefix}-${id}`}
|
||||
>
|
||||
{phase === "post-merge" ? t("app:workflow.postMerge", "Post-merge") : t("app:workflow.preMerge", "Pre-merge")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// phaseBadge moved to ./workflow-phase-badge (shared with the optional-steps panel
|
||||
// and the optional-steps dropdown). Imported above.
|
||||
|
||||
function getWorkflowName(
|
||||
selectedWorkflowId: string | null,
|
||||
|
||||
@@ -1079,12 +1079,14 @@ describe("InlineCreateCard button visibility when collapsed", () => {
|
||||
target: { value: "Verify login flow in browser" },
|
||||
});
|
||||
|
||||
const toggle = await screen.findByTestId("inline-create-browser-verification-toggle");
|
||||
expect(toggle).toHaveTextContent("Browser Verification");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
// Open the optional-steps dropdown and select browser verification.
|
||||
const trigger = await screen.findByTestId("inline-create-optional-steps-trigger");
|
||||
expect(trigger).toHaveTextContent("Steps: none");
|
||||
fireEvent.click(trigger);
|
||||
const option = await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification");
|
||||
expect(option).toHaveAttribute("aria-checked", "false");
|
||||
fireEvent.click(option);
|
||||
expect(trigger).toHaveTextContent("Steps: 1 selected");
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("../../api", () => ({
|
||||
// U6/R3: TaskForm's picker fetches whole workflows; the per-step
|
||||
// fetchWorkflowSteps + post-create selectTaskWorkflow flow is gone.
|
||||
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
fetchGitBranches: vi.fn().mockResolvedValue([]),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
@@ -239,6 +240,89 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional workflow steps (U4)", () => {
|
||||
const WF = {
|
||||
id: "wf-x",
|
||||
name: "Custom",
|
||||
kind: "workflow" as const,
|
||||
description: "",
|
||||
ir: { version: "v1" as const, name: "Custom", nodes: [], edges: [] },
|
||||
layout: {},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const STEP = {
|
||||
templateId: "browser-verification",
|
||||
name: "Browser Verification",
|
||||
description: "Verify web application functionality using browser automation",
|
||||
icon: "globe",
|
||||
phase: "pre-merge" as const,
|
||||
defaultOn: false,
|
||||
};
|
||||
|
||||
it("includes a toggled-on optional step in the create payload", async () => {
|
||||
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), {
|
||||
target: { value: "Verify the login page" },
|
||||
});
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
|
||||
|
||||
const trigger = await screen.findByTestId("task-optional-steps-trigger");
|
||||
expect(trigger).toHaveTextContent("Steps: none");
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds defaultOn steps as pre-enabled and submits them without toggling", async () => {
|
||||
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([{ ...STEP, defaultOn: true }]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "wf-x" } });
|
||||
|
||||
const trigger = await screen.findByTestId("task-optional-steps-trigger");
|
||||
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders no dropdown and omits enabledWorkflowSteps for 'No workflow'", async () => {
|
||||
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
|
||||
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
|
||||
// "No workflow" → null selection → no optional-steps fetch, no dropdown.
|
||||
fireEvent.change(await screen.findByTestId("task-workflow-select"), { target: { value: "__none__" } });
|
||||
|
||||
expect(screen.queryByTestId("task-optional-steps-trigger")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
await waitFor(() => {
|
||||
const call = vi.mocked(props.onCreateTask).mock.calls.at(-1)?.[0];
|
||||
expect(call).not.toHaveProperty("enabledWorkflowSteps");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("submits project-default branch selection by default", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("../../api", () => ({
|
||||
}),
|
||||
// U6/R3: TaskForm now fetches whole workflows (not steps) for the picker.
|
||||
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
refineText: vi.fn().mockResolvedValue("Refined text"),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
|
||||
@@ -164,6 +164,14 @@ function v2Def(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function v2DefWithOptional(): WorkflowDefinition {
|
||||
const base = v2Def();
|
||||
return {
|
||||
...base,
|
||||
ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"],
|
||||
};
|
||||
}
|
||||
|
||||
function builtinDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "builtin:coding",
|
||||
@@ -742,6 +750,34 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(start?.column).toBe("done");
|
||||
});
|
||||
|
||||
it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({
|
||||
...v2DefWithOptional(),
|
||||
...(updates as object),
|
||||
}));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
// The declared optional step is hydrated into the panel (optionalStepsOf).
|
||||
const row = await screen.findByTestId("wf-optional-step-browser-verification");
|
||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
||||
|
||||
// Toggling defaultOn must mark the editor dirty (serializeGraph threading) so
|
||||
// the Save button enables and persists the change.
|
||||
fireEvent.click(within(row).getByRole("checkbox"));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as {
|
||||
optionalSteps?: { templateId: string; defaultOn?: boolean }[];
|
||||
};
|
||||
expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("renders the start inspector without the entry-column select for v1 workflows", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import type { ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||
import { WorkflowOptionalStepsDropdown } from "../WorkflowOptionalStepsDropdown";
|
||||
|
||||
const STEP: ResolvedWorkflowOptionalStep = {
|
||||
templateId: "browser-verification",
|
||||
name: "Browser Verification",
|
||||
description: "Verify web application functionality using browser automation",
|
||||
icon: "globe",
|
||||
phase: "pre-merge",
|
||||
defaultOn: false,
|
||||
};
|
||||
|
||||
// Controlled host: parent owns the enabled set, mirroring the create surfaces.
|
||||
function Host({ steps, initial = [] }: { steps: ResolvedWorkflowOptionalStep[]; initial?: string[] }) {
|
||||
const [enabled, setEnabled] = useState<string[]>(initial);
|
||||
return (
|
||||
<WorkflowOptionalStepsDropdown
|
||||
steps={steps}
|
||||
enabledIds={enabled}
|
||||
onToggle={(id) =>
|
||||
setEnabled((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkflowOptionalStepsDropdown", () => {
|
||||
it("renders nothing when there are no optional steps", () => {
|
||||
const { container } = render(<Host steps={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the selected count in the trigger label", () => {
|
||||
render(<Host steps={[STEP]} />);
|
||||
const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger");
|
||||
expect(trigger).toHaveTextContent("Steps: none");
|
||||
});
|
||||
|
||||
it("opens, toggles a step, and updates the trigger count", () => {
|
||||
render(<Host steps={[STEP]} />);
|
||||
const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger");
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
const option = screen.getByTestId("wf-optional-steps-dropdown-option-browser-verification");
|
||||
expect(option).toHaveAttribute("role", "option");
|
||||
expect(option).toHaveAttribute("aria-checked", "false");
|
||||
fireEvent.click(option);
|
||||
expect(screen.getByTestId("wf-optional-steps-dropdown-option-browser-verification")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
expect(trigger).toHaveTextContent("Steps: 1 selected");
|
||||
});
|
||||
|
||||
it("pre-checks a step seeded as enabled by the parent (defaultOn)", () => {
|
||||
render(<Host steps={[STEP]} initial={["browser-verification"]} />);
|
||||
fireEvent.click(screen.getByTestId("wf-optional-steps-dropdown-trigger"));
|
||||
expect(screen.getByTestId("wf-optional-steps-dropdown-option-browser-verification")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the panel as an accessible listbox labelled by the trigger", () => {
|
||||
render(<Host steps={[STEP]} />);
|
||||
fireEvent.click(screen.getByTestId("wf-optional-steps-dropdown-trigger"));
|
||||
const panel = screen.getByTestId("wf-optional-steps-dropdown-panel");
|
||||
expect(panel).toHaveAttribute("role", "listbox");
|
||||
expect(within(panel).getByText("Browser Verification")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes on Escape", () => {
|
||||
render(<Host steps={[STEP]} />);
|
||||
const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger");
|
||||
fireEvent.click(trigger);
|
||||
const panel = screen.getByTestId("wf-optional-steps-dropdown-panel");
|
||||
fireEvent.keyDown(panel, { key: "Escape" });
|
||||
expect(screen.queryByTestId("wf-optional-steps-dropdown-panel")).toBeNull();
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("closes on outside click without losing selection", () => {
|
||||
render(
|
||||
<div>
|
||||
<Host steps={[STEP]} initial={["browser-verification"]} />
|
||||
<button data-testid="outside">outside</button>
|
||||
</div>,
|
||||
);
|
||||
const trigger = screen.getByTestId("wf-optional-steps-dropdown-trigger");
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByTestId("wf-optional-steps-dropdown-panel")).toBeTruthy();
|
||||
fireEvent.mouseDown(screen.getByTestId("outside"));
|
||||
expect(screen.queryByTestId("wf-optional-steps-dropdown-panel")).toBeNull();
|
||||
// Selection preserved.
|
||||
expect(trigger).toHaveTextContent("Steps: 1 selected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import type { WorkflowOptionalStep } from "@fusion/core";
|
||||
import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel";
|
||||
|
||||
// Controlled host mirroring how WorkflowNodeEditor drives the panel.
|
||||
function Host({
|
||||
initial,
|
||||
readOnly = false,
|
||||
onState,
|
||||
}: {
|
||||
initial: WorkflowOptionalStep[];
|
||||
readOnly?: boolean;
|
||||
onState?: (s: WorkflowOptionalStep[]) => void;
|
||||
}) {
|
||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>(initial);
|
||||
return (
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => {
|
||||
setOptionalSteps(next);
|
||||
onState?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkflowOptionalStepsPanel", () => {
|
||||
it("renders the empty state and an add picker when no steps are declared", () => {
|
||||
render(<Host initial={[]} />);
|
||||
expect(screen.getByText(/No optional steps/i)).toBeTruthy();
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
// browser-verification is in the catalog and not yet declared → available.
|
||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a step from the picker (defaultOn false) and removes it from the picker", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[]} onState={onState} />);
|
||||
fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), {
|
||||
target: { value: "browser-verification" },
|
||||
});
|
||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]);
|
||||
// The declared row is shown with the resolved template name…
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
||||
// …and the picker no longer offers it.
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles defaultOn for a declared step", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[{ templateId: "browser-verification", defaultOn: false }]} onState={onState} />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
fireEvent.click(within(row).getByRole("checkbox"));
|
||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("removes a declared step and returns it to the picker", () => {
|
||||
render(<Host initial={[{ templateId: "browser-verification" }]} />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
||||
expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull();
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an unknown/stale templateId as a muted, still-removable row", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[{ templateId: "does-not-exist" }]} onState={onState} />);
|
||||
const row = screen.getByTestId("wf-optional-step-does-not-exist");
|
||||
expect(row.className).toContain("is-unknown");
|
||||
expect(within(row).getByText(/Unknown step/i)).toBeTruthy();
|
||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
||||
expect(onState).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("disables editing when readOnly", () => {
|
||||
render(<Host initial={[{ templateId: "browser-verification" }]} readOnly />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fragmentSeamConflicts,
|
||||
copyIrWithFreshIds,
|
||||
columnsOf,
|
||||
optionalStepsOf,
|
||||
columnForY,
|
||||
bandTop,
|
||||
columnsToBandNodes,
|
||||
@@ -1503,3 +1504,84 @@ describe("copyIrWithFreshIds", () => {
|
||||
expect(t1NewId).toEqual({ x: 10, y: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionalSteps round-trip (U2)", () => {
|
||||
const v2WithOptional = (optionalSteps?: { templateId: string; defaultOn?: boolean }[]) =>
|
||||
makeDef(
|
||||
parseWorkflowIr({
|
||||
version: "v2",
|
||||
name: "wf-opt",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
...(optionalSteps ? { optionalSteps } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
it("optionalStepsOf reads declarations from a v2 IR and returns a copy", () => {
|
||||
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
const read = optionalStepsOf(def);
|
||||
expect(read).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
// mutating the result does not mutate the source IR
|
||||
read[0].defaultOn = false;
|
||||
expect(optionalStepsOf(def)).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("optionalStepsOf returns [] for v1 and for v2 without optionalSteps", () => {
|
||||
const v1 = makeDef({
|
||||
version: "v1",
|
||||
name: "legacy",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
});
|
||||
expect(optionalStepsOf(v1)).toEqual([]);
|
||||
expect(optionalStepsOf(v2WithOptional())).toEqual([]);
|
||||
});
|
||||
|
||||
it("flowToIr preserves optionalSteps across a full irToFlow round-trip", () => {
|
||||
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], optionalStepsOf(def));
|
||||
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
|
||||
{ templateId: "browser-verification", defaultOn: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes as v2 when optionalSteps present but no custom columns/fields/settings", () => {
|
||||
const { ir: out } = flowToIr(
|
||||
"opt-only",
|
||||
[
|
||||
{ id: "start", type: "workflowNode", position: { x: 0, y: 0 }, data: { kind: "start" } },
|
||||
{ id: "end", type: "workflowNode", position: { x: 0, y: 200 }, data: { kind: "end" } },
|
||||
] as unknown as FlowNode<WorkflowFlowNodeData>[],
|
||||
[{ id: "e1", source: "start", target: "end" }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ templateId: "browser-verification" }],
|
||||
);
|
||||
expect(out.version).toBe("v2");
|
||||
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
|
||||
{ templateId: "browser-verification" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the optionalSteps key entirely when empty (R6 byte-identity)", () => {
|
||||
const def = v2WithOptional();
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], []);
|
||||
expect("optionalSteps" in out).toBe(false);
|
||||
// and with the arg omitted entirely
|
||||
const { ir: out2 } = flowToIr("wf-opt", nodes, edges, columnsOf(def));
|
||||
expect("optionalSteps" in out2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowSettingDefinition,
|
||||
WorkflowOptionalStep,
|
||||
} from "@fusion/core";
|
||||
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
|
||||
@@ -434,6 +435,7 @@ export function flowToIr(
|
||||
columns?: WorkflowIrColumn[],
|
||||
fields?: WorkflowFieldDefinition[],
|
||||
settings?: WorkflowSettingDefinition[],
|
||||
optionalSteps?: WorkflowOptionalStep[],
|
||||
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
|
||||
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
|
||||
// Partition by parentId: foreach group children reassemble into that group's
|
||||
@@ -453,9 +455,15 @@ export function flowToIr(
|
||||
);
|
||||
const hasFields = Array.isArray(fields) && fields.length > 0;
|
||||
const hasSettings = Array.isArray(settings) && settings.length > 0;
|
||||
// Fields and settings are v2-only declarations: a workflow with either but no
|
||||
// custom columns still serializes as v2 (with the synthesized default columns).
|
||||
const v2 = (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings;
|
||||
const hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0;
|
||||
// FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
// Optional steps must round-trip through the node editor without data loss, yet
|
||||
// must never upgrade a legacy v1 graph. Fields, settings, and optional steps are
|
||||
// v2-only declarations: a workflow with any of them but no custom columns still
|
||||
// serializes as v2 (with the synthesized default columns). Empty/absent → not a
|
||||
// v2 signal, and the key is omitted entirely (R6 byte-identity for legacy graphs).
|
||||
const v2 =
|
||||
(Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps;
|
||||
const layout: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
/** Project one flow node (top-level or template child) into an IR node. */
|
||||
@@ -556,6 +564,12 @@ export function flowToIr(
|
||||
render: s.render ? { ...s.render } : undefined,
|
||||
}));
|
||||
}
|
||||
if (hasOptionalSteps) {
|
||||
// Optional-step DECLARATIONS round-trip through the editor opaquely (they are
|
||||
// not graph nodes; the resolver + server validator are the source of truth).
|
||||
// Omitted entirely when empty so legacy graphs stay byte-identical (R6).
|
||||
(ir as { optionalSteps?: unknown }).optionalSteps = optionalSteps!.map((o) => ({ ...o }));
|
||||
}
|
||||
return { ir, layout };
|
||||
}
|
||||
|
||||
@@ -967,6 +981,16 @@ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[]
|
||||
}));
|
||||
}
|
||||
|
||||
/** Extract the editor's working optional-step declaration list from a definition.
|
||||
* v2 with `optionalSteps` → a shallow copy; v1 or none → empty. Display metadata
|
||||
* (name/icon/phase) is NOT carried here — it is resolved from the step-template
|
||||
* catalog at render time so the resolver stays the single source of truth. */
|
||||
export function optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[] {
|
||||
const ir = def.ir as { optionalSteps?: WorkflowOptionalStep[] };
|
||||
if (!isV2(def.ir) || !Array.isArray(ir.optionalSteps)) return [];
|
||||
return ir.optionalSteps.map((o) => ({ ...o }));
|
||||
}
|
||||
|
||||
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
|
||||
export function emptyWorkflowIr(name: string): WorkflowIr {
|
||||
return {
|
||||
|
||||
27
packages/dashboard/app/components/workflow-phase-badge.tsx
Normal file
27
packages/dashboard/app/components/workflow-phase-badge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
||||
* One phase chip (pre-merge / post-merge) shared by every workflow-step surface so
|
||||
* the badge looks identical across the results tab, authoring panel, and dropdown.
|
||||
*
|
||||
* Shared phase chip for workflow steps (pre-merge / post-merge). Extracted from
|
||||
* WorkflowResultsTab so the node-editor optional-steps panel and the optional-step
|
||||
* dropdown render an identical badge without duplicating markup.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import type { useTranslation } from "react-i18next";
|
||||
|
||||
export function phaseBadge(
|
||||
phase: "pre-merge" | "post-merge",
|
||||
id: string,
|
||||
prefix: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): ReactNode {
|
||||
const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge";
|
||||
return (
|
||||
<span className={`phase-badge ${phaseClass}`} data-testid={`${prefix}-${id}`}>
|
||||
{phase === "post-merge"
|
||||
? t("app:workflow.postMerge", "Post-merge")
|
||||
: t("app:workflow.preMerge", "Pre-merge")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -156,6 +156,7 @@ async function runStepwiseGraph(
|
||||
signal?: AbortSignal;
|
||||
onReset?: (active: ForeachActiveContext) => void;
|
||||
captureResetResult?: (ok: boolean, reason?: string) => void;
|
||||
workflowStep?: WorkflowLegacySeams["workflowStep"];
|
||||
} = {},
|
||||
): Promise<{ trajectory: TrajectoryEntry[]; outcome: string; result: Awaited<ReturnType<WorkflowGraphExecutor["run"]>> }> {
|
||||
const task = taskWithSteps(stepCount);
|
||||
@@ -168,6 +169,7 @@ async function runStepwiseGraph(
|
||||
merge: async () => ({ outcome: "success" }),
|
||||
schedule: async () => ({ outcome: "success" }),
|
||||
execute: async () => ({ outcome: "success" }),
|
||||
...(opts.workflowStep ? { workflowStep: opts.workflowStep } : {}),
|
||||
stepExecute: async (_t, ctx) => {
|
||||
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
|
||||
const result = await runTaskStep(
|
||||
@@ -575,4 +577,45 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => {
|
||||
// Merge ran (the task merges with no step work).
|
||||
expect(result.visitedNodeIds).toContain("merge");
|
||||
});
|
||||
|
||||
// ── Pre-merge workflow-step seam (optional-step execution, R1) ─────────────
|
||||
|
||||
it("runs the pre-merge workflow-step seam exactly once after the foreach (enabled steps execute)", async () => {
|
||||
// This is the dead-toggle guard: without a workflow-step seam node on the
|
||||
// success path, a stepwise task's enabledWorkflowSteps (e.g. browser
|
||||
// verification) would never run. Wire a workflowStep spy and assert the graph
|
||||
// invokes it once, between the foreach and review.
|
||||
let workflowStepCalls = 0;
|
||||
const { outcome, result } = await runStepwiseGraph(
|
||||
3,
|
||||
[["APPROVE"], ["APPROVE"], ["APPROVE"]],
|
||||
{
|
||||
workflowStep: async () => {
|
||||
workflowStepCalls++;
|
||||
return { outcome: "success" };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(outcome).toBe("success");
|
||||
// The seam ran ONCE post-foreach — not per step-instance (3 steps here).
|
||||
expect(workflowStepCalls).toBe(1);
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
// Ordering: all step instances complete before the workflow-step seam, which
|
||||
// precedes review.
|
||||
const seamIdx = result.visitedNodeIds.indexOf("workflow-step");
|
||||
const reviewIdx = result.visitedNodeIds.indexOf("review");
|
||||
const lastStepIdx = result.visitedNodeIds.map((id) => id.startsWith("steps#")).lastIndexOf(true);
|
||||
expect(lastStepIdx).toBeLessThan(seamIdx);
|
||||
expect(seamIdx).toBeLessThan(reviewIdx);
|
||||
});
|
||||
|
||||
it("treats the workflow-step seam as a no-op pass-through when no steps are enabled", async () => {
|
||||
// No workflowStep seam wired → the handler skips to success and routes to
|
||||
// review, leaving the trajectory identical to the pre-seam behavior.
|
||||
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]]);
|
||||
expect(outcome).toBe("success");
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).toContain("review");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user