* 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>
* 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>
* 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
* 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.
* 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.
* 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
* 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
* 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
* 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
* 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.
* 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
* 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
* 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.
* 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>
* 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.
* 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
* 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.
* 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>