Prepare mobile 0.0.12 (#4627)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-04 04:06:45 -04:00 committed by GitHub
parent 2c53263a62
commit c23ab4323c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 90 additions and 128 deletions

View File

@ -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.",

View File

@ -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: () => <MobileAgentIcon agentId={option.agent} size={16} />,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateTerminal(option.agent)

View File

@ -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
<View style={styles.actionGroup}>
{actions.map((action, i) => {
const Icon = iconForAction(action.label, action.destructive, action.icon)
const customIcon = action.renderIcon?.()
return (
<View key={action.label}>
{i > 0 && <View style={styles.separator} />}
@ -73,10 +76,12 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
}
}}
>
<Icon
size={16}
color={action.destructive ? colors.statusRed : colors.textSecondary}
/>
{customIcon ?? (
<Icon
size={16}
color={action.destructive ? colors.statusRed : colors.textSecondary}
/>
)}
<View style={styles.actionTextBlock}>
<Text
style={[

View File

@ -127,27 +127,21 @@ function createTerminalOutputBatcher(onFlush: (data: string) => void): {
}
}
async function reclaimTerminalInputForClient(
function isTerminalInputLockedForClient(
runtime: OrcaRuntimeService,
ptyId: string,
client: TerminalViewportClient | undefined
): Promise<boolean> {
): 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)
}

View File

@ -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
})

View File

@ -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()
})

View File

@ -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' })
}

View File

@ -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<string, Promise<boolean>>()
async function reclaimLockedPtyForDesktopInput(ptyId: string): Promise<boolean> {
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<boolean> {
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