Mobile improvements: scrollback hydration, keyboard layout, and worktree-creation polish#1423
Merged
Mobile improvements: scrollback hydration, keyboard layout, and worktree-creation polish#1423
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
worktree.createrejects empty names, so synthesize one client-side.docs/mobile-prefer-renderer-scrollback.mdfor the full design.Workspace nameplaceholder; mobile now matches and only uses the creature as a server-bound fallback at submit time.src/renderer/src/lib/path.ts.src/renderer/src/store/slices/worktrees.ts). Branches outlive worktrees in git and remote branches/PRs aren't visible fromworktree.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.encryptedReplyto throw on a torn-down session. Now silently no-ops post-destroy instead of surfacing as an unhandled error.idle | desktop | mobile{clientId}driver state machine on the runtime, multi-mobile subscriber tracking via nestedMap<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 onpty: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. Seedocs/mobile-presence-lock.mdfor the full design.unsubscribe → resubscribewith the new viewport, which routed the runtime throughdriver=idlefor a frame and flashed the desktop banner. Newterminal.updateViewportRPC +runtime.updateMobileViewportre-fits the PTY in place and emits aresizedevent 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 mspendingSoftLeaversgrace: 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.applyFitScaleran insiderequestAnimationFrame+afterWritesDrained, soterm.element.scrollWidthwas zero or stale andcomputeFitScalesnapped to 1 — leaving the terminal un-zoomed until the user toggled the resize button (which had its ownsetTimeout(resetZoom, 200)).applyFitScalenow retries across animation frames untilscrollWidthis positive and stable across two consecutive frames (capped at 30 frames). Mobile session also schedules a delayedresetZoomafter the cold-startscrollbackinit, mirroring the existingresizedhandler.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
terminalDriverChangedIPC event.Test plan
pnpm tc:webcleanpnpm tc:nodecleanpnpm lintclean (0/0)pnpm vitest run pty-connection.test.ts19/19 passpnpm vitest run src/main/runtime/mobile-presence-lock.test.ts16/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.ts23/23 passtsc --noEmitandoxlintcleanManually validated:
Made with Orca 🐋