({
+ userProfileQueryOptions: () => ({
+ queryKey: ['profile'],
+ queryFn: async () => ({ profile: { id: 'author', name: 'Alice', avatar_url: null } }),
+ }),
+}))
+vi.mock('./comment-preview', () => ({ default: () => null }))
+vi.mock('./mention-input', () => ({ MentionInput: () =>
}))
+
+const comment: WorkflowCommentList = {
+ id: 'comment',
+ content: 'Comment',
+ created_by: 'author',
+ position_x: 100,
+ position_y: 100,
+ created_by_account: { id: 'author', name: 'Alice', email: 'alice@example.com', avatar_url: null },
+ created_at: 1,
+ updated_at: 1,
+ resolved: false,
+ mention_count: 0,
+ reply_count: 0,
+ participants: [],
+}
+
+function Fixture({ draft = false }: { draft?: boolean }) {
+ const [queryClient] = useState(
+ () => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
+ )
+ const [current, setCurrent] = useState(comment)
+ const [position, setPosition] = useState({ x: 100, y: 100 })
+ const [opened, setOpened] = useState(false)
+ return (
+
+ Loading profile}>
+
+
+
+ {draft ? (
+
{}}
+ onCancel={() => {}}
+ onPositionChange={(next) => setPosition({ x: next.elementX, y: next.elementY })}
+ />
+ ) : (
+ setOpened(true)}
+ onPositionUpdate={(next) =>
+ setCurrent((value) => ({ ...value, position_x: next.x, position_y: next.y }))
+ }
+ />
+ )}
+
+ {opened && Comment opened
}
+
+
+
+
+ )
+}
+
+it('keeps an authored comment focusable and draggable after adding keyboard movement', async () => {
+ // Browser-owned: real pointer capture, marker hit testing, and native button activation.
+ await page.viewport(1000, 800)
+ const screen = await render(
)
+ const marker = screen.getByRole('button', { name: /workflow.keyboard.openComment/ })
+ await expect.element(marker).toBeVisible()
+ await screen.getByRole('button', { name: 'Before canvas' }).click()
+ await userEvent.tab()
+ await expect.element(marker).toHaveFocus()
+ const initial = marker.element().getBoundingClientRect()
+ await userEvent.keyboard('{ArrowRight}')
+ await expect.poll(() => marker.element().getBoundingClientRect().x).toBe(initial.x + 5)
+ await userEvent.dragAndDrop(marker, screen.getByRole('group', { name: 'Canvas' }), {
+ targetPosition: { x: 300, y: 250 },
+ })
+ expect(marker.element().getBoundingClientRect().x).toBeGreaterThan(initial.x + 5)
+ await expect.element(screen.getByRole('status')).not.toBeInTheDocument()
+ await marker.click()
+ await expect.element(screen.getByRole('status')).toHaveTextContent('Comment opened')
+})
+
+it('preserves draft mouse dragging and lets keyboard users move the same handle', async () => {
+ // Browser-owned: native document pointer events and geometry of the positioned draft.
+ await page.viewport(1000, 800)
+ const screen = await render(
)
+ const handle = screen.getByRole('button', { name: 'workflow.keyboard.moveDraftComment' })
+ await expect.element(handle).toBeVisible()
+ const initial = handle.element().getBoundingClientRect()
+ await userEvent.dragAndDrop(handle, screen.getByRole('group', { name: 'Canvas' }), {
+ targetPosition: { x: 300, y: 250 },
+ })
+ expect(handle.element().getBoundingClientRect().x).toBeGreaterThan(initial.x)
+ await screen.getByRole('button', { name: 'Before canvas' }).click()
+ await userEvent.tab()
+ await expect.element(handle).toHaveFocus()
+ const dragged = handle.element().getBoundingClientRect()
+ await userEvent.keyboard('{Enter}{ArrowRight}{Shift>}{ArrowDown}{/Shift}{Enter}')
+ await expect.poll(() => handle.element().getBoundingClientRect().x).toBe(dragged.x + 5)
+ expect(handle.element().getBoundingClientRect().y).toBe(dragged.y + 20)
+ await expect.element(handle).toHaveAttribute('aria-pressed', 'false')
+})
diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx
index fcf6acea2d3..92cf9421d77 100644
--- a/web/app/components/workflow/comment/thread.tsx
+++ b/web/app/components/workflow/comment/thread.tsx
@@ -236,15 +236,17 @@ export const CommentThread: FC
= memo(
[setCommentPreviewHovering],
)
- // P0: Auto-focus reply input when thread opens or comment changes
+ const canReply = Boolean(onReply)
+
+ // Focus on thread transitions, not callback changes after position updates.
useEffect(() => {
const timer = setTimeout(() => {
- if (replyInputRef.current && !editingReply.id && !isCommentEditing && onReply)
+ if (replyInputRef.current && !editingReply.id && !isCommentEditing && canReply)
replyInputRef.current.focus()
}, 100)
return () => clearTimeout(timer)
- }, [comment.id, editingReply.id, isCommentEditing, onReply])
+ }, [comment.id, editingReply.id, isCommentEditing, canReply])
// P2: Handle Esc key to close thread
useEffect(() => {
diff --git a/web/app/components/workflow/hooks/__tests__/use-node-keyboard-interactions.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-node-keyboard-interactions.spec.tsx
new file mode 100644
index 00000000000..0fb4073eefb
--- /dev/null
+++ b/web/app/components/workflow/hooks/__tests__/use-node-keyboard-interactions.spec.tsx
@@ -0,0 +1,201 @@
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { createNode } from '../../__tests__/fixtures'
+import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import { collaborationManager } from '../../collaboration/core/collaboration-manager'
+import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constants'
+import { BlockEnum, ControlMode } from '../../types'
+import { useNodeKeyboardInteractions } from '../use-node-keyboard-interactions'
+
+const state = vi.hoisted(() => ({
+ readonly: false,
+ sync: vi.fn(),
+ history: vi.fn(),
+ select: vi.fn(),
+}))
+vi.mock('reactflow', async () =>
+ (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock(),
+)
+vi.mock('../use-workflow', () => ({
+ useNodesReadOnly: () => ({ getNodesReadOnly: () => state.readonly }),
+}))
+vi.mock('../use-nodes-sync-draft', () => ({
+ useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: state.sync }),
+}))
+vi.mock('../use-workflow-history', () => ({
+ WorkflowHistoryEvent: { NodeDragStop: 'NodeDragStop' },
+ useWorkflowHistory: () => ({ saveStateToHistory: state.history }),
+}))
+
+function Canvas() {
+ const onKeyDownCapture = useNodeKeyboardInteractions(state.select)
+ return (
+
+
+ Node
+
+
+
+
+
+ )
+}
+
+describe('node keyboard interactions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetReactFlowMockState()
+ vi.spyOn(collaborationManager, 'canApplyLocalGraphMutation').mockReturnValue(true)
+ vi.spyOn(collaborationManager, 'setNodes').mockImplementation(() => {})
+ state.readonly = false
+ rfState.nodes = [
+ createNode({ id: 'node', selected: true, position: { x: 0, y: 0 }, width: 100, height: 80 }),
+ ]
+ rfState.setNodes.mockImplementation((nodes) => {
+ rfState.nodes = nodes
+ })
+ })
+
+ it('moves from the origin, broadcasts the old and new positions, and saves undo history', async () => {
+ const user = userEvent.setup()
+ renderWorkflowComponent()
+ await user.tab()
+ await user.keyboard('{ArrowRight}{Shift>}{ArrowDown}{/Shift}')
+ expect(rfState.nodes[0]!.position).toEqual({ x: 5, y: 20 })
+ expect(collaborationManager.setNodes).toHaveBeenLastCalledWith(
+ expect.arrayContaining([expect.objectContaining({ position: { x: 5, y: 0 } })]),
+ expect.arrayContaining([expect.objectContaining({ position: { x: 5, y: 20 } })]),
+ 'keyboard-node-movement',
+ )
+ expect(state.sync).toHaveBeenCalledTimes(2)
+ expect(state.history).toHaveBeenLastCalledWith('NodeDragStop', { nodeId: 'node' })
+ })
+
+ it('selects and deselects the focused node while leaving editor keys alone', async () => {
+ const user = userEvent.setup()
+ renderWorkflowComponent()
+ await user.tab()
+ await user.keyboard('{Enter}{Escape}')
+ expect(state.select.mock.calls).toEqual([
+ ['node', false],
+ ['node', true],
+ ])
+ await user.click(screen.getByRole('textbox', { name: 'Node title' }))
+ await user.keyboard('{ArrowDown}')
+ expect(state.sync).not.toHaveBeenCalled()
+ })
+
+ it('moves a Tab-focused unselected node without moving a separate selection', async () => {
+ rfState.nodes = [
+ createNode({ id: 'node', selected: false, position: { x: 0, y: 0 } }),
+ createNode({ id: 'other', selected: true, position: { x: 100, y: 100 } }),
+ ]
+ const user = userEvent.setup()
+ renderWorkflowComponent()
+ await user.tab()
+ await user.keyboard('{ArrowRight}')
+ expect(rfState.nodes.map((node) => node.position)).toEqual([
+ { x: 5, y: 0 },
+ { x: 100, y: 100 },
+ ])
+ expect(state.sync).toHaveBeenCalledOnce()
+ expect(state.history).toHaveBeenCalledWith('NodeDragStop', { nodeId: 'node' })
+ })
+
+ it('moves from the title button through collaboration and keeps its native activation', async () => {
+ const user = userEvent.setup()
+ renderWorkflowComponent()
+ await user.click(screen.getByRole('button', { name: 'Select node' }))
+ await user.keyboard('{ArrowRight}')
+ expect(rfState.nodes[0]!.position).toEqual({ x: 5, y: 0 })
+ expect(collaborationManager.setNodes).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ position: { x: 0, y: 0 } })]),
+ expect.arrayContaining([expect.objectContaining({ position: { x: 5, y: 0 } })]),
+ 'keyboard-node-movement',
+ )
+ expect(state.sync).toHaveBeenCalledOnce()
+ state.select.mockClear()
+ await user.keyboard('{Enter} ')
+ expect(state.select.mock.calls).toEqual([['node'], ['node']])
+ })
+
+ it('keeps selected children stationary relative to a moving selected container', async () => {
+ rfState.nodes = [
+ createNode({
+ id: 'node',
+ selected: true,
+ data: { type: BlockEnum.Iteration },
+ position: { x: 100, y: 100 },
+ }),
+ createNode({ id: 'child', selected: true, parentId: 'node', position: { x: 30, y: 60 } }),
+ createNode({ id: 'other', selected: true, position: { x: 500, y: 500 } }),
+ ]
+ const user = userEvent.setup()
+ renderWorkflowComponent()
+ await user.tab()
+ await user.tab()
+ await user.tab()
+ await user.keyboard('{ArrowRight}')
+ expect(rfState.nodes.map((node) => node.position)).toEqual([
+ { x: 105, y: 100 },
+ { x: 30, y: 60 },
+ { x: 505, y: 500 },
+ ])
+ })
+
+ it.each(['iteration', 'loop'] as const)(
+ 'clamps a child to its %s container and skips empty history at the boundary',
+ async (type) => {
+ rfState.nodes = [
+ createNode({
+ id: 'parent',
+ width: 300,
+ height: 250,
+ data: { type: type === 'iteration' ? BlockEnum.Iteration : BlockEnum.Loop },
+ }),
+ createNode({
+ id: 'node',
+ parentId: 'parent',
+ selected: true,
+ width: 100,
+ height: 80,
+ position: { x: 184, y: 150 },
+ data: { isInIteration: type === 'iteration', isInLoop: type === 'loop' },
+ }),
+ ]
+ const user = userEvent.setup()
+ renderWorkflowComponent()
+ await user.tab()
+ await user.keyboard('{ArrowRight}{ArrowDown}')
+ expect(rfState.nodes[1]!.position).toEqual({ x: 184, y: 150 })
+ expect(state.sync).not.toHaveBeenCalled()
+ },
+ )
+
+ it.each(['readonly', 'comment', 'locked', 'start'] as const)(
+ 'does not move in %s state',
+ async (mode) => {
+ state.readonly = mode === 'readonly'
+ if (mode === 'locked') Object.assign(rfState.nodes[0]!, { draggable: false })
+ if (mode === 'start') Object.assign(rfState.nodes[0]!, { type: CUSTOM_ITERATION_START_NODE })
+ const user = userEvent.setup()
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ controlMode: mode === 'comment' ? ControlMode.Comment : ControlMode.Pointer,
+ },
+ })
+ await user.tab()
+ await user.keyboard('{ArrowRight}')
+ expect(state.sync).not.toHaveBeenCalled()
+ expect(rfState.nodes[0]!.position).toEqual({ x: 0, y: 0 })
+ },
+ )
+})
diff --git a/web/app/components/workflow/hooks/use-node-keyboard-interactions.ts b/web/app/components/workflow/hooks/use-node-keyboard-interactions.ts
new file mode 100644
index 00000000000..cd09cb11b07
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-node-keyboard-interactions.ts
@@ -0,0 +1,123 @@
+import type { KeyboardEvent } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useStoreApi } from 'reactflow'
+import { collaborationManager } from '../collaboration/core/collaboration-manager'
+import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
+import { getRestrictedIterationPosition } from '../nodes/iteration/use-interactions.helpers'
+import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
+import { getRestrictedLoopPosition } from '../nodes/loop/use-interactions.helpers'
+import { useWorkflowStore } from '../store'
+import { BlockEnum, ControlMode } from '../types'
+import { getKeyboardMovement } from '../utils/keyboard-movement'
+import { useCollaborativeWorkflow } from './use-collaborative-workflow'
+import { useNodesSyncDraft } from './use-nodes-sync-draft'
+import { useNodesReadOnly } from './use-workflow'
+import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history'
+
+export function useNodeKeyboardInteractions(onSelect: (id: string, cancel?: boolean) => void) {
+ const store = useStoreApi()
+ const workflowStore = useWorkflowStore()
+ const workflow = useCollaborativeWorkflow()
+ const { getNodesReadOnly } = useNodesReadOnly()
+ const { handleSyncWorkflowDraft } = useNodesSyncDraft()
+ const { saveStateToHistory } = useWorkflowHistory()
+ const { t } = useTranslation('workflow')
+
+ return (event: KeyboardEvent) => {
+ const target = event.target
+ if (!(target instanceof HTMLElement)) return
+ const isNodeTitle = target.hasAttribute('data-node-keyboard-target')
+ const nodeTarget = isNodeTitle ? target.closest('.react-flow__node') : target
+ const isNode = nodeTarget?.classList.contains('react-flow__node')
+ const isSelection = target.classList.contains('react-flow__nodesselection-rect')
+ if (!isNode && !isSelection) return
+ // The title button owns its normal click activation.
+ if (isNodeTitle && (event.key === 'Enter' || event.key === ' ')) return
+ const movement = getKeyboardMovement(event)
+ const isMovementKey = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)
+ const isSelectionKey = ['Enter', ' ', 'Escape'].includes(event.key)
+ if (!isMovementKey && !isSelectionKey) return
+
+ // React Flow 11 mutates its internal nodes before onNodesChange. Handle these
+ // keys before that mutation so collaboration receives the old and new positions.
+ event.preventDefault()
+ event.stopPropagation()
+ if (event.altKey || event.ctrlKey || event.metaKey) return
+ if (getNodesReadOnly() || workflowStore.getState().controlMode === ControlMode.Comment) return
+ const { nodes, setNodes } = workflow.getState()
+ const focusedNode = isNode
+ ? nodes.find((node) => node.id === nodeTarget?.dataset.id)
+ : undefined
+ if (isNode && !focusedNode) return
+ if (
+ focusedNode &&
+ (focusedNode.type === CUSTOM_ITERATION_START_NODE ||
+ focusedNode.type === CUSTOM_LOOP_START_NODE ||
+ focusedNode.data.type === BlockEnum.DataSourceEmpty)
+ )
+ return
+
+ if (isSelectionKey) {
+ if (focusedNode) onSelect(focusedNode.id, event.key === 'Escape')
+ return
+ }
+ if (!movement) return
+ if (!collaborationManager.canApplyLocalGraphMutation()) return
+
+ const movingIds = new Set(
+ nodes
+ .filter(
+ (node) =>
+ (focusedNode && !focusedNode.selected ? node.id === focusedNode.id : node.selected) &&
+ node.draggable !== false &&
+ node.type !== CUSTOM_ITERATION_START_NODE &&
+ node.type !== CUSTOM_LOOP_START_NODE &&
+ node.data.type !== BlockEnum.DataSourceEmpty,
+ )
+ .map((node) => node.id),
+ )
+ let moved = false
+ const nextNodes = nodes.map((node) => {
+ if (!movingIds.has(node.id)) return node
+ // A selected container already moves its descendants in canvas coordinates.
+ let parent = nodes.find((candidate) => candidate.id === node.parentId)
+ while (parent) {
+ if (movingIds.has(parent.id)) return node
+ parent = nodes.find((candidate) => candidate.id === parent?.parentId)
+ }
+ const next = {
+ ...node,
+ position: { x: node.position.x + movement.x, y: node.position.y + movement.y },
+ }
+ const parentNode = nodes.find((candidate) => candidate.id === node.parentId)
+ const iteration = getRestrictedIterationPosition(next, parentNode)
+ const loop = getRestrictedLoopPosition(next, parentNode)
+ next.position = {
+ x: iteration.x ?? loop.x ?? next.position.x,
+ y: iteration.y ?? loop.y ?? next.position.y,
+ }
+ if (next.position.x === node.position.x && next.position.y === node.position.y) return node
+ moved = true
+ return next
+ })
+ if (!moved) return
+ workflowStore.setState({ nodeAnimation: false })
+ setNodes(nextNodes, true, 'keyboard-node-movement')
+ handleSyncWorkflowDraft()
+ saveStateToHistory(
+ WorkflowHistoryEvent.NodeDragStop,
+ focusedNode ? { nodeId: focusedNode.id } : undefined,
+ )
+ const announcedNode =
+ nextNodes.find((node) => node.id === focusedNode?.id) ??
+ nextNodes.find((node) => movingIds.has(node.id))
+ if (announcedNode)
+ store.setState({
+ ariaLiveMessage: t(($) => $['keyboard.nodeMoved'], {
+ title: announcedNode.data.title,
+ x: Math.round(announcedNode.position.x),
+ y: Math.round(announcedNode.position.y),
+ }),
+ })
+ }
+}
diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx
index 3698ac1abe9..f754330386f 100644
--- a/web/app/components/workflow/index.tsx
+++ b/web/app/components/workflow/index.tsx
@@ -72,6 +72,7 @@ import HelpLine from './help-line'
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
import { useEdgesInteractions } from './hooks/use-edges-interactions'
import { useLocateNode } from './hooks/use-locate-node'
+import { useNodeKeyboardInteractions } from './hooks/use-node-keyboard-interactions'
import { useNodesInteractions } from './hooks/use-nodes-interactions'
import { useNodesSyncDraft } from './hooks/use-nodes-sync-draft'
import { usePanelInteractions } from './hooks/use-panel-interactions'
@@ -559,6 +560,7 @@ export const Workflow: FC = memo(
handleNodeEnter,
handleNodeLeave,
handleNodeClick,
+ handleNodeSelect,
handleNodeConnect,
handleNodeConnectStart,
handleNodeConnectEnd,
@@ -566,6 +568,7 @@ export const Workflow: FC = memo(
handleHistoryBack,
handleHistoryForward,
} = useNodesInteractions()
+ const handleNodeKeyDown = useNodeKeyboardInteractions(handleNodeSelect)
const { handleEdgeEnter, handleEdgeLeave, handleEdgesChange, handleEdgeContextMenu } =
useEdgesInteractions()
const {
@@ -767,6 +770,7 @@ export const Workflow: FC = memo(
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
+ onKeyDownCapture={handleNodeKeyDown}
className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''}
onNodeDragStart={handleNodeDragStart}
onNodeDrag={handleNodeDrag}
diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx
index 61b255cd095..c5d23dfdaba 100644
--- a/web/app/components/workflow/nodes/_base/node.tsx
+++ b/web/app/components/workflow/nodes/_base/node.tsx
@@ -244,6 +244,7 @@ const BaseNode: FC = ({ id, data, children }) => {
>