Commit Graph
5934 Commits
Author SHA1 Message Date
Brennan Benson d519e856ba feat(agent-status): reconcile provider turn evidence 2026-09-15 11:04:48 -07:00
Neil 231e805b1e fix(lint): enable anti-slop/no-shape-in-symbol-names (#20785)
Flip `anti-slop/no-shape-in-symbol-names` from "off" to "error" and clear
every violation under src, config, tests and mobile.

What the rule bans
------------------
The case-insensitive substring "shape" in any JS/TS identifier: variables,
functions, parameters, types, type parameters, class members, private names,
object-literal keys and JSX identifiers. The one exemption is a statically
accessed member read owned by another value (`zodObject.shape` is fine), so
third-party APIs stay readable without a suppression.

"Shape" names a value's structure rather than its domain role. `UserShape`,
`validateArgShape` and `errorShape` all tell you the symbol is "an object
with some fields" -- which is already what a type says -- while saying
nothing about what the value is for or who owns it. The rule forces the
name to carry the domain instead.

Violations fixed
----------------
689 violations across 109 files at baseline (verified by re-running the
audit against the pre-change tree with the rule set to "error").

Fix pattern
-----------
Rename for the domain role, not the structure:

  -type FieldShape = 'list' | 'map' | 'whole'
  -const FIELD_SHAPES = { ... } satisfies Record<keyof Observation, FieldShape>
  +type FieldEncoding = 'list' | 'map' | 'whole'
  +const FIELD_ENCODINGS = { ... } satisfies Record<keyof Observation, FieldEncoding>

  -function assertGitPushTargetShape(target: unknown): void
  +function assertValidGitPushTarget(target: unknown): void

  -function describeReadDirPathShape(p: string): ReadDirPathKind
  +function classifyReadDirPath(p: string): ReadDirPathKind

Predicates became statements about the value (`isDeltaShapedProviderFrameKind`
-> `isDeltaProviderFrameKind`, `isDeleteShapedDiscardEntry` ->
`discardDeletesEntryFile`, `isSkillsCliAgentKeyShaped` ->
`isUsableSkillsCliAgentKey`). Type aliases dropped the suffix where the
remaining name was already unambiguous (`GhGraphqlErrorShape` ->
`GhGraphqlError`).

No wire-visible name was renamed: no IPC or RPC channel, stream opcode,
request/response param, persisted field, or i18n key. The `--shape=symlink|copy`
CLI flag read by .github/workflows/skill-update-roundtrip.yml is unchanged --
only the local variable holding it was renamed.

Exemptions
----------
They are file-scoped entries in config/oxlint-anti-slop.json, not inline
`oxlint-disable` comments. An inline directive naming an anti-slop rule reads
back as an UNUSED directive under the root lint scan, which does not load this
plugin -- the changed-code quality gate counts that warning, so the comment form
cannot be used for a rule that lives only in this config.

* src/renderer/src/components/browser-pane/annotate/**:
  in the screenshot annotator a "shape" is the drawn geometry -- pen, arrow,
  rect, ellipse, highlight. That is a genuine domain noun, and it pervades
  every symbol in the module.
* repo-icon.tsx, repo-header-project-actions.tsx, mobile MobileRepoIcon.tsx:
  lucide exports the icon component as `Shapes`. The name is theirs, and the
  matching REPO_LUCIDE_ICONS key is the persisted icon name shared with the
  desktop picker -- renaming it would orphan saved repo icons.
* src/shared/onboarding-state-types.ts, src/shared/constants.ts:
  `shapedSidebar` is a persisted onboarding-checklist field and a telemetry
  enum member; renaming it would orphan saved state.
* src/shared/rpc-contract/rpc-send-params.ts: matching zod's own literal `shape`
  property is what selects the ZodObject branch of the conditional type.

No exemption was added merely to avoid a rename. Eight symbols initially
suppressed as "a cross-module refactor outside this change" were proven to have
zero non-TypeScript references repo-wide and renamed instead.

Zod's `ZodRawShape` needed no exemption at all: `Readonly<Record<string,
z.ZodType>>` is its definition, so repo-update-params.ts and
ui-update-value-tolerance-params.ts spell it out instead. Likewise
telemetry-event-classification.ts now reads `.shape` through an `in` narrowing,
which also retires two pre-existing type assertions; three more assertions the
rename had dragged onto changed lines (two `JSON.parse` sites, one node:sqlite
row read) became annotations and an explicit row mapping.

Verified
--------
* Audit reports zero violations; confirmed the rule genuinely fires by
  planting a probe violation.
* node config/scripts/run-typecheck-projects-in-parallel.mjs exits 0.
* Vitest over src/shared, src/main/github/project-view, the annotate module,
  the repo-icon components and the Chromium SameSite electron spec: all green.
* All 66 removed "shape" identifiers grepped repo-wide across every file type;
  none survive.
* node config/scripts/generate-rpc-params-catalog.mjs --check exits 0.
* node --check on every changed .mjs; oxfmt clean on all changed files.
* `pnpm run check:code-quality:changed` reports 0 findings.

Not machine-verified: the 3 mobile/ files (its Vitest run cannot resolve
`expo/tsconfig.base.json` in this worktree), and the WSL- and Playwright-gated
specs. All are rename- or comment-only hunks, read in full.
2026-09-15 02:00:27 -07:00
Neil bfdec26352 fix(lint): enable anti-slop/no-object-parameters (#20781)
The rule rejects the broad `object` type on any function input (declarations,
expressions, arrows, methods, call/construct signatures, function types), plus
local aliases and unions that resolve to `object`. `object` accepts every
non-primitive while exposing no properties, so it documents nothing and pushes
callers into assertions at the boundary.

Fixes all 185 violations across src, config, tests and mobile, and flips the
rule from "off" to "error" in config/oxlint-anti-slop.json.

Approach: replace each `object` input with the type its owner already has.
Most sites took an existing domain type or a type-only import (36 added);
40 new aliases name shapes that had none. Where a value is genuinely only
compared by reference, it gets a named identity token instead of a shape --
`Record<string, never>`, the built-in `WeakKey`, or a `unique symbol` brand,
matching the branding already used in src/shared. Same treatment for WeakMap
and Map key parameters. Two `as unknown as` casts became unnecessary once the
parameter carried a real type and were removed; no new casts were added.

Suppressions added: none. No `oxlint-disable` for this rule anywhere, and no
max-lines disable or per-file bump.

Three files sat exactly at their max-lines cap, so the added type imports were
made line-neutral rather than suppressed:
- src/main/ipc/browser.ts exports the existing guest-registration args type
  (renamed BrowserGuestArgs) so browser.test.ts reuses it on one line.
- pane-scroll.ts takes TerminalScrollIntentTarget through the existing
  pane-manager-types import via a type-only re-export.
- direct-rpc-client.ts drops the identity parameter entirely: the session
  check moved into the sendProbe callback that owns the token.

Verified: anti-slop config reports zero violations over src config tests
mobile; run-typecheck-projects-in-parallel exits 0; 144 affected test files
pass (1749 tests); oxlint and oxfmt clean on all changed files. Mobile has no
runnable test/typecheck target in this worktree (expo is not installed), so
its 6 files were typechecked against a standalone config and diffed against
the base branch -- error sets are byte-identical, including test files.
2026-09-15 01:59:58 -07:00
Neil f107499e44 fix(lint): enable anti-slop/no-reflect-get (#20786)
`anti-slop/no-reflect-get` rejects every call to `Reflect.get`. The
reflective read bypasses ordinary property access and throws away the
type evidence the compiler would otherwise give you: the result is
`any`/`unknown` with no narrowing, so a typo in the key or a shape drift
in the source object is invisible until runtime. The rule's remedy is to
parse dynamic input into a named domain type (or narrow it with `in`)
and then read the field normally.

Baseline: 86 violations across 67 files. Now zero unsuppressed
violations under
`npx oxlint --config config/oxlint-anti-slop.json --ignore-pattern 'config/oxlint-plugins/anti-slop/**' src config tests mobile`.

Fix pattern
-----------
44 of the 86 were rewritten. The dominant shape was an `unknown` value
read through `Reflect.get` right after a `typeof === 'object'` guard;
those became `in`-narrowed property access, which TypeScript checks:

  - Reflect.get(value, 'agents')
  + 'agents' in value ? value.agents : null

Two further shapes:
- `Reflect.get(Object(x), 'k')` on a possibly-primitive envelope became a
  small named reader that boxes once and indexes a
  `Record<string, unknown>` (`settingsField` in
  mobile/src/transport/settings-read-operations.ts).
- Tests reaching into private state moved to TypeScript's checked
  bracket-index escape hatch (`runtime['layoutQueues']`), or to a
  documented read-only accessor on the owning class
  (`SearchSubprocessLineAccumulator.retainedCapacityBytes()`,
  `CodexSubagentExecutions.retentionSizes()`).

No type assertion was added anywhere: the diff contains zero net-new
`as` casts, `as any`, `as unknown as`, `@ts-ignore`, or
`@ts-expect-error`, so nothing was laundered into the sibling
assertion rules.

Suppressions
------------
42x `// oxlint-disable-next-line anti-slop/no-reflect-get` across 38
files. Every one is the default-forward branch of a `Proxy` `get` trap:

    get(target, property, receiver) {
      ...
      return Reflect.get(target, property, receiver)
    }

`Reflect.get(target, property, receiver)` is the only construct that
forwards with correct `receiver` semantics; `target[property]` invokes
an accessor with the wrong `this` and silently breaks getters that read
sibling state. There is no typed alternative, so these are suppressed
rather than rewritten.

3x `// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
-- declaration merging requires interface` in
tests/e2e/github-url-smart-input-transition.spec.ts,
tests/e2e/linear-url-workspace-entry.spec.ts, and
tests/e2e/worktree-active-delete-scroll-position.spec.ts. Replacing
`Reflect.get(window, 'x')` with typed `window.x` requires a
`declare global { interface Window }` block, and `interface` is
mandatory for declaration merging. Matches the existing convention at
tests/e2e/helpers/runtime-types.ts:63.

1x `// eslint-disable-next-line no-var -- main-process gate handle for
this spec` in tests/e2e/project-group-creation-visibility.spec.ts, for
the same reason a `var` global is needed to type the handle. Matches
tests/e2e/agent-session-log-tail-stability.spec.ts:24.

Also updates two source-text anchors in mobile's rpc-recording mutation
harness (mobile/src/test-support/rpc-recording/operation-mutations.ts
and recording-runner.test.ts), which pin the exact text of the rewritten
line in settings-read-operations.ts and would otherwise fail with
"Mutant anchor matched 0 sites, expected 1".
2026-09-15 01:24:30 -07:00
Neil f7b2736d6d fix(worktree): block removal when the archive hook fails (#20153)
* fix(worktree): block removal when the archive hook fails

A repo's orca.yaml archive hook is the user's last chance to save work off a
checkout Orca is about to delete. A failed hook was logged as advisory and
stepped over, so the removal went ahead with nothing archived — and the caller
could still be told it succeeded.

The hook is now a blocking precondition, evaluated while the checkout, its Git
registration, its agents and Orca's ownership evidence are all still intact: it
sits ahead of the registration re-read, the lock/dirty preflights, stopPtys()
and removeWorktree in every orchestrator that runs it.

Failure is typed (worktree_archive_hook_failed) and carries the worktree path,
outcome, exit code where one was observed, and the hook's output. unverifiable
stays distinct from exited, so loss of contact is never read as a pass. The
waiver rides its own field at every layer and is never implied by --force, which
already carries the PTY-stop waiver; when used, the waived failure comes back on
result.archiveHookOverride rather than being swallowed.

worktree.archive-failure-blocking.v1 is advertised so an integration can tell
"accepts --run-hooks" from "safely propagates a failing hook" without risking the
data loss to find out. The runtime's SSH path cannot run a hook at all, so rather
than delete with the archive step silently skipped it refuses — waivable like
every other refusal here. #18563 retires that gate by making the path run the
hook for real.

Stacked on #20559, which makes a timed-out hook report honestly; without it a
hook that traps SIGTERM and exits 0 would defeat this gate.

Fixes #19334

* fix(worktree): close the skip-confirm dead end and the client/hook timeout gap

Four review findings on the gate.

A retry from the failure toast could fail for a DIFFERENT reason than the one
the user had just answered, and that second failure got a bare toast with no
buttons. With skipDeleteWorktreeConfirm set, the delete helpers pass no force, so
waiving a failed archive hook on a dirty checkout landed on the dirty preflight
and stopped there. Retry failures now re-enter the same failure toast, so every
retry stays as actionable as the first attempt. Third instance of this class.

The renderer gave worktree.rm a 60s budget while an archive hook may run for
120s. A hook that took 90s and succeeded timed the client out and reported
failure while the host went on to delete — telling the user their delete failed
and their checkout was gone. The budget is now derived from the hook's, and only
when a hook can run.

The SSH fail-open is logged rather than silent, and the capability's doc comment
scopes what it claims: a hook that RUNS and fails cannot delete the checkout; it
is not a promise the hook was found.

The SSH owner-resolution test now reads a real remote orca.yaml through a stubbed
provider and asserts the returned script is the remote one. It previously stopped
at the lookup key, which is the coverage that let this path break twice. It fails
against the row-only resolution.

* fix(worktree): name a signalled hook exit, and state why prunable cleanup skips the gate

Two things the rebase onto #20617 and #20576 surfaced, both found by rerunning
the real-repo harness rather than by reading the diff.

- #20617 added a registration-cleanup branch that returns before the archive
  gate. That ordering is correct — both of its arms describe a row with no
  checkout behind it, so there is nothing to archive and running the hook would
  fail on the missing cwd — but the gate's ordering invariant is documented, so
  the exception should be too.
- A signalled hook reported `Command failed with exit code null.`, which reads
  as a reporting glitch rather than the `unverifiable` verdict it is about to
  produce. It now says the command was terminated without reporting an exit
  code. Introduced by #20576; the withheld `exitCode` itself was always right.

Fixes #19334
2026-09-15 01:19:32 -07:00
Jinjing 6fe140ded8 Report clipboard and composer drop failures (#20795)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.

* fix(source-control): surface a failed notes copy instead of swallowing it

* Simplify diff comment notes copy error message

Replace parameterized translation template with a direct string. Add
explicit type annotations in tests to improve type safety.

* Sanitize clipboard write error messages for user display

- Only user-friendly messages for recognized errors
- Native failures logged but not exposed to UI
- Prevents information disclosure (CWE-209)
2026-09-15 00:57:23 -07:00
Jinjing 0569ca4cdc Improve microphone permission errors and drop failure reporting (#20801)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.

* fix(settings): say when the microphone is blocked and where to grant it

* Use generic stream for microphone permission requests

- Request generic audio stream instead of saved device to handle stale
  device IDs (unplugged microphones). This ensures the initial permission
  grant succeeds even if the previously saved device is no longer
  available.
- Refactor error handling to not require instanceof checks, supporting
  errors thrown as plain objects and improving robustness across browsers.
- Simplify tests with proper typing and add coverage for stale device and
  permission error edge cases.

* fix type check

* minor type fix
2026-09-15 00:55:28 -07:00
Neil 22ce8d69a1 fix(lint): enable anti-slop/no-module-mocking (#20783)
The rule rejects `vi.mock` / `vi.doMock` / `vi.unstable_mockModule` and the
`jest` equivalents, on the argument that a test which rewrites the module graph
asserts against a stand-in the production code never sees. It is already off for
`**/*.test.{ts,tsx}`, `**/*.spec.{ts,tsx}`, `tests/**` and `**/__mocks__/**` via
the existing override in config/oxlint-anti-slop.json; that override is
unchanged here. What the rule actually catches is module mocking that has drifted
out of a spec and into a first-party `.ts` support module, where nothing marks it
as test-only.

73 violations at baseline, all of them in test-support code. 9 were relocated
back into spec files the override already exempts; the remaining 64 sit in 10
files that are test-only but do not match the override globs, and carry a
file-level disable naming the rule and the reason.

Relocated:
- terminal-hydration-store-test-bootstrap.ts: the sonner / sync-runtime-graph /
  pty-transport `vi.mock` calls moved into the two specs that import it
  (terminals-hydration-canonical-rows, terminals-hydration-canonical-pty-overlap).
  Vitest hoists `vi.mock` inside a test file, so registration is strictly earlier
  than the previous module-eval-time call; the bootstrap keeps only the preload
  API proxy. Both importers were updated.
- ipc-events-ssh-authority-test-fixtures.ts: the 6 direct-ssh `vi.doMock` calls
  moved into useIpcEvents-agent-status-ssh-authority.test.ts as a local
  `stubDirectSshModules()` helper, which also de-duplicates the three copies the
  spec already had inline. The fixture now returns the store state and coordinator
  doubles it builds, typed via the exported DirectSshReconnectCoordinatorDouble.

Suppressed, with justification (each is `/* oxlint-disable
anti-slop/no-module-mocking -- ... */`, rule named, no blanket disable):
- config/scripts/headless-serve-shutdown-matrix.test.mjs (1) - a genuine Vitest
  spec that the override misses only because its globs say {ts,tsx}. The script
  under test is a top-level CLI module; the alternative is spawning real docker.
- src/main/codex-accounts/runtime-home-service-test-harness.ts (1) - stubs one
  probe predicate in ../pty/shell-startup-env, imported directly by several
  main-process readers; 17 specs share it.
- src/main/computer/desktop-script-provider-test-harness.ts (2) - stubs
  child_process/fs-promises for a provider that shells out; 8 specs share it.
- src/main/github/work-item-search-test-harness.ts (4) - one consumer lives in
  tests/e2e, where the relative mock ids resolve differently, so moving the calls
  into the specs would silently stop mocking there.
- src/renderer/src/components/automations/automations-page-test-harness.tsx (14)
  - the mount rig for 10 AutomationsPage specs.
- src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts
  (1) - stubs refreshWebRuntimeSessionTabsSnapshot, imported directly by several
  renderer runtime modules; 18 specs share it.
- src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts (7) -
  stubReactSyncEffect/stubAuxiliaryModules, shared by 11 specs.
- src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts (11) - stubs
  and hook invocation are one unit; 4 specs share it.
- src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts (13) - its
  only spec is at 799 of an 800 max-lines budget.
- src/renderer/src/hooks/ipc-events-test-harness.ts (10) - shared by 8 specs.

No violation was converted to real dependency injection, and no max-lines disable
was added.

Verified: the audit command exits 0 with no output (and reports errors on a
planted probe, so the rule is live); node config/scripts/run-typecheck-projects-in-parallel.mjs
exits 0; 354 spec files / 2506 tests covering every importer of every touched
file pass. No mobile/ file was touched.

The changed-code quality gate's root Oxlint scan runs without --config so it never
loads the anti-slop JS plugin, which made all 10 of those file-level suppressions
read as "Unused oxlint-disable directive". check-changed-code-quality.mjs now
exempts directives naming an anti-slop rule from that unused-directive warning,
the same carve-out isCastingDirectiveUnusedWarning already makes for the casting
suppressions the casting config enforces. Such a directive can never suppress a
root-config rule, so nothing the root scan would otherwise report is hidden;
audit:anti-slop remains the scan that enforces the rule.
2026-09-15 00:41:17 -07:00
Neil 49e5fa597a refactor(lint): enable anti-slop/no-reflect-apply (#20782)
`anti-slop/no-reflect-apply` rejects `Reflect.apply(fn, thisArg, argsArray)`.
It defeats the call-signature checks TypeScript applies to an ordinary call:
the args array is checked as an array, not positionally against the callee's
parameters, so arity and type errors pass silently. Dynamic dispatch belongs
behind a named interface, not behind a reflective call.

Flipped the rule from "off" to "error" and cleared all 17 baseline violations
across `src config tests mobile` (16 sites; one file had two).

Fix pattern: `Reflect.apply(fn, recv, args)` becomes `fn.call(recv, ...args)`,
or a direct method call when the implicit receiver is already the right object.
The receiver is preserved at every site.

Where the callee is a captured built-in whose overloads split on an argument's
shape (`String.prototype.split`, `JSON.stringify`), a call-signature capture no
longer compiles once the args are passed positionally. Those three sites capture
the function through a method-shaped type
(`{ split(separator: unknown, limit?: number): string[] }['split']`), which keeps
the forwarding call checked rather than asserted.

Behaviour notes:
- `diff-section-layout.test.ts` drops a `limit === undefined ? [sep] : [sep, limit]`
  conditional. Equivalent: `String.prototype.split` maps an undefined limit to
  2^32-1, and the `Symbol.split` path forwards undefined either way.
- `workspace-space-compaction.test.ts` forwards `reduce`'s two arguments unchanged,
  so the `arguments.length >= 2` initial-value branch is unaffected.
- `agent-session-history-byte-accounting.test.ts` is the one site where the receiver
  is not literally preserved (`JSON` -> undefined). `JSON.stringify` never reads
  `this` per spec, and restoring `.call(JSON, ...)` would reintroduce the overload
  failure under strictBindCallApply.

No suppression comments added — the rule has zero `oxlint-disable` sites.

`Reflect.apply` still appears at electron.vite.config.ts:159, inside a template
literal of generated bootstrap source. That is string content, not lintable code.
2026-09-15 00:10:11 -07:00
Neil c9ae17fe3d fix(lint): enable anti-slop/no-unknown-type-aliases (#20784)
Flips anti-slop/no-unknown-type-aliases from "off" to "error" and fixes the
3 baseline violations.

The rule rejects a named type alias whose resolved type is `unknown` (directly,
through another alias, through parentheses, or as a member of a union). Such an
alias is strictly worse than writing `unknown`: it reads like a real domain type
at every use site while accepting anything, so the compiler stops helping and
readers are actively misled. `unknown` is fine, but it must stay visible at the
boundary that actually parses it.

Violations fixed (3 at baseline, 5 source files touched):

- src/main/runtime/workspace-session-failed-write-rollback.ts
  `type RollbackValue = unknown` -> a real recursive JSON-shaped union
  `RollbackSlot` (primitives | null | undefined | typeof MISSING |
  readonly RollbackSlot[] | RollbackRecord), with a named
  `type RollbackRecord = { readonly [key: string]: RollbackSlot }`.
  The record is a named alias rather than an inline index signature because
  inline violates typescript/consistent-indexed-object-style, `interface`
  violates consistent-type-definitions, and `Readonly<Record<..>>` trips
  TS2456 circular-reference. The named alias satisfies all three.

- src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts
  `type DirectSshReconnectTimer = unknown` -> `ReturnType<typeof setTimeout>`,
  the handle that actually flows. `DirectSshReconnectTargetState.timer` is
  widened to `DirectSshReconnectTimer | null` to match the state machine, which
  initializes to null and resets to null in the scheduled callback.

- src/renderer/src/hooks/direct-ssh-host-hydration.ts
  `type HostReadTimer = unknown` -> `ReturnType<typeof setTimeout>`.

Fix pattern throughout: replace the alias with the type that already flows
through the code, never with `any` and never with a relabelled `unknown`.
Because the timer aliases are now honest, two pre-existing
`as ReturnType<typeof setTimeout>` casts at the clearTimeout boundaries could be
deleted, a net win under the repo's type-assertion policy.

Suppressions added: none. No eslint-disable, oxlint-disable, `any`, or `as`
cast was introduced anywhere in this change.

The diff is type-annotation-only; no runtime statement changed.
2026-09-15 00:02:04 -07:00
Neil ef39f32d4f test(native-chat): split the windowing test harness out of the suite (#20773)
#20719 grew NativeChatMessageList.windowing.test.tsx to 897 effective lines,
past the 800 ceiling for test files, so oxlint fails on main.

Moves the shared layout/ResizeObserver stubs into
native-chat-windowing-test-harness.tsx. No test was changed, split or dropped:
still 5 describes and 23 it() blocks, 29 assertions passing. The stubs' mutable
knobs become one exported `layout` object because an imported binding cannot be
reassigned across modules.

AGENTS.md forbids a max-lines disable, so extraction is the fix.
2026-09-14 21:35:48 -07:00
Brennan Benson 438603f9e7 feat(native-chat): add a message rail for jumping between your prompts (#20719)
* feat(native-chat): add a message rail for jumping between your prompts

A vertical rail down the right edge of the transcript, one bar per user
message, with the bar for the turn you are reading highlighted once
scrolling settles. Hovering the rail opens a panel that previews every
prompt and jumps to it on click.

Bars are capped at 20 and sampled evenly across the thread, always
keeping both ends and the active bar, so the rail stays readable at a
glance on a long conversation.

The active bar is resolved from virtualizer offsets rather than by
scanning rendered rows: the transcript is windowed, so an off-window row
has no element to measure. The row at the scroll fold resolves to its
owning prompt through turnKey, which is what keeps your own message lit
while you read a long reply instead of going dark.

Jumps reuse the existing reveal/pin path and scrollMessageToTop, which
releases the bottom pin. Scrolling through the virtualizer directly would
leave a reader snapped back down by the next streamed token.

Ticks cover loaded history only; older prompts gain a bar once "Load
earlier messages" pages them in.

* fix(native-chat): service a rail jump once and give its pin back

The rail borrowed the diff reveal's pin to reach a row the window had left
behind, but copied only its state shape, not its consumption. The request
was never cleared and the effect depended on `slots`, which is rebuilt on
every render, so three things went wrong at once:

- every later render re-scrolled to the jumped message, dragging a reader
  back there for the rest of the pane's life, and forcing the bottom pin
  off each time;
- the standing request outranked `revealedDiff` in the shared pin, so
  revealing a diff outside the window silently stopped mounting its row;
- the pinned row stayed mounted and measured indefinitely.

The request now carries a monotonic id, is serviced once, and is released
as soon as the scroll is issued, which hands the pin back.

The rail's scroll listener had the same churn: it listed `items` in its
deps, so a streaming turn tore the listener down and cancelled the pending
idle timer on every frame and the highlight never settled. It now
subscribes once and re-reads on a key built from the prompt ids.

Also: the hover trigger is a real button, because `asChild` discards the
primitive's focusable trigger and the panel is the only way to reach these
messages; the wheel forwarder honours line and page delta modes rather
than treating every delta as pixels; and the e2e panel assertion is exact,
since a loose bound passed at 20 rows against 20 ticks.

* fix(native-chat): make prompt rail accessible and reuse previews

* fix(native-chat): supersede prior navigation when selecting a prompt
2026-09-14 18:31:40 -07:00
Brennan Benson ff5b1a5a05 fix(native-chat): preserve detached transcript position during growth (#20710)
* fix(native-chat): stop the transcript following an end it measured short

The virtualizer compensates a row's measured size change by moving scrollTop
whenever it believes the view was already at the end. It decides that from the
spacer's own height minus a container-absolute offset, so the distance it
computes is short by everything in the document outside the spacer: the
transcript's top gutter, the "load earlier" block while older history is still
pageable, and the trailing chrome. A reader sitting ~100px above the bottom
therefore measured as "at the end", and every row that settled below them
dragged them down to it.

Measured in the windowing harness with a 92px gutter and 24px of trailing
chrome: a reader parked 96px above the end is pulled to the end on the first
growth frame, scrollTop 9261 to 9357.

The same option gates following an append, but that path measures the true
document distance, so it was never wrong, only redundant. The transcript
already decides whether to follow the end from the scroll container's real
geometry, and it re-pins once the growth is in the document rather than before
it, where the library's own write is clamped. Both library end behaviours are
retired by a threshold no finite distance can meet; the prepend anchoring that
shares the option is kept.

overflow-anchor:none is restated as structural: the engine's anchoring writes
never pass through the scrollToFn adapter that attributes this pane's own
scrolls, so they would arrive unmarked and read as the reader leaving.

* fix(native-chat): preserve visible rows on first measurement
2026-09-14 18:24:04 -07:00
Neil b61a2347b9 feat(design-system): gate renderer UI with @shadcn/lint (#20731)
* feat(design-system): gate renderer UI with @shadcn/lint

Wires shadcn-ui/lint's Oxlint plugin into the two places this repo already
ratchets: the changed-lines PR gate for rules the renderer can't satisfy
today, and `pnpm lint` for the one that is already at zero.

- config/oxlint-design-system.json: no-restyle (layout allowed),
  no-raw-colors, require-static-classes -- scoped to src/renderer/**/*.tsx,
  run over added lines only. Measured at 10 findings across the last 60
  commits (771 changed files), so it holds the line without a migration.
- config/oxlint-dead-classes.json: no-unknown-classes repo-wide, with the
  renderer's plain-CSS hook namespaces allow-listed. Now at zero.
- no-inline-styles and no-arbitrary-values stay off; STYLEGUIDE says why.

Fixes the three live bugs the linter found:

- `--editor-surface` never reached `@theme inline`, so `bg-editor-surface`
  generated no CSS -- 12 editor/artifact/notebook panes fell through to the
  page background instead of #1e1e1e in dark mode.
- `scrollbar-none` is not a Tailwind utility and was declared nowhere, so
  the remote file browser breadcrumbs showed the scrollbar they meant to
  hide. Declared as a real `@utility`.
- Notebook markdown cells used `markdown-preview-body`, which no stylesheet
  defines; the styled class is `markdown-body`. They rendered unstyled.

* ci: run the dead-class gate in PR CI

`pnpm lint` gained check:dead-classes, and pr-workflow-lint-parity requires
every `pnpm lint` step to have a matching step in pr.yml.

* fix(notebook): keep markdown theme selectors working
2026-09-14 17:52:21 -07:00
Brennan Benson db09a7bd50 fix(native-chat): let a reader park just above the latest message (#20709)
* fix(native-chat): let a reader park just above the latest message

A reader who scrolled up by less than the bottom threshold was still
classified as being at the end, so follow stayed armed and the next chunk
of stream carried them back down. One constant was answering two
different questions: how close to the end still counts as pinned, and
whether a reader's own scroll meant to stay there.

The first wants slack, because a streaming last message jitters in height
by tens of pixels. The second wants almost none, because it is a
statement of intent. Give it its own, far stricter band, and move the
choice of band into the decision rather than leaving it to the call site,
which is where the two got conflated.

Re-arming follow now requires the reader to be within 4px of the end:
enough for fractional-pixel and zoom rounding, well inside one line of
prose. The pin and the jump-to-latest affordance keep their 48px band.

* fix(native-chat): make transcript intent own end following
2026-09-14 17:47:19 -07:00
Jinjing ffc331212c Fix PTY child process verdict to preserve unverifiable state (#20729)
* fix(pty): preserve unverifiable local child reads

* fix(pty): make child-process inspection synchronous

Separate foreground and child-process sampling. Sample child processes
synchronously after confirming foreground availability, returning
unverifiable verdicts when pty reads fail. Handle both transport loss
and local read failures uniformly in the completion coordinator.
2026-09-14 17:13:25 -07:00
Jinjing 4bcdc67369 Distinguish pane load failures from empty states (#20735)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(settings): tell a failed load apart from a genuinely empty pane

* refactor: consolidate import types and simplify failure handling

- Move filesystem import types to shared for renderer use
- Add compactIpcErrorMessage for single-line error display
- Consolidate entry failure toasts to single global slot
- Simplify account tracking and discard retry logic

* fix type

* fix: clear stale state when pane loads fail

Credential reads, account fetches, and skill scans can fail, leaving stale
data on screen. This change clears previous state when a load fails,
distinguishing load failures from genuinely empty results, and prevents
stale controls from appearing after failed re-checks.

Use readIpcErrorMessage for consistent error handling and track runtime
targets to invalidate results from old targets.

* fix(settings): show credential action when bitbucket status read fails

When the credential-read operation fails, allow users to retry by showing
"Add or replace credentials" button. Initialize the credentials dialog with
the current (confirmed) connection state instead of stale data from a failed
read, preventing outdated information from pre-populating the form.
2026-09-14 16:44:30 -07:00
6cb5643241 fix(deps): migrate Tiptap security updates with Markdown compatibility guards (#19376)
* chore(deps): evaluate coordinated Tiptap security migration

* fix(editor): adapt link ranking and initialization for Tiptap 3.31

* fix(editor): preserve literal Markdown through Tiptap serialization

* test(editor): cover literal saves in local folder and paired workspaces

* test(editor): reselect folder after closing its final tab

* perf(editor): avoid repeated inline source-marker lookahead scans

* refactor(editor): inline redundant HTML match wrapper

* test(chat): await Tiptap React skill-pill rendering

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-14 16:36:06 -07:00
Jinjing b8554f1c59 fix(composer): clarify failed attachment drops (#20704)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.
2026-09-14 15:22:05 -07:00
Neil 767b7c14f1 fix(ai-vault): expand nested OMP session history (#20663)
Expand saved OMP descendants lazily while preserving exact child targets for Resume and View Log. Retain expanded branches across virtual scrolling and reject late responses/cycles. Includes the independently reviewed child-workspace correction from #20629.

61 combined target/map/nesting tests and actual OMP child/grandchild storage/CLI smoke pass. Earlier hidden Electron proof covers eight generations and narrow sidebar layout. Folder-only unresolved child targets remain disabled. No live delegation or full terminal-launch proof claimed.

Addresses #12885 Scope 2.
2026-09-14 15:17:36 -07:00
Neil 742a7ad842 fix(omp): resume independent child sessions from history (#20629)
Add Resume to eligible local OMP child history rows. Resolve lazy child targets from their own cwd and host, never an unrelated active workspace. Unresolved folder-only targets stay disabled; copy-command remains available.

Verified production map/resume resolver regression before/after; 50 focused tests and independent 40-test review, web types and code quality passed. Actual OMP storage/CLI smoke confirms distinct child/grandchild sessions. No native Windows or live SSH launch claim.

Addresses #12885 Scope 1.
2026-09-14 15:05:36 -07:00
Brennan BensonandMerge Sim f55b7ba680 fix(native-chat): cancel pending prompts precisely (#20601)
* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

* fix(native-chat): cancel pending prompts precisely

* fix(native-chat): consolidate capability imports

* fix(native-chat): harden precise prompt cancellation

* fix claude cancellation teardown races

* retry claude prompt lifecycle admission

* bound claude prompt cancellation retry work

* fix(codex): bound prompt turn identity on registration

* fix(native-chat): route rejected late dispatch settlements

* fix(codex): retain exact cancellable prompt turn ids

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-14 14:59:03 -07:00
Wooseong KimandWooseong Kim 46eb5959fa fix(ui): contain idle caret paint so agent panes stop burning CPU (#10554)
An idle agent pane kept ~40% of a core busy just by being frontmost. The
xterm cursor and the native chat caret blink with no paint-containment
boundary, so Chromium treated each blink as damage to the whole pane
ancestry and re-rasterized it twice a second.

- `.xterm-container` and the native composer's input shell get
  `contain: paint`, bounding blink damage to the surface that blinks.
- The mention hint gains `z-20` to match the slash picker: a contained
  element becomes a stacking context and paints at z-index 0 in tree
  order, which would otherwise cover the hint's drop shadow.

Also records that DECSCUSR pins `decPrivateModes.cursorBlink`, which wins
over the option in `_updateCursorBlink` — so parking `cursorBlink` does not
reliably stop a hidden pane blinking. Pre-existing, documented only.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>
2026-09-14 14:27:31 -07:00
mmarabelandNeil 68f0b2e835 feat(runtime): stream file uploads instead of buffering whole files (#16106)
* feat(runtime): stream file uploads instead of buffering whole files

Staging read each dropped file whole with readFile(), base64-encoded it
(a 4/3 expansion), and passed the string through IPC to the renderer,
which re-chunked it. Peak memory was ~2.3x the file size before a byte
moved, so a 25 MB per-file cap existed to protect the heap.

Staging now records identity only. The byte pump moves into main, where
the file handle and the runtime socket both live: 384 KiB slices (512 KiB
once base64-encoded, matching the chunk size the renderer used) appended
through the existing files.writeBase64Chunk RPC. Peak memory is one slice
regardless of file size, so the ceilings become user-safety limits on an
unattended transfer — 2 GB per file, 8 GB per drop — and over-limit errors
name both the size and the limit.

Because staging and streaming are separate calls, the staged entry carries
size, inode, device and mtime, and the streamer re-checks all four against
the pre-open lstat and against the handle it actually reads. A source
replaced or rewritten at the same size between the two calls is refused
rather than uploaded under the original name. The post-read check compares
mtime as well as size, so an in-place rewrite mid-transfer aborts before
commitUpload renames anything into place.

O_NOFOLLOW, realpath containment and stat identity are preserved, and the
pairing revision plus the runtime id ride every chunk, so a re-pair or a
replacement runtime aborts instead of appending the rest of the file to a
different host.

No wire change: files.writeBase64Chunk and its params are untouched, so
old and new hosts behave identically. The SSH import path is separate and
unchanged. The web client has no local filesystem to stream from and says
so instead of failing obscurely.

* fix(runtime): close the empty-upload and per-drop budget holes

Two gaps the first pass left open.

A zero-byte source returned before the post-transfer identity check, so a
file that gained content during the empty write's round trip committed as
an empty file at the user's chosen name. The empty chunk now falls through
to the same final check the slice loop uses.

Each staged source also started its own byte counter, so the 8 GB ceiling
capped one source rather than the drop: five 2 GB files staged cleanly at
10 GB total. The IPC handler now carries one budget across sourcePaths and
adds only what each source actually staged. The per-file ceiling is still
re-enforced where the bytes move; the drop total holds at staging because
identity enforcement means each file streams exactly the bytes measured.

* docs(runtime): name the invariants the upload helpers carry

* fix(runtime): name the source in errors and stop uploads with their window

Three problems an independent review turned up.

A dropped file's relative path is '', so the over-limit error read "'' is
3 GB, over the 2 GB per-file remote import limit" — the message this change
exists to fix, naming nothing. Errors now fall back to the file's own name;
the staged entry keeps '' so the destination path is unaffected. The
streamer had the same shape, falling back to the hidden .orca-upload-<nonce>
temp destination, a path the user never chose.

The byte loop used to live in the renderer and died with it. Moving it into
main meant closing or reloading the window left the rest of a multi-GB
transfer running, with the renderer's temp cleanup never reaching its
finally. An AbortSignal now rides the caller's lifetime and every chunk, is
re-checked per slice, and main sweeps the abandoned temp path itself when
the renderer is no longer there to do it.

Upload failures also reached the import result wrapped in Electron's
"Error invoking remote method '...'" prefix, because the throw crossed IPC
instead of happening in-renderer; extractIpcErrorMessage unwraps it.

An existing staging test asserted the empty-name message, so it encoded the
bug rather than catching it; it now asserts the file name.

* test(runtime): cover the containment check and the per-chunk host guards

The "escapes the dropped root" test only reached the lstat symlink guard,
so assertEntryInsideRoot had no coverage at all. The shape that actually
needs it is a regular file under a symlinked intermediate directory: lstat
sees a plain file, and realpath containment is the only thing that refuses
it. Disabling the guard now fails this test and nothing else.

Nothing asserted that the SSH target, connection generation and execution
host reach the writeBase64Chunk params either — the renderer tests stop at
the IPC boundary, so the streamer's half of that contract was untested.

* fix(runtime): survive a straggling append when sweeping an aborted upload

Aborting rejects the in-flight chunk locally, but the host may still apply
that append, and appends open with flag 'a' — which recreates the file the
sweep just deleted. The delete and the straggler also race: they are
separate calls on a queue that is not ordered between them.

Slices are strictly sequential, so at most one append can be outstanding.
A second pass after it has had time to land is therefore sufficient, not
merely a heuristic. The sweep moves out of filesystem-mutations.ts into its
own module so the behaviour is testable directly.

Found by an independent review pass, which also pointed out that the
"escapes the dropped root" test only reached the lstat symlink guard.

* fix(runtime): abort uploads only when the document commits, and honour manual disconnect per chunk

did-start-navigation fires before will-navigate blocks an external link or a
stray file drop, and the renderer survives those (verified against Electron 43
with a hidden window). Aborting there killed a healthy upload with a misleading
'window went away' error. did-navigate fires only once a new document has
replaced the caller.

The renderer's per-chunk calls used to go through the IPC handler that refuses
a manually disconnected environment; the loop in main made no such check, so a
disconnect mid-upload kept pushing the rest of the file. The handler now
resolves the selector to an environment id and the streamer checks it per slice.

Adds slice-boundary coverage against the real chunk schema and host write
flags, staging-to-stream on a real filesystem, and handler-level lifetime tests.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-14 14:08:31 -07:00
Neil f21f81dcfc fix(agents): find OMP by its full project name (#20647)
* fix(agents): find OMP by its full project name

* test(agents): make picker baseline proof omit OMP aliases

* style(test): brace picker baseline condition
2026-09-14 14:03:11 -07:00
Neilandshahidbeig-a11y 3632311d0b fix(omp): preserve status after terminal title owner rewrite (#20610)
Validated and independently reviewed OMP integration fix.

Co-authored-by: shahidbeig-a11y <258701601+shahidbeig-a11y@users.noreply.github.com>
2026-09-14 13:56:18 -07:00
Neil dede24df46 fix(store): preserve state identity for no-op updater branches (#20703) 2026-09-14 13:33:19 -07:00
2186a885dd fix(store): stop two no-op writes from re-running every selector in the app (#20641)
* fix(store): stop two no-op writes from re-running every selector in the app

zustand bails out of a `set` only when `Object.is(next, state)`. Two updaters that
mean "nothing changed" hand it a fresh reference instead:

- `setWorkspacePortScanRefreshing` wrote unconditionally — the one action in its
  file that did; its four siblings all early-return `state`.
- `applyGitHubPRRefreshEvent` ended its no-op branch with `: {}`, and
  `Object.assign({}, state, {})` reproduces every field unchanged while still
  notifying. ~20 sibling sites in the same store already use `return state`.

Both rebuild the root and wake every subscribed selector (~2.2k per the listener
census). Renders are unaffected — the selection is unchanged — so the cost is
wasted selector evaluation, not commit pressure. The resulting state looks
identical either way, which is why it goes unnoticed; both tests therefore count
subscriber notifications rather than asserting state.

* test(store): use checked initial state in notification regression

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
2026-09-14 13:23:34 -07:00
Jinjing 6c70801f72 fix(source-control): prevent text wrapping in section headers and action buttons (#20046)
* fix(source-control): prevent text wrapping in section headers and action

Use flex layout constraints (flex-1, shrink-0) and text truncation instead of
wrapping to keep section labels and action buttons on a single line in the
right sidebar.

* test(source-control): add section action button alignment tests

Ensure View all button stays on single line with icon actions in
crowded section headers. Pin layout constraints (shrink-0, flex-wrap,
whitespace-nowrap) to prevent regression.

* Rely on Button base styles for action label wrapping

Remove redundant shrink-0 and whitespace-nowrap utilities from
section action buttons. These should be supplied by the Button
component's base variant, not duplicated at each usage site.
2026-09-14 13:07:27 -07:00
Jinjing d5be0d69e7 Add copy button to code blocks (#20357)
* feat(native-chat): add copy button to code blocks

Enable users to copy code snippets directly from chat messages via a dedicated copy button on fenced code blocks. Supports language detection and integrates with markdown rendering via a `renderCodeBlock` prop.

* i18n: add English copy code button label

* refactor: use React.isValidElement type parameters for type narrowing

- Specify props types as type parameters to React.isValidElement instead
  of casting after the fact
- Allows TypeScript to narrow element.props type automatically
- Eliminates manual type assertions in extractCodeText and extractCodeFenceLanguage
2026-09-14 12:54:31 -07:00
Jinjing 2acd2f4c88 Surface stage, unstage and discard failures with retry capability (#20423)
* fix(source-control): surface stage, unstage and discard failures

* fix(source-control): use single slot for entry failure toasts

- Consolidate entry failures to one stable slot instead of per-worktree
- Handle stale retries inline at click time rather than via a cleanup hook
- Remove retry button from discard failures to prevent destructive accidents

* test: improve type safety and mock patterns in source-control tests

- Add proper type definitions for toast options and test data instead of using `as never`
- Replace `mock.calls.at(-1)` with safer `mock.lastCall` pattern
- Create `entry()` helper to construct typed test entries
- Add explicit type annotations to mocked functions for better IDE support

* test: extract shared toast options type for source control tests

Consolidate duplicate `ToastOptions` type definitions across three test files into a single `SourceControlToastTestOptions` type, reducing duplication and improving consistency.

* fix(source-control): separate refresh failures from mutation failures

Post-mutation refresh failures are logged separately, not surfaced as toasts
(mutation already succeeded). Use preventDefault() on retry to prevent sonner's
auto-dismiss from swallowing re-raised failures. Consolidate stage/unstage into
a shared handler to reduce duplication.

* fix(source-control): only dismiss entry failures from the owning worktre

Track which worktree owns the shared entry-failure toast slot. When a mutation
completes, only dismiss the slot if the completing worktree is the one that
raised the failure — a slow retry in one worktree should not erase a failure
another worktree has since raised into the slot.

* Remove entry mutation status refresh helper

Inlined into the caller during consolidation of failure handling and
tracking in the source-control entry mutations flow.

* Simplify entry mutation refresh without wrapper

Call refreshActiveGitStatusAfterMutation directly instead of through the
refreshEntryMutationStatus helper. This ensures refresh failures propagate
directly from the callback without being caught as mutation failures.
Remove tests that validated the wrapper's error handling.
2026-09-14 12:45:25 -07:00
Brennan Benson d8b6151e8c fix(native-chat): keep a resumed transcript pinned to its end (#20651)
* fix(native-chat): keep a resumed transcript pinned to its end

Follow state was recomputed from distance on every scroll event, and a pin writes scrollTop itself, so the browser reports that write back as a scroll event a frame later. Once a resumed session's later history pages and settling row heights had moved the end away from it, that echoed event read as the reader leaving and the pin was dropped for good, stranding them mid-transcript. Measured in Chromium: a pin followed by same-task growth delivers a scroll event reading 2000px from the bottom, indistinguishable from a reader scrolling up.

Pins now go through the virtualizer instead of writing scrollTop directly, so both parties resolve the end through the same maximum rather than holding rival definitions of it. Whether the reader left is now a question of provenance rather than distance: an offset this transcript wrote is never a departure. The end test reads live geometry, because the virtualizer's own isAtEnd subtracts a cached offset from a live maximum and this handler runs before that cache is refreshed. overflow-anchor:none stops the engine moving scrollTop under a settling row, which would otherwise look like the reader.

This does not make ownership singular. The virtualizer still writes autonomously from several paths and those writes stay unattributed; what this removes is the rival definition of the end, not the second writer.

* fix(native-chat): cancel stale end reconciliation

* fix(native-chat): attribute scroll ownership centrally
2026-09-14 12:22:44 -07:00
Brennan BensonandMerge Sim c6a7216984 fix(native-chat): hide activity while awaiting input (#20496)
* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-14 10:42:38 -07:00
Brennan Benson a4c11f1889 fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows (#20581)
* fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows

A structured chat pane could latch "Working for N" forever after the agent had
finished, showing the send arrow rather than Stop, while the sidebar and
`worktree ps` correctly read idle.

The client replica has one position (`state.cursor`) and one body. Two
operations keep those consistent: replace (both from one host snapshot) and
append (rows contiguous with the cursor). The `tail-page` branch was a third
thing: it took the cursor from the journal head, the items from a bounded page
(200 items, byte-capped), then merged retained client submissions over the
page's. Under continuous journal writes the client is always slightly behind,
so the branch ran on every window focus and on every pane re-activation. When
more than a page of rows had landed since a send, that send's user item fell
off the page, its submission was not carried, the retained `pending` survived,
and the cursor jumped past the dispatch-acceptance row. Nothing re-sends it: a
batch carries only touched items and that submission is never touched again.

Delete the third operation rather than guard it. A live subscription is now the
only thing that moves the cursor, and `subscribe({ cursor })` already replays
exactly the missed rows.

- remove the window `focus` listener and the owner/transport `refresh` contract
- skip warm hydration: a retained owner subscribes at its applied cursor
- cold hydration keeps its history read, applied as the existing `snapshot`
  (replace) event rather than `tail-page`
- delete the `tail-page` action and its reducer branch
- delete `resumeCursor` and `shouldAdvanceStructuredResumeCursor`; two cursors
  with two advancement rules were how position and body drifted apart

`older-page`/`loadOlder`, the unattached-refusal grace, generation guards and
the coalescer are unchanged. No host, wire or schema change.

Also fixes a second cost of the same branch: focus during a busy turn discarded
paged-in older items, shrinking the transcript to one bounded page mid-turn.

* fix(native-chat): preserve unavailable mixed-version session fences
2026-09-14 10:28:16 -07:00
2ed89b8781 fix(github): name an unfiltered empty project view instead of blaming a filter (#20588)
* fix(github): skip Projects search index for unfiltered views

Empty query still used items(query:\$q), which routes through GitHub's
Projects search index and can return totalCount 0 while the board is full
during index lag. Omit the query argument when the view filter is empty.

Fixes #12648.

* docs(github): drop the false stable-shape claim for empty project filters

Unfiltered item fetches omit items(query:) so boards skip search-index
lag. The View.filter field is still '' when GitHub returns null.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(github): name an unfiltered empty project view instead of blaming a filter

The search-index workaround in this branch was a no-op. Live introspection of
ProjectV2.items shows `query` is declared `String = ""`, so omitting the
argument and sending `$q = ""` coerce to the identical resolver input; GitHub
applies declared defaults for omitted args (verified against its own endpoint).
There is no non-search item field on ProjectV2 and ProjectV2View has no `items`
at all, so no request shape can dodge the index. Revert the branching query
construction and the module it added.

What the user actually reported in #12648 is the copy: a view with no filter
rendered "No items match this view's filter", which reads as data loss when a
freshly populated board momentarily comes back empty. Word the empty state from
the view's own filter — the filter message only when there is a filter, and an
honest "no items yet" plus a transience hint when there is not — and share the
one implementation between the table and roadmap surfaces.

Refs #12648.

---------

Co-authored-by: bbingz <zzb@gxsmjx.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 01:44:47 -07:00
Brennan Benson 0c812843ef feat(native-chat): show a drop target on the whole chat pane (#20561)
* feat(native-chat): show a drop target on the whole chat pane

Dropping a file into a chat only worked if you hit the input box, and nothing
on screen said so. A drag aimed at the transcript fell through to the terminal
behind the chat, which pastes the paths into the hidden TUI.

The chat pane shell is now the drop surface. While a drag carrying files is
over it, the pane dims behind a card naming what the drop will do; the
composer's existing attach logic — workspace, execution-host and SSH-owner
checks included — runs unchanged from the wider element.

Two routes reach the composer and they are widened differently:

- an in-app drag is claimed in the renderer, so the pane calls the composer's
  handlers through a claim the composer publishes to the surface around it.
- an OS drag is delivered by the preload drop route, which consumes the event
  at `document` before React sees it. The pane widens that route by publishing
  the composer's scope key as the nearest drop marker, and learns the drag
  ended from a document-level listener rather than a React drop.

The surface lives in the chat portal because that is the one place wrapping
both the bridge and structured panes, so neither pane root grows a second copy
of this wiring.

No composer mounted (a question card owns the input region) means no marker and
no overlay, so that drag stays the terminal's. A guarded composer still answers
for the drag — that refusal is what keeps it out of the terminal — but the pane
does not invite a drop it will refuse.

* Fix native chat drop ownership across session surfaces

* Keep native drop completion listeners ready before hover renders
2026-09-13 23:35:14 -07:00
Brennan BensonandMerge Sim 06c83e24c0 fix(workspaces): recover a real terminal when activation throws for a planless agent create (#20190)
* fix(workspaces): recover a real terminal when activation throws for a planless agent create

#20175's activation-failure recovery re-seeds the workspace surface, but passed
callerProvidesSurface: true whenever an agent was selected. Activation is the
caller that would have provided that surface, and it just threw. With the flag
set and no startup plan, worktree-initial-terminal-seeding takes its zero-tab
pre-seed branch: it queues setup/issue commands and returns null instead of
creating a terminal. The recovery then recovers nothing and the freshly created
workspace opens with no tabs.

Dropping the flag from the recovery call is the whole fix. The create-time call
keeps it: there, the agent pane really is the surface the caller provides.

The enclosing object literal becomes a ternary because oxlint's
unicorn/no-useless-spread rejects a lone conditional spread.

* test(workspaces): reuse activation recovery coverage

* test(workspaces): keep recovery assertion focused

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-13 23:29:51 -07:00
Brennan BensonandMerge Sim ad49aae600 fix(workspaces): re-seed a gate-reported empty workspace after an agent selection (#20182)
* fix(workspaces): re-seed a gate-reported empty workspace after an agent selection

#19940 routed two different questions through one predicate. An agent selection
justifies skipping the *pre-emptive* shell at create time, but it was also
suppressing the async activation gate's fail-closed re-seed. The gate returns
`empty` only after adoption, structured inventory and resume all produced
nothing, so it is positive evidence the agent surface never arrived (dead PTY,
unreadable census, null startup plan) — exactly when the re-seed is needed.

Suppressing it there left the workspace with zero tabs and no recovery:
ensureWebRuntimeWorktreeTerminalAfterWake returns early for a plain local
workspace, so nothing else seeds one.

`gatedEmptyOutcomeReseedSuppressed` now answers the re-seed question on its own,
honouring only an explicit `providesInitialSurface` caller promise — restoring
v1.4.200's behaviour for that path. The create-time skip keeps using the
agent-inclusive `activationProvidesInitialSurface` unchanged.

* refactor(workspaces): make gated reseed policy explicit

* fix(workspaces): harden gated empty reseeding

* chore(workspaces): split duplicate-folder routing fix

* refactor(workspaces): keep reseed helper outside caller census

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-13 23:29:13 -07:00
Neil 55b3392018 fix(terminal): drop the agent gutter from copied selections (#19770) (#20545)
* fix(terminal): drop the agent gutter from copied selections (#19770)

xterm selections are screen cells, not logical text. Agent CLIs paint
their messages behind a fixed left gutter, so every copied line carried
that gutter into the clipboard and pasted replies came out indented.

Terminal clipboard writes now drop the run of spaces that *every*
selected line shares, so relative indentation (nested bullets, fenced
code, YAML) survives and only the gutter is lost. A selection that
starts mid-line, or that includes any column-0 line, has a shared run of
zero and is copied verbatim.

Applied at every terminal clipboard seam: the Cmd/Ctrl+C shortcut, the
pane context menu's Copy, right-click-to-copy, the app menu's Copy,
copy-on-select, the X11 primary selection, the dashboard popout's
preview terminal, and mobile's selection Copy button.

New "Trim Gutter on Copy" terminal setting (default on) restores the
old verbatim-cell behaviour.

* fix(terminal): honour the gutter-trim setting on mobile copy

Mobile stripped the gutter unconditionally, so turning "Trim Gutter on
Copy" off left one surface still rewriting the clipboard. Mobile now
mirrors the desktop preference through the existing settings.get RPC —
a host predating the setting sends no key, which reads as on, matching
the desktop default.

Also folds the single-use gutter helpers into their callers so the
shared module exposes one function.

* refactor(terminal): parse each selection line once in the gutter rule

Also locks the Windows subtlety with a test: a blank CRLF row is '\r',
which reads as a zero-indent content row and would cancel the gutter
unless the CR is split off first.

* fix(terminal): publish the gutter-trim setting to paired clients

settings.get is an explicit allowlist projection, not the whole settings
object, so terminalCopyTrimsGutter never reached mobile: the client read
the key as absent, which means "older host", which means on. Mobile
therefore always trimmed and the desktop opt-out was inert.

Adds the field to the projection and a test that fails if it is ever
dropped again — absence is indistinguishable on the client from an old
host, so a silent regression here has no other signal.

* chore: drop unrelated formatter drift from this branch

A repo-wide `pnpm format` swept a quote-style change in pnpm-workspace.yaml
and a blank line in source-tree-walk.test.ts into this branch; neither is
related to the gutter fix.

* fix(terminal): trim the gutter on native copy events too

xterm binds its own DOM `copy` listener that writes raw screen cells
(CoreBrowserTerminal `_initGlobal`). Orca's own chords never reach it —
they preventDefault in keydown — but Ctrl+Insert is a Chromium copy
accelerator on Windows/Linux and is not in `terminal.copySelection`'s
bindings, so it still copied the gutter. Orca binds Shift+Insert for
paste on those platforms, which makes the asymmetry worse.

A capture-phase listener on the xterm element now writes the trimmed
text, closing the class rather than the one chord: any native copy event
— assistive tech, execCommand — lands on the same path. Installed for
both terminal panes and the dashboard popout's preview terminal.
2026-09-13 23:01:47 -07:00
Brennan Benson c287a5d9b7 feat(native-chat): add provider-aware Fast mode (#20506)
* feat(native-chat): add provider-aware fast mode

* chore: drop unrelated formatter churn from the merge

pnpm format reflowed pnpm-workspace.yaml quoting and a source-scan test
that this PR does not otherwise touch.

* fix(native-chat): review fixes for provider-aware fast mode

Review pass over the Fast mode work.

Claude reads its model catalog once per option write. The admit check, the
effort guard and the Fast guard each took their own `list_models`, so a model
write with Fast on paid two round trips for one list and let two guards answer
from two different catalogs. The guards are now pure over a single read.

Claude no longer refuses a Fast enable when the catalog identified nothing at
all. An empty list is not evidence against a model -- the same rule the model
admit-check already applies -- so a CLI that cannot answer would otherwise have
Fast refused on every model. A catalog that did list the model and stayed silent
about Fast is still not positive evidence and keeps refusing.

Codex refuses a direct `serviceTier` write instead of accepting one the next
turn discards. The turn derives the tier from `fastMode`; the key still restores
so a session persisted before Fast existed migrates.

Both option surfaces return a cached snapshot again. `SessionOptionsSurface` is
read through `useSyncExternalStore`, whose contract is a stable snapshot, and
rebuilding it per call breaks that for any consumer wired that way.

Also records two decisions that were emergent rather than stated: routing
Standard when Fast is on but no tier is named yet, and what a readback
disagreement does and does not prove.

Quality gate: merges the duplicate imports static analysis flagged, adds SAFETY
rationales for two pre-existing casts the changed-code gate now sees, and drops
a new assertion in favour of a checked narrowing.

* fix(native-chat): read Claude Fast state from the session frame

A fresh Claude session reports `fastModeState` while the settings readback still
has no `fastMode` boolean, so the two are not redundant -- the frame answers at a
moment the boolean has none. The picker fell back to "value unknown" and asked
the user to disambiguate what the provider had already reported, and the state it
reported had no reader at all.

Falls back to the frame only when neither a pick nor the settings readback
answers. `cooldown` throttles routing rather than clearing the pick, so it reads
as on; reading it as off would flip a control nobody touched.

Display only. The launch seed is untouched: an unset Fast preference still seeds
nothing, which its own guard continues to pin.

* perf(native-chat): skip the model catalog read when turning Fast off

Turning Fast off needs no support evidence, so the read only cost a
round trip — and restore replays a stored `false` on every acquire.

Also narrows the alias-matcher comment: the effort and admit guards
match on alias and resolved id only, so calling it the sole matcher
overstated it.

* fix(native-chat): clear a Claude Fast block once the child stops reporting it

The child omits fast_mode_disabled_reason entirely when nothing blocks Fast
and never sends a null, so requiring the key back latched the first reason
for the session's life: switching to a model that disallows Fast and back
retired the control for good, leaving a session running Fast with no way to
turn it off. A frame that reports state without a reason is the all-clear.

* test(native-chat): cover the mobile structured option hook

useMobileStructuredAgentOptions gained generation fencing, a pending-write
guard and a post-write options refresh with no test file. Pins the concurrency
contract and the fast mode round trip:

- a superseded options read is dropped instead of overwriting newer state
- an overlapping write is refused and the pending guard is released after
- an accepted same-fence write reads options back and applies the result,
  and a different-fence write does not
- a boolean fastMode pick reaches the wire encoded and is remembered decoded
- no Fast row when session support, catalog support or the model capability
  is missing

Each behaviour was ablated against the production logic to confirm it fails
without it. No production code changed.

* feat(native-chat): render a boolean session option as one toggle

On and Off were two radio rows under a header repeating the option name,
so a binary choice cost three lines and two clicks to read. It is now a
single switch row that owns its label, on desktop and mobile.

An unknown value keeps its caption: a switch cannot say "unset".

* fix(native-chat): resolve a boolean option's display value at the producer

A boolean session option reached the UI in three states while its control had
only two, so the renderer apologised for the gap with a "Current value unknown"
caption beside a switch that had already collapsed to off. For `thinking`, whose
catalog default is on, that caption sat next to a switch asserting the opposite
of what every composed dispatch assumes.

One expression fed both the displayed value and the option's provenance. Split
them: the boolean descriptor now always carries a value, resolved to the same
`values[id] ?? defaultValue` that buildNativeChatSessionOptionCommand already
composes, while `valueSource` is untouched and still records whether anything
confirmed it. `kind.currentValue` is required on the boolean arm so the third
state cannot come back.

The launch path is unaffected: resolveAgentSessionOptionLaunch and
buildNativeChatSessionOptionCommand build the composed `--model` argument from
the caller's picks and the catalog, never from a descriptor.

Both surfaces mark an unconfirmed value instead of captioning it, and the two
reasons stay distinct — `default` says the catalog value is what a launch will
send, `unreported` says nothing has told us anything. Only `unreported` is
reachable in the structured lane, where the agent may be routing a tier we have
never been told about, so the two never share a label.

* fix(native-chat): let assistive tech read the option value marker

The marker was aria-hidden next to an explicit aria-label, so the label
already won the accessible name and hiding it only cost screen reader
users the default-vs-unreported distinction that sighted users get. It is
now referenced by aria-describedby, which keeps the name Fast mode.

Mobile's summary row said "Not set" for a boolean while the sheet behind
it showed the switch on, so the two screens disagreed. A boolean always
has a value; the summary states it and the sheet's marker qualifies it.

* chore(i18n): drop the On/Off option strings the switch row retired

Replacing the On/Off radio pair removed the only call sites for these two
keys. i18next cannot rebuild a key with no call-site default, so leaving
them in the catalogs forced them into the boot bundle as dead weight.
Removing them shrinks it by two entries instead.
2026-09-13 21:58:32 -07:00
Brennan Benson 2ce252f471 fix(grok): announce a completion once, when Grok is actually finished (#20523)
* fix(grok): announce a completion once, when Grok is actually finished

Orca pinged on every Grok turn-end. Grok runs turns the user never asked for:
when a background task finishes it wakes itself, does a little work, and ends
another turn. One request produced several pings.

Grok already reports, on every turn-end, whether it still has work outstanding.
Read that instead of trying to classify which turns are "real":

  backgroundTasks absent          -> silent, this is the session-end tail
  StopFailure / StopCancelled     -> announce, a failure is never hidden
  stopHookActive                  -> silent, a Stop hook is keeping it working
  a shell task or subagent running -> silent, the work is not done
  otherwise                       -> announce

Nothing here knows what an auto-wake turn is. A turn that ends with work
outstanding stays quiet; the later turn where that work is finally done is the
one that announces. That is also why this survives the case where Grok completes
a user's goal inside one of those turns — prefix-based suppression would have
silenced it.

Monitors and scheduled entries are deliberately not counted as outstanding work.
They can run indefinitely, so counting them would suppress a user's completion
permanently, and a lost ping is worse than an extra one.

Also registers StopCancelled, which Grok fires instead of Stop on a user
interrupt, a declined permission, --max-turns, or a no-progress bail-out. Orca
never subscribed to it, so those turns were reported as successes.

Also removes a stale notification matcher that searched for prose the shipping
binary never sends; the typed notification kind is matched instead, and neither
idle_prompt nor task_complete is treated as a completion.

Needs-input behaviour (permission prompts and ask_user_question waits) is
unchanged and stays ungated by background work.

* fix(grok): never hide a failed or cancelled turn behind the background-work gate

The announce predicate checked field-absence before terminal outcome. Grok's
StopFailure and StopCancelled payloads carry no background inventory at all, so
the absent-field branch — added so the session-end tail stays silent — fired
first and silenced every failure and every cancellation.

That inverted the rule it was meant to serve. Before this series a cancelled turn
at least surfaced as a (wrong) success; gated this way it surfaced as nothing.

Terminal outcome is now checked first, so a failure or cancellation announces
regardless of what other fields the payload happens to carry.

The existing tests passed straight through the bug because they built failure
payloads with a backgroundTasks field Grok never sends for those events. They now
model the real payload shapes, verified against the provider's payload
definitions and the captured envelopes.

* fix(grok): settle completion from provider lifecycle state

* fix(grok): fence stale turn ends without prompt ids
2026-09-13 21:09:01 -07:00
dngur6344andNeil 9cf0a6c37f perf(remote): avoid repeated capability probes during file imports (#14555)
* perf: avoid repeated remote import capability probes

* test: cover cold remote import compatibility probe

* fix(remote): fence imports across runtime reconnects

* fix(remote): bind import proof to connection

* fix(remote): fence import routing by runtime identity

* test(remote): remove unsafe import fixture assertions

- type remote RPC mocks at declaration so call arguments stay checked
- narrow upload params before reusing generated temp paths

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-13 20:51:15 -07:00
PPP-JHandm4air 16d1ab81d3 perf(skills): bound WSL installed skill discovery (#12314)
* perf(skills): bound WSL installed skill discovery

* fix(skills): preserve bounded discovery correctness

* fix(skills): bound WSL metadata prefilter reads

* fix(skills): isolate absent discovery cwd cache keys

* test(skills): adapt WSL discovery mocks to runner

* fix(skills): preserve filtered discovery fallbacks

* fix(skills): share filtered scans and preserve WSL inventory

* fix(skills): share WSL scans without losing skill aliases

* fix: preserve skill metadata and retire filtered peer caches

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-13 19:16:42 -07:00
Brennan Benson e9db1642d1 refactor(attention): move the agent attention boundary off terminal panes (#20525)
* refactor(attention): move the agent attention boundary off terminal panes

The completion-attention pipeline asked PTY questions inline, so a non-terminal
agent surface was structurally excluded from unread, delivery and acknowledgement.

Extract a provider-neutral policy under `src/renderer/src/attention/` that reaches
every surface fact through an adapter, and move the PTY-shaped predicates
(`hasLivePtyForNotification`, `isCurrentLivePaneKey`, `isCurrentKnownPaneKey`,
`isVisibleForegroundPaneKey`, plus the leaf/foreground resolution the auto-ack scan
did inline) into `terminal-attention-surface.ts`, now their only call site.

Unread markers carry the reason that wrote them (`agent-completion`,
`terminal-bell`, `manual-mark-unread`, `legacy`); an unclassified boolean still on
live state reads as `legacy` rather than being guessed or migrated.

Main's `notifications:dispatch` closure becomes a delivery service with injected
collaborators; the IPC handler is a thin adapter. Behaviour is unchanged: unread is
still written before main's desktop gate, the tray dot before the cooldown/focus
gates, and mobile fan-out before the desktop early returns.

* fix(attention): recognize classified tab unread markers
2026-09-13 18:27:34 -07:00
Jinwoo Hong d138c3278d fix: show unexpected signout notice only once across versions (#20526) 2026-09-13 20:30:02 -04:00
Brennan BensonandMerge Sim 5e70014da8 feat(native-chat): support file drag and drop (#20494)
* feat(native-chat): support workspace file drops

* fix(native-chat): report OS file drops that attach nothing

#15782 is a silent failure on the Finder route, and that route still
swallowed every way it could fail:

- the preload handler returned with no feedback when the OS handed us
  file items `webUtils.getPathForFile` could read no path from (promised
  or virtual files). It now sends the existing `rejected` payload with a
  new `unresolved-paths` reason, which the global drop toast names.
- the composer's external-attach path dropped the batch with no notice
  when every path failed authorization, when an upload came back empty,
  and (new in this branch) when the owner changed mid-flight. Each exit
  now sets a notice; only a disabled composer stays quiet, because it has
  no notice surface.

Also stops `resolveNativeChatAttachmentOwnerForWorktree` throwing out of a
drop/IME handler when an SSH connection's generation is gone mid-attach —
that is an unknown owner, which the resolver already models as
`not-ready`.

* refactor(native-chat): one owner-identity check for composer attachments

The branch had two near-identical "is this still the same owner" helpers,
one per attach route, and they disagreed: the workspace-drop copy ignored
the SSH connection generation, so a reconnect between the drop and the IME
flush read as the same owner and the path landed on a new connection.

Collapses both onto one predicate in the pure ownership module (the
store/toast-free seam both routes already depend on), which compares the
full SSH expectation and never treats `not-ready` as a match.

* perf(file-explorer): resolve drag ownership at dragstart, not per render

The virtualized row list resolved the selection's source execution host on
every render — the virtualizer re-renders on every scroll frame, so a large
multi-selection paid a full projection scan plus a route allocation per
selected path per frame, and per visible row on top of that. Only
`onDragStart` ever read the result.

Rows now receive a resolver they call with the paths they are about to
drag. The three copies of the "stamp only if both halves resolve" guard
(explorer row, both combined-diff row shapes) collapse into one helper next
to the writer.

* fix(native-chat): refuse a guarded composer drop visibly

The drop handlers claimed the drag (preventDefault + stopPropagation) before
checking `disabled`, so a guarded composer told the browser it accepted the
drop, left the copy cursor up, and then did nothing — the same silent swallow
this branch exists to remove.

Dragover now answers `none` when the composer is guarded, so the cursor refuses
and no drop event follows. It still claims the event either way: the composer
sits inside the terminal surface, which accepts the same drag and would paste
the paths into the shell instead.

Drops `stopImmediatePropagation`. The capture-phase `stopPropagation` already
keeps the event off the editor below, so the stronger form only risked
suppressing unrelated listeners on the React root.

The fake DataTransfer in the test now starts at a dropEffect we never write, so
asserting `none` or `copy` proves the handler set it.

* fix(native-chat): decide attachment ownership per path, not per batch

A queued batch can mix sources — a workspace drop the target host owns and a
client-local paste it cannot read — because IME composition holds both until it
settles. Collapsing the batch to one verdict refused the whole thing on a remote
target, including the drop the user was entitled to make.

The verdict now follows the path it belongs to: owned paths attach, client-local
ones are refused, and the refusal is reported rather than dropped. A stale owner
still refuses everything, since that means the target moved under all of them.
Also guards the empty-batch case, which previously read as "every path owned".

* refactor(combined-diff): resolve drag ownership from the live workspace

The combined diff captured an execution host into the open-file record at tab
open and drilled it through three components to reach the row. That host was
never persisted, so after a restart every drag from a restored diff was refused
until the tab was reopened, and the capture failure was swallowed into an
undefined source with no trace.

Rows now resolve the owner the same way the source-control rows already do, from
the workspace the diff belongs to at the moment of the drag. That deletes the
prop drilling, the store capture and its bare catch, and leaves one way to
answer "who owns these paths" for every live listing.

The file explorer keeps its per-node owner: its tree is a cache that can still be
showing a previous host's listing, which is exactly what that field records.

* revert(file-explorer): drop the workspace-id tree reset

Resetting and reloading the tree when the workspace id changes at an unchanged
path is not needed for the drag source to be correct. The tree already records
the workspace whose root listing it committed, so a cache left over from a
previous workspace stamps that workspace and the composer refuses the drop —
the intended answer, reached without touching the reset rule.

That rule clears selection, the name filter and undo history, which is more
file-explorer behaviour change than this feature asked for.

* test(native-chat): stop the external-attach mock hiding new notices

The hook's test replaced the whole attachment-owner module with a hand-written
stub, so the two notices added alongside the owner-change guards resolved to
undefined. Calling them threw inside the async attach loop — an unhandled
rejection, which leaves every test in the file reported as passing while the run
as a whole fails. CI caught it; a local run reporting only pass/fail counts does
not.

The mock now spreads the real module, so a notice added later cannot go missing
from it, and both owner-change tests assert the string a user would read instead
of only asserting that nothing attached.

* test(native-chat): guard the last-path owner change on a one-file drop

The owner flipping while the final path is authorizing has no next loop
iteration to catch it, so the post-loop check is all that stands between a
single-file drop and a path attached to a host that no longer owns it — and a
one-file drop is the ordinary shape. No test covered that exit.

Removing the post-loop check now turns this red; before it, only the
multi-path exit was guarded.

* fix(native-chat): keep a mixed attachment batch in attach order

applyResolvedPaths partitioned a queued batch into a target-owned half and
a client-local half and concatenated them. An IME-delayed batch that mixed
a workspace drop with a paste made earlier in the same composition was
therefore inserted owned-first, so the dropped reference jumped ahead of
the pasted one in the draft.

Filter against the two verdicts in place instead. Membership is unchanged,
the order the user attached in survives, and the two intermediate arrays go
away.

* fix(file-explorer): name the owner of a dragged path whose row is hidden

A multi-selection outlives the rows that showed it. Nothing prunes
selectedPaths when a directory collapses, when the name filter narrows, or
when dotfiles are hidden, and the drag still carries every selected path.
Drag-source resolution read those owners from the row projection, which is
built from visible rows only, so one hidden path collapsed the whole drag to
an unstamped one and the composer refused it as coming from another
workspace.

The owner was never unknowable — the dir cache the projection is built from
still records which host listed that path. Fall back to it when the path has
no visible row. A path in neither (a name-filter synthetic node for a
directory that was never listed) still fails closed.

* fix(native-chat): ask which workspace the composer serves now

The IME-flush ownership check compared the workspace id captured when the
drop happened against the same captured value, so for a structured pane the
comparison could only ever hold. The live protection came from the host and
owner checks beside it; this one asked nothing.

Read the id through a ref so the check means what it reads as. A pane whose
structured target moves between the drop and the composition settling now
refuses the queued path instead of attaching it.

* fix(native-chat): ask which workspace an external attach lands on

The post-await ownership gate resolved the owner through the render closure, so
it re-asked the workspace the attach started in and compared the answer with
itself. A tab moved to another workspace mid-authorization passed the gate, and
the paths landed in a composer that no longer served that workspace.

Read the pane through a ref and compare the workspace identity as well as the
owner: two workspaces can both report a local owner, so the owner alone cannot
tell them apart.

* test(native-chat): read the real notice on a workspace drop

The drop tests hand-built their attachment-upload mock and hand-copied the
not-ready wording into it, so the assertion tracked the copy rather than the
string a user reads: rewording the real notice left all 15 tests green.

Spread the real module and override only the owner resolver, matching the two
sibling test files in this directory. Rewording the notice now fails the test.

* docs(native-chat): restore the hook's doc comment to the hook

The workspace comparison landed between the doc block and the function it
describes, leaving the comment attached to a type alias.

* test(native-chat): cover the upload window for a moved pane

The workspace-currency gate guards two windows and only the authorize loop was
covered. The upload window is the longer one: the paths go to the worktree the
attach captured, so a pane that moved workspaces meanwhile must not receive
remote paths living under the workspace it left.

* test(native-chat): pin the two untested attachment refusals

Refusing an already-blocked target at the drop rather than queueing it had no
test: queued paths that can never attach still spend the pending budget, and the
next legitimate drop is then turned away for being one too many.

Also pins the immediate already-false ownership verdict. Today's only caller
settles ownership synchronously so it cannot arrive false, but the hook exports
this entry point and the fallback is not a refusal — a false verdict is not
"owned", so a remote target blames client-local attachments for an ownership
failure. Verified: removing the branch reports the wrong notice.

* docs(native-chat): say which rule the ownership refusal follows

The per-path comment sat directly above the batch-wide ownership refusal while
describing the blocked-target logic below it, so the refusal read as a
contradiction of the line under it rather than as the file's stated rule.

Name the rule at the refusal: a failed ownership verdict refuses the whole
completion, the same way the pending-limit rejection does.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-13 16:41:51 -07:00
241fb9ed9d perf(terminal): batch file-link checks on their owning host (#20463)
* perf(terminal): batch file-link existence checks on their owning host

* test(relay): allow additive filesystem capabilities

* fix(web): keep terminal file links working under batched existence checks

createShellApi omitted pathsExist, so withFallback answered the new batch
call with a truthy proxy resolving to undefined and the whole hover batch
rejected — dropping every link on lines with an out-of-worktree path.

* test(web): assert the shim without type assertions

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: Neil <neil@stably.ai>
2026-09-13 16:27:06 -07:00
Jinwoo Hong 22f56f7c2a fix(runtime): reject malformed file Base64 padding (#20283)
Require padded file-write payloads to end on a Base64 quartet boundary. Cover both RPC methods and padded final upload chunks, and document client compatibility evidence.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-13 18:23:16 -04:00
Jinwoo Hong 1f7655f3e3 feat(ai-vault-search): public session search contract and transports (#20277)
* feat(ai-vault-search): define public contract and service seam

* feat(ai-vault-search): add IPC runtime relay and web transports

* fix(ai-vault-search): register search IPC at the core handler site

ai-vault.ts was two lines over the 300-line max-lines limit; the search
handlers belong with the other register*Handlers calls anyway.

* fix(ai-vault-search): withhold degraded-root paths from relay status

Status carried local filesystem paths over the relay while hits redact
theirs. redactStatusForTransport applies the same policy at the same
boundary: relay callers keep each root's reason and the array length as
the count, so the type only makes root optional.

* fix(ai-vault-search): close diagnostic path leak and remove test casts

* feat(ai-vault-search): carry an execution host id and per-host outcomes on hits

* feat(ai-vault-search): route desktop search by execution host scope, including runtimes

* feat(preload): accept an execution host scope on session search

* feat(web): answer only for the paired runtime on session search

* docs(ai-vault-search): describe execution-host routing and the all-hosts merge

* test(ai-vault-search): cover every host scope, the all-hosts merge and wire compat

* fix(ai-vault-search): resume every host mid-page so a merged page never drops a hit

* fix(ai-vault-search): decode the merged cursor with a schema instead of casts

CI's type-aware audit refuses type assertions; a zod record validates the
per-host entries and yields the typed map without one.

* refactor(ai-vault-search): defer cross-host merged search
2026-09-13 17:53:50 -04:00
Brennan BensonandMerge Sim cf20e089d2 fix(native-chat): wait for the runtime capability probe before resolving the creation launch route (#19819)
* fix(native-chat): wait for the runtime capability probe before resolving the launch route

A worktree created before the renderer's hydration-gated capability refresh
runs read the local capability set as null, which
resolveStructuredNativeChatSupport treats as a blocker, silently degrading
structured native chat to the legacy terminal-backed route. Creation submits
now await ensureLocalRuntimeCapabilities(), which probes the local runtime
when no answer has landed yet, so the route resolves on an actual answer.

Fixes #19154

* fix(native-chat): await the capability probe in the work-item direct launch route too

prepareDirectWorkItemAgentLaunch is the fourth creation-flow route owner and
already async; a pre-hydration submit-after-ready launch (fix-checks) read the
unprobed cache as unsupported and silently degraded to legacy. Draft-delivery
launches were unaffected (draft-prompt blocks structured before the capability
check). Same shape as the three creation-submit sites.

* fix(native-chat): keep the capability probe starting synchronously

The broken-bridge hardening wrapped the probe in Promise.resolve().then(...),
which deferred window.api.runtime.getStatus() by a microtask. The session-tabs
restore deliberately overlaps its inventory RPC with this refresh and relies on
the probe already being in flight when refresh returns, so the deferral broke it.

The bridge call is synchronous again; a synchronous throw becomes a rejection
instead, which is what the wrapper was actually for.

* fix(native-chat): hydrate local runtime capabilities at renderer boot

The capability cache's only writer was `useLocalStructuredSessionTabsSync`,
gated on workspaceSessionReady + terminalStartupRestorationReady + the
experimental flag. Every `resolveAgentLaunchRoute` reader treats an
unanswered cache as "unsupported", so the answer arriving seconds late is
what produces the bare-terminal create in #19154 — awaiting the probe at a
route decision guards four call sites but leaves the window open for the
three readers that are synchronous and cannot await.

Start the probe from the renderer boot chain, ungated, so the answer is
cached before any launch route is resolved. The per-call-site awaits stay
as the backstop for the residual window and for re-probing after a failed
probe.

Also: hoist the full-creation probe above its cancel gate so the gate stays
adjacent to createWorktree; pin the retry-after-failure, concurrent-ensure
and missing-bridge contracts; drop a stale microtask tick and correct two
comments that no longer described the code.

* test(native-chat): pin the cancel gate around the capability probe

The probe added an await to two composer creation paths. Full creation had
no gate between the route decision and createWorktree, so the earlier
revision opened a window where a dismissed composer still created a
worktree; the hoist that closed it was unpinned. Quick creation already
gated immediately before runBackgroundWorktreeCreation, so its inline
await is safe — pin that too, since nothing asserted it.

Both tests fail against origin/main (no probe) and the full-creation one
fails against the pre-hoist revision.

* fix(native-chat): close the folder-create cancel window the probe opened

The probe added the first `await` inside `submitFolderWorkspaceCreate`. On
`main` that function ran straight through to `createFolderWorkspace` with no
suspension of its own, so its caller's `isSubmissionCancelled()` gate and the
create call sat in the same turn. With the probe inline, a composer dismissed
while the probe is in flight still creates the folder workspace and launches
an agent — the same defect the full-creation hoist fixed on the git path.

Resolve capabilities in `folder-submit-orchestration` above its existing gate
and hand them down, so the create path's prefix is synchronous again. The
parameter stays optional: a caller without a cancel gate keeps the probe.

Both new tests fail against `origin/main` and against this branch's previous
head; the cancel-window one still fails with its probe-pending assertion
removed, so it pins the create, not just the probe.

* refactor(native-chat): require pre-resolved capabilities on the folder create path

The cancel-window fix in f492064432 left its invariant -- a caller that gates
on cancellation must resolve capabilities above its gate -- enforced only by a
comment, because `hostCapabilities` stayed optional with an inline probe as the
fallback. A future caller that owns a cancel gate and forgets the parameter
would silently reopen the window twice fixed already, and nothing would catch
it: the caller census test pins `resolveAgentLaunchRoute` callers, not this
function's, and `exactOptionalPropertyTypes` is off so even an explicit
`undefined` is legal.

Make it required and drop the now-unreachable inline probe. The sole
production caller already passes it, so runtime behaviour is unchanged: the
old ternary never evaluated its `await` when a value was supplied.

`null` keeps its meaning -- probed, genuinely unknown -- and still degrades to
the legacy route; only absence becomes impossible. The launch-route test that
covered the removed probe is replaced by one pinning that `null` contract with
the cache and the bridge both holding the structured capability, so only the
handed-in value can produce the legacy outcome. The cases in the sibling suite
are not about the route, so they go through one typed wrapper that supplies the
unknown answer rather than repeating it 21 times.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-13 14:26:27 -07:00