* ci(pr): run E2E when a PR touches tests/e2e paths
Regression specs under tests/e2e never ran on PR CI — only schedule and
release called e2e.yml — so a red regression test could merge green.
Path-filter and workflow_call the E2E suite when E2E-relevant files change.
Use merge-base diffs so base-branch drift does not false-trigger E2E, fail
the detector when git diff cannot compute the PR range, and pin
least-privilege contents:read on both the detector and reusable E2E workflow.
Closes#10518
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: Orca <help@stably.ai>
* ci(pr): make the E2E path gate actually block, and match the real config path
Two fixes to the new path-filtered E2E job.
The gate did not gate. pr.yml's `verify` job is the required check, and it
enumerates its dependencies explicitly — `e2e` was in neither `needs` nor the
result list, so a failing shard left `verify` green. That reproduces the exact
hole this job exists to close: a red spec merges green, just with a red box
further down the page. Add `e2e` to both.
Because the job is path-filtered, `skipped` is the normal result on a PR that
touches no E2E files and has to keep passing. That allowance is checked after
the strict loop rather than inside it, so it can never leak to the six jobs
that are always required.
The `playwright.` pattern matched nothing. The config is
tests/playwright.config.ts — beside tests/e2e/, not inside it — so no tracked
file starts with `playwright.` and editing the runner config would silently
skip E2E. Anchor it at `tests/playwright.`.
Adds a contract test alongside the existing release-e2e one. Verified it fails
when either fix is reverted, and simulated the gate across
success/skipped/failure/cancelled plus the skip-must-not-mask-a-real-failure
case.
* test(ci): close two gaps in the E2E gate contract
CodeRabbit was right on both counts — verified by reverting each and watching
the contract stay green.
The path filter was unasserted, so `e2e` could lose its `if:` and run on every
PR — the cost the filter exists to avoid — without failing anything.
The strict-loop check hardcoded four of the six required jobs, so dropping
GIT_COMPATIBILITY or SHELL_CONTRACTS left them unenforced while the contract
passed. Derive the list from verify.needs instead, so a newly added required
job that misses the loop fails here rather than silently going unchecked.
* ci(pr): land the E2E path gate advisory instead of blocking
The E2E suite is currently failing every scheduled run on main — 22 of the last
22 — so making verify depend on it would block any PR touching tests/e2e/**,
including the PRs that fix the suite. This PR's own run reproduced that: 3 of 12
shards failed on specs unrelated to it (agent-session resume, Jira linking,
plugin containment, terminal artifacts).
So the job runs and reports on E2E-path PRs but is left out of verify.needs for
now. The detector, the tests/playwright. path fix, and the contract tests are
unaffected — those stand on their own and were the substance of the review.
Flipping to blocking is a three-line change once the suite is green; the exact
wiring, including why the skipped allowance must sit outside the strict loop, is
recorded on verify's Require-successful-checks step. The contract test pins the
advisory choice so it reads as deliberate rather than as the unwired-gate bug it
originally caught, and still fails if the path filter, the strict-loop coverage,
or the config path regress.
---------
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: Orca <help@stably.ai>
#8321 mixed the selected card's wash into the opaque --worktree-sidebar
surface, lifting dark mode to 16% (#4b4b4b). Card text lost too much
contrast against it.
Return both modes to a translucent wash (light 8%, dark 10%) so the
brighter selection border added by #8321 carries the selected state
instead of the fill.
Co-authored-by: Orca <help@stably.ai>
* fix(browser): keep the address bar editable in a narrow toolbar
Every other browser toolbar control is shrink-0, so the address bar was
the only flexible item and absorbed the entire squeeze: below roughly
420px of pane width it collapsed to the leading globe icon with a
zero-width input. Clicking it only opened the suggestion dropdown, which
inherits `--radix-popover-trigger-width` and so rendered at icon width —
there was no way to type or edit a URL in that tab.
Focusing a squeezed bar now lifts the form out of the toolbar flow and
overlays the row edge to edge, giving a full-width editable field that
navigates on Enter (and a full-width suggestion list for free). A
measured slot stays in flow so the overlay cannot feed back into its own
width, and the slot keeps a min width so the globe remains a real hit
target instead of being overlapped by neighbouring buttons.
Fixes#11090
Claude-Session: https://claude.ai/code/session_01Mx53f7erbtw5NraS8HdXKE
* fix(browser): use the documented floating shadow for the expanded bar
STYLEGUIDE.md defines exactly three elevation levels and forbids a
fourth; shadow-md was not one of them. The overlaid address bar is a
floating surface, so it takes the documented floating shadow already
used by the other floating surfaces in this pane.
Claude-Session: https://claude.ai/code/session_01Mx53f7erbtw5NraS8HdXKE
* test(browser): make the narrow-toolbar regression deterministic
The spec passed only from a clean profile. Two preconditions it set once are
actively undone by the app:
- BrowserPane re-focuses a blank tab's address bar across several animation
frames plus the blank-url did-finish-load handler, so a single blur() was
reverted and the bar never reached its squeezed resting state.
- Startup paths re-open the right sidebar. At a fixed 700px window that leaves
the pane ~70px, so the overlay had nowhere to go and the field measured 0px.
Settling these separately let whichever settled first drift back while the next
one ran. Re-assert them in one loop until they hold simultaneously, and size the
window from the chrome actually measured instead of assuming a fixed 700px.
Verified 8/8 green, and still fails at the overlay assertion when the fix is
disabled, so the regression coverage stays real.
---------
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* Revert "fix(skills): stop reporting a failed update when the CLI succeeded (#11105)"
This reverts commit a8660839ee.
* fix(skills): stop offering an update the CLI provably cannot perform
The Update skills dialog reported "The update didn't finish" / "Some skills
could not be updated" with an armed Retry, directly above the runner's own
"All global skills are up to date" log line, on a clean exit 0.
`skills update` decides what to do by comparing its lock's `skillFolderHash`
against the source tree and never reads disk (published CLI, dist/cli.mjs:
`latestHash !== entry.skillFolderHash`). So once the lock records a revision
the filesystem does not actually have, it reports up-to-date, exits 0 and
writes nothing. No retry converges it.
Gate eligibility on that instead of blaming the run afterwards: a name stays
updatable only while the lock's hash and the revision the DISK hashes to
agree. Those that disagree fall through to `needs-attention`, which
skill-freshness-display-status.ts already documents as the state for a copy
"out of date somewhere the update command cannot reach". The dialog no longer
offers them, so it can neither claim failure nor claim success.
Deliberately compared against disk, not the bundled manifest: the updater
pulls from the source repo, which legitimately runs ahead of what a build
ships, and gating on the bundle would withhold real updates. Both sides must
also be positively identified — an unplaceable lock hash is not evidence.
Also reverts a8660839ee, which forgave `outdated` post-run. That turned the
false failure into a false success ("Updated 2 skills" over copies nothing
wrote) and let an empty verdict swallow spawnError, so an offline run
published green.
Reachable by anyone who updated during the stub-conversion window — every
bundled skill has a stub -> full -> stub oscillation in its last three
registry revisions.
* fix(skills): do not gate a skill when a placement is unidentifiable
`diskTreeShas` drops digests matching no known revision, so `every` ran over
only the resolved half — one stale copy beside one unidentifiable copy gated
the name, contradicting the unknown-stays-eligible rule the function documents.
Require every observed digest to resolve before treating all placements as
mismatched.
Caught by CodeRabbit on #11110.
* fix(skills): judge convergence only over placements the update command writes
A same-name plugin-cache or repo copy could defeat the gate two ways: an
unidentifiable repack read as an unresolved placement, and a cache copy
parked at the lock's own revision read as an anchor — both re-arming the
unwinnable update on a drifted canonical. Filter to
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, matching eligibility and outcome.
* fix(agent-status): track Codex rollout subagents
* fix(agent-status): resolve cross-day Codex child rollouts and unblock CI gate
Codex files each rollout under its own local start date, so a session that
runs past midnight spawns children into a sibling day directory. Scanning
only the parent's directory left 13% of real subagent spawns (48/371 across
local rollouts) permanently unresolved, which pinned a phantom "working" row
and re-ran readdirSync every poll tick forever. Resolve the child's own day
directory from occurred_at_ms, and time-box a child whose rollout stays
unreadable so a deleted or never-written file can't leak a working row.
Also make the hook HTTP handler return void: the changed-code quality gate
keys findings by span overlap, so this PR's added line inside the pre-existing
async createServer callback resurfaced no-misused-promises as a new finding.
Tests cover cross-day resolution, grace-period retirement, and that the poll
re-arms across successive roster changes (the prior tests passed even when
the poll died after its first change).
* fix(agent-status): keep the Codex subagent poll alive across nested hooks
A nested non-codex CLI inherits its parent's ORCA_PANE_KEY, so its hook
POST reached scheduleCodexSubagentPoll and tore the timer down before the
source guard, silently ending polling while a rollout child was still live.
* perf(terminal): eliminate dense control and frame gate regressions
* test(terminal): keep gate labels in valid expect shape
* test(terminal): expose the surviving sub-threshold control-density case
The only adverse strip fixture sat at 50% control density, which is exactly
where the fallback fires and wins. A shape at 31 controls per 64-unit block
evades the trigger and still loses to the per-character legacy (0.67x), so the
benchmark structurally could not show it.
Add that fixture, pin both density literals in the staleness guard so a retune
fails loudly instead of silently measuring a boundary that moved, and export
the probe constant the equivalence test was hardcoding.
The Update skills dialog showed "The update didn't finish" / "Some skills
could not be updated" with an armed Retry, directly above the runner's own
log line saying "All global skills are up to date" — on a clean exit 0.
`skills update` compares its lock's recorded hash against the source and
never reads disk (dist/cli.mjs: `latestHash !== entry.skillFolderHash`).
Once the lock has advanced past the installed bytes it prints up-to-date,
exits 0 and writes nothing. The copy left behind is a recognised older
revision, so the post-run re-scan sees `outdated`, skillUpdateFailedNames
counted that as a failed run, and skill-update-run settled to state 'error'.
Retry re-ran the same command, which no-op'd again — a closed loop.
Reclassify `outdated` as "the command did not converge this", not "the run
failed". The freshness badge still marks the copy not-current, so nothing is
hidden; the run just stops being blamed for it.
A botched write is still caught: a half-written bundle hashes to
`unrecognized`, a wholly-degraded or removed copy leaves no convergent
placement, and process-level failure still surfaces via the spawn error.
Reachable by anyone who updated during the stub conversion window — every
bundled skill has a stub -> full -> stub oscillation in its last three
registry revisions.
Add translations for OrchestrationPage coordinator and child PR names, plus agent workflow status messages (initial states and progress beats) across all supported languages (English, Spanish, Japanese, Korean, Simplified Chinese).
The Developer submenu shipped visible on every worktree right-click, and its
Park terminal action always refused with "These terminals cannot be parked
safely."
- reveal the Developer submenu only when Option/Alt is held at right-click,
captured at open time so it can't shift rows mid-menu
- stop a settled pendingActivationSpawn tag from refusing a manual park: first
activation stamps it on every tab and only a fresh updateTabPtyId consumes it,
so a reattached tab kept it forever
- resolve the single leaf of a rootless layout for parked watcher coverage, so a
workspace whose panes never mounted is no longer permanently uncoverable
- restore the !isVisible park guard dropped in #11016, which let the workspace
being viewed unmount its own terminals
- split manual-park eligibility out of the automatic cold-park policy module
* feat(new-workspace): make the project picker a type-ahead field
The Create-worktree Project slot read as bulky and unpolished: a label row,
an add-project icon, a 36px outline trigger and a chevron, all spent before
choosing anything — then a popover carrying its own *second* search box,
two-line rows, and a footer that scrolled out of reach.
The field is now the search. Typing filters in place, so the nested search
box is gone. Exactly one row is armed at any time and Enter takes it;
hovering arms, so pointer and keyboard drive one cursor rather than two
competing highlights. Armed is tracked by row key, not index, so a list
arriving late over SSH cannot slide a different project under a keypress
the user already aimed.
Rows are single-line at 28px with an on-row Enter cap that takes space only
while armed, and "Add a new project" is pinned to the popover edge so it
survives every state — scrolled, filtered to nothing, or no projects at all.
Long names and deep paths degrade deliberately: the name keeps up to half
the row and the path elides from its middle, so two monorepo siblings stay
distinguishable as …/services/checkout-api vs -web where a flat truncate
rendered both identically.
Recency is derived from when each project last had a workspace created,
which is the action this picker is about to repeat — no new store field.
The shell keeps data-project-combobox-root + role=combobox and stays
focusable, so the composer's initial-focus and project-required handlers
still land on it.
* fix(new-workspace): align, scroll and loosen the project picker
Five fixes to the type-ahead picker, three reported and two found while
checking for related breakage.
Alignment: the name and its smaller detail line were centred as boxes, so
the 12px path sat visibly high against the 14px name. Both now share a
baseline, in the committed field and in every row. The dot mark and the
Enter cap are chips rather than text, so they stay centred on the row.
Scrolling: the mouse wheel did nothing over the list. The composer is a
Radix Dialog, and react-remove-scroll cancels wheel events for portaled
content outside the dialog's DOM tree — the scrollbar dragged fine but the
wheel was dead. The old cmdk list carried a shim for exactly this; the
plain scroll pane that replaced it did not, so it has its own now.
Density: rows go 28px -> 32px, row text 13px -> 14px and detail 11px ->
12px, with a taller Add row and more air above section headings.
Escape stranded a query: with the list closed but text still typed, Escape
was ignored (it was gated on the list being open), leaving the field
showing text that matched nothing and hid the committed project. Escape now
always restores the committed display, and only bubbles when there is
nothing to undo.
Listbox ownership: options sat inside unroled section and scroll wrappers,
which breaks the listbox -> option relationship assistive tech relies on.
Sections are groups carrying the heading as their label, and the scroll
pane is presentational.
Both new behaviours are covered by tests verified to fail without the fix.
* chore(tools): keep the project-picker design lab
The exploration harness behind the picker rewrite: 16 interactive design
variants rendered against the app's real tokens and shadcn primitives, so a
prototype is a drop-in ProjectCombobox rather than a mockup.
Worth keeping because the frames encode bugs that only reproduce in
context. DialogFrame renders the picker inside a real Radix Dialog, which
is the only way the react-remove-scroll wheel bug shows up; the fixtures
carry duplicate display names and deep sibling paths that a naive truncate
renders identically.
Run with: npx vite --config tools/wt-picker-lab/vite.config.ts
* fix(new-workspace): stop the project list flashing open, shrink its empty state
Opening the picker read as a double flash. The shared popover surface is
translucent and fades 0 -> 1, which is right over the app canvas but wrong
here: this popover lands directly on the composer dialog, so for the length
of the fade the Name field underneath showed straight through the list and
you saw two layers at once. The list now uses an opaque surface and zooms
without fading, so it is solid from the first frame. Every other popover
keeps the blur and fade.
The "No projects match your search." state was a 60px centred block sitting
next to 32px rows, which read as a different kind of surface and made an
empty result feel like an error. It is now sized and aligned like a row.
The lab's dialog frame focused whatever Radix picked first, which popped the
Add-project tooltip on open and masked the real problem; it now focuses the
name field the way the real composer does.
* fix(new-workspace): square mark, centred empty state, and keep the list open on tab-focus
Four fixes, three reported and one found while sweeping for others.
Square mark: the option dot had a `rounded-full` override, so a project read
as a circle here and a square everywhere else (jump palette, sidebar). Drop
the override and use RepoBadgeMark's own shape.
Centred empty state: "No projects match your search." was left-aligned after
being shrunk to row height; centre it.
Tab-focus blinked the list shut: the field lives in the popover's anchor, not
inside its content, so Radix's dismissable layer saw focus land "outside" and
closed the list the instant you tabbed in. Focus and pointer events within
this control no longer dismiss it; genuine outside events still do.
Junk text could strand the field: typing a query that matched nothing and
then clicking away left the text sitting there with the list closed, showing
no project and no error. A query only means something while the list is open,
so closing without committing now clears it.
On pressing Create with no project: no change needed. The create gate has not
depended on project selection since #4991, and both submit paths already call
showProjectRequiredError(), which sets the inline message and turns the field
red via aria-invalid. Verified end to end: the button is pressable, the press
paints the field destructive, and the message appears beneath it.
* feat(new-workspace): rebuild the Run-on picker to match the project picker
"Run on" was the last composer field still built the old way: an outline
trigger wrapping a cmdk list, two-line rows, and no way to search. It now
matches the project picker, so the two fields in the same form read as one
control.
The field is the search — type to filter hosts, paths and recipes with no
nested search box. Exactly one row is armed at a time and Enter takes it;
hovering arms, so pointer and keyboard drive one cursor. Rows are 32px with
the label and its path on a shared baseline, the path eliding from its
middle so two deep sibling paths stay distinguishable. The popover surface
is opaque and unfaded because it lands on the composer dialog, where a
translucent fade shows the form underneath.
Two behaviours the project picker doesn't have are preserved. Disconnected
hosts keep their inline Connect action, tracked per host so one stalled
connect never blocks the others, and the list stays open so the connecting
state is visible. Two rows open nested lists rather than committing: VM
recipes, and "Add host" pinned to the popover edge so it survives every
state — scrolled, filtered to nothing, or with no hosts at all. Enter and
ArrowRight open a submenu; Escape backs out one layer at a time.
Extracted from NewWorkspaceComposerCard (-563 lines) into files that each
stay under the line limit without a suppression.
Tests: the run-target cases asserted cmdk internals (`[cmdk-item]`,
aria-disabled, cmdk-separator) that no longer exist. Rewritten against
behaviour and the listbox roles instead. All 22 composer tests pass,
plus a live sweep of 11 interactions in a real dialog.
* fix(new-workspace): drop the Enter cap, fix submenu hover, match the Add rows
Three follow-ups on the two composer pickers.
The ↵ cap on the hovered row is gone from both. On a run-target row it sat
next to the Connect action and read as a second, competing affordance; the
highlight already says what Enter will take.
Submenu rows never highlighted under the pointer. They passed a hardcoded
`armed={false}`, so the recipe list and the Add-host choices were the only
rows in either picker with no hover state. They now track their own hover.
"Add a new project" used a chunky FolderPlus where "Add host" uses a plain
Plus. Both rows were already the same height and type, so matching the glyph
is the whole difference.
* fix(new-workspace): restore the folder glyph, two-line Add-host cards, quiet Connect rows
Three follow-ups.
"Add a new project" goes back to FolderPlus — matching "Add host"'s plain
Plus made the two consistent but lost the glyph that says which kind of
thing is being added.
A disconnected host row no longer repeats its status. The Connect button
already says the host isn't connected, so "Connect this host to set up
projects" beside it was saying it twice. Rows without a Connect action keep
their detail, since there it explains why the host can't run.
The Add-host choices go back to two-line cards. Their descriptions explain
what you're picking ("Use an existing machine over SSH" vs "Pair another
Orca runtime"), unlike a host row's detail, which just labels a host you
already recognise. RunTargetRow grows a `stacked` variant for that rather
than making the single-line row do both jobs.
* fix(new-workspace): give Run on the same vertical rhythm as the other fields
Run on is nested inside the Project block so the two share its error and
empty states, which also put it on that block's 4px internal spacing. It
reads as its own field, so it sat noticeably tighter than the 16px gap
every other field in the composer gets. Pad it to match.
* refactor(new-workspace): share the type-ahead machinery between both pickers
Project and Run on were built one after the other, so each grew its own copy
of the same mechanics: query and open state, arming by row key, the
arrow-key walk, scroll-the-armed-row-into-view, the react-remove-scroll
wheel shim, and the closes-drops-the-query rule. Two copies of subtle
behaviour is two places for it to drift.
useTypeAheadCombobox now owns all of it. Callers pass a function that turns
a query into row keys and get back the query, the armed key, and the
movement helpers. Run on layers its submenu state on top by wrapping
`close`, which is the only part that isn't shared.
The two long class strings both files repeated verbatim — the field shell
and the opaque unfaded popover surface — are named constants now, so the
reason they differ from the stock popover recipe is written down once
instead of implied by a duplicated literal.
No behaviour change: 16,456 renderer tests pass, plus the 22-check live
interaction sweep across both pickers in a real dialog.
* fix(new-workspace): drop aria-expanded from option rows, remove the design lab
`aria-expanded` isn't a supported prop on `role="option"`, so the submenu
rows were claiming a state screen readers can't interpret there.
`aria-haspopup` alone already says the row opens a menu.
Removes tools/wt-picker-lab. It was the harness for exploring this redesign
— 12 interactive variants — and it did its job, but the 11 that lost are
dead code, and its prototypes were the only thing failing the react-doctor
gate (5 errors, all in throwaway variants; the shipped pickers had none).
Refresh the persisted Windows PATH during preflight without blocking Electron's main thread. Bound and deduplicate registry reads, preserve the last good cache on failure, skip host refresh for WSL, and add Windows regression coverage.
* fix(relay): tolerate wall-clock skew in host-proof validation
A few seconds of local clock lag made challenge issuedAt appear in the
future, so host-proof rejected every handshake and Mobile Relay looped
connecting forever. Allow ±30s skew while keeping the 10s challenge window (#10401).
* fix(relay): preserve host-proof challenge bounds
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix(codex): keep a host account switch inside the host lane
markLiveCodexSessionsForRestart walked every tab's PTYs and carded any pane
whose foreground looked like Codex. There was no lane check anywhere in that
path, so a host account switch raised a restart notice on live SSH/relay panes
— and a notice mutes the pane, so the user's remote terminal went deaf.
The notice was provably spurious: a remote spawn carries a connectionId, so
isDaemonHostSpawn is false and no CODEX_HOME is ever injected. The remote Codex
uses the remote machine's own credentials; a local selection cannot reach it.
Scope marking by lane instead. A pane's lane is (machine, runtime): `host`,
`wsl:<distro>`, `env:<id>` for a relay environment, or an SSH connection that
no managed selection can name. A switch made while a runtime environment is
active still cards that environment's panes, which is the case that made the
old "mark everything" behaviour look right.
WSL was the same defect, not a separate one. A Windows run saw a WSL pane
correctly escape a host switch, but only because its foreground read `wsl.exe`,
which fails the Codex-foreground test — the Win32 process table cannot see into
a WSL2 VM. That is incidental: `codex`, `node` and `python3` foregrounds are all
eligible today, so a WSL pane that surfaces one (WSL1 pico-processes are in
Win32_Process) would be carded by a host switch. The lane is now what decides.
Also stop queueing remote and SSH panes into the bind-driven stale sweep at all.
recordCodexPaneAccountForSpawn bails on anything that is not a daemon host
spawn, so listStalePanes can never report one stale, yet each pane still spent
every rung on a 15s-timeout remote RPC — ~75s per pane since the ladder widened
to five rungs.
The lane vocabulary moves to shared/ so the renderer keys panes exactly as a
launch does rather than growing a third copy of the rules.
Refs #10757
* fix(codex): key a WSL pane by the distro its launch actually used
The lane guard derived a pane's WSL distro from the workspace UNC path alone.
A launch does not: pty.ts hands getCodexSelectionTargetForPty a third argument,
the resolved runtime's distro, so a wsl.exe pane on an ordinary Windows-path
worktree launches under `wsl:Ubuntu`. The renderer keyed that same pane
`wsl:__default__`, so the Ubuntu switch never reached it — the pane kept the old
account with no notice, which is #10757 returning by a new route on the exact
platform the issue was reported from.
Resolve the distro the way the spawn does: the project execution runtime first,
then terminalWindowsWslDistro. Both are already in renderer state.
Also match a distro-less WSL switch against the whole `wsl:` family. Two
mutations reach the renderer as `{runtime:'wsl', wslDistro:null}` while writing
concrete distro slots: selecting the system default clears EVERY wsl slot
(setSelectedCodexAccountIdForTarget), and `add` stores the distro it discovered
from the machine. Keying those to `__default__` missed the very panes they
re-pointed. The residual cost is over-marking a sibling distro after an add,
bounded to this machine's WSL panes and far cheaper than a stranded pane.
An owner-less remote pane colliding with the host lane was untested — that
collision is what would mute a working remote terminal, so pin the disjointness
rather than the literal key.
Refs #10757
* fix(codex): resolve a pane's lane the way its launch resolved it
Three more places where the renderer's lane and the launch's lane disagreed.
Each disagreement is silent: too narrow and a stranded pane never gets its
notice (#10757 returns), too wide and a healthy pane is muted, because a notice
makes onData drop every keystroke.
Shell: main runs the request through resolveLocalWindowsTerminalRuntimeOptions,
so an unset shellOverride still lands on WSL when that is the Windows default.
Reading tab.shellOverride alone called such a pane `host` — a host switch would
have muted a working WSL terminal. Gate on the renderer platform, as pty.ts
gates on process.platform.
Cwd: a terminal's startup cwd is deliberately not constrained to the worktree
(resolveTerminalStartupCwd, #7685), and main keys the lane off that cwd. Follow
it through the same shared call instead of reading the workspace root, so a pane
split after `cd \\wsl.localhost\...` is keyed where it actually runs. The comment
claiming a pane can never start outside its workspace was simply wrong.
Family match: narrow the previous commit. setSelectedCodexAccountIdForTarget
only nulls every WSL slot when the account is null AND no distro is named; any
other write lands in one slot. So claim the family only when the change actually
cleared them all, and let `add` pass the created account's concrete target
rather than the row's "WSL default". Both call sites already knew which case
they were in.
Refs #10757
* fix(codex): derive the pane's project runtime the way main does
The previous commit reached for getLocalProjectExecutionRuntimeContext as a
stand-in for main's resolveLocalProjectRuntimeForWorktreeId. They are not the
same function, and the differences both produce wrong lanes:
- It falls back to `state.activeRepoId` when the worktree is not a git worktree,
so a folder-workspace pane inherited whichever repo happened to be selected.
That is not a property of the pane at all — the lane moved when the sidebar
selection moved. On a WSL project it both muted a healthy host pane and hid
the notice a host switch owed it.
- It synthesizes a runtime from `inherit-global` where main returns undefined,
and its host branch rewrites an explicit `wsl.exe` to powershell.exe, keying a
live WSL pane `host`.
Walk repo -> project directly instead, which is what resolveLocalProjectRuntimeForRepo
does, and use it only to supply a distro — never to downgrade a shell. That also
drops the throwing call out of this path entirely; the lane runs outside
scanCodexPanes' inspection guard, so a throw there would have lost the notice
for every pane in the batch, not just one.
Also find the added account by diffing the roster. Reading it back through the
row's active id returns null once two distro slots are filled, which sent the
notice to `wsl:__default__` while `add` had written a concrete distro.
Refs #10757
* fix(codex): key the lane off the runtime the renderer actually shipped
Reverses the project-runtime half of the previous commit. That commit assumed
main resolved the project runtime itself, so it re-derived one by hand. It does
not: for a local pane the RENDERER computes it with
getLocalProjectExecutionRuntimeContext and ships it with the spawn
(pty-connection.ts), and pty.ts feeds that straight to getCodexSelectionTargetForPty.
So the helper is not an approximation to be improved on — it is the launch.
The hand walk dropped the global Windows runtime default, which is what turns an
`inherit-global` project preference into WSL. A user who set their runtime
default to WSL but left terminalWindowsShell alone would have had every live WSL
pane keyed `host`: muted by a host switch, and missed by their own. It also
disagreed on folder workspaces, where the launch really does resolve through the
active repo.
Keep the repair-required early return: that call throws, and it sits outside the
scan's per-pane failure guard, so a throw would lose the notice for every pane in
the batch rather than one.
Separately, floating terminals have no workspace root, so their startup cwd is
used verbatim (resolveTerminalStartupCwdForWorkspace). Resolving one against a
root that does not exist yielded no cwd at all, keying a floating Codex pane on
a WSL filesystem as `host`. Read its cwd directly.
Require exactly one new account before trusting the roster diff — an unloaded
prior roster makes every account look new, and Add Account is not gated on it.
Refs #10757
* fix(codex): stop claiming a floating-terminal cwd the tab never has
The floating-terminal branch read tab.startupCwd, which no floating creation
path ever sets (FloatingTerminalPanel, FloatingTerminalWindowControls,
floating-workspace-tab-creation all pass none). Its cwd is resolved over IPC
from settings.floatingTerminalCwd and handed to the transport as a prop, so it
never reaches the store at all. The branch was inert and its comment described
main's handling of args.cwd rather than what the code read.
Say what is actually true: a floating pane is keyed by its shell, and the
configured-WSL-cwd-under-a-host-shell case is a known gap. Guessing from the
unresolved setting would risk the mute direction, which is the expensive one.
Also pin the repair-required early return. resolveLocalWindowsTerminalRuntimeOptions
throws there, and the lane runs outside scanCodexPanes' per-pane failure guard,
so without it Promise.all rejects and every pane in the batch loses its notice.
That guard had no coverage; removing it now fails with the spawn error.
Refs #10757
* fix(codex): trust the lane main recorded at spawn over a re-derived one
The switch path re-derived each pane's Codex lane from current state while
main had already written the resolved shell, cwd and distro at spawn. Four
review rounds each found another divergence between the two, and the
derivation still answers for a launch that never happened once the user
edits a runtime preference.
Prefer the recorded lane where one exists; keep the derivation for the panes
main never records — pre-feature panes, LocalPtyProvider spawns and remote
ids — and log when the two disagree.
* refactor(codex): drop a redundant guard around the recorded-lane lookup
Fixes the P0 where skill cards showed an unclearable amber "Needs attention"
while the Details dialog reported everything up to date.
Root cause: when the plugin-cache scan tripped one of its own bounds it recorded
an incomplete path, and inventorySkillFreshness expanded that into one fabricated
placement per manifest skill at a path it never stat'ed. Those synthetic
"inaccessible" copies lit the pill, were filtered out of the dialog, and could
never be cleared because plugin-cache is not an updatable topology.
- Removes the fabrication; reports typed scan issues instead.
- Requires readable SKILL.md evidence before promoting a directory to a candidate,
so a same-named foreign plugin (Codex's own computer-use) no longer flags.
- Prunes skill payloads and node_modules so ordinary vendor caches stop tripping
the depth and entry bounds.
- Partitions scan reasons: only a real read failure raises a pill; bounds that
ended the walk block an all-clear claim; the rest are Details-only.
Fixes#10633. Refs #10659, #10904, #10918, #10775, #10791, #10813.
* fix(mobile): heal a stale input line before sending diff-review notes
The stale-input marker is keyed by terminal handle, not by surface, so a
paste orphaned on a terminal by native chat is still marked when the user
sends diff-review notes to that same terminal — and those notes were
submitted on top of it. Gate the send on the heal, as the native-chat
answer send already does; when the clear fails, surface the error instead
of dropping the note silently.
Completes a follow-up deliberately deferred by #10480.
* test(mobile): assert the post-heal send carries the notes, not another clear
* style(mobile): trim the stale-heal comment to its non-obvious why
Keeps the terminal-handle keying and the #10228 link (why a NativeChat-named
helper runs in diff review) and the deviceToken rationale; drops the clause
that restated the call. Addresses CodeRabbit review feedback.
* fix(workspace-space): serialize local disk and cap traversal memory
Prevent resource exhaustion during large workspace scans by limiting
local disk access to one concurrent `du` call and capping portable
traversal memory to 100k entries or 64 MiB per worktree. Fixes July 27
incident with 298 worktrees causing host stalls and renderer OOM.
Portable traversals now use fixed-worker iterative frames instead of
recursive promises. Capacity failures become unavailable rows. Behavior
below limits is unchanged.
* fix(workspace-space): bound concurrent SSH fallback traversals
Desktop-side SSH fallback traversals run in the main process with independent admission
budgets. Without limiting, up to six concurrent traversals could stack six 64 MiB budgets.
Cap remote fallback traversals to 2 concurrent, keeping aggregate admission at 2 × 64 MiB.
Also make capacity error messages reflect configured limits instead of hardcoded defaults.
Reviewed with an independent reproduction. Replaced the JS measure pass with layout-native CSS on both surfaces and removed the shared hook, fixing 72-142px of Linear issue title hidden after a window resize.
Reviewed with an independent reproduction. Rewrote the reload-zoom reassert to be per-pane instead of sharing the value zoom in/out writes, fixing Cmd/Ctrl+0 reset and cross-tab zoom leakage, with E2E coverage proven to fail on revert.
Reviewed with an independent reproduction over a real plain-HTTP origin. Replaced the hidden-textarea fallback with a capture-phase copy event so clipboard text never enters the DOM, and added rejection handlers to the surfaces that had none.
Reviewed with an independent reproduction. Replaced a redundant per-keypress layout rebuild with cycling over rendered sidebar rows; verified the chord is not hardcoded to metaKey.
Reviewed with an independent reproduction. Both fixes verified; added a flush for a dropped directory toggle and an integration test connecting blur/Escape through to renameFileOnDisk.
The drag predicate recorded only the active pointerId, never its type, so
during a touch-started drag any primary mouse or pen event failed the id
match but passed the non-touch fallback and was accepted as the active
pointer. Record the type at pointerdown and require both ends to be
non-touch, keeping the WSLg mouse-press/pen-motion relay working.
* fix(new-workspace): center agent selection in create dialog
Pin the Agent combobox mark to 14px, drop residual button padding, and
use a full-width min-w-0 trigger so icon, label, and chevron align with
Project/Name in the new worktree dialog.
* fix(new-workspace): optically align agent picker content
* fix(new-workspace): center selected agent content
multi-client-navigation-isolation.integration.test.ts fails intermittently on
clean main — measured at 4-8/24 with a flush-window sweep — and has been taxing
unrelated PRs across the repo.
Mechanism, confirmed by instrumented trace (schedule t=23412, listener REGISTER
23460/23461, coalescer fire 23462): a ~4ms race between the 50ms session-tabs
notify coalescer and listener registration in onMobileSessionTabsChanged. When
the pending timer fires after the listener registers but before
session.tabs.activate is handled, it emits a stale
{type:'updated', activeTabId:'host-tab'} that the test consumes.
flushAll() at the top of onMobileSessionTabsChanged, before the listener joins the
set — mirroring the flushAll() its own unsubscribe closure already performs.
DRAINING rather than cancelling is load-bearing: cancel/dispose has no emit, so it
would silence the stale frame but DELETE the pending update for subscribers
already registered, leaving them stale until an unrelated next schedule(). That
would trade a flaky test for a real lost-update bug. The newcomer cannot miss an
update by having it drained — listMobileSessionTabs and the coalesced emit read
the same mobileSessionTabsByWorktree map, and snapshot-read to listener-add is
macrotask-atomic.
Sweep: 4-5/24 before, 0/24 after. The regression test uses fake timers and is
two-sided — it fails if flushAll is removed (stale frame delivered) and fails if
flush is swapped for dispose (nothing drained), on different assertions, so it
pins the specific choice rather than merely the presence of a change.
Reviewed independently and returned clean with both mutations re-run by the
reviewer. A latent flushAll re-entrancy double-emit exists in principle — fire()
does not re-check pending.has the way flush() does — but is unreachable: both
production callers' listeners only invoke the dispatcher reply, a shape that has
shipped since #8141 on the symmetric unsubscribe flush.
Not verified: live paired-device mobile/relay behaviour (no device available); the
mobile conclusion rests on a code trace.
Closes#10793.
When Orca could not verify the originating Codex session file it either threw — a
red per-pane toast and a failed spawn, reported as constant spam on #10757 — or
returned null. Returning null did NOT start a fresh session: the renderer had
already baked ['codex','resume',<id>] into the command and pty.ts never rewrote
it, so CODEX_HOME simply fell through to whichever account was selected.
The resume argv is now dropped so a plain `codex` launches, with a banner telling
the user. The invariant — never run `codex resume <id>` under an account that does
not own that rollout — is now satisfied by construction rather than by refusing to
spawn. A verified resume is unchanged and still pins CODEX_HOME to the
originating home.
Reviewed over two adversarial rounds; seven defects found and fixed, including a
HIGH where local-provider (non-daemon) spawns still carried
ORCA_SEQUENCED_STARTUP_COMMAND with `resume <id>` — the wrong account behind a
banner claiming it started fresh. `env` is now declared after the strip so no
point in the handler can reach the pre-strip value.
Live-validated in a real Orca dev build: all five cases proven on the SPAWNED
PROCESS, including a real rollout under an untrusted home (the only shape that
discriminates) and the local-provider path forced by stopping the daemon.
An earlier CI failure on multi-client-navigation-isolation.integration.test.ts was
investigated and is a PRE-EXISTING flake — a ~4ms race in the session-tabs notify
coalescer that fails 5-8/24 on clean main, more often than on this branch. Fixed
separately in #11022.
Not verified: no Windows execution — its POSIX-only tests skip there and the
#10757 reporter is on Windows. SSH is partial: no spurious banner or drop observed
against a real target, but headless spawn does not deliver startup commands so the
remote argv could not be read. The relay/mobile notice channel deliberately has no
banner; the argv drop does happen there, so the invariant holds.
Fixes#10757. Switching Codex accounts broke three ways, all rooted in the
self-contained per-account CODEX_HOME from #9501.
HISTORY DISAPPEARED. Codex's own /resume picker only lists rollouts under the
launch CODEX_HOME, and nothing bridged history into a per-account home — only
the AI Vault's discovery scan knew about the other homes. Every other
Orca-visible home's rollouts are now hardlinked in, on selection and again at
launch, so one physical log is listed everywhere.
THE RESTART PANEL STUCK. A queued restart was only drained by a mounted
TerminalPane, but the prompt covered every stale pane in the worktree including
parked and cold-deferred tabs. Requesting a restart now answers the prompt
immediately while the pane keeps its pending restart, and a pane drains it when
its reconnected PTY binds.
PANES STAYED ON THE OLD ACCOUNT. CODEX_HOME is fixed in a shell's environment at
spawn and the daemon keeps those shells alive across app restarts, while the
restart notices are renderer state and are discarded. Each PTY's launch account
is now recorded on disk and compared against the current selection at startup.
Also merged in: #10802 (a dismissed notice no longer kills the pane's keyboard),
#10803 (the sweep arms on real PTY binds, and launcher Codex panes are no longer
filtered out by Windows deepest-process reporting), #10804 (a resume-pinned pane
now says which account it is on), #10870 (the restart card no longer parks focus
on its destructive Restart button), #10853 (the retry ladder is widened past the
Windows worst case).
Six independent reviews found real defects in every original PR, several of them
dead-keyboard bugs and three introduced by the fix for another defect in the same
loop. Live QA on macOS covered every PR; Windows was validated three times.
WINDOWS: pass 1 found two defects that made the stale-account fix a no-op there
(the sweep fired before any PTY was bound and never retried; launcher panes were
filtered out). Pass 3 at the merged head: the prompt appears on its own after a
restart — warm ~3.7-4.2s, cold ~21s needing rung 4, so #10853's widening was
load-bearing rather than precautionary; an ordinary sentence typed into a healthy
pane while another pane's card is up reaches that pane and kills nothing; a pane
running vim after exiting Codex gets no card, still none 45s later. auth.json
byte-identical across every pass.
KNOWN GAPS, stated rather than implied: #10804 is unverified on Windows
(auto-resume could not be manufactured there); cross-volume Windows is untested
and expected to yield no bridged history (EXDEV, and Codex ignores symlinked
rollouts); a cold-parked pane never binds so the sweep never covers it; the
subagent-deepest launcher shape could not be reproduced on Windows, so that
branch is fixture-verified only; WSL passed isolation but the resume mechanism is
host-lane only. A host-account switch also marks and mutes live SSH remote panes
— confirmed pre-existing on main by two independent QA runs — tracked separately
in #10992. Related pre-existing defect filed as #10863.
* feat(skills): run skill updates in the background without a terminal
The Update skills dialog had no primary action at all — its footer was only
Re-check and Close, and the real action was a pre-filled command in an embedded
PTY that the user had to press Enter on. Orca already builds and validates that
command, so it now runs it.
- Add a headless runner for `npx --yes skills update <names> --global -y`. Both
--yes flags are load-bearing: npx's skips the package-install prompt, and the
skills CLI's takes its own non-interactive branch. stdin is ignored so
`process.stdin.isTTY` stays falsy, which is the other half of that gate.
- Own the run in main so closing the dialog backgrounds it instead of killing
it, and surface it in the status bar: spinner while running, a green check on
success that clears itself, and a failure that persists until acted on.
- Derive per-skill outcomes by re-scanning the freshness inventory after exit
rather than parsing stdout. `skills update` has no --json (that flag exists
only on `list`) and reports progress per-source, not per-skill, so the run
bar is deliberately indeterminate instead of faking a percentage. When the
re-scan has a verdict it outranks the exit code.
- Drop the version trail from the rows and surface the skill list and skip
reasons directly instead of hiding them behind a disclosure.
Also fixes a width bug the collapsed disclosure used to hide: deep plugin-cache
paths set the dialog's width and pushed the footer actions off-screen.
* refactor(skills): use one row component across every update state
The ready and running views were separate components with different row
shapes, so pressing Update swapped the dialog's body for a different layout.
They are now the same `SkillUpdateRow` instances throughout — only the status
slot's contents change — and a test asserts the row is literally the same DOM
node from "update available" through pending to the result.
- Collapse each skill's locations behind its own disclosure. A skill with
several plugin-cache copies was dumping every path inline and burying the
actions; the row now shows a location count and expands on demand.
- Put status in a single slot between the name and the count rather than a
leading icon column. A leading icon has nothing to show in the resting state
and reserving its box just indented every name past an empty gap.
- Pin the running/finished run's names in `groupSkillFreshness` so a successful
update doesn't drop its own rows the instant the re-scan lands.
`skill-freshness-group.tsx` becomes `skill-location-chip-copy.ts` — only its
chip label/tooltip helpers survived, and it no longer holds JSX.
* fix(skills): place the status glyph left of the skill name
Review feedback on the row header: the badge belongs immediately right of the
name so it reads as part of it, and the run's status circle/check belongs to
the left of the name rather than sharing the badge's slot on the far right.
Name, glyph and badge are now one left-aligned group; the location count and
chevron stay right-aligned. `available` still has no leading glyph — an empty
reserved box only indents the name past a gap with nothing in it.
* fix(skills): correct the headless update run's verdict, cancel path, and stopping copy
Review fixes for the headless skill-update runner.
Main process:
- Judge per-skill outcomes on a positive signal. "Absent from
eligibleUpdateNames" is not success: a deleted, half-written, or unreadable
skill also leaves that list, so a corrupt update reported a green check.
skillUpdateFailedNames now requires every convergent placement to come back
current or newer-known.
- Retire a child's handlers with a per-run token. A failed spawn emits error
*and* close, so the second settle clobbered the real spawn ENOENT; a
cancelled child could also settle, or write output into, the run that
replaced it. The token guards the rescan's finish closure too.
- Hold the run `running` until the killed process tree is actually dead.
Releasing on the synchronous path let an immediate re-Update spawn a second
npx writing the same bundles, with a watchdog so a sweep that never settles
cannot wedge the run.
- Kill the tree, not just the npx wrapper, via killWithDescendantSweep.
- Publish an error instead of a silent `started: false` when the cmd.exe rail
rejects the resolved npx path, which a profile directory containing & or %
is enough to trigger on Windows.
- Coalesce captured output into one push per tick instead of structured-cloning
the whole buffer to every window on each progress frame.
Renderer:
- Keep rows on screen while the settling re-scan runs. Refreshing the inventory
nulls it synchronously, so every row vanished at the moment the result
appeared. Rows render off the last good scan; eligibility stays on the live
snapshot so nothing is authorized off stale bytes.
- Retry the names that failed, not the eligibility list that same re-scan has
just emptied.
- Add Stop, restoring the escape hatch the embedded terminal used to provide,
and say "stopping" on every surface rather than claiming the update keeps
running in the background.
- Show a skipped skill's reason outside the disclosure, so it no longer depends
on a mount-time defaultOpen a later re-scan can never re-fire.
- Drop the summary line telling users to open "Update details", a control this
PR removes; it was translated into four languages.
- Keep the success linger from retiring a result the open dialog is showing.
- Delete skill-location-chip-copy.tsx: an unreferenced copy of the old row
component, colliding on basename with the module that is actually imported.
* fix(skills): divide update list from summary