fix(editor): restore cursor position after tag/env-var completion in code editors (#3406)

* fix(editor): restore cursor position after tag/env-var completion in code editors

* lint

* refactor(editor): extract restoreCursorAfterInsertion helper, fix weak fallbacks

* updated

* fix(editor): replace useEffect with direct ref assignment for editorValueRef

* fix(editor): guard cursor restoration behind preview/readOnly check

Move restoreCursorAfterInsertion inside the !isPreview && !readOnly guard
so cursor position isn't computed against newValue when the textarea still
holds liveValue. Add comment documenting the cross-string index invariant
in the shared helper.

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

* fix(editor): escape blockId in CSS selector with CSS.escape()

Prevents potential SyntaxError if blockId ever contains CSS special
characters when querying the textarea for cursor restoration.

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

* perf(editor): use ref for cursor fallback to stabilize useCallback

Replace cursorPosition state in handleSubflowTagSelect's dependency
array with a cursorPositionRef. This avoids recreating the callback
on every keystroke since cursorPosition is only used as a fallback
when textareaRef.current is null.

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

* refactor(editor): pass cursor position explicitly from dropdowns

Instead of inferring cursor position by searching for delimiters in the
output string (which could match unrelated < or {{ in code), compute
the exact cursor position in TagDropdown and EnvVarDropdown where the
insertion range is definitively known, and pass it through onSelect.

This follows the same pattern used by CodeMirror, Monaco, and
ProseMirror: the insertion source always knows the range, so cursor
position is computed at the source rather than inferred by the consumer.

- TagDropdown/EnvVarDropdown: compute newCursorPosition, pass as 2nd arg
- restoreCursorAfterInsertion: simplified to just (textarea, position)
- code.tsx, condition-input.tsx, use-subflow-editor.ts: accept position
- Removed editorValueRef and cursorPositionRef from use-subflow-editor
  (no longer needed since dropdown computes position)
- Other consumers (native inputs) unaffected due to TS callback compat

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

* docs(editor): fix JSDoc terminology — macrotask not microtask

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-03-03 22:10:00 -08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 2c79d0249f
commit 46065983f6
6 changed files with 86 additions and 33 deletions
@@ -31,6 +31,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block'
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
import { WandPromptBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/wand-prompt-bar/wand-prompt-bar'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand'
@@ -537,36 +538,40 @@ export const Code = memo(function Code({
/**
* Handles selection of a tag from the tag dropdown.
* @param newValue - The new code value with the selected tag inserted
* @param newCursorPosition - The cursor position after the inserted tag
*/
const handleTagSelect = (newValue: string) => {
const handleTagSelect = (newValue: string, newCursorPosition: number) => {
const textarea = editorRef.current?.querySelector('textarea') as HTMLTextAreaElement | null
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
recordChange(newValue)
restoreCursorAfterInsertion(textarea, newCursorPosition)
} else {
setTimeout(() => textarea?.focus(), 0)
}
setShowTags(false)
setActiveSourceBlockId(null)
setTimeout(() => {
editorRef.current?.querySelector('textarea')?.focus()
}, 0)
}
/**
* Handles selection of an environment variable from the dropdown.
* @param newValue - The new code value with the selected env var inserted
* @param newCursorPosition - The cursor position after the inserted env var
*/
const handleEnvVarSelect = (newValue: string) => {
const handleEnvVarSelect = (newValue: string, newCursorPosition: number) => {
const textarea = editorRef.current?.querySelector('textarea') as HTMLTextAreaElement | null
if (!isPreview && !readOnly) {
setCode(newValue)
emitTagSelection(newValue)
recordChange(newValue)
restoreCursorAfterInsertion(textarea, newCursorPosition)
} else {
setTimeout(() => textarea?.focus(), 0)
}
setShowEnvVars(false)
setTimeout(() => {
editorRef.current?.querySelector('textarea')?.focus()
}, 0)
}
/**
@@ -31,6 +31,7 @@ import {
TagDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { normalizeName } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
@@ -554,9 +555,17 @@ export function ConditionInput({
)
}
const handleTagSelectImmediate = (blockId: string, newValue: string) => {
const handleTagSelectImmediate = (
blockId: string,
newValue: string,
newCursorPosition: number
) => {
if (isPreview || disabled) return
const textarea = containerRef.current?.querySelector(
`[data-block-id="${CSS.escape(blockId)}"] textarea`
) as HTMLTextAreaElement | null
shouldPersistRef.current = true
setConditionalBlocks((blocks) =>
blocks.map((block) =>
@@ -582,11 +591,21 @@ export function ConditionInput({
: block
)
emitTagSelection(JSON.stringify(updatedBlocks))
restoreCursorAfterInsertion(textarea, newCursorPosition)
}
const handleEnvVarSelectImmediate = (blockId: string, newValue: string) => {
const handleEnvVarSelectImmediate = (
blockId: string,
newValue: string,
newCursorPosition: number
) => {
if (isPreview || disabled) return
const textarea = containerRef.current?.querySelector(
`[data-block-id="${CSS.escape(blockId)}"] textarea`
) as HTMLTextAreaElement | null
shouldPersistRef.current = true
setConditionalBlocks((blocks) =>
blocks.map((block) =>
@@ -612,6 +631,8 @@ export function ConditionInput({
: block
)
emitTagSelection(JSON.stringify(updatedBlocks))
restoreCursorAfterInsertion(textarea, newCursorPosition)
}
/**
@@ -999,7 +1020,9 @@ export function ConditionInput({
{block.showEnvVars && (
<EnvVarDropdown
visible={block.showEnvVars}
onSelect={(newValue) => handleEnvVarSelectImmediate(block.id, newValue)}
onSelect={(newValue, newCursorPosition) =>
handleEnvVarSelectImmediate(block.id, newValue, newCursorPosition)
}
searchTerm={block.searchTerm}
inputValue={block.value}
cursorPosition={block.cursorPosition}
@@ -1023,7 +1046,9 @@ export function ConditionInput({
{block.showTags && (
<TagDropdown
visible={block.showTags}
onSelect={(newValue) => handleTagSelectImmediate(block.id, newValue)}
onSelect={(newValue, newCursorPosition) =>
handleTagSelectImmediate(block.id, newValue, newCursorPosition)
}
blockId={blockId}
activeSourceBlockId={block.activeSourceBlockId}
inputValue={block.value}
@@ -1207,7 +1232,9 @@ export function ConditionInput({
{block.showEnvVars && (
<EnvVarDropdown
visible={block.showEnvVars}
onSelect={(newValue) => handleEnvVarSelectImmediate(block.id, newValue)}
onSelect={(newValue, newCursorPosition) =>
handleEnvVarSelectImmediate(block.id, newValue, newCursorPosition)
}
searchTerm={block.searchTerm}
inputValue={block.value}
cursorPosition={block.cursorPosition}
@@ -1225,7 +1252,9 @@ export function ConditionInput({
{block.showTags && (
<TagDropdown
visible={block.showTags}
onSelect={(newValue) => handleTagSelectImmediate(block.id, newValue)}
onSelect={(newValue, newCursorPosition) =>
handleTagSelectImmediate(block.id, newValue, newCursorPosition)
}
blockId={blockId}
activeSourceBlockId={block.activeSourceBlockId}
inputValue={block.value}
@@ -23,7 +23,7 @@ interface EnvVarDropdownProps {
/** Whether the dropdown is visible */
visible: boolean
/** Callback when an environment variable is selected */
onSelect: (newValue: string) => void
onSelect: (newValue: string, newCursorPosition: number) => void
/** Search term to filter environment variables */
searchTerm?: string
/** Additional CSS class names */
@@ -189,6 +189,8 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
const isStandardEnvVarContext = lastOpenBraces !== -1
const tagLength = 2 + envVar.length + 2
if (isStandardEnvVarContext) {
const startText = textBeforeCursor.slice(0, lastOpenBraces)
@@ -196,13 +198,10 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
const endText = closeIndex !== -1 ? textAfterCursor.slice(closeIndex + 2) : textAfterCursor
const newValue = `${startText}{{${envVar}}}${endText}`
onSelect(newValue)
onSelect(newValue, lastOpenBraces + tagLength)
} else {
if (inputValue.trim() !== '') {
onSelect(`{{${envVar}}}`)
} else {
onSelect(`{{${envVar}}}`)
}
const newValue = `{{${envVar}}}`
onSelect(newValue, tagLength)
}
onClose?.()
@@ -70,7 +70,7 @@ interface TagDropdownProps {
/** Whether the dropdown is visible */
visible: boolean
/** Callback when a tag is selected */
onSelect: (newValue: string) => void
onSelect: (newValue: string, newCursorPosition: number) => void
/** ID of the block that owns the input field */
blockId: string
/** ID of the specific source block being referenced, if any */
@@ -1598,10 +1598,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
let newValue: string
let insertStart: number
if (lastOpenBracket === -1) {
// No '<' found - insert the full tag at cursor position
newValue = `${textBeforeCursor}<${processedTag}>${textAfterCursor}`
insertStart = liveCursor
} else {
// '<' found - replace from '<' to cursor (and consume trailing '>' if present)
const nextCloseBracket = textAfterCursor.indexOf('>')
@@ -1615,9 +1617,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
}
newValue = `${textBeforeCursor.slice(0, lastOpenBracket)}<${processedTag}>${remainingTextAfterCursor}`
insertStart = lastOpenBracket
}
onSelect(newValue)
const newCursorPos = insertStart + 1 + processedTag.length + 1
onSelect(newValue, newCursorPos)
onClose?.()
},
[workflowVariables, onSelect, onClose, getMergedSubBlocks]
@@ -7,6 +7,7 @@ import {
splitReferenceSegment,
} from '@/lib/workflows/sanitization/references'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { restoreCursorAfterInsertion } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { normalizeName, REFERENCE } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
@@ -60,7 +61,6 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement>(null)
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
const [showTagDropdown, setShowTagDropdown] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
@@ -289,8 +289,9 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
* Handle tag selection from dropdown
*/
const handleSubflowTagSelect = useCallback(
(newValue: string) => {
(newValue: string, newCursorPosition: number) => {
if (!currentBlockId || !isSubflow || !currentBlock) return
collaborativeUpdateIterationCollection(
currentBlockId,
currentBlock.type as 'loop' | 'parallel',
@@ -298,12 +299,7 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
)
setShowTagDropdown(false)
setTimeout(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.focus()
}
}, 0)
restoreCursorAfterInsertion(textareaRef.current, newCursorPosition)
},
[currentBlockId, isSubflow, currentBlock, collaborativeUpdateIterationCollection]
)
@@ -0,0 +1,20 @@
/**
* Restores the cursor position in a textarea after a dropdown insertion.
* Schedules a macrotask (via setTimeout) that runs after React's controlled-component commit
* so that the cursor position sticks.
*
* @param textarea - The textarea element to restore cursor in (may be null)
* @param newCursorPosition - The exact position to place the cursor at
*/
export function restoreCursorAfterInsertion(
textarea: HTMLTextAreaElement | null,
newCursorPosition: number
): void {
setTimeout(() => {
if (textarea) {
textarea.focus()
textarea.selectionStart = newCursorPosition
textarea.selectionEnd = newCursorPosition
}
}, 0)
}