diff --git a/mobile/app.json b/mobile/app.json index 78d85e000..829a6c34e 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.11", + "version": "0.0.12", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -16,7 +16,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.stably.orca.mobile", - "buildNumber": "2", + "buildNumber": "1", "infoPlist": { "NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.", "NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 803bb1fdb..c2cc96381 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -81,6 +81,7 @@ import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browse import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url' import { StatusDot } from '../../../../src/components/StatusDot' import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' +import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon' import { TextInputModal } from '../../../../src/components/TextInputModal' import { ConfirmModal } from '../../../../src/components/ConfirmModal' import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor' @@ -3740,8 +3741,7 @@ export default function SessionScreen() { : createTabAgentOptions.length > 0 ? createTabAgentOptions.map((option) => ({ label: option.label, - hint: 'Agent preset', - icon: Bot, + renderIcon: () => , onPress: () => { setShowCreateTabDrawer(false) void handleCreateTerminal(option.agent) diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx index edb7f9b84..08fe57676 100644 --- a/mobile/src/components/ActionSheetModal.tsx +++ b/mobile/src/components/ActionSheetModal.tsx @@ -1,3 +1,4 @@ +import { type ReactNode } from 'react' import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native' import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native' import { colors, spacing, typography } from '../theme/mobile-theme' @@ -6,6 +7,7 @@ import { BottomDrawer } from './BottomDrawer' export type ActionSheetAction = { label: string icon?: LucideIcon + renderIcon?: () => ReactNode destructive?: boolean disabled?: boolean hint?: string @@ -56,6 +58,7 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content {actions.map((action, i) => { const Icon = iconForAction(action.label, action.destructive, action.icon) + const customIcon = action.renderIcon?.() return ( {i > 0 && } @@ -73,10 +76,12 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content } }} > - + {customIcon ?? ( + + )} void): { } } -async function reclaimTerminalInputForClient( +function isTerminalInputLockedForClient( runtime: OrcaRuntimeService, ptyId: string, client: TerminalViewportClient | undefined -): Promise { +): boolean { if (client?.type === 'mobile') { - return true + return false } // Why: pre-refactor mobile builds did not send client metadata. Desktop // callers we control now identify as desktop, so keep legacy mobile input // working without opening the new desktop path. if (!client) { - return true + return false } - if (runtime.getDriver(ptyId).kind !== 'mobile') { - return true - } - // Why: a live desktop typing into a remotely driven terminal is an explicit - // take-back. Otherwise a stale mobile socket can black-hole input until - // heartbeat cleanup, or forever behind a proxy that keeps it warm. - return runtime.reclaimTerminalForDesktop(ptyId) + return runtime.getDriver(ptyId).kind === 'mobile' } function resolveMobileFloorClientId( @@ -541,10 +535,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => { const leaf = runtime.resolveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null - if ( - leaf?.ptyId && - !(await reclaimTerminalInputForClient(runtime, leaf.ptyId, params.client)) - ) { + if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { return { send: { handle: params.terminal, @@ -799,17 +790,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!text) { return } - void reclaimTerminalInputForClient(runtime, stream.ptyId, stream.client) - .then((canSend) => { - if (!canSend) { - return null - } - return runtime.sendTerminal(stream.terminal, { text, enter: false, interrupt: false }) - }) - .then(async (result) => { - if (!result) { - return - } + if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + void runtime + .sendTerminal(stream.terminal, { text, enter: false, interrupt: false }) + .then(async () => { if (stream.isMobile && stream.client?.id) { await runtime.mobileTookFloor(stream.ptyId, stream.client.id) } @@ -1203,21 +1189,12 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!text) { return } - void reclaimTerminalInputForClient(runtime, ptyId, params.client) - .then((canSend) => { - if (!canSend) { - return null - } - return runtime.sendTerminal(params.terminal, { - text, - enter: false, - interrupt: false - }) - }) - .then(async (result) => { - if (!result) { - return - } + if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + void runtime + .sendTerminal(params.terminal, { text, enter: false, interrupt: false }) + .then(async () => { if (isMobile && clientId) { await runtime.mobileTookFloor(ptyId, clientId) } diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 97557f7d4..a8260b0e2 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -288,7 +288,7 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) - it('reclaims desktop multiplex input while a mobile client owns the terminal floor', async () => { + it('drops desktop multiplex input while a mobile client owns the terminal floor', async () => { const messages: string[] = [] const handlers = new Map< number, @@ -312,7 +312,6 @@ describe('terminal multiplex RPC', () => { rows: 20 }), getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'phone-1' }), - reclaimTerminalForDesktop: vi.fn().mockResolvedValue(true), registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { cleanups.set(id, cleanup) }), @@ -384,14 +383,7 @@ describe('terminal multiplex RPC', () => { )! ) - await vi.waitFor(() => expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1')) - await vi.waitFor(() => - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'typed while locked', - enter: false, - interrupt: false - }) - ) + expect(runtime.sendTerminal).not.toHaveBeenCalled() cleanups.get('terminal-multiplex:conn-locked')?.() await dispatchPromise }) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 3fad5c6e1..1950a2116 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -36,16 +36,11 @@ describe('terminal send RPC', () => { expect(runtime.isTerminalRunningAgent).toHaveBeenCalledWith('terminal-1') }) - it('reclaims the terminal for desktop input while a mobile client owns the floor', async () => { + it('drops desktop input while a mobile client owns the terminal floor', async () => { const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), - reclaimTerminalForDesktop: vi.fn().mockResolvedValue(true), - sendTerminal: vi.fn().mockResolvedValue({ - handle: 'terminal-1', - accepted: true, - bytesWritten: 1 - }), + sendTerminal: vi.fn(), mobileTookFloor: vi.fn() }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -62,13 +57,14 @@ describe('terminal send RPC', () => { if (!response.ok) { throw new Error(response.error.message) } - expect(response.result).toMatchObject({ send: { accepted: true, bytesWritten: 1 } }) - expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1') - expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { - text: 'x', - enter: false, - interrupt: false + expect(response.result).toEqual({ + send: { + handle: 'terminal-1', + accepted: false, + bytesWritten: 0 + } }) + expect(runtime.sendTerminal).not.toHaveBeenCalled() expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index f5c9983a1..ccde2b3cf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1987,7 +1987,7 @@ describe('connectPanePty', () => { expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) - it('reclaims and replays terminal input when mobile presence lock is active', async () => { + it('does not infer interrupts when mobile presence lock blocks terminal input', async () => { const { connectPanePty } = await import('./pty-connection') const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') @@ -2033,19 +2033,55 @@ describe('connectPanePty', () => { terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) ;(onDataHandler as unknown as (data: string) => void)('\x03') ;(onDataHandler as unknown as (data: string) => void)('x') - await flushAsyncTicks() + vi.advanceTimersByTime(500) - expect(window.api.runtime.restoreTerminalFit).toHaveBeenCalledWith(ptyId) - expect(window.api.runtime.restoreTerminalFit).toHaveBeenCalledTimes(1) - expect(transport.sendInput).toHaveBeenCalledWith('\x03') - expect(transport.sendInput).toHaveBeenCalledWith('x') + expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() + expect(transport.sendInput).not.toHaveBeenCalled() expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() } finally { setDriverForPty(ptyId, { kind: 'idle' }) } }) - it('lets remote locked terminal input reach the runtime transport for reclaim', async () => { + it('drops xterm protocol replies from live TUI output while mobile presence lock is active', async () => { + const { connectPanePty } = await import('./pty-connection') + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + + const ptyId = 'pty-mobile-tui-query' + setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) + try { + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, + ptyIdsByTabId: { 'tab-1': [ptyId] } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + // Simulate xterm answering a TUI's DA1 query while the phone owns the PTY. + ;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c') + await flushAsyncTicks() + + expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() + expect(transport.sendInput).not.toHaveBeenCalled() + } finally { + setDriverForPty(ptyId, { kind: 'idle' }) + } + }) + + it('blocks remote locked terminal input before it reaches the runtime transport', async () => { const { connectPanePty } = await import('./pty-connection') const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') @@ -2076,7 +2112,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() - expect(transport.sendInput).toHaveBeenCalledWith('x') + expect(transport.sendInput).not.toHaveBeenCalled() } finally { setDriverForPty(ptyId, { kind: 'idle' }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ee3c8a75c..f3522892b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -16,7 +16,7 @@ import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' -import { isPtyLocked, setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' +import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { isPaneReplaying, replayIntoTerminal } from './replay-guard' import { terminalOutputPrefersDomRenderer } from '@/lib/pane-manager/terminal-complex-script' import { @@ -172,37 +172,6 @@ function isCodexStartupCommand(command: string): boolean { return executable === 'codex' || executable?.startsWith('codex-') === true } -const pendingDesktopInputReclaims = new Map>() - -async function reclaimLockedPtyForDesktopInput(ptyId: string): Promise { - if (isRemoteRuntimePtyId(ptyId)) { - return true - } - const pending = pendingDesktopInputReclaims.get(ptyId) - if (pending) { - return pending - } - const reclaim = reclaimLocalLockedPtyForDesktopInput(ptyId).finally(() => { - pendingDesktopInputReclaims.delete(ptyId) - }) - pendingDesktopInputReclaims.set(ptyId, reclaim) - return reclaim -} - -async function reclaimLocalLockedPtyForDesktopInput(ptyId: string): Promise { - try { - const restored = (await window.api.runtime.restoreTerminalFit(ptyId)).restored === true - if (restored) { - // Why: driver-change IPC is asynchronous; unblocking locally avoids - // dropping rapid follow-up keystrokes during the same desktop take-back. - setDriverForPty(ptyId, { kind: 'desktop' }) - } - return restored || !isPtyLocked(ptyId) - } catch { - return false - } -} - function shouldKeepHiddenStartupRendererQueriesLive( startup: PtyConnectionDeps['startup'] ): boolean { @@ -1285,24 +1254,11 @@ export function connectPanePty( clearPendingTerminalInputIntent() return } + // Why: presence-lock input drop. While mobile is the driver for this + // PTY, desktop keystrokes must not reach the shell; the visible overlay's + // explicit Take back action owns restoring desktop input and dimensions. if (currentPtyId && isPtyLocked(currentPtyId)) { clearPendingTerminalInputIntent() - // Why: typing into a mobile-driven terminal is a desktop take-back. - // Local PTYs must restore first so main's IPC defense accepts the - // replay; remote PTYs let the runtime RPC reclaim at its own seam. - void reclaimLockedPtyForDesktopInput(currentPtyId).then((reclaimed) => { - if (!reclaimed || transport.getPtyId() !== currentPtyId) { - return - } - if (transport.sendInput(data)) { - markTerminalInputSent() - observeAcceptedTerminalInput(data) - observeSentTerminalInputIntent(data) - deps.clearTerminalTabUnread(deps.tabId) - deps.clearTerminalPaneUnread(cacheKey) - deps.clearWorktreeUnread(deps.worktreeId) - } - }) return } // Why: a real keystroke into the terminal is the unambiguous "user is