Commit Graph
437 Commits
Author SHA1 Message Date
Brennan Benson 81ae98e10d fix(mobile): honor host worktree create retention (#16342)
* fix(mobile): honor host worktree create retention

* fix(mobile): cover malformed worktree retention policy

* fix(mobile): fail closed on malformed retention policy

* fix(mobile): fail closed on missing dedupe ttl
2026-08-27 16:02:38 -07:00
Neil a724aa7b08 fix(mobile): report composing state from accessory backspace (#16757)
* fix(mobile): report composing state from accessory backspace

The accessory path edits the field itself and then mirrors it, but called
applyLiveInputMirror with two arguments where the signature takes three. The
local option type declared it 2-ary, so the type checker never saw the drop.

An omitted composing flag is not "not composing": it selects the Android-only
heuristic that holds the trailing non-ASCII run. A pinyin preedit is plain
ASCII, so the heuristic reads it as committed text and sends it. Typing
`ni hao`, tapping accessory Backspace, then picking a candidate put `ni ha` on
the PTY before the commit, giving `ni ha你好`.

Korean survived this by accident - the non-ASCII heuristic re-derives the
correct hold for Hangul - which is why it went unnoticed.

The held range is the fact the mirror needs, and it is already in scope.

Refs #13345

* fix(mobile): preserve accessory IME report provenance
2026-08-27 15:08:14 -07:00
Brennan Benson 80246238fc fix(mobile): reset recycled paragraph layout before reuse (#16692)
* fix(mobile): quantize chat pinch font scale so a zoom stops re-measuring the list every frame

A user bubble on a 390pt iPhone painted five lines inside a frame that
reserved six, with the last painted line cut through a glyph at the content
edge and "no longer needed." gone.

The paint is React Native's: a `<Text>` with no `numberOfLines` gets a text
container whose `lineBreakMode` is `NSLineBreakByClipping`
(RCTTextLayoutManager.mm). Measure lays out into `{width, CGFLOAT_MAX}`, paint
lays out into the mounted content frame — so a frame one line short does not
re-wrap, it dumps the remainder onto the last fitting line and clips it, with
no ellipsis. Reproduced on-device against the real component with the message
text held constant, so five painted lines can only be truncation.

Two conditions are each necessary, and removing either makes it vanish over
~6000 measured bubble renders: a pooled `RCTParagraphComponentView` carrying a
shorter row's content frame (`prepareForRecycle` clears `state` but not
`_textView.layoutMetrics`), and whole-list re-measure churn while rows enter
and leave that pool.

The churn was ours. `renderItem` closes over `fontScale`, and the pinch handler
committed a new scale on every gesture frame, so one zoom drove hundreds of
full-list re-measures. The pinch is composed `Simultaneous` with the list's own
scroll, so a stray second finger during a scroll started that storm at scales
the user cannot see — matching the report, whose glyph metrics are `fontScale`
1.0 exactly.

`quantizeFontScale` snaps commits to a 5% grid. React bails out of a same-value
`setState`, so gesture noise now commits nothing and a full-range pinch commits
at most ~20 times. Under the churn that produced 67 defects in 6636 bubble
renders, the quantized build measured 0 in 6064 — with a forced-defect control
bubble flagged in 100% of frames of both runs to prove the detector was live.

This removes the trigger we own; it does not close the RN recycling window
itself. That needs a one-line reset in `prepareForRecycle`, which cannot land
here without refreshing the `patchedDependencies` hash under `mobile/`.

* fix(mobile): reset recycled paragraph layout before reuse
2026-08-26 19:17:31 -07:00
Brennan Benson f6e16ba3f8 fix(native-chat): anchor an unmatched chat echo where it was sent (#16117)
* fix(native-chat): anchor an unmatched chat echo where it was sent

The reported symptom was old user messages replaying below every new turn, so the
conversation read as scrambled. The cause was not that the echo failed to match a
transcript row. Claude consumes a mid-turn send through a `queued_command`
attachment and writes no `type:"user"` record for it, so some echoes can never
match, and no amount of matching will change that. The cause was WHERE an
unmatched echo rendered: buildMobileNativeChatTransientData appended every pending
item after the entire transcript, so it re-read below each turn that landed
afterwards.

Render each echo directly after the transcript row it was sent against, using the
baseline the send already captures. An unmatched echo is then at worst a duplicate
in the right position rather than a scrambled one, and it stays visible. Echoes
sharing an anchor keep send order; a send with no baseline, or one whose anchor
folding dropped, still falls back to the tail.

Deliberately NOT fixed by deleting the echo. Inferring from send ordering that an
echo can never match, then removing it, loses the user's own text for a message
the agent did receive, and it cannot fire in the common case anyway - measured
drain groups are 1,017 of size 1 against 55 larger. It also escalates an existing
gap: the count pass has no baseline-tail guard, unlike the glue pass, while
`messages` is a 40-row window that head-trims, resets on reconnect and grows at
the front on loadEarlier, so a false landing there would license deleting a
DIFFERENT outstanding message.

That count-pass gap is real and left for a separate change; anchoring makes its
worst case a duplicate in place rather than a scrambled conversation.

* fix(native-chat): preserve folded echo anchors

* fix(native-chat): preserve forward-folded echo anchors

* fix(native-chat): keep leading folded echoes in place
2026-08-26 17:02:09 -07:00
Jinwoo Hong 8d61cb8b77 fix(relay): survivable mobile pairing recovery + desktop assign rate gate (#16659)
* fix(mobile): retry the stored assignment when the director reports no newer move

A director answering /v1/connect can only reply relay-moved with the stored
assignment; it has no 'assignment unchanged' verb, and sticky assignments make
equal-epoch replies the steady state. Treating every non-newer move as fatal
made pairing recovery unwinnable for any transient cell dial failure (DRAINING,
1006), which bricked off-LAN pairing on Android 0.0.44.

A non-newer move now confirms the stored assignment: the candidate re-dials it
with a 250ms floor instead of abandoning the relay path. The move is never
adopted or persisted, so the anti-rollback contract (requireStrictlyNewerEpoch
for persisted moves) is unchanged. 4429 stays out of director recovery: each
cell dial burns an invite attempt server-side and a director hop cannot relieve
cell load.

* fix(mobile): honor relay director Retry-After when pacing recovery

Mobile /v1/resolve collapsed every non-OK status into a generic error and
discarded Retry-After, so overloaded windows produced hammering instead of
paced retries. RelayDirectorHttpError now carries status and retryAfterMs
(clamped to 120s), and the reconnect controller floors its existing transport
delay with it — no new timers or retry state. The Retry-After parser is
extracted from the desktop relay client into src/shared and reused by both.

* fix(mobile): attribute pairing log lines to their candidate path

The pairing race interleaves the direct LAN and relay candidates into one
PAIRING LOG pane; direct lines (WebSocket closed, Reconnecting 10.x.x.x:6768)
carried no path label and repeatedly read as Relay retrying a private IP —
misleading users and two investigations. The coordinator now wraps each
candidate's sink with an idempotent Direct:/Relay: prefix at the one seam
where both paths are known.

* fix(relay): gate desktop /v1/assign at the per-host rate limit

The director rate-limits /v1/assign per host at 5s, but every desktop retry
path could fire immediately: both schedulers draw full jitter from [0, cap]
(floor 0), first attempts after a drain are undelayed, the 400-fallbacks issue
up to 3 assigns per round trip, and reconcile() cancels the armed Retry-After
timer from ~8 refreshDemand callers. Production shows hosts permanently
rejected at ~100-200 rejects per success.

A shared per-host gate now lives inside requestRelayAssignment — the single
assign call site — so every path books a >=5s (+jitter) slot. Retry-After
raises the gate persistently, surviving the coordinator's timer cancellation.
Concurrent callers serialize through a per-key chain. Callers with staleness
fencing pass isCurrent; a superseded caller aborts after the wait instead of
spending the host's slot. Internal 400-fallback retries stay one logical
attempt and do not re-enter the gate.

* refactor(mobile): rename the log-only assignment-echo predicate

isCurrentAssignmentMove no longer gates control flow — every non-newer move
retries the stored assignment — so the name overstated its role.

* fix(relay): honor mid-wait raises and cap the assign gate's inline wait

Review findings on the per-host assign gate: the deadline was read once
before sleeping, so a sibling's Retry-After landing mid-wait was ignored
(the exact storm the gate exists for), and the sleep was uncancellable —
a booked five-minute Retry-After could park pairing IPC, which awaits
reconcile inline, for its full duration.

The wait now runs in 1s slices, re-reading the deadline and the caller's
isCurrent fence each slice. Remaining waits beyond 15s fail fast as a
RelayHttpError 429 carrying the remainder, so the existing schedulers
pace with it while the gate keeps the deadline. Staleness aborts are
classified non-retryable. Also from review: the broker's isCurrent wiring
and the shared-gate default are now pinned by tests, the 4429 comment
states the reservation-order rationale precisely, the mobile Retry-After
ceiling is renamed to avoid colliding with the desktop's 5-minute one,
and a past-HTTP-date header case is covered.

* fix(relay): tag locally paced assigns and warn about frozen test clocks

Review polish: the synthesized 429 for a beyond-cap local wait now carries a
distinct message (relay_assignment_locally_paced_429) so log censuses can tell
it from a real director 429, with the comment stating the invariant that makes
the translation honest (local booking alone never exceeds ~5.5s). The gate's
sleep option documents that test fakes must advance the clock — the slice loop
re-reads it and never terminates against a frozen one.

* fix(relay): fence superseded callers at the assign send boundary

reserve() checks staleness while waiting, but a caller superseded after
booking — or between the 400 field-fallback retries — could still spend
one to two requests on an assignment nobody consumes. Re-check
isCurrent at the top of sendRelayAssignment so the fallback recursion
is fenced too.
2026-08-26 13:29:46 -07:00
Brennan Benson 588eec68b4 fix(native-chat): stop rendering a tool result whose call is outside the window (#15653)
* fix(native-chat): stop rendering a tool result whose call is outside the window

A tool result carries no call id, so it can only be attributed to a tool
call loaded alongside it. Both chat views read a windowed transcript tail
(mobile 40 messages, desktop 300), and the window regularly opens between
an assistant's `tool_use` record and the user-role record that answers it.
Claude also re-emits already-answered `tool_result` records at a `/compact`
boundary, long after their call scrolled out of the window.

`foldToolMessages` had no rule for those: with no assistant predecessor in
the output they were pushed through as standalone messages and rendered as
a bare, unowned block of raw tool output with no tool name — reading as a
message from nowhere mid-conversation. Sampling real Claude transcripts,
176 of 400 sessions (44%) produced one in a mobile-sized first page.

Drop a result no loaded call can own, before folding. It is not lost: it
comes back attached to its call as soon as the owning turn pages in.

* fix(native-chat): scope tool result attribution to folded turns

* fix(native-chat): preserve harness-attributed tool results

* fix(native-chat): keep interruption boundaries
2026-08-26 12:38:59 -07:00
Jinwoo Hong 4ff428f763 fix(mobile): retain relay during brief backgrounding (#16543) 2026-08-25 22:33:59 -07:00
Brennan Benson d2a35eebe3 fix(mobile): avoid unsupported Hermes array sorting (#16506) 2026-08-25 17:11:09 -07:00
Jinwoo Hong 0e0a8c943b fix(mobile): recover Relay connections after resume (#16498)
* fix(mobile): recover relay sessions on resume

* fix(mobile): expedite relay retry on app resume

* fix(mobile): keep relay reconnect controller under lint limit

* fix(mobile): rebuild relay client after pairing rejection

* refactor(mobile): keep relay reconnect policy under lint limit
2026-08-25 16:25:00 -07:00
Brennan Benson efa3b972c2 fix(native-chat): prevent duplicate mobile prompt echoes (#15656) 2026-08-25 15:47:40 -07:00
Brennan Benson 98bdd653ab fix(native-chat): stop the spinner on a not-yet-flushed transcript (#16493)
* fix(native-chat): stop the spinner on a not-yet-flushed transcript

A brand-new agent session can take minutes to write its first JSONL line,
and one that is never prompted never writes it at all. The host emitted no
stream frame until the file resolved, so every native-chat client sat on a
bare spinner with the composer enabled but the transcript blank -- forever,
in the never-prompted case.

The resolve poll now reports the transcript as pending after a short grace,
and both host handlers emit a `pending: true` snapshot. It is deliberately
not a plain empty snapshot: an empty window sold as a settled read would
capture over retained history and unblock consumers that require a
trustworthy transcript (the launch-draft adoption would re-offer a prompt
the agent may already have taken).

Clients render it as the "start a chat" empty state while keeping the read
unsettled -- `awaiting-transcript` on mobile, an `awaiting` read phase on
desktop, which also stops the seed loop expiring into an error card for a
session that is simply new. New optional field only, so older clients
ignore it and still stop spinning.

* fix(native-chat): negotiate pending transcript frames
2026-08-25 15:06:47 -07:00
Neil aa4c9c707c Refactor mobile home, worktree modal, and RPC client (#16165)
* refactor(mobile): split home modal and rpc client

* fix(mobile): restore render-phase remount key in NewWorktreeModal

The split moved the form-reset epoch from render-phase refs into
useState + useLayoutEffect, which changed when the remount key is
computed. On the render where visible flips false->true the key was
still the old epoch, so the previous session's NewWorktreeModalContent
rendered with visible === true carrying stale form state. Child layout
effects run before the parent's, so visible-gated hooks
(useNewWorkspaceRepositories, useNewWorktreeDrawerNavigation,
useNewWorkspaceRuntimeContext) fired for that stale instance before the
parent bumped the epoch and remounted.

Restore the ref-based computation so the key is correct on the first
render where visible flips true, keeping the composite open/client
epoch semantics and the file split intact.
2026-08-24 23:50:10 -07:00
Jinwoo Hong 4da8848168 fix(mobile): preserve relay close recovery codes (#16293) 2026-08-24 21:13:38 -07:00
Brennan BensonandNeil ec4687c434 feat(agents): distinguish Claude background monitoring (takes over #14205) (#16201)
* feat(agents): distinguish Claude background monitoring

Adds an optional `workingMode: 'monitoring'` discriminator for a Claude
session whose lead turn finished but which still has background shell tasks
or session crons registered. The wire state stays `working`, so older peers
that never read the field keep rendering Working.

(cherry picked from commit d5d54b4bdd)

Rebased onto current main (554 commits of drift) by Brennan Benson;
conflicts resolved by keeping both sides where main and this branch made
independent additions to the same construct.

* fix(sidebar): keep monitoring status visible

(cherry picked from commit fd6b38654d)

* test(agents): cover Claude monitoring drain

(cherry picked from commit ce4d61ebf8)

* test(mobile): avoid unresolved renderer test type

(cherry picked from commit bbcfa35ff9)

* feat(agents): render Claude monitoring as a static turquoise dot

Replaces the yellow Radio glyph from #14205 with a static dot in a new
--agent-monitoring token (#8abeb7), defined once for light and once for
dark like --workspace-status-done, so the status keeps its identity when
the theme flips. Deliberately a fixed UI value: it never reads terminal
theme state at runtime.

Adds the turn-boundary notification pins. The monitoring predicate and
the turnCompletedAt stamp are computed from the same "lead said done but
the pane resolves to working" expression, so a rename can silently drop
the stamp and kill a completion notification that works today with
nothing else going red.

* revert(agents): restore the yellow Radio glyph for monitoring

Brennan chose #14205's original treatment over the turquoise dot, so the visual
goes back to nwparker's: lucide Radio in text-yellow-500 across the sidebar,
dashboard dot, cmd-j palette and agent-map ring.

Reverts only the visual surface. The turn-boundary notification pins stay — the
monitoring predicate and the turnCompletedAt stamp share an expression, so a
rename can silently drop the stamp and kill a completion that works today with
nothing else going red. The --agent-monitoring token is removed with its last
consumer rather than left dead in main.css.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-24 15:24:32 -07:00
Neil 21c134bb66 refactor(mobile): reuse repository color helper (#16131) 2026-08-23 14:45:31 -07:00
NeilandMelih b2902cb61e fix(agent-resume): restore Kimi Code sessions after restart (#15883)
Co-authored-by: Melih <mberatsanli@gmail.com>
2026-08-23 02:26:52 -07:00
Jinjing 8bf1128a08 fix(mobile): remove close-tabs-to-right action (#15894) 2026-08-22 09:51:23 -07:00
Brennan Benson 30bf2647fc fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it (#15472)
* fix(mobile): replay a delivery-ambiguous worktree.create instead of failing it

A socket close or response timeout rejects an in-flight worktree.create as
delivery-unknown: the frame reached the wire, so the host may already have
built the worktree. The client only replayed connection-migration cutovers,
so every other ambiguity surfaced as a create failure for a create that may
well have succeeded. Replay on the same clientMutationId — which the host
already dedupes — after waiting for the transport to come back.

* fix(mobile): bound the ambiguous worktree.create replay by the host's dedupe window

The replay was bounded only by a retry count, but what makes a replay reconcile
instead of building a second worktree is wall clock: the host drops a settled
create's dedupe record 60s after it resolves, and past that the replay is just a
fresh create that the host's suffix loop happily duplicates — for a folder
workspace, into a second workspace with the very same name and no collision
check at all.

Two paths ran past that window:

- The request-timeout path. A silently dropped response frame leaves the socket
  alive, so nothing rejects until WORKTREE_CREATE_TIMEOUT_MS — ten minutes, with
  no bound at all on when the host actually resolved. This was previously the
  path that replayed *soonest*, short-circuiting the reconnect wait because the
  transport still looked healthy. Invert it: every path that reports a real drop
  has already left 'connected' by the time the rejection surfaces, so still being
  'connected' identifies the timeout and is now refused.
- The reported-drop path. Worst-case detection is a full liveness idle period
  plus the missed-probe budget before the client even learns the socket is dead,
  and the old 20s wait on top of that overran the record. Derive the wait from
  the watchdog constants and the TTL instead of hardcoding it, and anchor a
  single deadline at the first ambiguity so a second wait gets the remainder
  rather than restarting.

The TTL now has one definition shared by both processes, so the client asserts
its budget against the host's real window instead of a copied literal.

* fix(mobile): end the reconnect wait on a revoked pairing, and pin the wait's behavior

waitForRpcClientReconnected resolves only on 'connected' or the timeout, but an
'auth-failed' client never reaches 'connected' — so a create interrupted by a
revoked pairing sat out the full wait before surfacing the error it already had.
Treat auth-failed as a terminal answer on both the fast path and the listener.

The helper also shipped with no tests of its own: its already-connected fast path,
its timeout path, and the synchronous-notification-during-subscribe teardown were
only ever exercised indirectly through the retry suite, and neither RpcClient
implementation notifies synchronously, so that branch had no coverage at all. Add
a direct suite covering all of them, asserting listener and timer teardown rather
than just the resolved value.

Also give the fake-timer tests an explicit timeout. advanceTimersByTimeAsync
yields through real macrotasks between ticks while vitest's own budget runs on
real time, so on a loaded runner the default 5s is reachable — observed once as a
spurious timeout in this suite.

* fix(mobile): bound the ambiguous replay in wall clock, not timer time

The replay window was derived from the liveness watchdog's own budget
(idle + missed probes x probe timeout). That is a bound on how long the
watchdog takes to *fire*, not on how much wall clock passed. iOS and
Android suspend JS timers while the app is backgrounded, so across a
background cycle the socket dies silently and the pending create rejects
delivery-unknown minutes later with the timer-derived ceiling still
reading ~44s. The replay then lands well past the host's 60s dedupe
record and the suffix loop builds a SECOND worktree - for a folder
workspace, one with the very same name and no collision check at all.

Anchor the deadline on the watchdog's lastInboundAt instead: a wall-clock
stamp of a frame that really arrived, so it stays honest across a
suspension. Fall back to the send time when the transport can't vouch for
one (relay sessions run with idleProbeMs: null), which errs toward
refusing the replay.

Also restore the delivery-unknown discrimination test that the
still-connected guard had made vacuous, pin the still-connected guard
itself against a live inbound stamp, and pin the deadline against being
re-read from a fresher replacement session.
2026-08-19 18:10:39 -07:00
Brennan Benson fab6e0d6e7 fix(mobile): scope optimistic workspace removal to the deleted host (#15424)
* fix(mobile): scope optimistic workspace removal to the deleted host

A worktreeId repeats across hosts, so filtering the list on the bare id also
removed the identically-named workspace belonging to the other host. Match on
(worktreeId, hostId) through a named helper so the rule is testable.

* fix(mobile): key host worktree rows consistently
2026-08-19 17:12:44 -07:00
Jinwoo Hong bb09dc1749 fix(mobile): escalate a persistently rejected Relay pairing to re-pair (STA-4681) (#15237) 2026-08-17 22:36:54 -07:00
Neil 0e96b82e44 fix(mobile): keep phone tab selection across host snapshots
* fix(mobile): keep phone tab selection across host snapshots

Preserve device-owned tab focus across ordinary host republications while explicit follow navigation remains authoritative. Retire closed selections across clients so stale snapshots cannot resurrect tabs.

* fix(mobile): acknowledge session tab closes

* fix(mobile): avoid tombstones for uncommitted closes

* fix(web): implement session close IPC stubs

* refactor: simplify mobile tab close flow

* fix: bound session tab close confirmation
2026-08-17 18:26:52 -07:00
Jinwoo Hong b0e27354b5 fix(mobile): escalate continuous Relay outages (STA-4587) (#15071) 2026-08-17 02:14:42 -07:00
Brennan Benson c7995a66ae fix(mobile-native-chat): reland glued pending retirement without the two revert causes (STA-4482, STA-4492) (#14936)
* fix(mobile-native-chat): reland glued pending retirement without the two revert causes

Relands #14665 (reverted by #14819). #14665 retired mobile pending bubbles when
two fast sends landed as one transcript row, but shipped two regressions; both
are fixed here rather than re-applied and hoped for.

1. A rejected send restored a TRIMMED composer. #14665 reassigned `text` to
   `text.trimEnd()` at the top of `sendMessage` and then used that one value for
   both the bytes on the wire and the composer restore, so a rejection put back
   less than the user typed. The draft and the payload are now separate values:
   `draftText` is what the user typed and is what `clearDraftForSend` /
   `restoreRejectedDraft` see; only the transported `text` is trimmed.

2. Sends issued during hydration were stranded forever. #14665 persisted
   `glueBaselineTrusted: false` on any send captured while the transcript was
   still loading and never cleared it, so that send could never retire and stood
   as a permanent glue barrier for its neighbours. A hydration-time baseline is
   now a placeholder (`baselineResolved: false`) that the first authoritative
   read rebases onto real rows, ordinals included, instead of a permanent
   disqualification. That is STA-4492.

The intended behavior is unchanged: one transcript user turn retires a run of
2+ adjacent text-only pending sends only when it exactly spells their normalized
concatenation, every send is bounded by its OWN transcript tail, and exact
landings, image echoes and unresolved tails stay barriers.

No wire change: `baselineResolved` and the baseline tail are client-local React
state in `pendingBySession` and are never exchanged with a host. The only
client->host difference is trailing whitespace no longer being written onto the
agent's input line, over the existing `terminal.send` params.

Refs STA-4482, STA-4492. Original PR #14665, revert #14819.

* fix(mobile-native-chat): let the untrimmed draft reach the send seam

The composer sent `value.trimEnd()`, so the raw draft never reached
`sendMessage` and a rejected send still handed back a trimmed composer —
the split of `draftText` from the transported `text` had nothing to
restore. Pass the draft through; the seam already owns the wire trim.

Also pins the array-identity contract of
`retireLandedMobileNativeChatPending`: the drafts effect early-outs on
`next === current`, and nothing tested it.

* docs(mobile-native-chat): name the hydration rebase's residual ambiguity

* fix(mobile-native-chat): stop the hydration rebase stranding a send on its own echo

Rebasing recounted the send's ordinal against the first authoritative read.
That read can already carry the send's own echo — a re-subscribe after a tab
switch or reconnect returns whatever exists now — so the ordinal landed one
past anything the transcript could supply. The bubble never cleared, it stayed
a live segment at the head of its run so no later pair could glue either, and
`earlierOutstanding` carried the inflation onto the next send of the same text.
Only the tail needs recovering; the ordinal was already counted against an
empty transcript, which is right for "no history was known". A caption-less
image echo keeps its captured tail, since it counts turns after it.

`baselineResolved` also has to mean "captured against a settled read", not
merely "not loading": a read that failed hands back an empty list that reads as
an empty conversation, and the null tail then let any row the successful read
finally brought glue-retire those sends.

* test(mobile-native-chat): pin that a resolved hydration send leaves its run glue-capable

A held send sits as a live segment at the head of its run, so the cursor can
never reach a later pair — the stuck bubble takes the whole feature down with
it. Goes red against the ordinal recount.

* fix(mobile-native-chat): pin an image echo that captured no tail, and require the settled flag

A caption-less image echo keeps its captured tail because it counts image turns
after it — but a send issued before any history was known captured null, which
counts from the top of the transcript. An old image turn then claimed the send
and bound the user's fresh photo to it, leaving the just-sent turn with no
preview. A null tail is not a boundary worth preserving, so pin those too.

`transcriptSettled` was optional and defaulted to the gate it replaced, so any
caller that omitted it silently got the pre-fix behaviour. Required now, and
threaded through every harness.

* fix(mobile-native-chat): stop an unbounded send claiming an image turn already in the read

The image-preview pass runs before the rebase, so a send captured with no
boundary matched any image turn the settled read carried — binding the user's
freshly attached photo to an old one and retiring the bubble through
landedImagePendingIds, which short-circuits the retirement path entirely.
Pinning the tail in the rebase could not help: the claim was already made.
Such an entry now waits one tick and claims against a real tail.

* fix(mobile-native-chat): never move a boundary the send already captured

An unsettled read still shows this session's own retained history — a reconnect
or a failed read keeps the conversation on screen rather than blanking it — so
sends made across one already own a correct tail. The rebase overwrote it with
the tail of the read that followed, which sits at or after their own glued row,
so `turn.index <= segment.tail` rejected every turn and the pair stayed queued
for the session, blocking every later pair in the run. Pin only a send that
captured no tail at all.

A captioned image echo is now left alone entirely: it binds its preview by an
ordinal counted over the whole transcript, so supplying a tail without
recounting left it matching nothing, forever.

* fix(mobile-native-chat): supply a boundary only to a text-bearing send

An image echo reconciles by counting turns AFTER its tail and has no other
retirement path, so the tail supplied from a read that already carried its own
echo excluded the very row it was waiting for: the "Queued" photo bubble stuck
for the life of the session and the transcript row rendered as bare marker text
with no photo. A regression against main, and against the earlier revision of
this fix that pinned only captioned echoes.

The glue matcher is the only consumer a supplied tail helps. Everything that
reconciles relative to its own tail keeps whatever it captured.

* fix(mobile-native-chat): stop one unmatchable send freezing glue for the session

The match cursor only advanced on a hit, so a head that could never match —
a pair whose glued row arrived with the read, or a send the count pass claimed
against an older row — froze the run behind it and every later rapid pair
became permanently unretirable. Two cases previously disclosed as bounded were
not bounded at all. Slide past a non-matching head, keeping the cursor
monotonic so a later turn can never take a send an earlier one claimed.

The slide widens the search, so a span cap keeps the work linear in the run
length instead of quadratic; the existing budget test now asserts that bound
rather than the old one it silently broke. Re-fuzzed at 250k seeds: the
boundary guarantee still holds.

Also corrects a comment that claimed the preview-pass filter made a photo claim
against a real tail. It does not — an image echo keeps whatever tail it
captured, so a caption-less photo can still bind to an older photo turn, as on
main.

* fix(mobile-native-chat): stop the span cap stranding a long glued run

Capping each match attempt at 8 segments did not truncate a longer glue, it
rejected it outright: a row spelling 9+ sends exhausted the loop without
reaching the end of the text and returned zero, so none of the nine retired —
and each stuck send then inflated `earlierOutstanding` for the next send of the
same text. Nothing bounds how many sends pile onto the agent's input line;
accumulation ends when the agent accepts input again, not at any fixed count.

One inspection budget now covers the whole slide instead. The first attempt
spans the entire run and always fits, so a genuine glue is never truncated;
only a run of identical prefix-matching sends can exhaust the budget, which is
exactly the case that should be cheap. The in-flight attempt may overshoot the
remainder — that is what makes the guarantee hold — so the budget test asserts
the real ceiling. Re-fuzzed at 250k seeds with runs past the budget.
2026-08-17 01:11:24 -07:00
NeilandBrennan Benson 66b599399f fix(mobile): decide terminal preedit from the marked-text range, not a script table (#15007)
* fix(mobile): decide terminal preedit from the marked-text range, not a script table

The live terminal capture field decided what to withhold from the PTY with a
Unicode-block allowlist (Hangul jamo and syllables) and held exactly one trailing
code point. Kana and kanji are not in the table, so a Japanese reading streamed to
the PTY one fragment at a time and was repaired afterwards with DEL bytes (#7427).

A code-point table cannot work, and the counterexample is not exotic: Chinese
pinyin preedit is plain ASCII, and a Japanese romaji reading is one code point on
the first keystroke and three on the fourth. Preedit is a property of the FIELD,
not of the characters in it, so the only signal that identifies it is the text
system's marked-text range. That is what a reference terminal implementation uses
on every platform it supports - `hasMarkedText` there, the input-method context's
composing state elsewhere - and neither one classifies code points anywhere in the
input path.

So the mirror now takes the marked-text report per change and holds the whole
preedit region, whatever its length or script:
- Subscribe the capture field to `onChange`, not `onChangeText`; only the raw
  native event carries the report at all.
- A reported preedit is held entire and is never committed by the settle timer,
  because preedit is not text yet. Explicit boundaries still flush it.
- `isTerminalLiveHangulCodePoint` and its four ranges are deleted.

iOS reports the range but React Native drops it before JS, so the pinned patch
forwards `markedTextRange` into the change payload. It is three hunks and it
compiles because the app already sets `buildReactNativeFromSource` for iOS. The
same idea was proposed in #11450, which is where the patch comes from.

Android has no marked-text report in React Native at all, and a Kotlin patch would
not help: Android consumes the prebuilt react-android artifact, so node_modules
sources are never compiled. Until the report exists there, the fallback holds the
trailing non-ASCII run. It enumerates nothing, it covers kana, kanji and Hangul,
and ASCII keeps its zero-latency echo - but it cannot see an ASCII preedit, so
Chinese pinyin on Android still leaks its reading. Only a report fixes that.

Not-tested: no physical device or emulator was available, so no real IME drove
this path. Japanese, Chinese and Korean composition are covered at the model and
hook level only, and the iOS patch has not been compiled.

Co-authored-by: Brennan Benson <brennanb2025@users.noreply.github.com>

* fix(mobile): bound the fallback hold to text the pty has not received

The no-report branch walked the trailing non-ASCII run over the whole field and
ignored stableLength, unlike the reported branch directly above it. So after a
settle-timer commit the next keystroke re-held everything already delivered and
the caller erased it with DEL and retyped it — a nine-character Cyrillic word
cost a DEL per already-sent character, and for the 300ms before the re-send the
held text was the only copy, so a blur or reconnect destroyed characters the pty
already had.

Bound it the way the reported branch is bounded. Pinned by a test that drives a
settle commit between every keystroke and asserts no DEL reaches the wire.

---------

Co-authored-by: Brennan Benson <brennanb2025@users.noreply.github.com>
2026-08-17 00:01:24 -07:00
Jinwoo Hong b6d5972ec4 fix(mobile): reland truthful Relay recovery status (#14986) 2026-08-16 19:09:15 -07:00
Brennan Benson a324ee20d4 Reset terminal SGR state around restored output (#14700)
* fix(terminal): reset SGR around restored output

* fix(terminal): preserve live replay styling

* fix(terminal): ground dead reattach fallback
2026-08-16 17:08:10 -07:00
Brennan Benson c73e5a2f59 fix(mobile): bound pending-handle session-tab recovery polling (STA-4407) (#14916)
* fix(mobile): bound pending terminal recovery polls

* wip(mobile): partial STA-4407 bound pending-handle poll

* fix(mobile): finish bounded pending-handle recovery

* fix(mobile): preserve pending-handle recovery attempts on slow links

* fix(mobile): retain pending-handle cadence budget semantics

* test(mobile): pin pending recovery parked state resets

* chore(mobile): drop the STA-4407 worker status logbook

* fix(mobile): preserve pending recovery liveness

* fix(mobile): coalesce repeated recovery retries

* fix(mobile): memoize pending recovery context

* fix(mobile): type the pending recovery poll test renderer explicitly

* fix(mobile): type the poll test renderer without an any union

* fix(mobile): keep recovery context refs current

* test(mobile): tighten pending recovery coverage

* test(mobile): preserve recovery-source liveness

* fix(mobile): keep the poll test renderer union free of any

* test(mobile): prove parked recovery isolation

* fix(mobile): write the parked-recovery callback ref after commit

* test(mobile): isolate recovery identity changes

* test(mobile): prove recovery publication boundaries
2026-08-16 17:00:49 -07:00
Jinjing 1e63cfef06 Revert "fix(mobile): present pending Relay fallback accurately (#14922)" (#14976)
This reverts commit 3811881410.
2026-08-16 16:30:50 -07:00
Jinwoo Hong 3811881410 fix(mobile): present pending Relay fallback accurately (#14922) 2026-08-16 12:38:26 -07:00
Jinjing b8dc393c18 Revert "fix(mobile-native-chat): retire pending bubbles glued into one transc…" (#14819)
This reverts commit 68ca17e46c.
2026-08-15 16:34:57 -07:00
Jinjing 7aaa7c6f5b refactor(sidebar): group worktree-list files by domain (#14486)
* refactor(sidebar): group worktree-list files by domain

Follow-up to #14465 / #14467. Keep the landed extract and reorganize the
flat worktree-list dump into drag/, headers/, reveal/, rows/, scroll/,
and viewport/. Fold tiny modules into their owners, move leftover
sidebar-root files into the module, and retarget imports and source-path
tests. Layout-only; no behavior change.

* fix(sidebar): merge duplicate virtual-rows imports

Inlining virtual-row-dom-attributes left a second import from the same
module, which fails audit:code-quality:native --deny-warnings.

* refactor(sidebar): condense indentation comments

Shorten explanations to focus on the essential why, removing redundant
detail and improving readability without changing functionality.

* refactor: organize worktree-list into lifecycle dest folders

* fix react doctor

* fix: update reliability-gates path after worktree-list reorg

host-filtering.test.ts moved from viewport/ to listing/; keep the
runtime-routing.active-server-preference gate pointing at the real file.

* Extract workspace status colors to design tokens

Define theme-aware color tokens for workspace PR-state indicators (done, in-review, in-progress) to ensure consistent identity across theme switches. Update references to use the new tokens and refactor EmptyState button to use the Button component.

* fix(sidebar): stop mutating refs during worktree-list render

React Doctor fails static analysis when refs are written in render.
Commit reused array identity and the Smart live-signal latch after
paint, and return the attention map from the sort memo instead of
stashing it on a render-time ref.
2026-08-15 13:40:09 -07:00
Brennan Benson 68ca17e46c fix(mobile-native-chat): retire pending bubbles glued into one transcript row (mobile half of #14262) (#14665)
* fix(mobile-native-chat): retire pending bubbles glued into one transcript row

Mobile's native chat retires an optimistic pending bubble only when a
transcript user turn matches its normalized text at the expected ordinal.
When two rapid sends collapse into a single glued user row neither key
matches, so both bubbles pin below every newer reply for the rest of the
session — mobile has a parallel implementation with no glue handling at all.

Trim the send body once at the send seam so the bytes the host writes
verbatim and the reconciliation key describe the same message on every send
path, then add a bounded glue matcher: a greedy cursor walk that may only
consider transcript turns strictly AFTER each send's captured tail, so an
older turn that happens to read like the concatenation can never retire a
newer queued send.

Refs #14262

* fix(mobile-native-chat): harden glued pending retirement

* fix(mobile-native-chat): preserve pending image previews

* fix(mobile-native-chat): bound glue to loaded transcripts
2026-08-14 23:09:23 -07:00
Brennan Benson ab9d1a29a9 fix(worktree): never reissue a generated workspace name (#14350)
* fix(worktree): never reissue a generated workspace name

Generated workspace names were deduped only against currently-live
worktrees, so deleting a workspace returned its name to the pool. A later
workspace could draw the same name, land on the same directory path, and
inherit the previous occupant's agent conversation history — coding-agent
CLIs key their prompt history and transcripts by cwd.

Names are now retired permanently per repo. The registry is written in
main with the name Git actually used (the create loop can advance past a
requested name on collision), and seeded once per run from workspace
directories and surviving agent transcript buckets so already-spent names
are excluded from the start. Suggestions degrade to -2, -3 variants
instead of recycling, and those variants retire too.

User-typed names are untouched: retirement filters suggestions only.

* fix(mobile): honor retired workspace names, on one shared implementation

Mobile hand-duplicated the desktop name-suggestion algorithm and deduped
only against live workspaces, so a phone could still be offered a name
whose deleted workspace left agent conversation state behind at that path.

Both platforms now call one shared selector in src/shared, so the two can
no longer drift. The host publishes retired names as an optional field on
the existing worktree.list response, and mobile fetches them per selected
repo while the create sheet is open — mirroring the desktop hook.

Mobile never calls worktree.list for its catalog (it uses worktree.ps,
which carries rows only), so this is a targeted request rather than a
change to the catalog or its cache. Hosts predating the field omit it and
mobile falls back to live-only dedupe, which is the pre-change behavior.

* fix(worktree): close retirement consistency gaps

* test(worktree): cover retirement runtime contracts

* fix(worktree): retire generated collision names

* fix(worktree): enforce retired names at creation

* refactor(ai-vault): extract the Claude project-dir encoder

The bucket-name encoder and its scope-boundary check were private to the
session scanner, so a second consumer had to reimplement them — and got the
per-character encoding wrong. Move both to a shared module with direct tests.

* fix(worktree): make the retirement seed scan actually match buckets

The bucket encoder collapsed runs of non-alphanumerics while the real one
emits a dash per character, so every dot-path bucket missed and the Windows
default workspace root (C:\...) matched nothing at all. Reuse the shared
encoder and its boundary check, which also stops a repo absorbing a sibling
whose path merely shares its prefix.

Also:
- Derive the workspace leaf by stripping the known encoded parent instead of
  guessing from trailing dash segments, which retired the parent directory's
  name whenever a workspace was named numerically.
- Reuse isAutoGeneratedCreatureBranchName so the -10 and -100 tiers retire.
- Drop the .codex/sessions root: Codex keeps the cwd inside the transcript
  rather than in a directory name, so the scan could only ever see a year
  folder. Reading transcript contents is not a trade this feature justifies,
  so the gap is documented instead.
- Honor CLAUDE_CONFIG_DIR, which relocates the bucket root.
- Delete the unused retirableLeafName export.

Tests write buckets with the real per-character encoding against a fake home,
covering POSIX, dot-directory, Windows drive and WSL UNC roots; all three
platform cases fail against the previous encoder.

* fix(worktree): retire only generated names, keyed by cwd namespace

Two problems in the host-side registry.

Retirement fired for every create, including names the user typed. The
creature pool contains ordinary words — orca, runner, sole, molly, oscar — so
typing a retired 'nautilus' silently produced directory and branch
'nautilus-2' and burned the name for good. Creates now carry an explicit
nameWasGenerated flag; both the skip and the retire are gated on it, and it
defaults to false so CLI and automation callers are unaffected.

The registry was keyed by repo id, but both readers already discarded the id
and unioned by the cwd collision key, because the collision this prevents is
on the path. Keying by that namespace directly fixes several things at once:
entries no longer orphan when a repo is removed, remove/re-add no longer loses
every retirement for an unchanged path, the missing removeProject prune is
moot, and the backfill promise no longer merges into only the first repo id it
saw. The feature is unreleased, so no migration is needed.

Also:
- Memoize the collision key. It runs computeWorktreePath, which for a WSL repo
  is a blocking execFileSync('wsl.exe') whose failure path is uncached, and
  the previous code recomputed it once per repo on every create and every
  listRetiredNames call.
- Drop retiredNamesByRepo from the worktree list result. It had no readers and
  leaked onto 'orca worktree list --json', and its awaited backfill sat on CLI
  selector resolution. The dedicated listRetiredNames RPC keeps its consumers.
- Make the three RuntimeStore methods required. RuntimeStore is file-private
  with two constructors, so the 'older embedders' the optionality protected do
  not exist, and the optional chain silently returned no retirements.
- Revert the unrelated forceDeleteBranch rewrite, and make room under the
  file's line budget by extracting the create-args mapping instead.

* fix(worktree): send name provenance and stop gating Create on the fetch

Desktop and mobile now mark a create as generated-name only when the user
typed nothing and the composer fell back to the suggestion, so the host knows
which names it may retire.

Remove the retired-names loading gate from every create path. The host already
skips retired candidates before doing any git work, so the client gate bought
nothing while it could disable Create for the length of a full mobile
reconnect ladder (the wait had no timeout) and blank the desktop button
between queued creates. The suggestion still waits; the button never does.

Also make the web client call worktree.listRetiredNames instead of hardcoding
an empty list — the method is registered and mobile-allowlisted, so the
comment claiming no wire call existed was wrong — and filter the mobile
response to strings so a malformed row cannot throw during normalization.

* fix(worktree): key retirement by repo id and prune it with the repo

Reverts the collision-key storage key. It was a function of workspaceDir,
nestWorkspaces, worktreeBasePath and repo.path, so toggling any one of those
orphaned every retirement for every affected repo at once — trading a rare
churn (remove/re-add) for a common one. The read path already unions by cwd
namespace at query time, so cross-repo sharing never depended on the storage
key.

Instead, address the growth and orphaning directly:
- Drop the registry in removeProject, and in removeProjectForHost once the last
  host's copy of the repo id is gone, alongside the sparse-preset deletes that
  already follow this convention.
- Bound each repo's registry. The cap sits far above the 552-name pool because
  evicting inside it would reissue a name whose agent state is still on disk;
  only -2/-3 tier accumulation can ever reach it.
- Carry retirements through profile transfer, re-keyed to the destination repo
  id and dropped from the source, mirroring sparsePresetsByRepo.

Separately, fix the backfill merge: the scan promise is cached per cwd
namespace, but it closed over the first repo id that triggered it, so a second
repo in the same namespace received nothing. The scan stays shared; the merge
moves out of the cached promise and runs for whichever repo asked.

Local repos re-seed on re-add through that backfill. SSH repos do not — the
scan cannot see the execution host — which is now stated in the module.

* docs(worktree): spell out why the retirement bound sits above the pool

Names the trap directly: the neighbouring 50/200 bounds cap histories, so
lowering this one to match them would silently start reissuing names whose
agent state is still on disk. Also states that oldest-first eviction is a
deliberate least-bad choice rather than a neutral one.

* fix(worktree): send name provenance from the web runtime client

This client hand-enumerates worktree.create params, so the new optional field
was silently dropped and typecheck could not see it. On web and paired-desktop
the host therefore never received it: generated names were never retired, and
the host-side skip that backstops a stale suggestion was disabled too. The same
client does fetch retired names for suggestions, so it was filtering against a
registry nothing ever wrote to.

The test asserts both directions, and fails without the fix.

* fix(worktree): retire names that took more than one collision suffix

isAutoGeneratedCreatureBranchName strips exactly one trailing -N, which is
right for auto-rename eligibility but wrong here. Once the pool is spent the
suggester emits nautilus-2, and a collision on that yields nautilus-2-3 —
which a single strip leaves as nautilus-2, not a pool name, so retirement
no-opped at exactly the tier where every base name is already gone. Strip
repeated suffixes locally rather than moving the auto-rename predicate.

* perf(worktree): keep the retirement backfill off the blocking WSL probe

The backfill runs on composer repo-select, not just at create time, and it
derived the probe path synchronously — which for a WSL repo with a mirrored
workspace dir reaches getWslHome and its blocking execFileSync('wsl.exe').
A stopped distro froze the main process for up to 5s on composer open.

Adds an async twin of computeWorktreePath and uses it for the probe. Resolving
the home there also warms the shared cache, so later sync callers are free.

Also stops memoizing the collision key when the WSL home is still unresolved:
only the success path is cached upstream, so caching the fallback namespace
would strand the repo there for the rest of the session.

* fix(worktree): hold retired names across a refresh instead of blanking

refreshKey changes on every workspace-list mutation, so create-multiple
refetches after each create and the hook returned an empty list until the
refetch landed — precisely the window in which resetForNextCreate clears the
name field and a fresh suggestion is drawn. Keep the previous answer while
revalidating and reset only when the repo changes; a failed refresh keeps what
was already loaded rather than un-retiring everything.

Also makes the returned array referentially stable, so the suggestion memo
downstream stops rerunning on every refetch.

* refactor(worktree): put the retired-name cache rules on one implementation

The desktop and mobile hooks that fetch retired names had already drifted
four ways. The transports genuinely differ (IPC vs RPC), but the caching
rules must not, and mobile's copy reset to [] on any error -- which
un-retires every name for the rest of the sheet session, the one outcome
retirement exists to prevent.

Moves the rules into src/shared/worktree/retired-name-cache: response
normalization, the never-leak-across-repos rule, and the hold-previous-on-
failure rule. Pure, no React, because src/shared is on the main process's
import graph. Each platform keeps its own transport and effect.

Mobile moves up to desktop's behavior: it now holds the previous answer
through a failed refresh, and refetches when the workspace list changes
instead of never refetching after mount.

Also drops the unused `loading` return. Neither platform consumed it; its
only consumer was the Create-button gate reviewed out earlier, and removing
it makes that regression unexpressible.

* fix(worktree): import shared types from their real modules

Main dropped the src/shared/types barrel, so the retirement module's import
resolved locally but not against the PR's merge base.

* refactor(worktree): bound the retirement registry by tier compaction, not eviction

Retirement is a correctness guarantee — a spent name's directory may still hold
agent conversation state keyed by that cwd — so the 2000-entry cap was the wrong
shape: reaching it handed a name back. At the owner's measured rate (~6.6 pool
names retired per day in one repo) the cap was ~9 months out.

Names come from a fixed 552-entry pool and the suggester only reaches tier N+1
once every tier-N name is taken, so a completed tier is exactly a set that no
longer needs listing. A row is now a watermark plus the names above it: reads
answer at-or-below the watermark with no lookup, and compaction drops the 552
entries the watermark now covers. Bounded at one pool per repo forever, with no
eviction and nothing un-retired.

Tiers can complete out of order (a create-time collision can spend `nautilus-2`
while tier 1 is open), so compaction loops and higher-tier names simply wait.

The RPC result carries the watermark beside the names as a new field; a client
predating it reads the names only and under-retires the compacted tiers, which
degrades to the pre-retirement behavior rather than breaking.

* fix(worktree): preserve generated name retirement across failures
2026-08-14 22:18:36 -07:00
Brennan Benson e570cade3c fix(mobile): keep polling session tabs while an active terminal is pending-handle (STA-4256) (#14623)
A terminal tab published as `status: 'pending-handle'` renders the session
screen's spinner. Leaving it requires a snapshot that carries the materialized
handle, but a certified-live tabs stream parks `poll()` unless
`hasRecoveryNeed()` says otherwise — and that predicate never considered a
pending terminal. A host that mints the handle without republishing therefore
stranded the pane on its spinner forever: measured live, zero further
`session.tabs.list` calls over 90s while `terminal.list` kept firing every 2s.

Mirrors the existing native-chat recovery-need pattern. Client-only; no wire
change.
2026-08-14 19:20:05 -07:00
Brennan BensonandKaylee Williams 7558fb064a fix(browser): keep browser guests painting when the workbench is hidden (#14599)
* fix(browser): keep browser guests painting when the workbench is hidden

Chromium never paints inside a display:none subtree, so an Electron <webview>
stops emitting CDP screencast frames the moment any ancestor is parked that way.
Orca already models this per pane (browser-page-paintability.ts) and per worktree
surface, using opacity:0 so a phone- or agent-driven page keeps compositing — but
three ancestors above those layers still used `hidden` unconditionally:

  - the App-level terminal workbench container, hidden whenever activeView is not
    'terminal' (opening Settings froze every mobile browser pane),
  - Terminal's root, hidden when there is no active worktree,
  - the split-surface wrapper, hidden when the active worktree has no layout.

A pane-level escape hatch cannot override an ancestor, so all of them have to
agree. Share one predicate across the chain and swap `hidden` for an out-of-flow
transparent layer while a remote controller needs frames.

The predicate ORs automation visibility with the mobile driver, matching the
per-worktree gate. That term is load-bearing, not symmetry: agent-browser
commands acquire a visibility lease and then capture, so gating on the mobile
driver alone left automation from a non-workspace view capturing a blank surface.

Mobile: a stream can report `ready` and then deliver no frames, which cleared the
loading indicator and left an unexplained black rectangle. Key it off actually
having pixels. That also retires the `ready` state and its ref.

Co-authored-by: Kaylee Williams <65376239+KayleeWilliams@users.noreply.github.com>

* fix(browser): keep paint retention off store hot paths

---------

Co-authored-by: Kaylee Williams <65376239+KayleeWilliams@users.noreply.github.com>
2026-08-14 19:13:12 -07:00
Brennan Benson 83e2123582 Add global worktree visibility source defaults (#14276)
* Add global external worktree visibility defaults

* Expand global worktree visibility source defaults

* Fix host-scoped visibility settings races

* Fix global worktree visibility integration

* Enable source visibility defaults on mobile

* Polish external worktree settings navigation

* Clarify inherited worktree visibility settings

* feat(sidebar): replace the inherited-visibility switch with a Show/Hide picker

Each source row now shows a two-segment Show / Hide control preselected to the
global setting, and explains itself only where the project actually disagrees:
an "Overriding global setting: <value>" card names the value being ignored.
Picking the segment global already holds drops the override instead of pinning
a duplicate, so the same control both overrides and reverts, retiring the
separate "Use global" link. The dialog footer now lists every inheritable
source with its global value.

* fix(sidebar): preserve reset for matching visibility overrides
2026-08-14 12:15:58 -07:00
Brennan Benson 266b5ae8f5 fix(mobile): match desktop project and run target picker (#14457)
* fix(mobile): disambiguate repository locations

* fix(mobile): preserve explicit repository ownership

* test(mobile): use explicit renderer type

* refactor(mobile): match desktop project targets
2026-08-14 10:22:55 -07:00
NeilandOrca 2100fb2553 fix(runtime): cap remote git.diff and file previews at the transport budget (#14160)
* fix(runtime): cap remote git.diff and file previews at the transport budget

A remote or mobile user who opens the diff of a large image loses their whole
WebSocket, not just that request: the E2EE channel closes with 1013 when a reply
exceeds the 4 MiB outbound envelope. Two producers can exceed it unaided.

git.diff/branchDiff/commitDiff cap text with MAX_RENDERED_DIFF_COMBINED_CHARACTERS
(6M chars) -- a *renderer* budget that sits above the transport limit -- and return
base64 for previewable binaries bounded only by MAX_GIT_SHOW_BYTES, so a 10 MiB PNG
changed in place is ~26.7 MiB in one envelope. files.readPreview inlines base64 up
to 10 MiB, and mobile calls it for every image tab.

Both now measure against a budget derived from the outbound limit. The check sits in
orca-runtime-git.ts, downstream of the dedupe and of both the SSH-provider and local
branches, so a payload forwarded verbatim by an old relay is covered by the same code
and src/relay needs no change. Local and in-process callers pass no budget and keep
full fidelity.

Measuring raw bytes would not work, which is the whole reason this needs a module.
JSON escaping turns one control byte into six (\u00XX), and binary-buffer.ts sniffs
only for NUL in the first 8 KiB -- so a NUL-free file of 0x01-0x1f bytes is classified
as *text*, would pass a raw-byte cap, and would then blow the envelope. The budget is
escape-aware, with a three-branch fast path that keeps normal diffs at two native
byteLength calls and scans only the ambiguous band.

The SSH branch of readFileExplorerPreview had the same raw-vs-escaped gap: its stat
gate sizes base64 binaries, but text crossed unbounded. It now honours the same
decoded-text limit the local branch already enforced.

No wire change: GitDiffResult is untouched -- no third kind, no new field. Old clients
see an error for one request instead of a dropped connection. diff_too_large joins the
structured passthrough codes and lands on an existing error arm in both mobile
consumers and the desktop remote path; file_too_large was already handled on both.

Instruments the 1013 close, which nothing measured before, so the incidence this cap
is meant to drive to zero is finally observable. `emitter` separates a producer size
bug from a wedged link.

Known regression: remote image previews between ~3.096 and ~3.146 MB now return
file_too_large. They only intermittently worked before -- above ~3.0 MB they killed
the socket -- so this trades intermittent connection loss for a consistent error.

Test: 10281 passed in src/main/runtime + src/shared + src/main/git; mobile 3427
passed. Each of the six budget-enforcement sites is independently mutation-killed.
Escaping fixtures cover newline-dense, control-char, CJK, lone-surrogate and base64
content against native JSON.stringify. tsc clean for node, web and cli; oxlint clean.

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

* fix(runtime): harden remote reply transport budgets

* test(runtime): cover desktop remote preview budgets

* test(runtime): close telemetry review gaps

* chore(shared): repoint budget imports after the shared/types barrel removal

Upstream #14447 dropped the shared/types barrel; GitDiffResult now lives in
git-diff-compare-types and GlobalSettings in global-settings-types.

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

* fix(ssh): surface an over-cap preview read as file_too_large

The stream reader aborts an over-cap read with StreamProtocolError, whose numeric
code falls through mapRuntimeError to a generic runtime_error carrying the raw
"Reported totalSize N exceeds client cap M" string. Neither preview client
recognizes that: runtime-file-client.ts and mobile-file-preview-response.ts both
key on file_too_large. It also made the two file_too_large guards directly below
the read unreachable on the streaming path.

Gives the cap its own error type so the caller can translate it, keeping the
bandwidth saving the cap exists for. A genuine protocol fault still propagates
unmasked.

Found by the readiness review. Mutation-verified: removing the translation fails
exactly the new test.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-14 00:24:57 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00
Jinwoo Hong 54aa22b2df fix(mobile): self-heal host opens and harden session liveness (#14333) 2026-08-13 17:15:03 -07:00
Brennan Benson d2850b9296 Fix mobile HTML report rendering (#14196)
* fix(mobile): render local HTML reports at phone width

* fix(mobile): satisfy browser URL lint
2026-08-13 11:33:26 -07:00
Neil 4882eeb8ac rm git shim: neutralize stale wrappers without a host gate (#14255)
* Revert "fix terminal attribution shim removal edge cases (#14187)"

This reverts 585dd6d3a9. Re-landed in the next commit without the host capability gate. Nothing shipped with it, so no migration constraint.

* rm git shim: neutralize stale wrappers without a host gate

Re-lands the cleanup half of #14187: pass-through tombstones for retained wrapper paths, env/PATH scrubbing at every spawn owner, and the retired setting drop.

Only writes tombstones when the legacy directory already exists, so a clean install no longer has it created. Leaves out the terminal.attribution-removed.v1 capability gate: the tombstone neutralizes each host locally, so refusing terminal create/split against older hosts denied service without adding cleanup.

* rm git shim: surface neutralization failures and fix rollback marker

Readiness review follow-ups: warn on each failed attempt and on give-up (was silent and undiagnosable); write a VERSION marker distinct from the retired shim's '7' so a rolled-back build rewrites its own wrappers; clear a captured ORCA_REAL_* path that no longer exists so the cmd wrapper's where.exe fallback can run; stop a locked temp file masking the real error. Adds retry-exhaustion coverage.

* rm git shim: pin the cmd fallback order and correct the give-up count

Round-2 review follow-ups: string-pin that a stale ORCA_REAL_* is cleared before the where.exe fallback, and count the initial attempt in the give-up warning so it agrees with the per-attempt line.

* rm git shim: keep the split-failure toast

The revert took a toast that #14187 added alongside the gate but which stands on its own: without it a failed remote split only reaches the console and the pane silently never appears. Also pins attempt ordinals in the retry-exhaustion test.
2026-08-13 03:01:45 -07:00
Brennan Benson 0f51d0b3bb Fix native chat image marker position handling (#14162)
* fix(native-chat): handle image markers in any position

* fix(native-chat): preserve image caption whitespace

* test(native-chat): cover marker boundary spacing

* fix(mobile): normalize image echo reconciliation

* fix(mobile): use idiomatic tail access

* refactor(native-chat): share image echo matching

* perf(native-chat): avoid unchanged block copies
2026-08-13 01:18:08 -07:00
Brennan Benson 0ed6db77cf fix(mobile): open agent-cited external chat files (#14166)
* fix(mobile): open agent-cited external chat files

* fix(mobile): keep cited external files read-only

* refactor(mobile): derive cited-file mode from provenance

* fix(mobile): accept sentence-final cited paths

* fix(mobile): preserve cited SSH grant scope

* refactor(file-links): share location suffix parsing
2026-08-13 01:12:19 -07:00
Brennan Benson 6b84e33251 fix(mobile): trade a lease-only stream for output when leaving a chat tab (#14179)
* fix(mobile): trade a lease-only stream for output when leaving a chat tab

Tapping a terminal tab from a native-chat tab left the terminal blank. The
route subscribes the incoming handle synchronously in switchTab, while the
coverage it reads still describes the chat tab being left, so the handle gets
a `mobileInputLeaseOnly` subscribe — the host answers `subscribed` and nothing
else, no scrollback and no data frames. Input kept working because it rides a
separate terminal.send RPC.

The reconciler then cleared its covered marker (the active handle changed), so
the stream was active and uncovered — which its state machine could not tell
apart from a healthy one, because `streamActive` conflated the two. It settled
on 'none' and nothing else repaired it: the route's web-ready path bails on any
live subscription. The tab stayed blank until app restart.

Track which handles hold a lease-only subscribe and thread it into the
reconciler as `streamIsLeaseOnly`, so an uncovered handle holding one resumes
into a full stream. The covered branch is untouched, so the input lease that
keeps the chat composer from locking forever (#10681) still survives.

* fix(mobile): clarify stream reconciliation ownership

* fix(mobile): keep stream reconciliation checks clean
2026-08-13 00:07:28 -07:00
Neil 585dd6d3a9 fix terminal attribution shim removal edge cases (#14187)
* fix(terminal): fully retire attribution shim

* fix(terminal): harden shim tombstone path lookup
2026-08-12 23:22:48 -07:00
Brennan Benson 1136503c6a fix(mobile): use 'unsupported' for the no-review-creation test premise (#14091) 2026-08-12 12:16:03 -07:00
Neil 991a3fe963 chore(lint): update oxlint to 1.77 and enable no-op cleanup rules (#13901)
Enable eleven oxlint rules that simplify code without changing behavior, and fix
every existing violation. Each candidate was gated on measured cost rather than
assumption, so rules that regressed runtime performance or type checking were
dropped instead of suppressed.

typescript/no-redundant-type-constituents is the largest addition: 113 sites, no
autofix. Dead constituents are deleted. Where the redundant literal existed to
document intent (`string | 'all'`), it is preserved as `(string & {})`, which
keeps the autocomplete hint the original code was reaching for instead of
flattening it away. The rule also caught a broken import —
remote-shared-control-retirement-probe.ts pulled RuntimeStatus from
src/shared/types, which does not export it, so the type silently degraded to
`any`; no tsconfig covers that file, so tsc never saw it.

oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets
minimum-release-age=4320 and 1.78.0 is younger than that window.

Rules evaluated and rejected, with what disqualified each:
- prefer-string-raw: String.raw is a runtime call, not a literal (184x slower)
- prefer-string-replace-all: 26% slower
- text-encoding-identifier-case: ~5% slower, reproducible
- prefer-spread: [...str] is 110% slower than split('') and differs on surrogates
- no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors)
- prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks
- object-shorthand: rewrites source text asserted by a tracked reliability gate
- switch-case-braces: pushes ten files past max-lines, which cannot be suppressed
- no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs
- arrow-body-style: 115 violations have no fix, and it breaks max-lines
- newline-after-import: false-positives on the leading-semicolon ASI idiom

electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
2026-08-11 18:19:43 -07:00
Jinjing 2b1e69d4b0 Add keyboard dismissal for mobile markdown editor (#13856)
* fix(mobile-markdown): enable keyboard dismissal while editing

Allow users to dismiss the soft keyboard while composing markdown content. Extract the MarkdownReader component into its own file and add WebView-based caret preservation to restore the cursor position after the keyboard closes. This prevents the editor from losing focus and erasing the user's selected caret location when the keyboard hides.

* improve test
2026-08-11 13:08:50 -07:00