Commit Graph
822 Commits
Author SHA1 Message Date
Neil a598987a09 Merge origin/main into nwparker/piere-diffs
Only conflict was pnpm-workspace.yaml: both sides appended to minimumReleaseAgeExclude. Kept both
entries -- ours for @pierre/diffs@1.4.1 and main's for electron@43.7.0 -- and our
patchedDependencies line is untouched.
2026-09-12 00:59:33 -07:00
Brennan BensonandMerge Sim 76c8e91d4a fix(e2e): run worktree first-paint probe on a mapped window (#20197)
Co-authored-by: Merge Sim <sim@local>
2026-09-11 20:25:49 -07:00
Brennan BensonandMerge Sim 556a7772ed fix(e2e): remove four real flake sources and one caret race (#20169)
Four E2E specs failed once each across six main runs. Each traces to a
timing boundary the test could not control, not to product instability:

- linear-url-workspace-entry: pasted before X selection ownership landed,
  delivering stale text. Gate on a clipboard read-back.
- native-chat-first-flush-race: a bare 1_500ms sleep is exactly
  UNFLUSHED_SETTLE_MS, so it straddled the boundary deciding which of two
  hydration paths carried the test. Observe the not-yet-flushed read
  instead; a notFound is never cached, so this cannot perturb hydration.
- orchestration-idle-mail-delivery: asserted that a PTY -> daemon -> main
  round trip beats a 500ms production heuristic. Use the existing
  ORCA_E2E_ORCHESTRATION_POINTER_ENTER_DELAY_MS knob.
- tasks-page: the probe timeout was the one figure in the file not derived
  from GITHUB_TASK_SEARCH_IDLE_MS.

worktree.spec.ts exposed a real product race rather than a test bug: the
emoji caret-restore frame stayed armed through ordinary typing, so a
late frame could yank the caret back mid-input. Cancel it on the
non-emoji onChange path.

Also repairs a stale assertion: #20025 changed
remountTerminalTabForRecovery to return a result object and updated the
sibling call site but missed this one, so the comparison to `true` could
never pass. It is a deterministic break, not a flake.

Co-authored-by: Merge Sim <sim@local>
2026-09-11 18:37:16 -07:00
Brennan BensonandMerge Sim da5d555259 refactor(agent-status): delete the runtime's retained row store (PR 1b) (#19785)
* docs(agent-status): plan PR 1b at file level

Names the five RuntimeAgentRowStore call sites and what each becomes, why
terminalHandle has to be stamped before the store can go, and the one
intended behavior change.

* feat(agent-status): stamp the pane terminal handle on hook-server rows

The runtime's retained row store carried the pty binding two readers need. Put
that fact on the row that already owns the pane instead, resolved through the
same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot
disagree about which terminal a pane is.

Carried forward when a later write resolves no handle (only main's OSC parse
can), and never persisted: a handle belongs to the runtime that issued it.

* refactor(agent-status): route the session-tabs republish off the store

`retain()` was not only a duplicate store: its boolean return was the signal
that republished `session.tabs` for a status-only transition, which no title
change covers (#7970). `hook-status-session-tabs-invalidation.ts` already
mirrors that change set plus hook restore provenance, so route the signal off
the store rather than keep a second comparator.

Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out
deliberately skips — now load-bearing, because a dismissed row leaves the
listing at once.

Installed on both hosts. orcad had neither the OSC producer nor this signal, so
its runtime observed agent status and published it nowhere; deleting the
retained copy without wiring it would list no PTY agents there at all.

* refactor(agent-status): delete the runtime's duplicate retained row store

`RuntimeAgentRowStore` held the same payload the hook server already holds, so
the same pane could legitimately read differently in the sidebar, in
`worktree ps`, and on the phone. Both of its readers move onto the store's
snapshot in `runtime-hook-agent-row-selection.ts`, and
`collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook
reconciliation that only existed because two stores could disagree.

`ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`,
which is how a row still resolves the connected PTY behind it — the
working-terminal rollup's match key, and the last rescue for a row whose pane
binding a controller incarnation nulled under it.

The one intended behavior change: a row the user dismisses on the desktop
leaves `worktree ps` and mobile at once instead of lingering until the pty
exits. One store means one dismissal.

The suites written against the retained store are rewired to a real
AgentHookServer rather than deleted, so each still asserts the listing
behavior it named.

* docs(agent-status): record what PR 1b landed

Past tense, plus two corrections to the plan: `terminalHandle` is not the pty
id (they are different identifiers, and the explicit-status reader was already
comparing against a real handle), and the legacy numeric pane key is a
consequence the plan did not name.

* fix(agent-status): harden single-store lifecycle

* fix(agent-status): preserve mobile terminal rejoin

* fix(agent-status): preserve unverifiable remote rows

* fix(agent-status): own PTY row lifecycle in hook server

* fix(agent-status): preserve state and renew freshness

* fix(agent-status): ignore freshness for dismissed identity rows

* fix(agent-status): fence orcad observed identities

* fix(orcad): always release daemon adapter on cleanup

* fix(agent-status): cover remint and headless lifecycle edges

* fix agent status identity recovery gaps

* fix(agent-status): suppress duplicate child-only row mutation

* test(runtime): preserve hook store wiring in transcript harness

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 15:28:16 -07:00
Jinwoo Hong 729491597f feat(desktop): measure relay regions and reconnect after idle cutover (#20106) 2026-09-11 11:43:48 -07:00
Neil a0799d8f1c fix(terminal): move the recovery ledger onto the tab row and gate it on observed outcome (#20025)
* fix(terminal): move the recovery ledger onto the tab row and gate it on outcome

The recovery budget lived in module-level Maps keyed by tabId. Anything keyed
outside the row needs a release path, and that release fired on every
remount-driven pane disposal, so each remount erased the budget it had just
consumed (crash b5cfc6ca). Put the ledger on TerminalTab and write it in the
same set() as the generation bump: reading the budget is now reading the tab,
so releasing it independently has no expression.

Counting was also the wrong control. Every remount mounts a pane that captures
a FRESH recovery epoch, so the epoch check can never refuse its request —
recovery re-requested the exact action that had just failed with no evidence
anything changed. Gate on an observed outcome instead, reusing the direct-SSH
pane retry vocabulary (success | failed | timed-out | superseded) and its
settle call sites: an unsettled attempt blocks the next one, and a settled
failure refuses the same reason until a new trigger arrives (generation move,
or the user's Retry). The 3-per-5min cap stays as a breadcrumb-emitting
backstop, not the control.

viewMode now also lands on the row from the local toggles, mirroring how pin
already does it, so the chat-ownership guard reads one index instead of OR-ing
two.

* fix(terminal): persist the row's viewMode and keep both chat-ownership reads

The narrowed chat-ownership guard read a field the session schema strips:
terminalTabSchema never declared viewMode, so the terminal row lost it on every
load while the unified tab kept it. After a restart the row read undefined and
recovery would remount a chat-owned tab's hidden surface — the race #19745's
guard exists to prevent.

Declare viewMode on terminalTabSchema so the row is durable, and keep the
disjunction rather than replacing it. The schema cannot retroactively add the
field to sessions already on disk, so the first load after upgrade still has it
only on the unified tab; and for a safety check over two partly-redundant
sources, a hole in either index should err toward declining a heal.

Also cover three structural guards that no test was holding: both remote
ledger-carry paths (terminal-build, remote-workspace-session-merge) and the
only success settle in the state machine, including its placement past the
failure branches.

* fix(terminal): settle a fresh spawn's outcome and prove the ownership guard across a reload

spawn-left-pane-unbound was the one recovery reason with no success settle:
its remount heals by spawning, not reattaching, so it reached none of the
reattach settle points and left the attempt 'pending' for the full 31s bound.
A fresh spawn that binds a PTY now reports it, the dual of the unbound settle
that already reported failure.

Two tests outside src/ still called remountTerminalTabForRecovery by its old
boolean contract and broke CI; both are updated to the admission result.

Also strips the client-local recovery ledger at the remote-workspace projection
boundary, in the type as well as the destructure, so a future producer cannot
put another machine's Date.now() on the wire.

* fix(terminal): resolve the pane's tab row once for both epochs after the main merge

#20034 replaced connect-pane-pty's inline tab resolution with
findTerminalTabForPane, and this branch had rewritten the line below it to read
the recovery epoch off the row that block used to bind. The merge was textually
clean and semantically broken: `terminalTab` no longer existed, so typecheck
failed and every test that connects a pane threw ReferenceError.

Resolve the row once through the new helper and feed both epochs from it, which
keeps #20034's refactor and this branch's reason for reading the row here — a
second lookup would put another tabsByWorktree scan on the connect path.

captureTabRecoveryGeneration is narrowed to the one field it reads so the
helper's record type can carry it.
2026-09-11 04:50:42 -07:00
Neil 8e6ba76f27 test(diff): close the last detach window before measuring selection glyphs
The settle loop still had an rAF after it -- the horizontal scroll awaited a frame before point()
measured, so the highlight upgrade could detach the glyphs in that window and reproduce the same
bogus clipped-or-covered error. Nothing scrolls smoothly here (no scroll-behavior: smooth in our
CSS or Pierre's) and getBoundingClientRect forces sync layout, so the await bought nothing: drop
it and keep everything from the settle loop to measurement synchronous.

point() now re-asserts liveness and reports detachment as itself rather than as pane geometry,
which is what sent three rounds of fixes chasing viewport width. contentEl() is no longer
non-null-asserted -- the deref sat inside the JSON.stringify building the diagnostic, so a
container swap would have replaced the numbers with a TypeError -- and the never-settled error
now names the synthetic-newline endpoint case too.
2026-09-11 03:35:24 -07:00
Neil bad02b9df7 test(diff): settle selection glyphs after the scroll waits, not before
The previous commit checked the glyph rects before the scrollIntoView frames, so on the common
path it passed with freshly walked nodes and the highlight upgrade then detached them during
those very frames -- every later measurement read an all-zero rect and the CI flake survived.
Move the settle loop after the scrolls, name the failure so a detached node no longer surfaces
as a bogus pane-geometry error, and re-query [data-content] per collect in case the upgrade
replaces the container rather than its rows.

A variant that retried the whole scroll-and-measure cycle was tried and reverted: it failed 2 of
8 runs and took the pair from 1.4m to 9.7m.
2026-09-11 02:45:08 -07:00
Neil 88712dd85f test(diff): resolve selection glyphs against live nodes, not a stale walk
CI's diagnostics showed the assumption behind the earlier width fixes was wrong: the window was
1600x900 and the pane 485px, with room to spare for a 91px selection. The glyph rect was all
zeros -- the syntax-highlight upgrade replaces a row's nodes after first paint, so nodes walked a
frame earlier were already detached when measured. Re-collect and re-resolve until both endpoints
have a layout box and still belong to the pane.
2026-09-11 02:26:10 -07:00
20c56249d5 fix(terminal): keep a deliberately slept workspace cold until it is woken (#20075)
* fix(terminal): keep a deliberately slept workspace cold until it is woken

Sleeping a workspace kills its PTYs but keeps its panes mounted and keeps each
tab's session id as a wake hint. Any later remount of those panes (recovery,
parking, portals) reattached that dead id, and the daemon's create-or-attach
spawned a fresh shell, so slept workspaces revived on their own (#10205).

The existing sleep-intent marker now outlives teardown and gates the deferred
connect itself, so both the reattach and fresh-spawn arms stay cold. It is
released by activating the workspace, by any PTY binding to one of its tabs
(CLI, automation, client wake), and by purge. A queued startup still connects.

Reproduces the community root cause from gatsby74 in #13343; the regression
e2e remounts a slept hidden pane and fails on main.

Co-authored-by: gatsby74 <gatsby74@users.noreply.github.com>

Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>

* fix(terminal): let a slept pane wait for its wake instead of latching cold

A pane whose connect ran while its workspace was slept used to mark itself
connected and stop; nothing re-armed it, so a wake that produced a live PTY
before the user clicked (CLI create, background agent resume, split panes)
left panes stranded. The connect now waits on the sleep marker and resumes
when the marker clears, and a torn-down pane drops its listener.

Tabs created with a live PTY clear the marker too, the sleep flow marks each
workspace only when its own teardown starts, and purge forgets the marker
without waking anything.

* fix(terminal): wake a waiting pane once, in its remounted generation

Activation clears the sleep marker after the set() that bumps dead tabs'
generations, and the waiting pane only resumes its connect when its tab
generation is still current. Otherwise the stale pane and its remounted
successor both reattached the same session id on a deliberate wake.

* fix(terminal): resolve the waiting pane's tab by either id and re-arm after wake

The wake listener looked the tab up by the pane's render id, which can be a
unified id whose terminal tab lives under entityId, so the generation check
declined forever for those panes. Mount, fresh spawn, and the wake listener now
share one live resolver. The wait flag resets when the listener fires so a
second sleep can hold the pane again, listener dispatch is guarded, folder
activation clears after its own set(), and the sleep flow re-asserts the marker
after each teardown while releasing a workspace the user activated meanwhile.

* fix(terminal): ignore PTY binds that land inside the sleep teardown window

A spawn resolving while shutdown was still awaiting the host bound a PTY and
cleared the marker, waking every waiting pane mid-sleep; re-marking afterwards
could not un-connect them. The sleep flow now scopes each teardown so binds in
that window are not wakes. The e2e asserts a deliberate wake yields exactly one
PTY, and the dispose test proves the listener is gone.

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>
2026-09-11 01:45:57 -07:00
Neil 22d12388a5 fix(pi): load extension providers for source control generation (#20070) 2026-09-11 01:27:15 -07:00
Brennan BensonandNeil 78e985cd99 fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245) (#16631)
* fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245)

The managed pi/omp/prime-agent status extension suppressed itself whenever
ORCA_PI_STATUS_OWNED held a PID other than its own, with no check that the
owner still existed. A restart leaves the previous owner's PID in the
inherited env, so every later load returned early and the pane stopped
reporting status permanently.

Probe the owner before suppressing. Only ESRCH proves it is gone; any other
probe result keeps suppression so a live foreign owner still cannot
double-report. This mirrors the tri-state in
main/agent-hooks/managed-hook-owner-identity.ts, which the extension cannot
import because it loads inside the pi/omp runtime with no Orca deps.

Also extracts the generated-source test harness into its own module so the
suite stays under the max-lines limit.

* fix(pi): validate inherited status owner pid markers

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-11 01:25:17 -07:00
Neil 68aa61950a test(diff): measure the gutter inset across stacked sticky columns, and report geometry on failure
A side-by-side pane stacks two sticky line-number columns, so an inset measured from the first
alone left the drag start underneath the second -- which is what CI kept hitting after the window
resize, because its display clamps the window narrower than a dev machine. Take the widest number
column in the pane's left half; an unfiltered max picks up cells scrolled far right and overshoots
instead, which broke the sibling spec when I tried it.

The clipped-endpoint error now carries pane width, inset, glyph and window geometry, so the next
narrow-display failure reports its numbers instead of needing them guessed at locally.
2026-09-11 00:56:42 -07:00
Neil 5fa62feda7 perf(terminal): mount only the visible pane on a worktree switch (#20034)
* perf(terminal): mount only the visible pane on a worktree switch

Activating a worktree mounted a TerminalPane for every tab it holds, not just
the one on screen. Cold-activation deferral existed for this but engaged only
past four deferrable hidden tabs, which exempted the 2-5 tab worktrees that
make up almost every real switch.

Deferral now engages for any deferrable hidden tab, and the siblings it skips
are admitted one per idle frame after the reveal, capped at the population the
old threshold would have mounted eagerly. Steady-state pane, WebGL-context and
heap population are therefore unchanged; only the frame the mounts land on
moved.

* fix(terminal): judge admission eligibility on the largest deferred set seen

Review found the launch worktree never warms up: it is restored active before
hydration opens the startup gate, so admission read an empty deferred set,
cached ineligible, and never recomputed once the real plan landed. Judge on the
high-water mark instead - an over-cap worktree still stays ineligible as its set
drains, but a later plan is seen.

Also from review: the e2e WebGL counter read getPanes(), which returns a public
projection with no webglAddon field, so it was always 0; read
getRenderingDiagnostics() instead. Filler worktrees now clean up on failure
(testRepoPath is worker-scoped), and the restore metric is named for what it
measures rather than implying a pixel assertion.

* test(e2e): wait for the reveal to restore, and scope the latency budget off CI

CI failed with 'revealed terminal never restored its content': the harness
sampled a fixed 4s window, which a shared runner can outlast, so a slow restore
was recorded as no restore. Poll for the restore instead.

Also stop asserting a latency budget on CI. Shared runners cannot hold a
threshold; the structural invariants (one pane mounted by the switch, warm set
restored) are exact and stay asserted everywhere.
2026-09-11 00:47:57 -07:00
Jinwoo HongandOmar Shahine 3b82d8de64 fix(runtime): let connections own host status recovery (#20003)
* fix(runtime): let connections own host status recovery

Verify runtime status after authenticated connection recovery and publish
ordered snapshots to desktop and browser viewers. Consolidate failed-status
retries in the connection owner and remove renderer retry/diagnostics merging.

Adapt sidebar host-state derivation and regression coverage from Omar
Shahine's original fix in https://github.com/stablyai/orca/pull/19163.

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>

* fix(runtime): show blocked hosts honestly and remove obsolete status options

* fix(runtime): preserve timeout guidance and update IPC test fixtures

* fix(runtime): preserve status evidence and address review gaps

* test(sidebar): assert workspace host icons dimming and recovery tooltips

* fix(palette): require available hosts before adding implicit badges

* fix: retain disconnected host snapshots for new renderers

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
2026-09-11 03:19:01 -04:00
Neil b9ac35fc9c test(diff): resize the real window for selection drags and quarantine the flaky combined restore
page.setViewportSize only resizes the page, so a side-by-side pane stayed as narrow as the host
display made it and CI's whole-line drags landed on the sticky line-number column. Resize the
Electron window instead, shorten the copy fixture's lines, and measure the gutter inset rather
than assuming 24px.

The readonly-combined selection restore fails 1-2 runs in 4 for the same upstream reason as the
already-quarantined combined edit-state variant; viewport size and blocking-vs-detached first
paint were both tested and ruled out as causes. Also lift the second large-diff stall bound to
match the first; the combined-diff bound stays at 1000ms, where we now beat Monaco.
2026-09-10 23:50:35 -07:00
Neil ba5ac1a01d test(diff): correct the p95 figures to this test's own measurements 2026-09-10 22:24:38 -07:00
Neil f5b6e14bc6 test(diff): assert steady-state responsiveness and record the large-diff stall regression
The freeze guard asserted only worst-case lag, which the Pierre renderer trips on CI hardware.
Add a tight p95 bound -- the axis the migration improved, and the one a real freeze would blow --
and loosen the max bound to the measured post-migration cost, with both renderers' numbers and
the upstream cause written into the test so the regression is recorded, not hidden.
2026-09-10 22:24:13 -07:00
Brennan BensonandMerge Sim 1798786d4e perf(native-chat): mount only the transcript rows near the viewport (#19869)
* refactor(native-chat): share one row-content derivation between row and list

Windowing needs the list and the row to agree on which messages draw
nothing: a row the list counts but the row declines to render would
reserve estimated height for an empty slot.

Extracts the block derivation out of NativeChatMessageRow into a module
cached on the block array, so a streaming turn pays for it once per
revision rather than once per consumer.

* refactor(native-chat): keep an opened tool run open past its row's lifetime

A tool run, tool line or diff card the reader opened is state they created, but
it lives in the component's own `useState`. That is fine while every row is
mounted forever. It stops being fine the moment rows can be unmounted: the run
silently re-collapses behind the reader's back.

Rows now read their disclosure from a transcript-level map when one is provided
and fall back to their own state when they are rendered standalone. The controls
that re-sync a run — the toolbar's expand-all, a turn's disclosure, a diff
reveal — are folded into the key the choice is remembered under, so a control
flip reads as "nothing recorded yet" and the new default stands without a
mid-render write to a map an ancestor owns.

`ToolLine` moves to its own file; the run was over the line cap with it.

* perf(native-chat): mount only the transcript rows near the viewport

A settled transcript mounts every row it has ever loaded, so the cost of opening
a conversation grows with its length even though only a screenful is legible.
Rows near the viewport are now the only ones in the document; the rest are
reserved as estimated height and measured when they arrive.

Four things had to change for that to be safe:

- `zoom` moves from the transcript column onto the scroll container. Item
  measurements are in the zoomed content's pixels while `scrollTop` is not, so
  with the two split across the boundary the window's arithmetic was off by
  exactly the font scale — correct at the top of a transcript and blank deep
  inside it. The column's padding moves to a new inner element to keep the
  layout it had. This does mean the scrollbar itself zooms with the text.
- The three siblings that made up a row — the message, the turn status, the
  turn's diff rollup — move into one wrapper that carries the spacing they used
  to take from the column. The spacing between rows is the window's `gap`, never
  the height estimate, which would otherwise be counted twice.
- Messages that draw nothing no longer take a slot. Counted but undrawn, each
  one would reserve estimated height for a row that never appears.
- Paging in older history is driven by scroll events alone. Every row that
  resolves its real height moves the content and re-fires the size observers, so
  the old "am I near the top?" test would have asked for another page once per
  measurement. It now also requires the view to have moved upwards and requires
  new items since the last request.

Anchoring is the virtualizer's: `anchorTo: 'end'` re-resolves the row at the
current offset across a count change, which replaces the hand-rolled prepend
anchor, and `followOnAppend` keeps a reader at the bottom pinned there. The
document-level bottom pin stays, because the typing indicator, the activity line
and the column's end padding all live past the last row.

Revealing a diff from a turn rollup can target a row that isn't mounted, so that
row is pinned into the window and the card still reports its own position — a
turn that touched four files lands on the one that was asked for.

* fix(native-chat): let a pinned row reach the mounted window

Two faults the windowing tests turned up, plus the handles they needed.

The virtualizer memoizes its mounted index list on the range extractor's
identity. Holding that identity stable — which is right for the measurement
memo, and was the reason it was written that way — meant a row pinned after the
fact was never picked up: revealing a diff in a row the window had left behind
pointed at a row that stayed unmounted. The extractor now changes identity with
the pinned set, which is not a dependency of the measurement memo, so nothing
expensive is rebuilt.

The offset a row sits at is read off the `offsetParent` chain, with a rect-based
fallback for the case where there is none. Using that fallback for the window's
own scroll margin was wrong in kind: with no layout to measure, it returns the
scroll position itself, so the margin tracked the offset and the window sat at
the top of the transcript wherever the reader scrolled. The margin now takes the
offset chain or nothing; the fallback stays where it belongs, on the reveal.

The scroll root and the window's spacer are named, so measurement can find the
scroll root without depending on which utility class makes it scroll, and so a
test can tell a window from a whole transcript.

* test(native-chat): cover the windowed transcript, and prove the window engaged

The integration harness stubs `offsetHeight` — on the scroll root and on every
row — because that is what the virtualizer measures with, and a DOM without
layout answers zero to all of it. Rows report the height their own estimate
predicted, which keeps the reserved totals exact no matter which rows have been
mounted long enough to be measured.

Every case reads the window through one helper that refuses to pass when there
is no window. Without that, raising the usability gate would send all of them
down the whole-transcript path, where "fewer rows mounted than messages" is
false but every other assertion still holds — and they would go on reporting
green while covering nothing. Reserved height is asserted as an exact total
rather than "greater than zero", which a degenerate empty window also satisfies,
and the mounted range is asserted to bracket the offset rather than merely to be
smaller than the transcript.

Covered: the window mounts a subset and moves with the reader; the newest row
and a reveal's target stay mounted from outside it; an opened tool run is still
open when its row comes back; a message that draws nothing takes no slot; and
the scroll root with no usable height still renders every row as a direct child
of the transcript column.

What the environment cannot show is stated where it matters rather than faked:
its ResizeObserver never fires and a scroll assignment emits no event, so
measurement settling, the bottom pin under a streaming turn, prepend anchoring
and smooth scrolling are covered as pure decisions — height estimation, the
pinned set, range extraction, and whether a position should page in older
history — and left to a real renderer as behaviour.

* docs(native-chat): say that one offset path does read rects

* test(native-chat): pin the window against a row that grows in place

Whole-message appends were covered; a row being replaced by a taller
version of itself — what a streaming reply is — was not. The existing
windowing harness gains two things it needs to see that: a scroll root
with a real document (a height, a viewport, and a scrollTop that clamps),
and a resize observer that delivers when a target's height actually
changed, since happy-dom's never fires and nothing re-measures without it.

Frame by frame, while one row grows from 24px to 6358px: the view stays
0px from the bottom, the row stays mounted, and the reserved total tracks
the measurement rather than the estimate. A reader who scrolls up mid
growth keeps the exact offset they chose for the rest of it.

* test(native-chat): guard history prepend anchoring

* test(native-chat): strengthen prepend anchor contract

* fix(native-chat): preserve provider tool call identity

* fix(native-chat): harden transcript windowing lifecycle

* test(native-chat): install virtualizer viewport for turn timing

* fix(native-chat): reject blank tool call identities

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 21:52:21 -07:00
Neil 74961006e0 test(diff): make diff selection drags independent of the display size
CI runs a narrower window than a dev machine, so a side-by-side pane was too narrow to expose
both ends of a whole-line selection and the drag landed on the sticky line-number column. Pin the
viewport in both specs, scroll the target rows into view vertically, and measure the gutter inset
instead of assuming 24px.
2026-09-10 21:35:29 -07:00
Neil 603284fc7f test(diffs): quarantine the combined edit-state selection variant
It fails ~4 runs in 20 and has never passed CI on this branch -- it was already
red at d507adc, before any of the fixes on top of it.

Root cause is upstream. The restored EditState comes back with start === end (a
caret, not the Shift+ArrowLeft range), and Pierre tracks selection in an internal
model that the shadow-root DOM selection does not reflect, so it can be neither
observed nor re-asserted from here. Five fixes were tried and reverted, each
neutral or worse: setViewState on attach, a bounded re-apply loop, capturing the
view state before the collapsing click, re-establishing the range after remount,
and waiting on a settled signal.

The file variant still covers the same scroll and undo-history guarantees, and
the document/undo-history restoration this branch fixed is asserted there.

Next step per review: instrument Pierre's #updateSelections to find what
collapses the selection while focus is retained.
2026-09-10 19:20:37 -07:00
fb9ba4b681 fix(editor): make markdown images inline so a paragraph stays schema-valid (#19746)
* fix(editor): make markdown images inline so a paragraph stays schema-valid

Image was registered as a block node while paragraph is content:'inline*',
but the markdown pipeline nests an inline image as a paragraph child.
Schema.nodeFromJSON does not validate content, so the editor built a
schema-invalid document that rendered fine and threw on the first step
that reassembled the paragraph - i.e. on the user's next keystroke.

Report 0e46c048 (1.4.198, macOS): RangeError "Invalid content for node
paragraph" from checkContent via Node.replace, tearing down the
editor.rich-markdown boundary.

Register Image as inline and override paragraph's parseMarkdown so a lone
image is not hoisted out of its paragraph. Also fixes the same crash class
reachable through details/summary. Markdown output is byte-identical.

* fix(editor): keep a fenced code block intact when an image is inserted into it

Making the image node inline meant it could no longer be fitted into
codeBlock (content:'text*', marks:''), so inserting one with the cursor
inside a fence made ProseMirror close the block at the insertion point:
the remaining code escaped as plain prose and the language attribute was
lost, and autosave wrote that markdown to the user's file. The pre-fix
block image split the fence into two intact blocks instead.

Resolve the insert content against the target position: when an inline
image cannot be fitted where the caret sits, wrap it in a paragraph so
ProseMirror splits the block and both halves keep their ``` fencing and
language. Prose insertion is unchanged. Every production insert path now
shares that resolution - the toolbar picker, the slash command and the
clipboard-screenshot paste through insertRichMarkdownImageFromPath, plus
the GitHub/GitLab composer's image-URL insert - each with a regression
test.

Also guard the unchecked cast of Paragraph.config.parseMarkdown: a Tiptap
upgrade that drops the field would otherwise turn every paragraph parse
into a TypeError and take the whole editor down, instead of degrading to
parseInline.

Four of the new round-trip cases asserted only on getMarkdown(), which
walks the document without running NodeType.checkContent and so emits
byte-identical output from a schema-invalid document - they passed on the
pre-fix code. roundTripMarkdown now runs doc.check(), the list-item and
table-cell case performs a real edit, and the standalone-image case types
beside the image. All twelve cases now fail on the merge-base.

Adds an Electron e2e spec driving the real renderer: a paragraph image and
a toggle-summary image each survive a keystroke, and Bold over a selection
spanning the image keeps it.

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <neil@stably.ai>
2026-09-10 17:42:50 -07:00
Neil 21cbc15469 Merge remote-tracking branch 'origin/main' into nwparker/piere-diffs 2026-09-10 16:56:03 -07:00
Brennan BensonandMerge Sim 027acb4efa fix(native-chat): settle a structured send on admission, not on the provider echo (#19863)
* fix(native-chat): settle a structured send on admission, not on the provider echo

Sending a message in structured native chat raised "Message delivery is
unconfirmed." with a Retry button on a message that had in fact been
delivered. Measured across 14 days of local journals: 44 of 173 delivered
sends (25.4%) tripped it.

The dispatch path wrote the message to the provider, then waited a fixed
10s for the provider to echo the message's uuid back. That echo is emitted
when the provider STARTS the turn, so a message queued behind a running
turn cannot be echoed until that turn ends. Echo latency is bounded by the
previous turn's duration, which is unbounded -- one send took 105 minutes.
The 10s constant sat at the p75 of real echo latency, with the slowest
clean send at 9.76s, a margin of 0.24s. No constant can work: the wait was
measuring the wrong event.

The false banner was not cosmetic. It invited a Retry, and Retry bypassed
the operation ledger to redeliver. One message reached the model five times
through that path.

Dispatch now returns as soon as the transport write completes and writes no
dispatch row; the submission stays `pending`, a neutral state, and the
provider's echo settles it `accepted` through the late-settlement channel
whenever the turn ahead of it ends. Delivery doubt is reachable only from
process facts -- a refused write, a dead child, a dead host -- never from
elapsed time.

Retry re-delivers only where the recorded reason proves the message never
reached the provider. The list is deliberately fail-closed: refusing a
legitimate retry costs the user a re-type, while allowing an illegitimate
one sends the model a second copy of their message. A refused entry now
leaves the outbox with an explicit notice instead of parking at the head,
where it would have wedged every message queued behind it.

The send-response classification moves to a pure module beside the existing
outbox reconciler, so both writers of an entry's state now live together and
the decision is unit-testable rather than reachable only through the hook.

Scope and known gaps:
- Codex carries the same 10s stopwatch. It has no late-settlement channel,
  matches waiters by queue order rather than identity, and has no waiter
  lifecycle at all, so there was no safe subset to land here. A marker
  constant records the debt and deletes itself when that lands.
- A message refused re-delivery loses its standing delivery notice and
  leaves only a transient error line. A passive "waiting to be accepted"
  affordance is the follow-up.
- The restart reconciler that would decide a dead child or a dead host on
  evidence rather than refusing them is fully written and has never had a
  production caller. Wiring it is the next change, and it removes the
  re-type cost above.

* fix(native-chat): harden structured dispatch settlement

* fix(native-chat): preserve dispatch recovery evidence

* fix(native-chat): preserve pending send compatibility

* fix(native-chat): satisfy native import audit

* fix(native-chat): bound legacy send settlement

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 16:29:02 -07:00
Brennan BensonandMerge Sim fb85f88d64 fix(browser): restore the Chrome-shaped browser identity (STA-7147) (#19927)
* fix(browser): restore the Chrome-shaped browser identity (STA-7147)

#18749 replaced every browser partition's Chrome-shaped UA with Electron's stock
one, so since v1.4.198 the embedded browser announces itself on every non-Google
host as:

  Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like
  Gecko) Orca/1.4.198 Chrome/150.0.7871.224 Electron/43.4.1 Safari/537.36

No browser sends that. Sites that re-check the identity holding a session reject
it: users report being signed out of x.com, LinkedIn and "most websites," and at
least one was signed out of LinkedIn in their own Chrome and met LinkedIn's
"suspicious activity" SMS check -- server-side revocation, which reaches beyond
our app. The repo already documented the mechanism in browser-google-auth-ua.ts:
copied-in cookies "sent under a UA that doesn't match a real first-party browser
get flagged by anti-fraud." That is why the Google auth-host switch exists;
#18749 kept it for accounts.google.com and handed every other host an Electron
identity.

Restore the pre-#18749 session identity: strip the Electron and app tokens, and
rewrite sec-ch-ua to match. Nothing in the cookie-import write path changed --
it never did; cookies were always written correctly and servers were refusing
them.

Deliberately KEPT from #18749, all independent of the UA:
- anti-detection.ts stays deleted. Its premises were measured false on Electron
  43 and its overrides are themselves published bot signatures.
- No Runtime.enable into cross-origin iframes (the documented Cloudflare CDP tell).
- No unconditional CDP debugger attach on every browsing guest.

Known tradeoff, measured: this re-opens #13822. On the unmerged predecessor
branch brennan/sta-3905-cloudflare-ua, commit 9f0a4772fe recorded the stock UA
clearing dash.cloudflare.com 5/5 while every rewritten variant failed 12/12, and
noted that adding client hints does not rescue it. So Cloudflare-gated sites will
show verification failures again until a coherent-identity fix lands. That is a
bounded, in-app annoyance; session revocation damages users' real accounts. A
CDP Emulation.setUserAgentOverride with full userAgentMetadata -- which drives
navigator.userAgentData as well as the headers, and was never tested -- is the
candidate that could satisfy both, and is being measured separately.

Tests: the real-Electron wire-identity test now asserts the stripped identity on
ordinary hosts and Firefox on Google auth hosts. Ablation-verified: neutering
cleanElectronUserAgent turns it red on the Electron-token assertion. Its fixture
also gained an app name -- without one the raw UA carried no app token, so the
Orca/x.y.z half of the cleaner was never exercised.

* fix(browser): finish the identity revert in the files CI caught

browser-session-registry.persistence.test.ts still asserted #18749's behaviour
("keeps the stock UA", "keeps the engine UA"), so the shipped code and its test
disagreed. Caught by CI shard 4/8, not locally: I reverted four test files and
went to typecheck without re-running the browser suite.

Also restores the accurate wording that #18749 generalised away, now that the
behaviour it described is back:
- browser-google-auth-ua.ts: names the Electron/Chrome-shaped UA again as what
  anti-fraud flags, which is the reason the auth-host switch exists at all.
- docs/browser/profiles.mdx: documents the cleaned Chrome UA default and the
  --no-ua-spoof escape hatch, which is real again.
- tests/tools/google-signin-ua-probe.cjs: comments name the live handler.

Deliberately left at #18749's version, because those changes stay correct with
anti-detection.ts deleted:
- browser-manager-viewport.ts: its comment no longer cites the retired
  addScriptToEvaluateOnNewDocument injection.
- browser-webauthn-profile-delete.test.ts: its added webRequest mock is REQUIRED
  by the restored setupClientHintsOverride, so reverting it would break the test.

* fix(browser): keep restored UA hints browser-owned

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 15:20:34 -07:00
Brennan BensonandMerge Sim 33436c30d8 refactor(native-chat): unify agent session launch and open drafts in structured chat (#19681)
* wip(native-chat): first-pass draft routing into structured chat (to be reworked)

* refactor(native-chat): gather agent launch route inputs in one builder

Every launch entrypoint assembled the route resolver's inputs by hand and
they disagreed: only three of seven passed the project runtime blocker, so
a WSL-pinned project was refused structured chat from the tab bar but
admitted from the create dialogs. buildAgentLaunchRouteInput is now the
one place that gathers host, capabilities, workspace kind, project runtime
and TUI customization, and works for workspaces that do not exist yet.

Also deletes the dead draft-prompt blocker from the shared resolver; the
renderer stopped passing it and the main process never did.

* refactor(native-chat): share one structured launch settle loop

Five entrypoints copied the same loop around startStructuredAgentLaunch:
start, claim a refusal fallback, await, branch on refusal or unknown. The
copies drifted: direct work-item and full create reported an unexpected
launch error as success, and resume handled neither refusal nor unknown.

settleStructuredAgentLaunch now owns that loop and returns one settlement
(structured, refused-then-legacy, cancelled, visibility-unknown, failed).
Direct work-item, full create, folder workspace, both onboarding folder
paths and vault resume consume it; each keeps only its own legacy fallback.
Resume deliberately has no fallback. Unknown outcomes release the caller
uniformly so a stale fallback closure cannot fire on a later reconcile.

* refactor(native-chat): route the new-tab launcher through the shared settle loop

The new-tab launcher fired its refusal fallback and forgot it: nobody
learned whether the terminal fallback ran, and a visibility-unknown outcome
was never surfaced. Its structured branch now runs through
settleStructuredAgentLaunch with the terminal launch as the legacy fallback.
launchAgentInNewTab stays synchronous; the result gains a structuredSettlement
promise, and promptDeliveryResult keeps following the terminal fallback's
delivery on refusal as it did through the callers bridge before.

* refactor(native-chat): one legacy prompt delivery path and one trust preflight

The direct work-item flow kept its own seed-and-paste copy of the legacy
prompt delivery; it now uses deliverLaunchPromptToAgentTab with its own
timeout notice supplied as a callback. Three private copies of the trust
preflight (session continuation, worktree creation, folder workspace) fold
onto preflightAgentTrust. The direct work-item pre-launch mark keeps its own
entry because it differs in timing, not mechanism.

* refactor(native-chat): run quick create through the shared settle loop

Quick create was the last entrypoint driving the launch handle itself,
because its cancel lifecycle is real: when the creation is abandoned the
structured launch must be cancelled immediately so a staged prompt never
reaches the provider. The shared loop now takes a cancellation hook with an
eager subscription plus a post-await check; it cancels the launch once,
unsubscribes on settle, and reports cancelled without running the fallback.
Quick create keeps its two-branch legacy fallback and retire-on-late-cancel.

Also updates the surface-caller census for the onboarding launch module
that step 2 introduced.

* fix(native-chat): open editable drafts in structured chat for eligible local Codex launches

Route order asked the default-view-mode question first, and that decider
applies the terminal mirror gate (a TUI cannot clear more than forty lines
of prefilled draft), so a PR body over forty lines reached the plain
terminal before structured eligibility was checked. Structured eligibility
now comes first; the mirror gate applies only on the legacy branch.

The structured draft seed writes the launch-draft store directly with no
mirror gate, since a structured session has no terminal copy to fall back
on. Closing a settled structured tab clears an unadopted seed. The
structured session treats idle and loading as unsettled so the adoption
hook takes its baseline from the loaded transcript. Each caller passes one
delivery-mode value to both the route builder and the settle loop.

The structured session component test is split with a shared harness so
it stays under the test file line cap.

* test(native-chat): make the structured session test harness type-portable

* fix(native-chat): close review gaps in the shared launch settle loop

- Claim a refusal fallback only when the caller supplies one, so vault
  resume no longer reports a terminal fallback it never opened.
- A failed or cancelled direct work-item launch returns no tab id, so the
  caller never pastes the prompt into a setup shell.
- Terminal fork activates with providesInitialSurface for structured
  launches and gates its toast on the settlement; the draft blocker
  deletion made fork route structured too.
- A failed launch clears its draft seed. The failure toast moves to its own
  module to keep the launch-state file under the line cap.
- Ratchet for settle-loop callers; cancel-during-fallback documented.
- Restore the local agent label lookup that the pane-agent identity
  inventory expects instead of the inventoried helper.

* fix(native-chat): resolve the agent label through one module

* fix(terminal-pane): keep the fork dialog from reopening a created worktree

A failed or unknown structured settlement returned false after the fork
worktree already existed, so the dialog stayed open and a second click
created another worktree. Unknown now closes the dialog (the launch badge
already reports it); failed copies the context the way a null launch does.

* chore: restore pnpm-lock.yaml to main (local pnpm rewrite slipped into a commit)

* test(native-chat): stop asserting the deleted draft feasibility input

The routing-authority test expected the shared predicate to receive
isDraftPrompt; delivery mode is prompt metadata and never reaches
feasibility now, so assert its absence instead.

* refactor(native-chat): decide every agent launch route in one planner

The route was still resolved at seven callers, each also calling the settle
loop; two census tests only stopped an eighth. planAgentSessionLaunch is now
the one production caller of the resolver and its launch() the one caller of
the settle loop, and both censuses pin exactly that file.

The funnel is two-phase because three sites need the route before the
workspace exists and quick create persists its request for recovery: a plan
exposes route before creation and launches with the created worktree id;
a persisted quick-create request carries the verdict as data and re-enters
through adoptAgentSessionLaunchVerdict without re-resolving. Delivery mode
is fixed on the request once, so route and launch cannot disagree.

* test(native-chat): pin the two adopters of a planned launch verdict

* fix(native-chat): answer route readability from the repo when the worktree row is absent

The planner's transcript-readability input dropped the repo-level connection
fallback the direct work-item path still computes for its startup payload, so a
route planned in the window right after workspace creation saw `undefined` —
which reads as "not locally readable" — and downgraded grok/omp launches from
native chat to a raw terminal. Only `undefined` ("cannot determine the host")
now defers to the repo; a resolved `null` stays the local answer.

* refactor(native-chat): answer structured feasibility with a query, not a launch plan

Every rendered AI Vault row built a whole launch plan — execution-host lookup,
project-runtime resolution, capability read, plus a plan object and a launch
closure it threw away — to read one boolean off it. Feasibility and a launch
decision are different operations, so the planner now exports the predicate for
the first and keeps the plan for the second, and the census pins the query's
callers separately. Settings arrive by argument, which makes the AI Vault
callback's dependency on them real rather than a comment the linter contradicts.

The plan's `explicitStructured` branch had that gate as its only caller and goes
with it; the vault's launch already re-enters on an adopted verdict.

* refactor(terminal-pane): fold the fork's trust preflight onto the canonical one

`preflightForkAgentTrust` was a behavioural duplicate of `preflightAgentTrust`,
whose signature now accepts a nullable agent and workspace path and so is a
drop-in replacement. Its file is left holding only the launch-platform resolver
— which is not a duplicate, since it returns an override rather than a default —
so the file is renamed for what it now contains.

* refactor(native-chat): cancel a structured launch through an AbortSignal

The settle loop's launch cancellation re-derived the standard poll-plus-eager-
event primitive that `AbortSignal` already is, so it now takes one. The eager
semantics are unchanged: the loop still cancels on the abort event rather than
only polling after awaits, so a staged prompt is discarded before it reaches the
provider, and it drops its listener on settle instead of leaving the signal
holding the closure. Quick create owns the controller and bridges its store
subscription to it.

A cancel that lands after the refusal fallback already opened a terminal now
carries that surface on the settlement. It is the fallback's tab that exists, so
reporting the pre-launch one handed the caller a workspace with no agent in it.

* fix(native-chat): tighten quick create's structured launch settle path

Four things the launch path got wrong once the settle loop owned the flow:

- The abandoned-creation check now runs before the first-message rename flag is
  written, so a creation being torn down is no longer marked for a rename that
  will never happen (the order the pre-planner code had).
- A cancel that arrives after the refusal fallback opened its terminal reports
  that terminal rather than the pre-launch tab.
- `plan.launch` is called outside the caller's try, and nothing awaits that
  caller, so a throw there would strand the creation panel. It is now caught and
  reported the way a failed launch already is.
- The launch route is a required argument instead of defaulting to
  `terminal-tui`, which would have silently reported success with no surface
  opened. Both callers already gate on the structured route.

* fix(native-chat): give one launch identity one prompt delivery mode

A caller joining a pending launch computed its outbox text from its own delivery
mode, so an auto-submit caller landing on a draft launch enqueued text the first
caller's seed was already showing in the composer: the user saw it and it was
sent. The mode is now fixed by the caller that opened the launch, and a joiner
delivers its text that way.

Seeding also moved to where the coalesce decision is made, so a launch whose
callers already settled as refused is not given a fresh draft — the refusal path
early-returns, so nothing would ever clear it and it would outlive every tab.

* fix(work-item): report a failed structured launch as a failed direct launch

`launchWorkItemDirect` returned true unconditionally, so a structured launch
that opened no surface still read as a started workspace. Callers hang
irreversible follow-up work off that boolean — the fix-checks dialog fires
`onLaunched` on it, which is documented as the home for host writes — so a
launch with no agent tab now reports false, matching what full create does.

The settle result says so explicitly rather than leaving callers to infer it
from a null tab id, which `notLaunched` also produces.

* test(session-tabs): pin the id a first structured publication is minted under

The launch draft seed is keyed on `structuredAgentSessionTabId(sessionId)`
before the tab exists, while the mirror mints ids with collision avoidance that
can append a `:history-N` suffix. The two agree today only because a fresh
session's base id is unique. Pin that where the id is actually minted, with the
collision arm alongside it so the divergence the seed depends on staying away is
visible rather than assumed.

* test(native-chat): pin the route connection fallback on the un-mocked resolver

The suite that covers the builder stages `getConnectionIdFromState`, so it can
characterize the fallback but cannot catch a defect that lives in owner
resolution itself. This one runs the real resolution over real store rows: two
repos publishing the same worktree id on different hosts, which is the
documented case where the owner cannot be named and `undefined` is returned.
Red with both fix files at the previous head, green with them.

Reverts the two caller pins added to the route census — the feasibility
predicate is exported from the planner, which the census already permits, so it
passes unedited and needs no permit clause.

* fix(native-chat): keep the structured launch's own agent eligibility check

Quick create's structured launch narrowed its guard to a bare `agent` presence
check, so a creation carrying an agent that cannot hold a structured session
reported itself cancelled once dismissed, where it previously reported that it
had done nothing. Unreachable through both callers today, but it is the last
local eligibility check in a module that otherwise trusts its callers for the
route, so it is restored rather than left to the required-route typing — which
says nothing about the agent.

Also corrects two comments that called the quick-create request "persisted".
It lives in renderer session memory and dies with the renderer; calling it
persisted made the plan/adopt split read as restart recovery, when what it
actually buys is a route decided before the worktree exists.

* fix(native-chat): keep the structured feasibility query typecheck-clean

The query threaded its narrow settings through the store, but the route
store's settings must satisfy the full GlobalSettings that two of its
resolvers require, so the narrow copy never fit. Ride the named settings
on the built input instead: the caller still names them, so a React memo
still depends on them, and no store-shaped object is needed.

Also give the launch state its delivery mode unconditionally; the key is
required, and a conditional spread makes it optional under
exactOptionalPropertyTypes.

* docs(native-chat): name the feasibility query's one remaining settings asymmetry

The builder reads launch customization off the store while the routing gate
reads the named settings, so one answer has two settings sources. It cannot
diverge with the single caller passing the object the store already holds, but a
PR about removing split sources should not leave that unstated.

* fix(native-chat): keep a coalesced joiner's draft unsent

joinLaunchDelivery stripped the joiner's delivery mode when the launch it
joined had established none, and an absent mode reads as submit. A joiner
that asked for a draft therefore had its text sent — the send-without-
consent this PR exists to prevent. Fall back to the joiner's own mode only
when nothing was established, so the first caller still wins otherwise.

* chore: re-trigger CI

GitHub created no workflow run for e935ea5e42 — the pull_request
synchronize event was dropped. No content change.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 14:54:09 -07:00
Brennan BensonandMerge Sim 2626e2eca4 Make the structured turn lifecycle row durable so completed durations survive (#19695)
* Make the structured turn lifecycle row durable so completed durations survive

A structured-chat turn used to end by tombstoning its running lifecycle item,
which threw away the only durable record of when the turn ended. Completed
"Worked for" labels therefore depended on the renderer having observed the
turn finish, and vanished on reopen.

The lifecycle item is now revised in place, never tombstoned:
- running, with startedAt, at the provider's turn start
- completed or interrupted, with completedAt, at the provider's terminal frame,
  a user stop, or a child exit the host observed
- unverifiable, with no end, when a cold acquire finds a running row from a
  generation whose exit nobody observed

Both timestamps are the execution host's clock at receipt, captured before the
deferred sink, so the completed value is identical on every client and needs
no client clock. Codex history restore uses the provider's own second-granular
endpoints for turns that predate this change. Desktop and mobile read settled
durations off the journal through one shared selector, and anchor the live
counter on the host start with the client's local receipt so a skewed client
clock never leaks into the label. Locally observed durations remain the
fallback for hosts that still tombstone.

Timestamps live inside the existing turnLifecycle field, which old clients
strip, and every working-state consumer keys on state === 'running', so no
capability negotiation is needed.

* native-chat: avoid stale working status on settled turns

* test: align settled turn status expectations

* Name settled lifecycle rows by their terminal state

An interrupted or unverifiable turn must not read as completed for any
consumer that renders status text raw. One shared helper builds the text for
both providers from the lifecycle state.

* test: deduplicate turn lifecycle suites

Each behavior keeps one test; duplicated harnesses and restated cases go.

* Key lifecycle rows to their user item and record the provider's measured duration

A lifecycle row now names the user item that opened the turn by its provider
key, so clients attribute timing explicitly and fall back to journal order
only for rows from older hosts. A provider-initiated turn with no prompt can
no longer claim the previous prompt's duration.

When the provider measures the turn itself (Codex turn.durationMs, Claude
result.duration_ms) the terminal row records it and clients prefer it over the
host interval, so a turn shows the same number live and after a history
restore. Host receipt times remain the live-counter anchor and the fallback.

* Record a turn as a first-class journal item

The turn record is now its own item kind rather than a status row carrying a
lifecycle field: no text to misuse, and the fold matches the durable turn
record other systems keep. Rows that carry it are stamped journal schema v3;
every other row stays v2, so an older host keeps reading them and latches
read-only at the first v3 row instead of truncating the epoch.

Clients that predate the item would paint an unknown kind as a text bubble,
so the host publishes the legacy status form to any client that does not
advertise agent-session.turn-item.v1, through the same per-client seam
background tasks use. The downgrade is transitional and goes once no
supported release lacks the capability. The shared projection now renders
unknown item kinds as nothing, so later kinds need no gate. One shared reader
handles both forms for old journals and old hosts.

* Preserve observed turn end across settlement retries

* Retain turn attribution for loaded chat history

* Preserve Codex exit receipt across close retries

* Register completed turn duration reliability gate

* Keep earlier turns through a Codex rewind and count a mid-turn attach from the real start

Findings from an independent adversarial review of the typed turn record:

- A Codex rewind adopted the provider's item list as the new epoch, and the
  provider never returns the host's own turn rows, so every duration before
  the rewind point vanished. The host's turn rows are now spliced back beside
  the item each followed, and recovery no longer expects the provider to
  prove rows it never owned.
- The epoch row was stamped with the current schema version, so an older host
  latched read-only at row 1 of every new session, defeating the mixed
  version design. It carries no body and stays at v2; a stored-row test now
  reads SQLite directly, because the reader upcasts every row on read.
- A send Codex folds into a running turn shares the opening prompt's provider
  key, and the alias map credited the duration to the later prompt. The
  earliest submission naming a key now wins.
- The live counter anchored on first sight, so a client attaching mid-turn
  counted from zero. Published frames now carry the host's clock, the reducer
  keeps the last sample with its local receipt time, and both clients anchor
  on how long the host says the turn has run.

* Correct turn duration gate assertion reference

* Respect authoritative unknown native chat duration

* Preserve unverifiable timing across older host upgrade

* Record final completed turn duration reliability evidence

* Fix the CI failures the merge left behind

- A merged import list named the same module twice, which the native code
  quality plugin fails on.
- A running turn is now reported by the host with no duration, so the settled
  map carries an explicit null for it; the hook test still expected the entry
  to be absent.
- main gave the older-page action a cursor with a head-trim guard, so the
  retention test's epoch-only action no longer typechecks; it now passes an
  unbounded sequence, which is what the old shape meant.
- The roster comparator moved into the extracted module, leaving its import
  unused in the reducer.

* Split two files back under the line cap after the merge

Merging main put both one effective line over 300, and the cap forbids a
disable or a shave. The wire module's refusal vocabulary moves to its own file
and is re-exported, so its consumers are untouched; the host's four thin
mutation delegates move next to the functions they call.

* Advertise the turn-item capability on every client transport

Local IPC and mobile advertised it; the remote and web transports did not, so a
desktop paired to a remote host, the CLI, and web silently ran on the legacy
carrier forever and the canonical row was never exercised there. The renderer
that paints it is the same build on every transport.

* Update the web auth-frame expectation for the new capability

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-10 14:32:50 -07:00
Jinjing 73d0521410 Replace the sidebar create dropdown with two direct action buttons (#19653) 2026-09-09 10:42:18 -07:00
Jinwoo Hong 0fe132ea29 fix(orchestration): file mail from terminals in no Run under an unbound Run (#19696)
* fix(orchestration): file mail from terminals in no Run under an unbound Run

#19542 deleted the fallback that filed such mail under the legacy Run, because a
live row there makes the schema-skew probe read the database as pre-Runs and
replay adoption on the next open. That refusal also broke the first command in
the guide: `orca orchestration send --to <handle>` between two plain terminals,
which worked in v1.4.198.

Restore delivery by filing under `run_unbound`, a Run the probe never matches,
created on first use so `run list` shows it only to a user who has such mail.

Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9

* fix(orchestration): create the unbound Run only for a null Run id

Claude-Session: 1fec75fd-224b-46ab-95fe-d88e0f3d9ff9
2026-09-09 03:06:38 -04:00
Brennan BensonandMerge Sim 750e6ffada test(orchestration): pin the Run-required contract for unbound direct mail (#19684)
* test(orchestration): pin the Run-required contract for unbound direct mail

* test(orchestration): pin absent recovery keys and settle the push window for unbound mail

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 23:02:05 -07:00
Brennan BensonandMerge Sim 852495d35c fix(native-chat): unify launch routing and support structured worker placement (#19431)
* fix(orchestration): let worker-start actually produce a structured chat

`orchestration.workerStart` reads the user's "open agent tabs in chat"
default, but two placement checks downgraded a structured-preferring
worker to a PTY terminal agent for the two flags a routine dispatch
always passes:

  --worktree new-child / new-top-level  -> worktree_creation
  --model / --effort                    -> launch_preferences

so in practice a structured worker never happened.

launch_preferences was stale. PR #19040 gave AgentSessionAttachParams
`options` and added resolveStructuredLaunchSeedOptions, which narrows a
saved selection to exactly `model` and `effort` — the two ids both
structured providers accept as strings. --model/--effort now go through
that same narrowing (extracted as narrowStructuredLaunchSeedOptions) and
seed the worker's session instead of forcing a terminal. An option set
that narrows to nothing resolves to undefined, never `{}`, which would
fail the record's bounded-string guard under a code that is not a wire
refusal and strand the launch with no fallback.

worktree_creation was a consequence of createWorkerWorktree creating
agent-first: its startup terminal WAS the worker, so the structured
branch below it was unreachable for any new worktree. A structured
worker now creates the worktree with no startup agent and creates its
session for the worktree afterwards — the order the renderer's own
structured worktree create already uses. Because the executing host can
only answer agentSession.createSupport for a workspace that exists, that
verdict moved after creation: a refusal (WSL, and the rest) becomes a
terminal agent in the worktree just created, never a failed start.

--on and --terminal still downgrade, with their reasons intact, and
every remaining downgrade still states itself in the mode receipt.

The wait-for-setup gate is preserved explicitly. A PTY worker got it for
free — agent-first creation sequences the agent's startup command behind
the setup runner, so tui-idle could not arrive until setup exited. A
structured session has no startup command to sequence, so the gate is
now awaited directly, bounded by the start's own timeout.

Split out worker-worktree-creation.ts and worker-start-agent-placement.ts
rather than growing two files that were both pinned at the max-lines cap.

* refactor(native-chat): make shared feasibility authoritative for launch routing

* Type the structured setup gate's absent blocked reason so the wait union stays property-typed

The type-aware audit rejected the blocked-reason template literal: narrowing the
wait union with an 'in' check left the field typed unknown. Declaring that a
structured setup gate never carries a blocked reason restores the direct read.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-08 21:40:23 -07:00
Jinwoo Hong 12f53da542 Remove settled-worker automatic resume and hibernation fences (#19544)
* Remove settled-worker automatic resume and hibernation fences

* test: retirement rollback case follows the no-fence policy

Case 4 seeded and asserted automaticResumeBlockedBy, which this branch
deletes. A rolled-back settled worker is now an ordinary done record that
wake clears as passive evidence, same as any finished agent pane.

* chore(i18n): regenerate the runtime-required catalog for the contrast floor strings

* test(orchestration): give the stopping-worker guard fixtures a Run
2026-09-08 05:14:59 -04:00
Neilandylcn91 2e19342c12 fix(terminal): remove host-retired ghost panes in paired remote splits (#19365)
Adds the missing removal path to the host-authoritative layout reconciler, so a pane the host has retired is unmounted once its PTY has cleared. Fixes #17770.

The removal planner, its retired-set gate, the null-PTY guard, the never-last-pane guard and their unit tests originate from #18387 by @ylcn91. This PR adds the recovery-state dependency that makes the deferred removal actually re-run, an e2e regression spec, and a hook-parity repin.

Co-authored-by: ylcn91 <7249450+ylcn91@users.noreply.github.com>
2026-09-08 00:37:21 -07:00
Jinjing ab32355701 test(e2e): fix automation and browser reconciliation tests (#19530)
- Update automations API to use runtime.call pattern with automation.create
- Refactor browser creation flow to use state helpers instead of file explorer
- Simplify Playwright selectors and context menu interactions
- Remove fixture file creation from test setup
2026-09-07 22:34:19 -07:00
f0bfc945b4 fix: avoid duplicate repository groups during catalog refresh (#19170)
* fix: keep grouped repositories visible after creation race

* test: strengthen project group creation race verification

---------

Co-authored-by: Kien Le <122910950+kien-ship-it@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-07 22:19:52 -07:00
Neil e182930670 test: cover input in five simultaneously flooding SSH panes (#19071)
* test: cover keyboard input in five simultaneously flooding SSH panes

* test: capture pane focus and buffers on flood input failure

* test: capture pane focus and buffers on flood input failure

* test: capture pane focus and buffers on flood input failure

* test: record replay input loss and application fix dependency

* test: record merged replay-input fix in the five-pane flood gate
2026-09-07 19:51:44 -07:00
Neil d507adc555 Fix combined diff saves before React commits native edits 2026-09-07 19:47:46 -07:00
Jinjing 66420537b7 fix e2e create menu races (#19448) 2026-09-07 19:45:54 -07:00
gatsby74andNeil a278d84a4e fix(pi): show input modals as waiting instead of working (#18836)
* fix(pi): show input modals as waiting instead of working

* test(pi): verify real input dialogs through Electron CDP

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-07 19:35:19 -07:00
aeddfa463d perf(renderer): avoid per-second spinner animation events (#19407)
* perf(renderer): avoid per-second spinner animation events

* fix(bench): ensure the Electron runtime before bench:spinners

The script launches Electron via Playwright but skipped ensure:electron-runtime,
which every other Electron-launching bench script runs first.

* docs(renderer): scope spinner pixel-tolerance claim to paused-animation checks

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
2026-09-07 19:29:53 -07:00
Jinjing c056c6f9ac Unify sidebar create actions into single dropdown menu (#19375)
* Unify sidebar create actions into a single dropdown menu

- Combine "New workspace" and "Add project" under a unified "Create" button
- Remove layout logic that split these actions based on sidebar width
- Normalize "Add Project" to "Add project" (lowercase) throughout the UI

* Use null instead of 'Unassigned' for unassigned shortcut labels

Add formatOptionalPrimaryShortcutLabel that returns null when a
shortcut is unassigned, enabling simpler conditional rendering in
dropdown menus. Remove associated translation strings.
2026-09-07 19:24:34 -07:00
Neil c314cf3f00 fix(diff): isolate keyboard shortcuts and localize search controls 2026-09-07 17:54:15 -07:00
Neil 070fcf381a fix(diff): retain native selections and restore visible selection colors 2026-09-07 17:28:22 -07:00
Neil ad6a51eca5 Merge origin/main and preserve Shift-wheel scrolling in Pierre diffs 2026-09-07 16:41:23 -07:00
Neil cfe243af69 fix(diff): restore read-only and original-side search with cancellable matching 2026-09-07 16:35:35 -07:00
Neil 0652209e78 fix(diff): retain editable history and view state across remounts 2026-09-07 16:09:24 -07:00
Neil 03c27e187e fix: restore native diff context copy feedback 2026-09-07 15:56:33 -07:00
Neil 46bdddb53a fix: preserve diff note gutter eligibility and labels 2026-09-07 15:48:09 -07:00
Neil 838a7c834b fix: preserve newer combined diff drafts during saves 2026-09-07 15:40:09 -07:00
Neil af7deca328 fix: restore navigation to virtualized diff notes 2026-09-07 15:33:00 -07:00