Commit Graph
4537 Commits
Author SHA1 Message Date
조재중 ae3fa002c3 fix(i18n): localize the Link Routing description in browser settings (#9444)
The description was assembled from a bare template literal, so it stayed
English under every language pack. It now lives in the catalog as two entries —
description with a {{shortcut}} placeholder and descriptionBase for the
invert-on variant — following the copy-module pattern from #10991.

Co-authored-by: 조재중 <126754298+m-a-king@users.noreply.github.com>
2026-08-04 02:57:08 -07:00
Evgenii 853c2043b9 fix(i18n): localize the integration card status pills (#12208)
Every card under Settings -> Integrations built its status pill from bare
literals inside the JSX call, so the pill stayed English beside a translated
description. audit-localization-coverage.mjs inspects JSX attributes and object
properties, not conditional expressions handed to a prop, so the gate stayed
green while the strings shipped untranslated.

The three token-configured cards share tokenProviderStatusLabel(); Gitea passes
optional: true because it works read-only without a token, which is why its
unconfigured state reads Optional setup rather than Not configured.

Co-authored-by: Evgenii <kumiro@me.com>
2026-08-04 02:57:07 -07:00
Evgenii 5aab004832 fix(plugins): let language packs translate plugin chrome (#12455)
protectedTranslation refused every language-pack key under
auto.components.settings.plugin*, which caught 104 keys that carry no trust
meaning — section titles, empty states, Refresh, Add path. A 35-path exact
allowlist opens those while consent, provenance, and every *Failed string stay
protected; anything new stays protected until it is added deliberately.

PluginsSettingsSection.experimental is held back from the contributed
allowlist: the "Experimental" chip is a trust badge, which the module's own
boundary comment places out of scope.

Co-authored-by: Evgenii <kumiro@me.com>
2026-08-04 02:57:07 -07:00
Evgenii a1fe9c5d92 fix(i18n): format the stats date with the selected UI language (#12105)
formatTrackingSince() used the OS locale rather than the language chosen in
Settings. getIntlLocale() resolves the active i18n language to a BCP-47 tag,
mapping the synthetic plugin<hex> resource language that Intl rejects back to
the pack's real locale.

Co-authored-by: Evgenii <kumiro@me.com>
2026-08-04 02:57:07 -07:00
5Hyeons b316b93d84 fix(sidebar): keep the toolbar labels in the selected UI language (#11889)
SidebarToolbar is a React.memo boundary whose props are shallow-equal on a
language switch, so without its own useTranslation() subscription it kept the
English copy it rendered at boot — the persisted locale is applied
asynchronously, after the lazy catalog loads.

Co-authored-by: 5Hyeons <ohs2251@naver.com>
2026-08-04 02:57:07 -07:00
Neil e1071f59e9 Say why an update install failed instead of stalling for three minutes (#12224)
Surfaces the real install-failure cause instead of letting a failed elevation stall silently, and keeps the reconnect wait inside its total budget by recomputing the remaining time after each awaited RPC.

Relates to #11906 — this fixes the observability half. The functional half (a .deb/.rpm host cannot elevate and can never self-update) is unchanged, so the issue stays open.
2026-08-04 02:02:53 -07:00
NeilandOrca 96e31e7bf8 Remove a paired computer's deleted projects from every connected device (#12215)
* fix(repos): remove a paired computer's deleted projects from every connected device

A project deleted on a paired Orca host stayed in every connected client's
sidebar and could not be removed there.

Two independent defects:

1. Host-local repo IPC mutations only sent `repos:changed` to the host's own
   renderer (src/main/ipc/repos.ts:2711). The runtime client-event stream was
   fed only by mutations arriving over runtime RPC, and clients refetch a remote
   catalog only on a `reposChanged` event -- there is no polling on desktop -- so
   the deleted rows persisted indefinitely. The shared `notifyReposChanged`
   helper now also calls the new
   `OrcaRuntimeService.notifyReposChangedForRemoteClients()`
   (src/main/runtime/orca-runtime.ts:5175), mirroring the existing
   `notifyWorktreesChangedForRemoteClients` precedent. This covers every repo,
   project-group and folder-workspace IPC mutation, so renames, colors, reorders
   and adds propagate too.

2. Deleting the ghost row on the client routed `repo.rm` to the owner, which
   answered `repo_not_found`. `removeProject` wrapped its whole body in one
   try/catch, so the rejection aborted the local purge before the `set()`
   (src/renderer/src/store/slices/repos.ts:3466) and the delete button appeared
   to do nothing. Only `repo_not_found` is now tolerated; any other failure still
   keeps the row, and an opt-in `errorFeedback: 'toast'` makes it visible at the
   three single-project user-initiated entry points. Bulk and background callers
   keep today's silence plus their own aggregate reporting.

Closes #11994

Co-authored-by: Orca <help@stably.ai>

* fix(repos): revert inert RepositoryPane removeProject arg

The settings pane's only render site drops the argument; the toast is
already delivered by removeSettingsProjectFromAllHosts.

Co-authored-by: Orca <help@stably.ai>

* fix(repos): scope duplicate-repo-id deletes to the owning execution host

Cover the cross-host collisions #11994's broadcast now fans out to every paired
device. Same-name projects on different hosts were already isolated (per-host
UUIDs, host-scoped catalog merge and purge) and are pinned by regression tests.

Two same-repo-id paths were not: `repo.rm` with a `path:`/`name:` selector and
`deleteProjectHostSetup` both resolved one row and then deleted by bare id,
taking the sibling host's registration with it.

Co-authored-by: Orca <help@stably.ai>

* test(mobile): align the poll-interval rationale with the new reposChanged emission

Co-authored-by: Orca <help@stably.ai>

* fix(repos): resolve deleteProjectHostSetup's repo row only on the setup's own host

The sibling-host fallback could only ever pick a row on a host the caller
did not name; with no exact match the setup is stale and the existing path
already drops just the setup.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:26:48 -07:00
NeilandOrca 5f9a5b80b3 Show "Except default branch" filter only when "Hide sleeping" is on (#12460)
* Show the "Except default branch" filter only when "Hide sleeping" is on

The exemption only takes effect during the hide-sleeping sweep, so its row
now renders (and counts toward the filter badge) only while its parent
toggle is ticked.

Co-authored-by: Orca <help@stably.ai>

* Restore the sub-option indent on filter toggle rows

Tailwind v4 emits px-2 as padding-inline, which outranks the physical pl-6
override, so the indented row rendered flush with its siblings. Split the
base padding so the indent actually applies.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:22:34 -07:00
NeilandOrca b86880a9c1 Keep finished and interrupted agent sessions resumable after sleeping a workspace (#12214)
* fix(agent-sleep): keep finished and interrupted agent sessions resumable after sleeping a workspace

Manual workspace sleep ran a liveness filter over the panes it was about to kill
(isValidManualSleepLiveAgentEntry), then wiped every pre-existing sleeping record
in the worktree. A done, interrupted, typed-into, or >30-min-idle pane therefore
lost its only resume handle and woke as a bare shell. A second filter at wake
discarded any record carrying `interrupted: true`, which also killed interrupted
sessions across an app restart.

Capture now records every resumable pane, normalizing only `updatedAt` and
`interrupted` and preserving the entry's real `state` so a finished pane keeps
its passive record and resumes in place when its tab is opened instead of
spawning a duplicate tab. The wake-side interrupted check is gone. A
legacy-orchestration-worker block is carried onto the replacement record, and
the Pi-compatible promoted checkpoint is no longer overwritten by a re-derived
live record.

Closes #11598

* fix(agent-sleep): keep durable slept records a repeat sleep cannot re-derive

removeSleepingRecordsReplacedByManualWorktreeSleep wiped every record in the
worktree, and only the freshly captured set was merged back. A slept `done`
pane stays passive until its tab is opened, so a second sleep found no live
status row to rebuild its record from and deleted the pane's only
`--resume` handle with nothing written back — the original loss of #11598,
one wake/sleep cycle later.

The wipe now skips a record with no replacement in the new capture set when
it is a durable capture (`origin` `worktree-sleep` or `quit`). Provisional
`live`/legacy checkpoints are still cleared, so an unresumable Pi row does
not survive a sleep it cannot back.

Co-authored-by: Orca <help@stably.ai>

* fix(agent-sleep): keep a slept workspace's finished panes out of the mobile wake fan-out

A manual sleep now records every finished pane, and those passive records fed
wakeSleepingAgentsForWorktreeInBackground step (b), which background-mounts one
tab per passive record. A phone opening a slept 12-tab workspace would have
cold-restored 12 agents at once, undoing the process shedding the sleep was for.

Manual-sleep captures of finished panes carry restoreOnTabOpenOnly; step (b)
skips them and the pane resumes in place when its own tab is opened, which is
what desktop activation already did and what the phone's per-tab mount provides.

Co-authored-by: Orca <help@stably.ai>

* fix(agent-sleep): close the retained-row and shared-claim gaps in slept-session capture

Three narrow holes left by the manual-sleep capture rewrite, all in the same
"a slept session must stay resumable" contract:

- The retained pass captured `retained.entry` verbatim, so a retained row -
  stale by construction, since it exists only after the pane's pty died -
  produced a `working` record that wake then discarded on the >30min staleness
  rule. It now takes the same `updatedAt`/`interrupted` normalization and the
  same `automaticResumeBlockedBy` carry-over as the live pass.
- The retained pass also ran after the `origin: 'live'` promotion loop without
  its guard, so a pane holding both a promoted checkpoint and a retained row
  had the checkpoint (connectionId, transcript identity, active `working`
  class) overwritten by a re-derived passive record.
- The mobile wake filtered `restoreOnTabOpenOnly` records *after*
  canonicalization, so a lazy record sharing a provider-session claim with an
  eligible hibernated record could win the claim, delete the hibernated record
  as a duplicate, and then be skipped - stranding the session with nothing
  mounted.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-04 01:01:19 -07:00
f6878d660f Show Claude's AskUserQuestion card in desktop Chat when the agent runs on a paired headless server (#12223)
* fix(native-chat): show Claude's AskUserQuestion card when the agent runs on a paired headless host

Three gaps kept the question card off the desktop when the agent ran on a
remote `orca serve` host:

- The `session.tabs` projection reduced HTTP agent-hook rows to identity only,
  hard-coding `state: 'done'` and an empty prompt, so `toolName` and the full
  `interactivePrompt` never left the host. It now publishes the newest fresh
  hook row's status fields, bounded by the same staleness window `agentType`
  uses, excluding `providerSessionOnly` resume rows, and yielding to live
  title evidence unless a question is actually pending.
- Nothing republished `session.tabs` when only a hook row changed, and the
  re-emit carried an unchanged `snapshotVersion` that clients drop on their
  monotonic gate. Material hook transitions and pane/SSH status clears now
  bump the version and schedule a coalesced emit.
- The desktop card resolved only from live status. It now falls back to the
  pending ask in the transcript, matching mobile, so a relay gap can no longer
  leave the composer mounted over a pane parked on a selector.

Closes #11761

Co-authored-by: Orca <help@stably.ai>

* fix(native-chat): date the hook-row recency guard against a real clock

`resolveHookLiveAgentRow` compared a hook `receivedAt` (epoch ms) against
title stamps that are title-observation sequence numbers, so the guard could
never fire — any fresh hook row overrode live title-derived state, and a manual
rename (the one epoch writer) inverted it. Stamp the live OSC title path with
wall-clock ms and compare against that alone.

The regression test fabricated epoch-valued title stamps production never
writes; it now drives the title through `onPtyData`, and a new case pins the
opposite direction (hook row newer than the title wins).

Co-authored-by: Orca <help@stably.ai>

* fix(native-chat): stop an orphaned tool call from pinning a dead question card

extractPendingAsk pairs tool results to calls by a global FIFO (tool_use_id
is dropped at decode time), so one call that never gets a result desyncs the
queue for the rest of the transcript and strands an answered ask as pending.
Real transcripts also hold asks the user escaped and typed past. On desktop
that card replaces the composer, so the pane became unsendable.

Drop in-flight calls at a turn boundary — a user turn or the decoders'
interrupt row — since the turn that owned them is over. Claude's tool-result
turns decode as role 'tool', so normal FIFO resolution is untouched.

Co-authored-by: Orca <help@stably.ai>

* refactor(native-chat): trim the headless AskUserQuestion projection

Reuse rather than restate: the invalidator now takes the shared
`AgentHookEventPayload` instead of a locally redeclared row shape, and the
hook live row is a `Pick<>` of the retained OSC snapshot so one projection
branch consumes either carrier. Fold the immediate/coalesced session-tabs
emit into one method (also drops a redundant re-emit on the
provider-session push). Drop card tests that re-route shared-parser
assertions through React. Isolate pane-status-clear subscribers and prove
the no-republish case by version arithmetic instead of a timed silence.

Co-authored-by: Orca <help@stably.ai>

* test(native-chat): pin the AskUserQuestion card render under real Electron

Why: the 13 parser unit tests pin extraction, but nothing proved a card
actually renders where an inert tool call used to. This spec reproduces the
paired-headless topology from the client side — live status carrying agent
identity and state 'working' but no interactivePrompt/toolName, with the
pending ask present only in the transcript — and fails on main.

Refs #11761

Co-authored-by: Orca <help@stably.ai>

* test(native-chat): drop the unused testInfo parameter

Why: oxlint no-unused-vars fails the lint gate on an unused test parameter.

Co-authored-by: Orca <help@stably.ai>

* test(native-chat): drop leftover proof scaffolding from the ask-card spec

The env-var screenshot label and the fixed 2s settle only existed to make
the pre-fix capture comparable; the card assertion already waits.

Co-authored-by: Orca <help@stably.ai>

* test(runtime): use a truly unresolvable pane key in the hook republish guard

#11203 taught pane lookup to recover a reminted tab id by leaf id, so the old
fixture (new tab id, live leaf id) resolved and bumped the snapshot a second
time once this branch merged with main.

Co-authored-by: Orca <help@stably.ai>

* fix(runtime): refuse a hydrated unconfirmed hook row as live pane status

#12346 landed on main after this branch was cut: a nonterminal row restored from
last-status.json is stamped `restoredUnconfirmed` because its transition may have
fired while no receiver was up, and every freshness gate treats it as never-fresh.
The new headless `live` projection here only checked `receivedAt`, so a restart
inside the 30-minute window would republish the hydrated row — resurrecting the
AskUserQuestion card with no agent left to answer it.

`agentType` still reads those rows: they prove identity, just not liveness.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Neil <nwparker@users.noreply.github.com>
2026-08-04 00:48:58 -07:00
Brennan Benson c498d2d405 perf(renderer): index detectedWorktreesByRepo so SSH panes and cards stop walking the full detected catalog on every store write (#12421)
* perf(renderer): index detected worktrees for owner lookups

* test(renderer): make detected index perf gate deterministic

* test(renderer): cover detected index owner hot path
2026-08-04 00:26:40 -07:00
Brennan Benson 9f638da62d perf(terminal): gate mobile overlay ticks by pane pty affinity (#12419)
The fit-override and driver emitters are global listener sets: every event
reaches every mounted TerminalPane. Both tick callbacks re-rendered the tab
before checking whether the event's pty belonged to it — the override branch
only used its affinity filter to guard the rAF refit, and the driver branch had
no filter at all.

Remote handle rotation (replaceFitOverridePtyId / replaceDriverPtyId on
resubscribe after a network blip) republishes state for every reconnecting
pane, so the cost was quadratic in mounted pane count and remote-exclusive.

Move the pty-affinity check ahead of both ticks, matching the guard shape
already used by use-native-chat-can-send and pty-connection. The subscription
pair is extracted into useMobileOverlayTicks so the fan-out is directly
measurable under a real React render.
2026-08-04 00:20:10 -07:00
OrcaWinandOrcaWin e599d924bd fix(editor): stop Close All / Close Others freezing the renderer on large tab sets (#12404)
* fix(editor): stop Cmd/Ctrl+Shift+T reopening a phantom editor tab

`openFile` honored a recently-closed snapshot's `reopenId` whenever no
open file currently held that id, without checking whether the same
(path, mode, owner) was already open under a different id. Editor ids
are not symmetrical across worktrees: the first worktree to open an
absolute path gets the bare path as its id, and any other owner of the
same path gets a namespaced `editor:<wt>:<runtime>:<path>` id. So a
snapshot captured under one shape could be restored while the live tab
carried the other.

When that happened `openFile` took the `existing` branch and mapped over
`openFiles` looking for the stale id, matching nothing — no OpenFile was
created or updated — yet it still set `activeFileId`, the per-worktree
active id, the tab bar order and a unified tab entity to that stale id.
The user got a tab rendering nothing and a dangling active editor.

Resolve the id from the already-open file whenever one exists, so capture
and restore agree on canonicalization. This is a no-op for the non-reopen
path, where `resolveEditorFileIdForOwner` already returns `existing.id`
via the identical predicate.

* fix(editor): make Close All linear instead of cubic in tab count

closeAllFiles resolved a reopen position for every closing tab by calling
getRecentlyClosedTabPosition, which itself rescanned the worktree's tab-bar
order and group membership with a nested find per entry. That made the whole
bulk close O(N^3) in open editor tabs, and because it runs inside a synchronous
zustand set() the renderer is blocked for the entire duration: measured against
the real store, 500 tabs took 1.2s and 1000 tabs took 13.0s of frozen UI.
Sessions that accumulate tabs — long SSH/relay work, a mirrored host republishing
open files, a folder workspace opened wide — hit this on a single menu click.

Hoist the per-tab rescans into one precomputed index. The new
createRecentlyClosedTabPositionIndex builds tab-bar, unified-tab, and group
lookups in a single linear pass over the worktree's state, then answers each
position query in O(1); getRecentlyClosedTabPosition now delegates to it so
single-tab callers keep identical behavior. Positions, reopen order, and the
tab-bar/group indices recorded by #12236 are unchanged — first-occurrence wins
everywhere, matching the indexOf/find scans this replaces.

Position derivation moves to its own module so recently-closed-tabs.ts stays
under the max-lines limit without a suppression; existing importers are
unaffected via re-export.

Same measurement after the change: 500 tabs 44ms, 1000 tabs 142ms, and the
instrumented scan count drops from 503,000,500 to 4,000 at N=1000.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:27:28 -07:00
OrcaWinandOrcaWin 141b1f43f6 fix(runtime): keep the listener on loopback for a "This computer only" pairing link (#12405)
* fix(runtime): keep the listener on loopback for a "This computer only" pairing link

The runtime pairing URL handler called ensureNetworkExposure() for every
offer, including one whose advertised address is loopback. Settings ->
"Share this Orca server" offers a "This computer only" radio that pairs
against 127.0.0.1 precisely so nothing is reachable off-host, yet choosing
it rebound the WebSocket listener from 127.0.0.1 to 0.0.0.0 — and the widen
never narrows back, so the runtime stayed exposed to the whole LAN for the
rest of the process after the user picked the option that exists to avoid
exactly that.

Gate the widen on the advertised address: only a non-loopback endpoint (LAN,
Tailscale, custom host) needs a listener reachable off this machine, so those
paths keep widening exactly as STA-2370 intended. A loopback link is already
served by the loopback listener, so it now mints without touching the bind.
Classification reuses the shared pairing-address classifier, which also covers
localhost, ::1 and 127.0.0.0/8 typed into the custom-address field.

Tests: a real OrcaRuntimeRpcServer driven through the IPC handler asserts the
bind host stays 127.0.0.1 after a local link and flips to 0.0.0.0 after a
LAN one, plus handler-level cases for 127.0.0.1 / localhost / ::1.

* fix(runtime): gate the pairing widen on the user's declared reach, not the address shape

Review of #12405 found two ways the loopback fix misbehaved.

1. The guarantee died at the next launch. resolveInitialWebSocketBindHost()
   binds 0.0.0.0 whenever any device has lastSeenAt > 0, and MobileSocketWiring
   stamps that for EVERY authenticated socket — including the local browser
   opening a "This computer only" link. So the runtime was still published on
   every interface, one restart later. Grants now carry the reach they were
   minted for (DeviceEntry.pairingReach, persisted); a this-computer grant no
   longer counts as proof that an off-host client may reconnect. Registries
   written before the field default to network reach, so an already-paired
   phone still finds a wide listener after upgrading. A pending grant that is
   re-advertised for the network widens (never narrows) so its link survives.

2. The widen was gated on the shape of the typed address, which the renderer
   never sent the intent for. A Custom `127.0.0.1:8443` — the documented SSH
   tunnel / reverse proxy field — skipped the widen and produced a dead link,
   while `localhost:8443`, `[::1]:6768` and `ws://127.0.0.1:6768` widened, so
   the same loopback intent was handled three different ways. The renderer now
   sends the declared reach ('this-computer' | 'network') and main gates on it;
   the address is only used as a mismatch guard (a this-computer reach carrying
   an off-host address still widens rather than minting an unreachable link),
   resolved through resolveAdvertisedPairingHostname so every accepted address
   form classifies identically.

Also corrected the ensureNetworkExposure invariant comment: the widen is no
longer confined to the first pairing action, so it can now tear down live
loopback sockets — they reconnect on the reused pinned port.

Tests: reach-form matrix + tunnel/undeclared/mismatch cases in mobile.test.ts,
real-server relaunch bind for both reaches, legacy registry compatibility, the
pending-grant reach upgrade, a live-client port-stability guard, hostname
resolver coverage, and the renderer reach plumbing. Reverting only the source
fails 18 of them.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:44 -07:00
OrcaWinandOrcaWin dbb7804421 fix(updater): re-prove the retained Linux package before a privileged retry (#12395)
* fix(updater): re-prove the retained Linux package before a privileged retry

The recovery card's "Try Automatic Install Again" handed electron-updater the
cached .deb/.rpm path with no re-verification. That path is user-writable, so
the digest proven when the card rendered says nothing about the bytes dpkg or
rpm would read as root minutes later — and the card's other actions (copy
command, reveal) validated while the one that actually installs did not.

Re-hash the artifact immediately before the install, ahead of any destructive
quit prep, and abort with copy that tells the user to download again. This
narrows the window rather than closing it; only an immutable handoff would
close it, which is a larger change.

Also fix a macOS-only failure this suite gained with the platform-conditional
pre-commit copy: the expectation hardcoded the non-Darwin string, so the suite
was red on any Mac.

* fix(updater): re-prove the retained Linux package on every install path

Review findings on the original fix:

1. The abort force-sent its error status with no staleness guard, so a
   verdict from a hash that outlived its cycle overwrote whatever card had
   replaced it (a fresh 'available' from Check for Updates became a stale
   "package no longer matches" error). Now keyed on an install-cycle
   signature, the same protection failLinuxPackageRecovery already had.
2. 'read-failed' (EMFILE/EIO/EACCES mid-stream) was described as a digest
   mismatch and tore down the recovery card. It now reuses the accurate
   per-reason copy and keeps the card, exactly as the Copy/Show paths do
   for the same reason. It still fails closed: chmod 000 on a swapped file
   would otherwise be a one-line bypass, since root can read what we cannot.
3. The check was keyed on the recovery status, so it only covered the retry.
   The primary 'downloaded -> Restart to Update' install, whose window is
   hours rather than seconds, handed the same user-writable path to dpkg/rpm
   unverified. Moved into performQuitAndInstall keyed on the tracked
   artifact, so both paths are covered; non-Linux keeps its exact timing
   through a synchronous artifact guard.
4. The async prologue had moved the "quit timer is always cleared"
   invariant out of a try/finally. The re-proof now owns a flag cleared in
   finally, and a rejection fails closed instead of wedging the updater.
5. The install re-proof no longer joins an in-flight validation, so its
   proof cannot predate the click that asked for it.
6. The retry button gained the pending affordance the other actions have,
   since the click now streams the whole package before anything happens.

Tests: real packages are staged in a real updater cache for the whole Linux
block (a path that never existed would now abort every install); new cases
cover the swapped primary install, the stale-verdict drop, the preserved
card on read-failed, the rejecting re-proof, the concurrent second click,
and the fresh-hash guarantee. Each was verified to fail with only its
source change reverted.

* fix(updater): tell the renderer when a stale verdict abandons the install

The cycle guard that stops a stale digest verdict from clobbering a newer
card also withheld the only signal the renderer has that the restart was
called off. The preload abort relay keys on an 'error' status, so with the
status suppressed the window stays restart-prepared for the rest of the
session: Terminal/Settings skip their unsaved-work prompts and the shutdown
checkpoint stays deduped, so a later real quit stages no fresh snapshot.

Push the abandon from performQuitAndInstall's single return-false site, so
it cannot depend on what the reporter decides about the status text, and
relay it to the existing relay.abort() (a no-op unless the renderer armed
a restart). The status stays cycle-guarded exactly as before.

Tests: the stale-verdict and swapped-primary-install cases now assert the
push, a committed install asserts its absence, and the preload relay test
covers the new channel. Each fails with only its source change reverted.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:26 -07:00
OrcaWinandOrcaWin 20a2901677 fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep (#12394)
* fix(worktree): tell the truth about live PTYs, and offer force for a wedged sweep

Two gaps in the #11960 force path:

The delete toast described every unstopped-PTY failure as "could not confirm
every terminal has exited", including the case where verification positively
watched them running. Force Delete proceeds either way, so the user was being
asked to waive a doubt that did not exist while a running agent's uncommitted
work died with it. The live verdict now gets copy that says so.

A sweep that rejects before any per-PTY verdict exists (wedged daemon, dropped
SSH channel) fails with a teardown-timeout message that the force classifier did
not recognise, so no Force Delete button appeared — the exact dead end #11960
set out to remove. That error now carries the shared prefix and classifies.

* fix(worktree): close the sweep-rejection wedge and stop racing the delete

Review of #12394 found the fix covered only half the wedge it named, and
routed users into a force path whose own safety comment was untrue.

1. Only the outer deadline was classifiable. When a provider *rejects* the
   sweep — dropped SSH channel, erroring daemon — settleBeforeDeadline
   rejects with the provider's original error, which carries no marker, so
   classifyWorktreeForceDeleteReason still returned null and no Force Delete
   button rendered. That is the exact case #11960 named. A rejected sweep on
   the destructive path is now reworded through the existing unstopped-PTY
   prefix (provider text preserved, original kept as `cause`), so old and new
   clients alike classify it as 'unstopped-pty'.

2. Force could delete files while a sweep was still running. The deadline
   rejects without cancelling run(), so allSettled resolved with shutdown()
   still in flight — by construction the deadline error can only fire while
   something is in flight. Force then deleted the directory a live PTY still
   held open (EBUSY / half-delete on Windows and WSL). Sweeps are now tracked
   so the forced path waits for the abandoned work, bounded by a 2s grace;
   force never wedges, and when the grace expires the warning says handles may
   outlive the delete instead of implying the sweep finished.

3. The toast test named for the classifier passed the reason in as a literal,
   so it never exercised it. It now derives the reason exactly as the store
   does, and fails against main.

4. Added the missing unstoppedPtyLive key to the English catalog.

5. isProvenLivePtyRemovalError anchored the 'still live:' marker to the detail
   separator, so a worktree path can no longer spell out a live verdict and
   flip the toast to the destructive copy.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:21 -07:00
OrcaWinandOrcaWin bdafe840f7 fix(browser): keep the chrome inset when a page viewport shell is rebuilt (#12401)
The chrome inset that pushes a browser guest below the toolbar was applied
only from BrowserPane's layout effect, whose deps cover the first mount
(page id, active, paintable, slot readiness). Every path that rebuilds the
viewport shell without re-running that effect therefore produced a shell
with a zero-height inset: guest recovery replacing a lost guest
(replacePersistentWebview -> removeBrowserPageViewport -> render-time
ensureBrowserPageViewport), a session-profile switch that recreates the
guest for a new partition, and an overlay slot-root remount (STA-3228).
The recovered page then painted underneath the browser chrome until an
unrelated resize or tab switch forced a recompute.

Make the geometry a property of the viewport instead of the mount: remember
the last synced inset height per page and apply it whenever a shell is
built. This also fixes the ordering case where the chrome is measured
before the shell exists (the height used to be dropped on the floor).

Test: browser-page-viewport.test.ts covers the rebuild-after-replacement
and measured-before-shell cases; both fail without the change.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:08 -07:00
OrcaWinandOrcaWin 0f052a5010 fix(editor): stop Cmd/Ctrl+Shift+T reopening a phantom editor tab (#12399)
`openFile` honored a recently-closed snapshot's `reopenId` whenever no
open file currently held that id, without checking whether the same
(path, mode, owner) was already open under a different id. Editor ids
are not symmetrical across worktrees: the first worktree to open an
absolute path gets the bare path as its id, and any other owner of the
same path gets a namespaced `editor:<wt>:<runtime>:<path>` id. So a
snapshot captured under one shape could be restored while the live tab
carried the other.

When that happened `openFile` took the `existing` branch and mapped over
`openFiles` looking for the stale id, matching nothing — no OpenFile was
created or updated — yet it still set `activeFileId`, the per-worktree
active id, the tab bar order and a unified tab entity to that stale id.
The user got a tab rendering nothing and a dangling active editor.

Resolve the id from the already-open file whenever one exists, so capture
and restore agree on canonicalization. This is a no-op for the non-reopen
path, where `resolveEditorFileIdForOwner` already returns `existing.id`
via the identical predicate.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 23:25:02 -07:00
Brennan Benson ed4d6979b1 fix(app): await durable checkpoints before restart actions (#12433)
* fix(app): await durable checkpoints before restart actions

* fix(app): clear restart latch after refused reload

* fix(persistence): invalidate hash after stale rename
2026-08-03 23:11:36 -07:00
NeilandOrca dbbeca0916 Let a timed-out remote terminal pane reconnect when the host session is still alive (#12213)
* fix(terminal): let a timed-out remote terminal pane reconnect again

A remote-runtime PTY pane that missed the 60s auto-recovery window latched to
"disconnected" permanently, even while the host kept the session alive and
streaming. The cutoff destroyed every path back at once: it dropped the pending
retry and evicted the pane from the shared retry registry (so window `online`
and system-resume became no-ops), and the transport tore down the accepted
host-snapshot listener. The listener was also inert for the common case, since a
host that keeps publishing the same live handle never rotates it.

The cutoff now only stops the retry timer; the pane keeps its pending retry and
stays revivable. The accepted-snapshot listener survives the latch, and a
post-cutoff snapshot is accepted as reattach evidence whether the host rotated
the handle or republished the same one. The require-replacement published-wait
guard is scoped to its own recovery epoch so Reconnect and online/resume are no
longer swallowed.

No new polling: a latched pane still issues zero self-initiated RPCs, and the
same-handle reattach consumes a snapshot the client already receives.

Closes #12097

* fix(terminal): drop the settled attach retry when the recovery cutoff lands

The recovery cutoff now keeps a pending retry so online/resume can revive a
latched pane, but the host-session attach wait schedules a single-shot closure
that the cutoff itself resolves. Retaining it left the pane registered as
revivable work: 'online' would bump the epoch, arm a fresh 60s deadline and flip
the phase to 'recovering' while invoking a no-op, hiding the working Reconnect
button and the same-handle snapshot reattach for a full minute.

The attach wait now discards its own scheduled retry as it settles, so a pane
latched on that path stays 'disconnected' and Reconnect keeps working. Retries
scheduled by the resubscribe paths re-enter real work and are unaffected.

Co-authored-by: Orca <help@stably.ai>

* fix(test): type the reattach host snapshot factory so status narrows to 'ready'

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): keep an exhausted remote pane reattachable through Reconnect and resume

Reconnect and the online/system-resume trigger both opened a fresh recovery
epoch, which switched off the accepted-snapshot reattach path (gated on the
'disconnected' phase) for 60s while the require-replacement inventory wait
dead-ended without scheduling anything.

Gate the same-handle reattach on a spent auto-recovery window instead of the
live phase, consumed once per window, and park an unarmed retry at the
require-replacement dead end so online/resume/Reconnect have work to revive.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): pin latched-pane retention to one listener and one registry entry

The recovery cutoff now keeps the retry-registry entry and the accepted-snapshot
listener alive, so cover the two module-global collections that could accumulate:
destroy/detach cycles, concurrent latched panes, revive storms and snapshot churn
all return to baseline.

Co-authored-by: Orca <help@stably.ai>

* refactor(terminal): use the returned reattach epoch and pin the no-RPC claim

Addresses both CodeRabbit nitpicks: subscribeToHandle now takes the epoch begin()
returned rather than re-reading currentEpoch, and the latched attach-wait test
asserts the runtime call count is unchanged instead of only the advanced count.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 23:05:22 -07:00
외계공룡andchucoding dc0cb1806a feat(repo-icon): expand the project emoji picker (#7989) (#12058)
Replace the hardcoded 12-emoji grid in the repo icon settings with the full searchable, category-navigable emoji picker, reusing the existing emoji-picker-react dependency in a lazily-loaded chunk.

Every pick is re-validated through sanitizeRepoIcon, so an over-cap ZWJ/skin-tone sequence surfaces a toast instead of silently no-op'ing. Skin tones stay selectable.

Maintainer follow-up: use lazyWithRetry so a failed chunk cannot permanently blank the Settings page, disable autoFocusSearch on this inline picker, scope font-family to descendants (the library sets sans-serif on every child), cover the uncovered dark-mode picker variables, match the app scrollbar, and assert the result in the DOM per tests/e2e/AGENTS.md.

New strings are translated in es/ja/ko/zh.

Co-authored-by: chucoding <chucoding@users.noreply.github.com>
2026-08-03 23:00:02 -07:00
e59a319ffe fix(sidebar): keep each project's entry-point workspace visible under "Hide sleeping" (#12257)
"Hide sleeping" swept each project's main workspace out of the sidebar as soon as
it had no live PTY, browser tab or agent — even with "Hide default branch" off.
For a project whose only row is that workspace (a folder workspace, a fresh
clone, a detached-HEAD main), the entire project vanished with no in-place way
back.

Adds a shared `isSleepingSweepExemptWorkspace` predicate keyed on
`isMainWorktree` rather than the branch name, so folder workspaces (no branch),
detached-HEAD mains, and SSH rows whose head/branch are blanked while a provider
is disconnected all stay put. Wired into `computeVisibleWorktreeIds` (sidebar,
Cmd+1-9, workspace board), the jump palette's duplicate inline pass, and mobile's
`filterWorktrees`.

Ships default-on with an escape hatch: a persisted
`alwaysShowDefaultBranchWorkspace` setting surfaced as "Except default branch"
under "Hide sleeping". Explicit "Hide default branch" still wins, since it
filters before the sleeping sweep.

Mobile reads the setting but never writes it back, so a desktop opt-out can't be
clobbered by a filter tap before the ui.get roundtrip lands.

Combines the two PRs open against #8873. #8966's exempt set is a strict subset of
this one, so its production diff was subsumed rather than ported; its jump-palette
render harness and e2e spec were carried over, and are the only such coverage here.

Fixes #8873
Closes #8966

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:55:17 -07:00
NeilandOrca 0927b9c156 fix(gitlab): load pipeline job traces in the Checks side panel (#7732) (#12266)
* test(repro): demonstrate #7732 GitLab pipeline job details never load in Checks panel

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): load pipeline job traces in the Checks side panel (#7732)

Expanding a GitLab pipeline job in the Checks panel always showed
"No inline details are available for this check.": the mapper dropped the
numeric job id, `PRCheckDetail` had nowhere to carry it, and every consumer
called the GitHub check-runs API, which returns null for a GitLab job.

- carry `gitlabJobId` on `PRCheckDetail` and add the `gitlab-job:` branch to
  all three identity ladders (panel rows, editor tabs, fix-prompt keys) so
  same-stage jobs with no web_url stop colliding
- add a runtime-routed trace client so SSH/remote workspaces work, not just
  local IPC, and thread the MR's `projectRef` for fork pipelines
- bound the trace in main via the existing `sliceCheckLogTail` (now shared,
  not GitHub-only) so a multi-megabyte CI log never crosses the 1 MB
  transport frame cap; strip ANSI/section markers up to the CR only, which
  keeps each section's visible header and command echo
- render the excerpt inline instead of "Log tail available in full details."
- feed GitLab traces to "Fix with AI", which previously sent bare check names
- skip the fetch for jobs that cannot have a trace (created/manual/skipped)
  so GitLab's 404 does not replace the benign empty state, and re-arm a
  failed load when the job's state changes since the panel has no retry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): treat a missing job log as an empty log, not an error (#7732)

Round-1 review follow-up.

- a job canceled before it started (or whose log was erased/expired) is
  `completed`/`cancelled`, so the panel fetched its trace, GitLab answered 404,
  and `classifyGlabError`'s issue-edit copy ("Issue not found — it may have been
  deleted.") landed verbatim on the auto-expanded check row; main now maps that
  404 to an empty trace so the row keeps its benign empty state
- keep a missing project a real error (GitLab masks unauthorized projects as
  404) and add `classifyJobLogError` so 403/unknown failures stop borrowing
  issue-edit wording on a job-log read
- broaden the empty-log copy in all five catalogs: it now covers erased and
  expired logs, not only jobs that never ran
- e2e: derive the repro screenshot dir from `process.cwd()` (or an env
  override) instead of a hardcoded POSIX path to a throwaway worktree
- bound the raw trace before the ANSI/section passes so a multi-megabyte log
  is not scanned in full on the main-process event loop
- drop the redundant `if (repo)` in `handleFixChecksWithAI` and the now-dead
  "Log tail available in full details." catalog entry

Co-authored-by: Orca <help@stably.ai>

* fix(gitlab): address review — project ref on reload, retry re-arm, IPC timeout

- Carry the MR's GitLab project ref on the check-details tab so reloading a
  fork/cross-project job tab fetches the trace from the pipeline's own project.
- Re-arm the sidebar retry when a details load resolves to null, not only when
  it throws; a detail-less row otherwise never retried after the job moved on.
- Bound the local `gl.jobTrace` IPC call with the same 30s timeout the runtime
  RPC path uses — glab runs without a subprocess timeout in main.
- Document that the trace 404 -> empty-log mapping is deliberately broad.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:48:37 -07:00
NeilandOrca ba83a71e30 fix(terminal): apply the running-process close confirmation to every tab close path (#10142) (#12272)
* test(repro): demonstrate #10142 tab X close bypasses running-process confirmation

Unit repro: closeTerminalTab (the X-button/middle-click entry) never consults inspectRuntimeTerminalProcess and drops a tab with a live child.
E2E repro: Cmd+W shows 'Stop running command?' for a tab running sleep 300; cancelling then clicking the tab X closes it silently.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): confirm running-process close on every tab close path (#10142)

The tab-strip X button, middle-click and the tab context menu closed a
terminal with a live child process without asking, while Cmd+W raised
"Stop running command?" for the same tab. The probe lived only in
TerminalPane's pane-level close handler; every mouse entry point reaches
closeTerminalTab(), which guarded pinned tabs and nothing else.

Move the decision into closeTerminalTab, above the web-runtime branch so
paired/remote host-backed tabs are covered too, and give the last-pane
keyboard close back to it instead of probing twice:

- running-terminal-close-guard.ts probes every live PTY of the tab and
  fails open on a rejected probe or a stale remote handle, matching what
  Cmd+W already did. No live PTY ids => fully synchronous close, so idle,
  parked and hibernated tabs keep today's behavior.
- shouldConfirmRunningTerminalClose keeps lifecycle echoes, bulk closes,
  CLI/RPC closes and the post-confirmation re-entry off the modal path.
- A standalone confirm store drives RunningTerminalCloseDialog, which
  reuses the existing CloseTerminalDialog (no new user-visible strings).
  The request carries the tab label because a tab-strip close can target
  a tab the user is not looking at, and dedupes by tab id.
- TerminalPane.handleRequestClosePane now delegates the last pane to
  closeTerminalTab. Its transport ptyId is nullable by design, so the old
  path silently skipped the prompt mid-reattach; the pane keeps its own
  probe only for closing one pane of a split.
- Agent panes win the dialog copy when a split has both an agent and a
  plain command busy, instead of depending on PTY spawn order.
- Tab-group closeItem ran leaveWorktreeIfEmpty synchronously after a close
  that can now defer; it moves to onClosed and still honors skipEmptyCheck.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): close the running-process confirmation gaps on every path (#10142)

Follow-up hardening on the tab-close confirmation, from review of the first
pass:

- A pinned tab with `confirmClosePinnedTab` off never got the running-process
  prompt on any path, including Cmd+W, which is a regression against the old
  pane-level behavior: the pinned branch short-circuited on pinned-ness alone
  and re-entered with `force`, which the running guard excludes. The pin prompt
  now supersedes only when it will actually appear; with the setting off the
  close falls through to the running guard.
- The probe chain had no `.catch`, so a throw in the decision (a copy-kind
  lookup on a tab id makePaneKey rejects, a store subscriber) left the tab
  silently unclosed with no user feedback. It now fails open, as the pane path
  it replaced did.
- A wedged remote inspect RPC could leave the X button looking dead for its
  full 15s timeout. The probe is now bounded; every close path shares the
  bound, so keyboard and mouse still behave identically.
- The agent-vs-command copy had two resolvers on exactly the keyboard/mouse
  seam this issue is about. terminal-close-copy-kind.ts is now the single
  policy; TerminalPane and the tab-strip guard both call it.
- The running queue is async while the pinned queue is synchronous, so both
  could be pending at once and stack two modal overlays. The running dialog now
  waits for a visible pinned confirmation.
- Deduping a repeat close request dropped the second caller's callbacks; it now
  folds them in, so both closes resolve from one prompt. Ticking "don't ask
  again" also drains queued prompts instead of showing one the user just opted
  out of, and a queued prompt no longer inherits the previous tab's tick.

closeTerminalTab drops its private pinned predicate for the shared
isUnifiedTabPinned, whose only consumer the previous commit had removed.

* test(e2e): wait for `sleep` to own the terminal before closing it (#10142)

The running-process close specs polled `hasChildProcesses` to decide the tab
was busy, but macOS starts the shell under `login`, so an initialising terminal
already reports a child before `sleep 300` runs. Both specs could therefore
press close against a shell that never started the command: the probe correctly
saw an idle terminal and closed without asking, and the adjudicated repro failed
against a correct fix.

Wait for `foregroundProcess === 'sleep'` instead. Assertions are unchanged, and
the repro still fails at the pre-fix baseline (a92d8e0b0d).

* fix(terminal): ask instead of closing when the close probe times out (#10142)

Round-1 review follow-ups.

- The 4s probe bound closed the tab outright, but `inspectRuntimeTerminalProcess`
  gives remote runtimes a 15s RPC timeout: any probe taking 4-15s silently killed
  a running remote command that Cmd+W used to prompt about, and the pane path now
  delegates its last-pane close to this guard. An unanswered probe is unknown, not
  idle, so the timeout raises the confirmation with every pty treated as a
  candidate. Failing open still applies to an *answered* probe (rejection, stale
  remote handle), which is the pre-existing pane behavior.
- The split-pane Cmd+W probe had no bound at all; it now shares the same one, so
  the two paths give the same answer to the same question.
- The renamed regression spec dropped its repro scaffolding: the hardcoded
  /tmp screenshot directory (also a cross-platform path violation) and the
  title/docblock that still described the bug as open.

* fix(terminal): adopt the double-activation guard and layout pty lookup from #10167 (#10142)

Cross-referenced against @innocarpe's #10167, which solved the same issue.
Two things it got right that this branch did not:

- Queue actions now hold off for 350ms after a queued request replaces the
  visible one, matching the sibling pinned-tab confirmation. Without it the
  second click of a double-click aimed at one tab lands on the next tab's
  prompt and kills a running process the user never saw asked about — the
  exact bug class this PR exists to close.
- The pty lookup unions the layout bindings with ptyIdsByTabId. A mounting
  pane is bound into the layout before the liveness map catches up, and the
  store's own teardown collector unions both for that reason, so reading only
  the map let a close slip through that window with no prompt.

Also replaces the render-time ref write that failed React Doctor's
"Ref mutated during render" rule: the queued-request checkbox reset now goes
through CloseTerminalDialog's existing subject-change reset instead of
remounting via key, which keeps the exit animation on one element.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 22:48:22 -07:00
Brennan Benson 086a5d6495 fix(browser): rerun guest retention after veto release (#12430) 2026-08-03 22:41:14 -07:00
Jihwan KimandOrcaWin 5bd2f59d29 fix(runtime): open files from sibling workspaces (#11369)
* feat(runtime): match files to workspace owners

* fix(runtime): resolve terminal paths through sibling workspaces

* fix(editor): route restored sibling workspace files

* fix remote sibling file ownership routing

* fix(editor): migrate restored sibling file owners

* fix(editor): revalidate restored owner activation

* docs(review): record PR 11369 correction evidence

* fix(editor): reject collision before activation prep

* docs(review): record PR 11369 final correction

* fix(editor): retain projected reconciliation narrowing

* chore(review): keep verification artifacts out of PR

* fix(editor): harden restored owner migration

* fix(runtime): resolve workspace root terminal paths

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 21:41:58 -07:00
오영택 3d6d6dd67d fix(orchestration): scope agent lineage to its owning run (#11203)
Use durable Task-to-Run ownership and current pane, process-incarnation, and Run-generation authority for sidebar agent lineage. Add schema migrations, bounded lookup indexes, fail-closed renderer cleanup, and runtime/RPC regression coverage.

Co-authored-by: Jaeyoung22 <89302528+Jaeyoung22@users.noreply.github.com>
2026-08-03 21:21:03 -07:00
NeilandOrca a6b14eb04c fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112) (#12202)
* fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112)

#12101: a force-killed TUI never emits its DECRST reset, so its armed mouse
mode is latched into the on-disk checkpoint and re-derived into the
replacement process's emulator via the cold-restore history seed -- through
both rehydrateSequences and SerializeAddon's own mode trailer. The revived
bare shell then echoed SGR motion reports at the prompt. Seed a
RESET_MOUSE_REPORTING segment after the snapshot (before the torn escape
tail), only when there is real recovered content so the empty-array
"nothing to recover" sentinel survives.

#12112: agent panes arm a main-side PtyStartupIngress that answered opencode's
startup OSC 10/11 queries synchronously inside node-pty's onData, while the
POSIX tty still had ECHO on. The line discipline echoed Orca's own reply back
out as visible text. Echo suppression existed but was gated on windows-conpty.
Add PtyStartupReplyDelivery: POSIX defers the write off the query's turn and
recognizes its own echo anywhere in a span (bounded, non-destructive); ConPTY
keeps its synchronous write; windows-wsl is byte-identical to before.

Fixes #12101

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): read the slave's ECHO bit before answering a color query

The startup color reply was written into a PTY still in cooked mode, so the
line discipline echoed it back as visible junk (#12112). Whether that will
happen is readable state on the slave rather than something to infer from
returning bytes, so the reply now waits until the ECHO bit is observably
clear instead of guessing at echo shapes.

Two echo sources exist and only one is readable. A `quiet` verdict proves
the kernel will not echo, so it retires the caret projection; readline
echoes a master write in software with the tty already raw, so that
projection stays armed on every path. Scoping `quiet` narrowly is the whole
correctness argument here: reading it as "no suppression needed"
reintroduces the bug at a plain shell prompt.

Polling is bounded by a wall-clock budget rather than an attempt count,
because each probe is a subprocess and a multi-pane restore serializes them
on fork. Withholding measures flat at ~210ms from 1 to 100 panes.

Also resets a cold-restored pane's mouse reporting (#12101). The armed mode
is re-derived from the dead process's own persisted bytes through two
channels, so the daemon seeds a reset into recovered history and the
renderer stops trusting a persisted "live agent" signal after a cold
restore. The reset literals move to one shared profile module.

Fixes #12101
Fixes #12112

Co-authored-by: Orca <help@stably.ai>

* test(terminal): pin the cold-restore reset on the spawn-adopted reattach path

A spawn can be answered with an adopted session, which reaches the reattach
handler by a door that skips the restored-session path. Pin that the cold-restore
signal survives it, so #12101's junk cannot come back through it.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): note why the adopted-reattach snapshot leaves the cursor visible

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): harden startup reply delivery

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 21:00:57 -07:00
Brennan Benson 49dc113a0f Fix terminal corruption after restored snapshot replay (#12363)
* fix(terminal): preserve restored snapshot fidelity

* test(terminal): align legacy history handoff snapshot expectation

* fix(terminal): keep legacy snapshot panes mounted

* fix(terminal): refresh snapshot capability after startup

* fix(terminal): refresh snapshot capability in degraded startup

* fix(terminal): await snapshot provider authority
2026-08-03 20:00:28 -07:00
Jinjing a69ca6d5b7 fix(ui): always show Onboarding and Restart Orca in Help (#12379)
* fix(ui): always show Onboarding in the Help menu

Users could not find "Show onboarding again" because it required holding Alt. Surface it on the default Help menu so replay is discoverable without reinstalling.

* fix(ui): always show Restart Orca in the Help menu

Surface Restart next to Check for Updates by default and drop the Alt-only admin gate, which no longer hid anything else.
2026-08-03 19:23:22 -07:00
Brennan Benson 64b81cde50 perf(runtime): memoize mobile snapshots for mounted terminal worktrees (#12383)
The per-worktree rebuild memo from #12207 refused to skip any worktree
with a registered TerminalPane, because builders read live PaneManager/DOM
state the memo could not witness. Hidden worktrees stay mounted (8 parked
+ 12 retained by default, unbounded with terminalHiddenViewParking off),
so that set rebuilt on every publication — on the always-on hosts the memo
targeted, ~20 worktrees paid the full content build plus the new inputs
build and deep compare every tick.

Capture the live reads instead: snapshot pane leaf ids, the live active
leaf, the serialized pane tree, and per-pane numeric/pty bindings once per
publication into MountedTerminalSurfaceCapture, compare captures by value
in the memo, and have the builders read only the capture. Live state
outside the capture is now unrepresentable in a builder, so the skip stays
provably complete — and a mounted worktree whose panes did not change
reuses its snapshot like any other.
2026-08-03 19:22:49 -07:00
Brennan Benson 194e1a8d4d fix(persistence): make the renderer unload checkpoint durably flush before reporting success (#12387)
The sync before-unload checkpoint staged renderer state and then queued
store.flushPendingAsync() fire-and-forget, so reload/restart/update paths
navigated while the staged session, scrollback and UI state were still
only in memory. Quit is covered by the will-quit flush barrier; those
paths were not.

Keep staging synchronous (no sync durable writes), but record the flush
outcome and expose it on app:await-before-unload-checkpoint. Restart,
updater install and lazy-chunk recovery reload now join that write before
navigating and abort the attempt when it fails or outlives a 20s deadline.
2026-08-03 19:18:40 -07:00
Brennan Benson e39cdb897d perf(agent-status): coalesce live status bursts into one render pass (STA-3328) (#12377)
* perf(agent-status): coalesce live status bursts into one render pass (STA-3328)

Each live agent-status event arrives as its own IPC task, so a multi-agent
burst paid one full React render pass per event (200-488ms main-thread tasks
under heavy orchestration). Same-task store commits batch to a single render
pass, so buffer a burst for one 33ms window and apply it in one task. The
leading event still applies synchronously (zero added latency for single
events), only an applied event arms the window (dropped/pending events stay
immediate), and both clear paths purge queued sets so a coalesced event
cannot resurrect a removed status.

* fix(agent-status): preserve queued completion on teardown
2026-08-03 19:14:28 -07:00
Brennan Benson 73e7df7764 fix(browser): track guest recency across kill-switch off, release download veto on interrupt (#12232)
Two robustness follow-ups from the #12194 safety-review loop (pass 3),
staged by the reviewer before its session ended; both fail toward extra
retention only:
- recency bookkeeping now runs while the retention kill switch is off, so
  re-enabling evicts in true LRU order instead of worktree-list order
- interrupted downloads release the eviction veto instead of pinning the
  worktree as downloading forever
2026-08-03 18:52:48 -07:00
OrcaWinandBrennan Benson ce8b778d31 perf(runtime): withhold unchanged mobile snapshots from the graph payload (#12245)
* perf(runtime): withhold unchanged mobile snapshots from the graph payload

Every graph sync structured-cloned all 222 worktree snapshots to main even when
none had changed: 374 KB and ~5 ms per clone, paid twice because Electron clones
on serialize and again on deserialize. That transport cost — not the renderer
rebuild — is the bulk of a publication.

The renderer now sends only the snapshots main has not acknowledged and names
the rest in unchangedMobileSessionWorktrees. Detection is object identity, not a
deep compare: an unchanged worktree already returns its cached snapshot object.
Main seeds nextWorktrees from that list so its prune keeps withheld worktrees
live instead of removing them.

The call itself is unconditional. syncWindowGraph is not a one-way publish — its
return value is the only channel carrying agentOrchestrationByPaneKey to the
renderer, and the handler adopts pre-allocated handles, merges detached leaves,
refreshes writable flags, and drains graph-sync callbacks on every sync. Skipping
it would starve all of that.

Two failure modes are closed explicitly. The memo advances only after main
acknowledges, so a publication that throws is resent in full rather than
silently withheld forever. And a worktree main dropped on its own — worktree
metadata removal — comes back in mobileSessionResyncWorktrees, which also clears
the accepted-revision record so the republish is not rejected as a no-op.

Unchanged republish at 222 worktrees / 787 tabs: 374 KB to 3.4 KB, 5.08 ms to
0.02 ms per clone. One changed worktree: 5.3 KB.

* fix(runtime): resync stale withheld mobile snapshots

* fix(runtime): align accepted mobile snapshot membership

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 18:11:30 -07:00
Jinjing 637c7e94c9 Add SSH config host picker to add-host dialog (#12334)
* feat(ssh): add SSH config host picker for add-host form

Users can now click 'Fill from ~/.ssh/config…' to browse available SSH
config hosts in a picker, select one, and have the form automatically
prefill with resolved connection details (hostname, port, username, auth).

Previously, an 'import' button provided bulk sync on this form—confusing
and unhelpful when everything was already synced. That action is now
available as a secondary 'Add all' option in the picker.

* fix(ssh): import filter preservation and label fallback

- Reuse search loader on import completion to preserve active filter inside generation guard
- Fall back to hostname when manual host has no label, not empty string
- Make alias duplicate detection case-insensitive to match config picker behavior
- Validate host availability when restoring project group selection
- Add aria-selected attribute to picker options for accessibility

* fix(ssh): harden config picker import, alias folding, and host targeting

Review findings on the ~/.ssh/config picker + bulk add:

- Guard config-host resolution with a generation counter so a late resolve
  cannot overwrite a later pick or a form the user backed out of; freeze the
  other rows while a pick resolves.
- Stop "Add all N" from re-adopting deleted hosts — it now imports without
  reAdopt, matching the new-host count it advertises. Settings → Import keeps
  the explicit re-adopt path.
- Fold SSH aliases through a shared normalizeSshConfigAlias for import
  ownership, delete tombstones, reclaim, picker search, and the save-time
  duplicate check, which now occupies configHost *and* label like the picker.
- Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not
  when `ssh -G` merely echoes the /etc/ssh system default.
- Fail closed with unavailable/setup-not-found when an explicit
  projectHostSetupId names a non-actionable host instead of silently creating
  the workspace on a sibling host.
- Cache the parsed config for the picker session (refresh on open/retry) so
  filter keystrokes no longer reparse and Include-expand the file, keep the
  filter usable during loads, add a Retry on load errors, explain an empty
  Identity file after a config fill, and drop the always-false aria-selected.

* refactor(ssh): centralize host result limit and extract folder group val

Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message
cannot drift from the host's query limit. Extract findActionableFolderProjectGroup
to avoid repeating the folder-host-availability check across the composer hook.

* fix(ssh): pass -F to ssh -G when HOME differs from passwd home

In E2E tests and sandboxes, isolated HOME can differ from the system
passwd home. OpenSSH resolves the default config via getpwuid (passwd),
while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F
to explicitly specify the config path when they diverge, so ssh -G and
the picker resolve the same file.

* fix(ssh): verify config host exists before resolving with ssh -G

When a user edits ~/.ssh/config and removes a host, the import picker
should not fall back to ssh -G's echoed response (which treats any alias
as valid). Check the reloaded config file before resolving.

- Force reload config on each resolve to catch user edits post-open
- Reject aliases not in the current config before calling ssh -G
- Add test for deleted alias edge case
- Fix workspace-target fallback to honor explicit host selection

* fix(ssh): let tombstoned aliases be re-picked in the config picker

Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path.
2026-08-03 17:32:13 -07:00
Brennan Benson 9ec4907cfb fix(agent-status): restore hydrated nonterminal statuses as unconfirmed (#12346)
* fix(agent-status): restore hydrated nonterminal statuses as unconfirmed

A hook transition that fires while Electron is down has no receiver and is
discarded, so last-status.json can restore a stale 'working' as confirmed
truth for up to the 7-day hydrate TTL. Stamp hydrated nonterminal rows with
restoredUnconfirmed, carry it through both IPC paths, and treat such rows as
never-fresh in the shared and renderer freshness gates so the sidebar,
worktree.ps, and the raw snapshot all present the same degraded semantics.
Terminal states restore as-is; any accepted live event clears the flag; the
flag itself is never persisted. Interrupt/question inference refuses to
fabricate transitions onto unconfirmed rows.

* fix(agent-status): shed unconfirmed marker when the liveness sweep verifies done

The restored-subagent reaper's reconciled entry spread carried
restoredUnconfirmed onto a process-probe-verified 'done', making freshness
gates suppress a legitimate completion. Keep the marker only while the
reconciled state stays nonterminal.

* fix(agent-status): let live evidence replace hydrated rows

* fix(agent-status): keep restored rows degraded

Sort accepted live evidence after hydrated rows even across wall-clock rollback. Let unconfirmed rows own their preserved pane titles without asserting live state, while retaining independently live sibling evidence.

* fix(agent-status): suppress unmapped restored titles

Treat a single runtime title as covered by the single restored hook row while layout identity is unavailable. Preserve ordinary age-stale fallback and mapped sibling-pane evidence.
2026-08-03 17:22:43 -07:00
Brennan Benson 0ea3c9c31f fix(renderer): drive agent working spinner on the compositor (STA-3328) (#12359)
* fix(renderer): drive agent working spinner on the compositor (STA-3328)

The shared 12Hz JS clock wrote el.style.transform on every mounted spinner
(41 live = ~490 main-thread style writes/s), keeping style perpetually dirty
and delaying keystroke dispatch (measured typing inputDelay p90 363ms; 19ms
with the writes stopped). Replace it with a steps(12) CSS transform animation
so rotation runs on the compositor; one mount-time animation-delay write
preserves the shared-epoch phase sync.

* fix(renderer): lock agent spinner phases to document time

* fix(renderer): tolerate missing spinner animation API
2026-08-03 17:20:27 -07:00
Brennan BensonandOrcaWin f4b2b782b5 feat(orchestration): coordinator-driven release of settled worker terminals (STA-905) (#12355)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 17:17:26 -07:00
Brennan Benson 98e8d1c41d fix(terminal): reconcile status after escape interrupt (#12345)
* fix(terminal): reconcile status after escape interrupt

* fix(terminal): preserve absent interrupt baseline

* fix(terminal): ignore stale input acknowledgments

* fix(terminal): order interrupt acknowledgments
2026-08-03 17:05:04 -07:00
JinjingandOrca d7fe9d6bcc fix(ai-vault): support session scanning in SSH worktrees (#11004)
* fix(ai-vault): support session scanning in SSH worktrees

Add relay-native aiVault.listSessions scanning that discovers agent
sessions on SSH hosts. Includes fallback to filesystem crawl for
legacy relays, full cancellation support, result validation, and
scan coalescing to reduce redundant work.

* fix(ai-vault): scan sessions in SSH worktrees with coordinated cancellat

- Extract batching logic to `mapRemoteScanBatches` for reuse and proper cancellation checkpoints
- Move `AiVaultScanCoordinator` from relay to main to handle concurrent same-key requests with individual cancellation signals
- Report scope path truncation consistently across relay and SSH fallback paths
- Gracefully degrade relay handler on unsupported platforms instead of aborting startup
- Refactor issue display to separate blocking errors, scope notices, and skipped transcript counts

* fix(ai-vault): stabilize SSH session scan CI

Swallow async WSL relay stdin EPIPE so the live hook-relay shard no longer
fails after all tests pass. Merge main, resolve scan/relay conflicts, and
align cancellation/host-issue reporting with IPC expectations.

* fix(ai-vault): harden session scan cancellation, relay timeouts, and preemption

Thread the abort signal through every scan and parse path so superseded or
cancelled scans stop promptly instead of parsing every remaining transcript
for a caller that already left.  Replace the fragile message-text relay
timeout check with a typed error code so unrelated errors carrying the
phrase "timed out after" no longer suppress the filesystem fallback.  Fix
scan coordinator preemption so a forced Refresh in one window no longer
re-enters as a spurious cancellation in another.  Add a host-leg cache for
the all-hosts view and cap filesystem concurrency so a single slow remote
home cannot stall the whole merge.

Co-authored-by: Orca <help@stably.ai>

* fix(ai-vault): use stable React keys for scan issue banners

Drop array-index keys so react-doctor/no-array-index-as-key passes.
Uniqueness comes from host, kind, agent, path, and message.

* fix(ai-vault): SSH session scanning with configurable depth limits

Implement depth-aware caching and proper scan boundaries to make SSH session
scanning reliable in worktrees. Users can now select between faster (250
sessions) and comprehensive (unlimited) history scans. The scanner:
- Deduplicates scans across relay, host leg, runtime, and renderer layers
- Reuses larger scans to serve smaller depth requests
- Properly bounds in-scope discovery per-limit
- Fixes timeout enforcement when SSH providers ignore abort signals

* Move sessionLimit ref update to useLayoutEffect

Keep render pure for React Doctor by deferring ref updates to
a layout effect, which still executes before render-dependent
effects that consume the ref.

* fix(adhoc): stamp version prefix from main, not the feature branch

Adhoc builds check out arbitrary refs whose package.json often lags
version bumps (e.g. 1.4.165-rc.0 while main is 1.4.168-rc.1). Hourly
always builds main so it already tracks the product line; adhoc now
resolves the base version from origin/main (or ORCA_ADHOC_BASE_VERSION)
so branch builds share that prefix.

* Revert "fix(adhoc): stamp version prefix from main, not the feature branch"

This reverts commit a26a18eb3fd83f7e7d2db9a6a7c3e02e0f79089a.

* fix(ai-vault): fix scoped backfill and coordinator race conditions

Resolve race where the last waiter leaving could abort an already-settled scan (add `settled` flag). Redesign scoped session backfill to keep searching through newer files until the scope reaches its requested session quota instead of stopping at the candidate limit; out-of-scope files no longer consume the scope budget. Centralize scan limit normalization and fix error classification for cancelled scans using the proper helper instead of checking Error.name. Disambiguate cache keys using JSON and add cancellation check after scope discovery phase.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-03 16:17:00 -07:00
Brennan Benson a7282fed40 Fix Codex launch draft delivery after delayed setup (#12347)
* fix: preserve Codex launch drafts through setup

* test: enforce single startup draft delivery

* test: name startup draft release behavior
2026-08-03 16:06:29 -07:00
Jinjing 5941c6c9db Improve send comments to ai (#12150)
* Post fixing replies after launching PR comment resolution agents

Orca now automatically replies to selected comments after launching an
agent to resolve them. Review-thread comments get nested replies; review
summaries and issue comments get top-level @-replies. Payloads are
snapshotted at queue time and posted only after prompt delivery
succeeds. Bounded concurrency (4) prevents SSH slowdown.

* Batch conversation replies instead of posting per-comment

Combine N unresolvable comments into a single PR conversation reply to avoid timeline spam. Review threads still get one nested reply each. Refactor helper functions to pr-comment-fixing-reply-body.ts and change replyAsConversation(comment, body) → replyAsConversation(body).

* Handle bot account mentions in PR conversation replies

Use buildPRCommentConversationReplyBody to properly handle GitHub App
bot accounts, which don't resolve as @-mentions. Enforce delivery semantics
with type-safe PendingPRCommentAiAck payloads and improve error handling
around agent launch to prevent stranded state.

* Fix React Doctor ref-mutated-during-render errors in ChecksPanel

Move comment-resolution payload ref clears and latest-callback mirrors
into effects so render stays pure for the static analysis gate.
2026-08-03 15:58:20 -07:00
Jinjing ea68d97c28 Optimize worktree parent picker: conditional mount, virtualization, lazy status loading (#12225)
* Optimize worktree parent picker: conditional mount, virtualization, lazy

- Mount WorktreeParentPickerPopover only when open to avoid hundreds of
  unmounted instances subscribing to lineage and worktree store updates.
- Virtualize the candidate list and resolve activity statuses only for
  visible rows, eliminating redundant status subscriptions.
- Extract filtering, placement calculations, and row rendering into
  separate modules for testability and clarity.

* Memoize worktree parent picker search handler

Wrap search state update in useCallback to stabilize the handler
across re-renders. Reduces unnecessary effect runs and enables better
memoization of child components.

* Optimize worktree parent picker: defer unmount, memoize IDs

- Defer unmount until exit animation completes (200ms) to prevent premature teardown
- Memoize visibleWorktreeIds to prevent status hook from rebuilding its selector on every render
2026-08-03 15:57:05 -07:00
OrcaWinandOrcaWin 25213ec04d test(terminal): harden daemon upgrade adoption (#12353)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-03 15:03:37 -07:00
Brennan Benson 7de40ffbd8 fix(terminal): stop keyboard effect re-registering on every pane render (STA-3291) (#12348)
createExpandCollapseActions minted five fresh closures per TerminalPane
render; four are deps of useTerminalKeyboardShortcuts, so its seven
window listeners tore down and re-registered on every render and all
effect-owned IME chord/modifier state silently reset. Memoize the
actions via useExpandCollapseActions and pin registration stability
with a render-churn regression test.

Fixes #12269
2026-08-03 14:41:22 -07:00
Brennan Benson 056c2d9496 fix(runtime): bind mobile WS listener to loopback until pairing (STA-2370) (#11956)
The runtime RPC WebSocket listener bound to 0.0.0.0:6769 at startup, so a
desktop with no paired device was reachable from the whole LAN before the
user opted in. Default the bind to 127.0.0.1 and widen to all interfaces
only on an explicit opt-in:

- createMobilePairingOffer / getRuntimePairingUrl widen (ensureNetworkExposure)
  before advertising a LAN endpoint; the rebind reuses the resolved port so an
  already-issued offer stays valid, and concurrent offers share one rebind.
- orca serve and E2E set exposeNetworkByDefault to bind wide at startup.
- A previously-connected device (lastSeenAt > 0) rebinds wide at startup so
  reconnect after restart keeps working; a pending/never-connected offer does
  not persist exposure across a restart.

The advertised pairing endpoint still resolves to a concrete interface address,
never the 0.0.0.0 bind host.
2026-08-03 13:06:11 -07:00
OrcaWin 2c6a9d1446 fix(browser): recover embedded guests after lifecycle loss (#11717) 2026-08-03 13:02:56 -07:00
814b87c421 fix(sidebar): route project adds to the intended host, not the global runtime (#9541)
* fix(sidebar): route local folder adds to the intended host, not the global runtime

Adding a local folder while connected to a remote runtime failed with
"<path> was checked on <host>, but that host did not report a usable folder"
because addRepoPath decides local-vs-remote purely from the global
settings.activeRuntimeEnvironmentId when no explicit host is passed.

Two local-add flows relied on that global fallback and got misrouted:

- useAddRepoLocalFolderFlow (native picker / drag-drop): the Add Project
  host selector can display "Local" (selectedRuntimeEnvironmentId = null, so
  the guard passes) while the global still points at an unavailable runtime.
  Native-picked/dropped paths are always local, so force local routing.

- AddProjectFromFolderDialog ("Add folder as project" on a subfolder): a
  subfolder lives on the active repo's host, so carry that host through the
  modal data and route by it — local for local projects, the owning runtime
  for runtime projects — instead of the globally-active runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sidebar): route runtime server-path adds by selected runtime, not global

The Add Project "server path" step (reached only when a runtime host is
selected) called addRepoPath(path, kind) with no explicit host, so it
inherited the global settings.activeRuntimeEnvironmentId. When that global
diverged from the dialog's selected runtime, the add was misrouted off the
host the user picked — the same root cause as #9541, opposite direction.

Route the server-path add by the dialog's selected runtime explicitly.
Co-locate selectedRuntimeEnvironmentId in useAddRepoHostSelection next to
selectedSshTargetId so the dialog reads it from one place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sidebar): route the pre-add git server-path scan by the selected runtime

For kind === 'git', scanNestedRepos runs before addRepoPath and can
early-exit the flow into the nested-repo review, but it still routed by the
global active runtime — so it could scan the wrong host even after the add
itself was correctly routed to the selected runtime (CodeRabbit).

- scanNestedRepos accepts an optional runtimeEnvironmentId in its controls;
  when present it routes by that host, else falls back to the global
  (existing callers unchanged).
- useAddRepoServerPathFlow passes the selected runtime into the scan and
  derives runtimeKind/streaming support from it instead of the global-reading
  getNestedRepoRuntimeKind(null), so telemetry and the nested review target
  the same host as the add.

Adds renderer- and store-level regression tests covering scan routing to the
selected runtime, the null-override local case, and the nested-review handoff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sidebar): make nested-scan cancellation route by the scan's owning host

Follow-up to CodeRabbit review of the scan-routing change:

1. scanNestedRepos treated `{ runtimeEnvironmentId: undefined }` as an explicit
   local override via the `in` check. Only null or a string is now explicit;
   undefined falls back to the global (matches getAddRepoPathRouteSettings).

2. scanNestedRepos gained a routing override but cancelNestedRepoScan still
   routed by the global — an asymmetric contract where an override-routed scan
   could be un-cancellable if the global diverged mid-scan. cancelNestedRepoScan
   now takes the same override, and useAddRepoNestedReviewState remembers each
   scan's owning host by scanId (set when the scan is registered) so both stop
   and reset cancel on the host the scan actually ran on. The local folder flow
   routes its scan explicitly local so scan, cancel, and add all agree.

Adds store-level regression tests (explicit override wins, undefined falls back
to global, cancel routes by override) and a new useAddRepoNestedReviewState test
covering cancel-by-owning-runtime for stop and reset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sidebar): keep subfolder adds on their owning host

* fix(onboarding): keep completion on captured host

* chore(review): drop unreachable onboarding recovery

* fix(sidebar): preserve paired runtime checkout ownership

* fix(runtime): index paired worktrees by logical owner

* fix: fail closed on worktree owner alias collisions

* docs(sidebar): clarify host-routing intent flagged in review

Two Greptile P2 notes, addressed as comments (no behavior change):
- project-added-default-checkout.ts: the runtime branch's `hostId === executionHostId`
  is NOT unreachable — a colliding repo id can carry a runtime-qualified hostId with
  no runtimeOwnerEnvironmentId (see the "repo IDs collide" test). Documented why the
  comparison is reachable and load-bearing rather than replacing it.
- AddProjectFromFolderDialog.tsx: note that omitting the runtimeEnvironmentId spread
  intentionally signals local (NonGitFolderDialog coerces absence to null).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(activity): control portal readiness observer delivery

---------

Co-authored-by: fanyunqian.1 <fanyunqian.1@bytedance.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 12:53:11 -07:00
OrcaWinandBrennan Benson 34291f07e9 fix(runtime): skip unchanged worktrees when publishing mobile session snapshots (#12207)
* test(runtime): prove mobile session publication rebuilds every worktree

buildMobileSessionTabSnapshots consults its per-worktree cache after building
the content, so a republish saves the fanout but none of the work. With 300
worktrees, an unchanged republish still does 601 units of per-worktree work,
and a single changed worktree does 602.

Publication is keyed on agentStatusByPaneKey/agentStatusEpoch, so this runs on
every agent status tick. On a multi-client runtime host with 381 worktrees this
allocated ~350 MB/min and rode the renderer into repeated 4 GB OOMs.

Tests are marked it.fails so the branch stays green; drop .fails when the build
loop skips worktrees whose inputs are unchanged.

* refactor(runtime): make mobile session snapshot inputs explicit per worktree

Every per-worktree builder in buildMobileSessionTabSnapshots took the whole
AppState, so a worktree's real input set was the transitive closure of seven
helpers and could not be memoized safely. Introduce MobileSessionWorktreeInputs
— built once per worktree — and thread it through the group projection and the
terminal/markdown/file/browser tab builders so the compiler proves the input
set. Tab- and pane-keyed slices are narrowed to this worktree's tab ids, file
ids, browser workspace/page ids, and pane keys; agent statuses are bucketed per
worktree once per publication via a tab-id index.

No behavior change. Dropping AppState from the projection path also removes the
second per-worktree read of browserTabsByWorktree, so the publication-cost
counter falls from 601 to 1 per publication and its two cases now pass.

* fix(runtime): skip unchanged worktrees before building mobile session content

buildMobileSessionTabSnapshots consulted its per-worktree cache only after
building that worktree's three Maps, group projection, and full tab array, so
the cache suppressed the fanout but none of the computation. Every agent-status
tick therefore rebuilt every worktree, which drove sustained 4 GB renderer
working sets on a host holding 381 worktrees.

Cache MobileSessionWorktreeInputs alongside each snapshot and reuse the snapshot
when every input field is reference-equal, before any intermediate structure is
allocated. Worktrees with a mounted TerminalPane always rebuild: their live
DOM/PaneManager state is invisible to store references. Absent per-worktree
slices now resolve to shared empty values so an empty worktree can compare equal
to its last publication. jsonContentEquals stays as the backstop on the rebuild
path for inputs that churn by reference without changing output.

With 300 worktrees, per-worktree content builds go from 300 to 0 on an unchanged
republish and from 300 to 1 when one worktree changes.

* test(runtime): cover agent status publication cost

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-03 12:09:32 -07:00