Files
orca/mobile/scripts/mock-server-rpc-handlers.ts
T
Brennan Benson f23b3308a5 fix(mobile): route external mouse click and drag to the terminal (#11473)
* fix(mobile): route external mouse click and drag to the terminal

The terminal WebView suppresses mousedown/click at capture so xterm's own
mouse handling stays inert (its onData bytes are dropped by the mobile
bridge). That left hardware mouse clicks and drags with no path at all:
touch taps reached mouse-aware TUIs and drove selection, while a Bluetooth
mouse or trackpad click did nothing (#8818; wheel half landed in #11247).

Add a pointer-event router on the terminal surface (pointerType 'mouse',
left button only) that mirrors touch semantics:

- plain click: same pipeline as a touch tap (links/file paths first, then
  tracking-mode press+release reports, else keyboard focus), and a click
  on an active selection dismisses it like touch does
- drag with mouse tracking: press at the anchor, per-cell motion reports
  (drag/any modes), release on pointerup or pointercancel
- drag without tracking: character-anchored selection reusing the touch
  handle-drag plumbing (edge scroll, handles, copy pill)

Widen the RN gesture-input grammar to pass left-drag motion reports
(SGR button 32, default-encoding byte 64) through the existing
validation and rate limiting.

Mock server: echo the subscribe viewport and serialize scrollback so the
session screen leaves the resubscribe loop, serve the session-tabs
subscribe stream, and add a MOCK_TUI=1 mouse-tracking scenario plus a
[SEND] byte log - the rig used to reproduce and verify this fix on an
Android emulator.

Fixes #8818

* fix(mobile): capture the mouse pointer and clear stale gestures on pointerdown

A drag leaving the terminal surface dropped pointermove/pointerup without
pointer capture, stranding the gesture; a pointerup lost outside the
WebView could leave a tracked press latched until the next gesture.

* fix(mobile): end mouse gestures whose pointerup never reached the surface

Capture the mouse pointer on pointerdown so a drag that leaves the surface
keeps delivering pointermove/pointerup; when capture is unavailable and the
release is lost anyway, synthesize the release from the next buttons==0
pointermove or the next pointerdown, so a tracking TUI is never left with
the left button latched down.

* fix(mock-server): clear the terminal stream interval on resubscribe and unsubscribe

* fix(mobile): synthesize the lost-pointerup release at the pointer's current cell

* test(mobile): split terminal mouse click and drag coverage

* test(mobile): satisfy changed-line quality checks

* fix(mobile): cancel stale mock terminal callbacks

* refactor(mobile): extract mouse report cell mapping
2026-07-31 00:54:40 -07:00

302 lines
9.0 KiB
TypeScript

import type { WebSocket } from 'ws'
import {
DESKTOP_PROTOCOL_VERSION,
MIN_COMPATIBLE_MOBILE_VERSION
} from '../../src/shared/protocol-version'
import {
applyTerminalQuickCommandMutation,
type TerminalQuickCommandMutation
} from '../../src/shared/terminal-quick-commands'
import type { TerminalQuickCommand } from '../../src/shared/types'
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { handleMockAccountRequest } from './mock-server-account-rpc'
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
import { handleMockTerminalRequest } from './mock-server-terminal-stream'
import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario'
const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2)
const MOCK_WORKTREE_COUNT = readScenarioNumber('MOCK_WORKTREE_COUNT', 2)
const MOCK_RPC_DELAY_MS = readScenarioNumber('MOCK_RPC_DELAY_MS', 0)
const FAKE_REPOS = createMockRepos(MOCK_REPO_COUNT)
let fakeWorktrees = createMockWorktrees(FAKE_REPOS, MOCK_WORKTREE_COUNT)
// Mutable quick-command list so the mobile Quick Commands sheet can add/edit/
// delete against the mock the same way it does a paired desktop.
let fakeQuickCommands: TerminalQuickCommand[] = [
{
id: 'qc-codex-review',
label: 'codex review',
action: 'agent-prompt',
agent: 'codex',
prompt: 'please review this diff for correctness and edge cases.',
scope: { type: 'global' }
},
{
id: 'qc-dev-server',
label: 'dev server',
action: 'terminal-command',
command: 'pnpm dev',
appendEnter: true,
scope: { type: 'global' }
}
]
export type RpcRequest = {
id: string
method: string
deviceToken?: string
params?: Record<string, unknown>
}
export type RpcResponse = {
id: string
ok: boolean
result?: unknown
error?: { code: string; message: string }
streaming?: true
_meta: { runtimeId: string }
}
export type RpcRespond = (response: RpcResponse, shouldSend?: () => boolean) => void
export const mockScenarioSummary = {
repoCount: FAKE_REPOS.length,
worktreeCount: fakeWorktrees.length,
rpcDelayMs: MOCK_RPC_DELAY_MS
}
export function success(id: string, result: unknown, streaming?: boolean): RpcResponse {
const resp: RpcResponse = { id, ok: true, result, _meta: { runtimeId: 'mock-runtime' } }
if (streaming) {
resp.streaming = true
}
return resp
}
export function error(id: string, code: string, message: string): RpcResponse {
return { id, ok: false, error: { code, message }, _meta: { runtimeId: 'mock-runtime' } }
}
function responseDelayFor(method: string): number {
const methodOverride =
process.env[`MOCK_RPC_DELAY_${method.replace(/\W/g, '_').toUpperCase()}_MS`]
if (!methodOverride) {
return MOCK_RPC_DELAY_MS
}
const parsed = Number(methodOverride)
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : MOCK_RPC_DELAY_MS
}
function repoSelectorToId(repoSelector: unknown): string | null {
if (typeof repoSelector !== 'string') {
return null
}
return repoSelector.startsWith('id:') ? repoSelector.slice(3) : repoSelector
}
function terminalListWorktreeId(worktreeSelector: unknown): string | undefined {
if (typeof worktreeSelector === 'string' && worktreeSelector.length > 0) {
return worktreeSelector.startsWith('id:') ? worktreeSelector.slice(3) : worktreeSelector
}
return fakeWorktrees.find((worktree) => worktree.isActive)?.worktreeId
}
export function handleRequest(
request: RpcRequest,
send: (response: RpcResponse) => void,
ws: WebSocket
): void {
const respond: RpcRespond = (response, shouldSend) => {
const deliver = () => {
if (shouldSend?.() !== false) {
send(response)
}
}
const delay = responseDelayFor(request.method)
if (delay > 0) {
setTimeout(deliver, delay)
return
}
deliver()
}
// Each returns false for methods it does not own; first owner wins.
if (
handleMockGitRequest(request, respond, success) ||
handleMockFilePreviewRequest(request, respond, success, error) ||
handleMockAccountRequest(request, respond, success, error) ||
handleMockNativeChatRequest(request, respond, success, error, ws) ||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) ||
handleMockTerminalRequest(request, respond, success, ws, terminalListWorktreeId)
) {
return
}
switch (request.method) {
case 'status.get':
respond(
success(request.id, {
runtimeId: 'mock-runtime',
protocolVersion: DESKTOP_PROTOCOL_VERSION,
minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION,
capabilities: ['accounts.codex-reset-credit.v1'],
graphStatus: 'ready',
windowCount: 1,
tabCount: 2,
terminalCount: 2
})
)
break
case 'worktree.ps':
respond(
success(request.id, {
worktrees: fakeWorktrees,
totalCount: fakeWorktrees.length,
truncated: false
})
)
break
case 'repo.list':
respond(success(request.id, { repos: FAKE_REPOS }))
break
case 'settings.get':
respond(
success(request.id, {
settings: {
defaultTuiAgent: 'codex',
disabledTuiAgents: [],
agentCmdOverrides: {}
}
})
)
break
case 'settings.getTerminalQuickCommands':
respond(success(request.id, { terminalQuickCommands: fakeQuickCommands }))
break
case 'settings.updateTerminalQuickCommands': {
const updates = (request.params ?? {}) as { mutation?: TerminalQuickCommandMutation }
if (updates.mutation) {
fakeQuickCommands = applyTerminalQuickCommandMutation(fakeQuickCommands, updates.mutation)
}
respond(success(request.id, { terminalQuickCommands: fakeQuickCommands }))
break
}
case 'ui.get':
respond(
success(request.id, {
ui: {
groupBy: 'repo',
sortBy: 'recent',
hideSleepingWorkspaces: false,
hideDefaultBranchWorkspace: false,
filterRepoIds: [],
collapsedGroups: [],
trustedOrcaHooks: {}
}
})
)
break
case 'ui.set':
respond(success(request.id, { ok: true }))
break
case 'repo.hooks':
respond(
success(request.id, {
hooks: null,
source: null,
setupRunPolicy: 'run-by-default',
setupTrust: null
})
)
break
case 'preflight.detectAgents':
case 'preflight.detectRemoteAgents':
respond(success(request.id, ['claude', 'codex', 'gemini']))
break
case 'ssh.getState':
case 'ssh.connect': {
const targetId = String(request.params?.targetId ?? '')
respond(
success(request.id, {
state: {
targetId,
status: 'connected',
error: null,
reconnectAttempt: 0
}
})
)
break
}
case 'worktree.create': {
const repoId = repoSelectorToId(request.params?.repo) ?? FAKE_REPOS[0]?.id ?? 'repo-1'
const repo = FAKE_REPOS.find((candidate) => candidate.id === repoId) ?? FAKE_REPOS[0]
const name = String(request.params?.name ?? `mock-${fakeWorktrees.length + 1}`)
const created = createMockWorktrees(repo ? [repo] : FAKE_REPOS, 1)[0]
const next =
created && repo
? {
...created,
worktreeId: `${repo.id}::${repo.path}/worktrees/${name}`,
repoId: repo.id,
repo: repo.displayName,
path: `${repo.path}/worktrees/${name}`,
branch: `feature/${name}`,
displayName: name,
isActive: true
}
: null
if (next) {
fakeWorktrees = [next, ...fakeWorktrees.map((w) => ({ ...w, isActive: false }))]
mockScenarioSummary.worktreeCount = fakeWorktrees.length
}
respond(
success(request.id, {
worktree: {
id: next?.worktreeId ?? `repo-1::/tmp/orca-mobile-repro/${name}`,
worktreeId: next?.worktreeId
}
})
)
break
}
case 'worktree.activate': {
const selector = String(request.params?.worktree ?? '')
const id = selector.startsWith('id:') ? selector.slice(3) : selector
fakeWorktrees = fakeWorktrees.map((w) => ({ ...w, isActive: w.worktreeId === id }))
respond(success(request.id, { ok: true }))
break
}
case 'files.open':
case 'files.openDiff':
respond(
success(request.id, {
worktree: request.params?.worktree ?? 'id:mock',
relativePath: request.params?.relativePath ?? '',
kind: 'text',
opened: true
})
)
break
default:
respond(error(request.id, 'method_not_found', `Unknown method: ${request.method}`))
}
}