Skip to content

feat(web): inline YAML editor — Add/Edit modal + Delete (closes #74, partial #12)#76

Merged
naorsabag merged 10 commits into
masterfrom
feat/web-flow-crud-ui
May 5, 2026
Merged

feat(web): inline YAML editor — Add/Edit modal + Delete (closes #74, partial #12)#76
naorsabag merged 10 commits into
masterfrom
feat/web-flow-crud-ui

Conversation

@naorsabag
Copy link
Copy Markdown
Owner

@naorsabag naorsabag commented May 5, 2026

Summary

Lets users author and edit flows directly in the browser. The same authoring path as the CLI (openhop push / openhop remove), now inline in the sidebar — no terminal required.

Closes #74 (inline YAML editor — Add/Edit modal in left menu) and partially closes #12 (P11: enable editing flows from the UI).

What's new

Sidebar:

  • + New flow primary button at the top.
  • ✎ Edit and ✕ Delete affordances on every flow row, revealed on hover.

FlowEditorModal (new component):

  • CodeMirror 6 YAML editor with live validation via parseFlowYaml() from @openhop/shared — sub-100ms feedback.
  • Errors render with path + suggestion fields (mirrors CLI validate --json).
  • Server-side errors (e.g. node-ref lookups local validation misses) render distinctly under the editor.
  • Cmd/Ctrl-Enter saves; Esc cancels; click-outside cancels.

Wiring:

  • New useFlowMutations.ts — POST + DELETE wrappers. Distinguishes validation / server / network errors so the modal can render path-prefixed details.
  • useFlowList gains a reload() so create/delete refresh the sidebar without remounting.
  • App.tsx owns the modal state machine ({closed, new, edit}) and the delete confirmation. Edit pre-populates by GETting the stored flow and round-tripping through yaml.stringify.

Scope decisions (per #74)

  • Edit = full re-push (POST), creating a new flow ID. Patch-ops in-place editing is the CLI's openhop patch flow — out of scope, track separately if needed.
  • Delete confirmation: native window.confirm — simpler than a styled dialog, upgradeable later.
  • Static-deploy "fragment mode" for feat(web): serve the UI as a static site on GitHub Pages (naorsabag.github.io/openhop/) #75 deferred. The modal takes a minimal prop surface so it can grow that mode without a rewrite.

New deps (web only)

@uiw/react-codemirror     ^4.25.9
@codemirror/lang-yaml     ^6.1.3
yaml                      ^2.8.4

Web is bundled at build time — none of these ship to npm package consumers, only to the static web build.

Test plan

  • Unit tests: 5 new tests in flow-mutations.test.ts covering: STARTER_YAML passes validation; invalid step refs surface path + message; round-trip serialize → parse preserves shape; canned VALID_YAML accepts.
  • Test totals: shared 93/93, server 19/19, cli 83/83, web 27/27 (5 new) = 222/222.
  • Static checks: lint / typecheck / format:check / build all clean.
  • Audit gates: both prod-tree (moderate+) and full-tree (high+) exit 0.
  • Live API smoke against openhop serve:
    • POST valid YAML → 201 {id, version, title} (what createFlow consumes)
    • POST invalid YAML → 400 {details: [{path, message, suggestion}]} (what the modal renders)
    • GET /api/flows/:id{id, meta, flow} (round-trip through YAML.stringify re-parses cleanly)
    • DELETE → 204; DELETE non-existent → 404 (treated as success in the hook — flow is already gone)
  • Manual browser smoke — owed before merge: openhop serve, exercise New / Edit / Delete in a real browser, verify flows render after save.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Flow editor modal: YAML editor with starter YAML (path-aware), keyboard shortcuts, save/cancel, server and local validation feedback
    • Sidebar: root/folder “+” create menus and per-flow edit/delete actions; flows refresh after changes
  • New / Improved APIs

    • Flow create/delete now expose in-flight state and structured errors; flow list exposes a reload callback
  • Quality / Tests

    • Unit tests for YAML parsing/validation and starter‑YAML path behavior
  • Chores

    • Dev dependencies added for YAML editing/parsing support

…partial #12)

Lets users author and edit flows directly in the browser. The same
authoring path as the CLI (`openhop push` / `openhop remove`), now
inline in the sidebar — no terminal required.

UI surface:
- Sidebar: "+ New flow" primary button at the top; per-flow ✎ Edit and
  ✕ Delete affordances revealed on row hover.
- FlowEditorModal: CodeMirror 6 YAML editor with live validation via
  parseFlowYaml() from @openhop/shared (sub-100ms feedback). Errors
  render with their path + suggestion fields, matching the CLI's
  validate --json output. Cmd/Ctrl-Enter saves; Esc cancels;
  click-outside cancels.

Wiring:
- New file `useFlowMutations.ts` — POST + DELETE wrappers around
  /api/flows. Distinguishes validation / server / network errors so
  the modal can render server-side details (e.g. node-ref lookups
  that local validation can't catch).
- New file `FlowEditorModal.tsx` — modal component, mode-agnostic.
- `useFlowList` gains a `reload()` so create/delete refresh the
  sidebar without a remount.
- `App.tsx` owns the modal state machine ({closed, new, edit}) and
  the delete confirmation. Edit pre-populates by GETting the stored
  flow and round-tripping through yaml.stringify().

Scope decisions per #74:
- Edit is a full re-push (POST), creating a new flow ID. Patch-ops
  in-place editing is the CLI's `openhop patch` flow and is out of
  scope (track separately if needed).
- Delete uses native window.confirm — simpler than a styled dialog,
  upgradeable later.
- Static-deploy "fragment mode" for #75 deferred — the modal takes a
  minimal prop surface so it can grow that mode without a rewrite.

New deps (web only): @uiw/react-codemirror, @codemirror/lang-yaml, yaml.
Web is bundled at build time, so these don't ship to npm package
consumers — only to the static web build.

Verified:
- shared 93/93, server 19/19, cli 83/83, web 27/27 (5 new) = 222/222
- lint/typecheck/format:check/build all clean across the monorepo
- both audit gates green (no new prod-tree CVEs)
- live API smoke against `openhop serve`: POST valid → 201 with
  {id,version,title}; POST invalid → 400 with {details:[{path,message}]};
  GET /api/flows/:id → {id,meta,flow} (round-trip through YAML.stringify
  re-parses cleanly); DELETE → 204; DELETE non-existent → 404 (treated
  as success in the hook).

Manual browser smoke is owed before merge — kicking off `openhop demo`
or `openhop serve` and exercising New / Edit / Delete in a real browser.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a YAML-backed flow editor and CRUD wiring: a modal CodeMirror YAML editor with live parse/validation, create/delete mutation hook, sidebar create/edit/delete UI, flow-list reload API, Vitest tests, and dev dependencies for YAML/CodeMirror.

Changes

Flow CRUD and Editor Integration

Layer / File(s) Summary
Types / Error Shape
packages/web/src/hooks/useFlowMutations.ts
Adds CreateFlowResult, ServerErrorDetail, and MutationError types describing mutation outcomes and structured server validation details.
Mutations / Network
packages/web/src/hooks/useFlowMutations.ts
Adds useFlowMutations() exposing createFlow(yamlText), deleteFlow(flowId), and reset with { inFlight, error }; maps HTTP 400 → validation (with details), other non-OK → server, and exceptions → network.
Polling / Reload
packages/web/src/hooks/useFlowPolling.ts
useFlowList() adds tick state and a reload() callback that increments tick to force refetching the flows list; returned shape now includes reload.
Editor Seed / Starter YAML
packages/web/src/components/FlowEditorModal.tsx
Adds STARTER_YAML and buildStarterYaml(path?) which injects meta.path into the starter YAML when a folder path is provided.
Editor UI & Validation
packages/web/src/components/FlowEditorModal.tsx
Implements FlowEditorModal with CodeMirror YAML editor, live parseFlowYaml validation, keyboard shortcuts (Esc, Cmd/Ctrl+Enter), distinct rendering of local vs server errors (supports per-detail errors), and save gating by validation state.
App Orchestration
packages/web/src/App.tsx
Wires modal and mutations into the app: handlers to open modal for new/edit (path-aware starter), fetch flow for editing (/api/flows/{id} → YAML), create/delete flows via hook, call reload() after changes, select created flow, and manage modal state.
Sidebar UI & Wiring
packages/web/src/components/Sidebar.tsx
Adds AddMenu and root/folder “+” create UI, per-flow edit/delete action buttons, shared addMenu state, and new optional props onCreateAt, onEditFlow, onDeleteFlow threaded into tree items.
Tests
packages/web/__tests__/flow-mutations.test.ts
Adds Vitest tests validating parseFlowYaml behaviors (starter YAML acceptance, unknown node reference error path, malformed YAML rejection, YAML round-trip) and buildStarterYaml path-injection cases.
Dev Dependencies
packages/web/package.json
Adds dev dependencies: @codemirror/lang-yaml, @uiw/react-codemirror, and yaml to support the editor and YAML handling.
Dev Server Config
packages/web/vite.config.ts
Sets server.allowedHosts: true in Vite config with explanatory comments for non-localhost development environments.

Sequence Diagram

sequenceDiagram
    actor User
    participant Sidebar
    participant App
    participant FlowEditorModal
    participant Mutations as useFlowMutations
    participant Server

    User->>Sidebar: Click "New flow" / "Edit" / "Delete"
    Sidebar->>App: onCreateAt / onEditFlow / onDeleteFlow
    App->>FlowEditorModal: open(initialYaml)
    FlowEditorModal->>FlowEditorModal: parseFlowYaml (live)
    User->>FlowEditorModal: Edit YAML
    FlowEditorModal->>FlowEditorModal: update validation state
    User->>FlowEditorModal: Save
    FlowEditorModal->>App: onSave(yamlText)
    App->>Mutations: createFlow(yamlText)
    Mutations->>Server: POST /api/flows (text/yaml)

    alt Success
        Server-->>Mutations: 200 { id, meta, flow }
        Mutations-->>App: result
        App->>App: selectFlow(id), close modal
        App->>Sidebar: reload()
    else Validation Error (400)
        Server-->>Mutations: 400 { details }
        Mutations-->>App: validation error
        App->>FlowEditorModal: display serverError (details)
    else Server/Network Error
        Server-->>Mutations: non-OK / network fail
        Mutations-->>App: server/network error
        App->>FlowEditorModal: display serverError
    end

    User->>Sidebar: Click Delete
    Sidebar->>App: onDeleteFlow(id)
    App->>Mutations: deleteFlow(id)
    Mutations->>Server: DELETE /api/flows/{id}
    Server-->>Mutations: 200 or 404
    Mutations-->>App: success
    App->>Sidebar: reload()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(web): inline YAML editor — Add/Edit modal + Delete (closes #74, partial #12)' clearly and specifically describes the main changes: addition of an inline YAML editor with Add/Edit modal and Delete functionality for web flow management.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/web-flow-crud-ui

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/web/src/hooks/useFlowPolling.ts (1)

24-32: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard reload() fetches against stale responses.

Now that reload() can trigger overlapping /api/flows requests, an older response can arrive last and overwrite the freshly reloaded list right after create/delete.

Suggested fix
  useEffect(() => {
-    fetch(`${API_BASE}/api/flows`)
+    const controller = new AbortController()
+    setLoading(true)
+    fetch(`${API_BASE}/api/flows`, { signal: controller.signal })
       .then((r) => r.json())
       .then((data) => {
         setFlows(data)
         setLoading(false)
       })
-      .catch(() => setLoading(false))
+      .catch((err) => {
+        if (err instanceof DOMException && err.name === 'AbortError') return
+        setLoading(false)
+      })
+    return () => controller.abort()
   }, [tick])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/hooks/useFlowPolling.ts` around lines 24 - 32, The fetch in
useEffect (in useFlowPolling.ts) can produce stale responses when reload()
triggers overlapping /api/flows requests; update the effect to ignore or cancel
out-of-order responses by either (a) using an AbortController per request and
aborting the previous controller before starting a new fetch, or (b) tracking an
incrementing request id in a ref (e.g., latestFetchIdRef) and capturing the id
when starting the fetch, then only calling setFlows / setLoading if the response
id matches the latest id; apply this change inside the useEffect that currently
calls fetch(`${API_BASE}/api/flows`) so setFlows and setLoading only run for the
most recent request.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/__tests__/flow-mutations.test.ts`:
- Around line 47-65: The test for parseFlowYaml does not assert the suggestion
field even though the test title promises it; update the test block (the it(...)
that defines const result = parseFlowYaml(bad) and const err = result.errors[0])
to also assert the suggestion (e.g., check err.suggestion is truthy or contains
an expected hint) so the contract of parseFlowYaml (path, message, suggestion)
is validated; locate the test in flow-mutations.test.ts around the it('reports
path + message + suggestion for an unknown step ref') and add an assertion
referencing err.suggestion.

In `@packages/web/src/App.tsx`:
- Around line 80-87: handleDeleteFlow currently reads mutations.error after
awaiting mutations.deleteFlow(flowId), which can be stale; change deleteFlow to
return its result/error (e.g., return { ok: boolean, error?: Error } or throw
the Error) and update handleDeleteFlow to use the returned value (or catch the
thrown error) to populate the alert message instead of reading mutations.error.
Locate the deleteFlow implementation (the mutations object) and make it return
the error payload (or throw), then in handleDeleteFlow use the returned
error.message (or the caught error.message) when ok is false to show the real
error instead of mutations.error?.message.

In `@packages/web/src/components/FlowEditorModal.tsx`:
- Around line 98-116: FlowEditorModal currently sets role="dialog" and
aria-modal but does not manage focus — implement focus management in the
FlowEditorModal component: when the modal opens, programmatically move focus
into the modal (e.g., focus the first focusable element or a container with
tabIndex={-1}), trap focus inside the modal (handle Tab/Shift+Tab or use a
focus-trap library) so keyboard users cannot tab into the underlying page, and
restore focus to the previously focused element when onCancel is called/when the
modal closes; target the modal container (data-testid="flow-editor-modal") and
integrate with the existing onCancel flow.
- Around line 75-89: Guard keyboard and close handlers to ignore actions while a
save is in progress: check the component's saving state (e.g., saving boolean)
at the top of the onKey handler and in any cancel/close/backdrop handlers so
that when saving is true you do not call onSaveRef.current, onCancelRef.current,
or trigger modal close; additionally, prevent Ctrl/Cmd+Enter from invoking
onSaveRef.current by requiring !saving before parsing with
parseFlowYaml(textRef.current) and calling onSaveRef.current(textRef.current).
Apply the same guard to the other referenced handlers around lines 103-105,
137-139, and 200-215 so all pathways (Esc, backdrop, Cancel, Close, and keyboard
save) are no-ops while saving.

In `@packages/web/src/components/Sidebar.tsx`:
- Around line 157-165: The title on the edit button in Sidebar.tsx incorrectly
claims an unimplemented "E" keyboard shortcut; remove the "(E)" hint from the
title attribute on the button (the element that sets title="Edit (E)" and uses
data-testid={`sidebar-edit-${flowId}`} and onClick calling onEditFlow) so it
simply reads "Edit", or alternatively implement a key handler in the Sidebar
component that listens for the "e" key and invokes onEditFlow(flowId) if you
want to keep the hint—do one or the other, not both.

---

Outside diff comments:
In `@packages/web/src/hooks/useFlowPolling.ts`:
- Around line 24-32: The fetch in useEffect (in useFlowPolling.ts) can produce
stale responses when reload() triggers overlapping /api/flows requests; update
the effect to ignore or cancel out-of-order responses by either (a) using an
AbortController per request and aborting the previous controller before starting
a new fetch, or (b) tracking an incrementing request id in a ref (e.g.,
latestFetchIdRef) and capturing the id when starting the fetch, then only
calling setFlows / setLoading if the response id matches the latest id; apply
this change inside the useEffect that currently calls
fetch(`${API_BASE}/api/flows`) so setFlows and setLoading only run for the most
recent request.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b01a6003-9c40-4c88-87d9-80f48783acfe

📥 Commits

Reviewing files that changed from the base of the PR and between edcdb3d and 294b690.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json, !package-lock.json
📒 Files selected for processing (7)
  • packages/web/__tests__/flow-mutations.test.ts
  • packages/web/package.json
  • packages/web/src/App.tsx
  • packages/web/src/components/FlowEditorModal.tsx
  • packages/web/src/components/Sidebar.tsx
  • packages/web/src/hooks/useFlowMutations.ts
  • packages/web/src/hooks/useFlowPolling.ts

Comment thread packages/web/__tests__/flow-mutations.test.ts
Comment thread packages/web/src/App.tsx Outdated
Comment thread packages/web/src/components/FlowEditorModal.tsx
Comment thread packages/web/src/components/FlowEditorModal.tsx
Comment thread packages/web/src/components/Sidebar.tsx
naorsabag and others added 2 commits May 5, 2026 15:49
…utton

UX change requested post-#76 review: instead of a single "+ New flow"
button at the top of the sidebar, every folder gets its own "+"
affordance that opens a small menu. The menu's two options:

  📄 New flow   → opens the editor with `meta.path` pre-set to the
                  folder path, so the new flow lands in the right tree
                  position on save.
  📁 New folder → window.prompt() asks for a folder name, splices it
                  onto the parent path, and opens the editor with
                  meta.path: <parent>/<name>. Since folders are derived
                  from each flow's meta.path, "New folder" is "New flow
                  with this path prefix" — the user can edit the title
                  and body before saving.

Root-level "+" lives next to the search input (not as a big button).
Per-folder "+" is hover-revealed alongside the existing edit/delete
icons on flow rows. Menu closes on outside click or Esc.

New `buildStarterYaml(path?)` helper in FlowEditorModal injects the
path into the canonical starter YAML; locked in by 3 new tests.

Verified: shared 93/93, server 19/19, cli 83/83, web 30/30 (8 in
flow-mutations, 3 new) = 225/225. Lint/typecheck/format:check/build
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-contamination bug from 62fd4b0: a folder row's <li.group> wraps
its child flow rows' <li.group>, so hovering one flow triggered
group-hover on EVERY descendant — every flow in that folder revealed
its edit/delete icons at once. Same nesting also masked the folder's
"+" button on hover.

Fix: name the groups so each row's hover is scoped to itself.
- Folder row: <li class="group/folder"> + the "+" uses
  group-hover/folder:opacity-100.
- Flow row:   <li class="group/flow">   + edit/delete icons use
  group-hover/flow:opacity-100.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (4)
packages/web/src/App.tsx (1)

105-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t read mutations.error after await deleteFlow(); use a returned error payload (or throw).

This read can be stale due to closure timing, so alerts may show unknown error instead of the real failure.

Suggested direction
-      const ok = await mutations.deleteFlow(flowId)
-      if (!ok) {
-        window.alert(`Failed to delete flow: ${mutations.error?.message ?? 'unknown error'}`)
+      const result = await mutations.deleteFlow(flowId)
+      if (!result.ok) {
+        window.alert(`Failed to delete flow: ${result.error?.message ?? 'unknown error'}`)
         return
       }

And adjust useFlowMutations.deleteFlow to return { ok: boolean; error?: MutationError }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/App.tsx` around lines 105 - 107, The caller reads
mutations.error after awaiting mutations.deleteFlow(flowId), which can be stale;
update the deleteFlow implementation in useFlowMutations to return a structured
result (e.g. { ok: boolean; error?: MutationError }) or throw, update its type
signature accordingly, and modify the caller in App.tsx to use the returned
payload (const { ok, error } = await mutations.deleteFlow(flowId)) and show
error.message from that returned error instead of accessing mutations.error;
ensure any places using deleteFlow are updated to the new return shape or handle
thrown errors.
packages/web/src/components/FlowEditorModal.tsx (2)

113-131: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add focus management (initial focus, trap, restore) for the dialog.

role="dialog" + aria-modal alone is insufficient; keyboard focus can still escape to background UI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/components/FlowEditorModal.tsx` around lines 113 - 131,
FlowEditorModal's modal wrapper (the div with role="dialog" and
data-testid="flow-editor-modal") currently lacks focus management; add an
initial focus, focus trap, and restore behavior: in the FlowEditorModal
component create a ref for the dialog container, save document.activeElement
when opening, on mount set focus to the dialog or its first focusable child, add
keyboard handling to close on Escape by calling onCancel and implement a
Tab/Shift+Tab focus-trap (or integrate a well-tested library such as
focus-trap-react or react-focus-lock) to prevent focus escaping to background,
and on unmount restore the previously focused element; ensure these behaviors
are tied to the existing onCancel flow so closing via backdrop click or Escape
restores focus.

90-105: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Freeze modal actions while saving to prevent duplicate creates and “cancelled” in-flight saves.

Esc, backdrop click, close/cancel buttons, and Ctrl/Cmd+Enter still act during saving, which can produce duplicate POSTs or close the dialog mid-save.

Suggested fix
   const onSaveRef = useRef(onSave)
   const onCancelRef = useRef(onCancel)
   const textRef = useRef(text)
+  const savingRef = useRef(saving)

   useEffect(() => {
     onSaveRef.current = onSave
     onCancelRef.current = onCancel
     textRef.current = text
+    savingRef.current = saving
   })

   useEffect(() => {
     if (!open) return
     const onKey = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') {
+      if (savingRef.current) return
+      if (e.key === 'Escape') {
         e.preventDefault()
         onCancelRef.current()
       } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
         e.preventDefault()
         if (parseFlowYaml(textRef.current).success) {
           onSaveRef.current(textRef.current)
         }
       }
     }
@@
-      onClick={(e) => {
-        if (e.target === e.currentTarget) onCancel()
-      }}
+      onClick={(e) => {
+        if (!saving && e.target === e.currentTarget) onCancel()
+      }}
@@
-            onClick={onCancel}
+            onClick={() => !saving && onCancel()}
+            disabled={saving}
@@
-            onClick={onCancel}
+            onClick={() => !saving && onCancel()}
+            disabled={saving}

Also applies to: 118-120, 150-153, 216-217

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/components/FlowEditorModal.tsx` around lines 90 - 105, The
keyboard handler and all modal action handlers must short-circuit when a save is
in progress: update the onKey function in the useEffect to check a saving flag
(e.g., if (saving) return) before handling Escape or Ctrl/Cmd+Enter, and
likewise guard the backdrop-click handler and the close/cancel button handlers
so they return immediately when saving; only call onCancelRef.current() or
onSaveRef.current(textRef.current) (after parseFlowYaml succeeds) if saving is
false. Ensure the same saving check is applied to the handlers referenced
elsewhere (the close/cancel click handlers and any backdrop click handler) so no
duplicate POSTs or modal closes happen while saving.
packages/web/src/components/Sidebar.tsx (1)

290-298: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the unimplemented E shortcut hint from the Edit action.

The UI currently promises a keyboard shortcut that isn’t wired, which is misleading.

Suggested fix
-              title="Edit (E)"
+              title="Edit"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/components/Sidebar.tsx` around lines 290 - 298, The Edit
button currently shows a fake keyboard hint "(E)" in its title; remove the
unimplemented shortcut hint by updating the title prop on the button in
Sidebar.tsx (the element with aria-label={`Edit ${node.name}`} and
data-testid={`sidebar-edit-${flowId}`}) so it no longer contains " (E)"; leave
the onClick handler (onEditFlow) and aria-label as-is, or alternatively
implement a real keyboard handler if you intend to keep the hint.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/src/components/FlowEditorModal.tsx`:
- Line 37: The regex literal /^meta:\n  title: New flow\n/ contains literal
double spaces which triggers the no-regex-spaces lint rule; update that regex
(located where FlowEditorModal.tsx defines the pattern) to use a quantifier
instead of two literal spaces, e.g. replace the "  " with "\s{2}" (resulting in
/^meta:\n\s{2}title: New flow\n/) or "\x20{2}" if you want to match only space
characters.
- Line 33: Move the non-component helper buildStarterYaml (and its constant
STARTER_YAML) out of the component file into a new utility module (suggested
name: utils/flowEditorSeed.ts); implement STARTER_YAML and export function
buildStarterYaml(path?: string) there using the same logic, then replace the
current local definition in FlowEditorModal.tsx with an import {
buildStarterYaml } from the new module (also update any other files that import
it, e.g., App.tsx) so the component file only exports React components and
complies with react-refresh/only-export-components.

---

Duplicate comments:
In `@packages/web/src/App.tsx`:
- Around line 105-107: The caller reads mutations.error after awaiting
mutations.deleteFlow(flowId), which can be stale; update the deleteFlow
implementation in useFlowMutations to return a structured result (e.g. { ok:
boolean; error?: MutationError }) or throw, update its type signature
accordingly, and modify the caller in App.tsx to use the returned payload (const
{ ok, error } = await mutations.deleteFlow(flowId)) and show error.message from
that returned error instead of accessing mutations.error; ensure any places
using deleteFlow are updated to the new return shape or handle thrown errors.

In `@packages/web/src/components/FlowEditorModal.tsx`:
- Around line 113-131: FlowEditorModal's modal wrapper (the div with
role="dialog" and data-testid="flow-editor-modal") currently lacks focus
management; add an initial focus, focus trap, and restore behavior: in the
FlowEditorModal component create a ref for the dialog container, save
document.activeElement when opening, on mount set focus to the dialog or its
first focusable child, add keyboard handling to close on Escape by calling
onCancel and implement a Tab/Shift+Tab focus-trap (or integrate a well-tested
library such as focus-trap-react or react-focus-lock) to prevent focus escaping
to background, and on unmount restore the previously focused element; ensure
these behaviors are tied to the existing onCancel flow so closing via backdrop
click or Escape restores focus.
- Around line 90-105: The keyboard handler and all modal action handlers must
short-circuit when a save is in progress: update the onKey function in the
useEffect to check a saving flag (e.g., if (saving) return) before handling
Escape or Ctrl/Cmd+Enter, and likewise guard the backdrop-click handler and the
close/cancel button handlers so they return immediately when saving; only call
onCancelRef.current() or onSaveRef.current(textRef.current) (after parseFlowYaml
succeeds) if saving is false. Ensure the same saving check is applied to the
handlers referenced elsewhere (the close/cancel click handlers and any backdrop
click handler) so no duplicate POSTs or modal closes happen while saving.

In `@packages/web/src/components/Sidebar.tsx`:
- Around line 290-298: The Edit button currently shows a fake keyboard hint
"(E)" in its title; remove the unimplemented shortcut hint by updating the title
prop on the button in Sidebar.tsx (the element with aria-label={`Edit
${node.name}`} and data-testid={`sidebar-edit-${flowId}`}) so it no longer
contains " (E)"; leave the onClick handler (onEditFlow) and aria-label as-is, or
alternatively implement a real keyboard handler if you intend to keep the hint.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e0ae188a-8295-42da-9cf3-f9290e9c6630

📥 Commits

Reviewing files that changed from the base of the PR and between 294b690 and 62fd4b0.

📒 Files selected for processing (4)
  • packages/web/__tests__/flow-mutations.test.ts
  • packages/web/src/App.tsx
  • packages/web/src/components/FlowEditorModal.tsx
  • packages/web/src/components/Sidebar.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/tests/flow-mutations.test.ts

`

/** Build the seed YAML for a "New flow" inside a given folder path. */
export function buildStarterYaml(path?: string): string {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and show its content around line 33
fd -p "FlowEditorModal.tsx" packages/web/src/

Repository: naorsabag/OpenHop

Length of output: 109


🏁 Script executed:

# Get the file content to verify what's at line 33
cat -n packages/web/src/components/FlowEditorModal.tsx | head -50

Repository: naorsabag/OpenHop

Length of output: 2006


🏁 Script executed:

# Search for buildStarterYaml to understand its usage and where it's exported
rg "buildStarterYaml" packages/web/src/ -B 2 -A 5

Repository: naorsabag/OpenHop

Length of output: 2431


🏁 Script executed:

# Check for any drop-shadow filter usage in FlowEditorModal.tsx to verify guideline compliance
rg "drop-shadow" packages/web/src/components/FlowEditorModal.tsx

Repository: naorsabag/OpenHop

Length of output: 43


Move buildStarterYaml to a separate utility module to comply with react-refresh/only-export-components.

Exporting non-component functions from component files breaks React's fast refresh and causes CI failures. Move this utility function and STARTER_YAML to a dedicated helper module.

Suggested refactor
- export function buildStarterYaml(path?: string): string {
+ // moved to packages/web/src/utils/flowEditorSeed.ts
// packages/web/src/utils/flowEditorSeed.ts
const STARTER_YAML = `meta:
  title: New flow
  ...`

export function buildStarterYaml(path?: string): string {
  if (!path) return STARTER_YAML
  return STARTER_YAML.replace(
    /^meta:\n  title: New flow\n/,
    `meta:\n  title: New flow\n  path: ${path}\n`
  )
}

Then update App.tsx import to:

import { buildStarterYaml } from './utils/flowEditorSeed'
🧰 Tools
🪛 GitHub Check: build & test (node 20)

[failure] 33-33:
Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

🪛 GitHub Check: build & test (node 22)

[failure] 33-33:
Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/components/FlowEditorModal.tsx` at line 33, Move the
non-component helper buildStarterYaml (and its constant STARTER_YAML) out of the
component file into a new utility module (suggested name:
utils/flowEditorSeed.ts); implement STARTER_YAML and export function
buildStarterYaml(path?: string) there using the same logic, then replace the
current local definition in FlowEditorModal.tsx with an import {
buildStarterYaml } from the new module (also update any other files that import
it, e.g., App.tsx) so the component file only exports React components and
complies with react-refresh/only-export-components.

Comment thread packages/web/src/components/FlowEditorModal.tsx Outdated
naorsabag and others added 2 commits May 5, 2026 16:02
Two reports of the folder "+" not appearing on hover even after the
named-group fix. Rather than keep guessing at the hover-state path
(opacity-0 → group-hover/folder:opacity-100 with @media (hover: hover)
gating), just render it visible at all times.

UX trade-off: a small "+" sits permanently on every folder row's right
edge. Subtle (rgba 0.55) so it doesn't dominate; brightens to accent
(#7df9ff) when its menu is open. Discoverability beats minimalism for
the primary "create here" affordance.

Flow rows keep the hover-fade for ✎/✕ (those are destructive and
should stay subtle until needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e subtree

Screenshot evidence (folder rows had no visible "+", but stray "+" symbols
appeared mid-tree at random vertical positions — including over child flow
rows on hover) revealed the real bug: the folder's <li className="group/folder
relative"> wrapped both the header AND the expanded children <ul>. Two
bad consequences:

  1. The "+"'s `position: absolute; top: 50%` was relative to the entire
     <li> — i.e. the geometric center of (header height + children height).
     For deep / many-children folders this lands somewhere in the middle
     of the child flow rows, not next to the folder name.
  2. `:where(.group/folder):hover *` matched ANY descendant of the folder,
     including children inside the <ul>. Hovering a child flow row
     triggered the parent folder's group-hover, revealing the (mis-positioned)
     "+" over the flow.

Fix: introduce a <div className="group/folder relative"> inside the <li>,
containing only the header row (toggle button + "+" + popover). The <ul>
of children is now a sibling of the .group/folder, not a descendant. So:

  - "top: 50%" lands at the center of just the row
  - hover state isolates to the header row — children no longer trigger it
  - "+" still hover-revealed (opacity-0 → group-hover/folder:opacity-100),
    per the requested UX (only on hover, not always visible)

Also adds `server.allowedHosts: true` to vite.config.ts so the dev server
accepts requests from `host.docker.internal` and other non-localhost
hostnames — needed when running OpenHop inside docker-compose / a dev
container so the user (or tooling) can reach the dev server through the
docker host's published port without tripping vite's "host not allowed" 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/vite.config.ts`:
- Around line 11-17: Replace the insecure global override allowedHosts: true in
the Vite config with an explicit allowlist: modify the exported config where
allowedHosts is defined (symbol: allowedHosts) to use a whitelist array of
allowed hostnames (e.g., 'localhost', '127.0.0.1', and any known dev hostnames)
and read additional entries from an environment variable (e.g.,
process.env.VITE_ALLOWED_HOSTS split on commas) so dynamic
container/bridge/tunnel hosts can be appended; ensure the final value assigned
to allowedHosts is the array of strings rather than true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ebac77b6-b3d1-4654-a5d1-00a70ceb0f77

📥 Commits

Reviewing files that changed from the base of the PR and between f4746e4 and 6fcaf60.

📒 Files selected for processing (2)
  • packages/web/src/components/Sidebar.tsx
  • packages/web/vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/components/Sidebar.tsx

Comment thread packages/web/vite.config.ts Outdated
naorsabag and others added 3 commits May 5, 2026 16:29
…w rows

Three changes that move together:

1. **Delete a whole folder** (✕ button next to + on every non-root folder
   row, hover-revealed). Folders are virtual — they exist as a derived
   view of each flow's meta.path — so there's no server-side bulk-delete
   endpoint. App.handleDeleteFolder iterates every flow whose meta.path
   equals the folder OR starts with `${folder}/`, confirms the count
   up-front ("Delete folder X and all N flows inside?"), then issues
   DELETE per flow. Stops on first failure with an alert. The
   descendant-match predicate is locked in by 5 new tests so we don't
   accidentally delete sibling paths like "billing-tax" when deleting
   "billing".

2. **Synthetic root row "/"**. The sidebar's previous "+ at root" lived
   next to the search input — a separate affordance with its own popover
   logic. Replace it with a real root TreeItem at the top of the tree:
   - always expanded, no toggle caret, no delete (per user ask: "root
     dir that can't be deleted")
   - same hover-revealed "+" menu as other folders, opens with parentPath=""
   - non-clickable label "/", visually emphasized vs other folders so it
     reads as the workspace root
   The search bar simplifies back to just the input.

3. **More noticeable flow-row hover**. Inactive flow rows previously
   had no background change on hover — a minor discoverability gap.
   Now they pick up `hover:bg-white/5` (5% white tint, same as the
   AddMenu menu items) and `hover:text-text` (matches folder rows
   transitioning from text/70 → text on hover). Active rows are
   unchanged because they already have the cyan highlight.

Verified locally: shared 93/93, server 19/19, cli 83/83, web 35/35
(5 new in flow-mutations) = 230/230. Lint/typecheck/format:check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r takes effect

Inline style={{ background: 'transparent', color: '...' }} was winning
against the hover:bg-white/5 / hover:text-text Tailwind classes added in
the previous commit — inline styles outrank class-based ones. So the
class-based hover never applied; the row stayed transparent.

Move colors and background to className with arbitrary-value tw classes
for the active-cyan tones. Inline style now carries only the dynamic
paddingLeft (depth * 16 + 8). Now the inactive-row hover actually fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 of 8 comments resolved; 1 deferred.

  1. flow-mutations.test.ts — assert err.suggestion. Switched the typo
     ref from "nonexistent" (too far for findClosest) to "dbb" so the
     validator actually emits the hint, then locked path/message/
     suggestion together.

  2. App.tsx — handleDeleteFlow / handleDeleteFolder no longer read
     mutations.error after `await mutations.deleteFlow()`. The post-await
     closure still pointed at the pre-setState `mutations`, so the
     fallback alert always said "unknown error". Changed deleteFlow to
     return MutationError | null directly; callers use the returned
     value. (createFlow already returns its result + sets state, so the
     modal's serverError={mutations.error} prop wiring is fine — modal
     re-renders pick up the new error via props.)

  3. FlowEditorModal — freeze every dismiss + save trigger while
     `saving` is true. Added a savingRef updated each render; the
     keyboard handler bails out early if saving, the backdrop click
     ignores while saving, and Cancel + Close (✕) get `disabled={saving}`
     plus disabled:opacity-40. Save button was already gated via
     disabled={\!canSave}. Without this, Cmd+Enter could fire a second
     POST while the first was in flight, creating duplicate flows.

  4. (DEFERRED) Focus trap on the dialog. Real a11y fix — focus needs
     to move into the modal on open, be trapped on Tab, restore on
     close. Heavier lift than the rest of these and not a regression
     from the current PR; tracking separately rather than expanding
     scope here.

  5. Sidebar.tsx — drop the bogus "(E)" hint from the edit button's
     title; the keyboard shortcut isn't implemented.

  6. Move buildStarterYaml + STARTER_YAML out of FlowEditorModal.tsx
     and into a new src/lib/starter-yaml.ts. Exporting non-component
     helpers from a component file trips
     react-refresh/only-export-components and breaks Vite fast refresh.
     App.tsx + the test file now import from the lib path.

  7. starter-yaml.ts — replace the regex's literal `  ` (two spaces) with
     `\s{2}` to satisfy no-regex-spaces. Same match, no lint warning.

  8. vite.config.ts — replace `allowedHosts: true` (DNS-rebinding
     liability) with an explicit allowlist: `host.docker.internal`,
     `openhop` (docker-compose service name), and `.localhost` for
     subdomain coverage. Added a `VITE_ADDITIONAL_ALLOWED_HOSTS` env
     var for tunneled hostnames (ngrok / cloudflared / etc.) so
     contributors don't have to edit the config.

Verified: shared 93/93, server 19/19, cli 83/83, web 35/35 = 230/230.
Lint/typecheck/format:check/build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@naorsabag
Copy link
Copy Markdown
Owner Author

Round 2 review comments addressed in cb9193d. 7 of 8 actioned, 1 deferred.

# Comment Resolution
1 flow-mutations.test.ts:66 — assert err.suggestion Switched typo ref to dbb (close to db) so findClosest actually fires the hint; lock path + message + suggestion together.
2 App.tsx:107 — stale closure on mutations.error deleteFlow now returns MutationError | null; callers use the returned value directly.
3 FlowEditorModal:104 — modal can save twice / dismiss while saving Added savingRef; keyboard, backdrop, Cancel, Close all gated on \!saving. Save button was already gated via disabled={\!canSave}.
4 FlowEditorModal:131 — focus trap missing Deferred — real a11y fix, not a regression from this PR; tracking separately rather than expanding scope here. Happy to follow up.
5 Sidebar:360 — bogus (E) shortcut hint Title now reads just Edit.
6 FlowEditorModal:33 — react-refresh/only-export-components violation Moved buildStarterYaml + STARTER_YAML to src/lib/starter-yaml.ts; FlowEditorModal exports only the component.
7 FlowEditorModal:37 — no-regex-spaces literal Replaced with \s{2} in the new util file.
8 vite.config.ts:17 — allowedHosts: true (DNS rebinding) Explicit allowlist: host.docker.internal, openhop, .localhost. Added VITE_ADDITIONAL_ALLOWED_HOSTS env var for tunneled hostnames.

230/230 tests, lint/typecheck/format:check/build all clean.

…le-overwrite race)

Outside-diff comment from CodeRabbit's first review pass — caught after
the round-2 reply. Once useFlowList exposed `reload()` (so create/delete
can refresh the sidebar without remounting), two overlapping GET
/api/flows requests could resolve out of order: the older one arriving
last would overwrite the freshly-reloaded list, putting just-deleted
flows back in the sidebar.

Fix: AbortController per fetch effect; cleanup aborts the previous
controller when `tick` bumps. AbortError is silently ignored — losing
that race is the desired outcome, not a UI failure to surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@naorsabag
Copy link
Copy Markdown
Owner Author

Found and addressed the outside-diff comment in 2935d7a — the one I missed in round 2.

useFlowPolling.ts:24-32 — once reload() was added (so mutations can refresh the sidebar without remounting), two overlapping GET /api/flows requests could resolve out of order: an older one arriving last would overwrite the freshly-reloaded list, putting just-deleted flows back in the sidebar.

Fix: AbortController per fetch effect. Cleanup aborts the previous controller when tick bumps. AbortError is silently ignored — losing that race is the desired outcome, not a UI failure to surface.

Apologies for not catching this on the first sweep — it was tucked under the Outside diff range comments (1) block in the original review body and I only scanned the inline comments.

…deRabbit comment)

The remaining open review comment from #76 — the focus trap I'd
deferred as scope-creep last round. Reversing that decision: it's a
genuine a11y bug, the implementation is small, and it was the only
unaddressed item left on the PR.

Three pieces:

  - On open: save the previously-focused element to
    `previouslyFocusedRef` and move focus into the dialog (the first
    enabled focusable child, with the dialog itself as a fallback
    via tabIndex={-1}).
  - On close: restore focus to the previously-focused element.
  - While open: trap Tab / Shift-Tab inside the dialog. Tabbing past
    the last focusable wraps to the first; Shift-Tab past the first
    wraps to the last; if focus is somehow outside (e.g. a portal
    refocus stole it), the next Tab pulls it back in.

The Tab handler shares the keydown listener that already handles
Esc / Cmd-Enter, but Tab is processed first and unconditionally —
the saving-freeze gate intentionally does NOT apply to Tab, since
keyboard nav inside the modal should still work while a save is in
flight (e.g. to read the validation feedback).

The focusable-elements selector matches the standard set
(button:not([disabled]), [href], input/select/textarea, anything with
a non-negative tabIndex). CodeMirror's editor surface is included
because `cm-content` carries `tabindex="0"`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@naorsabag
Copy link
Copy Markdown
Owner Author

Focus management addressed in 1b3fd5b — reversing the deferral from round 2.

FlowEditorModal.tsx:110 — three pieces:

  • On open: save document.activeElement to previouslyFocusedRef, move focus into the dialog (first enabled focusable child, fallback to the dialog itself via tabIndex={-1})
  • On close: restore focus to the previously-focused element
  • While open: trap Tab / Shift-Tab — past the last wraps to the first, past the first wraps to the last; out-of-dialog focus gets pulled back in

The Tab handler shares the existing keydown effect but is processed BEFORE the saving-freeze gate (keyboard nav inside the modal should still work while a save is in flight — e.g. to read validation feedback).

CodeMirror is included in the focusable set because .cm-content carries tabindex="0".

Looking at the current comment list, every line-level comment is now either ackd by CodeRabbit (✅ Addressed in commits…) or covered by this commit. If something else is still outstanding, please point at it directly — Im happy to keep iterating.

@naorsabag naorsabag merged commit f82f800 into master May 5, 2026
7 checks passed
@naorsabag naorsabag deleted the feat/web-flow-crud-ui branch May 8, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant