Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 31 additions & 26 deletions mobile/app/h/[hostId]/session/[worktreeId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
const chatStreamRef = useRef<ReturnType<typeof useMobileNativeChatTerminalStream> | null>(null)
// Why: post-RPC refresh timers capture this screen and must not survive route reuse or unmount.
const delayedActionTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
// Why: highest applyLayout seq seen per handle; drop older scrollback/resized as stale, but a >20 gap resets (fresh subscription/server restart).
Expand Down Expand Up @@ -1299,6 +1300,7 @@
unsubscribeTerminalRef.current = unsubscribeTerminal

const clearTerminalCache = useCallback(() => {
chatStreamRef.current?.clearRetries()
terminalUnsubsRef.current.forEach((unsub) => unsub())
clearNativeChatInputLease()
terminalUnsubsRef.current.clear()
Expand Down Expand Up @@ -1334,21 +1336,21 @@
)

const subscribeToTerminal = useCallback(
(handle: string) => {
(handle: string): boolean | void => {
const diagnostics = terminalDiagnosticsRef.current
const logSkippedGate = (reason: string) =>
diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current)
if (!client) {
logSkippedGate('no-client')
return
return false
}
if (terminalUnsubsRef.current.has(handle)) {
logSkippedGate('already-subscribed')
return
return false
}
if (subscribingHandlesRef.current.has(handle)) {
logSkippedGate('subscribe-in-flight')
return
return false
}
const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat(
showNativeChatRef.current,
Expand All @@ -1359,43 +1361,41 @@
if (!covered) {
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
return false
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
return false
}
}

subscribingHandlesRef.current.add(handle)
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
diagnostics.streamArmed(handle, seq, viewportRef.current)

// Why: viewport is embedded in the subscribe params so the server auto-fits before serializing scrollback (no focus→safeFit race).
diagnostics.streamArmed(handle, seq, covered ? null : viewportRef.current, covered)
const terminateStream = () =>
chatStreamRef.current?.terminateStream(handle, unsubscribeTerminalRef.current)
const unsub = subscribeMobileTerminalSafely(
client,
{
nativeChatTerminalStream.buildMobileNativeChatTerminalSubscribeParams({
terminal: handle,
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport(
covered,
viewportRef.current
),
capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered)
},
clientId: deviceTokenRef.current!,
covered,
viewport: viewportRef.current
}),
(result) => {
Comment on lines +1376 to 1386

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n "deviceTokenRef.current" "mobile/app/h/[hostId]/session/[worktreeId].tsx"

Repository: stablyai/orca

Length of output: 1075


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="mobile/app/h/[hostId]/session/[worktreeId].tsx"

printf '--- Around lines 1340-1410 ---\n'
sed -n '1340,1410p' "$FILE"

printf '\n--- References around other occurrences ---\n'
for line in 1570 2345 2975 3010 3050 3315 3415 3795; do
  start=$((line-15)); end=$((line+25))
  printf '\n### lines %d-%d\n' "$start" "$end"
  sed -n "${start},${end}p" "$FILE"
done

printf '\n--- Search for buildMobileNativeChatTerminalSubscribeParams and subscribeMobileTerminalSafely ---\n'
rg -n "buildMobileNativeChatTerminalSubscribeParams|subscribeMobileTerminalSafely|subscribeTerminal|mobileNativeChatTerminal" "$FILE"

Repository: stablyai/orca

Length of output: 242


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="mobile/app/h/[hostId]/session/[worktreeId].tsx"

if [ ! -f "$FILE" ]; then
  printf 'File not found: %s\n' "$FILE"
  git ls-files | sed -n '/mobile\/app\/h\/\[hostId\]\/session\/\[worktreeId\].tsx/p' || true
  exit 1
fi

printf '%s\n' '--- Around lines 1340-1410 ---'
sed -n '1340,1410p' "$FILE"

printf '%s\n' ''
printf '%s\n' '--- References around other occurrences ---'
for line in 1570 2345 2975 3010 3050 3315 3415 3795; do
  start=$((line-15))
  end=$((line+25))
  printf '%s\n' "$line"
  sed -n "${start},${end}p" "$FILE"
done

printf '%s\n' ''
printf '%s\n' '--- Search for buildMobileNativeChatTerminalSubscribeParams and subscribeMobileTerminalSafely ---'
rg -n "buildMobileNativeChatTerminalSubscribeParams|subscribeMobileTerminalSafely|subscribeTerminal|mobileNativeChatTerminal" "$FILE"

Repository: stablyai/orca

Length of output: 16463


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="mobile/app/h/[hostId]/session/[worktreeId].tsx"

printf '%s\n' '--- subscribeMobileTerminalSafely call and result handling ---'
sed -n '1346,1510p' "$FILE"

printf '%s\n' ''
printf '%s\n' '--- locate imported module files ---'
rg -n "function( async)? subscribeMobileTerminalSafely|buildMobileNativeChatTerminalSubscribeParams|interface .*Client|type .*Client|client:" mobile/src -S -g '.*\.(ts|tsx)$' | head -80 || true

Repository: stablyai/orca

Length of output: 7997


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Locate mobile terminal stream module files ---'
fd -i 'mobile-terminal-stream-subscribe|mobile.*terminal.*stream' mobile/app mobile/src -t f | sed -n '1,80p'

printf '%s\n' ''
printf '%s\n' '--- Search token/client identity construction ---'
rg -n "mobileNativeChatTerminalSubscribeParams|buildMobileNativeChatTerminalSubscribeParams|subscribeMobileTerminalSafely|NativeChatTerminalSubscribeParams|deviceToken|client:" mobile -S -g '*.ts' -g '*.tsx' | sed -n '1,220p'

Repository: stablyai/orca

Length of output: 21505


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="mobile/src/session/mobile-terminal-stream-subscribe.ts"
NATIVE="mobile/src/session/mobile-native-chat-terminal-stream.ts"

printf '%s\n' '--- mobile-terminal-stream-subscribe.ts ---'
cat -n "$FILE"

printf '%s\n' ''
printf '%s\n' '--- relevant mobile-native-chat-terminal-stream.ts definitions ---'
sed -n '1,180p' "$NATIVE" | cat -n

printf '%s\n' ''
printf '%s\n' '--- tests for params/client semantics ---'
sed -n '1,130p' mobile/src/session/mobile-terminal-stream-subscribe.test.ts | cat -n
sed -n '1,120p' mobile/src/session/mobile-native-chat-terminal-stream.test.ts | cat -n

Repository: stablyai/orca

Length of output: 10393


Guard deviceTokenRef.current before subscribing the mobile terminal stream.

subscribeToTerminal() already checks client, but it can still build and send terminal.subscribe before loadHosts() populates deviceTokenRef.current. Building params with client: { id: undefined, type: 'mobile' } does not match the guarded RPC-client pattern used for terminal.send, so send only after deviceTokenRef.current is set and return early with subscribingHandlesRef.current.delete(handle).

if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
const data = result as Record<string, unknown>
diagnostics.firstStreamEvent(handle, seq, data.type)
diagnostics.firstStreamEvent(handle, seq, data)
if (data.type === 'end' || data.type === 'error') {
unsubscribeTerminalRef.current(handle)
terminateStream()
return
}
if (data.type === 'subscribed') {
if (nativeChatTerminalStream.isMobileNativeChatLeaseReady(covered, data)) {
markNativeChatInputLeaseReady(handle)
chatStreamRef.current?.notifyStreamReady(handle)
return
}
Comment on lines 1386 to 1400

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n "terminal-unavailable" --type ts

Repository: stablyai/orca

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -u

echo "== locate files =="
git ls-files | rg 'mobile-native-chat-terminal-stream|mobile/app/h/.*/session/|terminals|lease' || true

echo
echo "== search terminal-unavailable without type filter =="
rg -n "terminal-unavailable|isMobileNativeChatLeaseReady|terminalUnavailable|'terminal'|\"terminal\"" . || true

echo
echo "== file size candidates =="
for f in $(git ls-files | rg 'mobile-native-chat-terminal-stream|session/\[worktreeId\].tsx$|mobile/app/h/.*/session/\[worktreeId\].tsx$' || true); do
  wc -l "$f"
done

Repository: stablyai/orca

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -u

echo "== target file candidate exact =="
f="$(git ls-files | grep -F 'mobile/app/h/[hostId]/session/[worktreeId].tsx' || true)"
echo "$f"
if [ -n "$f" ]; then wc -l "$f"; fi

echo
echo "== focused search exact string =="
rg -n --fixed-strings "terminal-unavailable" "$(git ls-files)" || true

echo
echo "== focused search lease helper =="
rg -n --fixed-strings "isMobileNativeChatLeaseReady" . || true

echo
echo "== focused search mobile native chat stream source/test files =="
git ls-files | rg -n "mobile-native-chat-terminal-stream|nativeChatTerminalStream" || true

Repository: stablyai/orca

Length of output: 1722


🏁 Script executed:

#!/bin/bash
set -u

echo "== session stream helper =="
sed -n '1,120p' mobile/src/session/mobile-native-chat-terminal-stream.ts
echo
sed -n '1,210p' mobile/src/session/mobile-native-chat-terminal-stream.test.ts

echo
echo "== target app context around stream result flow =="
sed -n '1330,1445p' "mobile/app/h/[hostId]/session/[worktreeId].tsx"

echo
echo "== compose lease/state declarations and controls =="
rg -n -C 3 "markNativeChatInputLeaseReady|chatStreamRef|Composer|composer|leaseReady|terminal-unavailable" "mobile/app/h/[hostId]/session/[worktreeId].tsx"

echo
echo "== use-mobile native hook =="
sed -n '1,230p' mobile/src/session/use-mobile-native-chat-terminal-stream.ts

Repository: stablyai/orca

Length of output: 23705


🏁 Script executed:

#!/bin/bash
set -u

echo "== lock/composer dependency expressions =="
rg -n -C 5 "nativeChatInputLeaseReady|nativeChatInputLockReason|composer|Composer|native chat|chat composer|disabled=.nativeChatInputLeaseReady|disabled.*nativeChat" "mobile/app/h/[hostId]/session/[worktreeId].tsx"

echo
echo "== locate input lease hook source =="
git ls-files | rg 'use-mobile-native-chat-input-lease|mobile-native-chat-input-lease|composer|Composer' | rg -E '\.(ts|tsx|js|jsx)$' | head -80

echo
echo "== inspect terminal unavailable occurrences in all tracked files =="
tmp="$(mktemp)"
git ls-files > "$tmp"
rg -n --fixed-strings "terminal-unavailable" --files-with-from-stdin "$tmp" || true
rm -f "$tmp"

Repository: stablyai/orca

Length of output: 3689


🏁 Script executed:

#!/bin/bash
set -u

echo "== input lease hook candidates =="
git ls-files | grep -F 'mobile/src/session' | grep -E '\.(ts|tsx)$' | grep -F 'mobile-native-chat-input-lease' || true
git ls-files | grep -E 'use-mobile-native-chat-input-lease|mobile-native-chat-input-lease' || true

echo
echo "== composer gate file =="
f="$(git ls-files | grep -E 'inputLockReason|keyboardInset|InputLockReason|KeyboardInset' | head -10 || true)"
printf '%s\n' "$f"
if [ -n "$f" ]; then
  IFS=$'\n' read -d '' -ra matches <<< "$f"
  for m in "${matches[@]}"; do
    [ -n "$m" ] && { echo "--- $m"; wc -l "$m"; }
  done
fi

echo
echo "== all terminal-unavailable occurrences =="
tmp="$(mktemp)"
git ls-files > "$tmp"
grep -R --fixed-strings "terminal-unavailable" "$tmp" || true
rm -f "$tmp"

Repository: stablyai/orca

Length of output: 494


🏁 Script executed:

#!/bin/bash
set -u

echo "== use-mobile-native-chat-input-lease source =="
sed -n '1,260p' mobile/src/session/use-mobile-native-chat-input-lease.ts
echo
sed -n '1,220p' mobile/src/session/use-mobile-native-chat-input-lease.test.ts

echo
echo "== composer gate source with all tracked files =="
grep -R --fixed-strings "inputLockReason" . || true
grep -R --fixed-strings "keyboardInset" . || true

echo
echo "== deterministic control-flow probe for covered terminal-unavailable event =="
python3 - <<'PY'
from pathlib import Path
src = Path("mobile/app/h/[hostId]/session/[worktreeId].tsx").read_text()
helper = Path("mobile/src/session/mobile-native-chat-terminal-stream.ts").read_text()
checks = []

checks.append(("terminal-unavailable not present in repository", "terminal-unavailable" not in Path(".").read_text(errors="ignore")))
checks.append(("isMobileNativeChatLeaseReady rejects terminal-unavailable", "'terminal-unavailable'" in helper and "event.type !== 'subscribed'" in helper))

# Extract callback branches in subscribeToTerminal and classify event handling.
# This is a compact, read-only structural summary keyed only by data.type values observed.
for needle in ["terminals", "error", "terminal-unavailable", "subscribed", "resized", "scrollback"]:
    checks.append((f"subscribe callback mentions {needle!r}", f"data.type === '{needle}'" in src or f'"' + needle + '"' in src))

def classify(event_type, covered=True):
    # Mirror the branch ordering from lines 1388-1486:
    if event_type in ("end", "error"):
        return "terminateStream"
    # NativeChatTerminalStream.isMobileNativeChatLeaseReady(covered, data)
    if event_type != "subscribed" or (covered and event_type == "subscribed" and "leaseReady" in {"lease": False} and False):
        pass
    if event_type == "subscribed" and (not covered or True):
        return "markNativeChatInputLeaseReady/notifyStreamReady"
    # covered-terminal early return
    if covered:
        return "terminal-covered-noop"
    if event_type == "resized":
        return "resized-processed"
    if event_type == "scrollback":
        return "scrollback-processed"
    return "no-match-drop"

for et in ["terminal-unavailable", "subscribed", "error", "end", "resized"]:
    checks.append((f"covered {et!r} branch", classify(et)))

for name, value in checks:
    print(f"{name}: {value}")
PY

Repository: stablyai/orca

Length of output: 6442


🌐 Web query:

MobileNativeChatView inputLockReason waiting composer disabled

💡 Result:

The terms MobileNativeChatView, inputLockReason, and waiting composer disabled appear to describe custom implementation logic within a chat interface, likely related to handling state while a message or process is pending [1][2]. There is no standard, framework-agnostic API or component named MobileNativeChatView across major chat SDKs (such as Stream Chat, React Native Gifted Chat, or Chatwoot) that defines these specific property or state names [3][4][5][6]. In development contexts, these terms often arise in projects attempting to manage UI states during asynchronous operations, such as: 1. Input Locking: Developers often implement custom logic to disable the composer or "lock" input when a message is currently being sent, processed by a bot, or streaming a response to prevent duplicate sends or inconsistent states [7][6][8]. 2. State Management: The inputLockReason: 'waiting' pattern suggests a state machine or conditional rendering approach where the UI inspects a reason code (e.g., waiting, processing, streaming) to determine whether the composer should be disabled or visually modified [1][2]. 3. Custom Implementation: If you encounter this in your codebase, it is likely a specific component or context provider created to handle chat stream management [7][2]. You should search your local repository for the definition of MobileNativeChatView to find the corresponding state management logic, which typically involves checking a message status or a connection/process state before setting the disabled attribute on the underlying text input [3][9][2]. If you are attempting to implement this behavior, modern best practices involve queuing messages typed during processing rather than simply locking the input, which improves the user experience by allowing them to continue drafting while the backend finishes the previous request [7][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -u

echo "== MobileNativeChatView overlay =="
sed -n '1,260p' mobile/src/session/MobileNativeChatView.tsx
echo
sed -n '1,220p' mobile/src/session/MobileNativeChatOverlay.tsx

echo
echo "== MobileNativeChatView tests =="
sed -n '1,240p' mobile/src/session/MobileNativeChatView.test.ts

Repository: stablyai/orca

Length of output: 19996


🌐 Web query:

site:github.com stablyai/orca terminal-unavailable MobileNativeChatComposer sendDisabled waiting for terminal

💡 Result:

The error message "terminal-unavailable" and the status "sendDisabled: waiting for terminal" within the MobileNativeChatComposer are part of the terminal and chat lifecycle management in the stablyai/orca codebase [1][2][3][4]. These states generally indicate that the NativeChatComposer has detected a disconnection or a non-ready state in the underlying terminal session (the pseudo-terminal or PTY) and has therefore disabled input to prevent lost messages or inconsistent state [1][5][6][4]. Key technical insights regarding these states: 1. Terminal Lifecycle and Recovery: Orca manages terminal reliability through a daemon-based architecture [7][8]. If a terminal pane's renderer dies or loses IPC connection while the PTY remains alive, it enters a "fossil" state [1]. Automated "self-heal" mechanisms are triggered by specific error patterns—such as "Hello response timed out" or detected stalls—to remount the pane or replace a wedged daemon without closing the user's shell [1][5][9][7][8]. 2. Composer "sendDisabled" Logic: The MobileNativeChatComposer or similar chat UI components rely on a "sendDisabled" contract to gate user input [3][4][10]. This flag is enabled when the backend detects that the terminal is offline, reattaching, or in a "dead-run" state [6][3]. The goal is to ensure that any messages sent by the user are only dispatched when there is an active, healthy PTY transport to receive them [4]. 3. Common Causes for the Waiting State: - Daemon Wedging: If the terminal daemon is "wedged" (it accepts socket connections but fails the protocol handshake), it may repeatedly report "terminal-unavailable" [7][8]. - Network Partitioning/Sleep: Following network loss, sleep, or roaming, the system may enter a 60-second retry grace period where it attempts to reattach to the PTY before surfacing a permanent disconnected state [5][6]. - Initial Session Race: In some cases, native chat components may race the creation of a session's transcript or PTY, causing a temporary "waiting" state while the system confirms the session is ready [2][11]. If you are encountering this persistently, it typically suggests that the terminal's PTY transport has failed to resolve or reattach within the allowed grace period [5][6]. Users can often resolve this by checking for updates (which contain fixes for these recovery paths) or, in the case of a stuck daemon, manually restarting the daemon process if recommended by your specific version's troubleshooting documentation [7][8].

Citations:


Handle PTY-unavailable events instead of waiting forever.

isMobileNativeChatLeaseReady(*) only accepts type === 'subscribed' lease acknowledgements, and the covered-terminal result handler falls through to the terminal-covered noop after lease processing. A PTY stream event such as type: 'terminal-unavailable' therefore leaves inputLockReason === 'waiting', keeping the composer send path disabled with no retry/backoff triggered. Either expose this as a user-visible lock reason/disconnect state or treat it as terminateStream() so useMobileNativeChatTerminalStream reschedules the covered input-lease subscription.

// Why: keep the subscription as the input-floor lease but don't mutate covered xterm state; return-to-terminal resubscribes.
Expand Down Expand Up @@ -1538,7 +1538,12 @@
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
}
},
() => unsubscribeTerminalRef.current(handle)
() => {
if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
terminateStream()
}
)

if (subscribeSeqRef.current.get(handle) === seq) {
Expand All @@ -1551,7 +1556,7 @@
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
)

const notifyTerminalWebReady = useMobileNativeChatTerminalStream({
useMobileNativeChatTerminalStream({
showNativeChat,
activeHandle,
activeTabType: activeSessionTab?.type ?? null,
Expand All @@ -1560,9 +1565,9 @@
webReadyRef: webReadyHandlesRef,
initializedRef: initializedHandlesRef,
subscribe: subscribeToTerminal,
unsubscribe: unsubscribeTerminal
unsubscribe: unsubscribeTerminal,
controllerRef: chatStreamRef
})

// Why: server does the resize and emits 'resized' on the existing subscription — no client-side state tracking needed.
const toggleInFlightRef = useRef<Set<string>>(new Set())
const toggleDisplayMode = useCallback(
Expand Down Expand Up @@ -2918,7 +2923,7 @@
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
notifyTerminalWebReady(handle, wasAlreadyReady)
chatStreamRef.current?.notifyWebReady(handle, wasAlreadyReady)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
Expand Down Expand Up @@ -2946,7 +2951,7 @@
})()
}
},
[measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal]
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
)

useEffect(() => {
Expand Down Expand Up @@ -5361,4 +5366,4 @@
/>
</View>
)
}

Check failure on line 5369 in mobile/app/h/[hostId]/session/[worktreeId].tsx

View workflow job for this annotation

GitHub Actions / verify

eslint(max-lines)

File has too many lines (5019).
31 changes: 31 additions & 0 deletions mobile/scripts/start-emulator-desktop-runtime.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import path from 'node:path'

const DESKTOP_BUILD_TIMEOUT_MS = 300_000

export async function prepareEmulatorDesktopRuntime({
worktree,
cliOverride,
runCommand,
logStep,
logSuccess
}) {
const explicitCli = cliOverride?.trim()
if (explicitCli) {
return explicitCli
}

logStep('0', 'Building current desktop runtime for mobile pairing...')
await runCommand('pnpm', ['run', 'build:cli'], {
cwd: worktree,
timeout: DESKTOP_BUILD_TIMEOUT_MS
})
await runCommand('pnpm', ['run', 'build:electron-vite'], {
cwd: worktree,
timeout: DESKTOP_BUILD_TIMEOUT_MS
})
logSuccess('Current desktop runtime built')

// Why: pairing against an installed app can silently mix incompatible
// mobile and desktop protocol/transcript behavior.
return path.join(worktree, 'config', 'scripts', 'orca-dev.mjs')
}
46 changes: 38 additions & 8 deletions mobile/scripts/start-emulator-pairing-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,26 +28,47 @@ export async function startHeadlessPairingRuntime({
// home, so the pairing runtime must hand it a matching disposable HOME.
const homeDir = path.join(runDir, 'home')
mkdirSync(homeDir, { recursive: true, mode: 0o700 })
const transcriptHomeDir = process.env.ORCA_NATIVE_CHAT_TRANSCRIPT_HOME_DIR?.trim() || os.homedir()
const pairingAddress = primaryLanIp(lanIpCandidates)
const child = spawn(
orcaCli,
['serve', '--mobile-pairing', '--pairing-address', pairingAddress, '--json'],
{
cwd,
env: {
...process.env,
ORCA_E2E_USER_DATA_DIR: userData,
ORCA_E2E_HOME_DIR: homeDir,
HOME: homeDir,
USERPROFILE: homeDir
},
env: buildHeadlessPairingRuntimeEnvironment({
baseEnv: process.env,
userData,
isolatedHomeDir: homeDir,
transcriptHomeDir
}),
// Why: orca-dev synchronously owns the CLI and Electron descendants;
// a process group lets launcher shutdown reap the whole disposable tree.
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe']
}
)

return await waitForPairingRuntime({ child, userData, pairingAddress, logSuccess })
}

export function buildHeadlessPairingRuntimeEnvironment({
baseEnv,
userData,
isolatedHomeDir,
transcriptHomeDir
}) {
return {
...baseEnv,
ORCA_E2E_USER_DATA_DIR: userData,
ORCA_E2E_HOME_DIR: isolatedHomeDir,
ORCA_DEV_USER_DATA_PATH: userData,
// Why: login-shell agents write transcripts outside the disposable runtime home.
ORCA_NATIVE_CHAT_TRANSCRIPT_HOME_DIR: transcriptHomeDir,
HOME: isolatedHomeDir,
USERPROFILE: isolatedHomeDir
}
}

export async function registerWorktreeForPairingRuntime(runtime, worktree, tools) {
if (!runtime) {
return
Expand All @@ -71,7 +92,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce

const stop = () => {
if (!exited) {
child.kill('SIGTERM')
if (process.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid, 'SIGTERM')
} catch {
child.kill('SIGTERM')
}
} else {
child.kill('SIGTERM')
}
}
rl?.close()
rlErr?.close()
Expand All @@ -85,6 +114,7 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
process: child,
env: {
...process.env,
ORCA_DEV_USER_DATA_PATH: userData,
ORCA_USER_DATA_PATH: userData
},
stop
Expand Down
17 changes: 14 additions & 3 deletions mobile/scripts/start-emulator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
registerWorktreeForPairingRuntime,
startHeadlessPairingRuntime
} from './start-emulator-pairing-runtime.mjs'
import { prepareEmulatorDesktopRuntime } from './start-emulator-desktop-runtime.mjs'
import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs'

const execFileAsync = promisify(execFile)
Expand Down Expand Up @@ -78,7 +79,7 @@ Options:
}
}

const ORCA_CLI = process.env.ORCA_CLI || 'orca'
let orcaCli = process.env.ORCA_CLI?.trim() || 'orca'

// Colors for output
const colors = {
Expand Down Expand Up @@ -121,7 +122,7 @@ function assertIosSimulatorPlatform() {

// Execute orca CLI command
async function orca(args, options = {}) {
const { stdout, stderr } = await execFileAsync(ORCA_CLI, args, {
const { stdout, stderr } = await execFileAsync(orcaCli, args, {
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
encoding: 'utf8',
Expand Down Expand Up @@ -577,9 +578,19 @@ async function main() {
logInfo(`Using worktree: ${worktree}`)
await ensureMobileDependencies(worktree)

if (options.pair) {
orcaCli = await prepareEmulatorDesktopRuntime({
worktree,
cliOverride: process.env.ORCA_CLI,
runCommand: execFileAsync,
logStep,
logSuccess
})
}

pairingRuntime = await startHeadlessPairingRuntime({
enabled: options.pair,
orcaCli: ORCA_CLI,
orcaCli,
cwd: process.cwd(),
lanIpCandidates,
logStep,
Expand Down
39 changes: 36 additions & 3 deletions mobile/src/session/MobileNativeChatComposer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,28 @@ function suppressRendererWarning(): () => void {

describe('MobileNativeChatComposer', () => {
let renderer: ReactTestRenderer | null = null
let previousActEnvironment: boolean | undefined

beforeEach(() => {
previousActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})

afterEach(() => {
act(() => renderer?.unmount())
renderer = null
globalThis.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment
})

async function render(
onSend: (text: string) => Promise<boolean>,
onChangeText: () => void,
isAttaching = false
options: {
isAttaching?: boolean
sendDisabled?: boolean
onAttachImage?: () => void
onMicPress?: () => void
} = {}
) {
const restore = suppressRendererWarning()
try {
Expand All @@ -63,7 +71,7 @@ describe('MobileNativeChatComposer', () => {
value: ' hello ',
onChangeText,
onSend,
isAttaching
...options
})
)
})
Expand Down Expand Up @@ -105,13 +113,38 @@ describe('MobileNativeChatComposer', () => {

it('disables send while an attachment path is still being injected', async () => {
const onSend = vi.fn().mockResolvedValue(true)
await render(onSend, vi.fn(), true)
await render(onSend, vi.fn(), { isAttaching: true })

expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
})

it('disables only Send while the terminal lease is pending', async () => {
const onSend = vi.fn().mockResolvedValue(true)
await render(onSend, vi.fn(), {
sendDisabled: true,
onAttachImage: vi.fn(),
onMicPress: vi.fn()
})

expect(sendButton().props).toMatchObject({ disabled: true })
expect(renderer!.root.findByType('TextInput').props.editable).toBeUndefined()
expect(
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Attach image'
).props.disabled
).toBe(false)
expect(
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate'
).props.disabled
).toBeUndefined()

await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
})

it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const restore = suppressRendererWarning()
try {
Expand Down
Loading
Loading