Skip to content

Mobile improvements: scrollback hydration, keyboard layout, and worktree-creation polish#1423

Merged
Jinwoo-H merged 13 commits intomainfrom
Jinwoo-H/mobile-improvements
May 5, 2026
Merged

Mobile improvements: scrollback hydration, keyboard layout, and worktree-creation polish#1423
Jinwoo-H merged 13 commits intomainfrom
Jinwoo-H/mobile-improvements

Conversation

@Jinwoo-H
Copy link
Copy Markdown
Contributor

@Jinwoo-H Jinwoo-H commented May 5, 2026

Summary

Cluster of mobile-side improvements pulled together over the last few days. Touches three independent surfaces: terminal layout/safe-area on phones, runtime scrollback hydration so mobile shows the live xterm contents, and the new-workspace flow.

Commits

  • fix(mobile): lift terminal input above keyboard and Samsung nav bar — keeps the prompt visible when the on-screen keyboard or hardware nav bar covers the bottom of the screen.
  • fix(mobile): reserve bottom safe-area inset on home and worktree lists — pads the lists so the last row isn't tucked under the home indicator / nav bar.
  • fix(mobile): default new worktree name to a marine creature when blank — match desktop: worktree.create rejects empty names, so synthesize one client-side.
  • fix(mobile): preserve PTY scrollback on subscribe and resize — main-side daemon snapshot is now applied to the runtime headless emulator on subscribe.
  • fix(mobile): seed runtime headless emulator from daemon restore data — restore-time seed so a cold-attached pane has scrollback from the start.
  • feat(mobile): hydrate runtime headless emulator from desktop renderer scrollback — when a desktop renderer holds the live xterm buffer, mobile subscribers now receive the full visible state via a renderer pre-signal handshake. Cooperation gate suppresses the daemon-snapshot seed when the renderer takes over; runtime hydrates from a serialized renderer buffer (capped at 1000 rows for mobile) on first PTY data, with title/agent-status parity. See docs/mobile-prefer-renderer-scrollback.md for the full design.
  • fix(mobile): stop prefilling new-workspace name with creature placeholder — desktop renders a static Workspace name placeholder; mobile now matches and only uses the creature as a server-bound fallback at submit time.
  • fix(mobile): dedupe creature suggestion against worktree paths, not display names — collision check is on the on-disk basename, mirroring src/renderer/src/lib/path.ts.
  • fix(mobile): retry worktree.create on git branch/PR conflicts — ports the desktop's retry-with-suffix loop (src/renderer/src/store/slices/worktrees.ts). Branches outlive worktrees in git and remote branches/PRs aren't visible from worktree.ps, so client-side dedupe alone can't be authoritative; retry up to 25 times with -2, -3, ... when the server returns a retryable conflict.
  • fix(mobile): guard E2EE encryptedReply against late streaming emits after destroy — streaming RPC emits could fire after the WebSocket transport was destroyed (e.g. during a quick subscribe → background → resume cycle), causing encryptedReply to throw on a torn-down session. Now silently no-ops post-destroy instead of surfacing as an unhandled error.
  • feat(mobile): presence-based driver lock for desktop terminal — when a mobile client is actively driving a terminal, the desktop renderer is locked into a passive "watch" mode with a banner showing who's driving and a "Take back" affordance. Adds a idle | desktop | mobile{clientId} driver state machine on the runtime, multi-mobile subscriber tracking via nested Map<ptyId, Map<clientId, Subscriber>>, most-recent-actor for active phone-fit dims, earliest-by-subscribe for restore baseline, composite subscriptionId per (terminal, clientId) to prevent cleanup eviction across clients, and IPC defense-in-depth on pty:write/pty:resize. Subscribing in desktop mode is a passive watch, not a take-floor; deliberate input or a phone/auto display-mode toggle take the floor. See docs/mobile-presence-lock.md for the full design.
  • fix(mobile): smooth keyboard viewport changes and prevent stuck phone-fit dims — two follow-ups for presence lock. (1) Keyboard show/hide previously did unsubscribe → resubscribe with the new viewport, which routed the runtime through driver=idle for a frame and flashed the desktop banner. New terminal.updateViewport RPC + runtime.updateMobileViewport re-fits the PTY in place and emits a resized event on the existing stream — no driver flap. (2) The legacy resubscribe path captured the keyboard-shrunk PTY size as the restore baseline, leaving the desktop terminal stuck at phone dims after the phone disconnected. Added a 250 ms pendingSoftLeavers grace: same client resubscribing in-window restores the original baseline; a real disconnect inside the window promotes to immediate restore using that baseline. Older mobile builds without the new RPC fall back to the legacy path, which is now safer thanks to the soft-leave grace.
  • fix(mobile): apply fit-scale reliably on cold-start terminal init — on the first session open the WebView's xterm canvas hadn't finished reflowing when applyFitScale ran inside requestAnimationFrame + afterWritesDrained, so term.element.scrollWidth was zero or stale and computeFitScale snapped to 1 — leaving the terminal un-zoomed until the user toggled the resize button (which had its own setTimeout(resetZoom, 200)). applyFitScale now retries across animation frames until scrollWidth is positive and stable across two consecutive frames (capped at 30 frames). Mobile session also schedules a delayed resetZoom after the cold-start scrollback init, mirroring the existing resized handler.

Desktop behavior

Unchanged for the scrollback-hydration work — the cooperation gate, hydration, and renderer-serializer changes only affect the runtime headless emulator that backs mobile/RPC subscribers. Adds one extra IPC declare per pane spawn fired in parallel with transport.connect.

The presence-lock work introduces a renderer-side lock banner and read-only mode on the desktop terminal pane while a mobile client is driving. The banner shows the driving client and a "Take back" button; clicking it (or any deliberate desktop input on resize-only paths that already required focus) returns the floor and restores the original PTY dims. Driver state is mirrored to the renderer via a new terminalDriverChanged IPC event.

Test plan

  • pnpm tc:web clean
  • pnpm tc:node clean
  • pnpm lint clean (0/0)
  • pnpm vitest run pty-connection.test.ts 19/19 pass
  • pnpm vitest run src/main/runtime/mobile-presence-lock.test.ts 16/16 pass (driver state machine, multi-mobile, soft-grace, in-place viewport update, stuck-dim regression)
  • pnpm vitest run src/main/runtime/mobile-subscribe-integration.test.ts src/main/runtime/fit-override-integration.test.ts 23/23 pass
  • Mobile: tsc --noEmit and oxlint clean
  • Pre-existing test infra failures (better-sqlite3 NODE_MODULE_VERSION mismatch in orchestration/runtime-rpc; HOME-path mismatch in shell-ready) are unrelated to these changes.

Manually validated:

  • Keyboard/nav-bar safe-area on Pixel + Samsung
  • Mobile worktree-list scrollback after detaching from desktop renderer
  • New-workspace creation with name collisions on existing branches
  • Presence-lock banner appears on the desktop terminal when mobile is driving and clears on "Take back" or mobile disconnect
  • Keyboard show/hide on the mobile terminal no longer flashes the desktop banner; PTY restores to original dims after exiting the mobile screen
  • Cold-start zoom-to-fit on first session load (no resize-button toggle required)

Made with Orca 🐋

Jinwoo-H and others added 13 commits May 4, 2026 22:09
Edge-to-edge Android (Expo SDK 55 default) does not resize the window
when the IME opens, so KeyboardAvoidingView either no-ops or fights the
OS depending on behavior. Track keyboard height directly and pad the
input region. Add the bottom safe-area inset on Android so the input
also clears the system navigation bar that sits between the keyboard
and the screen edge on Samsung 3-button devices. Refit xterm on
keyboard show/hide so terminal rows match the new visible area.

Co-authored-by: Orca <help@stably.ai>
Edge-to-edge mode lets the FlatList/SectionList background scroll under
the system bars (iOS home indicator, Samsung 3-button nav), but the
last row is unreachable without contentContainerStyle.paddingBottom
including insets.bottom. Apply the inset on the home screen list, the
worktree screen list, and the home empty-state container so all
interactive content stays in the safe area while the chrome stays
edge-to-edge.

Co-authored-by: Orca <help@stably.ai>
The new-workspace modal advertised the name as Optional, but
worktree.create rejects an empty/invalid name and the server returns
the validation error to the user. Match the desktop behavior: pick the
first marine-creature label that doesn't collide with an existing
worktree on the host (Nautilus, Seahorse, Coelacanth, ...) and pass
that as the name when the field is blank. The suggestion is also shown
as the input placeholder so the user can see what will be used.

Co-authored-by: Orca <help@stably.ai>
The mobile subscribe path serialized the headless emulator with
scrollbackRows=0, so coming back to the app, switching terminals, or
toggling phone/desktop mode wiped any prior agent output the user
could otherwise scroll up to read. Include up to 1000 scrollback rows
in the snapshot when the buffer is in normal mode. Force
scrollbackRows=0 when an alternate-screen TUI is active so we don't
duplicate shell-prompt content with the alt-screen frame — the mobile
WebView already trims pre-1049h data, but skipping it server-side
saves bandwidth.

Co-authored-by: Orca <help@stably.ai>
After an Orca relaunch the runtime's HeadlessEmulator for a daemon-backed
PTY is created lazily on first onPtyData and starts with zero scrollback,
even though the daemon's on-disk checkpoint and the desktop xterm both
have the full prior history. Because serializeTerminalBufferFromAvailableState
always prefers the headless snapshot, mobile subscribers see only
post-relaunch bytes — typically just the bare prompt — while the desktop
shows the complete agent session.

Add runtime.seedHeadlessTerminal(ptyId, data, size?) and call it from the
PTY IPC layer immediately after provider.spawn returns, before registerPty,
using result.snapshot (warm reattach) or result.coldRestore.scrollback
(daemon crash recovery). The seed lands before any live data so subsequent
onPtyData writes append rather than replace.

Co-authored-by: Orca <help@stably.ai>
… scrollback

When mobile subscribes to a PTY whose desktop renderer holds the live xterm
buffer, the runtime headless emulator was previously seeded from the daemon
snapshot only — losing scrollback that lived in the desktop xterm. Add a
renderer-pre-signal handshake so the renderer declares serializer ownership
before pty:spawn returns, suppressing the daemon-snapshot seed; the runtime
then hydrates the headless emulator on first PTY data by serializing the
desktop xterm (capped at 1000 rows for mobile) and replaying the prior title
through the agent-status detector for parity.

Desktop behavior is unchanged: the cooperation gate, hydration, and renderer
serializer changes only affect the runtime headless emulator that backs
mobile/RPC subscribers. Adds one extra IPC declare per pane spawn, fired in
parallel with transport.connect; Electron's ipcRenderer→ipcMain channel
preserves order across consecutive invokes.

See docs/mobile-prefer-renderer-scrollback.md for the full design.

Co-authored-by: Orca <help@stably.ai>
…lder

Desktop's new-workspace flow renders a static "Workspace name" placeholder
and only uses the next-available marine-creature name as a server-bound
fallback at submit time when the field is blank — it never shows the
suggested creature in the input itself. Mobile was diverging by surfacing
the creature as the placeholder, which made it look like the field was
prefilled. Match desktop: keep the static placeholder, and compute the
fallback creature inside handleCreate() so it's still passed to
worktree.create when the user submits an empty field.

Co-authored-by: Orca <help@stably.ai>
…isplay names

The 'already exists locally' error from worktree.create is keyed on the
on-disk worktree directory basename (e.g. ~/orca/workspaces/<repo>/Seahorse),
not the user-facing displayName. Mobile was deduping the marine-creature
fallback against displayNames pulled from worktree.ps, so a renamed worktree
would let the creature collide with an existing folder and the server would
reject the create.

Match the desktop suggestion logic: pull worktree.path from worktree.ps,
derive the basename with a cross-platform helper that handles both POSIX
and Windows separators (mirroring src/renderer/src/lib/path.ts), and dedupe
against that. Renames the modal prop existingWorktreeNames →
existingWorktreePaths to make the contract obvious.

Co-authored-by: Orca <help@stably.ai>
The 'Branch X already exists locally / on a remote / already has PR #N'
errors come from server-side git checks, not from the worktree-list-based
basename dedupe. Branches outlive worktrees in git, and remote
branches/PRs aren't visible from worktree.ps, so even a freshly suggested
creature name can collide. Mirror desktop's retry loop in
src/renderer/src/store/slices/worktrees.ts: try the base name first, then
append -2, -3, ... up to 25 attempts when the error matches one of the
retryable patterns. Surfaces the last server error if every attempt
fails.

Co-authored-by: Orca <help@stably.ai>
…fter destroy

Streaming RPC handlers (terminal.subscribe, etc.) retain the encryptedReply
closure created inside E2EEChannel.handleRawMessage and may invoke it long
after the inbound message that originally triggered the streaming
subscription. If the mobile client disconnects mid-stream, runtime-rpc.ts
calls channel.destroy() which sets sharedKey = null, while
runtime.onClientDisconnected() then clears mobileSubscribers. A PTY tick
landing between those two steps would call the closure with a null
sharedKey, producing 'TypeError: unexpected type, use Uint8Array' from
inside nacl.box.after as an unhandled promise rejection.

Guard encryptedReply on both the WebSocket OPEN state and a non-null
sharedKey so late emits become silent no-ops. sendEncryptedControl already
had the same guard. Adds a regression test that reproduces the original
unhandled rejection.

Co-authored-by: Orca <help@stably.ai>
While a mobile client is the active driver of a terminal, suppress
desktop-side input and resize end-to-end (renderer xterm guards plus
defense-in-depth in the pty:write/pty:resize IPC handlers and the
runtime PTY layer) and surface a lock banner on the desktop pane with a
'Take back' affordance to reclaim the floor.

The driver state machine (idle | desktop | mobile{clientId}) replaces
the implicit binary lock and supports multi-mobile correctly:

- mobileSubscribers is rekeyed to Map<ptyId, Map<clientId, Subscriber>>
  with subscribedAt and lastActedAt timestamps.
- Most-recent-actor wins for active phone-fit dims; earliest-by-subscribe
  wins for desktop-restore target with non-null prevCols filtering and a
  baseline donation step on intermediate unsubscribes.
- terminal.subscribe registers cleanup under composite key
  '${terminal}:${clientId}' so two phones cannot evict each other; the
  unsubscribe RPC accepts both wire formats for backward compatibility.
- terminal.send and terminal.setDisplayMode gain an optional 'client'
  field; mobile callers take the floor on send/auto/phone but not on
  desktop. Subscribe in display-mode 'desktop' is a passive watch and
  does not take the floor.
- runtime:restoreTerminalFit IPC reuses the existing 'Take back' wire by
  calling reclaimTerminalForDesktop, which restores dims and flips
  driver to 'desktop' until the next mobile interaction.

Renderer mirrors the driver via onTerminalDriverChanged into a new
mobile-driver-state module; pty-connection drops onData/onResize while
locked; TerminalPane banner is presence-driven (not fit-driven) with
copy 'Mobile is driving this terminal — your input is paused.'

Tests cover the state-machine transitions, multi-mobile sequencing,
subscribe-in-desktop-mode passive rule, take-floor after take-back, and
restore-target preservation across peer churn.

Co-authored-by: Orca <help@stably.ai>
…-fit dims

Two fixes for the presence-based mobile lock that surfaced after the
initial implementation landed:

1. Banner flash on keyboard show/hide. The mobile session previously
   responded to keyboard visibility changes by tearing down its
   terminal subscription and immediately resubscribing with the new
   viewport. The brief gap between unsubscribe and resubscribe drove
   the runtime through driver=idle, which unmounted the desktop lock
   banner for one frame before remounting it. Replace the
   unsubscribe/resubscribe cycle with a new RPC, terminal.updateViewport,
   that updates the existing subscriber's viewport in place, re-fits
   the PTY to the new dims, and emits a 'resized' event on the
   existing stream so the mobile xterm reinits inline. Falls back to
   the legacy resubscribe path if the host build doesn't yet expose
   the RPC.

2. PTY stuck at phone dims after exiting the mobile screen. When the
   keyboard cycle did unsubscribe → resubscribe, the resubscribe ran
   against an already-phone-fitted PTY. With no peer subscribers in
   the inner map, handleMobileSubscribe captured the current PTY size
   (e.g. 49x16 with the keyboard up) as previousCols/Rows. When the
   phone later disconnected (router.back → WS close),
   onClientDisconnected restored the PTY to that polluted baseline,
   leaving the desktop terminal stuck at phone dims. Add a 250ms
   resubscribe-grace: when the last mobile subscriber leaves we now
   keep its record alive and the driver=mobile{clientId} state in
   place. If the same client resubscribes inside the window we
   restore the snapshot (preserving the original previousCols). If
   the client disconnects inside the window we promote the grace
   into immediate restore using the snapshot's baseline.

Driver-state behaviour: driver=idle is now emitted only after the
soft-leave grace expires, so legacy mobile builds that still
unsubscribe → resubscribe on viewport changes also stop flashing the
desktop banner. Tests cover the in-place viewport update, the
keyboard-cycle stuck-dim regression, and the resubscribe-grace driver
flap suppression.

Co-authored-by: Orca <help@stably.ai>
Cold-start sequence was: WebView loads, parent measures viewport
(returns null because xterm hasn't been created yet), subscribe
without viewport, server replies at desktop dims (e.g. 150 cols),
init() runs, parent then re-measures and unsubscribe → resubscribes
with the real phone viewport, server fits PTY and sends a fresh
scrollback at phone dims, init() runs again. The applyFitScale
queued by that second init() ran inside requestAnimationFrame
+ afterWritesDrained, but xterm's DOM/canvas hadn't finished
reflowing yet — term.element.scrollWidth was zero or stale, so
computeFitScale returned 1 and the terminal stayed un-zoomed.
Toggling the resize button worked because that path already
schedules setTimeout(resetZoom, 200), and two clicks gave the
DOM enough time to settle.

Two changes:

1. TerminalWebView applyFitScale now retries across animation
   frames until term.element.scrollWidth is positive and stable
   across two consecutive frames (capped at 30 frames so we never
   spin on a backgrounded WebView). The actual transform commit is
   factored into commitFitScale so logic stays unchanged once the
   DOM is settled.

2. The mobile session 'scrollback' handler now schedules
   setTimeout(resetZoom, 200) after init(), mirroring the existing
   'resized' handler. This gives the cold-start path the same
   delayed re-fit guarantee that mode toggles already had.

Subsequent re-entries weren't affected because the WebView (and
its xterm canvas) was already warm by then.

Co-authored-by: Orca <help@stably.ai>
@Jinwoo-H Jinwoo-H merged commit d1b26e2 into main May 5, 2026
3 checks passed
@Jinwoo-H Jinwoo-H deleted the Jinwoo-H/mobile-improvements branch May 5, 2026 06:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant