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({