Commit Graph
4962 Commits
Author SHA1 Message Date
Brennan Benson 09f213c48d fix(terminal): inset the grid inside the xterm surface (#13252)
Padding X/Y was applied as start-edge container margin, so the cell grid
stayed flush on the trailing edges and a fractional background opacity
stacked a darker gutter around the viewport. Put the setting on .xterm
so FitAddon insets both axes and the themed background fills the pad.
2026-08-14 13:18:03 -07:00
Brennan Benson 83e2123582 Add global worktree visibility source defaults (#14276)
* Add global external worktree visibility defaults

* Expand global worktree visibility source defaults

* Fix host-scoped visibility settings races

* Fix global worktree visibility integration

* Enable source visibility defaults on mobile

* Polish external worktree settings navigation

* Clarify inherited worktree visibility settings

* feat(sidebar): replace the inherited-visibility switch with a Show/Hide picker

Each source row now shows a two-segment Show / Hide control preselected to the
global setting, and explains itself only where the project actually disagrees:
an "Overriding global setting: <value>" card names the value being ignored.
Picking the segment global already holds drops the override instead of pinning
a duplicate, so the same control both overrides and reverts, retiring the
separate "Use global" link. The dialog footer now lists every inheritable
source with its global value.

* fix(sidebar): preserve reset for matching visibility overrides
2026-08-14 12:15:58 -07:00
Jinjing cb42b60849 fix(linear): label Start workspace and add Open on Linear (#14492)
* fix(linear): label Start workspace and add Open on Linear

The issue page header used three unlabeled icons. Match the GitHub
issue header: copy stays quiet, Open on Linear is an external-link
control, and Start workspace is a labeled primary button.

* Address PR review feedback (#14492)

- Make the header source contract ignore Start workspace formatting
2026-08-14 02:29:45 -07:00
Neil 87bafb5e6a fix(terminal): ground the emulator to the snapshot's baseline after a byte gap (#14374)
A hidden-delivery byte gap can strand more than the SGR pen, and the reset
#14241 added to the split alt-screen replay is undone before any content is
painted: xterm answers `?1049l` with restoreCursor(), which reloads the pen,
all four G-set designations, GL, origin mode and wraparound from the register
saved at `?1049h`.

- Bracket the buffer switch with the baseline: before, so `?1049h` banks
  grounded state rather than the gap's; after, so `?1049l`'s restore cannot
  reapply it.
- Ground everything a serialized payload is diffed against, not just the pen:
  SGR, GL, all four G-sets, origin, autowrap, insert, the per-buffer scroll
  region, and the saved-cursor register.
- Switch buffers only when the pane is actually on the other one. `?1049` is
  not a no-op otherwise — it still swaps the kitty flag registers, which would
  park the flags of an agent that negotiated them on the normal screen.
- Return to the normal buffer when the gap ate the TUI's exit sequence; the
  restored history was painting into the alt buffer with scrollback left empty.
- Restore the CAN #14241 dropped, so a control string the gap truncated is
  discarded instead of committed by the next ESC.
- Ground the abandon path exactly once instead of twice.
- Derive the parity/fuzz preambles from the same builder; they had drifted and
  were asserting against bytes production no longer emits.
2026-08-14 02:22:46 -07:00
Langning ZhangandJinwoo-H 92b6ffd17d Terminate renderer graph reload generations and contain disposed-frame notifications (#14070)
* fix(runtime): terminate renderer graph reload generations

* fix(runtime): harden renderer reload teardown

* fix(runtime): fence renderer graph publication ownership

* test(runtime): register renderer graph reload gate

* test(runtime): record live reload validation

* fix(runtime): ignore cancelled renderer navigations

* chore: preserve main formatting during branch sync

* chore: satisfy changed-code quality gate

* fix(runtime): restore cancelled renderer reloads

* fix(runtime): preserve committed reload fencing

* test(runtime): prove cancelled reload timeout

* docs(reliability): record reload cancellation oracle

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-08-14 01:21:00 -07:00
dngur6344 78ca45e2ae fix(renderer): remove duplicate git history tooltips (#14453)
* fix(renderer): remove duplicate git history tooltips

* test(renderer): harden tooltip regression coverage
2026-08-14 00:33:15 -07:00
Jinjing d03eb3218e Revert "fix(renderer): stop git history hover from showing two tooltips (#14468)" (#14485)
This reverts commit 2e91a74deb.
2026-08-14 00:32:43 -07:00
Neil b6d36c26f4 fix(terminal): resolve Ctrl chords positionally on a non-Latin layout (#14469)
macOS maps a control chord by physical key regardless of layout: measured with
UCKeyTranslate, physical A/U under Control produce U+0001/U+0015 on 2SetHangul,
Russian and Greek exactly as on ABC, though unmodified those keys give ㅁ/ㅕ,
ф/г, α/θ. A native terminal inherits this by passing the OS characters through.

The browser does not expose that translation. xterm's legacy encoder works
anyway because it reads keyCode, which Chromium reports from the physical key,
but its kitty encoder derives the key number from `key` and only consults
`code` when Shift or Option is held. Ctrl is not in that gate, so a pane with
the kitty protocol negotiated reports CSI-u for U+3141 rather than 'a' and the
chord does nothing. Ctrl+C escaped this only via its hand-written ETX bypass.

Recover the byte from `code` when `key` is non-ASCII, which reproduces the
OS control table. An ASCII `key` stays authoritative so a Dvorak remap is
honoured. KeyC is excluded: the interrupt policy owns it, and off macOS that
policy declines to a selection so the copy binding wins.

Fixes #13331
2026-08-14 00:02:53 -07:00
Jinjing b3efd48c7f test(e2e): stabilize terminal failure coverage (#14466)
* test(e2e): stabilize terminal failure coverage

* docs(e2e): classify failed-run findings

* rm report
2026-08-13 23:59:18 -07:00
Brennan Benson 9e5ee5ef8e feat(workspaces): rework cleanup discovery and dialog (#13413)
* fix(workspaces): support full cleanup scans

* feat(workspaces): persist cleanup snapshots

* feat(workspaces): add cleanup filter model

* refactor(workspaces): remove cleanup presets

* feat(workspaces): rework cleanup dialog

* fix(workspaces): keep cleanup row ordering render-pure

* refactor(workspaces): simplify cleanup browsing

* refactor(workspaces): show cleanup facts

* refactor(workspaces): surface cleanup row facts

* fix(workspaces): remove misleading cleanup count

* fix(workspaces): preserve full scan semantics

* fix(workspaces): scope snapshot persistence

* fix(workspaces): preserve cleanup browse compatibility

* fix(workspaces): reconcile cleanup dialog state

* test(workspaces): update snapshot store fixtures

* test(workspaces): preserve cleanup scan modes

* perf(workspace-cleanup): stream scan progress and size results

* fix(workspace-cleanup): address review feedback

* fix(workspace-cleanup): preserve host-scoped cleanup metadata

* fix(workspace-cleanup): declare review source dependencies

* fix(workspace-cleanup): align size scan banner

* fix(workspace-cleanup): shorten scan action

* perf(workspace-cleanup): avoid redundant scan IO

* perf(workspace-cleanup): bound restarted evidence scans

* fix(workspace-cleanup): satisfy scan queue lint

* perf(workspace-cleanup): bound scan and snapshot work

* perf(workspace-cleanup): serialize final enrichment

* test(workspace-cleanup): assert final enrichment drain

* fix(workspace-cleanup): stop progress after renderer teardown

* perf: batch workspace cleanup git evidence scans

* perf(workspace-cleanup): stop redundant snapshot and scan work

* fix(workspace-cleanup): resolve review findings across scan, store, and dialog

Correctness:
- Chunk git-evidence dispatches at the shared 500-target limit and exclude
  queued/in-flight ids from target selection, so fleets past the limit can no
  longer strand rows permanently mislabeled as checked-but-unknown.
- Key destructive selection pruning on the user's filter state instead of the
  per-tick matched-set identity; streaming reclassification no longer silently
  deselects rows.
- Clamp the facet clock to max(scannedAt, open time): a stale hydrated
  snapshot no longer misbuckets idle thresholds or keeps dead agents fresh;
  row labels use the same clock.
- Supersede and cancel the previous broad scan when a new one starts (renderer
  registry and same-sender guard in main) instead of racing two fleet scans.
- Gate snapshot persistence on hasTargetedWorkspaceCleanupScan so
  worktreeIds: [] can never persist an empty fleet snapshot.
- Re-apply dismissals at set-time in progress application so a dismissal
  landing mid-enrichment is not clobbered.
- Record a one-off local snapshot prune for single (unbatched) remote deletes
  so removed workspaces cannot resurrect from cache.
- Strip .exe when normalizing foreground process names so Windows agent
  processes match.

Performance:
- Cache per-candidate facet and review-info objects on candidate identity;
  no-op streaming ticks reuse the previous rows array and skip every
  downstream pass; matched-set identity is stable under equal membership.
- Compute facet counts/options only while the filter popover is open.
- Equality-bail git-evidence publishes; structural (non-stringify) facet-group
  comparison memoized in the toolbar.
- Identity-token fast path for the enrichment cache (cache hits skip both
  JSON.stringify signatures); prune viewed/dismissal records on removal and
  expiry; bound the superseded-scan-id set.
- Restore the no-op bail in removeWorkspaceSpaceWorktrees (regression).
- Abort main-side scans when the renderer is destroyed; module-scope
  controller maps survive handler re-registration.
- Batch removal preflight into one targeted scan (with refreshActivity) per
  500 ids instead of one scan per row.
- Scan repos at concurrency 2, report discovered counts upfront for honest
  progress, share fs-activity probes per path (folder workspaces), read only
  the reflog tail, and skip the snapshot read-before-write via a remembered
  scannedAt.

Split workspace-cleanup-worktree-listing, workspace-cleanup-facet-row-caches,
and workspace-cleanup-selection-model out of files that crossed max-lines.

* fix(workspace-cleanup): address verifier findings

- Fall back to a full reflog read when the newest record exceeds the 8KB
  tail window, so an oversized subject cannot hide recent ref activity.
- Bound the single-removal snapshot prune batch id with a UUID; embedding
  the unbounded worktreeId silently failed main's 128-char validation and
  skipped the prune for long remote ids.
- Key the main-side broad-scan supersession by sender AND scan mode so
  legacy suggestion-only and full-workspace scans stay isolated, matching
  the renderer registry.

* fix(workspace-cleanup): own facet caches with useMemo instead of render-time ref writes

React Doctor (CI changed-lines gate) correctly flagged the three cache refs
written during render. Each per-candidate cache now lives in one memo with
the derived context it is keyed on, so the memo deps are the invalidation
and interior fills stay content-addressed; the matched-set identity
stabilization is dropped since its only consumer reads through a
useEffectEvent and never keys on identity.
2026-08-13 23:47:20 -07:00
Jinjing 2e91a74deb fix(renderer): stop git history hover from showing two tooltips (#14468)
Remove native title attributes from commit rows, subjects, and ref
badges so only the managed tooltip appears.
2026-08-13 23:44:43 -07:00
Neil fcd99fc032 fix(sidebar): satisfy extracted hook quality gates (#14467) 2026-08-13 23:40:04 -07:00
Neil 08027d0d23 refactor(sidebar): split WorktreeList into a worktree-list module (#14465)
WorktreeList.tsx was 6.8k lines behind an `eslint-disable max-lines`. Break it
into `sidebar/worktree-list/`: the container keeps store wiring and composition,
the virtualized viewport keeps layout, and the drag, reveal, virtualization,
row-model, and row-render concerns each get their own file. Every file now fits
the oxlint budget, so the suppression and its baseline entry are gone.

Behaviour-preserving. The only deliberate cleanups are duplicate branches folded
into shared helpers (drop-preview state updates, status-hover fallback, the two
identical scroll-to-index reveal branches) and a dead sticky-header-index ref.

Tests that asserted on WorktreeList.tsx source text or imported its named
helpers now point at the module that owns them.
2026-08-13 23:35:41 -07:00
Neil e2e7768cc8 fix(terminal): deliver Ctrl+C when a non-Latin input source rewrites the key (#14462)
With a non-Latin input source the OS reports the layout's own glyph for `key` —
a Hangul jamo on Korean 2-Set, Cyrillic es on Russian — while `code` stays KeyC.
The interrupt policy read `key` for identity and only consulted `code` when
`key` was empty or Unidentified, so a jamo short-circuited it to false: the
press missed the ETX path and was CSI-u encoded instead, leaving a TUI running.

Trust `key` only when it is a Latin letter, which keeps a Dvorak remap of C
authoritative. Otherwise ask the layout map what the physical key produces
unmodified: an IME layered over a Latin layout answers 'c', and over a Dvorak
base answers 'j', which correctly declines. When the map is itself non-Latin it
cannot answer either, so fall back to physical position — how terminals have
always resolved control chords.

Fixes #14460
2026-08-13 23:18:08 -07:00
NeilandOrca b9627e91a9 perf(renderer): parallelize runtime catalog and worktree refresh (#14138)
Co-authored-by: Orca <help@stably.ai>
2026-08-13 23:11:24 -07:00
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00
Jinwoo Hong cd6114ab7e fix(browser): acknowledge paired tab before navigation (#14402) 2026-08-13 22:40:58 -07:00
NeilandOrca 5a6837fce4 refactor(store): unify the duplicated catalog equality and identity-key helpers (#13804)
* refactor(store): unify the catalog structural-equality walks

Three near-identical structural deep-equality walks had landed independently in
the same window: areValuesEqual (#13744, repo-identity-reconcile.ts),
areCatalogEntriesEqual (#13770, repos.ts — already folded into the first on this
branch's base) and catalogValuesEqual (#13662,
worktree-catalog-reconciliation.ts). All three walk plain records and arrays and
fall back to reference equality for anything exotic.

They are not interchangeable. Two axes genuinely differ, and each caller depends
on its own side:

- Own-key set. #13744/#13770 require equal own-key counts plus hasOwnProperty,
  so an absent key differs from a key present and holding `undefined`. #13662
  compares the union of both sides' keys, so those are equal. The strict side is
  load-bearing: the repo/project merges branch on
  `'localWindowsRuntimePreference' in project` (repos-project-runtime.test.ts
  "clears stale local runtime preferences"), and projects are now reconciled
  with this comparator. The loose side is test-pinned by
  worktree-catalog-reconciliation.test.ts "reuses rows with equivalent nested
  catalog data", where a locally built row carries `optional: undefined` that
  the host omits.
- Leaf comparison. #13744/#13770 use `===` (NaN never equal, 0 equals -0);
  #13662 uses `Object.is` (the reverse).

So instead of picking a winner, src/shared/structural-value-equality.ts holds
one walk parameterised by those two axes and exports the two policies as
`structuralValuesEqual` and `structuralValuesEqualIgnoringUndefined`. Every
caller keeps its exact current semantics; the ~40 duplicated lines and the
silent divergence go away. src/shared/persisted-ui-equality.ts (a fourth copy
with a Set branch and no plain-object guard) is deliberately left alone: it
gates a disk write in main with no direct test coverage.

Also folded, all provably behaviour-identical:

- The `${hostId}\0${repoId}` composite key had three copies
  (getRepoHostIdentityForParts, repoOwnerKey, getEntryKey) that must agree or
  repos silently stop reconciling. Moved to src/shared/repo-host-identity.ts
  because one of them lives in src/shared; the renderer module re-exports it.
- mergeFetchedReposForHost's hand-inlined upsert loop now calls mergeByIdentity.
  mergeByIdentity additionally skips replacing a structurally equal row, which
  cannot change the result here: reconcileFetchedRepos runs immediately after
  over the same identities in the same order and restores exactly those rows.
- Renamed repos.ts's `catalogRowsUnchanged` to `arrayElementsUnchanged`. It is a
  pure element-identity compare, two files away from
  `catalogRowsEqual`, which is a full structural compare.

src/shared/structural-value-equality.test.ts pins both policies over arrays,
nested records, null-prototype records, absent-vs-undefined keys, symbol keys,
and non-plain objects (Date/Map/Set/class) falling back to reference equality.

* fix(store): keep merged sourceRepoIds order host-independent

Prefixing the cross-host remainder made a cross-host project's sourceRepoIds
order a function of the refreshing host, so the projects reconcile never reused
the row. Also pins the repo-derived host-id contribution the new per-project
slice feeds the host-id resolvers.

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

* refactor(store): migrate call sites that landed after this branch

github.ts and ai-vault-session-identity.ts began using areValuesEqual on main
while this branch was stale, and repo-identity-reconcile's record reconciler
still called its own deleted walker. All three now use structuralValuesEqual;
reuseEqualCatalogRows keeps its duplicate-id cap and calls the ignoring-undefined
variant, which is the key-union semantics catalogValuesEqual had.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-13 22:15:20 -07:00
Jinjing eb9ac440aa Revert "Center newly opened tabs at end, keep close controls visible" (#14450) 2026-08-13 22:07:53 -07:00
Neil aecce221bd fix(test): deflake relay exec env, project boundary, and speech resume tests (#14446)
Three test-suite problems, all root-caused in the tests rather than in
production behavior.

1. src/relay/agent-exec-handler.test.ts (real failure, not a flake)

The two spawn-argument assertions failed with "Number of calls: 1" — spawn
ran, but the env differed. Cause: both assert
`expect.objectContaining({ ...process.env, ... })`, which demands that every
ambient variable reach the child verbatim. #7986 (1a6abc87d1) changed both
sides at once: it rewrote the assertion from `env: process.env` to that
objectContaining form, and in the same commit made the handler apply
`applyTerminalGitCredentialPromptGuard`, which appends its own entries to
Git's indexed-config protocol (GIT_CONFIG_COUNT / KEY_n / VALUE_n).

So whenever the test runner's own environment already carries that protocol —
exactly what Orca exports into its agent terminals — the snapshot expects
GIT_CONFIG_COUNT=2 while the correctly guarded child gets 4. The test passes
on a bare CI shell and fails when run from a guarded terminal.

The implementation is right: appending the guard after the caller's config is
the documented contract, and "guards wrapped agents after atomically replacing
inherited indexed config" already covers it. Fixed the test instead, by
clearing the guard-owned keys (GIT_CONFIG_* protocol and WSLENV) from the
ambient env for the duration of the suite and restoring them afterwards, so
the passthrough baseline is deterministic. No assertion was weakened or
removed.

2. project-view-wrapper-source-context-boundary.test.ts (flake: 30s timeout)

`buildProjectWorkItem` is a pure function, but it lived in
ProjectViewWrapper.tsx, so importing it pulled in the store, sonner, lucide,
and the whole UI kit — ~8.8s of transform and module evaluation for one
assertion, which tipped past the 30s limit under parallel load.

Extracted it to project-work-item.ts (its only dependency is
githubProjectHost) and pointed the test there. Both test cases are unchanged.
Also dropped the now-unneeded happy-dom environment, since nothing in the file
touches the DOM any more. 9.15s -> 0.12s.

3. model-manager-download-resume.test.ts (flake: 30s timeout)

"bounds a server that advances by pathologically tiny segments forever"
drives the loop to the MAX_TOTAL_DOWNLOAD_REQUESTS ceiling of 4096. Each
iteration did a real writeFileSync plus two statSync calls through
getPartialDownloadBytes — ~12k synchronous filesystem syscalls in a tight
loop. Fast on an idle disk, but it serializes against every other vitest
worker on a loaded machine, which is what blew the per-test timeout.

Stubbed getPartialDownloadBytes to read the byte counter the test already
maintains, so the loop is pure CPU. The file was only ever a stand-in for that
counter. Ceiling and rejection assertions are unchanged: 332ms -> 15ms.

The two remaining ~1.1s cases in that file spend their time in the real 1s
retry backoff around real stream and file-write plumbing; they are left on
real timers because faking them would mean faking the transport too, and 1.1s
leaves ample headroom.
2026-08-13 21:51:55 -07:00
Jinjing 25abb9368d fix(stats): wrap usage breakdown model names (#14065) 2026-08-13 21:25:54 -07:00
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00
Neil 400321edcd fix(workspaces): gate the GitHub palette number match on repo remote identity (#14413)
* fix(workspaces): gate the GitHub palette number match on repo identity

`repoMatchesGitHubSlug` returned the permissive `'unknown'` whenever the repo
displayName was not in `owner/repo` form and no upstream metadata existed — the
common basename-named non-fork case. The caller only rejects on `false`, so a
pasted issue/PR URL could activate a workspace in a different repo that happened
to share the number, since issue/PR numbers are per-repo.

Mirror the GitLab gate from #14381: fall back to the probed
`gitRemoteIdentity.canonicalKey` before giving up, comparing host and owner/repo
after normalizing port, `www.`, and case. An `upstream`-derived identity stays
`'unknown'` because `deriveGitRemoteIdentity` ranks `upstream` above `origin`, so
a fork's own origin is invisible and rejecting would drop URLs from the fork the
user actually checked out.

The canonicalKey compare runs after the displayName branch: displayName is
compared host-agnostically, so mirrors and host aliases of the same owner/repo
keep matching as they do today, and the probed remote only fills in where no
name evidence exists.

Refs STA-4237

* fix(workspaces): keep SSH host aliases matching in the palette identity gate

`git remote -v` reports ssh.github.com, www., and ~/.ssh/config `Host` aliases
verbatim, so comparing a probed canonicalKey against a pasted URL host rejected
legitimate GitHub/GitLab remotes. Normalize the alias hosts both sides can fold
offline, and downgrade a host-only mismatch to 'unknown' when the probed host is
dotless (an unexpandable OpenSSH alias); dotted hosts like ghe.example.com still
lose. Lifts the GitHub host normalizer into shared instead of a third copy.

* fix(repos): keep the www host fold out of the derived project identity

getProjectIdentityKey feeds the persisted Project id, so folding www. there
re-keyed existing projects on upgrade and dropped localWindowsRuntimePreference.
Restrict the fold to the palette's URL-vs-remote comparison, and pin the derived
id for a www. remote so it cannot drift silently again.
2026-08-13 20:28:32 -07:00
Neil d3381e550c fix(terminal): stop leaking raw PTY lifecycle tokens into the error toast (#14415)
* fix(terminal): stop raw PTY-not-found and session-expired tokens reaching the error toast

A reattach the host answers "no such session" for surfaced its wire token
verbatim — `SSH_SESSION_EXPIRED: orca:<conn>@@pty-N` or the relay's raw
`PTY "..." not found` — including the internal PTY id, and invited the user
to file an issue for an ordinary lifecycle event.

Humanize both in the toast's existing daemon-boundary seam and mark them
explained so the issue link is suppressed. The copy stays silent on whether
the remote shell died: absence from the host is not proof of exit.

Refs STA-4238

* style(terminal): apply oxfmt to the toast humanization tests

* fix(terminal): treat the humanized session copy as literal replacement text
2026-08-13 20:28:25 -07:00
Neil 0a0ae974d4 fix(workspaces): gate the GitLab palette number match on repo identity (#14381)
* fix(workspaces): gate the GitLab palette number match on repo identity

After a full GitLab identity compare failed, matching fell through to an
unguarded iid compare, so a pasted URL could activate a workspace from another
project or host. GitLab iids are per-project and start at 1, so small-number
collisions across projects are the norm.

Mirror the GitHub shape rather than rejecting outright: gate the number-only
fallback on a tri-state repo identity check that stays permissive when identity
is unresolvable, so forks and host aliases keep matching. A stored URL that
parses to a different project with the same type and number is a direct
contradiction and is rejected even when identity is unknown.

Refs STA-4155

* fix(workspaces): keep the GitLab repo gate permissive for fork checkouts

deriveGitRemoteIdentity keeps a single remote and ranks `upstream` above
`origin`, so a fork checkout resolves to the upstream project and the fork's
own origin is invisible to the gate. Rejecting on that mismatch dropped MR
URLs from the fork the user actually checked out — a false negative the
tri-state was meant to prevent. Treat an upstream-derived identity as unknown.

Refs STA-4155

* docs(workspaces): note that the GitLab repo identity is a one-shot snapshot
2026-08-13 18:46:36 -07:00
Jinjing b19d99bd00 Center newly opened tabs at end, keep close controls visible (#14314)
* Center newly opened tabs at end, keep close controls visible

- Add end padding to tab strip to preserve close button visibility against fade
- Center scroll position for newly revealed end tabs, avoiding hard scroll-to-end
- Only pin-to-end for new tabs that are also active; restored mid-strip tabs stay in place

* test(tab-scroll): clarify when tabs center vs settle at end

Add lastTabGeometry helper to correctly derive scrollWidth from the pad policy. Update test expectations to document that center requests only land centered when the strip is within 2x the inset of the last tab's width; wider gaps settle at the end with inset.

* fix(tab-bar): sync active tab id ref after render

React Doctor rejects mutating refs during render. Keep the latest
active tab id in a layout effect so overflow navigation still sees
it without an impure render write.
2026-08-13 18:28:41 -07:00
Neil 517a2b3da6 perf(worktrees): bound the duplicate-id scan in reuseEqualCatalogRows (#14271)
* perf(worktrees): bound the duplicate-id scan in reuseEqualCatalogRows

Rows sharing an id are scanned linearly with a deep compare each, so a bucket
of k duplicates costs O(k^2). Both callers key on ids that are unique by
construction, so this is a bound on damage rather than a live fix.

Cap the scan instead of adding a second index: reuse is only an optimization,
so a missed match yields a new object identity, never a wrong row. A fingerprint
index would buy a little more reuse in a case nothing reaches, at the cost of a
second equality implementation that must stay in step with catalogValuesEqual
with no automated guard.

Worst-case duplicate bucket, no matches: k=1000 134ms -> 1.2ms, k=2000 541ms ->
2.2ms. Unique-id path unchanged (2000 rows: 0.86ms both).

* docs(worktrees): lead the duplicate-id cap comment with its reachability

A reader hitting MAX_DUPLICATE_ID_SCAN should learn first that no caller
produces duplicate ids today, so the cap reads as bounding future damage rather
than fixing something live.
2026-08-13 18:14:25 -07:00
Neil eb22e497bb Revert "fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes" (#14395) 2026-08-13 18:11:33 -07:00
Brennan Benson d16092e503 fix(updater): recover renderer shutdown checkpoint (#14373)
* fix(updater): recover renderer shutdown checkpoint

* test(updater): cover checkpoint recovery in Electron

* fix(updater): keep staging failures blocking
2026-08-13 17:06:36 -07:00
Neil 6a0c8fa541 fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes (#14384)
* Reapply #13326 and #13928 (un-revert #14361)

Restores the SSH reattach-identity and daemon-occupancy fixes. Reverting them
reintroduced their P0s, filed as STA-4224, STA-4225, STA-4227, STA-4230,
STA-4232, STA-4233 and STA-4234 against #14361.

The tab loss that motivated the revert is fixed in the commits that follow, so
this reapplication is not a straight redo.

* fix(relay): stop the fallback attach fence refusing a pane that moved tabs

The primary fence was moved to the shell's own incarnation precisely because
paneKey/tabId froze the pane's LOCATION at spawn and refused panes that had
merely moved. The fallback that older clients fall into kept the old rule, so
the correction never reached it — the same 'the rule exists, but this path does
not ask it' leak this work has hit repeatedly.

A refusal here is not recoverable: an identity mismatch never grounds a respawn,
so the pane keeps a live shell it can no longer reach and renders blank.

Narrowed to paneKey, which is the identity; the tab is a location. Restoring the
tabId comparison reddens the new test.
2026-08-13 16:45:24 -07:00
Brennan Benson 45736ca8dd feat(dashboard): give the agent map a real filter panel (#14338)
* feat(dashboard): filter the agent map by multiple hosts

The map's host control was a single-choice segmented row in its own header:
pick All, Local, SSH, WSL, or Remote — never two at once. It also sat outside
the shared filter menu, so the menu's badge never counted it and "Clear all
filters" could not reach it.

Hosts are now checkboxes in the filter menu alongside agent state, so a fleet
split across a Mac and a Windows runtime can show both and hide the rest.
Hosts that contribute no agents are omitted, and the section hides entirely
for a single-host fleet.

Lifting `hostFilter` out of AgentMap also drops `compact`, which existed only
to hide the segmented control next to an open terminal panel.

The filter-option builders move to agent-dashboard-filter-options.ts to keep
AgentDashboardToolbar under the 400-line cap; that move is mechanical.

* feat(dashboard): give the agent map a real filter panel

The map's filters were a single-choice host row in its own header plus a few
rows borrowed from the board's dropdown. This replaces them with one panel that
owns every map facet.

- Quick views: Everything / Needs me / Stuck / Unread / Last 30 min /
  Long runners / Stale > 3d / Orchestration. Each replaces the filters
  wholesale rather than stacking on whatever was set.
- Provider (agent type) checkboxes.
- Three time ranges — session lifespan, since last message, time in current
  state — on a non-linear scale so minutes and days are both reachable.
- Orchestration flows select the coordinator *and* the agents it dispatched;
  a children-only filter hid the half that explains the flow.
- Sections collapse, each showing its value when closed. A section that is
  filtering forces itself open so a collapsed row can never be the reason the
  map looks emptier than the filters claim.
- The shown-count moves into the panel header, next to the controls that
  change it, and now reflects the map's own facets rather than the board's.

The map's surface becomes a Popover, not a DropdownMenu: the panel holds range
sliders and a Radix menu swallows the arrow keys those need. The board keeps
its dropdown untouched via a new `filterControl` slot on the shared toolbar.

`ui/slider` renders one thumb per value so a two-value range works; the two
existing single-value callers are unchanged.

* fix(dashboard): preserve agent map filter semantics

* refine agent map filter facets

* perf(dashboard): keep map filters out of main view
2026-08-13 16:31:32 -07:00
OrcaWinandBrennan Benson e1ee1b3ef3 fix(macos): recover severed terminal TCC attribution after updates (#13992)
* fix(macos): recover severed terminal TCC attribution after updates

When a packaged update leaves the daemon healthy but TCC-severed (spawning
binary gone), surface a Manage Sessions toast and replace the daemon before a
new terminal only when zero live sessions remain. Does not broaden FDA or
auto-kill sessions. Addresses the Orca-specific path of #13594.

* fix(macos): coalesce severed-TCC toast checks and sync i18n keys

Add catalog entries for the Manage Sessions toast strings and guard overlapping
mount/focus probes with an in-flight latch so only one infinite toast can fire.

* test(macos): harden severed TCC recovery coverage

* fix(macos): clear recovered TCC warning

* fix(macos): clarify severed TCC warning copy

* fix(macos): bound TCC attribution health checks

* fix(macos): preserve bounded attribution checks after merge

* test(macos): use real execFile callback contract

* fix(macos): cover legacy daemon attribution

* fix(macos): clarify affected Orca terminals

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-13 16:30:44 -07:00
Jinwoo Hong 77b37d85e2 feat(vm): create workspaces from provisioned SSH roots (#14359)
* feat(vm): use recipe-provisioned SSH roots

* fix(vm): preserve ordinary create failure timing

* test(vm): prepare provisioned root SSH fixture

* ci(vm): enable SSH setup for provisioned root E2E
2026-08-13 16:23:22 -07:00
Jinwoo Hong f9f55075f6 fix(vm): adopt provisioned SSH checkout roots (#14353) 2026-08-13 16:23:22 -07:00
Jinwoo Hong a5a998ea77 fix(vm): make hidden SSH cleanup retryable (#14351) 2026-08-13 16:23:21 -07:00
Brennan Benson c3b8c145e2 fix(agent-status): preserve Codex escape interruption (#14372) 2026-08-13 16:19:26 -07:00
m4air 2f85dda183 Narrow paired browser link fix to registration race 2026-08-13 15:55:23 -07:00
m4air 232e398c35 Fix paired remote terminal browser links
STA-4181
2026-08-13 15:55:23 -07:00
Brennan Benson 953cfab635 fix(terminal): make the bold font weight its own setting (#14368)
* fix(terminal): make the bold font weight its own setting

Deriving bold as max(700, regular + 200) silently destroyed bold. A family
exposes only a few real faces: the monospace the default chain resolves to on
macOS has exactly two, splitting at 600. Measured by rasterizing each weight to
a canvas — 100-500 are byte-identical (ink 3023) and 600-900 are byte-identical
(ink 3855), at every weight the same advance. So any base weight at or above 600
put both values in the same face and bold stopped existing, on 4 of the 9
positions the slider offers, with no error and nothing the user could do.

Arithmetic cannot fix it — on a two-face family there is no heavier face to
escape to. So bold is now user-owned: a new terminalFontWeightBold setting with
its own control, defaulting to 700. The default pair (500/700) straddles the
boundary, so existing profiles render exactly as before; a collision is now a
choice the user can see and undo.

The old test asserted 800 -> {800, 900} as 'keeps bold heavier', which is where
this hid: numerically heavier, identically rendered.

* fix(terminal): surface bold face collisions accurately
2026-08-13 15:32:01 -07:00
Neil 11cd2b4310 revert(ssh): back out #13326 and #13928 — reconnect loses every tab (#14361)
* Revert "fix(daemon): stop killing live coding agents when the daemon can't report its sessions (#13928)"

This reverts commit 2e8cf589de.

Reverted together with #13326: the 1.4.182-daily.202608131439 build carrying
both loses every tab on an SSH disconnect/reconnect cycle. Reverting first so
main stays releasable and the P0 fixes in the wild remain cherry-pickable,
rather than fixing forward on a shipped regression.

* Revert "fix(ssh): stop SSH reconnect from multiplying terminals and resuming agents twice (STA-3077)" (#13326)

This reverts commit 3ab8b6a117.

Reported on 1.4.182-daily.202608131439: connect to an SSH worktree, disconnect
the host from the hosts popup, reconnect — every tab is gone. That is worse than
the behaviour this PR set out to fix, where most tabs were retained.

Reverting rather than fixing forward, so main stays releasable and the P0 fixes
already out in the wild stay cherry-pickable. STA-3077 stays open.
2026-08-13 14:54:29 -07:00
Neil 281cc77e79 fix(ai-vault): make the merged scan stamp independent of leg order (#14270)
* fix(ai-vault): make the merged scan stamp independent of leg order

The all-host merge picked its stamp with a strict `stampMs > latestMs` and
echoed the winning leg's verbatim string. Two legs reporting the same instant
in different legal ISO shapes ("...:05Z" vs "...:05.000Z") therefore resolved
by position in the results array, i.e. by host-enumeration order (local, then
SSH, then runtime). The prior lexicographic max was order-independent, so this
was a regression with no test covering it.

A merge has no single scan instant, so its stamp is derived data rather than
any one leg's string: return the canonical ISO form of the newest accepted
instant. That is order-independent and format-independent, and drops a
variable instead of adding a tie-break branch.

Also share one request resolver between main and the renderer so the
renderer's merged-scope predicate is equivalent to main's routing by
construction, rather than by a comment that overclaimed it.

* docs(ai-vault): scope the merged-predicate comment to the desktop IPC path

The replacement comment still asserted the result is always several hosts'
legs. The paired web transport drops executionHostScope and serves one host,
so 'all' there is a single scan. State that the predicate is deliberately
over-inclusive and why erring the other way would be unsafe.

* test(ai-vault): pin the merged-stamp Date range boundary

new Date(ms).toISOString() throws RangeError outside +/-8.64e15. That is
unreachable only because Date.parse applies TimeClip, so the NaN guard alone
constrains the argument. Nothing pinned that. Dropping the guard now fails
these two cases with the RangeError they exist to prevent.

* refactor(ai-vault): route session-title scope through the shared resolver

The last character-for-character copy of the request-scope default. Leaving
it would make the shared resolver the single source of truth for two of three
sites, which is the drift this change exists to remove. No behavior change.
2026-08-13 14:49:59 -07:00
Brennan Benson 3290ca0d9e feat(agent-map): flare fresh questions and finishes (#14349) 2026-08-13 14:31:13 -07:00
Brennan Benson 162001eef3 feat(sidebar): replace the external-worktrees inbox list with one card (#14341)
The Non-Orca worktrees modal already lists every hidden external worktree
with search, virtualization, and per-row Show. Its filter is a strict
superset of the sidebar inbox's, so the expanded sidebar list was a second,
worse copy that grew to ~900px at 24 worktrees.

The inbox is now a single clickable card stating the count, which opens that
modal. Drops the expand state, nested list, per-row Import, and the
Keep hidden / Import all footer.
2026-08-13 14:04:09 -07:00
Kevin Espiñeira 2f0c33757d fix(worker-start): match Codex effort ceilings (#14281)
Honor the advertised reasoning-effort ceilings for Codex models, preserve conservative unknown-model handling, and localize the new ultra effort label.
2026-08-13 13:58:41 -07:00
Brennan Benson 68a9dfa6e3 feat(dashboard): give the agent question state its own color and glyph (#14248)
* feat(dashboard): give the agent question state its own color and glyph

On the agent map, 'working' (yellow-500) and 'waiting' (amber-500) sat 16 hue
degrees apart and rendered as identical halo rings, so the only cue separating
"busy, leave it alone" from "it is asking you something" was a hue step most
people cannot resolve at map zoom. Every other surface distinguishes the two by
shape (spinner vs question glyph); the map had dropped that.

Move 'waiting' to orange-500 — midway between working-yellow and blocked-red —
and give the map node the same question glyph the sidebar and tabs already use,
so the state reads by shape when hue fails (low zoom, red-green CVD).

Both live behind one token, --agent-question, plus a shared AgentQuestionIcon,
so the sidebar, terminal tabs, kanban, toolbar and map cannot drift apart again.
The unread amber pip is deliberately left alone: "new output" and "needs an
answer" are different states and now read as different colors.

* test(dashboard): retarget the agent-row question assertion at the shared token

DashboardAgentRow renders the glyph through AgentStateDot, so the component
already moved with the token — only its assertion still pinned text-amber-500.
Missed locally because I ran components/dashboard-popout but not
components/dashboard.

* fix(dashboard): raise light question marker contrast

* perf(dashboard): keep question badges on the SVG paint path
2026-08-13 13:58:18 -07:00
Brennan Benson 820fe5b85c fix(sidebar): remove non-Orca source icons (#14345) 2026-08-13 13:57:07 -07:00
Jinwoo Hong f0c1503326 test(renderer): cover Agent Dashboard status replay fanout (#14036) 2026-08-13 12:48:47 -07:00
Brennan Benson 0824ed39ea fix(terminal): clear the SGR pen on hidden-output restore and abandon (#14241)
* fix(terminal): clear the SGR pen on hidden-output restore and abandon

The hidden-delivery gate drops renderer-bound PTY bytes while a pane has no
visible view. The renderer's xterm is a separate emulator from the daemon
model, so when the dropped span contains the sequence closing an attribute run
(e.g. the ESC[22m ending a bold run) the renderer's pen stays latched while the
daemon model stays correct. Neither recovery path cleared it:

- buildMainModelSnapshotReplayWrites reset the pen on the two alt-screen
  branches but not on the normal-buffer branch, and replayed scrollbackAnsi
  ahead of the reset it did emit, so replayed content inherited the stale pen.
- abandonHiddenOutputRestoreAndDrainPendingForeground declares the dropped
  bytes unrecoverable (it writes a user-visible warning) and then drained the
  queued foreground chunks straight into xterm under that same unknown pen.

Add RESET_GRAPHIC_RENDITION and emit it ahead of replayed content in every
branch, and on both abandon exits. The existing profiles all clear DEC mode
bits and none touched SGR.

* fix(terminal): also restore charset designation after a dropped-byte gap

A gap can strand more than the pen: a dropped `ESC(B` leaves line-drawing
selected and ordinary text renders as box characters. Route both recovery
paths through one RESET_AFTER_BYTE_GAP profile covering SGR + charset.

Deliberately not a soft reset (DECSTR): xterm's DECSTR wipes kitty flags and
stacks (terminal-kitty-keyboard-mode-tracker applySoftReset), which would
silence Option chords for a live agent that negotiates them only at startup.
Reset what a gap strands and no running TUI re-asserts on its own; leave the
rest to its next repaint.

* fix(terminal): close the emulator state gap where the drop is announced

The restore-needed marker is the single point where "renderer-bound bytes
were dropped" is known. The handler already resets the transport's
cross-chunk parser state there for exactly this reason — a partial escape
spanning the gap would corrupt the next chunk. The emulator carries state
across chunks in the same way, so reset it in the same place.

That makes restore, abandon and overflow all start from a known pen by
construction, instead of each recovery path having to remember.

* fix(terminal): fully ground byte-gap recovery state

* fix(terminal): reset state when remote restore re-arms

* fix(terminal): keep the gap reset on the warning abandon path

The reset had been folded into an else of the unavailable-warning branch, so
the primary abandon path relied on the marker's earlier reset still standing.
It does not always: this function captures a replayingSnapshot, so it can run
after a partially-applied replay has already moved the pen, and the warning
itself is plain text carrying no SGR. Restore the unconditional write, guarded
only against the remote re-arm which writes its own.

* fix(terminal): scope the byte-gap reset to the pen and skip it under flood

Two regression risks in the widened recovery reset, both removed:

- The profile had grown to cancel partial escapes, close OSC 8 and re-designate
  all four ISO 2022 registers. Each changes what a live TUI sees on a path that
  runs in production, and none has a reported symptom behind it — a legitimately
  line-drawing TUI that does not re-designate after recovery would render box
  characters as ASCII. Scope back to SGR, which is what the field reports show.

- The marker-time reset ran before the flood-backpressure guard, so a flood
  wrote one reset per marker in exactly the case that guard exists to damp. Move
  it after; the flood path repaints through buildMainModelSnapshotReplayWrites,
  which grounds the pen itself, so no coverage is lost.

Coverage verified non-vacuous: blanking RESET_AFTER_BYTE_GAP fails 5 tests
across all four paths (replay branches, marker, abandon-with-warning, remote
re-arm).
2026-08-13 12:11:20 -07:00
Brennan Benson 096d1a46c4 fix(agent-map): hold orchestration chevrons at a fixed pitch (#14256)
* fix(dashboard): hold agent map orchestration chevrons at a fixed pitch

CHEVRON_SPACING only chose how many chevrons to draw; placement then divided
the edge evenly, so the pitch grew with the distance between agents and, past
the 32-chevron cap, grew without bound. Step at a literal 8px pitch instead,
centering the run so it never overhangs either node.

Fixed pitch makes length drive the chevron count, so cap coverage now decides
how far the run reaches: at 32 it spanned only 248px and every longer link
would have shown a chevron cluster stranded mid-edge. Raise it to 256 (~2048px,
past any real in-project link) and memoize the generated path per endpoint
coordinates, since the scene rebuilds every path string on each zoom frame and
on 4Hz snapshot refreshes while world positions stay put.

* test(agent-map): isolate lineage path cache coverage
2026-08-13 11:58:13 -07:00
Brennan Benson 99f9a3c43d fix(mobile): keep terminal identity scoped to leaves (#14247)
* fix(mobile): scope terminal identity to leaves

* fix(mobile): preserve leaf title projection
2026-08-13 11:44:25 -07:00