Reconciles this PR's shared extraction with STA-3330 (#12369), which changed the
snapshot's caller contract, and with the per-terminal write lock that landed from
the STA-3333 splits.
- The snapshot builder no longer self-heals an unlisted tracked model into its own
choices; callers reconcile it in first. #12369 added `withTrackedModel` in the
renderer for that. It is pure logic both platforms need, so it moves to
`src/shared` as `withTrackedNativeChatModel`, desktop imports it instead of
keeping a copy, and every mobile consumer (snapshot, command build, option
lookup, typed-command recording) now goes through the same reconciled list.
- Typed model values are validated against the active list AND the seed. Validating
against the active list alone would have rejected an alias this host's CLI no
longer lists — exactly the case #12369's reconciliation exists to preserve.
- `dispatchCommand` now takes the per-terminal write lock that main's
`answerQuestion` uses. That is the real mutual exclusion for the picker-vs-send
interleave, replacing the UI-level guard as the load-bearing fix; the disabled
pills stay as the affordance.
* feat(native-chat): track Claude models from the installed CLI per host (STA-3330)
The Claude seed no longer pins version labels to aliases that resolve
differently across CLI versions, and the catalog now defines listModels
backed by a one-shot list_models control request over --print stream-json.
Hosts whose CLI predates the request answer with a control error and keep
the seed. Discovery also feeds Source Control AI via the commit-message
spec, and the /model echo detector matches resolved model names.
* fix(native-chat): preserve discovered Claude capabilities
* fix(native-chat): tolerate malformed Claude model entries
* fix(native-chat): discover models in folder workspaces
* fix(native-chat): trust discovered Claude capabilities
* fix(native-chat): remove Claude model fallbacks
* fix(native-chat): keep the Claude model picker rendered
The Claude picker rendered nothing until the per-host `list_models` probe
returned, so it popped in ~1s after mount and never appeared at all when
the probe failed — an old CLI without `list_models`, no `claude` on PATH,
or an older remote runtime whose response omits `catalogOrigin`.
Restore the version-neutral family seed as the starting list; discovery
still replaces it wholesale on success, so a host with a real catalog
never shows an obsolete hardcoded row.
Separately, the tracked model could fall outside the active list: the
terminal header scrape yields family ids (`opus`) while a current CLI
lists `opus[1m]` and no plain `opus`. That blanked the picker trigger and
dropped the model's effort and fast-mode controls. Reconcile the tracked
id into the active list once, so the snapshot, the appliers, and typed
command recording all see a labelled, operable row for it.
* fix(mobile): make native-chat file links and path citations tappable (STA-3331)
- Linkify POSIX absolute paths in chat prose (leading-/ regex alternative;
URL guard now keys off the char before the matched slash)
- Parse agent-style path:line(:col) citations in prose, code spans, and the
open flow; line/column ride into the mobile file preview route
- Route non-web markdown hrefs (file: URIs, relative/absolute paths) to the
file opener instead of silently dropping them; unknown schemes stay dead
- Resolve chat paths against the worktree root, not the terminal's live cwd
- Reuse the terminal tap-to-open flow for chat taps (haptic, preview route,
tab activation with retries) via a shared identity-stable hook, and toast
on misses instead of silent no-ops
- Keep snake_case paths whole (intraword underscores are literal text),
scan bold/italic/strike spans for paths, split trailing punctuation off
autolinks, and let taps land while the composer keyboard is up
* fix(mobile): harden chat file tap handling
* refactor(chat): share native chat href routing
* fix(mobile): detect files directly under path roots
* fix(mobile): keep inline tokens and dunder paths intact around emphasis
Review follow-ups on the chat file-link work:
- A rejected intraword `_` token left the scan index past its closing
underscore, so every inline token between two snake_case words was
swallowed and rendered as literal source — including markdown links,
which became untappable. Rescan from just past the opening delimiter.
- Treat a path separator as an intraword flank so `src/__init__.py` and
`a/__tests__/x.ts` stay whole; previously they rendered as bold plus a
remnant that the new absolute-root pattern turned into a tap on `/x.ts`.
- Bound the `:line(:col)` tail so `src/app.ts:1e3` and `:80%` no longer
parse a line number, while a cited range still opens its first line.
- Route chat tap failures through the composer banner (toast fallback):
chat taps happen with the keyboard up, which covers the toast.
- Drop the tap-handler mirror's dep list; the call site rebuilds its
accessors every render, so it could never skip on a route that
rerenders per keystroke.
* Revert "fix(mobile): keep inline tokens and dunder paths intact around emphasis"
This reverts commit 308bfaf22b.
* fix(mobile): preserve chat file-link parsing and feedback
Snap and Linuxbrew don't ship installers for Darwin or BSD, so seeding
their PATH entries on those platforms adds phantom directories every
spawn must stat. Keep them on Linux only, while preserving Nix and
Homebrew across all platforms as they have multi-platform support.
* feat(sidebar): link Linear issues from Edit Worktree Details
The Issue field only accepted GitHub numbers, so a workspace tracking a
Linear issue had no way to say so from the dialog — the link could only be
set at creation time or through `orca worktree set --linear-issue`.
Replaces the field with one provider-aware row: a chip suffix inside the
input selects GitHub or Linear, and pasting a URL flips the chip to match.
A bare key never steers the provider — Linear and Jira issue keys are
byte-identical in shape, so shape alone cannot decide one.
One issue per workspace. A changed field displaces the other provider's
slot and the row names what Save is about to unlink. GitLab and Jira links
are left alone: the row cannot display them, and nothing else in the UI
could restore one it dropped.
- Folder workspaces read-only (their link is creation-time only)
- Remote runtimes assert the capability before writing or clearing, since
`worktree.set` parses in strip mode and would silently drop the keys
- `updateWorktreeMeta` now reports failure so the dialog can stay open
instead of closing over a save that refetch reverted
- Parses are length-bounded — `matchGitHubItemPath` strips trailing
slashes with an unanchored regex that is quadratic on a large paste
* fix(sidebar): respect one-issue-per-workspace rule conditionally
Only clear displaced issue links when they actually existed, preventing
unnecessary Linear keys in GitHub-only workspaces. Skip comment updates
when unchanged to avoid workspace reordering. Add accessibility to
displacement messages and improve folder workspace error handling.
* fix(sidebar): resolve workspace ambiguity and improve Linear issue linki
The same workspace ID can exist under multiple hosts — the owner index reports
this as ambiguous rather than guessing. Dialog callers now pass their repoId so
lookups are unambiguous. Linear identifiers without an org key are resolved
across all workspaces (not just the active organization). Added race-condition
protection for async issue lookups and better change detection to avoid clearing
work-item titles when re-saving an identifier in different spelling.
* feat(sidebar): inline SSH reconnect control on workspace cards
Replaces the blocking SshDisconnectedDialog with an inline pill in the
workspace card title row, and unifies the SSH connect vocabulary across the
sidebar card, terminal overlay, host-header menu, and status-bar row.
- new src/renderer/src/ssh/ modules: typechecker-total status predicates
(recoverability), a shared in-flight connect registry, the promoted UI
connect timeout, and the shared connect verb table
- WorktreeCardSshHostControl: one 16px pill shape for every state, icon-only
in compact/new card modes, passive glyph for connected/null/removed hosts
- migrates the four duplicated status predicates to the shared module
- deletes SshDisconnectedDialog (and its window-capture Enter handler)
* fix(ssh): address review round 1 on the inline reconnect control
- reconnect surfaces get a 180s UI connect fence instead of the composer's
20s: main allows 120s for an interactive passphrase before the 30s connect
even starts, so the short cap toasted "timed out" and ran the stale-metadata
resync against a host that was about to connect fine
- carries the existing es/ja/ko/zh translations onto the shared connect verbs
(they were en-only, regressing four locales) and drops the dead
SshDisconnectedDialog key namespace
- migrates the three remaining copies of the connecting predicate
(SshStatusSegment, SshTargetRow, external-automation-source-availability)
- SshTargetStatusRow and SshTargetRow now join the shared in-flight registry,
so a connect started on one surface disables the others immediately
- drops the per-card aria-live region that duplicated the button's own label
* fix(ssh): address review round 2 on the inline reconnect control
- guard sshTargetRemoved on isRuntimeOwnedSshTargetId: runtime-owned targets
are filtered out of ssh:listTargets, so absence is not evidence of removal
(every ephemeral-VM card otherwise read "SSH host removed")
- drop the card-root SSH dim: an ancestor opacity composited the control's
destructive tint and spinner down to an illegible alpha
- aria-disabled instead of disabled while connecting, so the pill stays
pointer-reachable for its tooltip and focus
- stop Enter/Space propagation so WorktreeList's container key handler does
not steal activation
- prefer a live connection over a stale removal tombstone
- reclaim title width in labeled mode (no leading icon, no min-width floor)
- register composer connects in the shared in-flight registry
* temp checkin of files
* fix(ssh): hold connect lock for backend request, not UI timeout
Replace manual begin/end pairs with trackSshConnect wrapper that holds
the lock for the full backend request duration. Previously, the lock
was released after the UI timeout fired, even though the backend was
still dialing — a second click on any surface for this host would
trigger a second connect and a duplicate credential prompt on
passphrase-gated targets. The wrapper survives unmount, unlike a
finally block in a component handler.
* fix(ssh): scope connect locks to their acquisition, not target
A tracked request settling after its lock was cleared (via reset or
explicit end) must not unlock a newer connect on the same target. Lock
IDs ensure only the owning acquisition releases, preventing stale
settlements from clearing active locks.
Busy panes render mid-composition (title updates drive renders), and the
modified-Enter chord owner must survive those renders or held-modifier
CJK input leaks newlines. Wires the hook TerminalPane-shaped (dep objects
rebuilt per render) and interleaves re-renders through hold, composition,
and auto-repeat; fails under pre-fix churn wiring (verified: redispatch
not absorbed), passes with memoized actions.
* fix(mobile): serialize native chat PTY writes
Two composed native-chat write sequences into one PTY interleaved their
bytes: the per-terminal send-in-flight guard lived inside the image
attachments hook, so ask answers, permission choices, and question
answers wrote straight past it. Move the guard to a shared module-scope
write lock (the terminal outlives any one screen) and take it on all
four paths.
Ask answers additionally queue behind the prior chain's RPC rather than
racing it, and a superseding answer is fenced once a key has actually
landed: an accepted or ambiguously-delivered keystroke already moved the
remote selector, so a replacement's from-scratch key plan would answer
the wrong question. A superseding answer inherits the cancelled chain's
hold (refcounted, last chain out releases) so changing your mind
mid-answer still works, and Stop/cancel stay unguarded so an interrupt
can never deadlock against the send it cancels.
Refs STA-3333.
* fix(mobile): report a fenced native-chat answer instead of dropping it
The fence added for superseding Ask answers returned false with no
onSendError, and the card re-enables on a false result — so a queued
answer vanished with no banner and no toast, looking exactly like a dead
button. Every other bail in answerAsk reports. Keep the generation-
mismatch branch silent: a newer answer owns the error surface.
Also covers the hold-count release, which had no test at all: replacing
it with an unconditional release left all 58 tests green while silently
reopening the terminal mid-sequence — the exact interleave this PR fixes.
* fix(mobile): stop a superseded answer from clearing the fence banner
A chain that finished its key plan reported `true` even after a newer answer
superseded it. The route sends through useNativeChatAcceptedAction, whose
accepted callback retires the send-error banner — and that callback runs after
the successor's fence report, because finishTurn() fires in `finally`, before
the chain's own promise settles. So the successful predecessor deterministically
wiped the fence message the successor had just raised: every healthy write took
that branch, which made the previous commit's report vacuous exactly where it
mattered.
A superseded chain now reports no success, matching every other supersession
checkpoint in this hook.
* fix(mobile): fence on the turn slot, not the generation counter
The supersession guards added in c36f2ce14e read `generationRef`, but
`cancelPending()` bumps that counter from three callers with no successor
chain: Stop, ask-cancel, and the lease effect that fires on every
disconnect. An answer whose key had already landed then reported false —
`fail()` never runs on an accepted write, so the card stayed up silent
with Submit re-enabled, and the retry wrote the same key into a selector
that key had already advanced.
Test the turn slot instead: a successor takes it synchronously before
its first await and cannot resolve ahead of this chain, so it means
"a successor took over" exactly, without catching bare cancellations.
* fix(mobile): report a fenced answer when a dropped lease bumps the generation
The fence-report guard tested generationRef, which answers "was I
cancelled", not "did a successor replace me". Stop and ask-cancel both
write an Escape that clears the ask card, so their silence is harmless.
A dropped input lease bumps the same counter and writes nothing: the
card stays up with Submit re-enabled while the predecessor's option key
has already advanced the live selector, so the retry double-steps it.
Gate on the turn slot, matching the two success returns.
* test(mobile): cover the turn-slot release after a landed answer
The slot delete in the finally was the one line in this hook no test
killed: without it a landed answer's resolved-false turn stays parked,
so every later answer on that handle reads a fenced predecessor and the
ask card dies after its first use.
* test(mobile): keep PTY write locks terminal-scoped
* fix(mobile): keep repeated-prefix chat replies streaming
Text alone can't tell "the transcript caught up with this stream" from
"a new reply repeats the previous turn's prefix", so the old suppress-on-
prefix rule swallowed genuine repeated replies. A stateful gate remembers
which transcript tail predates the current stream segment and hides the
bubble only when that tail moved during the segment, scoped to the active
host/workspace/tab/session so a swapped chat can't inherit a baseline.
Refs STA-3333.
* fix(mobile): keep the streaming gate alive across chat/terminal toggles
The gate lived in MobileNativeChatView, but MobileNativeChatOverlay returns
null whenever the user peeks at the terminal — that unmounts the view and
throws the baseline away, so the repeated-prefix reply was swallowed again on
the way back. Move the gate (and the fold memo it reads) up to the overlay,
which stays mounted across those toggles.
While hidden the transcript is empty and the throttled stream reports no text,
which the gate would have read as "idle" and re-anchored on. Pass the agent's
working state so a textless tick inside a live segment holds the baseline
instead. The scope key is now keyed off the tab rather than the view-gated
chat resolution, so it survives the toggle too; streamIdentity keeps its exact
previous value because the delayed-send guards compare against it.
Also drops a dead disjunct in the caught-up test: a null baseline is already
unequal to every real tail id.
* test(mobile): model the real re-show ordering in the streaming-gate tests
The overlay regression test replayed the transcript before the stream text on
the way back from the terminal view. That ordering is backwards: the session
withholds `messages` until a fresh read settles (an RPC round trip) while the
throttled stream text returns in ~50ms — and with the transcript already back,
a gate that got discarded on the toggle still passes. Replay the real order,
which pins the gate's lifetime as intended.
Swaps the hidden-gap duplicate case for the in-view one (a tool frame clears
the assistant text mid-turn), which is where the hold actually earns its keep;
the hidden-gap direction stays covered at the gate level.
* fix(mobile): stop the streaming gate adopting a reply as its own history
A textless status tick was re-anchoring the gate's pre-stream baseline, so
two paths still rendered wrong:
- The reply's transcript push beats its throttled status text whenever the
pane stays `working` past the turn (a live subagent or background task).
The tick in between adopted the just-landed reply as history, and the
status text that followed rendered it a second time — a duplicate bubble,
and a regression against main's suppress-on-prefix rule.
- Peeking at the terminal between turns empties the transcript. That empty
tail was adopted as the baseline, so the next repeated-prefix reply was
swallowed again — the bug this PR exists to fix.
Only a tick that carries a real tail and sits outside a live turn anchors
now, with an exception for a gate that has never anchored: mounted mid-turn,
the first real tail it sees is the best history it will ever get.
Also drop `buildMobileNativeChatData`, a test-only builder this PR had wired
the new gate into; its green test asserted the exact suppression this PR
removes. Its fold/pending/image coverage moves to the builder the view calls.
* test(mobile): pin the textless anchor's text reset
Mutation testing found the `prevText` reset on an anchoring textless tick
unpinned: keeping the previous turn's text there reads the next turn's
opener as a new segment, re-anchors onto the reply that just landed, and
renders it a second time — the same duplicate-bubble class already fixed
twice on this branch.
* Add search by name, project, and prompt for automations
Split the monolithic automations page into focused modules: extract dialog logic, list panel rendering, search functionality, and utility helpers into separate files. Introduce deferred search matching to keep the input responsive, with proper bounds checking to reject oversized pastes. The page stays unfiltered when search is inactive or too large, preserving the original list view in those cases.
* fix(i18n): add missing automation search localization keys
Sync en.json keys used by AutomationListSearchField and the no-matches empty state so static analysis localization catalog check passes.
* Localize remaining automation strings and optimize search
- Add 12 i18n keys for automation labels, counts, and usage display
- Extract AutomationPaneTab and SelectedExternalRunPage types to shared automation-page-state module
- Optimize search fingerprint by truncating prompts to indexed prefix for bounded performance per tick
- Improve escape-key handling in search field to clear input before blurring
- Remove deprecated getAutomationListSearchQuery function
The build-time repair layer carried overrides that rewrite already-correct CJK
values back to English. Most are inert against today's catalogs but fire on the
next regeneration, so they read as latent regressions rather than policy:
- zh workspace status picker (Play/Flag/Zinc/Rose/Emerald/Amber/Violet/Sky/
Blue/Neutral) and `sheet`/`page` were pinned to English while every sibling
option, and ko/ja/es, stay translated — half a Chinese picker.
- The zh `蓝色的`/`琥珀色`/`中性的` phrase fixes correctly flagged the adjectival
的 form but replaced it with English instead of the bare color noun.
- ja `Play` was pinned to English though the catalog already reads 再生.
- A value-wide zh `Open: '进行中'` mapped every "Open" to "in progress",
including the button that opens an MCP config file. "Open" is a verb (打开)
on buttons and a state (开放) beside 已关闭, so no single mapping fits.
Catalog corrections in the same area:
- The GitHub/PR state picker key override read 진행 중 / 进行中 for ko and zh
while ja already had the correct オープン; now 열림 / 开放, matching 닫힘 /
已关闭 on the sibling entry.
- The terminal cursor-color group is the on-screen cursor, not the Cursor
editor; ja already had カーソル, ko/zh now get 커서/光标 instead of "Cursor".
- Tailwind swatch labels 天空 (the sky) and 锌 (the metal) do not read as
colors; now 天蓝/锌灰, and ja 空 becomes 空色.
- The ko disk-usage heading was pinned to bare "Space" while its own
description says 저장 공간; both now use 저장 공간.
Two policy tests pinned the Play de-localization. They diagnosed the input
correctly — 玩 / 遊ぶ are wrong for a play icon — so the expectations move to
播放 / 再生 rather than English.
Destructive drift (localized -> English on regeneration) drops from 40 to 21
for zh, 6 to 5 for ja, and 5 to 3 for ko. What remains is deliberate: search
qualifiers, path and filename literals, and product names.
Cherry-picked the unambiguous subset of #8816:
- 디렉토리 -> 디렉터리 and 쉘 -> 셸, the standard loanword transcriptions
- Milestones: 이정표 (a signpost) -> 마일스톤, the Linear product noun
- Permission granted: 허가 (a licence) -> 권한
- "privacy envelope" rendered as 봉투, a paper envelope
- commentTooLarge: 너무 커서 (too bulky) -> 너무 길어, across 9 composers
The rest of the PR is left out: 83 leaves are agent -> Agent recapitalizations
that fight the catalog convention, 17 are reverted by the repair policy, and
several change meaning (viewed -> 읽음 "read" on the GitHub file checkbox,
which means seen; "resolve PR base" read as fixing a problem; 차이점 -> diff,
which also breaks ko search recall).
Co-authored-by: tykimseoul <tykimseoul@gmail.com>
Co-authored-by: Orca <help@stably.ai>
Most of #5640 landed independently, but 12 strings were still falling through
to English: the ko "Diverged" pull-policy notice, and the ja source-control
primary-action blocked reasons.
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
Eight renderer call sites built Intl.RelativeTimeFormat(undefined, ...), which
resolves to the OS locale, so relative timestamps rendered in Korean on a
Korean-locale machine even with the UI language set to English. Unlike
DateTimeFormat, RelativeTimeFormat emits language words, so it must follow the
UI language.
Rebased onto main: the formatter now resolves through getIntlLocale() (#12105)
rather than i18n.resolvedLanguage, so the synthetic plugin<hex> resource
language cannot reach the constructor and throw. GitHubItemDialog and
PullRequestPage no longer own their formatter — main moved it into
work-item-state-presentation, which is converted instead, along with the newly
added site there.
Co-authored-by: moseoh <azqazq195@gmail.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
en.json carries ~190 keys per locale that the locale catalogs have not been
bootstrapped with yet, so every repair-locale-catalog run threw a TypeError
before doing any work. Skip missing leaves instead.
Split out of #11728 so the crash fix can land without the catalog
regeneration, which still needs native-speaker review.
Co-authored-by: Turtle-Hwan <turtlehwan@gmail.com>
Co-authored-by: Orca <help@stably.ai>
The override for SshTargetForm.137e88ce8d held a truncated relay-TTL sentence
ending mid-clause at "최대:", which renders "Timeout after disconnect
(seconds)" — the sibling key 55c56cf2c7 — not its own English source. #12192
only corrected the terminals token inside that wrong sentence. Point it at the
value ko.json already ships, so a catalog repair cannot overwrite the correct
string with the wrong one.
Fixes#12113.
shouldPreserveEnglishValue keyed on the English value, so any key whose source
string equalled a NEVER_TRANSLATE_VALUES entry was forced back to English on
every repair run — agent, commit, repo, terminal and Continue were all on that
list. Measured on a clean checkout: ko 279, ja 461, zh 1180, es 479 values
rewritten, the large majority destroying translator work.
17 generic terms move into locale-generic-ui-terms.mjs, and the brand revert now
skips a term's canonical rendering, so genuinely nonsensical forms (zh 回购,
ja/zh 端子, es Comprometerse) still fire while 터미널/커밋/エージェント survive.
Brand, path, and code tokens are untouched — MD -> 医学博士 and HEAD -> CABEZA
are why that list still earns its keep.
No catalog values change; every file is under config/scripts/.
Co-authored-by: AnddyAgudelo <44873492+AnddyAgudelo@users.noreply.github.com>
Nine ko values said something other than the English source. The riskiest is
SourceControl.6d7f2a47e5 "Discard folder", rendered as 폴더 삭제 ("delete
folder") next to a sibling delete-untracked action. Others: "Only branches Orca
named itself" read as "branches named Orca"; "staged changes" as 단계적
("phased"); "first-party cloud" as the mojibake 1方클라우드; "discard the
deletion" as "the deletion is deleted"; "Stage all changes" as a sentence
meaning "prepare"; and Recipes as 조리법 (cooking recipes).
EphemeralVmsPane.skillTitle is dropped from the PR's test and override — the
key was renamed to cloudVmSkillTitle on main, so the assertion would resolve to
undefined.
Co-authored-by: ShinSungkyu <kxu4583@naver.com>
The source-control primary action rendered 押す ("press") and 引く ("pull a
physical object") for Push/Pull. プッシュ/プル match the sibling フォースプッシュ
and 同期 labels. A guard test pins both so bootstrap re-translation cannot
silently regress them.
Co-authored-by: Iris-Fla <103801589+Iris-Fla@users.noreply.github.com>
The browser.loadFailure.* keys were still raw English in es/ja/ko/zh. en.json is
untouched; every {{value0}} token and the Orca/HTTPS brand terms are preserved.
13 of the zh keys were already covered by #12368, so only the 6 it did not
reach are taken here.
Co-authored-by: MumuTW <42820974+MumuTW@users.noreply.github.com>
185 zh values were still verbatim English, plus the 6 VoiceMicrophoneSetting
keys were missing. Placeholders, "X of Y" counts, and key ordering are
unchanged; the only edits to already-translated values are punctuation.
Applied at key level rather than as a branch merge — the PR was cut from an
older base and conflicted only on JSON context, with no value drift against
main.
Co-authored-by: 闲人 <38777313+qiuyongjin@users.noreply.github.com>
Co-authored-by: jake <qiu5630@163.com>
The badge value is pinned in locale-key-overrides.mjs, so the ko/zh fix from
#10664 would have been reverted by the next catalog repair. ja carried the same
defect — 新規 reads as "create new" — and is corrected alongside.
SidebarNav.c86d83b5c3 is the onboarding pill rendered beside Orca Mobile, so
"New" marks a new feature. Both locales had translated it as a create action —
ko 새로 만들기 ("create new"), zh 新建 ("create new") — which reads as a button.
The PR's other hunk (zh GH PR) already landed on main and resolved to a no-op.
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
The shared Automation tour copy was rendered without passing through
translate(), and the overlay surface hardcoded its default Next and Done
labels. Copy is keyed off the step id rather than its position, so inserting a
step ahead of them cannot shift the text onto the wrong step.
Co-authored-by: 5Hyeons <ohs2251@naver.com>
The description was assembled from a bare template literal, so it stayed
English under every language pack. It now lives in the catalog as two entries —
description with a {{shortcut}} placeholder and descriptionBase for the
invert-on variant — following the copy-module pattern from #10991.
Co-authored-by: 조재중 <126754298+m-a-king@users.noreply.github.com>
Every card under Settings -> Integrations built its status pill from bare
literals inside the JSX call, so the pill stayed English beside a translated
description. audit-localization-coverage.mjs inspects JSX attributes and object
properties, not conditional expressions handed to a prop, so the gate stayed
green while the strings shipped untranslated.
The three token-configured cards share tokenProviderStatusLabel(); Gitea passes
optional: true because it works read-only without a token, which is why its
unconfigured state reads Optional setup rather than Not configured.
Co-authored-by: Evgenii <kumiro@me.com>
protectedTranslation refused every language-pack key under
auto.components.settings.plugin*, which caught 104 keys that carry no trust
meaning — section titles, empty states, Refresh, Add path. A 35-path exact
allowlist opens those while consent, provenance, and every *Failed string stay
protected; anything new stays protected until it is added deliberately.
PluginsSettingsSection.experimental is held back from the contributed
allowlist: the "Experimental" chip is a trust badge, which the module's own
boundary comment places out of scope.
Co-authored-by: Evgenii <kumiro@me.com>
formatTrackingSince() used the OS locale rather than the language chosen in
Settings. getIntlLocale() resolves the active i18n language to a BCP-47 tag,
mapping the synthetic plugin<hex> resource language that Intl rejects back to
the pack's real locale.
Co-authored-by: Evgenii <kumiro@me.com>
SidebarToolbar is a React.memo boundary whose props are shallow-equal on a
language switch, so without its own useTranslation() subscription it kept the
English copy it rendered at boot — the persisted locale is applied
asynchronously, after the lazy catalog loads.
Co-authored-by: 5Hyeons <ohs2251@naver.com>
D1 — a picker apply could interleave with a composer send. The composer already
blocks a text send while an apply is dispatching, but not the reverse: the host
spaces a send's body and its Enter ~500ms apart, so an apply tapped inside that
window was submitted as part of the user's prompt, and the pill then claimed a
model change that never ran as a command. The pickers render inside the composer,
so they now take its in-flight state directly — the same guard, mirrored.
D2 — an option was filed under the wrong model. `setTrackedSessionOption` resolves
the owning model when it commits, not when the command was built, and the report
effect mutates the same record off-queue. A report landing mid-dispatch therefore
recorded `/effort low` against the model it switched TO. Ports desktop's
supersession guard, which skips the commit when the baseline moved.
D3 — a command template's prefix also matches prose that starts with it, so
"/model is a weird word" tracked that prose as the current model, rendered it as
the pill label, and matched no catalog model, dropping every per-model option.
Parsed values are now canonicalized against the catalog; a typed value containing
whitespace is treated as a prompt rather than a command.
Perf — `/` on a Codex tab returned all 45 commands into a non-virtualized
ScrollView showing ~5, re-reconciled on every streaming tick above the transcript.
Capped at 12.
Also splits the row primitives out of MobileNativeChatSessionOptionPickers.tsx,
which the D1 guard pushed to 402 effective lines against a 400 cap.
Re-entering a chat tab re-delivers the same `agentStatus.model`, and the
reported-model effect re-applied it unconditionally — so picking a model, moving
to another tab, and coming back reverted the pill to the model the agent reported
at session start, which cannot have observed the `/model` sent after it. The
status stream reconnecting had the same effect.
A report is now only treated as evidence when the matched catalog id CHANGES for
that scope; a genuinely new report still supersedes a local pick. Mobile has no
screen read to confirm a switch against, so the repeat is all we can key off.
* fix(ports): keep the app responsive when security software slows process creation
Orca ran the workspace port scan's probe commands (lsof/ps on macOS,
netstat + powershell.exe on Windows) directly in the Electron main process.
libuv performs process creation inline on the calling event loop, which in
the main process is the browser UI thread, so an endpoint-security module
hooking CreateProcessW froze the whole window for the length of the spawn.
The same stall also produced a false diagnosis: the 4s command watchdog was
armed before execFile (local-workspace-port-scanner.ts:389 -> :410), so its
deadline had already passed by the time the command started. Every scan on a
hooked host reported a command timeout, tripping the 60s -> 5min backoff and
the "Port scanning is temporarily paused after a command timeout" banner even
though the commands themselves were healthy.
Probe commands now run on a lazily created, unref'd worker thread with FIFO
one-at-a-time dispatch, and the watchdog is armed after execFile returns so it
measures the command rather than the spawn. Node's own execFile timeout kill
(killed: true) is classified as a command timeout, keeping the backoff working
for genuine hangs. A scan that observes a stalled spawn skips its optional
metadata commands for that cycle, capping a hooked-host scan at roughly one
stall instead of three.
Closes#11161
* fix(ports): keep advertised URLs when a stalled spawn skips port metadata
Review follow-up on #11161. The stalled-spawn early return handed
scanWorkspacePorts raw ports with no cwd/commandLine, so every port failed
attribution and reconcileAdvertisedUrls told the watcher each worktree's
listeners had vanished. shouldEvictAfterScan then deleted every cached
advertised URL and broadcast a removal event; those URLs are only ever
captured from live PTY output, so the dev-server link was gone until the
server restarted.
The scanners now report metadataAvailable, and reconciliation is skipped for
a scan that never gathered attribution evidence. The skip is also no longer
self-perpetuating: on an EDR-hooked host every spawn stalls, so gating purely
on the current scan's spawnMs made every port permanently external (Stop
refused with 'Only workspace-owned local processes can be stopped here.').
Metadata is now re-probed on the scan after a skip, matching what the comment
and test name already claimed.
Co-authored-by: Orca <help@stably.ai>
* test(windows): stop a temp-dir lock from failing the CLI launcher smoke test
The native launcher assertions passed on windows-latest, but teardown's
rmSync raced Windows' release of the image handle on the exe the test had
just executed and threw EPERM, failing the job.
Cleanup now retries and, on Windows only, tolerates a residual lock code
instead of reporting it as a launcher regression.
Co-authored-by: Orca <help@stably.ai>
* fix(ports): scope the metadata skip away from attribution-dependent scans
The metadata skip was a process-wide parity flag, so Stop and the
localhost-label allowlist could land on a degraded cycle and reject a
port the panel had just shown as workspace-owned. Give those callers an
explicit requireMetadata option, and carry the previous cycle's listener
metadata forward so a skipped background scan no longer republishes
workspace ports as external.
Also pin the watchdog ordering: the stall in the execution test was
shorter than the watchdog budget, so a watchdog armed before execFile
still passed.
* build: guard worker-thread entries against electron imports (#11161)
Electron's module is not registered on worker threads, so
require("electron") throws "Cannot find module 'electron'" inside a
main-process worker and kills it at startup (verified on Electron 43.1.0).
plain-node-entry-guard covered only forked plain-Node entries, so the five
worker entries relied on hand-written "must stay electron-free" comments.
The port-scan probe worker is one import away from
port-scan-command-client.ts, which deliberately contains require('electron').
A violation there fails closed at runtime while every unit test still passes,
because the client's require is try/caught on the main thread.
Covers stt-worker, warp-theme-parser-worker,
session-scanner-opencode-sqlite-worker-entry, main-thread-hang-watchdog-entry
and port-scan-command-worker-entry. The scan is transitive over the emitted
chunk graph, so a shared chunk that reaches electron is caught too.
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* test(windows): retry teardown for main's duplicate-PATH launcher fixture
Main's new csc-compiled harness runs an exe from the temp tree, which is
exactly the image-handle/AV lock the merged-in removeFixtureTree retry exists
for; its bare rmSync would report a teardown lock as a launcher failure.
Co-authored-by: Orca <help@stably.ai>
* test(ports): pin the packaged-asar worker entry path
resolveWorkerEntryPath's packaged branch never runs in dev or e2e, so the path construction had no coverage. Split the electron read out of it and unit-test both layouts.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Surfaces the real install-failure cause instead of letting a failed elevation stall silently, and keeps the reconnect wait inside its total budget by recomputing the remaining time after each awaited RPC.
Relates to #11906 — this fixes the observability half. The functional half (a .deb/.rpm host cannot elevate and can never self-update) is unchanged, so the issue stays open.
`getScopedRecord` returned an existing record without re-inserting it, so the
per-tab record map evicted by insertion order rather than recency. A long-lived
active tab is the oldest key, so crossing the 32-scope cap silently dropped its
tracked model and reset the pill to "Model". Desktop's scope cache does
delete-then-set for exactly this reason.
Also moves the shared session-option tests to src/shared so the root suite runs
them (they only exercised src/shared logic the Electron renderer consumes, but
sat under mobile/ where only mobile's vitest project sees them), and restores
two "why" comments dropped while extracting the shared modules.
The 5-minute liveness tick from #12432 survived fenceAndCloseNow(), so a
tick landing between the pre-sign-out fence and the profile wipe could
briefly resurrect a broker (benign but soft — the entitlement check
bails afterward). The fence now clears the interval; the next auth
mutation re-arms it via refreshDemand, and the safety net otherwise
behaves identically. Found in release review of the #12432 cherry-pick.
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(ssh): handle owner displacement and graceful shutdown
SSH connections can reconnect with valid session proof after network loss or
device sleep. When the incumbent owner is still half-open, allow the
reconnecting client to displace it outright rather than wait for socket closure
— a window that may never close. Retain displaced deliveries for the new owner
to rotate. During app shutdown, drain SSH sessions without terminating recovery
operations, and retry pending owner grants in case a replacement commits mid-drain.
* fix(ssh): handle owner displacement and graceful shutdown
Make QuitTeardownStartGate a shared singleton so SSH connects use the
same shutdown fence as the main quit path. Track test-connection probes
to ensure they complete before final teardown. Guard owner displacement
to prevent stale owners from clearing recovery state claimed by newer
owners.
* fix(ssh): fix flaky test sync and add error code safety check
Test was using tick-based Promise.resolve() loops which don't guarantee
the async operation has started. Replace with signal-based synchronization
that waits for the actual lease flush. Also add nullish-coalescing to
error code check to prevent crashes if error is null or undefined.
* fix(ssh): fence reset transport opens during shutdown
* fix(ssh): keep recovery leases stable across reconnects
* fix(ssh): close transports owned by cancelled connect attempts
When a connect is cancelled after its transport has opened, that cancelled
attempt still owns the transport and must close it — otherwise it leaks. Add
disconnectConnection() to close by identity (not by target ID) so a cancelled
attempt closes only the transport it minted, without tearing down its
replacement's live transport. Track priorConnection to detect whether this
attempt opened a new transport or reused an existing one, and close only on
abandonment if this attempt owns the session.
* fix(ssh): fence old owner proofs and close superseded transports
When an owner reconnects with a new proof while an old one is still live,
the old proof is now fenced with SUPERSEDED_ERROR instead of retrying
indefinitely. The relay also closes stale transports to signal that their
recovery generation has been overtaken by a newer one.
This ensures overlapping reconnect scenarios complete with the newest proof
rather than getting blocked by stale recovery attempts.
* fix(relay): re-pin stdin/stdout fds after closing to prevent recycling
When the relay closes stdin/stdout to signal EOF to the SSH peer, the OS
can recycle those fds (0 and 1) for new sockets or files. If Node still
treats process.stdin/stdout as those numbers, subsequent operations
corrupt socket clients and trigger shutdown errors. Re-pin the fds by
opening /dev/null to keep them occupied and prevent recycling.
A stalled per-repo git scan no longer publishes a healthy-looking empty catalog. Adds an execution-host ownership gate so a degraded host cannot republish another host's worktree rows under its own id.
Relates to #11869 — this fixes the stall-publishes-zero half. The issue stays open for the remainder.
* fix(repos): remove a paired computer's deleted projects from every connected device
A project deleted on a paired Orca host stayed in every connected client's
sidebar and could not be removed there.
Two independent defects:
1. Host-local repo IPC mutations only sent `repos:changed` to the host's own
renderer (src/main/ipc/repos.ts:2711). The runtime client-event stream was
fed only by mutations arriving over runtime RPC, and clients refetch a remote
catalog only on a `reposChanged` event -- there is no polling on desktop -- so
the deleted rows persisted indefinitely. The shared `notifyReposChanged`
helper now also calls the new
`OrcaRuntimeService.notifyReposChangedForRemoteClients()`
(src/main/runtime/orca-runtime.ts:5175), mirroring the existing
`notifyWorktreesChangedForRemoteClients` precedent. This covers every repo,
project-group and folder-workspace IPC mutation, so renames, colors, reorders
and adds propagate too.
2. Deleting the ghost row on the client routed `repo.rm` to the owner, which
answered `repo_not_found`. `removeProject` wrapped its whole body in one
try/catch, so the rejection aborted the local purge before the `set()`
(src/renderer/src/store/slices/repos.ts:3466) and the delete button appeared
to do nothing. Only `repo_not_found` is now tolerated; any other failure still
keeps the row, and an opt-in `errorFeedback: 'toast'` makes it visible at the
three single-project user-initiated entry points. Bulk and background callers
keep today's silence plus their own aggregate reporting.
Closes#11994
Co-authored-by: Orca <help@stably.ai>
* fix(repos): revert inert RepositoryPane removeProject arg
The settings pane's only render site drops the argument; the toast is
already delivered by removeSettingsProjectFromAllHosts.
Co-authored-by: Orca <help@stably.ai>
* fix(repos): scope duplicate-repo-id deletes to the owning execution host
Cover the cross-host collisions #11994's broadcast now fans out to every paired
device. Same-name projects on different hosts were already isolated (per-host
UUIDs, host-scoped catalog merge and purge) and are pinned by regression tests.
Two same-repo-id paths were not: `repo.rm` with a `path:`/`name:` selector and
`deleteProjectHostSetup` both resolved one row and then deleted by bare id,
taking the sibling host's registration with it.
Co-authored-by: Orca <help@stably.ai>
* test(mobile): align the poll-interval rationale with the new reposChanged emission
Co-authored-by: Orca <help@stably.ai>
* fix(repos): resolve deleteProjectHostSetup's repo row only on the setup's own host
The sibling-host fallback could only ever pick a row on a host the caller
did not name; with no exact match the setup is stale and the existing path
already drops just the setup.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* Show the "Except default branch" filter only when "Hide sleeping" is on
The exemption only takes effect during the hide-sleeping sweep, so its row
now renders (and counts toward the filter badge) only while its parent
toggle is ticked.
Co-authored-by: Orca <help@stably.ai>
* Restore the sub-option indent on filter toggle rows
Tailwind v4 emits px-2 as padding-inline, which outranks the physical pl-6
override, so the indented row rendered flush with its siblings. Split the
base padding so the indent actually applies.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(agent-sleep): keep finished and interrupted agent sessions resumable after sleeping a workspace
Manual workspace sleep ran a liveness filter over the panes it was about to kill
(isValidManualSleepLiveAgentEntry), then wiped every pre-existing sleeping record
in the worktree. A done, interrupted, typed-into, or >30-min-idle pane therefore
lost its only resume handle and woke as a bare shell. A second filter at wake
discarded any record carrying `interrupted: true`, which also killed interrupted
sessions across an app restart.
Capture now records every resumable pane, normalizing only `updatedAt` and
`interrupted` and preserving the entry's real `state` so a finished pane keeps
its passive record and resumes in place when its tab is opened instead of
spawning a duplicate tab. The wake-side interrupted check is gone. A
legacy-orchestration-worker block is carried onto the replacement record, and
the Pi-compatible promoted checkpoint is no longer overwritten by a re-derived
live record.
Closes#11598
* fix(agent-sleep): keep durable slept records a repeat sleep cannot re-derive
removeSleepingRecordsReplacedByManualWorktreeSleep wiped every record in the
worktree, and only the freshly captured set was merged back. A slept `done`
pane stays passive until its tab is opened, so a second sleep found no live
status row to rebuild its record from and deleted the pane's only
`--resume` handle with nothing written back — the original loss of #11598,
one wake/sleep cycle later.
The wipe now skips a record with no replacement in the new capture set when
it is a durable capture (`origin` `worktree-sleep` or `quit`). Provisional
`live`/legacy checkpoints are still cleared, so an unresumable Pi row does
not survive a sleep it cannot back.
Co-authored-by: Orca <help@stably.ai>
* fix(agent-sleep): keep a slept workspace's finished panes out of the mobile wake fan-out
A manual sleep now records every finished pane, and those passive records fed
wakeSleepingAgentsForWorktreeInBackground step (b), which background-mounts one
tab per passive record. A phone opening a slept 12-tab workspace would have
cold-restored 12 agents at once, undoing the process shedding the sleep was for.
Manual-sleep captures of finished panes carry restoreOnTabOpenOnly; step (b)
skips them and the pane resumes in place when its own tab is opened, which is
what desktop activation already did and what the phone's per-tab mount provides.
Co-authored-by: Orca <help@stably.ai>
* fix(agent-sleep): close the retained-row and shared-claim gaps in slept-session capture
Three narrow holes left by the manual-sleep capture rewrite, all in the same
"a slept session must stay resumable" contract:
- The retained pass captured `retained.entry` verbatim, so a retained row -
stale by construction, since it exists only after the pane's pty died -
produced a `working` record that wake then discarded on the >30min staleness
rule. It now takes the same `updatedAt`/`interrupted` normalization and the
same `automaticResumeBlockedBy` carry-over as the live pass.
- The retained pass also ran after the `origin: 'live'` promotion loop without
its guard, so a pane holding both a promoted checkpoint and a retained row
had the checkpoint (connectionId, transcript identity, active `working`
class) overwritten by a re-derived passive record.
- The mobile wake filtered `restoreOnTabOpenOnly` records *after*
canonicalization, so a lazy record sharing a provider-session claim with an
eligible hibernated record could win the claim, delete the hibernated record
as a duplicate, and then be skipped - stranding the session with nothing
mounted.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(native-chat): show Claude's AskUserQuestion card when the agent runs on a paired headless host
Three gaps kept the question card off the desktop when the agent ran on a
remote `orca serve` host:
- The `session.tabs` projection reduced HTTP agent-hook rows to identity only,
hard-coding `state: 'done'` and an empty prompt, so `toolName` and the full
`interactivePrompt` never left the host. It now publishes the newest fresh
hook row's status fields, bounded by the same staleness window `agentType`
uses, excluding `providerSessionOnly` resume rows, and yielding to live
title evidence unless a question is actually pending.
- Nothing republished `session.tabs` when only a hook row changed, and the
re-emit carried an unchanged `snapshotVersion` that clients drop on their
monotonic gate. Material hook transitions and pane/SSH status clears now
bump the version and schedule a coalesced emit.
- The desktop card resolved only from live status. It now falls back to the
pending ask in the transcript, matching mobile, so a relay gap can no longer
leave the composer mounted over a pane parked on a selector.
Closes#11761
Co-authored-by: Orca <help@stably.ai>
* fix(native-chat): date the hook-row recency guard against a real clock
`resolveHookLiveAgentRow` compared a hook `receivedAt` (epoch ms) against
title stamps that are title-observation sequence numbers, so the guard could
never fire — any fresh hook row overrode live title-derived state, and a manual
rename (the one epoch writer) inverted it. Stamp the live OSC title path with
wall-clock ms and compare against that alone.
The regression test fabricated epoch-valued title stamps production never
writes; it now drives the title through `onPtyData`, and a new case pins the
opposite direction (hook row newer than the title wins).
Co-authored-by: Orca <help@stably.ai>
* fix(native-chat): stop an orphaned tool call from pinning a dead question card
extractPendingAsk pairs tool results to calls by a global FIFO (tool_use_id
is dropped at decode time), so one call that never gets a result desyncs the
queue for the rest of the transcript and strands an answered ask as pending.
Real transcripts also hold asks the user escaped and typed past. On desktop
that card replaces the composer, so the pane became unsendable.
Drop in-flight calls at a turn boundary — a user turn or the decoders'
interrupt row — since the turn that owned them is over. Claude's tool-result
turns decode as role 'tool', so normal FIFO resolution is untouched.
Co-authored-by: Orca <help@stably.ai>
* refactor(native-chat): trim the headless AskUserQuestion projection
Reuse rather than restate: the invalidator now takes the shared
`AgentHookEventPayload` instead of a locally redeclared row shape, and the
hook live row is a `Pick<>` of the retained OSC snapshot so one projection
branch consumes either carrier. Fold the immediate/coalesced session-tabs
emit into one method (also drops a redundant re-emit on the
provider-session push). Drop card tests that re-route shared-parser
assertions through React. Isolate pane-status-clear subscribers and prove
the no-republish case by version arithmetic instead of a timed silence.
Co-authored-by: Orca <help@stably.ai>
* test(native-chat): pin the AskUserQuestion card render under real Electron
Why: the 13 parser unit tests pin extraction, but nothing proved a card
actually renders where an inert tool call used to. This spec reproduces the
paired-headless topology from the client side — live status carrying agent
identity and state 'working' but no interactivePrompt/toolName, with the
pending ask present only in the transcript — and fails on main.
Refs #11761
Co-authored-by: Orca <help@stably.ai>
* test(native-chat): drop the unused testInfo parameter
Why: oxlint no-unused-vars fails the lint gate on an unused test parameter.
Co-authored-by: Orca <help@stably.ai>
* test(native-chat): drop leftover proof scaffolding from the ask-card spec
The env-var screenshot label and the fixed 2s settle only existed to make
the pre-fix capture comparable; the card assertion already waits.
Co-authored-by: Orca <help@stably.ai>
* test(runtime): use a truly unresolvable pane key in the hook republish guard
#11203 taught pane lookup to recover a reminted tab id by leaf id, so the old
fixture (new tab id, live leaf id) resolved and bumped the snapshot a second
time once this branch merged with main.
Co-authored-by: Orca <help@stably.ai>
* fix(runtime): refuse a hydrated unconfirmed hook row as live pane status
#12346 landed on main after this branch was cut: a nonterminal row restored from
last-status.json is stamped `restoredUnconfirmed` because its transition may have
fired while no receiver was up, and every freshness gate treats it as never-fresh.
The new headless `live` projection here only checked `receivedAt`, so a restart
inside the 30-minute window would republish the hydrated row — resurrecting the
AskUserQuestion card with no agent left to answer it.
`agentType` still reads those rows: they prove identity, just not liveness.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Neil <nwparker@users.noreply.github.com>
* perf(renderer): index detected worktrees for owner lookups
* test(renderer): make detected index perf gate deterministic
* test(renderer): cover detected index owner hot path