#30863 made the static job fail on unformatted files; 22 files that landed
around it were never run through oxfmt. Formatter output only, plus one
shared paneActive local in NativeChatStructuredSession.tsx so formatting
does not push that file past max-lines.
Co-authored-by: E2E Test <e2e@test.local>
* fix(memory): release consumed stream buffers and abandoned request state
* fix(memory): preserve queued cancellation for shared promise callers
* fix(memory): keep workspace cache cleanup free of browser side effects
* fix(memory): follow renamed workspace owners in cached diff views
* fix(memory): a viewer unmounting after its workspace is removed cannot re-create cached diff entries
Combined-diff view state, scroll offset and scroll anchor now live in one owner-tagged
entry per view key. A mounted viewer claims its key; writes only update claimed entries,
so the late unmount write that follows a workspace removal is a no-op instead of
re-adding orphan scroll entries.
* fix(memory): tighten retention fixes and let evicted diff views heal
- Combined diff: writes go through a per-mount lease. Forgetting a workspace revokes its
leases, so a late unmount write cannot resurrect entries, while an LRU-evicted entry of
a still-mounted viewer is re-created on its next write. Drops the duplicated view scrollTop.
- NDJSON framer: copy a retained tail only when it is a strict slice of its chunk, copy an
unfinished line once, and copy every rejected-line prefix.
- Binary frame reader: single exit path for the end-of-feed leftover copy.
- Relay readiness reuses waitForPromiseWithSignal instead of re-implementing it.
- PTY preconnect input: pass the listener-removing settle into createInput instead of
mutating the input afterwards.
* docs(memory): state the deferSettled ordering guarantee precisely
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
Move the 'recorded tunnel still matches this build' check into
orcad-managed-tunnel-active.ts as activeTunnelMatches(). No behaviour change.
Co-authored-by: E2E Test <e2e@test.local>
* fix(ssh): system SSH forward processes own their listener even under a user ControlMaster
Forward processes ran through the user's ssh_config ControlMaster, so the master held the
listener (it survived the child's kill) and logged sshd's refusals where the managed tunnel's
fallback check never looked. Every forward process now builds its args through one
unmultiplexed builder (-S none); orphan matching tolerates the older argv.
* docs(ssh): say why a forward never reused a live master's login
* docs(ssh): state the forward login trade-off plainly
* test(ssh): keep the real-OpenSSH test's test-double cast on its SAFETY line
* ci: fail the static job on unformatted files
Runs oxfmt --check over the same tree pnpm format writes (~2s), and makes the
pre-commit hook format markdown and YAML so it covers what CI now checks.
* ci: route docs-only and workflow-only diffs through the formatting check
The format check reads the whole tree, so a diff that skipped the static job
could land drift that fails every later PR. Any changed file oxfmt formats now
selects static analysis (not typecheck or units), like the mobile-only route.
The path detector checks out .oxfmtrc.json for its ignores. The hook no longer
fails a commit whose only matches oxfmt ignores (lockfiles).
* fix(ssh): Disconnect closes the managed server tunnels the SSH session carried
The managed tunnels live in their own manager, so the session teardown closed ordinary
forwards and the connection but left a system-SSH tunnel's ssh -L process authenticated and
listening. The teardown now closes every managed tunnel built or building on the target.
* fix(ssh): a session teardown also closes forwards still starting or awaiting their identity check
* fix(ssh): a connection teardown waits for every forward of it to settle, starts and closes under way included
* fix(ssh): bound a teardown's wait on starts, skip edit rollback after one, and never record a tunnel a teardown overtook
* fix(ssh): an edit a Disconnect cancelled leaves the target's saved forwards alone
* fix(terminal): Restart on a failed terminal opens a plain shell instead of Codex
The exit card's Restart passed a null startup, and a Codex account-restart
fallback added in #25485 turned that null into `codex`. Account switches
already name their agent explicitly, so the fallback had no legitimate caller.
* test(terminal): stub the exit-actions controller without Reflect.get
---------
Co-authored-by: E2E Test <e2e@test.local>
* feat(native-chat): `$` opens Codex skills in the composer
Typing `$` in a Codex chat now opens a skills-only list, as Codex's own
apps do. `/` still lists commands and skills for every agent, and other
agents keep `$` as plain text.
* fix(native-chat): `$` skips shell variables and parameters, as Codex does
In Codex chats `$HOME`, `$5`, `$(pwd)`, `${X}` and `$?` stay prose instead of opening the skill list, so Enter sends them; one classifier now serves the trigger, the menu and the insert.
* fix(native-chat): `$` matches skill names only, as Codex does
A `$` query no longer matches skill descriptions, so `echo $CODEX_HOME` + Enter sends instead of picking skill-installer; `/` still searches descriptions.
The tunnel was rebuilt only when this desktop's own check saw the server start. When another
desktop restarted it onto a new port, the old forward stayed and every check answered
unverifiable. A check that finds the server not answering now reads the port the host's
readiness record names and, when it moved, rebuilds the tunnel there.
* perf: preserve matching Terminal settings sections during search
* fix(settings): keep a retained Terminal sessions section fresh and share the section stack
Move the search-stable section wrapper into SettingsSectionStack so every settings
pane keys sections by their own element key instead of the array index, and subscribe
ManageSessionsSection to the daemon session inventory invalidation signal so a kill
routed through another surface still reaches a section that no longer remounts.
Restates the read-count oracles as node identity plus non-growing read counts so a
future freshness fix does not read as a regression.
* test(settings): mock the Windows administrator section in the Terminal section-lifetime suite
* style: format Terminal section-lifetime reliability gate
* perf(push): wake delivery worker after notifications are queued
* perf(push): wake one drain per send so bursts cost O(sends + drains) claims
A wake started all twelve drains, so each send arriving at an idle worker cost
~13 claim transactions. A wake now starts one drain; the poll tops up to the
full drain set.
A remote-runtime automation whose terminal stays open after completion kept
its 24h terminal.wait and data stream alive, holding one of the server's 16
long-wait slots each. Abort the wait and close the stream on dispose.
* fix(session-tabs): a held paired-tab close keeps hiding through an undecided re-pair, and its expiry redraws the tab
* fix(session-tabs): dropping a held close also clears its hide under the current pairing
* fix(session-tabs): redraw only the worktree a dropped close un-hid; inventory frames see the redraw mark
* fix(session-tabs): a re-pair proven the same machine asks for the snapshot its moved closes wait on
* fix(session-tabs): a dropped close clears only the current-pairing hide it laid
* fix(session-tabs): every un-hide of a held close lets its worktree's current snapshot redraw
* fix(sidebar-inputs): a failed domain read is retried by the next flush or phone read
A failed producer read used to clear the domain from the dirty set, so it stayed
stale until its own source announced another change. It now stays dirty without
a new mark: the next flush or phone read retries it, and nothing schedules one.
* docs(sidebar-inputs): flushDirty reads what was dirty; a failed read stays unpublished
* fix(ssh): an SSH server update counts terminals again just before it stops the server
The SSH update passed the terminal census taken before the artifact upload and no recount,
so a terminal opened during the upload was missed and, on a host running terminals inside
orcad, ended by the restart. The recount is now a required deploy option, so every runtime
(SSH, WSL, first deploy) supplies one and the stop step always recounts.
* fix(ssh): a recount that throws reads as unverifiable and defers the update
* fix(renderer): re-activating a workspace keeps the host it was selected on
A failed sleep, an automation's focus restore and a rename event re-activated
the workspace by id alone, which cleared activeWorkspaceExecutionHostId. For a
same-id twin (local + SSH) that left the active workspace ambiguous, so owner
resolution refused terminals and writes or guessed this computer. Each site now
passes back the host captured before the change.
Co-Authored-By: Claude <noreply@anthropic.com>
* test(sidebar): sleep restore assertions name the restored host argument
* test(automations): type the selected host without an assertion
* fix(renderer): same-workspace re-selection keeps the selected host; restores yield to a newer pick
Link clicks and surface reveals inside the active workspace re-selected it by
id alone, clearing the selected twin's host. They now pass it back through one
getActiveWorkspaceExecutionHostId helper (the owner module's private copy moved
there). The failed-sleep and rename restores skip when the user picked another
workspace during their awaits.
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(renderer): one re-select helper keeps a single-argument call when no host is selected
* fix(ipc-events): a rename restore also yields to a twin copy picked during the refresh
* refactor(renderer): capture and restore the workspace selection as one id+host value
Every restore site (failed sleep, automation focus restore, rename) now goes
through captureWorkspaceSelection / isWorkspaceSelected / restoreWorkspaceSelection,
so the host can't be dropped or replayed over a newer pick.
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(native-chat): a chat's visuals folder lives as long as the chat
A chat's scratch folder was deleted once the chat had gone 30 days
unused, so an old chat's inline visuals turned into "Visualization
unavailable" while the chat and its history stayed. The common pattern
keeps a chat's files for as long as the chat and never deletes them on
an "unused for N days" timer.
The sweep now removes only folders no chat record maps to, plus
leftover .expired folders older builds parked. Everything that existed
only to make the age rule safe goes with it: the per-folder save lock
and its expiry candidates, the session-lane idle check, the journal
last-activity query, and the migration's fresh-window timestamp. The
visuals skill no longer tells agents the folder is temporary.
* fix(native-chat): an image sent in a local chat lives as long as the chat
A local chat's pasted images lived in one paste folder that deleted
every file older than 30 days, sent or not, so an old chat's images
turned into label chips. Paired-server chats already keep pastes in the
host's attachment store, where the send that names a file claims it for
its chat and only unclaimed files are ever swept: the common pattern.
A local composer paste now goes into this machine's own attachment
store (starting the local chat host first), so its send claims it the
same way. The old paste folder is no longer written or swept: messages
sent before this change may name its files. Restored drafts still read
it.
An unclaimed upload now waits 30 days instead of one before the sweep
takes it. A draft names its image by this path, so the window is how
long an unsent draft keeps its image; 30 days is what local drafts had,
and it outlasts the host's send-id window so a resent draft still finds
its image.
A chat folder an older build set aside as unused (<hash>.expired) goes
back to its chat instead of being deleted.
* fix(native-chat): a terminal-backed chat's pastes are never swept
A terminal-backed chat has no journal, so no send claims its pastes and
nothing can tell a sent one from an abandoned one. Its pastes therefore
stay in the paste folder, which is no longer swept, rather than in the
attachment store, whose sweep would take them after 30 days unclaimed.
Only a local structured chat's paste (forStructuredChat) goes to the
store, where its send claims it.
* fix(native-chat): keep the attachment owner's literal kind for structured local chats
* test(ipc): expect the local chat store option on the clipboard registrar
* fix(native-chat): preserve attachment ownership and restore held files safely
* fix(native-chat): settle restored image checks independently
* test(native-chat): assert restored image outcomes without batching
* Re-check draft images when the attachment sweep removes their upload
A draft checks its pasted images once, when its composer comes back. Every
chat in an open workspace mounts its composer at launch, so that check ran
before the store sweep (60 s after host start, then every 6 h) deleted an
expired unsent upload, and nothing checked again: the chip lost its picture
with no Not kept feedback and Send stayed enabled.
The sweep now reports each removal attempt; the main process forwards it to
its window, and the draft store starts a fresh finite check for every loaded
local draft image in that folder, mounted or not. A notice is a reason to
check, never proof of absence.
* Let the app effect remove the draft re-check listener it adds
startNativeChatDraftLoad dropped the unsubscribe for the upload-removal
listener, so each recovery through the root error boundary's Retry remounted
App and added another listener. Return it so the effect's cleanup removes it.
The drag carried bare worktree ids, and the drop read each id's host from the
id-keyed worktree map, which keeps one row per id. When the same worktree is
published by two hosts (a local and an SSH copy), dragging one card could set
the status or manual order on the other host's copy.
The pointer and native drag sessions now record the dragged cards, and their
visible lineage children, as (id, host) copies; the lineage expansion runs by
host identity so a twin's children are not pulled in. The shared drop-commit
context hands the copies to every status, reorder and board-drop commit.
Every write then resolves through one choke point, dropWriteRows: a dragged id
writes only the dragged copies (read fresh; a copy gone by drop time is
refused), and any other id (a re-ranked sibling, a drop with no drag session)
only when a single host publishes it. The board drop indicator reads the same
rows, so the preview matches the commit.
Setting a project up on another host (a cloned or imported folder)
only knew how to link the folder through a GitHub identity. A project
keyed by its Git remote (GitLab, Bitbucket, a self-hosted host) was
always refused with "Imported folder does not match the selected
project identity". A fresh clone's remote is not probed when it is
registered, so it never projected into that project by itself.
The link now probes an unprobed folder's remote. If the canonical key
of that remote is the project's, the remote is recorded on the repo, so
the folder projects into the project. SSH and HTTPS forms share a key.
A GitHub project is still linked by stamping upstream, and a remote
that names another repository is still refused.
* fix(native-chat): a usage limit reads as a pause with its reset time and a Continue, not a failure
Claude, Codex and Grok chats that hit the account's usage limit now end the
turn on one warning row that names the limit and, in the reader's clock,
when it resets, with Continue to pick the turn back up. Each adapter reads
its protocol's typed limit signal into a `usageLimitReached` failure fact;
older clients keep the host's sentence.
* fix(native-chat): keep the failure words and chat session files within their line budgets
Moves the attachment-problem sentences to their own file and hands the composer's send straight to Continue.
* fix(native-chat): drop a check the shown branch already rules out
* fix(i18n): ship the usage-limit failure words in the runtime catalog
* fix(native-chat): reconcile usage failures by owning turn and typed cause
* fix(codex): keep snapshot import type-only
* fix(claude): open refusal turns without opening empty placeholders
* fix(native-chat): bind usage Continue to the latest idle failed turn
* test(native-chat): isolate stale Continue from ordinary delivery progress
* test(native-chat): use a braced journal assertion
* test(native-chat): build failure rows through their word factory
* fix(claude): retain terminal failure evidence through result reconciliation
* fix(native-chat): complete usage Continue through the composer sender
* test(native-chat): provide the composer options surface in Continue coverage
* test(native-chat): use typed failure words in renderer fixtures
* test(native-chat): match the remote send payload apart from transport options
* fix(native-chat): replace the previous sender notice on Continue
* fix(native-chat): clear superseded Continue notices before rendering
* fix(native-chat): preserve ordinary composer send arguments
* fix(claude): resolve terminal ownership and preserve resultless quota failures
* fix(claude): keep repeated terminal detail unchanged and complete test fixtures
* fix(claude): resolve deferred terminal replays after owning journal writes
* refactor(native-chat): share tombstone append within the sink line budget
* fix(claude): retain both input and echo correlation on the live turn
* fix(claude): preserve unowned result completion after terminal resolution
* fix(claude): allow unassociated successful goal completion
* fix(claude): keep terminal completion on the live turn
* fix(claude): keep typed notices separate from quota failures
* fix(claude): preserve context-limit failures as ordinary errors
* fix(native-chat): keep quota failures visible in collapsed turns
* fix(native-chat): return keyboard Continue focus to the composer
* test(native-chat): keep quota fixture context stable
* test(native-chat): hoist quota context to module scope
* fix(i18n): use one Chinese word for the agent in both Continue tooltips
gitExecFileAsyncBuffer never passed stdin to the child, so a buffered read
that needs it (cat-file --batch, hash-object --stdin) waited for EOF until
its timeout. Add the option and forward it, with a real-Git regression
test.
The localization audit treated the CSS inside three feature-wall <style>
elements as rendered copy, so the extractor wrapped 7-10k character style
sheets in translate() and every locale carried them. A translation could
then rewrite the CSS, and no pack could translate them under the
per-string cap. The audit now skips <style> and <script> children; the
three sheets are plain strings again, and their keys leave all catalogs
and the runtime-required English catalog.
A host control request that failed for an unexpected reason sent the thrown
error's message to the desktop as the error code. During a staging database
outage the desktop received Postgres's own text, and node-pg messages can
carry the database connection URL.
The relay now sends store codes and this codebase's snake_case invariant
slugs unchanged, and maps everything else to database_transient or
control_operation_failed. The original is logged through the existing
redaction (now shared with the background-operation logger), so only the
slug shape and SQLSTATE leave the process.
* test(codex): the stopped-send receipt guarantee lives in the journal since #30403#28046's dispatch-boundary tests pinned that the Codex adapter drops an echo
for a stopped or refused send. #30403 moved that rule to the journal's queued
late-accept write. Pin it there through the host's Stop path, and keep the
adapter tests asserting what still holds at the adapter: no fallback or second
turn/start write, and the earlier send's receipt survives an abandoned wait.
* test: pin that a receipt after Stop adds no dispatch row
* fix(mobile): phone page loads older history only from the Load earlier tap
On the phone page a scroll near the top paged in older history, often while iOS
was still bouncing the list past the top. The place-hold then wrote scrollTop
while WebKit owned the scroll, and the list stopped scrolling until the reader
left the chat. The page now loads older history only from the existing Load
earlier control (the native app keeps its drag paging), and the place-hold
never writes while the list is bounced past either end.
* fix(mobile): a reconnect no longer drops the older page the reader asked for
A replayed snapshot cancelled any in-flight "Load earlier messages" page, even
when the replay merged onto the same window and cursor, and every other lost
page (host refusal, error reply, transport drop) was swallowed, so the control
went back to its label as if nothing had been tried.
A page now lands unless its window was replaced or the cursor it was sent from
changed. A page that is lost shows one line under the control,
"Couldn't load earlier messages. Tap to try again.", cleared by the next tap or
a page that lands.
* fix(mobile): keep the failed-page copy out of raw JSX text
* fix(mobile): name the failed-page copy
* fix(mobile): clear the failed-page line when the window is replaced
* docs(mobile): the older-page read's comment names the retry line a refusal now shows
* feat(mobile): one older-page loader that owns the spinner, the budget and the outcome
* fix(mobile): terminal-backed chats read older pages through the shared loader, bounded to one tap's budget
* fix(mobile): agent-session chats read older pages through the shared loader
A failed page now shows the retry line instead of an invisible chat error,
history that can never be read ends paging, and one tap shares one budget.
* fix(mobile): the Load earlier control renders the loader's outcome for both chat types
A retryable failure keeps the button with the retry line; history that can never
be read swaps the button for one line saying so; scrolling at the top no longer
re-fires a failed read.
* style(mobile): format the read-back guard
* test(mobile): a failed agent-session older page keeps the chat readable, retries on a tap, and ends at unreadable history
* test(mobile): the older-page read now records its per-tap budget in the page goldens
Hand-updated: only the nativeChat.readSession options change (absent -> 15 s
budget spanning connect); every value is re-hashed and re-keyed as the recorder does.
* fix(mobile): every history reset ends an agent-session tap quietly, as the subscription's reload replaces the window
Only a final read refusal (damaged history, or saved by a newer Orca) ends a tap at
'Earlier messages can't be loaded.'; the test now drives that reachable path.
* fix(mobile): history that can't be loaded stays a line until the window is replaced
* fix(mobile): a page row keeps its key when older messages join it
A folded row's id is its first message's, so an older page that brings the
start of a tool run (or an older sibling of a roster) replaced the displayed
row: the list remounted it and the page's history split lost its first row.
Rows now carry a presentation key that stays while any of their messages
draw, handed on from the last committed render by the raw message ids each
row draws, and a vanished key resolves to the row that took its messages.
* fix(mobile): the page holds a parked reader's message, and leaves a following one to the follow owner
The place-hold fired on the first drawn row's id, which a kept row key no
longer changes, and held the lowest row on screen even while the reader
followed the end, so at the 2,000 cap it wrote against the follow owner.
It now fires when the source window's first message changes (an older page,
a cap trim), only for a reader who is not following, and puts back the
first tagged row on screen, else the nearest row the update kept, through
the row keys. iOS bounce still gets no write.
* fix(mobile): an open tool run keeps the reader's lines when older calls join it
With the run's row kept, older calls joining its front pushed the lines the
reader was on behind Show more. The reveal now counts the lines added above
its first line apart from what Show more asked for, so those lines stay
shown, each diff keeps its share, and Show less only undoes Show more. Lines
and the run are tagged, so the page's hold puts the line being read back
where it was. The flow test opens a chat inside a silent run, loads the rest
from an old host's cursorless reply, then lands a reply and a send.
* fix(mobile): an open tool run reads its first line from the pairs it already drew
Pairing the run again for its first line on every render broke the bounded
pairing a 2,049-call run relies on (one pairing per reveal). The flow test's
icon mock names every icon the run reads.
* fix(mobile): a cap trim that splits the top run leaves the run its key
Row-key hand-on walked the current rows oldest first, so when a trim left a
run's first result alone above it, that orphan took the run's key and the open
run remounted closed. Walk newest row first so the run keeps it.
* fix(mobile): a row's key belongs to the messages it is made of, not to the results it shows
A cap trim that split the top run gave the run's key to whichever split-off result row a
position-ordered scan met first: the result above it for calls made one at a time, the one
below it for parallel calls. The run then remounted closed and the reader's line went.
Each row is now identified by its own sources (a run by its assistant and call messages, a
roster by its group, a lone result row by its results) and keeps the key of the one previous
row it continues; results are content only. A key no row kept resolves to the row that took
in what it drew. If identity ever came from two rows, neither inherits.
* fix(mobile): the page holds the first part of what the reader saw that is still the same part
The hold took a line only from the first row on screen and otherwise trusted
that row's container. A result row absorbed by its run, a roster the fold moved
to an older sibling, or a trimmed line then moved the reader. It now lists the
tool lines on screen across every row (a run header only when no line shows,
a row top only when neither), and puts back the first one still drawn: a run
header only in the row that kept its key, a row top only while the same
message places that row. One write, skipped under half a pixel.
* test(mobile): pin the run-header rule in the reading-place flow; fold the hold's last cases into it
An open run whose header is above the screen holds nothing: with only its
Show more on screen, the rows after it keep their height when older calls
join. Removing the header-on-screen check moves them 840 px. The collapsed
case now checks the same rows. The iOS bounce and native cases move into
the flow test over its page model, so the hold's separate fake scroller
goes.
* fix(mobile): the page's follow owner pins to the live end before paint when the transcript grows
RN Web reports content size a task after layout, so with no browser scroll
anchoring a following reader saw one frame far above the end after Load earlier.
One ResizeObserver over the page scroller and its content, bound to the list's
mount, calls the same pin, which reads the live end and writes only when short of it.
* test(mobile): one page follow flow pins growth, the late size report and layout to the live end
The page's follow tests fold into one flow so the view test stays within its
line limit; the place-hold reads the follow owner through one prop bag so the
view does too.
* fix(mobile): only the page asks the list for its scrollable node
The native list never needs the resize observer, so it shouldn't reach for
the page-only node at attach.
* fix(mobile): a following reader on the page keeps the end as the browser's scroll anchor
At the 2,000-message cap a trim plus an append of the same height changes no size, so the
follow owner hears nothing, and Chromium's scroll anchoring keeps a visible message still,
scrolling the reader one message off the end each time. While the reader follows, the page's
scroller now takes the transcript out of anchoring and leaves a 1 px end marker as the anchor.
Parked readers and native lists are unchanged.
* test(mobile): move the cap-anchoring flow into its own view test file
MobileNativeChatView.test.ts is at the 800-line limit for test files.
* fix(mobile): the cap-anchoring test passes every required view prop; the style augmentation names why it is an interface
* fix(mobile): type the page's anchoring style locally instead of augmenting ViewStyle
* style(mobile): wrap the page scroller's styles
* test(mobile): the cap-anchoring contract joins the page describe, and one scroll-event helper replaces the copied event literals
* fix(mobile): a chat switch on the page opens the next chat at its newest message
The chat view stays mounted across chats, so a reader parked in one chat left
the follow owner parked for the next. On a scope change the page now resumes
following and pins to the scroller's live end before paint; the place-hold
drops what it held for the previous chat. Native keeps its current switch
behavior.
* fix(mobile): the phone page draws only the messages near the screen, in one list
On the page, RN Web's list decided which rows were in the document by index and
drew them in batches after paint: a chat opened climbing through its history
before reaching the newest message, and rows paged in above the reader were
drawn in the list's header instead, a second path where iOS stopped finger
scrolling at the seam.
The page now draws the transcript in one keyed flow: Load earlier, a top
spacer, the rows near the screen, a bottom spacer, then the live status and
waiting messages. Which rows are drawn is named by row key, so a page landing
or a cap trim never changes it. Rows drawn above the reader take their height
out of the top spacer, so nothing moves and nothing is written while the reader
touches or scrolls; a correction the spacer can't take (the place-hold's, or
making room above) is written once the reader is at rest. The follow owner
still owns the end and pins at commit. Native keeps its FlatList and props.
* refactor(mobile): the page window's coverage decision is its own pure module; the view reads the follow owner through one handle
* fix(mobile): rows arriving in a chat the page drew empty open at its end, not all at once
* fix(mobile): the page window draws from every scroll event, not RN Web's throttled report
* style(mobile): one-line comments in the page window
* refactor(mobile): the chat view reads the follow owner through one handle, keeping the file under its line cap
* fix(mobile): the page's spacer holds only measured rows, and older pages land once the reader stops
The phone page's windowed list sized its top spacer from estimated row heights. On iPhone a scroll
write stops a fling, so the estimate error could only be removed at rest: reaching the top showed
thousands of pixels of empty space, and an older page landing mid-fling grew a row above the reader
and moved what they were reading.
Now the spacer stands only for rows measured for how they draw now; rows never measured are left out
and measured in place only while following or once the reader has stopped (no touch, no scroll of
theirs for 150 ms). A page that arrives while the reader scrolls waits for that moment. Rows report
their heights through the page's one ResizeObserver, coverage is arithmetic over those heights, and
one at-rest rule puts the row the reader was reading back when anything above it changed size.
Unchanged rows keep their objects across a re-fold, so a height stays valid until its row changes.
* refactor(mobile): the page window's row registry, height scans and sync render in their own modules, under the line caps
* fix(mobile): an opened tool run, its lines and Show more stay as the reader left them when the page window unmounts the row
The phone page now draws only rows near the screen, so a row the reader scrolled past unmounts. Its
tool run's open state, each line's open state and Show more lived in the row's own state and reset on
remount, closing what the reader opened and drawing the row at a height its measurement no longer
matched. They now live in one per-chat map the page list provides (the desktop transcript's
mechanism); rows rendered elsewhere keep their own state as before.
* style(mobile): format
* fix(mobile): quality rules: explicit listener cleanup, no side effects in a state updater, kept disclosures scoped by a keyed provider
* fix(mobile): the page window moves only on measured rows, and never off the end while following
At 4x CPU the end pin's scroll event reached the coverage pass before the observer had measured the
drawn rows, so they were summed at the first-guess height, read as above the screen, and the window
shrank to the last row; the browser's clamp then looked like the reader scrolling up and a long chat
opened at its oldest message. Coverage now waits until every drawn row has a measured height (the
observer reports them before paint), and a following reader's window always reaches the end.
* style(mobile): format
* fix(mobile): the reading place is taken only from the reader's scrolls, so Load earlier at the top keeps it
A write's scroll event can arrive after an older page's rows were drawn above and before the correction; re-taking the place and covering then kept the pushed position.
* fix(mobile): only the reader's touch, wheel, pointer or key ends following on the page
Scroll to latest after a parked turn could land at the top: the window drew the end, the document shrank and regrew, and a scroll event iOS delivered after rows grew above read as the reader moving up, so following ended and the window drew where the scroll had been clamped. A move up now ends following only within a second of the reader's input on the list.
* fix(mobile): a scroll the browser makes when the page shrinks is not the reader's, so Collapse keeps the place
Collapsing tools while parked shrinks the rows above as they are re-measured; the browser clamps the scroll, and its event (delivered before the resize callback) re-took the reading place with nothing on screen and moved the window, so the reader ended at the bottom. A scroll with no input of the reader's and no scroll of theirs just before is now layout's.
* fix(mobile): the page decides once whether a scroll was the reader's, from what explains it
Three bugs today had one cause: a scroll the reader didn't make was taken as theirs (a write's late event, a clamp after the document shrank, a scroll delivered after rows changed). A scroll is now the reader's only when nothing else explains the move: not the page's write, not a commit whose resize callback hasn't run, not rows above the reading place, not a clamp to a shorter end. The window and both follow transitions use that one verdict; the reading place is re-taken after each resize callback, so a correction that couldn't land is never written later; no write lands while iOS bounces past an end. Replaces the one-second input window.
* fix(mobile): a jump away from the end while following draws where it lands, not every row to the end
The jump's scroll event covered with following still set, which keeps every row from the new start to the end: up to 1,603 rows mounted in one 1.8 s task before the follow change trimmed them.
* fix(mobile): Tools and text size keep a parked reader's place on the page
Toggling Tools regroups the rows, so the window's reading place (a row key) can vanish and nothing put the reader back: they moved about 20 messages. The place-hold, which already follows what the reader was reading through a regroup, now also runs when how rows draw changes.
* perf(mobile): measuring rows costs the same at any chat length
Each settled moment walked every measured row (resolving and comparing its state) while following, so the fill grew slower as it went (27-43 ms per round at 4x). Now one round checks rows within three screens of the reader, fills the next rows just above the measured run directly, and walks every row at most once a second; a round measures 12 rows, not 24.
* style(mobile): window hook back under its line cap
* fix(mobile): iOS bouncing past the end is the reader's scroll, not a clamp
The clamp test matched any position at or past the end, so every rubber-band frame read as layout: the page counted the reader as settled mid-bounce and an older page landed during it, with a one-to-two-frame blank. A clamp now lands exactly on the new end from a position the old one allowed.
* fix(mobile): a drawn measuring group waits for its measurement, so a landing while following doesn't redraw 30 times
After a landing while following, the next choice ran before the browser measured the group just drawn: the group sat at the top, so nothing looked owed above it and the group came down unmeasured, then went up again, 28 times in 180 ms until the pin landed. The choice now waits while the group's rows have never been measured, and re-renders only when it changes. Choosing what to measure moves to the heights; the re-check timer to the motion.
* fix(mobile): a correction puts back what layout moved, never the reader's own scroll
The browser can run a resize callback before the scroll event of a wheel, key or scrollbar move; the correction measured the reading row's move on screen, which included that scroll, and wrote it back (300 px per wheel tick while rows above were measured). The correction now puts back only the row's move within the content, keeps the scroll, and undoes only a clamp. A scroll that lands while a commit is unreconciled is left for the resize callback to tell apart, so a reader's move there still counts as theirs.
* fix(mobile): the end pin waits for the reader's move to be counted; measuring stays cheap while a reply streams
- The resize callback pinned to the end before telling apart a move no scroll event had reported, so a reader's first scroll up while following could be written back. The list now counts that move (which may end following) and then runs the pin.
- The nearby stale walk checked every measured row before its three-screen reach applied (2,236 checks per streamed update at 4x); the reach now bounds the walk.
- Measuring rounds draw 8 rows and leave 32 ms between rounds, so a streamed reply gets frames while rows are measured.
* fix(mobile): Collapse keeps the place when the browser hasn't applied a clamp yet
Collapsing shrank rows above a parked reader in rounds; when a round left the scroll past the new end before the browser clamped it, the write guard took it for an iOS end bounce and skipped the correction, and the window re-seated at the end. Corrections wait for rest, so past-the-end there is a pending clamp, not a bounce; only above-the-top stays guarded.
* fix(mobile): no second correction when the browser's own scroll anchoring already kept the row
On Chromium, rows landing above a parked reader are held on screen by the browser's scroll anchoring, which moves the scroll. The resize callback counted that move as the reader's, kept it, and put the row's content shift back on top: the row left the screen by 1.4-2.8k px. When the reading row is where it was on screen, the move is layout's and nothing is owed.
* fix(mobile): the page alone keeps a parked reader's place; the transcript is out of browser scroll anchoring
On Chromium the browser's scroll anchoring and the page's own correction both answered the same layout change, and the overlap can't be measured after the fact: a reader's wheel tick in the frame of a landing still got the landing put back twice (0.9-1.6k px). The transcript is now never a browser anchor, as on WebKit, and the end marker anchors only while following; the overlap guess (placeHeld) is gone.
* fix(mobile): no browser anchor while following either; only the page moves the scroll
The 1 px end marker stayed a browser anchor while following, so rows measured in above made the browser move the scroll to keep the end; the resize callback counted that move as the reader's and every measuring round waited for rest (spinner 36 s instead of 2.7 s on a 2,000-row open). The end pin already holds a following reader before paint and a shrink clamps onto the end, so the marker and its anchoring go.
* fix(mobile): on Chromium the page keeps a reader's place while they scroll, as the common list does
With the transcript out of browser scroll anchoring, corrections that waited for rest left Chromium readers (Android, desktop) seeing rows above change size mid-scroll, worse than main. Where a scroll write keeps momentum (every engine but WebKit), the resize callback's correction and the place-hold now write at once, as the common list does; WebKit, which stops a fling on any write and has no anchoring, still waits for rest.
* fix(mobile): a parked reader's place is the part of the row at the screen's top, not the row's own top
With browser anchoring off, growth inside the partly visible top row above the reading point (a tool card or image getting taller) left the row's top in place, so nothing corrected it: the text moved by the growth (240 px), where main's browser anchoring held it. The page now anchors the part of that row at the screen's top, as the browser's anchoring would pick; a following reader keeps the row's top, which the end pin makes enough.
* fix(mobile): on Chromium the browser keeps a parked reader's place; the page owns only following and a regroup
The page kept a parked reader's place itself with a saved element at the screen's top, which a
replaced card inside the top row took away (+240 px, main 0). Where the browser has scroll
anchoring it now holds the parked place, as main does; the end pin holds a following reader; the
place-hold takes the scroll for a regroup or a move to another row, with anchoring off from before
the update until its correction. Only input that goes the move's way makes a move the reader's
there, so the browser's moves never end or resume following. WebKit is unchanged. While following,
measuring waits for a streaming reply to pause.
* test(mobile): the page's own hold is tested as on iOS; layout settles before the reader's wheel
* style(mobile): format the page's anchoring change
* fix(mobile): the browser chooses its anchor again once the reader rests, after a scroll that landed on a spacer
A long jump can land where only a spacer is drawn; spacers are never an anchor, so Chromium chose
none and kept none after rows mounted there, until the next scroll (growth above then moved the
reader +240 px; main 0). At rest after a scroll the page nudges the scroll by a pixel and back, so
the browser chooses from the rows drawn now.
* fix(mobile): at the very top the page keeps a parked reader's place, where Chromium's anchoring never adjusts
Parked at scroll offset 0 while older rows were still being measured above, each measured group
pushed the reader down 0.9-1.2k px: Chromium makes no anchoring adjustment at offset 0, and the
page wrote no correction there. The page now takes its anchor and corrects only at the very top;
everywhere else the browser keeps the place, so the two never both move it.
* fix(mobile): after each commit the browser chooses its anchor from the rows laid out, not the container around them
Chromium keeps a partly visible container as its anchor when nothing inside qualifies; rows it
never laid out don't, and spacers never do. After a long scroll onto a spacer the anchor was the
transcript's wrapper, whose top never moves, until the next scroll: growth in the first ~150 ms
moved the reader +240 px (main 0). After each commit, while the browser holds the place, the page
lays the rows out and scrolls a pixel and back (its own writes), so Chromium chooses among them.
The settle-time re-choose goes.
* fix(mobile): the re-choose nudge is not taken as the page's own writes, so a reader's scroll landing there still counts
The nudge ends where it started, so the scroll event after it reports only what else moved the
scroll. Recorded as the page's own targets, it swallowed the End key's move to the end (within a
pixel of a target), which then didn't resume following (main and f044 did).
* fix(mobile): preserve native chat state across page windows
* test(mobile): use typed RPC fake for chat paging
* fix(mobile): on iOS a Tools toggle keeps the reader's place even while the list still glides
The page's WebKit place-hold wrote its correction only at rest, so a regroup committed within
150 ms of a reader scroll (or mid-glide after a tap outside the list) skipped it, and nothing
redid it: the text the reader was on moved by the regrouped rows' change and stayed there.
Unlike a source change, whose new rows stay out of the spacer until measured at rest, a regroup
resizes drawn rows at once. It now corrects at once, as main's hold always did.
* Preserve reading positions within phone chat scroll boxes
* Correct phone chat type declarations
* fix(mobile): a browser notice elsewhere on the phone page no longer fails a terminal that is still loading
On the page each terminal document listens for window errors, and before ready a reported error is
fatal ("Terminal failed to load"). The window is the whole app's, so the browser's ResizeObserver
loop notice, which the chat list's resize work can raise and which carries no error, reached a
terminal mounted in the background and failed it. The guest reporter now forwards only events with
an error behind them.
* fix(mobile): a parked reader stays parked when a collapse below them clamps the phone page to its end
After every resize the page attached following whenever the scroll sat within 1 px of the end,
whatever put it there. A collapse below a reader parked near the end makes WebKit clamp onto the
shorter end, so the next reply pulled them down. Following now comes only from classified motion
(the reader's own move, or a move toward the end), as on main; the resize callback only pins.
* refactor(mobile): the chat's row list, page window or native FlatList, is its own component
Main's composer and tab changes took MobileNativeChatView past its line cap once merged; the
platform choice of list and its wiring move out unchanged.
* fix(mobile): only a move toward the end makes a parked phone-page reader follow
A clamp onto a shorter end that lands while the reader's finger is still down from tapping a
collapse below them is classified as the reader's move, and any reader move ending at the end
attached following. Following now attaches only on a move down toward the end, as on main; the
test covers the clamp with and without a finger down.
* fix(mobile): a slow drag up detaches a following phone-page reader
Detach needed a single scroll event moving up more than 1px, so a slow drag that reports many 1px
moves never detached, and the next reply pulled the reader back to the end. Any upward move of the
reader's that leaves the end now detaches. Found by tracing the remaining iPhone jump.
* test(mobile): reconcile each step of the slow-drag detach test, as the browser's frame does
* test(mobile): run the slow-drag detach test as iOS Safari, where the bug was seen
* Fix following after a slow return to the chat end
* fix(mobile): closing a tool line forgets its nested command and output scroll positions
A nested box's address is the run's key, then the line's key:command, so the
old prefix match on the closed key never reached it and a reopened line
restored a stale scroll position.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
* fix(runtime): worktree removal from the CLI, a paired client or a headless host prunes the scan snapshots and label routes too
The desktop IPC removal dropped localhost label routes and pruned the persisted
cleanup and space-analysis scan snapshots; the runtime removal did neither, so a
worktree removed through the runtime could reappear in those snapshots. Both
paths now call one shared cleanup.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: drop casts from the runtime removal cleanup test
---------
Co-authored-by: Claude <noreply@anthropic.com>
The state mutation wrapper ran `timeout -s KILL 900 sh -c ...`, which old
BusyBox (`timeout [-t SECS] [-s SIG] PROG`) reads as running a program
named 900. The script never ran, the capture answered nothing and parsed
as failed, so every update stopped the server and rolled back. Probe the
syntax as the room check (#30618) already does.
Co-authored-by: Claude <noreply@anthropic.com>
* fix(persistence): an id-only worktree meta write refuses twins instead of taking the first writer's stamp
resolveWorktreeMetaOwner returned the raw-id row's host stamp whenever one
existed. With same-id twins that stamp names whichever twin wrote first, so
first-work renames, id-only updateMeta and sort-order writes for one twin
landed on the other. Twins now refuse; a single-host repo still uses the stamp.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(sidebar-inputs): a local pane of a same-id twin stamps the local copy
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(ssh): a bridged relay terminal's proven exit reaches the runtime from every close route
The legacy relay bridge told only the renderer when the old relay proved a
bridged terminal exited. A tab close through kill or pty:kill, or a shell
exiting on its own, left the runtime's record live (terminal list, wait for
exit, worktree ps). Only stop-and-wait published the exit.
The bridge's exit callback now publishes every exit it proves to the runtime
as host-confirmed. Stop-and-wait learns whether the bridge saw the exit
('reported') or holds no such terminal ('gone') and publishes to the runtime
only in the second case, so the exit is never recorded twice.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(ssh): a bridged exit keeps its operator-close cause
Codex review: a proven absence carries no exit code, so pass it as an
unreported cause instead of letting -1 read as an unconfirmed stop; and the
renderer's direct bridge close records the stop request first, as the kill
routes do.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(renderer): list a pane main holds before the window mounts it, so a tab close by a split's handle finds it (dark, layout-owner build only)
Co-Authored-By: Claude <noreply@anthropic.com>
* test(e2e): the split-close spec polls through the host's "can't answer yet" terminal list
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(runtime): a pane the window lists before main binds its terminal keeps main's terminal (dark, layout-owner build only)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(runtime): a tab close by a main-side split's handle stands on main's own record of the pane (dark, layout-owner build only)
Replaces the window-side listing and the binding fill: the window's list lags main in three
ways (built before main wrote the pane, from it before the binding, or before the window mounts
it), and only main's own layout answers all three.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(runtime): main's relisting for a close tells no clients (dark, layout-owner build only)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: E2E Test <e2e@test.local>
Co-authored-by: Claude <noreply@anthropic.com>
Export accepted manifests up to the 768 KiB validation cap, but every catalog
request (stage, state, abort) refuses params over the host's sealed-frame
budget, about 1.1 KiB lower. A manifest in that window was journaled and
fenced, then every way forward or back refused before sending, leaving the
host fenced. Measure the request budget once, in the export module, and check
it before the fence and before a delta's journal.
Co-authored-by: Claude <noreply@anthropic.com>
* fix(native-chat): save Codex's own reports of which message it received and which turn took it, with the turn's end
Codex's exact-ID echo of a message and its answer naming the turn that took a message are kept
until the journal holds them, saved at once and nonfatally, and any still unsaved go into the
turn end's transaction and every exit's settlement. Turn ends record who ended them, whether
receipt capture was complete, and a hook reason attributed to the turn.
* fix(native-chat): correct fact row narrowing and preserve sidebar sorting
* test(native-chat): pass the resolved-batch shape and new turn fields in tests
* fix(native-chat): any lost Codex receipt removes the completeness claim for the rest of that child
A per-turn record of lost receipts had to guess which turn a lost receipt belonged to, and an
older Codex can answer a send with one turn while recording it in another. Capture now keeps one
flag per child process: any loss (overflow, a receipt the journal can no longer take, a damaged
frame, a user item with no readable id, a cleared capture) means no later turn end of that child
claims every receipt was saved. Only a turn end that saves the held receipts with itself may
write the claim.
* test(native-chat): expect host provenance on turns the host ended
* fix(native-chat): save the reason when a Codex prompt hook stops a turn, not only when it blocks
* refactor(native-chat): keep only Codex's exact receipts; save each from any echo of it
Drop the completeness certificate, the answered-turn row, end provenance, the saved hook reason
and the retention map with its turn-end retry and exit carry; none has a reader any more.
A Codex echo now saves its send's receipt whenever the journal shows that send handed to this
child and still unsettled, so the second echo retries a failed first save.
* test(native-chat): read the retried Codex receipt from the host's saved record
* refactor(native-chat): decide a late receipt at its turn in the journal queue
Codex now reports either exact live echo of a send, and the journal writes the
acceptance only if the send is still unsettled when the write reaches its turn
in the queue. A second echo therefore retries a receipt whose first save
failed, and repeated receipts for any agent add no row or publication. This
replaces the adapter-side check that read the journal at echo time, with its
fence and handover conditions and the armed-entry fallback.
Adapter tests that pinned "an unarmed echo settles nothing" now leave that to
the journal, which ignores receipts for absent or settled sends.
* test(native-chat): drop the Codex exit test that pinned an unarmed echo settling nothing
An exact echo read after the child's exit is now reported like any other; the
journal decides whether the send still owes a receipt.
* fix(native-chat): type the late settlement's handover by either write it routes to
* style(native-chat): route the late receipt with a ternary
* fix(ssh): an overlapping managed-server update check reads the failure the first one recorded
recordedUpdateFailure read the caller's SshTarget, a copy taken before the
check queued on the host lock, so the in-lock re-read added in #30253 never
saw the other check's failure: both tried the update, and a skipped second
check lost the failure note. Read the stored row instead.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(ssh): decide the failure clear on a read after the update, not before the lock
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(automations): headless keeps finished per-run sessions asleep instead of closing them (#24208)
* fix(automations): a declined sole-terminal sleep never counts as slept
---------
Co-authored-by: E2E Test <e2e@test.local>
* The workspace you're on disappearing lands you where a sidebar delete does, whatever removed it
When the active workspace left the list for any reason other than a
successful delete from one of Orca's delete flows (a delete that partly
failed, git worktree remove, the orca CLI, another client, a workspace
opened while its own delete ran), Orca showed the empty "Select a
workspace" screen. The successor was chosen only by per-flow committers
prepared before each delete.
One App-lifetime store subscriber now remembers the last listed active
row and, once the selection is empty and that row has left the list,
focuses the same successor a sidebar delete picks: the most recently
visited other workspace in the project, else its main workspace, on that
row's own host. A selection the user cleared stays cleared; a process
exit's clear is marked automatic. The per-flow committers are removed.
Tracked in STA-8872.
* test: drop type assertions from the removal successor test
* fix: preserve selection intent across workspace removal
* fix(terminal): a pane main first sees at its binding goes where the window split it (dark, layout-owner build only)
* fix(terminal): keep main's divider positions when taking the window's split tree
* test(terminal): main's divider above the window's new split is kept
* ci: the layout-owner switch-on job runs the hidden-terminal load spec that caught the split graft
* test(terminal): type the side-by-side fixture as a split so ratio spreads typecheck
---------
Co-authored-by: E2E Test <e2e@test.local>