Skip to content

Commit e1db23a

Browse files
committed
feat(undo-redo): unify field, code, and loop config edits into one stack
- Record block-level field edits (tools, model, prompts, dropdowns, sliders, tag/reference insertion) on the workflow undo stack - Consolidate the separate per-field code-editor stack into the workflow stack and delete it; route Cmd+Z/redo in every text editor to the workflow undo so native/editor undo is suppressed and there is one source of truth - Make loop/parallel config values (iterations, collection, condition, batch size) undoable; coalesce consecutive same-field edits into one step - Group the model->apiKey clear into a single undo step - Reveal the affected block (select + open its editor panel) on undo/redo so a reverted field change is never off-screen
1 parent aed4402 commit e1db23a

20 files changed

Lines changed: 950 additions & 482 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx

Lines changed: 7 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
TagDropdown,
3636
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
3737
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
38+
import { useEditorUndoRedo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-editor-undo-redo'
3839
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
3940
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
4041
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
@@ -46,9 +47,7 @@ import { normalizeName } from '@/executor/constants'
4647
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
4748
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
4849
import { createShouldHighlightEnvVar, useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
49-
import { useCodeUndoRedo } from '@/hooks/use-code-undo-redo'
5050
import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
51-
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
5251

5352
const logger = createLogger('Code')
5453

@@ -258,12 +257,8 @@ export const Code = memo(function Code({
258257
const emitTagSelection = useTagSelection(blockId, subBlockId)
259258
const [languageValue] = useSubBlockValue<string>(blockId, 'language')
260259
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
261-
const blockType = useWorkflowStore(
262-
useCallback((state) => state.blocks?.[blockId]?.type, [blockId])
263-
)
264260

265261
const effectiveLanguage = (languageValue as 'javascript' | 'python' | 'json') || language
266-
const isFunctionCode = blockType === 'function' && subBlockId === 'code'
267262

268263
const trimmedCode = code.trim()
269264
const containsReferencePlaceholders =
@@ -344,14 +339,7 @@ export const Code = memo(function Code({
344339
const updatePromptValue = wandHook?.updatePromptValue || (() => {})
345340
const cancelGeneration = wandHook?.cancelGeneration || (() => {})
346341

347-
const { recordChange, recordReplace, flushPending, startSession, undo, redo } = useCodeUndoRedo({
348-
blockId,
349-
subBlockId,
350-
value: code,
351-
enabled: isFunctionCode,
352-
isReadOnly: readOnly || disabled || isPreview,
353-
isStreaming: isAiStreaming,
354-
})
342+
const handleEditorUndoRedo = useEditorUndoRedo()
355343

356344
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId, false, {
357345
isStreaming: isAiStreaming,
@@ -404,10 +392,9 @@ export const Code = memo(function Code({
404392
setCode(generatedCode)
405393
if (!isPreview && !disabled) {
406394
setStoreValue(generatedCode)
407-
recordReplace(generatedCode)
408395
}
409396
}
410-
}, [disabled, isPreview, recordReplace, setStoreValue])
397+
}, [disabled, isPreview, setStoreValue])
411398

412399
useEffect(() => {
413400
if (!editorRef.current) return
@@ -550,7 +537,6 @@ export const Code = memo(function Code({
550537

551538
setCode(newValue)
552539
setStoreValue(newValue)
553-
recordChange(newValue)
554540
const newCursorPosition = dropPosition + 1
555541
setCursorPosition(newCursorPosition)
556542

@@ -582,7 +568,6 @@ export const Code = memo(function Code({
582568
if (!isPreview && !readOnly) {
583569
setCode(newValue)
584570
emitTagSelection(newValue)
585-
recordChange(newValue)
586571
restoreCursorAfterInsertion(textarea, newCursorPosition)
587572
} else {
588573
setTimeout(() => textarea?.focus(), 0)
@@ -602,7 +587,6 @@ export const Code = memo(function Code({
602587
if (!isPreview && !readOnly) {
603588
setCode(newValue)
604589
emitTagSelection(newValue)
605-
recordChange(newValue)
606590
restoreCursorAfterInsertion(textarea, newCursorPosition)
607591
} else {
608592
setTimeout(() => textarea?.focus(), 0)
@@ -699,7 +683,6 @@ export const Code = memo(function Code({
699683
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
700684
setCode(newCode)
701685
setStoreValue(newCode)
702-
recordChange(newCode)
703686

704687
const textarea = editorRef.current?.querySelector('textarea')
705688
if (textarea) {
@@ -718,7 +701,7 @@ export const Code = memo(function Code({
718701
}
719702
}
720703
},
721-
[isAiStreaming, isPreview, disabled, readOnly, recordChange, setStoreValue]
704+
[isAiStreaming, isPreview, disabled, readOnly, setStoreValue]
722705
)
723706

724707
const handleKeyDown = useCallback(
@@ -731,37 +714,17 @@ export const Code = memo(function Code({
731714
e.preventDefault()
732715
return
733716
}
734-
if (!isFunctionCode) return
735-
const isUndo = (e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && !e.shiftKey
736-
const isRedo =
737-
((e.key === 'z' || e.key === 'Z') && (e.metaKey || e.ctrlKey) && e.shiftKey) ||
738-
(e.key === 'y' && (e.metaKey || e.ctrlKey))
739-
if (isUndo) {
740-
e.preventDefault()
741-
e.stopPropagation()
742-
undo()
743-
return
744-
}
745-
if (isRedo) {
746-
e.preventDefault()
747-
e.stopPropagation()
748-
redo()
749-
}
717+
handleEditorUndoRedo(e)
750718
},
751-
[isAiStreaming, isFunctionCode, redo, undo]
719+
[isAiStreaming, handleEditorUndoRedo]
752720
)
753721

754722
const handleEditorFocus = useCallback(() => {
755-
startSession(codeRef.current)
756723
if (!isPreview && !disabled && !readOnly && codeRef.current.trim() === '') {
757724
setShowTags(true)
758725
setCursorPosition(0)
759726
}
760-
}, [disabled, isPreview, readOnly, startSession])
761-
762-
const handleEditorBlur = useCallback(() => {
763-
flushPending()
764-
}, [flushPending])
727+
}, [disabled, isPreview, readOnly])
765728

766729
/**
767730
* Renders the line numbers, aligned with wrapped visual lines and highlighting the active line.
@@ -881,7 +844,6 @@ export const Code = memo(function Code({
881844
onValueChange={handleValueChange}
882845
onKeyDown={handleKeyDown}
883846
onFocus={handleEditorFocus}
884-
onBlur={handleEditorBlur}
885847
highlight={highlightCode}
886848
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
887849
/>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
TagDropdown,
3737
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
3838
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
39+
import { useEditorUndoRedo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-editor-undo-redo'
3940
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
4041
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
4142
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
@@ -142,6 +143,7 @@ export function ConditionInput({
142143
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
143144

144145
const emitTagSelection = useTagSelection(blockId, subBlockId)
146+
const handleEditorUndoRedo = useEditorUndoRedo()
145147
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
146148
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
147149
const shouldHighlightEnvVar = useMemo(
@@ -1268,6 +1270,7 @@ export function ConditionInput({
12681270
}
12691271
}}
12701272
onKeyDown={(e) => {
1273+
if (handleEditorUndoRedo(e)) return
12711274
if (e.key === 'Escape') {
12721275
setConditionalBlocks((blocks) =>
12731276
blocks.map((b) =>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function SelectorInput({
101101
allowSearch={allowSearch}
102102
onOptionChange={(value) => {
103103
if (!isPreview) {
104-
collaborativeSetSubblockValue(blockId, subBlock.id, value)
104+
collaborativeSetSubblockValue(blockId, subBlock.id, value, { recordUndo: true })
105105
}
106106
}}
107107
activeSearchTarget={activeSearchTarget}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/starter/input-format.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
2424
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
2525
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
2626
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
27+
import { useEditorUndoRedo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-editor-undo-redo'
2728
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
2829
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
2930
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
@@ -127,6 +128,7 @@ export function FieldFormat({
127128
isPreview,
128129
disabled,
129130
})
131+
const handleEditorUndoRedo = useEditorUndoRedo()
130132

131133
const value = isPreview ? previewValue : storeValue
132134
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [createDefaultField()]
@@ -443,6 +445,7 @@ export function FieldFormat({
443445
<Editor
444446
value={fieldValue}
445447
onValueChange={getEditorValueChangeHandler(field.id)}
448+
onKeyDown={handleEditorUndoRedo}
446449
highlight={jsonHighlight}
447450
disabled={isReadOnly}
448451
{...getCodeEditorProps({ disabled: isReadOnly })}
@@ -478,6 +481,7 @@ export function FieldFormat({
478481
<Editor
479482
value={fieldValue}
480483
onValueChange={getEditorValueChangeHandler(field.id)}
484+
onKeyDown={handleEditorUndoRedo}
481485
highlight={jsonHighlight}
482486
disabled={isReadOnly}
483487
{...getCodeEditorProps({ disabled: isReadOnly })}
@@ -515,6 +519,7 @@ export function FieldFormat({
515519
<Editor
516520
value={fieldValue}
517521
onValueChange={getEditorValueChangeHandler(field.id)}
522+
onKeyDown={handleEditorUndoRedo}
518523
highlight={jsonHighlight}
519524
disabled={isReadOnly}
520525
{...getCodeEditorProps({ disabled: isReadOnly })}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/code-editor/code-editor.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/components/emcn'
1414
import { Button } from '@/components/ui/button'
1515
import { cn } from '@/lib/core/utils/cn'
16+
import { useEditorUndoRedo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-editor-undo-redo'
1617
import {
1718
createEnvVarPattern,
1819
createWorkflowVariablePattern,
@@ -54,6 +55,7 @@ export function CodeEditor({
5455
wandButtonDisabled = false,
5556
}: CodeEditorProps) {
5657
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
58+
const handleEditorUndoRedo = useEditorUndoRedo()
5759

5860
const editorRef = useRef<HTMLDivElement>(null)
5961

@@ -209,7 +211,10 @@ export function CodeEditor({
209211
<Editor
210212
value={value}
211213
onValueChange={onChange}
212-
onKeyDown={onKeyDown}
214+
onKeyDown={(e) => {
215+
if (handleEditorUndoRedo(e)) return
216+
onKeyDown?.(e)
217+
}}
213218
highlight={(code) => customHighlight(code)}
214219
disabled={disabled}
215220
{...getCodeEditorProps({ disabled })}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type React from 'react'
2+
import { useCallback, useRef } from 'react'
3+
import { useUndoRedo } from '@/hooks/use-undo-redo'
4+
5+
/**
6+
* Routes undo/redo keyboard shortcuts to the workflow undo stack while a text
7+
* editor is focused, suppressing the browser/editor-native undo so the workflow
8+
* stack stays the single source of truth.
9+
*
10+
* The returned handler is stable for the lifetime of the component and always
11+
* calls the latest undo/redo (via refs), so it is safe to use inside callbacks
12+
* with empty dependency arrays.
13+
*
14+
* @returns A keydown handler that returns `true` when it handled an undo/redo
15+
* shortcut, letting callers stop further processing of the event.
16+
*/
17+
export function useEditorUndoRedo() {
18+
const { undo, redo } = useUndoRedo()
19+
const undoRef = useRef(undo)
20+
const redoRef = useRef(redo)
21+
undoRef.current = undo
22+
redoRef.current = redo
23+
24+
return useCallback((event: React.KeyboardEvent): boolean => {
25+
if (!(event.metaKey || event.ctrlKey)) return false
26+
27+
const key = event.key.toLowerCase()
28+
const isUndo = key === 'z' && !event.shiftKey
29+
const isRedo = (key === 'z' && event.shiftKey) || key === 'y'
30+
if (!isUndo && !isRedo) return false
31+
32+
event.preventDefault()
33+
event.stopPropagation()
34+
if (isUndo) {
35+
undoRef.current()
36+
} else {
37+
redoRef.current()
38+
}
39+
return true
40+
}, [])
41+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { useParams } from 'next/navigation'
44
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
55
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
6+
import { useEditorUndoRedo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-editor-undo-redo'
67
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
78
import type { SubBlockConfig } from '@/blocks/types'
89
import { useTagSelection } from '@/hooks/kb/use-tag-selection'
@@ -176,6 +177,7 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
176177
})
177178

178179
const emitTagSelection = useTagSelection(blockId, subBlockId)
180+
const handleEditorUndoRedo = useEditorUndoRedo()
179181

180182
// Local content enables immediate UI updates and streaming text display
181183
const [localContent, setLocalContent] = useState<string>('')
@@ -265,6 +267,7 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
265267

266268
const handleKeyDown = useCallback(
267269
(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
270+
if (handleEditorUndoRedo(e)) return
268271
if (e.key === 'Escape') {
269272
setShowEnvVars(false)
270273
setShowTags(false)
@@ -458,6 +461,7 @@ export function useSubBlockInput(options: UseSubBlockInputOptions): UseSubBlockI
458461
})
459462
},
460463
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
464+
if (handleEditorUndoRedo(e)) return
461465
if (e.key === 'Escape') {
462466
updateFieldState(fieldId, {
463467
showEnvVars: false,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ export function useSubBlockValue<T = any>(
109109

110110
// Emit the value to socket/DB and update local store
111111
const emitValue = useCallback(
112-
(value: T) => {
113-
collaborativeSetSubblockValue(blockId, subBlockId, value)
112+
(value: T, linkedUpdates?: Array<{ subblockId: string; value: unknown }>) => {
113+
collaborativeSetSubblockValue(blockId, subBlockId, value, { recordUndo: true, linkedUpdates })
114114
lastEmittedValueRef.current = value
115115
},
116116
[blockId, subBlockId, collaborativeSetSubblockValue]
@@ -161,7 +161,9 @@ export function useSubBlockValue<T = any>(
161161
return
162162
}
163163

164-
// Handle model changes for provider-based blocks - clear API key when provider changes (non-streaming)
164+
// Handle model changes for provider-based blocks - clear API key when provider changes
165+
// (non-streaming). The clear is grouped into the model edit's single undo step.
166+
let linkedUpdates: Array<{ subblockId: string; value: unknown }> | undefined
165167
if (
166168
subBlockId === 'model' &&
167169
isProviderBasedBlock &&
@@ -174,13 +176,13 @@ export function useSubBlockValue<T = any>(
174176
const oldProvider = oldModelValue ? getProviderFromModel(oldModelValue) : null
175177
const newProvider = getProviderFromModel(newValue)
176178
if (oldProvider !== newProvider) {
177-
collaborativeSetSubblockValue(blockId, 'apiKey', '')
179+
linkedUpdates = [{ subblockId: 'apiKey', value: '' }]
178180
}
179181
}
180182
}
181183

182184
// Emit immediately; the client queue coalesces same-key ops and the server debounces
183-
emitValue(valueCopy as T)
185+
emitValue(valueCopy as T, linkedUpdates)
184186

185187
if (triggerWorkflowUpdate) {
186188
useWorkflowStore.getState().triggerUpdate()
@@ -198,7 +200,6 @@ export function useSubBlockValue<T = any>(
198200
isStreaming,
199201
emitValue,
200202
isBaselineView,
201-
collaborativeSetSubblockValue,
202203
isProviderBasedBlock,
203204
]
204205
)

0 commit comments

Comments
 (0)