* fix(feedback): pre-include Orca version and OS in errors and feedback
Make terminal errors and Send Feedback carry easy-to-copy client
environment details so bug reports include build and platform context
without a follow-up ask.
* fix(feedback): satisfy exhaustive-deps in environment prefill hook
Destructure hook params so useEffect/useLayoutEffect dependency lists
are complete and the changed-code quality gate passes.
* fix(preload): stop importing node:os in sandboxed preload
Sandboxed Electron preloads cannot require node:os. That import crashed
the whole preload script, leaving window.api undefined and taking down
App chrome (dock badge, preflight). Keep platform.get on process APIs
only, and guard best-effort badge/preflight callers when api is missing.
* fix(feedback): count text typed below the env footer
Strip only the prefilled Orca/OS/Shell block for Send validation so
users who click past the footer and type can still submit.
## What this changes
Two renderer inputs were trusted because their TypeScript types said they were valid. Both are now validated at the boundary that owns them.
**Terminal cursor style.** `normalizeTerminalCursorStyleDefault` preserved any non-null migration-stamped value without checking it against the actual enum, so a runtime value outside `bar | block | underline` survived into `terminal.options.cursorStyle`. It now enum-checks and falls back to `block`. Applied on both read paths (desktop `Store` load, web `getStoredSettings`) and both write paths (`Store.updateSettings`, web `settings.set`), so an unsupported value cannot reach persistence, the `settings:changed` publication, or xterm.
**Plugin language packs.** The renderer stored the `listLanguagePacks()` IPC result without a runtime check, so a non-array response was assigned to state and later crashed its first array consumer on `.find`. Ingress now accepts only an array, drops members failing `isPluginLanguagePackRegistration`, keeps valid siblings in their original order, and logs which failure mode occurred. The registration guard requires `resourceLanguage` to equal `pluginLanguageResourceId(id)`, which keeps a pack with a missing or inconsistent identity out of i18next — that shape reproduces as `TypeError: Cannot read properties of undefined (reading 'includes')` with the guard removed.
Catalog validation is shape-only on this path (`validatePluginLanguagePackCatalogShape`), so revalidating an already-parsed catalog does not allocate a second copy of it. `isCatalogObject` also now requires a plain-object prototype, and the walk rejects repeated or cyclic object references.
## Root cause: partially known
Worth stating plainly, because the fix is defensive rather than causal:
- The non-array pack container and the out-of-enum cursor value are both reproduced directly by tests.
- Two additional field stacks are *consistent with* an invalid `resourceLanguage` and are blocked by this guard, but the reports do not prove that malformed registrations were their source.
- No production writer in current code or history emits an out-of-enum cursor style. The Ghostty config importer (`src/main/ghostty/mapper.ts`) already rejects unsupported `cursor-style` values. The original writer is unidentified.
Every in-tree producer supplies valid data, so these guards are no-ops on the current happy path. They are boundary hardening against a mutation we have not located, not a repair of a known writer.
## Trade-offs
- Malformed registrations are skipped with one warning rather than surfaced in the UI. Valid siblings keep their identity and order. Note the main-process registry already reports per-plugin parse errors, so this path only catches corruption after main has validated.
- Renderer ingress walks each catalog once per lazy load or `contentPacksChanged`. It does not run on render or terminal-output paths, but a maximum-size burst still costs tens of milliseconds synchronously. Pack count and plugin-ID length remain uncapped.
- An unsupported cursor value now renders and persists as `block`, so anyone relying on an undocumented third-party value loses it.
- Loading settings whose cursor style is absent or invalid now marks state dirty and rewrites once, matching the existing `terminalRightClickToPasteDefaultedForPlatform` pattern above it.
## Compatibility
`listLanguagePacks` is local `ipcMain`/`ipcRenderer` only and is not implemented in the web build, so nothing here crosses the remote wire — no RPC parameter, publication schema, stream frame, opcode, or capability changed. Validation is receiver-side at existing boundaries. Nothing is platform-, shell-, PTY-, native-module-, SSH-, WSL-, or worktree-dependent, and the web build normalizes on both read and write. Malformed plugin data is rejected before i18next sees it; no new permission, network path, executable input, or persistence schema was added.
## Verification
Locally on the final head: the 5 touched test files pass (591 tests), plus node and web typecheck, oxlint, and oxfmt. All GitHub required checks pass, including Windows packaging, static analysis, Git and wire compatibility, shell contracts, and all 32 Node 24/26 shards. The path-gated E2E job is skipped after its detector passed.
Scope note: an earlier revision generalized this hardening to `Project.sourceRepoIds` and profile transfer without field evidence. That scope was removed; the diff is cursor and plugin paths only.
Forward the renderer's already-pinned {mergeBase, headOid} to the SSH relay so a single-file branch diff reads the two blobs directly instead of rediscovering live HEAD. Six sequential git processes become two concurrent reads, and a branch move mid-review no longer changes which revision is displayed.
Equivalence with the legacy route is proven against real Git across 14 change types; wire compatibility is proven over a real SSH socket against relay bundles built from main and from the pre-merge-base.
Once V8 optimizes the calling function, `String.prototype.codePointAt` on a
sliced string pairs a trailing high surrogate with the code unit that follows
the SLICE inside its parent, returning a code point the string does not
contain. Every UTF-8 byte scan built on `codePointAt` therefore reported one
byte too many for a prefix slice cut mid-pair, but only after tier-up, which
is what made terminal-stream-byte-length.test.ts fail intermittently on the
same commit.
Read the units explicitly with `charCodeAt`, which stays bounds-correct in
every tier, via a shared `readUtf8CodePointAt`.
* perf(native-chat): suspend hidden transcript streams
* fix(native-chat): keep assembly renders pure
* fix(native-chat): keep a transient error from stranding a revealed chat
- An error snapshot frame no longer latches frameArrived, so the in-flight
read can still seed the pane instead of leaving it on the error surface.
- Retained history is shown over a full-pane read error for the same source.
- The paged read window survives a hide/reveal; only a source change resets it.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): match the trimmed native-chat retention signature
The shared retention type no longer takes `loading`, so mobile's call was an
excess-property error that broke the mobile typecheck job. Dropping it also
lets mobile inherit the desktop behavior: a stream error or dropped client
keeps the last transcript instead of swapping it for the error empty state.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): keep the repos array identity across no-op refetches
reconcileFetchedRepos deliberately returns the previous array when a refetch
changes nothing, so identity-keyed memos can skip work. Two later steps in the
same chain copied unconditionally and threw that identity away:
- reconcileReadoptedSshRepoRows spread the input on its no-prune path, which is
the common case.
- applyManualRepoOrder allocated a fresh array even when the saved order moved
nothing.
Both now return the input when they change nothing, so state.repos stays
referentially stable through fetchRepos, fetchRuntimeEnvironmentRepos,
fetchReposForAllHosts, and hydratePersistedUI.
Return type stays Repo[] with the same cast reconcileFetchedRepos already uses;
all four call sites only read the result.
Co-authored-by: Orca <help@stably.ai>
* refactor(renderer): make the store repos array readonly at the type level
Preserving the repos array identity means handing callers the same array that
is live store state, which previously relied on `as Repo[]` casts to launder
readonly inputs back into a mutable field. A cast is a footgun: the next person
to add a .push or .sort corrupts store state with no type error.
Widen RepoSlice['repos'] to readonly Repo[] and propagate honestly. Consumers
that only read take readonly Repo[]; genuine local accumulators are annotated
Repo[] and built from copies.
Removes all four pre-existing `as Repo[]` casts in the reconcile chain
(repo-identity-reconcile, superseded-ssh-repo-rows, manual-repo-order x2) —
this lands with fewer casts than main has today.
Type-only change; no runtime behavior differs.
Co-authored-by: Orca <help@stably.ai>
* fix(renderer): compare nested repo fields so reconciliation actually fires
Preserving the repos array identity was inert. reconcileFetchedRepos compares
repo fields with !==, but every repo the renderer receives carries nested
records that are new objects on every fetch:
- main's hydrateRepo unconditionally rebuilds hookSettings for every repo,
even a pristine one (persistence.ts:5109)
- IPC structured-clone (and the JSON hop for SSH/runtime hosts) reclones
gitRemoteIdentity, upstream, repoIcon, and the path arrays
So every repo compared unequal, `identical` went false on every refresh, and
the array was rebuilt regardless of the copies removed in the parent commit.
Compare nested plain records structurally instead. They are small sanitized
values; anything non-plain falls back to reference equality.
The end-to-end test previously passed for a fixture-only reason: its repo had
five scalar fields and the mock returned the same object both calls, the one
shape production never produces. It now uses a production-shaped repo and
fails without this change.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(settings): paste bash-native skill setup under WSL PTY
WSL worktree setup terminals force wsl.exe even when shellOverride is
powershell.exe. Auto-pasting the PowerShell `& { wsl.exe ... }` wrapper
into bash fails with a leading-& syntax error (#13305). Rewrite that
wrapper to a bash login-shell script for setup-terminal paste only;
clipboard copy still keeps the PS host wrapper for manual use outside Orca.
* fix(settings): align skill paste with resolved PTY shell
* fix(onboarding): keep shell preparation out of render
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* Fix static analysis page stuck in loading state
- Bound check-details requests with 30s timeout, matching remote RPC budget
- Track request IDs to discard stale responses when context changes
- Propagate githubRepository through store and components for proper routing
- Add retry button for failed check-details loads
- Improve accessibility with ARIA labels for loading and error states
* Fix static analysis page stuck in loading state
When an open check-details tab's repository is removed, the loading
state would continue indefinitely because the fetch was still being
triggered. Prevent the fetch call in this scenario to unblock the UI.
Also migrates translation keys to obfuscated identifiers.
* Fix static analysis page stuck in loading state
Add deadline-based timeouts and request ID tracking to prevent stale responses
from freezing the checks panel. Include abort signal propagation throughout the
request chain and provide retry UI for failed check details loads.
* fix(checks): prevent loading state from getting stuck on retry
- Consolidate mount checks into a helper function
- Details now clear when a new request begins
- Add i18n strings for retry status
* fix(terminal): skip a too-wide alt frame on snapshot replay
Reopening a parked worktree could paint a stale full-width TUI frame
through a narrower viewport, leaving clipped gutter fragments and
mid-word omissions until the live application repainted.
Replay pins xterm to the snapshot grid so soft-wrapped normal-buffer
history stays exact (#7279), then the post-replay fit returns the pane
to its container grid. Alternate buffers have no scrollback and do not
reflow; an absolutely positioned frame remains at its capture layout,
so narrowing exposes only clipped portions of those fixed-grid rows.
Skip only the visual frame when its capture is wider than the grid the
fit will land on. The alt buffer is still entered and cleared, so the
resize signal lands on a clean screen that the live application can
repaint. Equal-width and wider restores retain the frame, while normal
history always replays at its capture grid before fitting.
The target width comes from proposeDimensions, not terminal.cols: an
unfitted pane can still read xterm's default grid even when its actual
container matches the capture.
* fix(terminal): preserve offline SSH prepaint frame
* fix(terminal): drop a too-wide daemon alt frame on reattach
The renderer-only gate did not run on the user-visible remount path.
Instrumentation showed that daemon-connectResult-snapshot won before
the model-snapshot branches, so the composed snapshot painted in full
and the later narrower fit exposed its stale fixed-grid frame clipped
at the new viewport.
The daemon branch could not omit only the visual frame while main sent
one merged string. Publish the normal-buffer/mode prefix and visual alt
frame as additive optional metadata while retaining the merged snapshot
for mixed-version fallback. New renderers can keep history and restore
state without painting a frame captured for a wider grid.
Replay ordering remains capture-grid, write, then fit so normal-buffer
soft wrapping stays exact. The application owns the foreign-width alt
frame and repaints it after the resize signal rather than Orca trying to
transform an absolutely positioned screen.
* fix(terminal): preserve split daemon snapshot payload
* fix(terminal): preserve live state when dropping alt frame
* fix(terminal): restore DECOM cursor state exactly
* fix(terminal): preserve ordinary snapshot bytes
* test(terminal): cover fixed-grid alt replay resize
* fix(terminal): repaint after dropping mismatched frames
A hidden snapshot can omit an alternate-screen frame before the pane has a measurable target grid. If reveal later lands on the capture grid, a same-size PTY resize emits no SIGWINCH, so pulse the local PTY size whenever that frame was skipped.\n\nKeep performSafeFit's measurable-pane contract intact, publish daemon snapshot prefix and frame as explicit optional strings, and fall back to the merged payload when either field is absent. Cold owner-gone restores now omit a mismatched frame while retaining history and fresh-shell reset treatment; offline SSH preconnect remains unchanged.\n\nPin the vendored SerializeAddon out-of-range-row behavior used to capture live SGR state.
* fix(repo-icon): keep a renamed fork's own owner avatar
Fork repos always took the upstream owner's avatar, so a renamed fork
showed its parent project's logo. Same-name forks (personal copies)
still prefer the upstream owner; renamed forks now keep their origin
owner across auto-detect, the startup backfill, and the settings
avatar refresh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(repo-icon): re-read repo state before backfill avatar write
The startup backfill computed icon updates from a pre-loop snapshot, so
an icon chosen in settings while the upstream/origin probes were pending
could be clobbered. Re-read the repo after the probes and only migrate
an icon that is still the auto-detected GitHub avatar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(repo-icon): own the fork avatar rule in one shared selector
The renamed-fork rule was written out twice — once in the main-process
auto-detect and once in the renderer refresh — so the two copies could
drift. Move it next to `githubAvatarIcon` as `githubAvatarSlug`, which
collapses the renderer resolver to a single unbranched path.
Also stop swallowing a rejected origin probe: it cannot tell a renamed
fork from a same-name one, so degrading to the upstream owner would flip
a renamed fork's stored avatar back to the parent's. Letting it propagate
keeps the stored icon, matching how the non-fork path already behaved.
Adds coverage for the startup backfill, the third decision point the fix
claims, which had none.
* test(repo-icon): cover pending backfill icon change
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>