* perf(mobile): avoid unchanged worktree catalog payloads
* fix(mobile): isolate catalog snapshots by limit
* review: reassert host truth on unchanged polls; content-address snapshots
Client — the `changed` gate meant an unchanged poll skipped setWorktrees /
setLastKnownWorktrees / setCachedWorktrees, so optimistic local edits
(togglePin, handleDeleteWorktree's failure re-add) and the #8498 cache guard
were no longer repaired while the host catalog was stable. The gate bought
nothing: setCachedWorktrees is an in-memory Map write and areWorktreeListsEqual
already ran every poll, so the steady state still short-circuits on array
identity. All wire savings are unaffected. admit() now just returns the
confirmed rows and HostScreen applies them exactly as it did pre-PR.
Also on the client:
- a stale response from a superseded client/host no longer clears the token the
current client/host just established
- discriminate on `worktrees` rather than on `'unchanged' in response`, so a
future catalog field named `unchanged` can't reclassify a full response
- useRef over useMemo for the snapshot client; React may discard memoized values
- hoist WORKTREE_PS_FULL_LIMIT so the truncates-at-200 rationale travels with it
Host — replace the per-limit snapshot cache with a content-addressed id (ETag
semantics). Ownership lives in the id, so concurrent clients, differing limits,
and runtime restarts are correct by construction; this drops the LRU, the
eviction policy, the per-runtime WeakMap, and the retention of up to 8 full
catalogs. The remaining cache is a pure memo: because ids derive from content,
dropping or thrashing it costs CPU and nothing else. Keeping the memo also
keeps the measured steady-state cost — hashing every poll instead measured
2.24ms vs 0.75ms for the compare on a 310KB catalog.
Verified: mobile 2784 passed / 3 skipped, src/main/runtime/rpc 1064 passed,
node + mobile typechecks, oxlint, oxfmt, max-lines ratchet.
* fix(runtime): isolate catalog snapshot memo
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix(mobile): stop serving a pre-write host-list snapshot to loads issued after the write
removeHost/persistHost await hostListMutation, but the in-flight loadHosts()
de-dupe handed back a pass that started BEFORE the write committed, so a load
issued after removal repainted the removed host card (#8791). Every durable
write now drops the shared pass via host-list-load-sharing.ts so the next
caller reads fresh; concurrent loads with no write between them still share
one Keychain pass.
Also extracts the host action sheet into host-list-action-sheet-actions.ts to
pin closeBeforePress on Edit host + Remove (the freeze half of #8791, already
fixed by #8536).
* fix(mobile): invalidate host loads after token writes
* fix(mobile): protect host token cache from stale reads
* fix(mobile): keep source-control layout steady while Create PR eligibility loads
The Create PR entry unmounted until the first hostedReview.getCreationEligibility
answer arrived, so on a cold open the changed-files list painted first and then
shifted down 54pt (createPrBlock marginTop 12 + createPrButton minHeight 42)
when the button appeared — while the user was already tapping (#8411).
- buildMobileCreatePrAction: cold loading now reserves the row with a disabled
placeholder instead of unmounting it.
- useMobileHostedReviewEligibility: a fetch-imminent idle frame renders as an
in-flight load, so the reservation is present on the first painted frame.
- New per-worktree+branch memory of the last resolved eligibility seeds cold
loads, so branches whose answer is hidden (existing review, unsupported
provider) do not get a placeholder that collapses on every reopen.
Fixes#8411
* fix(mobile): harden source-control layout reservation
* fix(mobile): keep review status row footprint fixed
* fix(mobile): derive eligibility state from keyed snapshots
* fix(mobile): restore pairing self-heal and recover a wedged handshake
readPairingKeychainItem threw when an Android presence record pointed at a
SecureStore entry that read back null. Android reports absent and undecryptable
identically, so the keystore fault the presence record was added to survive
latched every caller out of its own orphan cleanup: the pairing journal store
never reached its null-secret branch, stale winner-stamped metadata survived,
and every later QR scan failed with "mobile relay pairing recovery pending".
Report absent instead and drop the stale presence claim, still without falling
back to the superseded older generation.
The handshake-timeout path closed the socket with no handleSocketClosed
fallback, unlike the connect-timeout and activity-probe paths. When React
Native omits onclose for a wedged transport the client stayed in 'handshaking'
forever with no reconnect armed.
* fix(mobile): keep the presence pin when a recorded keychain item reads null
Clearing the presence record on the self-heal removed the only thing that
stops readPairingKeychainItem's generation walk, so the next read fell back to
the superseded value under an older generation -- exactly what #11430's
presence record exists to prevent, and reachable for host device tokens and
relay resume bundles after an Android encrypt rotation. Return null and leave
the record in place; the null return alone unlatches every caller's orphan
cleanup, and delete/re-pair already clear or re-stamp the record.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): date synthesized socket closes in transport diagnostics
Move the log-only close clocks behind handleSocketClosed's stale guard so a
synthesized close records them and a late onclose can't clobber the replacement.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): account for delayed synthesized closes
---------
Co-authored-by: Orca <help@stably.ai>
* fix(checks): stop skipped and manual checks reporting as failures
Route every check-classification surface through one shared helper so
desktop renderer, desktop main and mobile agree on the same verdict.
- GitLab `manual` jobs and pipelines are neutral again, not action_required/failure
- `skipped` counts as passed everywhere, including mobile
- a neutral check no longer demotes a summary that has passing checks
* fix(checks): move the check-classification parity test into the renderer project
The parity table lived in src/shared but imported a renderer module, and both
config/tsconfig.node.json and config/tsconfig.cli.json are composite projects
that include src/shared without that renderer path, so `pnpm typecheck` failed
with TS6307 on two of its three projects. Only the web project spans both trees.
Co-authored-by: Orca <help@stably.ai>
* fix(checks): stop the Tasks-grid pill contradicting its own verdict
The checks pill's label, tone and icon all read one ProviderCheckSummary, but
getChecksLabel short-circuited on the raw `neutral` counter while the tone and
icon key off `state`. After the classification fix a PR with 19 success + 1
neutral renders an emerald CheckCircle2 pill that reads "1 unresolved", and
mobile's own label (which keys off `state`) reads "19/20 passed" for the same
summary.
Move the label into src/shared/provider-check-summary.ts so desktop and mobile
cannot fork it again, and key it off `state`.
Also covers deriveWorkItemCheckSummary, the desktop-main producer of the summary
that reaches the Tasks grid and the relay-paired mobile client. It was rewritten
here with no test at all; the parity table stands in derivePRCheckStatusFromRollup,
which is a different normalizer. The new main-process test drives getWorkItem with
a real statusCheckRollup fixture, pinning the StatusContext `state` fallback that
would otherwise be deletable with the whole suite still green.
Co-authored-by: Orca <help@stably.ai>
* fix(gitlab): route the pipeline job-array rollup through the shared check classifier
The array path in derivePipelineStatus kept its own copy of the rollup rules, so
manual-only read green and one unrecognized job status demoted a passing pipeline
to neutral — both disagreeing with every other check surface.
Also retry the packaged-CLI smoke temp cleanup on Windows: the copied Orca.exe can
still be locked by AV/indexers after every assertion passed, failing the package job.
Co-authored-by: Orca <help@stably.ai>
* fix(gitlab): stop the skipped pipeline string diverging from the Checks tab
- classifyPipelineString now counts a skipped pipeline as passing, matching
the per-check classifier; canceled stays neutral and is pinned as an
explicit, sign-off-pending divergence.
- Pin the production string path (head_pipeline.status) in the parity table
and note that the job-array branch has no production caller yet.
- Count skipped checks in the Checks panel's passing header so it agrees
with the checks pill.
- Correct the packaged-CLI smoke retry comment: the EBUSY is the smoke's own
just-exited Electron process, not AV/indexers.
Co-authored-by: Orca <help@stably.ai>
* fix(checks): finish cross-surface check parity and back out the skipped MR-card flip
Review follow-ups on the check-classification PR.
- PullRequestPage and GitHubItemDialog kept private copies of getCheckCounts /
getChecksSummaryLabel that still counted only `success` as passing, so a
2-success/3-skipped PR read "2 passing · 3 skipped" there and "5 passing" in
the sidebar. Both copies move to pr-check-counts.ts, which routes the passing
bucket through classifyCheckOutcome; action_required keeps its own amber
bucket. The summary icon now keys off passing count, so an all-neutral PR
stops painting a green tick above "0 of N checks passing".
- The sidebar checks header and triage strip still called
`{status: completed, conclusion: null}` pending, contradicting the grey
"Unresolved checks" pill. Both now read summarizeProviderChecks and render an
unresolved chip/strip instead of an amber spinner that can never resolve.
- classifyPipelineString('skipped') is reverted to neutral. That flip painted
MR cards green for pipelines that never ran, on the only GitLab path with
production callers, and contradicted the same function's deferral of
`canceled`. Both tone changes stay deferred, pinned by one test.
- classifyPipelineString('manual') resolves to pending rather than neutral: a
blocked pipeline is outstanding, and neutral let the worktree card fall
through to its emerald `open` default while GitLab still refuses the merge.
- TaskPage's checks pill helpers move to task-page-checks-pill.ts so the
"1 unresolved on a green pill" fix is actually pinned by a test.
- smoke-packaged-cli no longer lets an EBUSY cleanup replace the real failure.
* fix(checks): stop completed unknown checks from spinning
---------
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): route external mouse click and drag to the terminal
The terminal WebView suppresses mousedown/click at capture so xterm's own
mouse handling stays inert (its onData bytes are dropped by the mobile
bridge). That left hardware mouse clicks and drags with no path at all:
touch taps reached mouse-aware TUIs and drove selection, while a Bluetooth
mouse or trackpad click did nothing (#8818; wheel half landed in #11247).
Add a pointer-event router on the terminal surface (pointerType 'mouse',
left button only) that mirrors touch semantics:
- plain click: same pipeline as a touch tap (links/file paths first, then
tracking-mode press+release reports, else keyboard focus), and a click
on an active selection dismisses it like touch does
- drag with mouse tracking: press at the anchor, per-cell motion reports
(drag/any modes), release on pointerup or pointercancel
- drag without tracking: character-anchored selection reusing the touch
handle-drag plumbing (edge scroll, handles, copy pill)
Widen the RN gesture-input grammar to pass left-drag motion reports
(SGR button 32, default-encoding byte 64) through the existing
validation and rate limiting.
Mock server: echo the subscribe viewport and serialize scrollback so the
session screen leaves the resubscribe loop, serve the session-tabs
subscribe stream, and add a MOCK_TUI=1 mouse-tracking scenario plus a
[SEND] byte log - the rig used to reproduce and verify this fix on an
Android emulator.
Fixes#8818
* fix(mobile): capture the mouse pointer and clear stale gestures on pointerdown
A drag leaving the terminal surface dropped pointermove/pointerup without
pointer capture, stranding the gesture; a pointerup lost outside the
WebView could leave a tracked press latched until the next gesture.
* fix(mobile): end mouse gestures whose pointerup never reached the surface
Capture the mouse pointer on pointerdown so a drag that leaves the surface
keeps delivering pointermove/pointerup; when capture is unavailable and the
release is lost anyway, synthesize the release from the next buttons==0
pointermove or the next pointerdown, so a tracking TUI is never left with
the left button latched down.
* fix(mock-server): clear the terminal stream interval on resubscribe and unsubscribe
* fix(mobile): synthesize the lost-pointerup release at the pointer's current cell
* test(mobile): split terminal mouse click and drag coverage
* test(mobile): satisfy changed-line quality checks
* fix(mobile): cancel stale mock terminal callbacks
* refactor(mobile): extract mouse report cell mapping
* fix(mobile): clear state after closing final tab
* fix(mobile): clear terminal on empty snapshots
* fix(mobile): preserve terminal during transient empty snapshot
* fix(mobile): recover pairing save when the Android keystore alias is unusable
Orca Mobile could reach a state where pairing succeeded but the host could
never be saved, with every attempt failing identically:
Could not encrypt the value for key 'orca.host-token.host-...'
under keychain 'key_v1'. Caused by: unknown
expo-secure-store derives ONE Android keystore alias from the keychain
service (`<service>:unauthenticated`) and shares it across every host token,
so a single unusable alias rejects all writes. Its built-in self-heal only
covers KeyPermanentlyInvalidatedException, and a null-message
GeneralSecurityException takes the unrecoverable branch instead — leaving
onboarding permanently blocked, which a reinstall does not clear.
Route host-token persistence through a keychain generation that rotates to a
fresh service (and therefore a fresh alias) only after a write has already
failed. Generation 0 keeps expo's default service so tokens written by
earlier builds stay readable, reads walk back through retired services, and
deletes clear every generation so a rotation cannot strand a live credential.
Refs #6600
* fix(mobile): record a keychain rotation before storing the token under it
Greptile flagged that a token could be stored under a generation the
generation record never captured. `commitGeneration` swallowed the
AsyncStorage failure and cached the new generation in memory, so the write
succeeded for the rest of the session — but the next launch re-read the old
record, and because reads only walk back from the recorded generation they
never probed the newer service. The host silently vanished and the user had
to re-pair, which is the same class of loss this change set out to fix.
Record the rotation first and let a storage failure propagate, so a token is
never written under a generation reads won't reach. Advancing the record
before the write is safe because reads walk back through every older service;
the worst case is one spent generation and one extra probe per miss.
* fix(mobile): harden pairing keychain recovery
* fix(mobile): harden pairing keychain recovery state
* fix(mobile): fail closed on unreadable pairing credentials
* fix(mobile): keep terminal input composable while the connection is cut
Fixes#6713. While the socket was down every input control on the mobile
session screen was hard-disabled by the single canSend gate — the keyboard
would not even open, and everything typed during the outage was silently
discarded.
Split the gate: canCompose (local composing, survives an outage) vs canSend
(needs the live socket). The buffered command box stays editable offline and
holds the text; the send button, accessory keys, and live-input capture stay
connection-gated; the live/buffered mode toggle stays tappable so live-mode
users can reach the compose box. The return-key submit path holds composed
text instead of firing a doomed RPC.
Also reset the live-input mirror when the connection drops: bytes sent into a
stalled link are lost but were recorded as delivered, so the first
post-reconnect send replayed stale fragments or emitted phantom erases
(observed as `YZZYecho CLEANLINE` corrupting the next command on device).
* fix(mobile): stop stalled terminal input replaying into the PTY after reconnect
Device verification of the first commit surfaced the real replay vector for
the second defect: sendRequest parks in waitForConnected while disconnected,
so live-mirror deltas queued behind a dying send drain into the connect wait
and fire on the next socket — bytes typed during an outage executed tens of
seconds later (observed on device as the prompt reading `nOPQ` after
reconnect with no post-recovery typing).
Add SendRequestOptions.failWhenDisconnected — reject now instead of parking —
and opt in every keystroke-grade terminal send: live mirror, accessory keys,
buffered command send, and gesture arrows. Deliberate command sends
(initialPrompt on terminal create) keep the connect wait.
terminal.send param construction moves to terminal-send-request.ts and the
accessory raw-send tail to terminal-live-accessory-raw-send.ts.
Re-verified on simulator through a blackhole cut-proxy: text typed during the
stall no longer replays, and the first post-recovery command executes verbatim.
* test(mobile): assert route-slice anchors are unique so pins cannot slice the wrong region
* docs(mobile): trim replay-fix comments to one-line rationale
* fix(mobile): render the terminal caret for main-buffer TUIs
The mobile WebView never flipped xterm's isCursorInitialized, which both
renderers check before they ever read cursorStyle/cursorInactiveStyle. The
native TextInput owns keyboard focus and xterm's textarea is inert, so the
focus and keydown paths never fire, leaving DECSET 1049 as the only way to
flip it. Alt-screen TUIs got a caret as a side effect; Claude Code, which
redraws its composer in the main buffer, never did.
Set showCursorImmediately so the caret does not depend on focus, and switch
cursorInactiveStyle to block: mobile is permanently unfocused, so that option
is what renders, and a bar is dpr device px wide and disappears under the fit
scale() the WebView applies.
Refs #8313, #7093
* test(mobile): prove main-buffer caret rendering
* test(mobile): calibrate terminal listener cleanup
* test(mobile): keep caret oracle teardown assertion-free
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(editor): map .cts/.mts to the typescript language id
The comment above EXT_TO_LANGUAGE already documents that Monaco maps
.tsx/.cts/.mts onto the typescript language id, but only .tsx was in the
table, so .cts/.mts files opened as plaintext.
* fix(mobile): map cts and mts to typescript
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(checks): rank successful checks above skipped and neutral
Checks were ordered with `skipped` (4) and `neutral` (3) ahead of
`success` (5), so a PR with a long tail of skipped jobs pushed every
passing check below the fold — you scroll past a wall of "Skipped" to
find out whether anything actually ran.
Rank the no-signal conclusions last (`success` 3, `neutral` 4, `skipped`
5) and pull the order out of its three duplicated copies
(checks-panel-content, PullRequestPage, GitHubItemDialog) into
`src/shared/pr-check-severity-order.ts`. Unknown conclusions now sink to
the bottom instead of silently ranking as `neutral`.
* fix(checks): look up check ranks through a Map, not an object literal
An object-literal rank table resolves `constructor`, `toString`, and
`__proto__` off Object.prototype, so those keys returned a function
instead of falling through to UNKNOWN_CHECK_RANK — the comparator then
subtracted functions, went NaN, and left the list in arbitrary order.
Conclusions come from provider payloads, so keep the lookup on a Map and
cover prototype property names in the test.
* test(checks): cover provider-neutral ordering states
* fix(checks): preserve actionable provider states
* fix(checks): preserve unresolved provider rollups
* fix(checks): keep unknown GitLab rollups neutral
* fix: preserve neutral review check summaries
* fix: complete provider-neutral check ordering remediation
* fix: use provider-neutral mobile review status input
* fix: hydrate GitLab mobile review status
* fix: type mobile GitLab review hydration
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
ws.onopen zeroed reconnectAttempt before the handshake, so any endpoint
that accepted the socket but never authenticated pinned the counter at
0-1: no escalation gate could fire, backoff never grew, and every screen
showed "Connecting…" forever (issue #10119). Reset the counter on
e2ee_authenticated instead, and make classifyConnection apply the
warning/unreachable gates during connecting/handshaking so an escalated
verdict latches through redials.
* fix(mobile): stop re-creating a terminal when the session tab list empties
The session route treated "zero session tabs" as "this workspace has never
had anything" and auto-created a terminal. Closing the last tab prunes
sessionTabs and nulls activeHandle locally, which is exactly that state, so
the close was immediately followed by a brand-new terminal — and the guard
re-arms on every route mount, so it recurs across visits (#9717, #7345).
Gate the auto-create on whether this route has ever published a non-empty tab
list for the workspace. A cold hydrate still gets its first terminal; an
emptied list gets the empty state and its create button.
Extracted to a hook because the route file sits at its max-lines cap; the
call site is 4 counted lines smaller than the effect it replaces.
* fix(mobile): keep emptied workspaces empty across visits
* fix(mobile): reach the auto-create callbacks without a render-time ref write
The hook kept `consumeCreationRoute`/`createTerminal` out of the effect deps by
writing latest-refs during render. React can replay or discard a render, so the
write can leak from UI that never commits — React Doctor flags it as a blocking
"Ref mutated during render" error, which failed PR Checks' static analysis.
useEffectEvent (React 19.2, already used in SourceControl.tsx) gives the same
stable-callback-outside-deps behaviour with no render-time mutation. Retire the
deprecated `MutableRefObject` for `RefObject` in the same pass.
Retargets the source pin at the new wiring; test counts unchanged.
* docs(mobile): document the two per-route reset contracts
Both exported helpers exist for a non-obvious reason — they must be re-created or
re-derived per worktree, or a reused route inherits the previous workspace's
hydration state and the resurrection guard silently disarms.
* fix(mobile): preserve terminal creation through reconnect
* fix(native-chat): mirror multi-line launch drafts into the chat composer
seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline,
so every Linear launch ("Linked Linear issue: X\n<url>") and any GitHub launch
with a typed note was invisible in chat. The rejection existed because the send
path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with
embedded newlines.
Orca injects the draft itself, so when the composer still holds exactly what was
injected the buffer already IS the message: the send becomes the submit key
alone — no clear, no paste, nothing that can concatenate, and multi-line submits
as one turn for free. Only the edited case needs real buffer replacement, and
that now clears every line and verifies against the agent's rendered input line
instead of firing blind.
Measured on real PTYs against Claude Code and codex (both agree exactly):
clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts
for the law, the sequences that do NOT work, and why an upper bound is safe.
* fix(native-chat): send the mobile clear burst as its own write
Live QA caught the bundled form failing: a multi-line burst prefixed onto the
body in the SAME terminal.send reached the agent as LITERAL Ctrl+U characters,
so the parked draft survived and the message arrived as
draft + 21x \x15 + body. Sending the burst as its own non-submitting write —
the shape the image paste has always used — clears as intended.
The body write's own single-Ctrl+U prefix is dropped once that dedicated clear
ran, for the same reason: a Ctrl+U immediately followed by body text in one
write lands as a literal control character and headed the received message.
Re-verified live end to end: received prompt is exactly the draft, one turn,
zero control characters.
* test(native-chat): invert the multi-line Linear launch-draft mirror expectation
The Linear work-item launch seeds `Linked Linear issue: ENG-42\n<url>\n`.
This test pinned the pre-relaxation rule (multi-line drafts withheld), which
the send path no longer needs now that it submits the TUI buffer in place or
clears every line first — so it asserted the exact behavior the fix removes.
Assert the seeded payload instead of absence, so the test fails if the mirror
regresses to single-line-only.
* fix(native-chat): preserve launch draft send contents
* fix(native-chat): preserve confirmed send queue ordering
* fix(native-chat): preserve send pacing after renderer stalls
* test(native-chat): align activation with multiline draft mirroring
* fix(native-chat): clear launch drafts from any cursor
* fix(native-chat): retire mobile-consumed launch drafts
* test(mobile): stabilize QR capacity boundary fixture
* fix(mobile): keep a proxied wss host on :443 when editing
A host paired through a reverse proxy is stored as `wss://desk.example.com`
with no explicit port. Editing it — even to only change the display name —
rewrote the endpoint to `wss://desk.example.com:6768` and stranded the host,
with no warning.
`endpointPort` intentionally reports only explicitly written ports, so it
returns undefined for that endpoint. The edit screen passed that undefined
straight through as `fallbackPort`, where `resolveFallbackPort` substituted
the LAN `DEFAULT_PORT`.
Add `endpointPortOrSchemeDefault`, which falls back to the scheme's implicit
port for wss and leaves bare ws alone so LAN pairings keep landing on
DEFAULT_PORT, and use it for the edit screen's fallback. `normalizeHostEndpoint`
is untouched — filling a missing port from `fallbackPort` is its documented
contract and stays covered by its existing tests.
* review(mobile): preserve untouched host endpoints
* fix(mobile): preserve routed endpoint edits
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* feat(mobile): add session.tabs.list handler to mock server
The mock WebSocket server had no handler for session.tabs.list, so the
session screen of a paired dev client hung on 'Loading tabs' forever —
the terminal pane, live input, and command input could never be
exercised against the mock. Respond with a single ready terminal tab
wired to the existing term-1 fixture so the whole session surface works
offline.
* fix(mobile): complete the session.tabs.list mock contract
The new mock response omitted four non-optional fields of
RuntimeMobileSessionTabsResult: publicationEpoch and activeGroupId on the
result, and parentTabId and leafId on the terminal tab. Nothing caught it —
the object literal had no type annotation, and MobileSessionTabsStreamHealth
is generic over both result and tab. A shape-incomplete mock yields
untrustworthy repros for exactly the bugs it gets used for (session tabs,
split panes, pane-to-tab attribution).
Fill the fields with host-realistic values: a per-process publisher epoch, a
layout UUID leaf id, and the `${parentTabId}::${leafId}` surface id
mobileTerminalSurfaceId actually emits. Pin the shape with an explicit return
type so a future required field fails typecheck instead of silently drifting.
Move the fixture into its own module: inlining it pushed
mock-server-rpc-handlers.ts to 317 lines against a 300-line max-lines cap,
which broke `pnpm lint` on the parent commit. It registers through the file's
existing delegation chain, after the native-chat scenario so MOCK_NATIVE_CHAT=1
keeps ownership of the method.
Co-authored-by: Hanjoon Choe <hanjoonchoe@gmail.com>
* test(mobile): pin session tabs mock fidelity
Normalize the selector-backed worktree ID like the real runtime and cover the complete terminal surface response so future contract drift fails the mobile suite.
* fix(mobile): share terminal.list worktree resolution with session tabs
Main added `terminalListWorktreeId`, which the rebased session-tabs fixture
duplicated with a different no-selector fallback — `terminal.list` resolved to
the active fake worktree while `session.tabs.list` returned a literal 'mock',
so a session repro saw two different worktree ids for one screen.
* test(mobile): cover the bare session-tabs worktree selector
Answers the review note that only the `id:`-prefixed path was exercised.
* fix(mobile): make the mock publication epoch unique per process
Date.now() can repeat across a sub-millisecond restart, so the epoch did not
actually guarantee the fresh-publisher identity its comment claimed.
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
The mobile terminal WebView only handled touch. Wheel events fell through to
xterm, which either scrolls its own hidden viewport or — in the alternate
screen — emits cursor keys via onData, and the mobile onData bridge forwards
those to sendMobileTerminalQueryReply, which drops anything that is not a
query-reply grammar. Net effect: an external mouse or trackpad scrolls nothing
inside the terminal, and nothing reaches the PTY.
Attach a wheel handler on the terminal surface that reuses the touch path's
router: alternate-screen and mouse-aware TUIs get bounded cursor keys / wheel
reports through the existing validated terminal-input gate, and the normal
buffer gets the same coalesced scrollback scroll as a swipe.
Refs #6863, #8818
mobile/src/terminal/terminal-webview-{tap-routing,init-surface}.test.ts
request the happy-dom vitest environment, but happy-dom was only declared
at the repo root. The mobile suite resolved it by walking up into the root
node_modules, so `cd mobile && pnpm install && pnpm test` fails with
ERR_MODULE_NOT_FOUND and loses those 12 tests unless a root install
happens to be present.
* fix: stop notification loss, credentialed cache reuse, and clipboard clobber
Mobile catch-up (#8591): fetchMissed swallowed the RPC failure while
deliverLive kept advancing and persisting lastDeliveredSeq, so the next
successful catch-up asked from above the abandoned range and the desktop
cut it. Sessions are module-scope, so an unchanged epoch never resets it.
Quarantine the watermark at the last contiguously-delivered seq and hold
it there until some later catch-up actually drains — not just one retry.
A batch cut short by a teardown quarantines at the last event it settled.
Jira attachment cache: currentEpoch summed two independent counters, so a
site at siteEpoch 1 read the same value before and after a global clear.
The mid-flight guard passed and re-inserted credentialed image bytes that
"disconnect all" had just purged — resident for the process lifetime since
pruneExpired has no timer. One monotonic ticker, compared by max.
Web copy fallback: the handler registered in the capture phase, so xterm's
bubble-phase listener overwrote text/plain with the terminal selection
afterwards; served was already true, so the copy reported success. Every
Orca copy affordance over plain HTTP (Copy Pane ID, Copy Path, commit SHA,
PR URL) pasted the terminal selection. Bubble phase with
stopImmediatePropagation. Covers the secure-context retry branch too,
which shares the same helper.
* fix: roll back the persisted watermark on catch-up failure; cover stopImmediatePropagation
Adversarial review of a98d7f4d5d found two gaps.
1. The quarantine clamped only writes made AFTER the failure. getMissedSince
waits up to 30s, so a live event routinely persists a higher seq while the
request is still outstanding; that value stayed on disk, and the next launch
read it back and resumed past the abandoned range -- the original bug,
reached through a restart. quarantineCatchUpWatermark now re-persists the
clamped seq, so the stored value never outlives the gap it guards.
2. web-clipboard-copy-terminal-selection's second test registered its "late"
document handler BEFORE the fallback's, so it lost on registration order
alone and stopImmediatePropagation was never exercised -- the test passed
with that line deleted. Bubbling reaches the document before the window, so
a window-level listener is what actually requires it.
* fix(mobile): mark a notification seen only once its show lands
A pre-marked seen key made a rejected show unrecoverable: the next
catch-up re-fetched the seq and the dedup guard dropped it, and the
first later event to drain the batch lifted the quarantine past it.
Also contains the rejection so it does not escape the un-awaited
'ready'/live handlers as an unhandled rejection.
Co-authored-by: Orca <help@stably.ai>
* test(web-clipboard): pin stopImmediatePropagation with a same-target handler
Both existing cases passed with plain stopPropagation, and with the listener
back in the capture phase — neither half of the fix was actually pinned. The
window-level clobber is on a different target, so stopPropagation suppresses
it too. Registering the clobber on the document, ordered after the fallback's
own listener, is the only shape stopPropagation cannot cover.
Addresses the review comment posted after the last commit.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(ai-vault): resume a bridged Codex session under the selected account's home
The account session bridge hardlinks every rollout into each per-account
CODEX_HOME, and vault dedup keeps the lexicographically-smallest alias, so
Resume could pin an inline CODEX_HOME naming a peer account — running the
session under that account's auth.json and quota. At resume time the owning
host now substitutes the selected account's home when it holds the same
rollout at the same sessions-relative path, declining on any uncertainty so
resume degrades to today's behavior instead of failing.
* fix(ai-vault): repin dropped sessions without a cwd instead of resuming under the wrong account
The drag payload only carried sessionCwd when session.cwd was truthy, so a
null-cwd codex session dropped onto a pane silently fell back to the prebuilt
command - which pins the wrong account's CODEX_HOME, the exact defect this PR
eliminates on the other resume surfaces.
- Serializer always sends sessionCwd (null when the session has no cwd), so
absence now only means an older-serializer payload.
- The repin rebuild accepts a null cwd (the builders already omit the cd
prefix), matching the sidebar Resume/Copy paths which repin regardless of cwd.
- An unrepinnable payload (absent sessionCwd) now fails loudly with guidance
instead of silently resuming under the wrong account's home.
* fix: address pr-bug-scan validated finding from #6471
stripTags now removes real markup (closing/self-closing/attributed/custom tags) outside the allow-list; only bare identifier-glued generics like Array<string> are kept. Blocks svg/center/custom leak a
* fix: address pr-bug-scan validated finding from #6471
stripTags now removes real markup (closing/self-closing/attributed/custom tags) outside the allow-list; only bare identifier-glued generics like Array<string> are kept. Blocks svg/center/custom leak a
* fix(mobile): harden markdown preview tag stripping
* fix(mobile): preserve angle-bracket prose while stripping tags
---------
Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix(native-chat): surface draft launch context in chat composers
Creating a workspace from a GitHub issue delivers the issue link only into
the agent TUI's input buffer (argv prefill or startup paste), so the chat
view showed no trace of it on desktop or mobile.
Desktop: draft launches now seed an in-memory launch draft keyed by tab id
(direct work-item launches, background GitHub work-item creates, quick-create
composer, and new-tab draft deliveries). The chat composer adopts the seed
once as its editable draft, declines permanently if the composer already has
text, and drops an untouched copy when any user turn lands (the one-line TUI
input means the prefill was submitted or deliberately cleared) or on its own
send, whose existing input pre-clear retires the TUI copy.
Mobile: the host publishes the draft as an optional launchDraft field on the
mobile terminal tab snapshot (additive, no protocol bump) and the mobile
composer adopts it with the same once-only/decline/resolve semantics. Mobile
chat sends now also pre-clear the TUI input line (Ctrl+U, desktop parity) so
a pending prefill cannot concatenate with the sent message.
Completion seeding resolves the launch tab from the synced store tabs when
the backend spawned the terminal and activation reports no primaryTabId.
Split the Windows shell-quoting tests into their own file to stay within the
max-lines budget.
* revert(mobile): drop incidental pnpm-lock churn from the launch-draft branch
The libc binding fields and the @typescript-eslint peer re-resolution came from
a local install, not from this change; mobile/package.json is untouched.
* fix(native-chat): resolve launch drafts without trusting cross-host clocks
The rule required a user turn stamped at or after the seed. Grok omits row
timestamps, so a Grok launch draft never resolved; and the seed time is a
renderer clock while the stamp comes from the executing host's JSONL, so a
remote workspace whose clock trailed never resolved either. Both left the
composer adopting an already-submitted prefill, which re-sends it as a
duplicate turn.
Resolve on any user turn that is not PROVABLY older than the seed (a launch
draft's session starts with zero user turns), with the existing cross-host
skew slack, plus a timestamp-free backstop for wider skew: a new tail user
turn since the draft was first observed. "Load earlier" prepends, so it
cannot move the tail and cannot over-resolve.
Split out of native-chat-pending.ts to stay under the max-lines ratchet.
* fix(worktrees): seed the launch draft on the agent's own tab, never on tabs[0]
Two defects in the completion seed:
- The tab was resolved by array position. buildStartupOpt returns undefined on
the backend-spawn path, so applyDefaultTerminalTabs stamps launchAgent on no
tab and the launchAgent guard was dead there. A repo with default terminal
tabs ("dev server", "logs", ...) got the draft on a tab that runs no agent,
and then published it to mobile as THAT tab's launchDraft. Correlate on the
backend startup tab, then on a launchAgent-stamped tab, then on primaryTabId
(which is the agent tab whenever the renderer owns startup); never tabs[0].
- Runtime-owned worktrees mirror their session tabs async, so tabsByWorktree
was empty at seed time and the seed was silently dropped for that whole host
class. Defer to the first mirrored tab via the existing delayed-delivery
queue, which now holds every pending delivery for a worktree instead of one
(setup/issue commands and the seed both wait on the same first tab).
* fix(store): evict nativeChatLaunchDraftByTabId on every teardown path
The new map was absent from all four paths its sibling
nativeChatLaunchPromptByTabId participates in: tab close, the orphan terminal
sweep, the bulk worktree purge, and the removeWorktree teardown. A stranded
entry is worse than a plain leak here because sync-runtime-graph keeps
publishing it to mobile as that tab's launchDraft.
* fix(native-chat): only seed single-line unsubmitted launch drafts
The unsubmitted-delivery branch seeded on every draft delivery, which also
caught the agent-session-fork path whose prompt is multi-line scraped context.
The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a
multi-line prefill cannot be fully cleared and its earlier lines would glue
onto the next message. The GitHub work-item draft this feature targets is a
bare issue URL, so narrowing costs it nothing.
Also assert the composer retires the seed after a send — deleting that call
previously failed no test.
* fix(mobile): stop the chat pre-clear from wiping a just-pasted image
The text write set clearInputFirst unconditionally. On the image path that
Ctrl+U lands AFTER pasteMobileNativeChatImagePaths already pasted the image,
so the agent receives the text alone while acceptSend still renders the
thumbnail on the sent bubble — silent image loss.
Desktop's image path clears exactly once, before the paste, and never again;
mobile now matches: pre-clear only when nothing was deliberately pasted first.
The image paste already leads with its own Ctrl+U, so a launch-draft prefill
parked on the input line still cannot glue onto the message.
Pinned at both levels: the controller test drives the real send hook and
asserts clearInputFirst per branch, and the send module asserts the wire text
carries no leading \x15. The image-attachments test injects its own baseSend,
so it structurally could not observe this.
* fix(mobile): hold the launch-draft prefill until the transcript settles
session.tabs delivers launchDraft before the transcript read resolves, so the
seed effect could run against an empty in-flight message list and miss the
user-turn decline. Launching from an issue, submitting the prefill in the TUI,
and never opening desktop chat (nothing else clears the host seed) then
prefilled the mobile composer with the already-sent issue link — a send tapped
before it retracted duplicated it to the agent.
Thread the session's loading state through and skip the seed while the read is
in flight. idle/waiting-session still seed: no session means no user turns.
* fix(runtime): publish a launch draft to mobile only for the tab's own agent
The publish had no agent check while the desktop consumer declines on
mismatch. The seed is keyed by tab id, which survives a pane's agent switch, so
mobile could adopt a draft desktop refuses — seed for claude, never open
desktop chat, switch the pane to Codex, and mobile prefills the Codex chat with
the Claude-era issue link. Align publish with the consumer.
* fix(native-chat): take the launch-draft baseline only after the transcript loads
The timestamp-free backstop snapshotted the transcript's user turns on first
observation of the draft, which can happen while the read is still in flight and
`messages` is []. A pane bound to a session that already had user turns then
backfilled above that zero baseline with a different tail id, so clause 2
resolved and silently dropped the seed — the launch context never appeared, and
the feature no-oped for exactly the panes it was meant to serve. Clause 1 was
already correct there (that history is provably older than the seed).
Gate baseline capture and resolution on the transcript read settling, the same
shape mobile's drafts hook uses. Clause 1 is unchanged; while loading the merged
list is empty anyway, and a pane with live appends is never reported 'loading'.
Also restore clause 1's short-circuit: it scans with .some() again and only
allocates the user-turn list when falling through to the backstop.
NativeChatView sat at exactly the 400-line cap, so the composer's two
launch-draft props are now spread from the hook result they already mirror.
* fix(native-chat): reject multi-line launch drafts inside the seed helper
The single-line guard lived in deliverLaunchPromptToAgentTab, so the two
other seeding entry points (worktree create, direct work-item launch)
bypassed it — and every Linear launch is multi-line by construction
("Linked Linear issue: STA-…" + url). The chat send pre-clears the TUI
with Ctrl+U, which kills to start of LINE, so those earlier lines stay
parked to glue onto the next message.
* fix(worktrees): keep the deferred agent seed off ambiguous mirrored tabs
The runtime-owned deferred path fell back to tabs[0], which the module's
own docstring forbids: with repo default tabs ("dev server", "logs") the
seed lands on a tab running no agent, where mobile withholds it and
desktop's agent check ignores it — the feature is silently dead for that
create and the entry leaks until tab close.
The queue entry is consumed before delivery, so there is no retry to fall
back on; accept the first mirrored tab only when it is the worktree's
only one and so unambiguously the agent's.
* fix(mobile): treat a launch-draft-only session-tab frame as a change
mobileSessionTabEqual's terminal branch never compared launchDraft, and
the route keeps `prev` when tabs compare equal — so a publish whose only
delta is the draft appearing or retracting was discarded and never
reached the composer. Live QA passed only because agentStatus happened to
change in the same frame.
MobileSessionTab's terminal variant did not declare the field either
(the controller read it through the structurally wider
MobileNativeChatTab), which is why TypeScript never flagged it.
* fix(mobile): judge a launch prefill only from its own settled transcript
Two ways the drafts hook was reading a transcript that was not the active
chat's:
- transcriptLoading came from `status`, a plain useState written by a
passive effect declared before the drafts hook. On the commit where the
tab identity changes it still holds the previous tab's value, so the
guard was off on exactly the render that seeds: first entry saw
status 'idle' with an empty list and seeded an already-submitted link,
and a tab switch declined the new tab's prefill from the old tab's
turns. The session hook now tracks the identity its messages describe
and reports transcriptLoading until they agree; the retire effect gates
on it too.
- Leaving chat view nulled launchDraft while draftKey stayed the same,
which the hook could not tell from a host retraction — it declined the
prefill permanently, so peeking at the terminal dropped the context.
The controller now passes the raw field plus an explicit chatActive
flag, and both effects hold their state when the tab is not on chat.
The controller wiring was previously unasserted: replacing both props
with constants left all 795 mobile session tests green.
* fix(native-chat): keep the launch-draft baseline across a transcript reload
baselineKey went null whenever the transcript was loading, and the null
branch DISCARDED an already-valid baseline taken from a settled read. It
was then re-taken from the fuller list, swallowing the very user turn
that resolves the draft — so a stale prefill gets re-adopted as a
duplicate turn. Key the baseline on draft identity alone and gate only
the capture.
session.status is also not a truthful read-in-flight signal: a live
'working' hook outranks 'loading', so the guard could be off over an
in-flight empty list. Expose the read phase itself and gate on that.
* test: cover the launch-draft reducers and the sync-key skip gate
Every consumer test injects the three launch-draft reducers as bare
vi.fn()s, so reducing markNativeChatLaunchDraftAdopted to a no-op left
2609 tests green — while in the app the composer would resurrect the
prefill after every manual clear.
canSkipRuntimeMobileSessionSyncKeyBuild had no launch-draft case either:
when it skips, the sync key is never even built, so the existing
getRuntimeMobileSessionSyncKey case cannot catch its removal.
* fix(native-chat): hold the launch-draft baseline in state, not a render-mutated ref
react-compiler rejects reading or writing a ref during render. Adjust the held
baseline with the sanctioned render-time setState instead, keeping the local
copy so the render that first sees a settled transcript resolves against it.
* fix(mobile): carry the transcript identity in the session read state
react-doctor flags the separate loadedIdentity state as an extra render for a
derivable value. Hold status alongside the identity it describes in one state
written by the subscription effect, so transcriptLoading derives from it.
* test(native-chat): assert the readPhase contract without the hook-status race
The test asserted status === 'working', which depends on liveStatusOverride
winning over ambient transcript state — green locally, red under CI load. The
contract is that readPhase stays 'loading' once live content unmasks status,
so assert exactly that; it still fails if readPhase derives from status.
* fix(mobile): derive pre-read chat status instead of writing it from the effect
react-doctor's no-derived-state-effect flags idle/waiting-session/loading being
set in the subscription effect: all three are pure functions of the props. Derive
them during render and keep state only for the genuinely async outcome, tagged
with the identity it describes.
The tag now gates `messages` too, so a just-switched tab never sees the previous
tab's transcript at all rather than seeing it behind a loading flag.
* fix(mobile): drop a settled chat read once its subscription is torn down
The settled outcome was only ever replaced by a newly arriving frame, so any
effect re-run that landed back on an already-settled identity resurfaced it over
a list the same effect had just cleared: 'ready' with no messages and
transcriptLoading false. Toggling out of chat view and back hit this every time
(the agent goes null, then returns), flashing the "start a chat" empty state over
a real conversation and opening the launch-draft seed's decline check on an empty
transcript. A reconnect did the same via the client dep.
Identity and client are the effect's only inputs, so tagging the read with both
and dropping it during render when either moves covers every re-run.
* feat: [AI-GEN] add Trae CLI as a supported TUI agent
Closes#10579.
Wire trae-cli into the desktop and mobile agent catalogs following the
same integration pattern as other CLI agents (e.g. Ante, Devin):
- src/shared/types.ts, tui-agent-config.ts: register 'trae' with
detectCmdAliases (traecli/trae-agent) and argv prompt injection,
matching trae-cli's `trae-cli [prompt]` contract. The CLI's own
third documented alias `ta` is intentionally excluded — too generic
a 2-letter name to use as a PATH-existence detection signal without
false-positiving on unrelated tools.
- src/shared/trae-headless-command.ts: recognize `--print`/`-p` and
`--output-format json|stream-json` as one-shot headless invocations
(same shape as claude-headless-command.ts) so they aren't mistaken
for a live interactive session.
- agent-kind.ts, telemetry-events.ts, agent-status-types.ts,
agent-type-label.ts, tui-agent-display-names.ts,
tui-agent-permissions.ts (YOLO via trae-cli's own --yolo flag),
tui-agent-selection.ts: standard per-agent registrations.
- agent-catalog.tsx, agent-favicon-assets.ts,
mobile/src/tasks/mobile-tui-agents.ts,
mobile/src/components/mobile-agent-icon-assets.ts: catalog entries
and bundled favicon (fetched from docs.trae.cn, required by mobile's
offline-icon invariant test).
- i18n: add the "Trae" label to all five locale catalogs (en/es/ja/ko/zh).
- Tests: agent-process-recognition, agent-status, tui-agent-startup.
Verified with `pnpm typecheck` (desktop + mobile), the relevant vitest
suites (869 tests across 12 files, all green), oxlint (clean), and a
real end-to-end launch of the actual trae-cli binary through Orca's
pty.spawn IPC path (confirmed via the OS process table).
* fix: [AI-GEN] point Trae catalog entry at the real CLI quick-start doc
docs.trae.cn/cli (what the installed CLI's own --help text prints as
its "User manual" link) soft-404s — the docs site restructured and the
working page is docs.trae.cn/cli_get-started-with-trae-cli (confirmed
by HTTP fetch: real page title "TRAE CLI 快速开始" vs the old path's
"404 - 页面不存在"). Addresses CodeRabbit's homepageUrl review comment.
* fix: [AI-GEN] detect Trae on traecli, not the ambiguous trae-cli name
Per @AmethystLiang's review: the open-source bytedance/trae-agent
project (MIT, ~12k stars) registers its own console script as
`trae-cli` (pyproject.toml: `trae-cli = "trae_agent.cli:main"`), an
entirely unrelated CLI with a different contract (`trae-cli run
"task"`, `-p` short for `--provider`). Detecting on bare `trae-cli`
would false-positive on that project's installs and break launch for
anyone who has it instead of the actual TRAE CN CLI.
- tui-agent-config.ts: detectCmd/launchCmd/expectedProcess -> `traecli`
(TRAE CN's own installer symlinks this alias too, but the other
project does not ship it). Dropped the `trae-agent` alias entirely —
it's the colliding project's literal repo name, the highest
false-positive string available.
- agent-catalog.tsx: cmd -> `traecli` to match; faviconDomain ->
`www.trae.cn` (bare `trae.cn` 404s on Google's favicon service;
`www.trae.cn` is the product-root domain that actually resolves).
- mobile-tui-agents.ts: faviconDomain -> `www.trae.cn` to match.
- Tests updated: agent-process-recognition now asserts `trae-cli` and
`trae-agent` are NOT recognized as Trae (regression guard against
reintroducing the collision); tui-agent-startup updated for the new
launch command.
promptInjectionMode stays `argv` and the headless-command file stays
as-is — both verified against the real TRAE CN CLI's actual --help
output (pasted in the PR review thread), not assumptions.
* refactor: [AI-GEN] share one print-mode headless matcher across agents
trae-headless-command.ts was a rename-only fork of claude-headless-command.ts,
and ante-headless-command.ts carried a third copy of optionName. Collapse both
print-mode files into print-mode-headless-command.ts, dispatch from a
Partial<Record<TuiAgent, ...>> table instead of an if-chain, and compress the
Trae comments to the repo's one-line style.
* fix: [AI-GEN] terminate Trae flag parsing before the positional prompt
`traecli` is a Cobra CLI with subcommands, so an argv prompt starting with
`help`, `config`, `-…` was dispatched as a subcommand or flag instead of being
run as the task. Add `argvPromptSeparator: '--'` (same reason Grok has it), and
stop the shared print-mode headless matcher at `--` so a prompt that reads like
`--print` no longer drops the pane out of agent recognition.
* docs: [AI-GEN] name both Trae CLIs explicitly in the detect-name comment
Co-authored-by: Orca <help@stably.ai>
* docs: [AI-GEN] drop the vendor tag from the Trae union comment
Co-authored-by: Orca <help@stably.ai>
* fix: [AI-GEN] guard the nullable startup plan in the Trae separator test
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: 陈泽榜 <chenzebang@jianzhikeji.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): heal a stale input line before sending diff-review notes
The stale-input marker is keyed by terminal handle, not by surface, so a
paste orphaned on a terminal by native chat is still marked when the user
sends diff-review notes to that same terminal — and those notes were
submitted on top of it. Gate the send on the heal, as the native-chat
answer send already does; when the clear fails, surface the error instead
of dropping the note silently.
Completes a follow-up deliberately deferred by #10480.
* test(mobile): assert the post-heal send carries the notes, not another clear
* style(mobile): trim the stale-heal comment to its non-obvious why
Keeps the terminal-handle keying and the #10228 link (why a NativeChat-named
helper runs in diff review) and the deviceToken rationale; drops the clause
that restated the call. Addresses CodeRabbit review feedback.
* fix(mobile): keep the reconnect watermark alive across the app's own teardown
The catch-up added in #8690 could never run. app/index.tsx unsubscribes the
notification stream on every non-'connected' state and builds a fresh
subscription on reconnect, so the closure holding the ready-counter, the
delivered watermark and the seen-set is destroyed exactly when a reconnect
needs them. Every reconnect looked like a cold open, `reconnectReadyCount`
was always 1, and notifications dispatched while the socket was down were
never fetched.
Move that state to a per-host module-scope session so it survives the
teardown.
Refs #8591
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): tag the notification watermark with a counter epoch so a desktop restart can't kill catch-up
The desktop's notification `seq` is a per-process in-memory counter that starts
at 0 on every launch. The mobile client's watermark is persisted in AsyncStorage
and monotonic. After a desktop restart the two index different counters, so a
client holding seq 57 meets a fresh counter at 2, `57 >= 2` cuts everything, and
reconnect catch-up dies silently until the new process out-dispatches the old
watermark — 57 notifications later. Users see nothing and get no error (#8591).
Stamp every dispatched notification with an epoch identifying the counter
lifetime, ride it on the `ready` frame and the getMissedSince response, and
persist it beside the watermark. A watermark whose epoch doesn't match the live
counter is void: the client resets to 0 and the desktop returns its retained
buffer instead of nothing.
The epoch param is optional on the wire in both directions, so a client or
daemon that predates it degrades to today's seq-only cut rather than erroring.
Also extracts the OS-permission helpers to notification-permissions.ts (re-
exported, so no importer changes) to keep mobile-notifications.ts under its
max-lines budget.
Mutation-tested: 3 mutations applied to the epoch logic, 3 killed — including
the storage-seed race guard, whose first mutant survived until the deferred-read
test was added.
* fix(mobile): make the notification watermark atomic and counter-scoped
Round-1 review found four ways the epoch fix could still lose notifications.
All four are addressed here.
1. Seen-set survived an epoch change. Seen-keys are seq-derived, and terminal
bells carry no notificationId (they key on `seq:N` alone). After a restart
the fresh counter re-issues low seqs, so a replayed post-restart bell was
dropped as a duplicate of a bell from the previous counter. The dedup window
belongs to one counter lifetime, so it is cleared on epoch change.
2. Legacy watermarks were trusted. Pre-upgrade installs stored a bare seq with
no epoch. Adopting the first observed epoch as "nothing changed" left that
unprovenanced seq cutting a counter it was never measured against — #8591
through the upgrade path. An epoch-less seq no longer survives adoption.
3. seq and epoch were separate storage keys. A process death between the two
writes left epoch-B beside seq-57-from-A: a pair that looks internally valid
on the next launch and is therefore trusted. They are now one JSON value,
which cannot tear, with a read-only migration from the legacy key.
4. Sessions were never retired. They live at module scope so they survive the
subscription teardown a reconnect performs, so host removal is the only
thing that can drop them. Removal now retires the session and its watermark.
Mutation-tested: 3 mutations, 3 killed. The first version of the bell test
passed with the fix removed — it exercised the live path, which only adds to
the seen-set; only the replay path consults it. Rewritten against the replay
path, it fails with `expected 1 to be 2`: the literal lost notification.
Mobile notifications + transport: 355 passed. Desktop replay: 11/11.
* fix(mobile): catch up on the first connection after a cold open
Catch-up hung off 'has this process connected before', which is false on the
first ready of a fresh launch — exactly the post-upgrade / post-eviction case
that loses everything between the stored watermark and the next live seq. Wait
for the persisted read, then catch up whenever this device has delivered for
the host before; a first-ever pairing still gets no replay.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): serialize live delivery behind the watermark seed, and key catch-up on the record
Co-authored-by: Orca <help@stably.ai>
* test(mobile): pin the two catch-up mechanisms mutation testing found unguarded
Mutating each mechanism of the #8591 fix in turn showed two survived with the
suite still green: the seed's epoch-provenance check, and the host session
outliving the subscription teardown. Both are load-bearing, so pin them.
- seen-set survives teardown: the desktop's retained buffer replays a
notification already delivered live, and only the session-scoped seen-set
stops a duplicate banner.
- a seed resolving after a live epoch was adopted must not reinstate the dead
watermark. Not reachable through subscribeToDesktopNotifications today
('ready' awaits the seed first), so it asserts on the exported pair and says
so.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): serialize notification delivery per host so the watermark can't outrun what was shown
Addresses two MAJOR findings from review of this branch.
MAJOR #1 — the watermark could be persisted past a notification the user never
saw. `deliverLive` advanced `lastDeliveredSeq` before awaiting the local show,
and replay + live delivery ran concurrently, so a live seq 11 handled while
catch-up was still showing seq 6 persisted 11. A process death before 7..10 were
shown lost them permanently: the next launch asks the desktop for seq > 11.
This predates the branch — `origin/main` advances the watermark at the same point
— so it is a residual this fix closes, not a regression the branch introduced. It
is fixed here because the branch is what makes the watermark load-bearing.
Three changes:
- the advance moves AFTER the show/dismiss await, so the watermark means
"everything up to here reached the user" rather than "was dispatched"
- a per-host `deliveryTail` promise chain (`enqueueHostDelivery`) serializes
deliveries, so a monotonic advance is also an in-order one
- the catch-up batch is ONE queue entry, not one per event. Awaiting per event
returns to the event loop between replays and let a live event slot in
between seq 6 and 7 — which is exactly the interleave being fixed. The RPC
stays outside the queue: `sendRequest` waits up to 30s and holding the chain
for that would stall live delivery on a slow link.
MAJOR #2 — every delivery awaits the persisted read, so an AsyncStorage read that
never settled disabled the host's notifications for the whole app lifetime, with
no error and nothing to see. The seed is now bounded at 3s; a late seed still
applies when it lands. Proceeding unseeded is strictly better: the watermark
stays 0, so catch-up over-fetches and the seen-set de-duplicates.
Serializing removed an overlap the duplicate-suppression relied on:
`showLocalNotification` deduped two same-id events by observing the first still
pending when the second arrived. With deliveries serialized the first completes
first, so the second saw no pending state and scheduled a second banner for the
same notification. The claim moves to enqueue time, where the overlap is still
observable. Dismisses are deliberately not claimed — a dismiss for a shown id is
what retires it.
Evidence — each mechanism disabled individually against the unchanged suite:
- batch-as-one-entry -> reverted to per-item enqueue: ordering test fails
- watermark advance -> moved back before the await: ordering test fails
- seed timeout -> removed: wedged-read test fails
- live-path claim -> removed: concurrent-dedup test fails
- replay-path claim -> removed: cross-path dedup test fails
Each kills exactly one test, so no mechanism is unguarded and none is redundant.
`mobile-notifications.test.ts`'s local `flushAsync` drained 10 microtask ticks.
Deliveries are now several awaits deeper, so a fixed tick count under-drains; it
yields to the macrotask queue instead. Verified with real timers that the
behavior it asserts is unchanged — only the drain depth was wrong.
Full mobile suite: 344 files, 2499 passed, 2 skipped. tsc clean, oxlint clean.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): block iOS uploads below the last shipped App Store version
The closed-train guard looked up each candidate version's own App Store
record, but a version only gets one once it is submitted for review.
0.0.34 reached TestFlight and was never submitted, so it had no record,
nothing looked closed, and the patch-bump walk stopped there — while
0.0.35 had already shipped. Apple rejected the upload after a 24-minute
build (90186 closed train, 90062 needs a higher CFBundleShortVersionString).
Fetch the highest closed version once and treat everything at or below it
as closed, comparing semver numerically so 0.0.10 outranks 0.0.9.
Also read appVersionState alongside appStoreState: the latter is
deprecated in App Store Connect API 3.3 and renames the shipped state to
READY_FOR_DISTRIBUTION, so reading only the old field would silently find
zero closed versions once Apple stops populating it.
* chore(mobile): prepare 0.0.36
app.json sat at 0.0.32 while 0.0.35 shipped on the App Store, because
release versions are resolved on the runner and never committed back.
Close the four-version drift so the checked-in version matches reality
and the iOS release no longer depends on the closed-train walk to find
an open version.
Bump Android versionCode 8 -> 9 in the same commit: the version is shared
between platforms, and shipping 0.0.36 with the code that already shipped
for 0.0.32 produces an APK that cannot install over the released build.
* fix(mobile): clear native-chat composer optimistically at send time
Over relay the send RPC round trip is visible and a lost ack (or a
relay/direct cutover) could strand the sent prompt in the composer
forever: the unconfirmed-send deadline dropped its tracking entry, so a
late transcript echo could never clear the draft.
Clear the draft at send time and restore it only on a definite
rejection. holdUnconfirmedSend now only manages the delivery-unconfirmed
notice; it no longer touches drafts.
* fix(mobile): isolate question answers from composer drafts
* Add Close Tabs to the Left and complete Close Others across tab menus and mobile long-press sheets
* Fold the per-sheet Close action into the bulk-close module (session route max-lines)
* fix(mobile): preserve pinned tabs during bulk close
---------
Co-authored-by: Tom de Bres <tomdebres@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* fix(mobile): heal an orphaned native-chat image paste across screen unmounts
The stale-input marker lived in a per-screen `useRef`, but the condition it
tracks — a bracketed image paste sitting unsubmitted on the agent's composer
line — lives on the host and outlives the screen. Backing out of a session and
returning remounted the hook with an empty Set, so the next message submitted
on top of the orphaned paste and the agent received `<image path><text>`.
Move the marker to a module-level store keyed by terminal handle, and consult
and consume it from every write path that can submit the composer: the image
hook's text-only send, the controller send (which the chat overlay's question
card reaches directly, bypassing the image hook), and the ask-answer send.
Permission choices and the Escape cancel deliberately do NOT heal: they are
`enter: false` keys for an active overlay that swallows the clear, so healing
there would consume the marker without clearing the line and leave the next
real message corrupted. Desktop scopes its Ctrl+U the same way.
* fix(mobile): stop the ask heal from burning the marker on selector answers
The heal ran on every ask answer, but Claude's and Codex's selector shapes
cannot submit the composer: a single-select answer is a bare option digit and
every stepping group is written `enter: false` (the host coerces it), so the
clear is swallowed by the live overlay while the host still acks the write.
That consumed the one-shot marker and left the orphaned paste to corrupt the
next real message — the same failure this PR exists to fix, through a new door
that main did not have.
Scope the heal to the pasted-label shape, which does commit the composer.
Desktop splits it the same way: use-native-chat-interactive-send.ts routes only
the non-stepping answer through the clearing sender and never pre-clears
sendNativeChatAskAnswer.
Also pin the three deliberate skips (selector answer, permission choice, Escape
cancel) with tests, so the PR's central design argument is an invariant rather
than a comment, and guard the failed-heal toast with the generation check every
other error surface in answerAsk already uses.
Add closeBeforePress flag to Rename, Browser, and Refresh actions to
defer modal opening until the action sheet closes. Eliminates the race
condition that caused the mobile app to freeze when opening these modals.
An image send whose text+Enter RPC ended 'unknown' (ack loss / path
cutover) collapsed to accepted=true, so the terminal was never marked
stale. When the Enter truly never landed, the already-pasted image path
sat on the input line and glued onto the next plain-text message.
Propagate the send outcome through handleNativeChatSendWithOutcome and
mark the terminal input stale on any non-accepted outcome; the next send
heals with Ctrl+U (a no-op when the message did land). Chips still clear
on 'unknown' to avoid a double-send on retry.
The New Workspace flow built a bare launch command client-side and sent it as
`startupCommand`, so the host ran it verbatim and never applied the default
launch args. The first Claude session therefore started in manual mode, while
opening another Claude via the "+" tab (which sends the agent id and lets the
host resolve args) started with `--dangerously-skip-permissions`.
Send `startupAgent` from every client-built create path (blank, reuse-branch,
and new-branch) so the host resolves the launch command, args, env, and
host-shell quoting through the same path the "+" new-tab and CLI use. The
work-item path already delegated via `startupDraft`. Custom `agentDefaultArgs`
are now honored on all paths.
Adds a shared `agentLaunchCreateFields` helper and removes the now-unused
client-side command map, which had also drifted from the canonical launch
commands for continue, hermes, command-code, kiro, and mistral-vibe.
Claude-Session: https://claude.ai/code/session_014iufZnQwPD2obYuvdahjaE
Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br>