parent
2c53263a62
commit
c23ab4323c
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue