Skip to content

feat(flows): Copilot builds directly onto the canvas (no card, no JSON) - #4581

Closed
graycyrus wants to merge 2 commits into
tinyhumansai:mainfrom
graycyrus:feat/flows-copilot-direct-apply
Closed

graycyrus wants to merge 2 commits into
tinyhumansai:mainfrom
graycyrus:feat/flows-copilot-direct-apply

Conversation

@graycyrus

@graycyrus graycyrus commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

What

Redesigns the Workflow Copilot from propose-and-gate to direct-manipulation, per product direction: "I say it → it builds it on the canvas, live; I say change X → it changes it, live; no approval, no card, no JSON."

Frontend — auto-apply

  • FlowCanvasPage: handleProposal/Accept/Reject + preview state → one handleAutoApply that applies the graph to the draft immediately (diff → added-node highlight; snapshot onto a bounded previousDrafts undo stack; setDraftGraph + canvasVersion bump). Fires an "Applied — Undo?" toast whose action pops the stack and restores the prior draft.
  • WorkflowCopilotPanel: removed the Accept/Reject diff card + onAccept/onReject; now just surfaces onProposal (+ clearProposal so a stale proposal can't re-fire).
  • EditableFlowCanvas: the added-node sage-green ring now flashes ~3s then clears.

Backend — kill the JSON + "save via UI" talk (framing only)

  • workflow_builder/prompt.md: reframed propose, never persist → build directly onto the canvas; never output JSON, never say proposal/Save & enable; end with a plain-language summary. Keeps "cannot persist/enable" — the agent.toml tool scope still structurally enforces it.
  • tools.rs + builder_tools.rs: propose_workflow/revise_workflow descriptions reworded to applies to the canvas draft. No behavior/scope change.

Untouched (verified)

Save (persist) and run-time approval (sending) — separate code paths, unchanged. WorkflowProposalCard (regular chat) left as-is.

Known follow-up

The shared DELEGATE_DIRECTIVE now sends "build directly onto my canvas" wording to the regular-chat builder path too, which still renders a review card — a minor wording/UX mismatch to reconcile in a follow-up (that path is out of scope here).

Verification

typecheck/lint/prettier clean · 224 frontend tests pass (Accept/Reject tests rewritten for the no-card flow + new auto-apply/undo tests) · cargo check/fmt clean.

Summary by CodeRabbit

  • New Features

    • Workflow Copilot now applies suggested changes directly to the canvas and supports undo after changes are applied.
    • Added updated Copilot messaging across multiple languages to match the new flow.
  • Bug Fixes

    • Prevented the same proposal from reappearing after reopening the panel.
    • Improved canvas highlighting so added nodes flash briefly instead of staying highlighted too long.
    • Added an undo path to restore the previous draft after an applied change.

graycyrus added 2 commits July 6, 2026 16:26
The workflow_builder subagent builds a valid proposal (revise_workflow), but the
Accept/Reject card never rendered — the proposal was silently dropped.

Root cause: in ChatRuntimeProvider.onSubagentToolResult, the proposal extraction
(maybeParseWorkflowProposalTool → setWorkflowProposalForThread) sat AFTER four
early-return guards for timeline/UI bookkeeping. Under the builder's heavy event
volume (50+ progress events; the progress channel holds 64 and the parent
try_sends, dropping on overflow), the timeline-entry lookup fails → a guard
returns early → the proposal is never dispatched → panel's proposal stays null.

Fix (primary, frontend): hoist proposal extraction to the top of the handler,
before the guards — it only needs event.{tool_name,success,output}, no timeline
state. Confirmed event.thread_id is already the PARENT copilot thread (traced in
progress_bridge.rs), so it lands where useWorkflowBuilderChat watches. Added a
regression test: proposal dispatches even with no matching timeline row.

Hardening:
- progress channel capacity 64 → 256 (run_task.rs) — reduce the drop pressure.
- emit SubagentCompleted in dispatch.rs Completed AND Incomplete arms — delegated
  subagents no longer show 'running forever' in the timeline (mirrors spawn_subagent).
- orchestrator prompt: clarify describe_workflow is for installed skills, not Flows
  automations (which use build_workflow) — stops the wasted erroring tool call.

typecheck/lint/prettier clean; 122 frontend tests pass; cargo check + fmt clean.
Redesigns the Workflow Copilot from propose-and-gate to direct-manipulation, per
product direction: 'I say it → it builds it on the canvas, live; I say change X →
it changes it, live; no approval, no card, no JSON.'

Frontend (auto-apply):
- FlowCanvasPage: handleProposal/Accept/Reject + preview state → a single
  handleAutoApply that applies the graph to the draft immediately (diff for the
  added-node highlight, snapshot onto a bounded previousDrafts undo stack, then
  setDraftGraph + canvasVersion bump). Fires an 'Applied — Undo?' toast (existing
  toast system) whose action pops the stack and restores the prior draft.
- WorkflowCopilotPanel: removed the Accept/Reject diff card + onAccept/onReject;
  now just surfaces onProposal (+ clearProposal to avoid re-firing a stale one).
- EditableFlowCanvas: added-node sage-green ring now flashes ~3s then clears.
- i18n: flows.copilot.appliedTitle/undo (+ reworded subtitle/empty) x14 locales.

Backend (kill JSON + 'save via UI' talk — framing only, no behavior change):
- workflow_builder/prompt.md: reframed 'propose, never persist' → 'build directly
  onto the canvas'; never output JSON / say 'proposal'/'Save & enable'; end with a
  plain-language summary. (KEEPS 'cannot persist/enable' — agent.toml tool scope
  still structurally enforces it.)
- flows/tools.rs + builder_tools.rs: propose/revise_workflow descriptions reworded
  to 'applies to the canvas draft'.

Save + run-time approval paths untouched. WorkflowProposalCard (regular chat) left
as-is (separate context).

typecheck/lint/prettier clean; 224 frontend tests pass; cargo check/fmt clean.
@graycyrus
graycyrus requested a review from a team July 6, 2026 12:22
@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR redesigns the Flow Copilot from an Accept/Reject proposal-preview model to a direct-apply model: proposals are surfaced once and applied immediately to the canvas draft, with a bounded undo stack for reverting. Related prompt/tool text, i18n strings, transient node highlighting, proposal extraction timing, and backend progress-event/buffer changes support this redesign.

Changes

Copilot Direct-Apply Redesign

Layer / File(s) Summary
WorkflowCopilotPanel API and behavior
app/src/components/flows/WorkflowCopilotPanel.tsx, app/src/components/flows/WorkflowCopilotPanel.test.tsx
Removes onAccept/onReject props and diff-preview UI; forwards each new proposal once via onProposal then clears it; tests updated to verify direct surfacing and no re-surfacing on re-render.
FlowCanvasPage auto-apply and undo
app/src/pages/FlowCanvasPage.tsx, app/src/pages/__tests__/FlowCanvasPage.test.tsx
Adds diffGraphs-based auto-apply handler, bounded previousDrafts snapshot stack, undoLastAutoApply, canvas remount versioning, and rewires WorkflowCopilotPanel to onProposal={handleAutoApply}; tests cover auto-apply persistence and undo toast.
EditableFlowCanvas transient highlight
app/src/components/flows/canvas/EditableFlowCanvas.tsx
Adds visibleAddedNodeIds state that mirrors addedNodeIds and auto-clears after 3 seconds, driving diff-overlay and flow-node-added class logic.
ChatRuntimeProvider proposal extraction reorder
app/src/providers/ChatRuntimeProvider.tsx, app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
Moves workflow proposal parsing before timeline guards, attaching proposals to the parent thread even when no timeline row exists; adds regression test.
Copilot i18n strings
app/src/lib/i18n/*.ts
Updates flows.copilot.subtitle wording and adds flows.copilot.appliedTitle/flows.copilot.undo keys across all locale files.
Workflow builder prompt/tool contract text
app/src/lib/flows/workflowBuilderPrompt.ts, src/openhuman/flows/builder_tools.rs, src/openhuman/flows/tools.rs, src/openhuman/agent_registry/agents/workflow_builder/prompt.md, src/openhuman/agent_registry/agents/orchestrator/prompt.rs
Updates directive/tool description text from "propose/return proposal" to "validate and apply directly to the user's canvas draft" semantics.

Backend Progress Event and Channel Sizing

Layer / File(s) Summary
Subagent progress emission and buffer size
src/openhuman/agent_orchestration/tools/dispatch.rs, src/openhuman/channels/providers/web/run_task.rs
Emits AgentProgress::SubagentCompleted on Completed/Incomplete dispatch paths and increases the progress channel buffer from 64 to 256.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WorkflowCopilotPanel
  participant FlowCanvasPage
  participant EditableFlowCanvas
  WorkflowCopilotPanel->>FlowCanvasPage: onProposal(proposal)
  FlowCanvasPage->>FlowCanvasPage: snapshot draftGraph, diffGraphs
  FlowCanvasPage->>EditableFlowCanvas: apply draftGraph, addedNodeIds
  FlowCanvasPage->>FlowCanvasPage: show Undo toast
  FlowCanvasPage->>FlowCanvasPage: undoLastAutoApply() restores prior draft
Loading

Possibly related PRs

  • tinyhumansai/openhuman#4472: Ties into the same ChatRuntimeProvider workflow-proposal extraction/dispatch pipeline that populates pendingWorkflowProposalsByThread.
  • tinyhumansai/openhuman#4570: Modifies the same WorkflowCopilotPanel/FlowCanvasPage components, evolving the earlier Accept/Reject flow toward this direct-apply behavior.
  • tinyhumansai/openhuman#4574: Modifies the same workflowBuilderPrompt.ts revise/canvas-copilot directives supporting the updated flow-builder contract.

Suggested labels: feature, rust-core, agent

Suggested reviewers: sanil-23, M3gA-Mind

Poem

A carrot proposed, then vanished with grace,
Applied to the canvas without missing a pace.
If ever it stumbles, don't fret, little friend —
One hop of the Undo, and back we amend.
🥕 Direct to the burrow, no cards to review,
Hop, patch, and undo — the new warren view!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: Workflow Copilot now applies builds directly to the canvas instead of using an approval card or JSON output.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/flows-copilot-direct-apply

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

@coderabbitai coderabbitai Bot added agent Built-in agents, prompts, orchestration, and agent runtime in src/openhuman/agent/. feature Net-new user-facing capability or product behavior. rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. labels Jul 6, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 456c67d5cf

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

onProposal={handleProposal}
onAccept={handleAcceptProposal}
onReject={handleRejectProposal}
onProposal={handleAutoApply}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Drop stale copilot proposals while closed

If the user sends a copilot turn, closes the panel before workflow_builder finishes, and then edits the canvas, the proposal remains pending because WorkflowCopilotPanel is unmounted. Reopening the panel now wires that pending proposal into handleAutoApply, so the stale graph is immediately applied over the current draft and can wipe out the intervening manual edits; direct-apply needs to happen when the proposal arrives or discard/guard proposals that completed while the panel was closed.

Useful? React with 👍 / 👎.

addToast({
type: 'info',
title: t('flows.copilot.appliedTitle'),
action: { label: t('flows.copilot.undo'), handler: undoLastAutoApply },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make each undo toast single-use

When two copilot proposals are applied within the toast duration, or the user double-clicks Undo, every visible toast action calls the same undoLastAutoApply stack-pop handler and the toast is not removed after the action. That lets an old or repeated Undo click revert additional AI changes beyond the one the toast represents, so the handler should dismiss/bind the toast to a specific apply entry or otherwise be single-use.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
src/openhuman/flows/builder_tools.rs (1)

107-110: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Leftover "review card" wording contradicts the new direct-apply contract.

The instruction parameter description still says "Echoed back for the review card" — but the new tool description above (Line 84-87) and prompt.md now explicitly tell the agent there is no review card and it must never say that phrase. This schema text is part of what the agent sees as the tool's contract, so it re-introduces the exact terminology this PR set out to remove.

✏️ Proposed fix
                 "instruction": {
                     "type": "string",
-                    "description": "The revision instruction that motivated this change (e.g. 'add a Slack step after the summary'). Echoed back for the review card; does not affect validation."
+                    "description": "The revision instruction that motivated this change (e.g. 'add a Slack step after the summary'). Echoed back to the user; does not affect validation."
                 },
🤖 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 `@src/openhuman/flows/builder_tools.rs` around lines 107 - 110, The
`instruction` field description in `builder_tools.rs` still mentions “review
card,” which conflicts with the direct-apply contract. Update the schema text
for the `instruction` parameter so it only describes the revision instruction
itself and removes any “Echoed back for the review card” wording, keeping it
consistent with the surrounding tool description and prompt contract.
🧹 Nitpick comments (3)
src/openhuman/agent_registry/agents/orchestrator/prompt.rs (1)

351-374: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test coverage for the new describe_workflow/build_workflow routing guidance.

The updated guidance (Lines 105-110) is a meaningful behavioral instruction, but the existing test only checks for run_skill/Handoff Plan/skill entries.

🧪 Suggested additional assertions
         assert!(out.contains("- **ascii-art**: ASCII art via pyfiglet"));
         assert!(out.contains("- **no-dir**: (no description)"));
+        assert!(out.contains("build_workflow"));
+        assert!(out.contains("Flows `workflow_id`"));
     }
🤖 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 `@src/openhuman/agent_registry/agents/orchestrator/prompt.rs` around lines 351
- 374, Add test coverage for the new routing guidance in render_installed_skills
by extending the existing installed-skills test to assert that the generated
output includes the describe_workflow and build_workflow instructions, not just
run_skill and Handoff Plan. Use the render_installed_skills test and its current
assertions as the place to verify that the catalog now steers users to the
workflow description/build path when appropriate.
app/src/lib/i18n/en.ts (1)

4442-4445: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead i18n keys for the removed Accept/Reject preview UI.

flows.copilot.proposalTitle, added, removed, noChanges, accept, reject, and previewHint were used by the diff-preview card that this PR removes from WorkflowCopilotPanel. If nothing else in the codebase still renders these strings, consider pruning them (across all locales) in a follow-up to avoid unused-translation drift.

🤖 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 `@app/src/lib/i18n/en.ts` around lines 4442 - 4445, The removed diff-preview UI
in WorkflowCopilotPanel left several unused translation entries behind, so prune
the dead flows.copilot.* keys if nothing else references them. Remove
proposalTitle, added, removed, noChanges, accept, reject, and previewHint from
the locale tables, and update all locale files consistently to keep the i18n
catalog in sync.
src/openhuman/agent_orchestration/tools/dispatch.rs (1)

216-239: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate SubagentCompleted emission block — extract a helper.

The exact same 15-line progress-send block (agent_id, task_id, elapsed_ms, iterations, output_chars, output, worktree_path=None, changed_files=empty, dirty_status=None) is repeated verbatim in the Completed and Incomplete arms. Extracting a small async helper (e.g. emit_subagent_completed_progress(outcome: &SubagentRunOutcome)) would remove the duplication and keep future field additions to AgentProgress::SubagentCompleted (see src/openhuman/agent/progress.rs:111-138) in one place.

♻️ Suggested refactor
+async fn emit_subagent_completed_progress(outcome: &SubagentRunOutcome) {
+    if let Some(progress) = current_parent().and_then(|p| p.on_progress.clone()) {
+        let _ = progress
+            .send(AgentProgress::SubagentCompleted {
+                agent_id: outcome.agent_id.clone(),
+                task_id: outcome.task_id.clone(),
+                elapsed_ms: outcome.elapsed.as_millis() as u64,
+                iterations: outcome.iterations as u32,
+                output_chars: outcome.output.chars().count(),
+                output: outcome.output.clone(),
+                worktree_path: None,
+                changed_files: Vec::new(),
+                dirty_status: None,
+            })
+            .await;
+    }
+}

Then call emit_subagent_completed_progress(&outcome).await; in both arms.

Also applies to: 263-280

🤖 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 `@src/openhuman/agent_orchestration/tools/dispatch.rs` around lines 216 - 239,
The same AgentProgress::SubagentCompleted send block is duplicated in the
Completed and Incomplete branches of dispatch.rs; extract it into a small async
helper such as emit_subagent_completed_progress(outcome: &SubagentRunOutcome)
and call that helper from both arms. Keep the helper near the existing dispatch
logic so the shared field mapping (agent_id, task_id, elapsed_ms, iterations,
output_chars, output, worktree_path, changed_files, dirty_status) is maintained
in one place and future changes to SubagentCompleted stay consistent.
🤖 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 `@app/src/components/flows/canvas/EditableFlowCanvas.tsx`:
- Around line 196-209: The useEffect in EditableFlowCanvas is violating the
set-state-in-effect rule by calling setVisibleAddedNodeIds unconditionally
before the early return, and that update is redundant because
visibleAddedNodeIds already starts from addedNodeIds and the component remounts
per proposal. Remove the leading state reset from the effect, keep the
timeout-based fade-out logic for addedNodeIds, and leave the cleanup in place so
only the timer drives the temporary highlight.

In `@app/src/pages/FlowCanvasPage.tsx`:
- Around line 195-262: The stacked auto-apply toast behavior is mismatched with
the shared LIFO undo logic, so older “Undo” actions can revert the wrong
proposal. Update FlowCanvasPage’s toast handling around addToast,
handleAutoApply, and undoLastAutoApply so only one auto-apply toast can be
active at a time, or bind each toast to the specific draft snapshot it
represents instead of always calling the same global undo handler. If you keep
multiple toasts, make the handler restore the exact pre-apply graph captured
when that toast was created rather than popping whatever is currently on top of
previousDrafts.

---

Outside diff comments:
In `@src/openhuman/flows/builder_tools.rs`:
- Around line 107-110: The `instruction` field description in `builder_tools.rs`
still mentions “review card,” which conflicts with the direct-apply contract.
Update the schema text for the `instruction` parameter so it only describes the
revision instruction itself and removes any “Echoed back for the review card”
wording, keeping it consistent with the surrounding tool description and prompt
contract.

---

Nitpick comments:
In `@app/src/lib/i18n/en.ts`:
- Around line 4442-4445: The removed diff-preview UI in WorkflowCopilotPanel
left several unused translation entries behind, so prune the dead
flows.copilot.* keys if nothing else references them. Remove proposalTitle,
added, removed, noChanges, accept, reject, and previewHint from the locale
tables, and update all locale files consistently to keep the i18n catalog in
sync.

In `@src/openhuman/agent_orchestration/tools/dispatch.rs`:
- Around line 216-239: The same AgentProgress::SubagentCompleted send block is
duplicated in the Completed and Incomplete branches of dispatch.rs; extract it
into a small async helper such as emit_subagent_completed_progress(outcome:
&SubagentRunOutcome) and call that helper from both arms. Keep the helper near
the existing dispatch logic so the shared field mapping (agent_id, task_id,
elapsed_ms, iterations, output_chars, output, worktree_path, changed_files,
dirty_status) is maintained in one place and future changes to SubagentCompleted
stay consistent.

In `@src/openhuman/agent_registry/agents/orchestrator/prompt.rs`:
- Around line 351-374: Add test coverage for the new routing guidance in
render_installed_skills by extending the existing installed-skills test to
assert that the generated output includes the describe_workflow and
build_workflow instructions, not just run_skill and Handoff Plan. Use the
render_installed_skills test and its current assertions as the place to verify
that the catalog now steers users to the workflow description/build path when
appropriate.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 95c7f51c-3885-4d4e-811d-2b2ad749696c

📥 Commits

Reviewing files that changed from the base of the PR and between 4cd28e2 and 456c67d.

📒 Files selected for processing (28)
  • app/src/components/flows/WorkflowCopilotPanel.test.tsx
  • app/src/components/flows/WorkflowCopilotPanel.tsx
  • app/src/components/flows/canvas/EditableFlowCanvas.tsx
  • app/src/lib/flows/workflowBuilderPrompt.ts
  • app/src/lib/i18n/ar.ts
  • app/src/lib/i18n/bn.ts
  • app/src/lib/i18n/de.ts
  • app/src/lib/i18n/en.ts
  • app/src/lib/i18n/es.ts
  • app/src/lib/i18n/fr.ts
  • app/src/lib/i18n/hi.ts
  • app/src/lib/i18n/id.ts
  • app/src/lib/i18n/it.ts
  • app/src/lib/i18n/ko.ts
  • app/src/lib/i18n/pl.ts
  • app/src/lib/i18n/pt.ts
  • app/src/lib/i18n/ru.ts
  • app/src/lib/i18n/zh-CN.ts
  • app/src/pages/FlowCanvasPage.tsx
  • app/src/pages/__tests__/FlowCanvasPage.test.tsx
  • app/src/providers/ChatRuntimeProvider.tsx
  • app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
  • src/openhuman/agent_orchestration/tools/dispatch.rs
  • src/openhuman/agent_registry/agents/orchestrator/prompt.rs
  • src/openhuman/agent_registry/agents/workflow_builder/prompt.md
  • src/openhuman/channels/providers/web/run_task.rs
  • src/openhuman/flows/builder_tools.rs
  • src/openhuman/flows/tools.rs

Comment on lines +196 to +209
// Copilot auto-apply highlight (Phase 6 redesign): `addedNodeIds` arrives
// once per mount (the host bumps `canvasVersion`/remounts this component on
// every auto-applied proposal) rather than standing for the duration of a
// preview-under-review. Mirror it into local state and fade it out after a
// few seconds so newly added nodes flash sage-green then settle, instead of
// staying ringed forever with no Accept/Reject to dismiss them.
const [visibleAddedNodeIds, setVisibleAddedNodeIds] = useState<ReadonlySet<string>>(addedNodeIds);
useEffect(() => {
setVisibleAddedNodeIds(addedNodeIds);
if (addedNodeIds.size === 0) return;
const timer = setTimeout(() => setVisibleAddedNodeIds(EMPTY_ID_SET), 3000);
return () => clearTimeout(timer);
}, [addedNodeIds]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Unconditional setState at the top of useEffect before a conditional return — and it's redundant here.

setVisibleAddedNodeIds(addedNodeIds) fires unconditionally before the early return when addedNodeIds.size === 0, matching the exact anti-pattern this codebase's react-hooks/set-state-in-effect rule disallows ("resetting state flags at the top of an effect before returning").

It's also unnecessary: visibleAddedNodeIds is already initialized to addedNodeIds (Line 202), and since the host remounts this component (new key) on every auto-apply, addedNodeIds is stable for the component's entire lifetime — this effect only ever runs once per mount, so the leading setState call is a no-op that exists solely to satisfy the lint pattern the codebase avoids elsewhere.

♻️ Proposed fix — drop the redundant setState, keep only the fade-timer
-  const [visibleAddedNodeIds, setVisibleAddedNodeIds] = useState<ReadonlySet<string>>(addedNodeIds);
-  useEffect(() => {
-    setVisibleAddedNodeIds(addedNodeIds);
-    if (addedNodeIds.size === 0) return;
-    const timer = setTimeout(() => setVisibleAddedNodeIds(EMPTY_ID_SET), 3000);
-    return () => clearTimeout(timer);
-  }, [addedNodeIds]);
+  const [visibleAddedNodeIds, setVisibleAddedNodeIds] = useState<ReadonlySet<string>>(addedNodeIds);
+  useEffect(() => {
+    if (addedNodeIds.size === 0) return;
+    const timer = setTimeout(() => setVisibleAddedNodeIds(EMPTY_ID_SET), 3000);
+    return () => clearTimeout(timer);
+    // `addedNodeIds` is fixed for this component's lifetime (the host remounts
+    // on every auto-apply), so this effect intentionally runs once on mount.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);

Based on learnings, "do not perform synchronous setState ... directly inside useEffect bodies... disallows patterns such as resetting state flags at the top of an effect before returning."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Copilot auto-apply highlight (Phase 6 redesign): `addedNodeIds` arrives
// once per mount (the host bumps `canvasVersion`/remounts this component on
// every auto-applied proposal) rather than standing for the duration of a
// preview-under-review. Mirror it into local state and fade it out after a
// few seconds so newly added nodes flash sage-green then settle, instead of
// staying ringed forever with no Accept/Reject to dismiss them.
const [visibleAddedNodeIds, setVisibleAddedNodeIds] = useState<ReadonlySet<string>>(addedNodeIds);
useEffect(() => {
setVisibleAddedNodeIds(addedNodeIds);
if (addedNodeIds.size === 0) return;
const timer = setTimeout(() => setVisibleAddedNodeIds(EMPTY_ID_SET), 3000);
return () => clearTimeout(timer);
}, [addedNodeIds]);
// Copilot auto-apply highlight (Phase 6 redesign): `addedNodeIds` arrives
// once per mount (the host bumps `canvasVersion`/remounts this component on
// every auto-applied proposal) rather than standing for the duration of a
// preview-under-review. Mirror it into local state and fade it out after a
// few seconds so newly added nodes flash sage-green then settle, instead of
// staying ringed forever with no Accept/Reject to dismiss them.
const [visibleAddedNodeIds, setVisibleAddedNodeIds] = useState<ReadonlySet<string>>(addedNodeIds);
useEffect(() => {
if (addedNodeIds.size === 0) return;
const timer = setTimeout(() => setVisibleAddedNodeIds(EMPTY_ID_SET), 3000);
return () => clearTimeout(timer);
// `addedNodeIds` is fixed for this component's lifetime (the host remounts
// on every auto-apply), so this effect intentionally runs once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
🤖 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 `@app/src/components/flows/canvas/EditableFlowCanvas.tsx` around lines 196 -
209, The useEffect in EditableFlowCanvas is violating the set-state-in-effect
rule by calling setVisibleAddedNodeIds unconditionally before the early return,
and that update is redundant because visibleAddedNodeIds already starts from
addedNodeIds and the component remounts per proposal. Remove the leading state
reset from the effect, keep the timeout-based fade-out logic for addedNodeIds,
and leave the cleanup in place so only the timer drives the temporary highlight.

Source: Learnings

Comment on lines +195 to 262
// AI-undo stack: pre-apply `draftGraph` snapshots, most-recent last. Bounded
// so a long copilot session doesn't grow this unboundedly. Only ever read via
// its own setter's updater function (below), never rendered directly.
const [, setPreviousDrafts] = useState<WorkflowGraph[]>([]);
const AI_UNDO_STACK_LIMIT = 10;
// Node ids the most recent auto-applied proposal added, so the canvas can
// flash a "just added" highlight on remount (Phase 5c's diff ring, now a
// transient flash instead of a standing preview state).
const [lastAddedNodeIds, setLastAddedNodeIds] = useState<Set<string> | undefined>(undefined);
const [canvasVersion, setCanvasVersion] = useState(0);
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(item => item.id !== id));
}, []);

// Last-persisted graph, independent of canvas remounts (fixes a P1: the
// editable canvas seeds its own dirty baseline from whatever graph it's
// mounted with, so bumping `canvasVersion` on Accept — remounting the
// canvas with the just-accepted proposal as its "initial" graph — made an
// unsaved accepted proposal instantly read as clean; the accepted change
// was then lost on back/reload instead of gating behind the required Save.
// Only ever updated by a real Save (`handleSave` below), so a diff against
// it survives any number of accept/reject/preview remounts.
// mounted with, so bumping `canvasVersion` on an auto-apply — remounting
// the canvas with the just-applied graph as its "initial" graph — made an
// unsaved AI change instantly read as clean; the change was then lost on
// back/reload instead of gating behind the required Save. Only ever
// updated by a real Save (`handleSave` below), so a diff against it
// survives any number of auto-apply/undo/manual-edit remounts.
const persistedGraphRef = useRef<WorkflowGraph>(graph);

const handleGraphChange = useCallback(
(next: WorkflowGraph) => {
// Freeze the draft while a proposal is under review — the preview graph
// (with ghosts) must not overwrite the real draft.
if (preview) return;
setDraftGraph(next);
},
[preview]
);
const handleGraphChange = useCallback((next: WorkflowGraph) => {
setDraftGraph(next);
}, []);

const handleProposal = useCallback(
// Undo the most recent auto-applied AI change: pop the pre-apply snapshot
// off `previousDrafts` and restore it as the draft. Fired from the "Undo"
// action on the auto-apply toast.
const undoLastAutoApply = useCallback(() => {
setPreviousDrafts(prev => {
if (prev.length === 0) return prev;
const restored = prev[prev.length - 1];
log('copilot auto-apply: undo — restoring previous draft');
setDraftGraph(restored);
setLastAddedNodeIds(undefined);
setCanvasVersion(v => v + 1);
return prev.slice(0, -1);
});
}, []);

// A copilot proposal applies DIRECTLY to the canvas — no preview, no
// Accept/Reject. The pre-apply draft is pushed onto the AI-undo stack first
// so an "Undo" toast can revert it; the canvas remounts (bump
// `canvasVersion`) with the new graph, highlighting the added nodes.
const handleAutoApply = useCallback(
(proposal: WorkflowProposal) => {
const proposedGraph = proposal.graph as WorkflowGraph;
const d = diffGraphs(draftGraph, proposedGraph);
log('copilot proposal: added=%d removed=%d', d.addedNodeIds.size, d.removedNodeIds.size);
setPreview({
proposal,
base: draftGraph,
addedNodeIds: d.addedNodeIds,
removedNodeIds: d.removedNodeIds,
});
log('copilot auto-apply: added=%d removed=%d', d.addedNodeIds.size, d.removedNodeIds.size);
setPreviousDrafts(prev => [...prev, draftGraph].slice(-AI_UNDO_STACK_LIMIT));
setDraftGraph(proposedGraph);
setLastAddedNodeIds(d.addedNodeIds);
setCanvasVersion(v => v + 1);
addToast({
type: 'info',
title: t('flows.copilot.appliedTitle'),
action: { label: t('flows.copilot.undo'), handler: undoLastAutoApply },
});
},
[draftGraph]
[draftGraph, addToast, t, undoLastAutoApply]
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stacked "Applied — Undo?" toasts share one LIFO stack — clicking an older toast's Undo reverts the newest change instead.

addToast never dismisses/replaces a prior auto-apply toast (Line 206-208), so if the user triggers two proposals before dismissing the first toast, two toasts stack up, but both action.handler reference the same undoLastAutoApply (Line 230-240), which always pops the top of previousDrafts regardless of which toast was clicked. Clicking the older toast's "Undo" will silently undo the newer apply, not the one that toast represents.

Consider either (a) replacing any existing auto-apply toast before pushing a new one so only one Undo affordance is ever visible, or (b) capturing the specific snapshot index at toast-creation time and binding each toast's handler to restore that exact snapshot.

♻️ Proposed fix: keep only one active auto-apply toast
   const handleAutoApply = useCallback(
     (proposal: WorkflowProposal) => {
       const proposedGraph = proposal.graph as WorkflowGraph;
       const d = diffGraphs(draftGraph, proposedGraph);
       log('copilot auto-apply: added=%d removed=%d', d.addedNodeIds.size, d.removedNodeIds.size);
       setPreviousDrafts(prev => [...prev, draftGraph].slice(-AI_UNDO_STACK_LIMIT));
       setDraftGraph(proposedGraph);
       setLastAddedNodeIds(d.addedNodeIds);
       setCanvasVersion(v => v + 1);
+      // Only one auto-apply "Undo" affordance should be actionable at a time —
+      // undoLastAutoApply always targets the stack's top, so a stale older
+      // toast's Undo button would otherwise revert the wrong change.
+      setToasts(prev => prev.filter(item => item.id !== 'ai-auto-apply'));
       addToast({
+        id: 'ai-auto-apply',
         type: 'info',
         title: t('flows.copilot.appliedTitle'),
         action: { label: t('flows.copilot.undo'), handler: undoLastAutoApply },
       });
     },
     [draftGraph, addToast, t, undoLastAutoApply]
   );

(Note: id would need to be threaded through addToast/ToastNotification rather than always auto-generated, or filter by title as a simpler proxy.)

🤖 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 `@app/src/pages/FlowCanvasPage.tsx` around lines 195 - 262, The stacked
auto-apply toast behavior is mismatched with the shared LIFO undo logic, so
older “Undo” actions can revert the wrong proposal. Update FlowCanvasPage’s
toast handling around addToast, handleAutoApply, and undoLastAutoApply so only
one auto-apply toast can be active at a time, or bind each toast to the specific
draft snapshot it represents instead of always calling the same global undo
handler. If you keep multiple toasts, make the handler restore the exact
pre-apply graph captured when that toast was created rather than popping
whatever is currently on top of previousDrafts.

@graycyrus

Copy link
Copy Markdown
Contributor Author

Closing as superseded. #4578 ("prompt bar → instant canvas with backend-driven copilot builder") shipped the same direct-apply Copilot experience via a cleaner backend-agent architecture, so this branch is redundant. The genuinely-useful remaining work (builder producing resolvable graphs — the null-binding bug) is carried forward in #4586.

@graycyrus graycyrus closed this Jul 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Built-in agents, prompts, orchestration, and agent runtime in src/openhuman/agent/. feature Net-new user-facing capability or product behavior. rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant