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