From d2aab68ae7d803733c2e709ac85099f36e3a2ef0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:37:19 -0700 Subject: [PATCH] Automations ux improvement (#17626) * Add keyboard navigation to automations UI Improves workflow efficiency by enabling keyboard-driven navigation across automations list, run history, and detail pane tabs. * Add Escape key support to automations detail pane Pressing Escape now clears external and automation run page views, then returns to the automations list. Also improves cross-browser compatibility of keyboard event handling by using Element checks and getAttribute instead of dataset access. * Fix keyboard navigation to let Enter key reach focused controls - Enter key now passes through to focused buttons, links, and other interactive controls - Arrow key navigation through automation run history still works - Prevents intercepting native keyboard behavior of interactive elements * improve test * Move keyboard focus to follow row selection When navigating automation runs with arrow keys, focus must follow the selection so Enter key acts on the newly selected row rather than the previously focused one. --- .../install-electron-package-binary.test.mjs | 4 +- .../pty-login-shell-startup-commands.test.ts | 4 +- ...-spawn-env-codex-resume-provenance.test.ts | 4 +- .../AutomationListSearchField.test.tsx | 33 +++ .../automations/AutomationListSearchField.tsx | 8 + .../automations/AutomationRunHistory.test.tsx | 106 ++++++++ .../automations/AutomationRunHistory.tsx | 63 ++++- .../AutomationsDetailPane.test.tsx | 229 ++++++++++++++++++ .../automations/AutomationsDetailPane.tsx | 52 ++++ .../automations/AutomationsListPanel.test.tsx | 160 +++++++++++- .../automations/AutomationsListPanel.tsx | 11 + .../automations/AutomationsPage.tsx | 41 ++-- .../automation-detail-tab-navigation.test.ts | 207 ++++++++++++++++ .../automation-detail-tab-navigation.ts | 109 +++++++++ ...utomation-list-keyboard-navigation.test.ts | 93 ++++++- .../automation-list-keyboard-navigation.ts | 65 +++++ ...on-run-history-keyboard-navigation.test.ts | 169 +++++++++++++ ...omation-run-history-keyboard-navigation.ts | 74 ++++++ 18 files changed, 1392 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/components/automations/AutomationsDetailPane.test.tsx create mode 100644 src/renderer/src/components/automations/automation-detail-tab-navigation.test.ts create mode 100644 src/renderer/src/components/automations/automation-detail-tab-navigation.ts create mode 100644 src/renderer/src/components/automations/automation-run-history-keyboard-navigation.test.ts create mode 100644 src/renderer/src/components/automations/automation-run-history-keyboard-navigation.ts diff --git a/config/scripts/install-electron-package-binary.test.mjs b/config/scripts/install-electron-package-binary.test.mjs index caef587b782..0bfd90d612b 100644 --- a/config/scripts/install-electron-package-binary.test.mjs +++ b/config/scripts/install-electron-package-binary.test.mjs @@ -250,7 +250,9 @@ describe('install-electron-package-binary', () => { expect(result.status, result.stderr).toBe(0) expect(existsSync(join(cacheRoot, 'preserved.marker'))).toBe(true) - expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')).toHaveLength(2) + expect( + readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n') + ).toHaveLength(2) } finally { rmSync(projectDir, { recursive: true, force: true }) } diff --git a/src/main/ipc/pty-login-shell-startup-commands.test.ts b/src/main/ipc/pty-login-shell-startup-commands.test.ts index 1715e690cfc..7ccb0386b9f 100644 --- a/src/main/ipc/pty-login-shell-startup-commands.test.ts +++ b/src/main/ipc/pty-login-shell-startup-commands.test.ts @@ -361,7 +361,9 @@ describe('registerPtyHandlers', () => { const [, , options] = spawnMock.mock.calls[0]! expect(options.env.ORCA_SHELL_FEATURES).toContain('ready') - expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe("codex --prefill 'linked issue context'") + expect(options.env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe( + "codex --prefill 'linked issue context'" + ) expect(mockProc.proc.write).not.toHaveBeenCalled() mockProc.emitData('\x1b]777;orca-shell-ready\x07') diff --git a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts index 82c576edd05..a2a6153e99a 100644 --- a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts @@ -217,9 +217,7 @@ describe('registerPtyHandlers', () => { expect(spawned.agentResumeUnavailable).toBeUndefined() const env = spawnMock.mock.calls.at(-1)![2].env as Record expect(env.CODEX_HOME).toBe(ORIGIN_HOME) - expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe( - `codex 'resume' '${RESUME_SESSION_ID}'` - ) + expect(env[POSIX_SHELL_STARTUP_COMMAND_ENV]).toBe(`codex 'resume' '${RESUME_SESSION_ID}'`) expect(selectedHome).not.toHaveBeenCalled() } finally { vi.useRealTimers() diff --git a/src/renderer/src/components/automations/AutomationListSearchField.test.tsx b/src/renderer/src/components/automations/AutomationListSearchField.test.tsx index 4210d6d0fd1..0f3969bd9b6 100644 --- a/src/renderer/src/components/automations/AutomationListSearchField.test.tsx +++ b/src/renderer/src/components/automations/AutomationListSearchField.test.tsx @@ -80,4 +80,37 @@ describe('AutomationListSearchField', () => { expect(escape.defaultPrevented).toBe(true) expect(onClear).toHaveBeenCalledTimes(1) }) + + it('routes Enter into onEnter and ignores modified or composing Enter', () => { + const onEnter = vi.fn() + act(() => { + root.render( + undefined} + onClear={() => undefined} + onEnter={onEnter} + /> + ) + }) + + const input = container.querySelector('input') + expect(input).not.toBeNull() + + const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + input?.dispatchEvent(enter) + expect(enter.defaultPrevented).toBe(true) + expect(onEnter).toHaveBeenCalledTimes(1) + + const shiftEnter = new KeyboardEvent('keydown', { + key: 'Enter', + shiftKey: true, + bubbles: true, + cancelable: true + }) + input?.dispatchEvent(shiftEnter) + expect(shiftEnter.defaultPrevented).toBe(false) + expect(onEnter).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx index 865f96bb4da..750ff2e1415 100644 --- a/src/renderer/src/components/automations/AutomationListSearchField.tsx +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils' import { isAutomationListArrowKey, shouldHandleAutomationListSearchArrowKey, + shouldHandleAutomationListSearchEnterKey, type AutomationListArrowKey } from './automation-list-keyboard-navigation' @@ -16,6 +17,7 @@ type AutomationListSearchFieldProps = { onQueryChange: (query: string) => void onClear: () => void onArrowNavigate?: (key: AutomationListArrowKey) => void + onEnter?: () => void className?: string } @@ -25,6 +27,7 @@ export function AutomationListSearchField({ onQueryChange, onClear, onArrowNavigate, + onEnter, className }: AutomationListSearchFieldProps): React.JSX.Element { const inputRef = useRef(null) @@ -74,6 +77,11 @@ export function AutomationListSearchField({ onArrowNavigate(event.key) return } + if (onEnter && shouldHandleAutomationListSearchEnterKey(event)) { + event.preventDefault() + onEnter() + return + } if (event.key !== 'Escape' || event.nativeEvent.isComposing) { return } diff --git a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx index 41d839e615a..d835d530890 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.test.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.test.tsx @@ -131,3 +131,109 @@ describe('AutomationRunHistory unanswered history', () => { expect(onRecoverHistory).toHaveBeenCalledWith('reconnect') }) }) + +describe('AutomationRunHistory keyboard navigation', () => { + it('navigates runs with ArrowDown and ArrowUp and opens on Enter', async () => { + const onOpenRun = vi.fn() + const run1 = makeRun({ id: 'run-1', scheduledFor: FIRST }) + const run2 = makeRun({ id: 'run-2', scheduledFor: LATEST }) + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + await act(async () => { + root.render( + + ) + }) + + const buttons = container.querySelectorAll('button[data-automation-run-id]') + expect(buttons[0].getAttribute('data-current')).toBe('true') + expect(buttons[1].getAttribute('data-current')).toBe('false') + + // Press ArrowDown + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }) + ) + }) + + expect(buttons[0].getAttribute('data-current')).toBe('false') + expect(buttons[1].getAttribute('data-current')).toBe('true') + + // Press Enter to open selected run + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + }) + + expect(onOpenRun).toHaveBeenCalledWith(run2) + + // Press ArrowUp + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true, cancelable: true }) + ) + }) + + expect(buttons[0].getAttribute('data-current')).toBe('true') + expect(buttons[1].getAttribute('data-current')).toBe('false') + + // Press Enter to open first run + await act(async () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + }) + + expect(onOpenRun).toHaveBeenCalledWith(run1) + }) + + it('moves focus with the selection so Enter reaches the selected row, not the old one', async () => { + const onOpenRun = vi.fn() + const run1 = makeRun({ id: 'run-1', scheduledFor: FIRST }) + const run2 = makeRun({ id: 'run-2', scheduledFor: LATEST }) + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + await act(async () => { + root.render( + + ) + }) + + const buttons = container.querySelectorAll('button[data-automation-run-id]') + buttons[0].focus() + expect(document.activeElement).toBe(buttons[0]) + + await act(async () => { + buttons[0].dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }) + ) + }) + + expect(buttons[1].getAttribute('data-current')).toBe('true') + expect(document.activeElement).toBe(buttons[1]) + + // Enter is passed through to the focused row, which must now be the selected one. + ;(document.activeElement as HTMLButtonElement).click() + expect(onOpenRun).toHaveBeenCalledTimes(1) + expect(onOpenRun).toHaveBeenCalledWith(run2) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index 13395dae6ad..cc36d416f14 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -18,6 +18,11 @@ import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-dis import { AutomationOwnerConflictNotice } from './AutomationOwnerConflictNotice' import type { AutomationActionNotice } from './automation-row-action-dispatch' import type { AutomationHostRecoveryAction } from './automation-host-status-descriptors' +import { + getAutomationRunHistoryArrowTarget, + isAutomationRunHistoryArrowKey, + shouldHandleAutomationRunHistoryKey +} from './automation-run-history-keyboard-navigation' import { translate } from '@/i18n/i18n' type AutomationRunHistoryProps = { @@ -38,6 +43,7 @@ export function AutomationRunHistory({ onRecoverHistory, onOpenRun }: AutomationRunHistoryProps): React.JSX.Element { + const containerRef = React.useRef(null) const [selectedRunState, setSelectedRunState] = useState<{ automationId: string runId: string | null @@ -54,8 +60,62 @@ export function AutomationRunHistory({ selectedRunState.automationId === automationId ? selectedRunState.runId : null const selectedRun = runs.find((run) => run.id === selectedRunId) ?? runs[0] ?? null + const findRunRow = React.useCallback( + (runId: string): HTMLElement | null => + containerRef.current?.querySelector(`[data-automation-run-id="${runId}"]`) ?? + null, + [] + ) + + React.useEffect(() => { + if (runs.length === 0 || notice) { + return + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (!shouldHandleAutomationRunHistoryKey(event)) { + return + } + + if (event.key === 'Enter') { + if (selectedRun) { + event.preventDefault() + onOpenRun(selectedRun) + } + return + } + + if (isAutomationRunHistoryArrowKey(event.key)) { + const targetRun = getAutomationRunHistoryArrowTarget({ + runs, + selectedRunId: selectedRun?.id ?? null, + key: event.key + }) + if (targetRun) { + event.preventDefault() + setSelectedRunState({ automationId, runId: targetRun.id }) + // Enter is left to the focused control, so focus has to follow the selection. + findRunRow(targetRun.id)?.focus?.({ preventScroll: true }) + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [automationId, findRunRow, notice, onOpenRun, runs, selectedRun]) + + React.useEffect(() => { + if (!selectedRunId) { + return + } + const element = findRunRow(selectedRunId) + if (element && typeof element.scrollIntoView === 'function') { + element.scrollIntoView({ block: 'nearest' }) + } + }, [findRunRow, selectedRunId]) + return ( -
+
{translate('auto.components.automations.AutomationRunHistory.53fc5f07ab', 'Run history')} @@ -94,6 +154,7 @@ export function AutomationRunHistory({