Files
orca/mobile/src/session/QuickCommandsSheet.test.ts
T
Brennan Benson 5fcf777617 feat(mobile): Quick Commands (terminal + agent-prompt presets) (#9298)
* feat(mobile): add Quick Commands (terminal + agent-prompt presets)

Brings the desktop Terminal Quick Commands feature to mobile: saved
agent-prompt or terminal-command presets that launch a new terminal tab.

Entry point sits in the session tab strip next to the "+" new-terminal
button (with a divider) — quick commands spawn a tab, so they live with
tab creation, mirroring desktop's tab-bar split button.

- Launcher button + Quick Commands bottom sheet (search, This project /
  Global groups, run/edit/delete rows, add row).
- Add/Edit sheet mirroring desktop TerminalQuickCommandDialog: Label,
  Action toggle (Terminal Command | Agent Prompt), Agent select, Prompt /
  Command Text, Advanced (Append Enter, Scope Global/Project), validation
  and save-failure feedback.
- Launch reuses handleCreateTerminal (extended with enter + toast copy):
  agent prompts launch the agent then deliver the prompt; terminal
  commands run the (Enter-appended) command text.
- Expose terminalQuickCommands over the remote/mobile RPC surface
  (getClientSettings/updateClientSettings allowlists, RuntimeStore type,
  and the strict SettingsUpdate zod schema).
- Mirror the agent-prompt support predicate mobile-side (stdin-after-start
  agents are unsupported) with a parity test guarding drift from desktop.
- Mock server: sample quick commands + settings.update handler for QA.

* fix(mobile): harden quick command execution

* fix(mobile): harden quick command persistence and launch

* test(mobile): preserve unexpected quick command errors

* fix(mobile): harden quick command launch performance

* fix(runtime): reject malformed quick command updates

* refactor(mobile): reuse shared quick-command logic instead of mirroring

The mobile quick-commands mirror was built on a false premise — that
runtime-importing src/shared/terminal-quick-commands breaks the RN bundle
/ Vitest. It doesn't: tui-agent-config → orca-cli-command-name is a pure
leaf with no module-load Node APIs (verified via probe + bundle-graph).

- Mobile now reuses the canonical desktop helpers (action/agent/scope/
  matchesRepo/support/flatten) directly from src/shared; only genuinely
  mobile-specific pieces (agent-branded labels, native row truncation,
  the launch plan) stay local.
- Multiline runnable terminal commands now flatten via the shared
  flattenTerminalQuickCommand (";"-join) — unity with desktop, so a
  command saved on one runs identically on the other.
- Drop the MOBILE_TUI_AGENT_PROMPT_COMMAND_UNSUPPORTED mirror + its parity
  test; use the shared supportsTerminalAgentQuickCommand predicate.
- Export the shared MAX_QUICK_COMMAND_* length caps for reuse.

* fix(mobile): protect quick command data boundaries

* fix(mobile): enforce quick command limits

* fix(mobile): make quick command updates atomic

* fix(mobile): keep quick command filters recoverable

* fix(mobile): use filled play icon for quick commands

* Revert "fix(mobile): use filled play icon for quick commands"

This reverts commit 169bf053b0.

* fix(mobile): gate quick commands on host capability
2026-07-19 23:42:31 -07:00

226 lines
6.6 KiB
TypeScript

import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import type { TerminalQuickCommand } from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands'
import { QuickCommandsSheet } from './QuickCommandsSheet'
const mocks = vi.hoisted(() => ({
alert: vi.fn(),
commands: [] as TerminalQuickCommand[],
persist: vi.fn()
}))
const quickCommandEditorForm = 'QuickCommandEditorForm'
const quickCommandsList = 'QuickCommandsList'
const command: TerminalQuickCommand = {
id: 'command',
label: 'Test',
action: 'terminal-command',
command: 'pnpm test',
appendEnter: true,
scope: { type: 'global' }
}
vi.mock('react-native', () => ({
Alert: { alert: mocks.alert },
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ ChevronLeft: 'ChevronLeft' }))
vi.mock('../components/BottomDrawer', () => ({
BottomDrawer: ({ children }: { children: ReactNode }) => children
}))
vi.mock('./QuickCommandEditorForm', () => ({
QuickCommandEditorForm: 'QuickCommandEditorForm'
}))
vi.mock('./QuickCommandsList', () => ({
QuickCommandAgentPicker: 'QuickCommandAgentPicker',
QuickCommandsList: 'QuickCommandsList'
}))
vi.mock('./use-quick-commands', () => ({
useQuickCommands: () => ({
commands: mocks.commands,
loading: false,
ready: true,
error: null,
persist: mocks.persist
})
}))
function deferred<T>() {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('QuickCommandsSheet', () => {
let renderer: ReactTestRenderer | null = null
let consoleSpy: MockInstance
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
mocks.alert.mockReset()
mocks.commands = []
mocks.persist.mockReset()
const originalConsoleError = console.error
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
consoleSpy.mockRestore()
})
it('keeps the sheet open when a launch is rejected', async () => {
const onClose = vi.fn()
const onLaunch = vi.fn(() => false)
await act(async () => {
renderer = create(
createElement(QuickCommandsSheet, {
visible: true,
onClose,
client: {} as RpcClient,
repoId: 'repo-1',
repoName: 'Repo',
onLaunch
})
)
})
act(() => renderer!.root.findByType(quickCommandsList).props.onLaunch(command))
expect(onLaunch).toHaveBeenCalledWith(command)
expect(onClose).not.toHaveBeenCalled()
})
it('submits one full-list mutation for a same-frame double tap', async () => {
const save = deferred<boolean>()
mocks.persist.mockReturnValue(save.promise)
await act(async () => {
renderer = create(
createElement(QuickCommandsSheet, {
visible: true,
onClose: vi.fn(),
client: {} as RpcClient,
repoId: 'repo-1',
repoName: 'Repo',
onLaunch: () => true
})
)
})
act(() => renderer!.root.findByType(quickCommandsList).props.onAdd())
const editor = renderer!.root.findByType(quickCommandEditorForm)
act(() => {
editor.props.onChange({ label: 'Test' })
editor.props.onChange({ command: 'pnpm test' })
})
const readyEditor = renderer!.root.findByType(quickCommandEditorForm)
act(() => {
readyEditor.props.onSave()
readyEditor.props.onSave()
})
expect(mocks.persist).toHaveBeenCalledTimes(1)
await act(async () => {
save.resolve(true)
await save.promise
})
})
it('keeps creation closed when the host command limit is reached', async () => {
mocks.commands = Array.from({ length: MAX_QUICK_COMMANDS }, (_, index) => ({
...command,
id: `command-${index}`
}))
await act(async () => {
renderer = create(
createElement(QuickCommandsSheet, {
visible: true,
onClose: vi.fn(),
client: {} as RpcClient,
repoId: 'repo-1',
repoName: 'Repo',
onLaunch: () => true
})
)
})
const list = renderer!.root.findByType(quickCommandsList)
expect(list.props.canAdd).toBe(false)
act(() => list.props.onAdd())
expect(renderer!.root.findAllByType(quickCommandEditorForm)).toHaveLength(0)
})
it('shows only global commands and defaults new commands to global without a repo', async () => {
const repoCommand: TerminalQuickCommand = {
...command,
id: 'repo-command',
scope: { type: 'repo', repoId: 'repo-1' }
}
mocks.commands = [command, repoCommand]
await act(async () => {
renderer = create(
createElement(QuickCommandsSheet, {
visible: true,
onClose: vi.fn(),
client: {} as RpcClient,
repoId: null,
repoName: 'Folder workspace',
onLaunch: () => true
})
)
})
const list = renderer!.root.findByType(quickCommandsList)
expect(list.props.globalCommands).toEqual([command])
expect(list.props.repoCommands).toEqual([])
act(() => list.props.onAdd())
expect(renderer!.root.findByType(quickCommandEditorForm).props.draft.scope).toEqual({
type: 'global'
})
})
it('does not delete a shared command until the destructive action is confirmed', async () => {
await act(async () => {
renderer = create(
createElement(QuickCommandsSheet, {
visible: true,
onClose: vi.fn(),
client: {} as RpcClient,
repoId: 'repo-1',
repoName: 'Repo',
onLaunch: () => true
})
)
})
act(() => renderer!.root.findByType(quickCommandsList).props.onDelete(command))
expect(mocks.persist).not.toHaveBeenCalled()
const actions = mocks.alert.mock.calls[0]?.[2] as
| Array<{ style?: string; onPress?: () => void }>
| undefined
act(() => actions?.find((action) => action.style === 'destructive')?.onPress?.())
expect(mocks.persist).toHaveBeenCalledTimes(1)
expect(mocks.persist).toHaveBeenCalledWith({ type: 'delete', id: command.id })
})
})