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