Mobile Chat UI subscribes to an agent transcript by providerSession.id, so
losing that id blanks the chat: use-mobile-native-chat-session clears the
message list and then returns without subscribing when sessionId is null.
Two places dropped the id on a status ping that carried no session metadata,
both while the agent was idle at its prompt — exactly when mobile reads it:
- The renderer store refused to carry the id across `done`. A completed turn
does not end the provider session (the TUI stays alive and resumable), and
OSC 9999 repaints plus reconnect snapshot replays re-deliver a metadata-less
`done` onto an already-done row, so retention has to cover done -> done.
- The main-process OSC ingest overwrote the cached row without the id. The OSC
wire payload has no providerSession field, so an OSC observation is never
evidence the session ended. Dropping it there erased the id from persisted
rows (lost across restart) and from headless `orca serve`, which serves those
rows to mobile directly rather than through the renderer store.
Both keep the turn boundary: a new turn after `done` still starts clean, so a
reused pane cannot inherit a finished session.
Closes#10630
Revealing a live PTY from a paired phone passes the tab id baked into
its env, but the create-terminal handler only recognised ownership via
tab.ptyId and the live ptyIdsByTabId map. With the worktree closed on
the desktop no pane is mounted, so ptyIdsByTabId is empty and tab.ptyId
holds at most one leaf's PTY -- a split pane's second PTY lives only in
the persisted layout. Ownership missed, and createTab ran with an id
that already existed; it mints a fresh uuid on collision, so a second
tab appeared on the same session (#10486).
Resolve ownership through the persisted layout as well. Every binding
consulted is an exact match on the ptyId, so all of them outrank the
reveal's tab id hint: that hint is written once when the PTY spawns and
is never rewritten, so it goes stale as soon as a pane is dragged to
another tab. A mounted pane outranks a recorded one, same-tier
conflicts stay ambiguous so the mobile mount planner keeps failing
closed, and the hint is what resolves a reveal that nothing records
yet -- which is also what keeps paneKey hook attribution intact.
* feat(browser): let Shift invert link routing instead of always forcing the system browser
Shift+Cmd/Ctrl-click has always meant "open in the system browser", which is a
no-op when that is already where links go. Users who keep Link Routing off have
had no gesture to pull a single link into Orca's built-in browser.
Adds "Hold Shift to open in ___", a nested toggle under Link Routing that makes
the modifier open a link the opposite way from the setting. It ships off, so the
one-way escape hatch is unchanged for every existing user.
The title and description name the destination the modifier actually reaches and
flip with the parent setting, since "the opposite" is meaningless on its own.
- openHttpLink gains modifierHeld; resolveModifierRouting owns the decision so
every surface (terminal URLs, OSC 8, xterm web links, markdown preview) routes
identically. forceSystemBrowser stays for callers that must bypass settings.
- The terminal hover hint names Orca when the modifier would open there, and is
re-resolved per hover so toggling applies without recreating panes.
- Link Routing's own copy drops "always uses your system browser", which the new
toggle can falsify; the nested row states the live destination instead.
* fix(browser): route the Checks panel hosted-review link through the shared modifier
The "Open on GitHub" button had its own Shift+Cmd/Ctrl escape hatch that passed
forceSystemBrowser directly, so it kept the old one-way behavior while every
other surface honored the invert setting. Route it through modifierHeld like
the terminal and markdown paths.
Also wraps the modifier row's description in translate(); the title in the same
file was localized but the description returned raw English (caught in review).
* fix(browser): make link-routing modifier copy true in every state
Review follow-ups on the Shift-inverts-routing change.
- The nested row promised "⇧⌘+click opens one in Orca" in the present tense
while its own toggle was off, so the out-of-box state described behavior the
user did not have. Phrase it as enabled-state copy, matching sibling rows.
- The parent Link Routing description gained "opens a link the other way",
which is false in the default state and contradicts the child row when
inverting is on. No fixed sentence there is true in every state, so the child
row — which knows the live destination — now owns the claim. That leaves
getBrowserLinkRoutingShortcutLabel unused, so drop it.
- The rich markdown editor still forced the system browser while the preview of
the same file honored the modifier, so one link routed two ways depending on
which view it was clicked in.
- A remote runtime pins every link to the system browser, so the hover hint
could promise Orca for a click that lands elsewhere. Gate the hint on the same
condition openHttpLink uses.
- Index both modifier titles: the search entry is built with openLinksInApp
false, so the row was unfindable by the title it renders when routing is on.
- Drop the ariaLabel that shared no words with the visible label (WCAG 2.5.3)
and add the four missing settings-search keyword keys to all five catalogs.
* test(editor): pin the rich markdown Shift+click routing hop
The editor half of the modifier fix had no coverage — reverting it to
forceSystemBrowser left the suite green while the same link routed one way in
the markdown preview and the other way in the rich editor. Also pins that a
non-local source owner survives the hop, since that is what keeps an SSH file's
links out of Orca's browser.
* test(editor): cover the Ctrl chord for rich markdown link routing
This file is the only test of handleRichMarkdownEditorClick, and it exercised
metaKey alone, so the isMac branch of modKey had no coverage off macOS. Also
stop claiming the source-owner case proves SSH links stay out of Orca — it
proves the owner survives the hop; http-link-routing.test.ts enforces the rest.
* style: trim review comments to the one-line house rule
Both explained the change adequately in two lines; the extra lines were worked
examples, not information.
* fix(browser): keep Link Routing copy unchanged until inverting is enabled
Removing the "⇧⌘-click always uses your system browser" sentence outright
reworded the row for every user on upgrade, including everyone who never turns
the opt-in on. Restore it verbatim in the default state and only hand the chord
sentence to the nested row once inverting makes "always" untrue.
* revert(editor): keep rich markdown Shift+click on the system browser
Per Brennan: the editor's Shift path hands the link to the client OS and should
not follow the invert setting — the preview opening in Orca is the intended
divergence, not a bug. Restores main's call exactly; the test now pins the
divergence so a future consistency pass cannot erase it silently.
* fix(browser): surface the inverted modifier on the hosted-review link
The hosted-review click path now passes modifierHeld, so with inverting on and
Link Routing off the chord opens in Orca — but the hint stayed gated on
openLinksInApp, hiding a live gesture. Resolve the destination instead of a
boolean. Default-off output is unchanged.
* test(browser): pin the inert modifier hint when links already open in Orca
* refactor(terminal): require the pane link hint so dropping the wiring fails to build
The optional option fell back to a duplicated copy of the legacy hint string, so deleting the hook wiring reverted the tooltip silently with every test green.
* fix(browser): trim the runtime id before hiding the hosted-review modifier hint
openHttpLink and terminalUrlOpenHintOptionsFor both trim, so a blank runtime id hid the hint while the click still reached Orca.
The mobile terminal WebView only handled touch. Wheel events fell through to
xterm, which either scrolls its own hidden viewport or — in the alternate
screen — emits cursor keys via onData, and the mobile onData bridge forwards
those to sendMobileTerminalQueryReply, which drops anything that is not a
query-reply grammar. Net effect: an external mouse or trackpad scrolls nothing
inside the terminal, and nothing reaches the PTY.
Attach a wheel handler on the terminal surface that reuses the touch path's
router: alternate-screen and mouse-aware TUIs get bounded cursor keys / wheel
reports through the existing validated terminal-input gate, and the normal
buffer gets the same coalesced scrollback scroll as a swipe.
Refs #6863, #8818
* fix(activity): bound Activity portal readiness oscillation (React #185)
React error #185 is "Maximum update depth exceeded" -- an unbounded render loop,
not setState-after-unmount as the crash reports superficially suggest. Verified in
react-dom@19.2.7's production bundle: getRootForUpdatedFiber throws
Error(formatProdErrorMessage(185)) when 50 < nestedUpdateCount. That frame is the
top of all 14 crash stacks across shipped 1.4.156 / 1.4.158 / 1.4.159.
nestedUpdateCount and rootWithNestedUpdates are module-level globals keyed on the
ROOT, not on a component. One runaway loop saturates the counter and the throw
lands on whichever fiber calls setState next, so SortableTab (terminal.workbench),
the Radix Presence/MenuPortal (page.settings) and the sidebar.worktrees button are
innocent bystanders with misleading component stacks -- four boundaries, one bug.
The driver is Activity's portal readiness pass. When 'ready' is unreachable while
'unavailable' stays reachable, updateReadiness flips loading<->unavailable from
inside useLayoutEffect -- React's sync lane, the lane that increments
nestedUpdateCount -- so it saturates and throws rather than merely being slow. A
prior investigation captured this live on Windows via CDP at 239 renders in 847ms.
Bound the oscillation: after ACTIVITY_PORTAL_READINESS_MAX_FLIPS consecutive
loading<->unavailable transitions the latch parks on 'unavailable' until a real
'ready' arrives. The latch rewrites only the hook's output, never the DOM probe,
so a terminal that churns during a slow attach and then genuinely comes up still
releases it. Because the counter is global per root, this also protects every
innocent bystander in the app -- most of the user-visible value.
Extract reconcileActivityPortalThreads and resolveActivityPortalSwap so the portal
loop is testable against the real collaborators. Note the reconciliation keeps its
worktree+tab comparison deliberately: Terminal mounts one TerminalPane per
(worktree, tab) and routes it by worktree+tab, so staging a same-tab pane would
leave the staged slot empty and its readiness stuck on 'loading' forever. Same-tab
switches swap in place via isolatedPaneKey instead.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): coalesce portal readiness updates
* test(activity): tighten readiness regression coverage
---------
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): avoid flash while restoring parked terminals (#10871)"
This reverts commit 5a6a9e0b28.
Reverted for terminal rendering regressions (flashing, lost content).
Conflict resolution preserves the forwardRef signature from #10433 and
drops the parked-presentation gating #11016 fed with its effective set.
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): limit pre-paint WebGL resume to macOS (#10794)" and "fix(terminal): stop switch bold flash and Windows lag (#10692)"
This reverts commits 4681edb520 and
8f5a45401f.
#10794 was itself a partial revert of #10692, so both are reverted
together: the Windows retained-WebGL LRU and the macOS pre-paint
(layout-phase) visibility transition that survived it. Terminal
visibility resume returns to passive disposal and recreation on every
platform, and the WebGL context ceiling returns to a flat 128.
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): release an abandoned synchronized-output frame on reveal (STA-2694) (#10907)"
This reverts commit 97cb32c1cc.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): verify clipboard writes so TUI "Copied" never lies
Windows/Electron can return from clipboard.writeText without updating the
OS clipboard, so Claude Code / OpenCode OSC 52 copy and terminal selection
copy looked successful while paste stayed empty (#8977, same root as #5611).
Verify standard clipboard writes by reading back after write, surface OSC 52
host write failures with a toast, and route selection copy through a shared
helper that only clears the selection after a confirmed write.
* fix(terminal): harden clipboard write verification
* fix(terminal): contain clipboard failure notifications
* fix(terminal): isolate verified clipboard writes
* fix(terminal): address greptile clipboard verify nits
Drop the dead onWriteFailure pass-through from the coalesced OSC 52
handler so failure toasts stay owned by the microtask path. Cover
multi-line / CRLF identity in write+verify tests, and export the
verification-failed error constant for stable matching.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(worktree-palette): stop blanked display names from crashing Cmd+J
Blanking the "Display Name" field made buildWorktreeMetaUpdates emit
`displayName: undefined` as a present key. The store's `{ ...worktree,
...updates }` spread then erased the live name, so the next palette
keystroke threw "Cannot read properties of undefined (reading
'toLowerCase')" in searchWorktrees (crash a1f81ea1, build 1.4.159).
Fixed at three layers so no single guard is load-bearing:
- Producer: persist the blanking intent as '' instead of undefined, and
let WorktreeSet accept '' so remote/SSH hosts stop dropping the clear.
- Store: applyWorktreeUpdates and applyDetectedWorktreeUpdates drop
present-but-undefined keys for fields Worktree declares required.
- Readers: resolveWorktreeDisplayName/resolveWorktreeBranchLabel mirror
the main-side mergeWorktree fallback (custom -> branch -> folder) for
all four Cmd+J searches, the checks/review index, and the render site.
Co-authored-by: Orca <help@stably.ai>
* test(worktree): assert omitted display name shape
---------
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): park SSH worktrees like local ones (C1 retention, slice A)
SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH
worktree retained every pane forever (C1: renderer heap climbs to the V8
ceiling). SSH bytes transit local main — fact-mode watchers already cover
them, and main keeps a headless model served over pty:getMainBufferSnapshot
that the SSH reattach path never consulted.
- isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded
through both park verdicts, both selectors, watcher coverage, and the
watcher start guard. Remote-runtime/fail-open/foreign/null unchanged.
- Parked-SSH reveal paints from main's headless model (dimension-matched,
~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a
non-empty source==='headless' payload — never a blank/stale paint.
- Kill switch: settings.terminalSshViewParking (default on).
DESIGN.md records the approved plan and the H1 magnitude non-claim.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B)
Un-parkable worktrees (remote-runtime ptys, uncoverable tabs, SSH with the
slice-A switch off) had unlimited retention: the parking cap/TTL only ever
saw eligibility-passing worktrees, so one bad tab pinned a whole worktree's
panes forever. Retention is now memory-bounded, not eligibility-bounded.
- terminal-hidden-worktree-retention.ts: retention budget (12 hidden / 45min
TTL, sized from the measured 2.5-19MB per-pane V8 cost, DESIGN.md §2) over
hidden worktrees ordinary parking can never evict; reuses the hot-retain
ranking so last-active exemption, deterministic ties, and deadline-driven
rechecks hold. Fail-open/foreign-pty tabs are eviction-exempt (a remount
would fresh-spawn and orphan the live shell).
- Terminal.tsx: force-parked ids join the parked set AFTER the coverage veto
(darkness for uncoverable tabs is the accepted cost); buffers captured via
the sleep-flow registry before the unmount render; retention TTL added to
the recheck deadlines for budget candidates only.
- Verdict stays out of its own effect deps; policy test asserts idempotence
and time-monotone membership (flip-loop dwell regression).
- Kill switch: settings.terminalHiddenWorktreeRetentionBudget (default on).
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): demote hidden scrollback for eviction-exempt worktrees (C1, slice C)
The retention budget (slice B) must exempt worktrees holding fail-open or
foreign-worktree ptys — a remount would fresh-spawn and orphan the live
shell — which would leave that class unbounded again. Instead, past the same
45min retention TTL their hidden panes drop to the minimum scrollback tier
(measured: ~19MB -> ~1.3MB V8 heap per 50k-row pane; trimmed history is
gone by design, reveal restores the configured cap for future output).
- terminal-hidden-scrollback-demotion.ts: module-state verdict registry
(parked-watcher pattern) with content-equality notify damping; applied in
the existing scrollback-rows effect in use-terminal-pane-lifecycle.
- selectScrollbackDemotedTerminalWorktrees: pure, TTL-gated, time-monotone.
- Retention TTL wakeups now also cover exempt worktrees so demotion fires.
- Kill switch: settings.terminalHiddenScrollbackDemotion (default on).
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): paint the SSH model snapshot inline, not via nested coordinator (C1 slice A fix)
applyMainBufferSnapshot runs its own structuralReplayCoordinator.run; calling
it from applyReattachPayload (already inside the coordinator when a relay
replay exists) deadlocks on the coordinator's tail chain. The model paint now
mirrors the daemon-snapshot branch inline (folded scrollback + rehydrate +
screen, dimension-matched, escape tail last) and arms the restored-snapshot
seq baseline so deferred/live chunks the snapshot covers dedupe instead of
double-painting. Also falls through (no early return) so reattachPayloadApplied
still latches. Adds the folder-workspace id parity unit case.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): SSH park+reveal e2e round-trip + as-built design notes (C1)
Docker-gated (ORCA_E2E_SSH_DOCKER=1) spec: SSH tab parks behind a decoy and
reveal restores marker content at multi-viewport scrollback depth. DESIGN.md
records the as-built deltas (inline paint, force-park shape, last-active
floor) and the residuals so follow-ups aren't lost.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): paint SSH reveal from main's model even when the relay replay is empty (C1 review #1)
A relay restart empties the replay buffer; the reveal previously painted
nothing even when main's headless model held the session. The reattach now
prefetches the model snapshot when no structural replay exists (SSH-shaped
ptys only) and paints it inside the coordinator; emptiness is judged on the
composed payload (scrollbackAnsi + data + pendingEscapeTailAnsi) so an
alt-screen snapshot with an empty screen frame still paints.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): decouple scrollback demotion (slice C) from the retention-budget switch (C1 review #2)
Per the approved contract each slice reverts behind its own switch: slice C
now requires only the master terminalHiddenViewParking plus its own
terminalHiddenScrollbackDemotion flag. The TTL wakeup timer fires for
demotion candidates even with the budget switch off. No DEFAULT_SETTINGS
entries exist for sibling flags (defaults are the '!== false' optional
pattern), so no explicit defaults are added.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): scope eviction exemption to the tab, not the worktree (C1 review #3)
One eviction-exempt tab (fail-open/foreign pty) previously vetoed force-park
for its whole worktree, pinning co-located remote-runtime tabs forever. The
worktree now force-parks while exempt tabs keep their mounted panes via a
per-tab exclusion mirroring the Activity-portal pattern (legacy watcher sync,
legacy render, and the overlay cold-parking hook). Ordinary parking is
untouched — a worktree with an exempt tab still cannot ordinary-park.
Slice C now also demotes exempt tabs' panes as soon as their worktree
force-parks under the count budget (they are the only panes left mounted).
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): demote un-parkable worktrees the force-park lever spared (C1 review #4)
The last-active exemption means a single hidden un-parkable worktree never
force-parks — and slice C previously only targeted exempt-tab worktrees, so
its panes held full scrollback forever. Demotion now also covers un-parkable
non-exempt worktrees past the retention TTL that are absent from the
force-parked set (last-active spared, or slice B switched off). Membership
stays time-monotone for fixed inputs; covered by new idempotence/monotone
selector tests.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): keep the hidden clock running through transient background-measure windows (C1 review #5)
Whole-worktree background mounts (browser-automation bootstrap lease, mobile
mounts, agent wakes) open a ~3s self-clearing measure window that previously
deleted hiddenSince — every remount restarted the 30s hysteresis and the
45min retention TTL, so a periodically re-mounted force-parked worktree
never re-parked. The measure window still pauses parking/eviction verdicts
(all selectors skip measuring candidates); only the clock survives, so the
prior verdict resumes as soon as the window closes. Visible and
portal-holding worktrees still reset the clock.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): make the SSH park+reveal depth assertion prove the model paint (C1 review #6a)
Pad the session with ~180KB of output after the numbered markers so the
earliest marker falls outside the relay's 100KiB rolling replay buffer while
staying inside main's ~5k-row headless model; asserting marker_1 after
reveal now proves the headless-model paint rather than passing under the
relay fallback.
Co-authored-by: Orca <help@stably.ai>
* docs(terminal): rewrite DESIGN.md as the single as-built C1 contract (review #7)
One contract matching the code: status IMPLEMENTED around force-park (not
the unmount proposal), real kill-switch names with coupling + revert
matrices, the true retention-floor formula with measured per-pane and
demotion numbers, an explicit when-OOM-is-still-possible paragraph naming
the H2 pendingSideEffects residual, the applyMainBufferSnapshot deadlock
constraint inside the slice-A section, stable-signal phrasing instead of a
capability latch, fail-open AND foreign-worktree exemption class, verified
cites, and a planned/landed/follow-up test matrix.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): resolve the eviction exemption per pane, not per tab (C1 review #8)
isEvictionExemptTerminalTab read only tab.ptyId — the FIRST leaf's pty —
while the coverage veto that makes a worktree a retention candidate walks
every pane. A split tab whose second leaf held an unrestorable pty therefore
failed coverage (→ force-park target) yet looked exempt-free, so force-park
unmounted it and orphaned the live shell. The exemption now resolves panes
through the same resolveParkedTerminalPaneCandidates, keeping tab.ptyId in
the union for the no-layout/no-capture case.
Also from the same review round:
- force-park's capture passes includeLocalBuffers:false like every other
shutdownBufferCaptures caller; it was serializing up to 512KB/pane of
scrollback into the store inside a fix meant to bound renderer heap.
- Terminal.tsx unmount resets the scrollback-demotion registry — module
state with no reset path, read by a pane effect that runs before the host
effect that would clear it, so a stale verdict trimmed restore replays.
- memoize watcher coverage per tab within the parking pass; the retention
candidates re-asked it for every mounted worktree, not just the parked few.
* docs(terminal): drop DESIGN.md — the as-built C1 contract moves to the PR body
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): cap the deferred PTY side-effect queue (C1 residual H2)
pendingSideEffects grew without bound under background timer throttling
(~64 drained/s vs hundreds queued/s overnight). Cap at 512 entries with
oldest-first eviction: titles drop (last-wins), a pending bell latches
onto the next survivor, agent-status payloads collapse onto the survivor
keeping the newest 16 (last-wins store state, KB-scale strings).
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up)
Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so
OSC 133;D and Command Code scrape signals went dark while parked. New
parked-terminal-command-status.ts ports the store-level subset: git-UI
nudge on every command finish, same-turn status-row drop for SSH PTYs
(exact mounted-path parity — the foreground tracker refuses SSH ids),
and the Command Code working seed / 1500ms done settle. Byte mode scans
the same shared parsers for authority-off parity. Local-PTY status drops
stay with the mounted pane: they need pty-connection's process-confirm
ladder to tell a leaked nested-shell 133;D from a real agent exit.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): retention-budget force-park e2e with a retentionLimit override (C1 6b)
ORCA_E2E_TERMINAL_RETENTION_LIMIT flows preload → e2e-config →
getTerminalParkingPolicyOverrides (exposeStore-gated, positive-integer
only) so a spec can shrink the force-park budget to 1. The Docker-gated
spec opens two remote worktrees on one relay target (second pre-seeded
remote repo), disables terminalSshViewParking to make both un-parkable,
hides both behind the local context, and proves the older one force-parks
while the last-active exemption spares the newest; re-activating the
evicted worktree restores the marker tail via relay replay.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): retention-budget e2e via same-repo remote worktrees (passes docker lane)
The first draft added a second remote repo mid-session, whose pane pty
spawn misroutes to the local daemon with the remote cwd (pre-existing
multi-repo issue, reproducible without any retention override — a seeded
local repo plus one remote repo shows the same misroute). The spec now
budgets across three worktrees of the ONE connected repo, created through
the product createWorktree path (an external git-worktree-add only lands
as a detected worktree needing adoption) and polled through the relay's
transient post-connect reconnect window. Verified green on the local
Docker lane in 20.8s.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): prevent remount thrashing during post-measure cool-down (
Implements the C1 retention contract: preserve worktree `hiddenSinceMs` through a
background-measure window (so TTL/ranking stay honest), but re-park waits for a
full `coldParkDelayMs` cool-down after the measure ends. Without the cool-down,
every ~3s measure lease on a past-deadline worktree thrashes remount/reattach.
Core changes:
- Terminal.tsx: add measure clock (measuringTerminalWorktreeIdsRef) and post-measure
cool-down tracking (terminalWorktreeParkCooldownUntilRef); gate parking candidates
until cool-down expires.
- Extract snapshot replay choreography to shared terminal-snapshot-replay-paint.ts
(used by SSH reattach + daemon restore paths).
- Add SSH model snapshot timeout (750ms) with fallback to relay replay.
- Move cold-park recheck deadline logic to terminal-cold-park-recheck-deadlines.ts;
add cool-down deadline to scheduling.
- useTerminalTabColdParking: implement matching measure-clock contract with per-tab
cool-down gate to keep tab deadlines synced with worktree retention clock.
- Add resolveTerminalMountScrollbackRows() to demote new xterms under demoted
worktrees (pane births during demotion must take the demoted tier at create).
- Add kill switches: terminalSshViewParking, terminalHiddenWorktreeRetentionBudget,
terminalHiddenScrollbackDemotion.
* fix(terminal): detect Command Code completion in parked mid-turn panes
Seed the byte watcher with in-flight turn state from agent status: the
watcher is recreated per park cycle with no startup command to arm it,
and the banner scrolled away before parking. Also memoize
eviction-exempt checks and use SSH PTY ID builder in tests.
* fix(terminal): flush pending command-code settles on reveal remount
When a parked pane reveals mid-Command Code turn, the new detector
cannot re-observe the already-passed idle composer. Cancelling the settle
leaves the row stranded at 'working', so dispose now flushes the pending
settle instead.
Extract readInFlightCommandCodeTurn to shared space and seed detectors
with in-flight turns so remounts complete mid-flight commands. Also
memoize SSH model probes to prevent double timeouts on reattach.
* fix(terminal): remove scrollback demotion (C1 slice C)
The scrollback demotion feature for eviction-exempt hidden worktrees is no longer needed. Retention budget limits are now sufficient without this additional bound. Remove the terminal-hidden-scrollback-demotion module, the selectScrollbackDemotedTerminalWorktrees function, and related per-pane demotion logic.
* test(terminal): assert bounded probe during stalled reveal
Add assertion to verify that a stalled reveal operation makes exactly one
`getMainBufferSnapshot` call, ensuring retry logic doesn't introduce
redundant probes that would extend the timeout window before relay fallback.
* fix(terminal): implement C1 retention budget for hidden parked worktrees
Addresses OOM regressions in hidden parked terminals by force-evicting
worktrees past a retention budget: at most 12 mounted while hidden, none
past 45 minutes (absolute, not exempted by last-active). Eviction is
least-recently-hidden-first. Exempt tabs (unrestorable local PTYs) keep
their panes to avoid orphaning shells; worktrees are force-parked even
if they contain exempts, and their buffers released elsewhere. SSH/remote
worktrees serialize buffers pre-eviction for reveal; local worktrees keep
daemon snapshots. Command Code's done-settle window is transferred across
park/reveal boundaries so the row cannot strand at 'working'. Model probe
on SSH reattach is scoped to park-reveal only, not ordinary reconnects.
Includes new E2E suite proving the budget actually releases memory.
* memoize eviction-exempt terminal tabs to avoid redundant store reads
Each tab's exemption check re-reads the store and walks the layout tree.
Introduce selectEvictionExemptTerminalTabIds() to resolve all exempt tabs
for a worktree in a single pass, then memoize the result in Terminal.tsx
and useTerminalTabColdParking. This prevents O(n) store reads when checking
exemptions across multiple tabs and ensures the set remains stable across
unrelated re-renders.
* refactor: reformat hidden-worktree retention comments
Reflow to 80-character lines and remove internal ticket references
(C1, C1 slice C).
* fix(lint): split overlay slot and eviction-exempt tabs under max-lines
Static analysis failed because TerminalPaneOverlayLayer (401) and
terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the
slot component and eviction-exempt helpers into dedicated modules.
* test(terminal): stabilize retention budget e2e control arm
Stage un-parkable remote pty ids only after both worktrees are hidden, and
keep re-staging during the control-arm poll so a late updateTabPtyId cannot
flip the decoy back to park-restorable and ordinary-park it before budget
engages.
* test(terminal): pin retention e2e decoy to a mounted pane snapshot
Use the active pane-identity snapshot for the decoy tab instead of all
worktree tabs, and re-assert un-parkable ids after the control-arm hold so
a deferred/empty tab id cannot fail the budget-off mounted-count check.
* fix: memoize terminal eviction exemptions on layout leaf PTYs
Splits add leaf panes to the layout store without changing the tabs
array. A memo keyed only on tabs misses this change, leaving new panes
unexempted for unmount. Include layout leaf PTYs in the exemption memo
key so it recalculates when splits occur or PTYs are re-minted.
---------
Co-authored-by: Orca <help@stably.ai>
Resolving a session's resume target scanned every worktree in every repo:
two `Object.values(worktreesByRepo).flat().find(...)` calls, which also
allocate a fresh 1124-element array each time, plus an equivalent `some()`
walk. All three run per visible session row, so a panel render repeated
them ~20 times.
`getIndexedWorktreeMap` already exists for this and is WeakMap-cached on
`worktreesByRepo`, so the index is built once per store snapshot rather
than per call. `connection-owner-resolution.ts` already resolves worktrees
this way.
~1.6ms -> ~0.002ms per render pass at 1124 worktrees. Net -4 lines.
Behavior is unchanged: the map dedupes by id, which only diverges from
`find()` when one id appears twice with different objects. Worktree ids are
`repoId::path`, so a duplicate id within a repo array — the documented race
the index was built for — refers to the same worktree.
* perf(vault): dedupe scope paths by key instead of rescanning
#11303 took the session maps off the workspace-switch path, but scope path
derivation still follows the active worktree and stayed quadratic.
addAiVaultWorkspaceScopePath deduped by re-normalizing every already
accepted path on each insert, so accepting K paths cost O(K^2)
normalize('NFC') calls — ~632k at 1124 workspaces. isAiVaultWorkspaceScopePathClaimed
separately rescanned every live worktree per prior id, and runs twice per
switch via activeWorktreePaths and scopePaths.
- carry a Set of comparison keys alongside the paths, so each insert is one
normalize plus one Set lookup
- thread that accumulator from the workspace pass into the project pass
rather than restarting deduplication against a plain array
- build one comparison-path -> worktree id map for the claim check, keeping
first-writer-wins to match the previous some() short-circuit
deriveAiVaultScopeSessionPaths on a real 1124-workspace profile:
189.7ms -> 0.8ms. Output is unchanged, including ordering: verified against
the previous implementation across 117 scenario/option combinations
covering monorepo and mixed-repo layouts, priors both claimed and
unclaimed, duplicate paths, NFD/NFC, WSL UNC, trailing and doubled
separators, relative and blank paths, and four project-key shapes.
Adds the first test file for this module: scope semantics (priors, claimed
priors, cross-repo rejection, dedupe, NFD/NFC) plus a timing guard.
Verified fail-first — the guard reports 220ms on the previous
implementation. Path length is chosen deliberately, since normalize() cost
scales with it and short synthetic paths understate the old shape.
* fix(vault): make the claim check independent of worktree ordering
Review catch on the first pass: keying claims by comparison path meant a
duplicate path had to pick one owner, and picking the active worktree
masked a real claimant later in the list. Concretely, with the active
worktree also listed at its own prior path, the prior was reported
unclaimed where the previous some() reported it claimed.
Excludes the active worktree while building the set instead of comparing
ids at read time, so any surviving entry is a claim by construction and
ordering cannot decide the result.
Adds a test over four orderings, verified fail-first against the previous
commit. Also adds a timing guard for deriveAiVaultWorkspaceScopePaths,
which the session-scope guard did not cover.
Equivalence rerun against the pre-optimization implementation: 156
scenario/option combinations, identical paths and ordering.
#11303 removed the O(sessions x roots) path normalization from the session
worktree map, but nothing fails if that shape comes back. Adds the two
checks that were missing, plus the tool that would have caught it.
- timing guard: 1200 worktrees x 400 sessions must build in <150ms.
Verified fail-first — restoring the pre-#11303 per-session
buildWorktreeCandidates call takes it to 238ms; it is ~15ms as merged.
- path boundary: '/repo/alpha-sibling' must not be attributed to
'/repo/alpha'. Hoisting the root normalization out of the loop must not
degrade containment into a bare startsWith.
Also adds tools/benchmarks/workspace-switch-paint-latency.mjs, which
attaches over CDP and measures first-paint-after-click and max frame gap.
The existing worktree-switch-responsiveness.spec.ts only times the
synchronous click task, which stays ~1ms because the highlight is a direct
DOM mutation — that is why a ~1s stall could ship without tripping a
budget. On the affected build it read maxFrameGap p50=973ms.
Switching worktrees rebuilt two ~500-entry maps in the Agent Session
History panel because their memo deps included the active repo/worktree,
which the maps never read; the worktree map also rebuilt ~530 path
candidates (and re-normalized every root) per session, ~255k
isPathInsideOrEqual calls per switch.
- Drop activeRepo/activeWorktree from the sessionProjectById memo via
buildAiVaultSessionProjectById, and activeWorktreeId from
useAiVaultSessionWorktreeMap; 'current' is now stamped per row at read
time (withAiVaultCurrentWorktreeStatus), so switches reuse both maps.
- Hoist candidate building out of the per-session loop and precompute a
normalized-root matcher per candidate, so map rebuilds on data changes
are O(sessions + roots) normalizations instead of O(sessions x roots).
NFC folding from #10841 is untouched; non-ASCII (NFD/CJK) matching is
covered by new tests. Warm switch with the panel on All/500 drops
425ms -> 125ms on the full-scale rig (cold 491ms -> 193ms); panel-closed
switches are unchanged.
* fix(dashboard): give the agent preview terminal a real pane's keyboard
The dashboard's preview terminal is a bare xterm, not a pane, so it never
ran `resolveTerminalShortcutAction` — its only custom key handler covered
copy/paste and IME. Ctrl+Backspace therefore fell through to xterm's default
`\x08`, which readline binds to backward-delete-char: one character instead
of a word.
Route the preview's keys through the pane's own shortcut policy, so word and
line kills, Option chords, modified Enter, and scrollback chords encode
identically. Pane-scoped verdicts (splits, search, focus) are swallowed rather
than passed to xterm, which would send e.g. Ctrl+Shift+D as a bare Ctrl+D.
The policy needs three things the preview could not see:
- kitty-protocol flags — mirrored locally from the same PTY output stream
- the PTY's execution host — bytes follow the host, not the client OS, so a
new `DashboardCard.terminalInput` profile is derived in the main renderer
(the only one holding the store) and relayed to the pop-out
- host terminal options — the ConPTY backend and the kitty withhold now apply
Also brings the emulator itself up to a pane's: Orca's Unicode 11 width shim
(replayed CJK/emoji/ZWJ laid out wrong without it), Windows Ctrl+Alt chord
repair, user font/cursor/line-height/word-separator/sensitivity settings,
ligatures, the TUI wheel multiplier, lazy Arabic shaping, clickable links, and
the IME candidate anchor — extracted from pane-lifecycle so both surfaces share
one implementation.
The in-window drawer built its snapshot from a slice subset, which would have
degraded the new profile to client-OS defaults there; it now reads the full
store non-reactively.
* fix(dashboard): enumerate pane-scoped chords instead of a default case
The switch-exhaustiveness gate rejects a `default` over the shortcut-action
union — it would let a newly added action be swallowed silently instead of
forcing a decision at the preview's boundary.
* fix(dashboard): preserve native shortcuts and PTY host routing
* chore: keep merge scope limited to dashboard
* perf(dashboard): avoid full-store copies for terminal profiles
* fix(dashboard): validate terminal input profiles at IPC boundary
* fix(dashboard): sync preview terminal refs on commit, not during render
react-compiler rejects ref writes in the render pass; every reader is an event handler or a post-await continuation, so a commit-phase sync is equivalent.
* test(dashboard): cover the three seams that relay the host-input profile
Reverting any of them left every suite green: the dialog's terminalInput prop
(the only reader of DashboardCard.terminalInput), the drawer's hand-threaded
store slices, and the pop-out's republish triggers. Each new assertion was
mutation-checked against its source line.
* fix(dashboard): follow the WSL host for a preview terminal's byte routing
The card resolver handed resolveTerminalInputHostPlatform a transport with no
getLocalSessionMetadata, so a WSL pty on a Windows client resolved to win32
while its own pane resolves linux — Shift+Enter would then encode CSI-u where
the pane sends alt-enter. Mirror the pane transport's own gate.
* fix(dashboard): republish on every slice the host-input profile resolves from
The compare set covered 4 of the ~11 slices that decide a card's execution
host, so a change to the rest (folder workspaces, project groups, the runtime
catalog, detected worktrees) never triggered a publish. On a quiet board there
is no later publish to heal from, and the pop-out — which cannot re-derive the
profile — keeps encoding bytes for the host the pty used to run on.
* fix(dashboard): sync preview terminal refs on layout, not on a passive effect
xterm's keydown is a native listener, so React never flushes a passive effect
before it. A just-relayed host profile could therefore miss the next keystroke.
* test(dashboard): pin the preview's replay-vs-live kitty scan
The existing A/B passed either way: a lone CSI > u sets the same flags through
scan and scanReplay. Redeliver the push across a snapshot and its replay so
stack semantics would leave the TUI's single pop on a stale frame.
* fix(dashboard): rebuild the drawer's snapshot on every host-input slice
The in-window drawer read ~12 host slices through getState() while subscribing
to none of them, on the premise that agent activity drives the next rebuild. A
quiet board has no such rebuild: an SSH handshake completing with every agent
idle leaves the preview terminal encoding bytes for the pre-connect host, and
agentStatusEpoch only ticks on live status changes so it never heals. Watch the
same set useDashboardPopoutBridge republishes on — each writer bails out when
nothing changed, so the added deps are far quieter than agentStatusByPaneKey,
which already rebuilds this memo on every status ping.
The existing coverage mounted a second hook, which always recomputes; the new
test re-renders the same hook after changing only sshConnectionStates.
* fix(dashboard): key the preview by the user's terminal shortcut policy
The preview passed 11 of the 12 inputs the pane's policy takes and let the
12th default to orca-first. Under terminal-first a remapped tab.close chord is
meant to yield to the shell — Ctrl+W is a word-kill there — but the preview
kept claiming it as a pane close and swallowed the bytes.
* test(dashboard): pin the host-input profile to card snapshots only
The count path main added in #11042 renders no cards, so it must not pay a
per-pty host resolution on every agent-status tick. Both assertions run against
a card that does have a live pty, so only the gate keeps the profile off.
* refactor(dashboard): extract the board's client-host read
The merge of main's label bounding pushed build-dashboard-snapshot.ts to
302 lines. The client's own platform facts are a distinct concept from the
pty host each card keys against, so they move out rather than earn a
max-lines bypass.
* Support Windows drives in the remote host filesystem picker
The remote picker was locked to the system drive on Windows hosts: the
breadcrumb root resolved to C:\ and typed drive paths (M:\dev) were
treated as filter text, so projects could only ever be created on C:.
- Server: answer host-root browses ('/') on win32 with the mounted
drives instead of resolving to C:\.
- Client: recognize drive-anchored input (M:\, M:/, m:) as path mode,
resolve segments from the normalized drive root, and make
joinPath/parentPath/breadcrumbs drive-aware. Up from a drive root
returns to the host root (the drive list).
Fixes#7438
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Document why joinDrivePath uses a literal backslash
Review feedback suggested path.win32.join, but the renderer bundle
imports no Node builtins anywhere and runs sandboxed, so path.win32 is
not available here. The backslash targets the remote Windows host
regardless of client OS; say so at the call site.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Complete Windows drive browsing over SSH
* fix remote Windows drive browsing
* fix(ui): key remote breadcrumbs by path
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
mobile/src/terminal/terminal-webview-{tap-routing,init-surface}.test.ts
request the happy-dom vitest environment, but happy-dom was only declared
at the repo root. The mobile suite resolved it by walking up into the root
node_modules, so `cd mobile && pnpm install && pnpm test` fails with
ERR_MODULE_NOT_FOUND and loses those 12 tests unless a root install
happens to be present.