Files
windmill/frontend/src/lib/components/flows/flowDiffManager.svelte.ts
T
centdixandClaude 3d5b79c154 feat(aichat): simplify flow mode edits (#6981)
* draft

* Phase 1: Remove deprecated granular flow AI tools

Simplify AI chat flow mode to use only YAML-based editing:
- Remove all commented-out granular tools (add_step, remove_step, set_code, etc.)
- Clean up FlowAIChatHelpers interface to only essential methods
- Update system prompts to focus on YAML-only workflow
- Remove unused imports and type definitions

This is part of a larger refactoring to simplify the flow editing
experience to a single YAML editing tool with automatic diff visualization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* use minified json

* use openflow in system prompt

* handle inputs

* cleaning

* cleaning

* diffmode in flowgraph

* remove acceptrejectmodule

* use new diff mode

* cleaning

* better props

* better logic

* cleaning

* accept reject logic

* use get set

* draft manager

* use diff manager

* draft

* Refactor flowDiffManager to be instance-based with auto-computation

- Remove singleton export, making it instantiable per FlowGraphV2
- Add afterFlow state tracking for auto-diff computation
- Add beforeInputSchema/afterInputSchema for schema change tracking
- Add $effect for reactive auto-computation when beforeFlow/afterFlow changes
- Add setAfterFlow() and setInputSchemas() methods
- Simplify accept/reject methods to just mark pending=false
- Add validation to throw error when accepting/rejecting without beforeFlow
- Update setSnapshot to accept undefined for clearing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Refactor FlowGraphV2 to own diffManager instance

- Import and create diffManager instance per FlowGraphV2
- Remove onAcceptModule and onRejectModule props
- Add validation $effect to error if both diffBeforeFlow and moduleActions provided
- Add $effect to sync props (diffBeforeFlow or moduleActions) to diffManager
- Add $effect to watch current flow changes and update afterFlow
- Replace computedDiff with diffManager.moduleActions
- Use raw modules instead of merged flow (diffManager handles merging)
- Expose getDiffManager() and setBeforeFlow() methods
- Pass diffManager to graph context instead of callbacks
- Remove $inspect for removed props

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update FlowModuleSchemaMap to use FlowGraphV2's diffManager

- Remove import of flowDiffManager singleton
- Update setBeforeFlow to call graph.setBeforeFlow()
- Update setModuleActions and getModuleActions to use graph.getDiffManager()
- Add getDiffManager() proxy method
- Simplify handleAcceptModule and handleRejectModule to use new API
- Handle editor state separately from diff operations
- Remove diffBeforeFlow, moduleActions, onAcceptModule, onRejectModule props passed to FlowGraphV2
- Remove onAcceptModule and onRejectModule from Props interface and destructured props

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update FlowAIChat to use flowModuleSchemaMap's diffManager

- Remove import of flowDiffManager singleton
- Update revertToSnapshot to use flowModuleSchemaMap.getDiffManager()
- Add null check for diffManager before using

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Verify FlowGraphDiffViewer compatibility with refactored architecture

FlowGraphDiffViewer already uses the correct prop patterns:
- Before graph: moduleActions prop (display-only mode)
- After graph: diffBeforeFlow prop (full diff mode with auto-computation)

Each FlowGraphV2 instance creates its own diffManager, making the side-by-side
view work correctly with independent diff state per graph.

No code changes required.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update graph components to use diffManager instead of callbacks

- Update graphBuilder.svelte.ts to pass diffManager instead of onAcceptModule/onRejectModule
- Update InputNode and ModuleN type definitions with diffManager
- Update ModuleNode.svelte to pass diffManager to MapItem
- Update MapItem.svelte to pass diffManager to FlowModuleSchemaItem
- Update FlowModuleSchemaItem.svelte to use diffManager directly for accept/reject
- Replace callback-based accept/reject with direct diffManager calls
- Only show accept/reject buttons when beforeFlow exists and action is pending

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix removed modules not showing in diff viewer

Problem: After refactoring, removed modules were no longer appearing in the
diff viewer because we changed effectiveModules from using the merged flow
(which includes removed modules) to using raw modules.

Solution:
- Add mergedFlow state to flowDiffManager to store timeline's merged flow
- Add markRemovedAsShadowed parameter support for side-by-side view
- Store timeline.mergedFlow in auto-computation $effect
- Add getter for mergedFlow and setMarkRemovedAsShadowed method
- Clear mergedFlow in clearSnapshot()
- Update FlowGraphV2 to set markRemovedAsShadowed in diffManager
- Update effectiveModules/FailureModule/PreprocessorModule to use mergedFlow

The merged flow contains all modules including removed ones, enabling:
- Unified view: Removed modules appear in red with "removed" badge
- Side-by-side view: Removed modules show as shadowed in After graph

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Simplify accept/reject logic by removing actions instead of toggling pending state

Previously, accepting or rejecting a module action would set pending to false but keep the action in the moduleActions map. This caused a bug where the $effect would overwrite moduleActions with fresh actions having pending: true, making accept/reject buttons reappear on previously handled modules.

Now, when a user accepts or rejects a module action, we remove it entirely from the moduleActions map. This is simpler and fixes the button reappearing issue.

Changes:
- acceptModule: Remove action from moduleActions instead of setting pending: false
- rejectModule: Remove action from moduleActions instead of setting pending: false
- checkAndClearSnapshot: Check if moduleActions is empty instead of checking pending states
- Fix typo: getModuleFromFrom → getModuleFromFlow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* cleaning

* fix logic

* make diff drawer part of manager

* accept submodules

* fixes

* Phase 4: Add checkAndApplyChanges() helper to flowDiffManager

- Added new checkAndApplyChanges() function to apply mergedFlow to flowStore when all changes are decided
- This replaces the old checkAndClearSnapshot() behavior and ensures flowStore is updated atomically
- Handles both flow structure and input schema updates

* Phase 2: Simplify acceptModule() - only modify mergedFlow

- Remove flowStore mutations from acceptModule()
- For removed modules: just delete the shadowed (__prefix) version from mergedFlow
- For added/modified: no action needed (already correct in mergedFlow)
- Call checkAndApplyChanges() to apply changes when all decided

* Phase 3: Simplify rejectModule() - only modify mergedFlow

- Remove all flowStore mutations from rejectModule()
- For added modules: delete from mergedFlow
- For removed modules: replace shadowed (__) module with original from beforeFlow
- For modified modules: restore old version in mergedFlow
- For Input schema: revert afterInputSchema
- Call checkAndApplyChanges() to apply changes when all decided

* Phase 5: Verify acceptAll/rejectAll work with new architecture

- acceptAll() and rejectAll() already pass options correctly to acceptModule/rejectModule
- They will automatically benefit from checkAndApplyChanges()
- No changes needed for this phase

* Phase 6: Remove FlowGraphV2 reactive effect that updates afterFlow

- Removed the  (lines 252-266) that continuously updated afterFlow
- This effect created reactive loops when flowStore changed
- afterFlow should only be set once when AI generates changes via setFlowYaml()
- The initial sync effect (lines 226-250) is kept for prop-driven diff mode

* Phase 7: Update FlowAIChat setFlowYaml to use diffManager

- Changed setFlowYaml() to use diffManager.setAfterFlow() instead of modifying flowStore
- flowStore remains unchanged during AI review phase
- Changes are staged in mergedFlow for user review
- Only applied to flowStore when all changes are accepted/rejected
- Added error handling for missing diffManager

* Fix linter warnings

- Remove unused FlowTimeline type import
- Fix ChangeTracker initialization with proper type parameter
- Keep deleteModuleFromFlow and checkAndClearSnapshot for potential future use

* Update plan document with implementation status

- Mark all phases as complete
- Add commit references
- Update file checklist
- Add implementation summary at top of document

* Add comprehensive implementation summary document

- Detailed overview of architecture changes
- Before/after comparisons for each file
- Complete testing scenarios checklist
- Troubleshooting guide
- Migration notes and backwards compatibility info

* Show pending modules in editor panel

- Pass diffManager from FlowModuleSchemaMap to FlowEditorPanel
- Add effectiveModules derived value that uses mergedFlow when in diff mode
- Update module iteration to use effectiveModules instead of flowStore
- Allows users to view added/modified modules during AI review
- Fixes issue where clicking on pending modules showed nothing

* Add implementation summary for show pending modules feature

* fix

* shorter system prompt

* Fix Input schema diff mode issues

- Add Accept/Reject buttons to Input node (previously only showed Diff button)
- Pass diffManager to FlowInput component
- Add effectiveSchema derived value that uses afterInputSchema when in diff mode
- Add effectiveDisabled to prevent editing Input when reviewing AI changes
- Update FlowInputViewer to show pending schema changes
- Fixes issue where Input schema changes couldn't be accepted/rejected
- Fixes issue where pending Input schema wasn't visible in the panel

* Disable delete and move buttons when in pending mode

- Add effectiveDeletable derived value that checks diffManager.hasPendingChanges
- Replace all instances of deletable with effectiveDeletable in template
- Prevents delete/move operations when AI changes are being reviewed
- Delete and move buttons are hidden when there are pending changes
- Buttons reappear once all changes are accepted or rejected
- Prevents conflicting operations during review phase

* no move or delte when reviewing

* use context

* inline script reduction

* use json

* rollback to direct modif

* fix merge

* cleaning

* fix reject removed

* add set step code tool

* better prompt

* add back relevant tools

* add back accept reject

* use edit mode for pending

* fix input

* remove unneeded effect

* cleaner + bug fix

* fix failure and preprocessor

* fix show diff for failure module

* fix accept reject on failre module

* no auto add module to context

* cleaning

* add back effect

* cleaning

* fix multiple setflowjson

* track effectivemoduleactions for graph rendering

* nit prompt

* styling

* rm md files

* rm flake copy

* cleaning

* fix z index

* fix revert

* only change before after

* use add remove modify tools

* input + failure + preproc tools

* parsing issues

* nit

* use raw schema for tools

* resolve ref for gemini

* fix schema

* show test on graph

* much cleaner logic

* ignore empty assets

* Remove debug console.log statements from production code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove debug $inspect calls from FlowGraphV2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add error logging to setFlowJson before re-throwing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Standardize null/undefined handling to prefer null

- Use .nullable().optional() instead of .nullish() in Zod schemas
- Simplify addModuleToFlow signature to use string | null
- Coerce undefined to null when extracting parsed args
- Simplify null checks to only check !== null

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove debug console.log from AI tool functions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Extract special module IDs to constants

Add SPECIAL_MODULE_IDS constant with INPUT, PREPROCESSOR, and FAILURE
to avoid magic strings throughout the flow AI chat code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add cleanup for diffDrawer reference on unmount

Prevents potential memory leaks by clearing the diffDrawer reference
when the FlowGraphV2 component is destroyed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Use structuredClone instead of JSON.parse(JSON.stringify())

structuredClone is more efficient and type-safe for deep cloning objects.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Cache module lookups in reconstructMergedFlow

Move getAllModulesMap and getAllModuleIds calls outside the loop to avoid
redundant recomputation. Track merged IDs incrementally as modules are added.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Revert "Use structuredClone instead of JSON.parse(JSON.stringify())"

This reverts commit a62ba5b980.

* cleaning

* allow delete

* better openflow for ai agents + truncate system prompt

* handle ai agent tools

* fix set code for tool

* fix wrong cancel request called

* mark tool calls as canceled

* get lang instructions

* use streamiing args

* give db url to claude

* fix revert

* save and clear when leaving editor

* keep whitespace in user message

* uniformize colors

* fix diff button

* remove db from backend claude

* remove move module tool

* no failure and preprocessor

* fix error given to llm

* fix z index

* fix ts errors

* cleaning

* fix add module logic

* fix(copilot): add 'tools' to branchPath description for aiagent containers

The branchPath parameter description was missing 'tools' option for aiagent
containers and didn't mention branchall support.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(copilot): correct AI agent tool IDs and summaries documentation

Tool summaries CAN contain spaces (they're human-readable descriptions).
Only tool IDs must avoid spaces.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(copilot): remove reference to non-existent set_flow_json tool

The set_module_code tool description referenced set_flow_json which
doesn't exist as an exposed tool (it's an internal helper).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(copilot): clarify inspect_inline_script is read-only

The tool description incorrectly suggested it could modify code.
This tool only inspects - use set_module_code to modify.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(copilot): clarify afterId behavior for AI agent tools

Updated wording to clarify that afterId can be used but is optional
for AI agent tools since tool order doesn't affect execution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(copilot): remove unused id param from get_instructions_for_code_generation

The id parameter was only used to check for preprocessor, which is no
longer needed. Simplified the tool to only require the language param.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(copilot): add result format to search_scripts tool description

Helps AI understand what data format to expect from the tool.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(copilot): add result format to resource_type tool description

Helps AI understand what data format to expect from the tool and
provides example resource type names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* nit

* Add support for adding branches to branchall/branchone via add_module

Previously, add_module could only add modules inside existing branches.
Now, using insideId with branchPath=null will add a NEW branch to a
branchall or branchone container.

API:
- add_module({ insideId: "my_branchall", branchPath: null, value: { summary: "New Branch", skip_failure: false, modules: [] } })
- add_module({ insideId: "my_branchone", branchPath: null, value: { summary: "Condition", expr: "...", modules: [] } })

Changes:
- Extended addModuleToFlow to handle branchPath=null case
- Updated validation to allow branchPath=null when adding branches
- Updated tool descriptions and system prompt documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* nit

* add remove branch tool

* check all ids for duplicates

* no dup

* nit

* cleaning

* fix dup ids

* split core.ts

* only mount diff drawer if useful

* remove wrong logic

* update exprs

* fix

* chore(flow): Add unit tests to flow diff manager (#7291)

* setup

* add basic tests for flowdiff

* add complex tests

* fix branch issue

* more complex tests

* add flow diff manager tests

* add utils

* better handling of moved case

* more tests for move case

* add buggy test case

* rm

* rework config

* cleaning

* fix config

* rm

* fix for reverting type change module

* all good

* rm

* add missing testmode

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-08 19:08:14 +01:00

544 lines
16 KiB
TypeScript

/**
* Flow Diff Manager
*
* A reusable store for managing flow diff state, module actions, and accept/reject operations.
* This decouples diff management from specific UI components (like AI chat) and makes it
* available for any use case that needs to track and apply flow changes.
*/
import type { ExtendedOpenFlow } from './types'
import type { FlowModule, FlowValue } from '$lib/gen'
import type { ModuleActionInfo } from './flowDiff'
import {
buildFlowTimeline,
insertModuleIntoFlow,
findModuleParent,
locationsEqual,
DUPLICATE_MODULE_PREFIX,
NEW_MODULE_PREFIX
} from './flowDiff'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { StateStore } from '$lib/utils'
import { getIndexInNestedModules } from '../copilot/chat/flow/utils'
import { dfs } from './previousResults'
import type DiffDrawer from '../DiffDrawer.svelte'
import { SPECIAL_MODULE_IDS } from '../copilot/chat/shared'
export type FlowDiffManager = ReturnType<typeof createFlowDiffManager>
/**
* Options for computing diff
*/
export type ComputeDiffOptions = {
/** Mark all changes as pending (requiring user approval) */
markAsPending?: boolean
/** Mark removed modules as shadowed instead of removed (for visualization) */
markRemovedAsShadowed?: boolean
}
/**
* Creates a flow diff manager instance
*/
function createSkeletonModule(module: FlowModule): FlowModule {
const clone = JSON.parse(JSON.stringify(module))
if (clone.value.type === 'forloopflow' || clone.value.type === 'whileloopflow') {
clone.value.modules = []
} else if (clone.value.type === 'branchone') {
clone.value.default = []
clone.value.branches.forEach((b: any) => (b.modules = []))
} else if (clone.value.type === 'branchall') {
clone.value.branches.forEach((b: any) => (b.modules = []))
} else if (clone.value.type === 'aiagent') {
clone.value.tools = []
}
return clone
}
export function createFlowDiffManager({ testMode = false } = {}) {
// State: snapshot of flow before changes
let beforeFlow = $state<ExtendedOpenFlow | undefined>(undefined)
// State: current flow (after changes)
let currentFlow = $state<FlowValue | undefined>(undefined)
// State: merged flow containing both original and modified/removed modules
let mergedFlow = $state<FlowValue | undefined>(undefined)
// State: current input schema
let currentInputSchema = $state<Record<string, any> | undefined>(undefined)
// State: whether to mark removed modules as shadowed (for side-by-side view)
let markRemovedAsShadowed = $state(false)
// State: whether to allow accepting/rejecting changes to the flow
let editMode = $state(false)
// State: module actions tracking changes (added/modified/removed/shadowed)
let moduleActions = $state<Record<string, ModuleActionInfo>>({})
// Reference to DiffDrawer component for showing module diffs (not reactive)
let diffDrawer: DiffDrawer | undefined = undefined
// Derived: whether there are any pending changes
const hasPendingChanges = $derived(Object.values(moduleActions).some((info) => info.pending))
// Auto-compute diff when beforeFlow or currentFlow changes
$effect(() => {
if (beforeFlow && currentFlow) {
const timeline = buildFlowTimeline(beforeFlow.value, currentFlow, {
markRemovedAsShadowed: markRemovedAsShadowed,
markAsPending: editMode
})
// Store the merged flow for rendering
mergedFlow = timeline.mergedFlow
// Update module actions
const newActions = { ...timeline.afterActions }
// Check for input schema changes
if (beforeFlow.schema && currentInputSchema) {
const schemaChanged =
JSON.stringify(beforeFlow.schema) !== JSON.stringify(currentInputSchema)
if (schemaChanged) {
newActions[SPECIAL_MODULE_IDS.INPUT] = {
action: 'modified',
pending: editMode
}
}
}
updateModuleActions(newActions)
// If no more actions, clear the snapshot (exit diff mode)
if (Object.keys(newActions).length === 0 && !testMode) {
clearSnapshot()
}
} else if (!beforeFlow) {
// Clear module actions and merged flow when no snapshot
mergedFlow = undefined
updateModuleActions({})
}
})
/**
* Helper to update moduleActions and notify listeners
*/
function updateModuleActions(newActions: Record<string, ModuleActionInfo>) {
moduleActions = newActions
}
/**
* Set the before flow snapshot for diff computation
*/
function setBeforeFlow(flow: ExtendedOpenFlow | undefined) {
beforeFlow = flow
}
/**
* Set the current flow state for diff computation
*/
function setCurrentFlow(flow: FlowValue | undefined) {
currentFlow = flow
}
/**
* Set the current input schema for tracking schema changes
*/
function setCurrentInputSchema(schema: Record<string, any> | undefined) {
currentInputSchema = schema
}
/**
* Set whether to mark removed modules as shadowed (for side-by-side view)
*/
function setMarkRemovedAsShadowed(value: boolean) {
markRemovedAsShadowed = value
}
/**
* Set whether to edit the flow
*/
function setEditMode(value: boolean) {
editMode = value
}
/**
* Clear the snapshot and all module actions
*/
function clearSnapshot() {
beforeFlow = undefined
currentFlow = undefined
mergedFlow = undefined
currentInputSchema = undefined
updateModuleActions({})
}
/**
* Set module actions directly (useful when actions are computed elsewhere)
*/
function setModuleActions(actions: Record<string, ModuleActionInfo>) {
updateModuleActions(actions)
}
/**
* Helper to get a module from a flow by ID
*/
function getModuleFromFlow(id: string, flow: ExtendedOpenFlow): FlowModule | undefined {
if (flow.value.preprocessor_module?.id === id) {
return flow.value.preprocessor_module
} else if (flow.value.failure_module?.id === id) {
return flow.value.failure_module
} else {
return dfs(id, flow, false)[0]
}
}
/**
* Internal helper to delete a module from a flow object
* Returns true if the module was found and deleted, false otherwise
*/
function deleteModuleInternal(id: string, flow: ExtendedOpenFlow): boolean {
if (flow.value.preprocessor_module?.id === id) {
flow.value.preprocessor_module = undefined
return true
} else if (flow.value.failure_module?.id === id) {
flow.value.failure_module = undefined
return true
} else {
const result = getIndexInNestedModules(flow, id)
if (!result) {
// Module not found (may have been deleted along with a parent)
return false
}
const { modules } = result
const index = modules.findIndex((m) => m.id === id)
if (index >= 0) {
modules.splice(index, 1)
return true
}
return false
}
}
/**
* Helper to delete a module from the flow
* Returns true if the module was found and deleted, false otherwise
*/
function deleteModuleFromFlow(
id: string,
flowStore: StateStore<ExtendedOpenFlow>,
selectNextIdFn?: (id: string) => void
): boolean {
selectNextIdFn?.(id)
const deleted = deleteModuleInternal(id, flowStore.val)
if (deleted) {
refreshStateStore(flowStore)
}
return deleted
}
/**
* Accept a module action (keep the changes)
* Removes the action from tracking after acceptance
*/
function acceptModule(id: string, flowStore?: StateStore<ExtendedOpenFlow>, asSkeleton = false) {
if (!beforeFlow || !currentFlow) {
console.warn('Cannot accept module without beforeFlow and currentFlow snapshots')
return
}
const info = moduleActions[id]
if (!info) return
const actualId = id.startsWith(DUPLICATE_MODULE_PREFIX)
? id.substring(DUPLICATE_MODULE_PREFIX.length)
: id
if (id === SPECIAL_MODULE_IDS.INPUT) {
// Accept input schema changes: update beforeFlow to match currentInputSchema
if (beforeFlow.schema && currentInputSchema) {
beforeFlow.schema = JSON.parse(JSON.stringify(currentInputSchema))
}
} else if (info.action === 'removed') {
// Removed in after: Remove from beforeFlow
deleteModuleInternal(actualId, beforeFlow)
} else if (info.action === 'added') {
// Added in after: Add to beforeFlow
// Check if parent exists in beforeFlow; if not, recursively accept parent first.
const parentLoc = findModuleParent(currentFlow, actualId)
if (
parentLoc &&
parentLoc.type !== 'root' &&
parentLoc.type !== 'failure' &&
parentLoc.type !== 'preprocessor'
) {
const parentInBefore = getModuleFromFlow(parentLoc.parentId, beforeFlow)
if (!parentInBefore) {
// Parent is missing in beforeFlow. It must be pending acceptance.
// Accept as skeleton to avoid auto-accepting all siblings.
acceptModule(parentLoc.parentId, flowStore, true)
}
}
// Use insertModuleIntoFlow targeting beforeFlow, sourcing position from currentFlow
let module = getModuleFromFlow(actualId, {
value: currentFlow,
summary: ''
} as ExtendedOpenFlow)
if (module) {
// Check if module already exists in beforeFlow (could be a skeleton from earlier acceptance)
const existingModule = getModuleFromFlow(actualId, beforeFlow)
if (existingModule) {
// Module exists in beforeFlow - check if it's in the same location
const beforeLocation = findModuleParent(beforeFlow.value, actualId)
const afterLocation = findModuleParent(currentFlow, actualId)
// Compare locations - if different, this is a move and we need to insert at new location
const sameLocation = locationsEqual(beforeLocation, afterLocation)
if (sameLocation) {
// Module is in the same location, update it in-place
const moduleToApply = asSkeleton ? createSkeletonModule(module) : module
Object.keys(existingModule).forEach((k) => delete (existingModule as any)[k])
Object.assign(existingModule, $state.snapshot(moduleToApply))
} else {
// Module is being moved - insert at new location (the old copy will be removed when old__id is accepted)
const moduleToInsert = asSkeleton ? createSkeletonModule(module) : module
insertModuleIntoFlow(
beforeFlow.value,
$state.snapshot(moduleToInsert),
currentFlow,
actualId
)
}
} else {
// Module doesn't exist, insert it
const moduleToInsert = asSkeleton ? createSkeletonModule(module) : module
insertModuleIntoFlow(
beforeFlow.value,
$state.snapshot(moduleToInsert),
currentFlow,
actualId
)
}
}
} else if (info.action === 'modified') {
// Modified: Apply modifications to beforeFlow module
const beforeModule = getModuleFromFlow(actualId, beforeFlow)
const afterModule = getModuleFromFlow(actualId, {
value: currentFlow,
summary: ''
} as ExtendedOpenFlow)
if (beforeModule && afterModule) {
Object.keys(beforeModule).forEach((k) => delete (beforeModule as any)[k])
Object.assign(beforeModule, $state.snapshot(afterModule))
}
}
}
/**
* Reject a module action (revert the changes)
* Removes the action from tracking after rejection
*/
function rejectModule(id: string, flowStore?: StateStore<ExtendedOpenFlow>) {
if (!beforeFlow) {
throw new Error('Cannot reject module without a beforeFlow snapshot')
}
const actualId = id.startsWith(DUPLICATE_MODULE_PREFIX)
? id.substring(DUPLICATE_MODULE_PREFIX.length)
: id
const info = moduleActions[id]
if (!info) return
// Only perform revert operations if flowStore is provided
if (flowStore) {
if (id === SPECIAL_MODULE_IDS.INPUT) {
// Revert input schema changes
flowStore.val.schema = beforeFlow.schema
currentInputSchema = flowStore.val.schema
} else if (info.action === 'added') {
// Added in after: Remove from flowStore (currentFlow)
// deleteModuleFromFlow handles the case where the module was already deleted (e.g., with its parent)
deleteModuleFromFlow(actualId, flowStore)
} else if (info.action === 'removed') {
// Removed in after: Restore to flowStore (currentFlow)
// Source from beforeFlow
const oldModule = getModuleFromFlow(actualId, beforeFlow)
if (oldModule) {
// For type changes (old__ prefix), rename the new module to avoid ID conflict
if (id.startsWith(DUPLICATE_MODULE_PREFIX)) {
const existingNew = getModuleFromFlow(actualId, flowStore.val)
if (existingNew) {
existingNew.id = `${NEW_MODULE_PREFIX}${actualId}`
}
}
insertModuleIntoFlow(
flowStore.val.value,
$state.snapshot(oldModule),
beforeFlow.value,
actualId
)
}
refreshStateStore(flowStore)
} else if (info.action === 'modified') {
// Modified: Revert modifications in flowStore (currentFlow)
const oldModule = getModuleFromFlow(actualId, beforeFlow)
const newModule = getModuleFromFlow(actualId, flowStore.val)
if (oldModule && newModule) {
Object.keys(newModule).forEach((k) => delete (newModule as any)[k])
Object.assign(newModule, $state.snapshot(oldModule))
}
refreshStateStore(flowStore)
}
currentFlow = flowStore.val.value
}
// Note: The $effect will automatically recompute the diff, clearing the action
// since flowStore (currentFlow) now matches beforeFlow for this module.
}
/**
* Accept all pending module actions
*/
function acceptAll(flowStore?: StateStore<ExtendedOpenFlow>) {
const ids = Object.keys(moduleActions)
for (const id of ids) {
if (moduleActions[id]?.pending) {
acceptModule(id, flowStore)
}
}
}
/**
* Reject all pending module actions (in reverse order for nested modules)
*/
function rejectAll(flowStore?: StateStore<ExtendedOpenFlow>) {
const ids = Object.keys(moduleActions)
// Process in reverse to handle nested modules correctly
for (let i = ids.length - 1; i >= 0; i--) {
if (moduleActions[ids[i]]?.pending) {
rejectModule(ids[i], flowStore)
}
}
}
/**
* Revert the entire flow to the snapshot
* @param flowStore - The flow store to update
* @param snapshot - Optional specific snapshot to revert to (defaults to beforeFlow)
*/
function revertToSnapshot(flowStore: StateStore<ExtendedOpenFlow>, snapshot?: ExtendedOpenFlow) {
const targetSnapshot = snapshot ?? beforeFlow
if (!targetSnapshot) return
flowStore.val = targetSnapshot
refreshStateStore(flowStore)
clearSnapshot()
}
/**
* Set the DiffDrawer instance for showing module diffs
*/
function setDiffDrawer(drawer: DiffDrawer | undefined) {
diffDrawer = drawer
}
/**
* Show diff for a specific module or Input schema
*/
function showModuleDiff(moduleId: string) {
if (!diffDrawer || !beforeFlow) return
if (moduleId === SPECIAL_MODULE_IDS.INPUT) {
// Show input schema diff
diffDrawer.openDrawer()
diffDrawer.setDiff({
mode: 'simple',
title: 'Flow Input Schema Diff',
original: { schema: beforeFlow.schema ?? {} },
current: { schema: currentInputSchema ?? {} }
})
} else {
// Show module diff
const beforeModule = getModuleFromFlow(moduleId, beforeFlow)
// Need to check failure_module and preprocessor_module for currentFlow as well
let afterModule: FlowModule | undefined = undefined
if (currentFlow) {
if (currentFlow.preprocessor_module?.id === moduleId) {
afterModule = currentFlow.preprocessor_module
} else if (currentFlow.failure_module?.id === moduleId) {
afterModule = currentFlow.failure_module
} else {
afterModule = dfs(moduleId, { value: currentFlow, summary: '' }, false)[0]
}
}
if (beforeModule && afterModule) {
diffDrawer.openDrawer()
diffDrawer.setDiff({
mode: 'simple',
title: `Module Diff: ${moduleId}`,
original: beforeModule,
current: afterModule
})
}
}
}
return {
// State accessors
get beforeFlow() {
return beforeFlow
},
get currentFlow() {
return currentFlow
},
get mergedFlow() {
return mergedFlow
},
get moduleActions() {
return moduleActions
},
get hasPendingChanges() {
return hasPendingChanges
},
get currentInputSchema() {
return currentInputSchema
},
get editModeEnabled() {
return editMode
},
// Snapshot management
setBeforeFlow,
setCurrentFlow,
setCurrentInputSchema,
setMarkRemovedAsShadowed,
setEditMode,
clearSnapshot,
// Module actions management
setModuleActions,
// Accept/reject operations
acceptModule,
rejectModule,
acceptAll,
rejectAll,
revertToSnapshot,
// Diff drawer management
setDiffDrawer,
showModuleDiff
}
}