diff --git a/packages/docs-web/src/content/docs/adapters/web.md b/packages/docs-web/src/content/docs/adapters/web.md index 7a3aeebb86..0025ca0219 100644 --- a/packages/docs-web/src/content/docs/adapters/web.md +++ b/packages/docs-web/src/content/docs/adapters/web.md @@ -172,6 +172,7 @@ The Workflow Builder at `/workflows/builder` provides a visual editor for creati - **Command picker** -- Browse available commands when configuring command nodes - **Validation panel** -- Real-time validation feedback as you build - **Undo/redo** -- Full undo/redo stack with keyboard shortcuts +- **Delete node** -- Remove a selected node with `Delete` or `Backspace`, the Delete button in the inspector header, or the right-click context menu on any node - **Save** -- Saves the workflow YAML to your project's `.archon/workflows/` directory You can also browse existing workflows on the `/workflows` page and open any of them in the builder to edit. diff --git a/packages/web/package.json b/packages/web/package.json index 5b02e1bbe6..d94f57c5ae 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,7 +8,7 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "type-check": "tsc --noEmit", - "test": "bun test src/lib/ && bun test src/stores/", + "test": "bun test src/lib/ && bun test src/stores/ && bun test src/hooks/", "generate:types": "openapi-typescript http://localhost:3090/api/openapi.json -o src/lib/api.generated.d.ts" }, "dependencies": { diff --git a/packages/web/src/components/workflows/NodeInspector.tsx b/packages/web/src/components/workflows/NodeInspector.tsx index 1dfd797570..1d4748fecc 100644 --- a/packages/web/src/components/workflows/NodeInspector.tsx +++ b/packages/web/src/components/workflows/NodeInspector.tsx @@ -642,11 +642,9 @@ function JsonTextareaField({ function AdvancedTab({ node, onUpdate, - onDelete, }: { node: DagNodeData; onUpdate: (updates: Partial) => void; - onDelete: () => void; }): React.ReactElement { return (
@@ -696,12 +694,6 @@ function AdvancedTab({ onUpdate({ hooks: v }); }} /> - -
- -
); } @@ -718,14 +710,23 @@ function DagInspector({ return (
{/* Header */} -
- +
+ {node.label || node.id} +
diff --git a/packages/web/src/components/workflows/WorkflowCanvas.tsx b/packages/web/src/components/workflows/WorkflowCanvas.tsx index f784c67c4f..e1c6170b16 100644 --- a/packages/web/src/components/workflows/WorkflowCanvas.tsx +++ b/packages/web/src/components/workflows/WorkflowCanvas.tsx @@ -82,6 +82,7 @@ interface WorkflowCanvasProps { setNodes: React.Dispatch>; setEdges: React.Dispatch>; onNodeSelect: (nodeId: string | null) => void; + onNodeDelete: (nodeId: string) => void; onDirty: () => void; onPushSnapshot?: () => void; commands: CommandEntry[]; @@ -100,12 +101,19 @@ export function WorkflowCanvas({ setNodes, setEdges, onNodeSelect, + onNodeDelete, onDirty, onPushSnapshot, commands, }: WorkflowCanvasProps): React.ReactElement { const { screenToFlowPosition } = useReactFlow(); const [quickAddPosition, setQuickAddPosition] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + nodeId: string; + } | null>(null); + const contextMenuRef = useRef(null); const nodeTypes: NodeTypes = useMemo(() => ({ dagNode: dagNodeComponent }), []); @@ -164,10 +172,12 @@ export function WorkflowCanvas({ }, }; + onPushSnapshot?.(); setNodes(nds => [...nds, newNode]); + onNodeSelect(id); onDirty(); }, - [screenToFlowPosition, setNodes, onDirty] + [screenToFlowPosition, setNodes, onNodeSelect, onDirty, onPushSnapshot] ); // Track whether we've already pushed a snapshot for the current drag gesture @@ -278,17 +288,63 @@ export function WorkflowCanvas({ }, }; + onPushSnapshot?.(); setNodes(nds => [...nds, newNode]); + onNodeSelect(id); onDirty(); setQuickAddPosition(null); }, - [quickAddPosition, setNodes, onDirty] + [quickAddPosition, setNodes, onNodeSelect, onDirty, onPushSnapshot] ); const handleQuickAddClose = useCallback(() => { setQuickAddPosition(null); }, []); + // Approximate menu size used for viewport-edge clamping. + const CONTEXT_MENU_WIDTH = 160; + const CONTEXT_MENU_HEIGHT = 40; + + const handleNodeContextMenu = useCallback( + (e: React.MouseEvent, node: DagFlowNode) => { + e.preventDefault(); + onNodeSelect(node.id); + const x = Math.min(e.clientX, window.innerWidth - CONTEXT_MENU_WIDTH); + const y = Math.min(e.clientY, window.innerHeight - CONTEXT_MENU_HEIGHT); + setContextMenu({ x, y, nodeId: node.id }); + }, + [onNodeSelect] + ); + + // Dismiss the context menu on Escape or any click/contextmenu outside it. + useEffect(() => { + if (!contextMenu) return; + + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setContextMenu(null); + }; + const onClickOutside = (e: MouseEvent): void => { + if ( + contextMenuRef.current && + e.target instanceof Node && + contextMenuRef.current.contains(e.target) + ) { + return; + } + setContextMenu(null); + }; + + window.addEventListener('keydown', onKey); + // Use capture so we beat ReactFlow's own handlers and any stopPropagation. + window.addEventListener('mousedown', onClickOutside, true); + window.addEventListener('contextmenu', onClickOutside, true); + return (): void => { + window.removeEventListener('keydown', onKey); + window.removeEventListener('mousedown', onClickOutside, true); + window.removeEventListener('contextmenu', onClickOutside, true); + }; + }, [contextMenu]); + return (
{ onNodeSelect(node.id); }} + onNodeContextMenu={handleNodeContextMenu} onPaneClick={handlePaneClick} nodeTypes={nodeTypes} panOnDrag @@ -324,6 +381,25 @@ export function WorkflowCanvas({ commands={commands} /> )} + + {contextMenu && ( +
+ +
+ )}
); } diff --git a/packages/web/src/hooks/useBuilderKeyboard.test.ts b/packages/web/src/hooks/useBuilderKeyboard.test.ts new file mode 100644 index 0000000000..8239741657 --- /dev/null +++ b/packages/web/src/hooks/useBuilderKeyboard.test.ts @@ -0,0 +1,136 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { + handleBuilderKeydown, + isInputTarget, + type BuilderKeyboardActions, +} from './useBuilderKeyboard'; + +function makeActions(): BuilderKeyboardActions & { + calls: Record; +} { + const calls: Record = {}; + const bump = (name: string): (() => void) => { + return (): void => { + calls[name] = (calls[name] ?? 0) + 1; + }; + }; + return { + calls, + onSave: bump('onSave'), + onUndo: bump('onUndo'), + onRedo: bump('onRedo'), + onToggleLibrary: bump('onToggleLibrary'), + onToggleYaml: bump('onToggleYaml'), + onToggleValidation: bump('onToggleValidation'), + onAddPrompt: bump('onAddPrompt'), + onAddBash: bump('onAddBash'), + onDeleteSelected: bump('onDeleteSelected'), + onDuplicateSelected: bump('onDuplicateSelected'), + onQuickAdd: bump('onQuickAdd'), + onFitView: bump('onFitView'), + onSelectAll: bump('onSelectAll'), + }; +} + +function makeEvent( + key: string, + target: { tagName?: string; isContentEditable?: boolean; role?: string } | null +): KeyboardEvent { + const el = + target === null + ? null + : ({ + tagName: target.tagName ?? 'DIV', + isContentEditable: target.isContentEditable ?? false, + getAttribute: (name: string): string | null => + name === 'role' ? (target.role ?? null) : null, + } as unknown as HTMLElement); + return { + key, + target: el, + metaKey: false, + ctrlKey: false, + shiftKey: false, + preventDefault: mock(() => {}), + } as unknown as KeyboardEvent; +} + +describe('isInputTarget', () => { + test('returns true for INPUT, TEXTAREA, SELECT', () => { + expect(isInputTarget(makeEvent('a', { tagName: 'INPUT' }))).toBe(true); + expect(isInputTarget(makeEvent('a', { tagName: 'TEXTAREA' }))).toBe(true); + expect(isInputTarget(makeEvent('a', { tagName: 'SELECT' }))).toBe(true); + }); + + test('returns true for contentEditable elements', () => { + expect(isInputTarget(makeEvent('a', { tagName: 'DIV', isContentEditable: true }))).toBe(true); + }); + + test('returns true for ARIA editable roles (combobox, textbox, searchbox)', () => { + expect(isInputTarget(makeEvent('a', { tagName: 'DIV', role: 'combobox' }))).toBe(true); + expect(isInputTarget(makeEvent('a', { tagName: 'DIV', role: 'textbox' }))).toBe(true); + expect(isInputTarget(makeEvent('a', { tagName: 'DIV', role: 'searchbox' }))).toBe(true); + }); + + test('returns false for regular elements without editable role', () => { + expect(isInputTarget(makeEvent('a', { tagName: 'DIV' }))).toBe(false); + expect(isInputTarget(makeEvent('a', { tagName: 'BUTTON' }))).toBe(false); + expect(isInputTarget(makeEvent('a', { tagName: 'DIV', role: 'menu' }))).toBe(false); + }); + + test('returns false when target is null', () => { + expect(isInputTarget(makeEvent('a', null))).toBe(false); + }); +}); + +describe('handleBuilderKeydown — delete invariant', () => { + let actions: ReturnType; + + beforeEach(() => { + actions = makeActions(); + }); + + test('Delete key on canvas triggers onDeleteSelected', () => { + handleBuilderKeydown(makeEvent('Delete', { tagName: 'DIV' }), actions); + expect(actions.calls.onDeleteSelected).toBe(1); + }); + + test('Backspace key on canvas triggers onDeleteSelected', () => { + handleBuilderKeydown(makeEvent('Backspace', { tagName: 'DIV' }), actions); + expect(actions.calls.onDeleteSelected).toBe(1); + }); + + test('Backspace in INPUT does NOT trigger onDeleteSelected', () => { + handleBuilderKeydown(makeEvent('Backspace', { tagName: 'INPUT' }), actions); + expect(actions.calls.onDeleteSelected).toBeUndefined(); + }); + + test('Backspace in TEXTAREA does NOT trigger onDeleteSelected', () => { + handleBuilderKeydown(makeEvent('Backspace', { tagName: 'TEXTAREA' }), actions); + expect(actions.calls.onDeleteSelected).toBeUndefined(); + }); + + test('Backspace in contentEditable does NOT trigger onDeleteSelected', () => { + handleBuilderKeydown( + makeEvent('Backspace', { tagName: 'DIV', isContentEditable: true }), + actions + ); + expect(actions.calls.onDeleteSelected).toBeUndefined(); + }); + + test('Backspace in ARIA combobox does NOT trigger onDeleteSelected', () => { + handleBuilderKeydown(makeEvent('Backspace', { tagName: 'DIV', role: 'combobox' }), actions); + expect(actions.calls.onDeleteSelected).toBeUndefined(); + }); + + test('Delete in ARIA textbox does NOT trigger onDeleteSelected', () => { + handleBuilderKeydown(makeEvent('Delete', { tagName: 'DIV', role: 'textbox' }), actions); + expect(actions.calls.onDeleteSelected).toBeUndefined(); + }); + + test('enabled=false suppresses all shortcuts', () => { + handleBuilderKeydown(makeEvent('Delete', { tagName: 'DIV' }), actions, false); + handleBuilderKeydown(makeEvent('Backspace', { tagName: 'DIV' }), actions, false); + expect(actions.calls.onDeleteSelected).toBeUndefined(); + }); +}); diff --git a/packages/web/src/hooks/useBuilderKeyboard.ts b/packages/web/src/hooks/useBuilderKeyboard.ts index 192f29bd2b..89343331bd 100644 --- a/packages/web/src/hooks/useBuilderKeyboard.ts +++ b/packages/web/src/hooks/useBuilderKeyboard.ts @@ -1,6 +1,6 @@ import { useEffect, useCallback } from 'react'; -interface BuilderKeyboardActions { +export interface BuilderKeyboardActions { onSave: () => void; onUndo: () => void; onRedo: () => void; @@ -16,97 +16,113 @@ interface BuilderKeyboardActions { onSelectAll?: () => void; } -function isInputTarget(e: KeyboardEvent): boolean { - const tag = (e.target as HTMLElement).tagName; - return ( - tag === 'INPUT' || - tag === 'TEXTAREA' || - tag === 'SELECT' || - (e.target as HTMLElement).isContentEditable - ); +const EDITABLE_ARIA_ROLES = new Set(['combobox', 'textbox', 'searchbox']); + +export function isInputTarget(e: KeyboardEvent): boolean { + const target = e.target as HTMLElement | null; + if (!target) return false; + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + if (target.isContentEditable) return true; + const role = target.getAttribute?.('role'); + if (role && EDITABLE_ARIA_ROLES.has(role)) return true; + return false; } -export function useBuilderKeyboard(actions: BuilderKeyboardActions, enabled = true): void { - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (!enabled) return; +export function handleBuilderKeydown( + e: KeyboardEvent, + actions: BuilderKeyboardActions, + enabled = true +): void { + if (!enabled) return; - const mod = e.metaKey || e.ctrlKey; - const inInput = isInputTarget(e); + const mod = e.metaKey || e.ctrlKey; + const inInput = isInputTarget(e); - // --- Always-active shortcuts (even in inputs) --- - if (mod) { - if (e.key === 's') { - e.preventDefault(); - actions.onSave(); - return; - } - if (e.key === 'z' && e.shiftKey) { - e.preventDefault(); - actions.onRedo(); - return; - } - if (e.key === 'z') { - e.preventDefault(); - actions.onUndo(); - return; - } - if (e.key === '\\') { - e.preventDefault(); - actions.onToggleLibrary(); - return; - } - if (e.key === 'j') { - e.preventDefault(); - actions.onToggleYaml(); - return; - } - if (e.key === '.') { - e.preventDefault(); - actions.onToggleValidation(); - return; - } - } + // --- Always-active shortcuts (even in inputs) --- + if (mod) { + if (e.key === 's') { + e.preventDefault(); + actions.onSave(); + return; + } + if (e.key === 'z' && e.shiftKey) { + e.preventDefault(); + actions.onRedo(); + return; + } + if (e.key === 'z') { + e.preventDefault(); + actions.onUndo(); + return; + } + if (e.key === '\\') { + e.preventDefault(); + actions.onToggleLibrary(); + return; + } + if (e.key === 'j') { + e.preventDefault(); + actions.onToggleYaml(); + return; + } + if (e.key === '.') { + e.preventDefault(); + actions.onToggleValidation(); + return; + } + } - // --- Only when NOT in input/textarea --- - if (inInput) return; + // --- Only when NOT in input/textarea --- + if (inInput) return; - if (mod) { - if (e.key === 'd') { - e.preventDefault(); - actions.onDuplicateSelected(); - return; - } - if (e.key === '0') { - e.preventDefault(); - actions.onFitView?.(); - return; - } - if (e.key === 'a') { - e.preventDefault(); - actions.onSelectAll?.(); - return; - } - } + if (mod) { + if (e.key === 'd') { + e.preventDefault(); + actions.onDuplicateSelected(); + return; + } + if (e.key === '0') { + e.preventDefault(); + actions.onFitView?.(); + return; + } + if (e.key === 'a') { + e.preventDefault(); + actions.onSelectAll?.(); + return; + } + } - // Single-key shortcuts - switch (e.key) { - case 'n': - actions.onQuickAdd?.(); - break; - case 'p': - actions.onAddPrompt(); - break; - case 'b': - actions.onAddBash(); - break; - case 'Delete': - actions.onDeleteSelected(); - break; - case 'f': - actions.onFitView?.(); - break; - } + // Single-key shortcuts + switch (e.key) { + case 'n': + actions.onQuickAdd?.(); + break; + case 'p': + actions.onAddPrompt(); + break; + case 'b': + actions.onAddBash(); + break; + case 'Delete': + case 'Backspace': + // Backspace is the natural delete key on macOS keyboards, which lack + // a dedicated Delete key. The isInputTarget() guard above prevents + // this from interfering with text fields. + e.preventDefault(); + actions.onDeleteSelected(); + break; + case 'f': + actions.onFitView?.(); + break; + } +} + +export function useBuilderKeyboard(actions: BuilderKeyboardActions, enabled = true): void { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + handleBuilderKeydown(e, actions, enabled); }, [actions, enabled] );