Commit Graph
471 Commits
Author SHA1 Message Date
Brennan BensonandMerge Sim 7f8eb90ac3 Align worktree host labels across desktop and mobile (#18237)
* refactor: align worktree host labels across clients

* fix(mobile): expose safe host display labels

* fix(mobile): preserve legacy mixed-host labels

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-02 15:32:06 -07:00
Neil fdfe354045 test(relay): bind test WebSocket servers to loopback
A control-handshake test that expects a timeout was instead getting
'Unexpected server response: 401' about once in fourteen runs. A slow machine
cannot turn a timeout into a 401 -- that needs a real HTTP response, so the
connection was reaching a different server.

new WebSocketServer({ port: 0 }) binds the wildcard address while the client
dials 127.0.0.1. On macOS those differ, and with SO_REUSEADDR a foreign process
can hold the more specific 127.0.0.1:P and win the connection. Caught live: a
wildcard bind took port 52584, which a running Orca app already held on
loopback, and Orca answered the probe. A listener that checks a token answers
401.

Ten constructions across seven files now pass host: '127.0.0.1', so the
reservation covers the address the client dials and a duplicate bind is refused.

Adds a ratchet, because this is not authors forgetting a convention: all 30+
.listen(0, ...) sites already pass '127.0.0.1', while 7 of 7 ws constructions
did not. ws accepts { port } alone and binds the wildcard silently, so nothing
told them. The guard pins the wildcard count, and pins separately at zero the
option shapes it cannot read -- spreads and variable option objects fail rather
than being exempted, and a recognized-construction floor catches the matcher
going blind, which otherwise reads exactly like a clean tree.

mobile/scripts/mock-server.ts stays on the wildcard deliberately: a phone
reaches it over the LAN.
2026-09-01 19:05:42 -07:00
Neil c8937936eb refactor(mobile): pin the terminal WebView payload and split its widest slice
The payload is one concatenated string, so slice boundaries follow document
order rather than responsibility -- but join is associative, so cutting a slice
into consecutive slices is byte-identical by construction. Splits the widest
slice, which carried fit-scale, a DECSET scanner and the write queue together
with no room left under the line cap.

Adds a hash guard. The behavioral tests each execute one region of the payload
in a vm, so an edit to an uncovered region shipped silently; the composed output
is now pinned by sha256 and length.

Derives the source-file list from the composer's own imports instead of a second
hardcoded list a new slice had to be added to by hand -- the same silent
subject-loss shape already found twice elsewhere in this repo.
2026-09-01 16:38:22 -07:00
Neil de8aaac344 refactor(mobile): name terminal WebView modules for their contents
fragment-01..10 were arbitrary line-count slices of one template literal. Two
seams fell mid-expression -- inside buildMouseClickInput and inside the touchmove
listener -- so those pieces had no identity to name. Re-splits at real statement
boundaries and names each for what it holds.

The composed output is byte-identical: sha256 42cc000f..., 729776 bytes, verified
before, after the regroup, and after formatting. Also fixes two ratchet tests that
read fragment paths directly, one of which duplicated the composer's file list.
2026-09-01 12:27:01 -07:00
Jinjing 512ec1a0da Revert "Move mobile search button to bottom left floating (#17808)" (#17990)
This reverts commit 823934034f.
2026-09-01 11:33:18 -07:00
Jinjing 823934034f Move mobile search button to bottom left floating (#17808)
* Move workspace search toggle to floating button

Extract the search bar into a separate component and move the search toggle button from the toolbar to a bottom-left floating action button, positioned above the new workspace FAB. This consolidates phone-only floating actions in one location.

* Remove SearchWorkspacesFab component

Consolidates search functionality into bottom-left floating action button as part of mobile search button repositioning.
2026-09-01 02:56:37 -07:00
Brennan BensonandMerge Sim 0b2912b507 fix(mobile-native-chat): retire an image echo glued with the send beside it (#17783)
* fix(mobile-native-chat): retire an image echo glued with the send beside it

A message sent with images could render two or three times over, with the copy
carrying the photos sorting below the reply that answered it — and it never
cleared.

A send issued while the agent is mid-turn is glued onto the agent's input line
with any send adjacent to it, so the pair lands as one transcript row whose text
is the concatenation. Every retirement path then declined the pair:

- The image matcher wanted the whole row to equal the echo's text, so a glued
  row never bound. That also stranded the local preview: the phone's photo never
  reached the authoritative row.
- The exact-count path skips image echoes by design.
- The glue path excluded image echoes too, which made one a *barrier* — splitting
  the run so the text-only send beside it was left alone, and a lone match is
  rejected as an ordinary landing.

So neither echo could ever retire, and the unmatched image echo fell through to
the trailing bucket, which is what put it below the reply.

Match a glued row in the image matcher, and let an image echo take part in the
glue pass once its preview has been rebound. It stays a barrier while unbound,
so the existing guarantee is kept: an image echo never retires before its local
preview reaches the transcript row, or the photo would disappear.

* fix(mobile-chat): require image provenance for glued prefix matches

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-01 00:05:00 -07:00
Neil f088872554 fix(mobile): keep split session hooks render-pure
Documents the pre-existing render-time refs the split relocated onto changed
lines, and drops a ref assignment the split added that the monolith never had.
2026-08-31 23:35:08 -07:00
Neil 451002ba1a fix(mobile): honor host-follow tab snapshots
(cherry picked from commit a4a9c4da19d6967eceb8025dd01480a56261039f)
2026-08-31 23:35:08 -07:00
Neil cb94265cf4 fix(mobile): restore session parity after extraction
(cherry picked from commit 47706a5388)
2026-08-31 23:35:08 -07:00
Neil 7caf7ab1e1 refactor(mobile): split session and terminal surfaces
(cherry picked from commit f129f2926a)
2026-08-31 23:35:08 -07:00
Brennan BensonandMerge Sim a9e6fb7eff fix(native-chat): stop rendering tool output as the agent's streaming reply (#17782)
* fix(native-chat): stop rendering tool output as the agent's streaming reply

A tool result could appear in native chat as a raw, un-collapsed "assistant"
bubble that never went away for the rest of the turn — on mobile it showed up
as a wall of a source file's contents, prefixed by "Exit code 1".

Providers publish a tool's stdout/error as `lastAssistantMessage` so status
cards and dashboard rows can preview what the agent just did. Native chat reuses
that same field as its live streaming bubble, so the preview rendered as prose.
For Claude the preview is *only ever* tool output mid-turn: claude-tool-fields
writes real prose exclusively at Stop, so the bubble could never contain an
actual streaming reply.

It also could not be retired. The bubble hides once a transcript assistant block
leads with the streamed text, and tool output never lands in one — so the only
remaining exit was the turn ending, which is why a long tool-heavy turn pinned it
on screen.

Carry provenance instead of changing what the status surfaces show: mark the
writes that come from a tool result/error, keep the flag in lockstep with the
value it describes through the listener merge, and have both native-chat
streaming paths ignore a flagged preview. Status cards, dashboard rows and
automation capture are untouched.

The wire field is optional, so an older host that never sends it keeps today's
behavior rather than silently suppressing previews.

* fix(native-chat): preserve tool output provenance through renderer sync

* fix(native-chat): retain preview provenance in Claude roster state

* test(native-chat): cover restored tool preview provenance

---------

Co-authored-by: Merge Sim <sim@local>
2026-08-31 23:05:31 -07:00
Jinwoo Hong 8f15f217a2 Preserve user-set workspace names across branch changes (#17448)
* fix(worktrees): preserve user workspace names across branch changes

* test(worktrees): cover pinned rename metadata

* fix(workspaces): address display-name review edge cases

* fix(workspaces): keep automatic names fresh across refreshes

* fix(workspaces): preserve legacy CLI labels

* fix(workspaces): preserve display-name provenance across hosts

* fix(workspaces): honor legacy display-name provenance

* fix(workspaces): fence display-name refresh races

* fix(workspaces): accept peer renames from provenance-less hosts

The old-host preserve fence kept a pinned local label on every refresh,
which also suppressed a legitimate rename another client persisted
through the same host until app restart. Narrow it to labels the host
re-derived itself (branch short name, or path basename when detached);
any other changed label in a mode-less response is explicit meta a peer
wrote there. Stale prior-label responses stay covered by the downstream
staleness fence, in-flight writes by the pending fence.

* refactor(workspaces): unify display-name pin derivation

Three call sites (renderer optimistic update, local IPC updateMeta
handler, remote worktree.set handler) each restated the same formula;
a future edit to one would silently skew provenance between paths.
2026-08-31 19:08:05 -04:00
Neil 55efba2ac0 perf(mobile): summarize diff review counts in one pass (#17460) 2026-08-30 23:04:47 -07:00
Neil 7e5cc79e93 perf(mobile): project home host connection maps once (#17459) 2026-08-30 23:04:42 -07:00
Neil 7a72976d51 perf(mobile): compare terminal themes without serialization (#17455) 2026-08-30 23:04:21 -07:00
Neil 3db5a6aab8 perf(mobile): index slept worktree identity lookups (#17512) 2026-08-30 20:47:58 -07:00
a651e81843 refactor(agents): remove dead hook IPC and derive shared agent defaults (#16089)
* refactor(agent-hooks): drop the unused per-agent hook status IPC surface

No renderer, CLI, or mobile caller invoked window.api.agentHooks.*Status; main
already reads install status through MANAGED_AGENT_HOOK_STATUS_READERS. The
14 handlers had also drifted (kimiStatus existed in main/preload but not in
AgentHooksApi or the web stub).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tui-agent-config): default launchCmd and expectedProcess to detectCmd

32 of 36 entries repeated the binary name three times. Entries are now
authored in a source form where both default to detectCmd and resolved once
at module load, so TUI_AGENT_CONFIG keeps its exact shape for consumers
(verified equal to the previous table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mobile): derive the agent order, labels, and picker from src/shared

The mobile mirror (and its regex-over-desktop-source parity test) predates
mobile importing runtime values from src/shared, which it now does in a dozen
modules. Only the favicon-domain map stays mobile-local because desktop's lives
in the renderer catalog next to bundled ?url imports. The parity test now
imports the real registries and also checks label parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): align preload surface after hook IPC removal

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-30 19:02:14 -07:00
3d341c132f fix(mobile): dismiss the keyboard after sending to an agent (#17059)
* fix(mobile): dismiss the keyboard after sending to an agent

Sending a message left the software keyboard up, covering the reply the
user was waiting on. Drop it once the send is accepted, on all three send
paths: the terminal live input, the buffered command input, and the chat
composer.

Gated on the tab being an agent session. A plain shell keeps the keyboard
so back-to-back commands stay typeable, a rejected send keeps it so the
handed-back draft stays editable, and the accessory shortcut row is
untouched because dismissing would pull away the row being tapped.

* fix(mobile): gate keyboard dismissal on accepted sends

* fix(mobile): fence keyboard dismissal completions

* fix(mobile): fence stale send completions

* test(mobile): update terminal guard expectations

* fix(mobile): restore rejected buffered drafts by origin

* fix(mobile): preserve intentional buffered draft clears

* fix(mobile): harden send dismissal authority

* test(mobile): preserve Strict Mode send dismissal

* fix(mobile): preserve drafts across terminal remints

* fix(mobile): preserve draft ownership through terminal races

* fix(mobile): harden draft recovery and send freshness

* fix(mobile): fence route reuse and native draft clears

* fix(mobile): preserve native draft edits before clear

* test(mobile): pin the terminal-list sweep that bounds buffered drafts

`bufferedTerminalDraftState.pruneDrafts(retainedHandles)` is the only bound on
two structures that live as long as the session screen — the buffered-draft
record and the pending-restoration map — and nothing failed when it was deleted
or when it was pointed at the raw `terminal.list` handles instead of the
retained set. Both mutations reddened 0 of 3,949 mobile tests.

Adds the wiring pin (both mutations now redden it) plus two behavioural tests
showing why the argument matters: `terminal.list` omits a chat-covered handle
while the desktop graph reloads, so the raw list drops a draft the user is
still holding while the retained set keeps it.

---------

Co-authored-by: Merge Sim <merge@sim.local>
Co-authored-by: Merge Sim <sim@local>
2026-08-30 16:59:55 -07:00
Neil 81b4d88bd6 refactor(mobile): split tasks route into focused modules (#17438)
* refactor(mobile): split tasks route into focused modules

* fix(mobile): repair tasks refactor module boundaries

* chore(mobile): document intentional render resets

* fix(mobile): remove stale lint suppressions from tasks split

* test(mobile): keep parity checks stable with doctor suppressions

* test(mobile): follow tasks module split

* test(mobile): follow project routing module split
2026-08-30 14:10:36 -07:00
Neil 2daea491b9 Enable React static component and purity lints (#17337)
* Enable React purity and static component lints

* fix lint follow-up clock activation and eligibility expiry

* test render status bar provider panel with hooks

* fix checks clock activation before paint

* fix React type import in git history files
2026-08-29 23:46:35 -07:00
Neil 8cf692cf71 perf(mobile): slow certified terminal inventory sweeps (#17182)
* perf(mobile): slow certified terminal inventory sweeps

* fix(mobile): recover terminal inventory after stream teardown

* fix(mobile): keep fast sweeps while tabs drop a connected terminal

Tab snapshots are partial and only ever add terminals, so `terminal.list`
is the sole remover. With healthy sweeps slowed to 1/min, a background
terminal closed on the desktop lingered up to 60s, leaking its WebView and
leaving tabStripVisible stale.

Carry `connected`/`orphaned` through TerminalRecord and treat absence of a
connected, non-orphaned handle as a hint to schedule the authority -- never
as a decision to prune. Parked leaves and orphaned PTYs are legitimately
untabbed forever, so excluding them keeps the slow cadence from pinning.
2026-08-29 21:18:28 -07:00
Neil 7e7f241ec6 chore: enable React correctness lints (#17285)
* chore: enable React correctness lints

* chore: enable additional safe lint rules
2026-08-29 21:02:11 -07:00
Neil 15abc3fe26 Split mobile host screen layers (#17181)
* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Split settings navigation metadata

* Split daemon initialization lifecycle

* Split GitLab item dialog

* Split relay dispatcher layers

* Split mobile host screen

* Retarget mobile view settings source test

* Fix F3-speech for #17123

* Fix F1-cycle for #17131

* Fix F4-navtest for #17157

* Fix F2-allowlist for #17161
2026-08-29 20:16:28 -07:00
Neil 5f75b247b9 Extract mobile troubleshoot screen styles (#17146)
* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Fix F3-speech for #17123

* Fix F1-cycle for #17131
2026-08-29 20:07:23 -07:00
Neil 692ff95dd3 Split mobile browser pane modules (#17141)
* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Fix F3-speech for #17123

* Fix F1-cycle for #17131
2026-08-29 20:04:45 -07:00
Neil 2dfaa676d8 chore: update oxlint and oxfmt (#17150) 2026-08-29 14:13:35 -07:00
Brennan Benson 8cf7c6926e fix(mobile): create-worktree sheet dies after picking a source and loses the picked PR (#16917)
* fix(mobile): keep the create form on screen through drawer swaps and survive reconnects

The create-worktree flow could reach a state where the shared modal host was
mounted with no sheet in it: a full-screen transparent window that swallows
every tap with no way out. Frame analysis of the reported recording and a live
simulator repro both land on the same state - the form sheet laid out at the
right frame with progress=1, backdrop painting, sheet not painted.

- Keep the form sheet mounted through every drawer transition, so the host
  Modal is never on screen without a sheet, and drop the render-read pin ref.
- Re-assert a pinned sheet's enter transform when it takes the window back
  from a fill picker; nothing re-applied it before.
- Key the form session on hostId, not on the RpcClient object: useHostClient
  swaps that object on every reconnect, which silently remounted the form and
  threw away the picked source.
- Run the pasted-item lookup concurrently with the provider fan-out instead of
  after it (measured 2631ms -> 1480ms for a typed PR number).

* fix(mobile): remount the sheet view on window hand-back so a rebuilt native view repaints

On-device confirmation showed the committed hand-back re-assert never
reaches the native view: progress already sits at 1 and translateY at 0,
so withTiming produces no style delta, and the dead screen stayed
reachable (1/25 on the committed build; 1/9 with a sub-pixel value
nudge, which lands on the stale native binding when the view was rebuilt
with a new tag). Remounting the sheet's Animated.View on an epoch keyed
to the hand-back mounts a fresh native view with the style computed from
the current shared values - progress is already 1, so it paints in place
with no visible animation. 0 dead in 50 attempts on the remount build
under the same churn condition that reproduced the dead screen on base.

LANE-REPORT.md carries the full confirmation evidence and limits.

* chore: drop the stray lane report from the repo root

It is a working artifact, not source, and the root directory guard blocks
any new top-level entry.

* test(mobile): assert the sheet subtree rebuild directly, not through a test-only prop

The hand-back test proved the remount by reading an epoch-keyed nativeID that
existed only for it — production markup shaped by a test, and an assertion a
future refactor could satisfy without rebuilding anything. Count mounts of the
sheet's content instead, which is the property the fix actually depends on, and
drop the nativeID.

Also stop typing test renderers as 'ReactTestRenderer | null'. The static
analysis job installs no mobile/node_modules, so that type is unresolvable
there and the union trips no-redundant-type-constituents on every added line.

The hand-back re-assert is not dead code as the old comment implied: the drawer
swap hands back at 166ms, before the 180ms enter animation ends.

* fix(mobile): keep the create form when a render is thrown away

The session key was built from counters mutated during render. A blurred screen
suspends this subtree (react-native-screens freezes via react-freeze), so React
runs the component and then discards that render — but the counter bumps
survive it. The next committed render then produced a new key and remounted the
form, throwing away the picked source for a host switch or a close that never
committed.

Hold the open epoch in state, which React discards with the render that set it,
and put the host in the key directly instead of counting host changes.
2026-08-28 15:45:54 -07:00
Brennan Benson 5dc09db2cd fix(ui): label agent state glyphs and swap monitoring to a heartbeat (#16981)
* fix(ui): label agent state glyphs and swap monitoring to a heartbeat

The monitoring glyph read as unlabeled: AgentStateDot set only aria-label,
which renders no hover tooltip, so hovering it showed the row's own title —
the same truncated text already visible. Its row siblings (agent icon, model
chip) both had hover titles, leaving this glyph the odd one out.

Give every state a native title in the shared primitive, so done/working/
blocked/idle gain the same affordance across the sidebar, tab bar, dashboard,
kanban, cmd-J palette and AI Vault at once. Callers can override via a new
optional title prop; AiVaultSessionSubagents drops its now-redundant wrapper.

Native title rather than the Radix tooltip: AgentStateDot renders in two
surfaces with no TooltipProvider above it — the dashboard popout is its own
React root, and AgentMapScene — so Radix would throw there. StatusIndicator
already sets a native title for the same reason.

Also swap lucide Radio for Activity. Radio reads as "broadcasting"; the
heartbeat line reads as "still running", which is what the state means.
Mobile keeps its documented 1:1 parity with the desktop primitive.

Fixes STA-5794

* fix(ui): avoid duplicate agent state tooltips

* fix(ui): preserve disabled agent tooltip reason

* fix(ui): stop the state dot from shadowing a row's disabled reason

The shared AgentStateDot now emits a native title on every state, so at
any call site nested inside an element that already has a title, the
dot's generic state word wins on hover over the more useful ancestor
text. That regressed the sidebar agent row, which carries
`sendTargetDisabledReason ?? rowTitle`: hovering the dot showed
"Working" instead of the actionable send-target reason. Same guard the
review-notes send menu already uses.

Also covers three hunks that shipped untested: the Radix opt-outs in
ActivityPrototypePage and the AI Vault subagent line's dropped wrapper
title both stayed green when reverted, and the suppression test was a
`not.toContain` sweep that passed against the pre-fix tree.

* fix(ui): preserve heartbeat hover tooltips

* fix(ui): preserve lineage drop hit zones

* Use styled tooltips for state indicators

* Update jump palette tooltip assertions

* Limit status tooltips to agents

* Restore agent workspace status tooltips

* Keep status tooltips on agent indicators

* Clarify agent status tooltip ownership

* Restore agent-derived workspace status tooltips
2026-08-28 15:34:47 -07:00
Jinwoo Hong 5c10bf9001 fix(sta-5781): stop cross-client resets of workspace view preferences (#17057) 2026-08-28 15:20:21 -07:00
Jinwoo Hong bf5660df51 feat(mobile): add causal network diagnostics (#16837) 2026-08-27 21:42:23 -07:00
Brennan Benson 4f14d6d757 fix(mobile): stop held accessory keys after release under latency (#14219)
* fix(mobile): backpressure accessory key repeats

* fix(mobile): keep accessory repeats on the pressed terminal

* fix(mobile): serialize accessory key presses

* fix(mobile): preserve queued accessory taps

* fix(mobile): fence queued taps across reconnects

* fix(mobile): recheck queued tap delivery context

* fix(mobile): stop accessory repeats after IME send failure

* fix(mobile): pace repeated live-input edits

* fix(mobile): dispatch accessory taps without ack delay
2026-08-27 19:06:08 -07:00
Neil bdabb09a07 Extract mobile host and browser helpers (#16779) 2026-08-27 17:50:11 -07:00
Neil 7ee8b5e1a6 Refactor lower max-lines modules (#16760) 2026-08-27 16:10:51 -07:00
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