`worktreeUsesRemoteConnection`, `getRemoteConnectionIdForWorktree`,
`worktreeUsesWslPath` and `rightSidebarShowsPullRequestData` each did
`Object.values(state.worktreesByRepo).flat().find(...)` plus a linear
`repos.find(...)`. They are called from unmemoized Zustand selectors
(`use-tab-agent.ts:263`, `use-visible-review-refresh.ts:45`), so every store
write re-ran the whole scan once per open tab.
Measured on a real instance (10 repos / 423 worktrees / 382 tabs): the `.find()`
predicate alone ran 1,320,424 times in 30s — 44,000 worktree visits/sec — while
the app was idle.
Switched to the existing WeakMap-cached `getIndexedWorktreeMap` /
`getIndexedRepoMap` from `store/worktree-repo-index.ts`, matching what
`connection-owner-resolution.ts` already does. Same duplicate-id and
host-collision semantics; no behavior change.
Benchmark at that scale, 200 store writes x 382 tabs x 3 lookups:
before 2.762ms per store write
after 0.167ms per store write (16.6x)
At ~20 store writes/sec that is 55.2ms/sec of renderer CPU down to 3.3ms/sec.
The new scale test counts worktree `id` reads: 160,000 before, 800 after.
Doubles the maximum UTF-8 bytes accepted for manually shared artifacts,
enabling users to share larger content while maintaining recovery and
transport constraints.
* Move workspace search toggle to floating button
Extract the search bar into a separate component and move the search toggle button from the toolbar to a bottom-left floating action button, positioned above the new workspace FAB. This consolidates phone-only floating actions in one location.
* Remove SearchWorkspacesFab component
Consolidates search functionality into bottom-left floating action button as part of mobile search button repositioning.
* Add browser history search to the new-tab omnibox
- Display matching pages from browser history in the tab entry panel
- Extract address-bar history scoring into reusable `browser-history-match` module
- Rank by match tier (host prefix > substring > title > tail) then frecency
* Replace History icon with ExternalLink for browser search results
* Replace ExternalLink with Globe icon for browser search results
* Fix browser history matching for workspace docs and recency rankings
Promote path-prefix matches to top tier for entries without a host (workspace
docs), and clamp the recency bonus so future timestamps cannot outrank fresh
visits. Includes tests for both path-prefix promotion and recency bonus
clamping behavior.
* Cache browser history by identity and snapshot omnibox entries
- Use WeakMap to cache prepared browser history entries by identity, so
re-parsing is skipped for the same snapshot
- Change omnibox to read a history snapshot at menu open (via getState)
instead of subscribing to live updates, preventing background navigations
from reshuffling results mid-keystroke
- Support fully-qualified URL prefix matching (e.g., `https://github.com`)
to preserve address-bar behavior
- Fix percentile calculation in performance tests (nearest-rank method)
* Break browser history ties with URL for stable snapshot ordering
When browser history entries tie on tier, score, and recency, the sort
order can become non-deterministic, especially when combining browser and
document history that may be reordered in snapshots. Add normalizedUrl
as the final tie-breaker to guarantee consistent ordering.
Delivery callbacks and telemetry belong to the completed send operation, not to the
picker instance that launched it. Remove early returns that skipped delivery
acknowledgment and success toast when the popover was already closed.
* fix(native-chat): keep the attachments on a Claude turn that pasted images
A Claude turn carrying pasted images reached native chat with no images at all —
no thumbnails on mobile, and not even an attachment chip on desktop. Nothing
showed that the message had any.
Both carriers were being dropped:
- Claude records the paths in a companion turn marked `isMeta`, holding one
`[Image: source: <path>]` text block per image. The decoder treats an `isMeta`
user row as injected, filters it down to tool-result blocks, and returns null
when none remain — so the whole row went away.
- The prompt row's own `image` blocks are `{source: {type: 'base64'}}`, which
carry no url or path, so `imageRefBlock` drops them too.
With the companion gone, `isImageSourceUserTurn` could never fire and the fold in
`normalizeImageTranscriptMessages` was unreachable on the Claude path.
Surveying every transcript under `~/.claude/projects`: 238 of 241 image-source
rows are `isMeta`, across every versioned release (2.1.220 through 2.1.237); the
3 that are not carry no version field at all. 38 of those rows hold more than one
content block, which also defeated the single-block rule in
`isImageSourceUserTurn`.
Let image-source text survive the injected-turn filter, and recognize a turn
whose blocks are *all* markers rather than only a lone one. An ordinary injected
turn (a skill preamble, a compact summary) is still dropped, and a turn that
mixes prose with a marker is still not an image-source turn.
Carrying the paths keeps the payload small; decoding the base64 instead would put
hundreds of KB per image on the wire to mobile.
* fix(native-chat): preserve image companion ordering
* fix(native-chat): keep image companions turn-local
---------
Co-authored-by: Merge Sim <sim@local>
* fix(native-chat): stop rendering tool output as the agent's streaming reply
A tool result could appear in native chat as a raw, un-collapsed "assistant"
bubble that never went away for the rest of the turn — on mobile it showed up
as a wall of a source file's contents, prefixed by "Exit code 1".
Providers publish a tool's stdout/error as `lastAssistantMessage` so status
cards and dashboard rows can preview what the agent just did. Native chat reuses
that same field as its live streaming bubble, so the preview rendered as prose.
For Claude the preview is *only ever* tool output mid-turn: claude-tool-fields
writes real prose exclusively at Stop, so the bubble could never contain an
actual streaming reply.
It also could not be retired. The bubble hides once a transcript assistant block
leads with the streamed text, and tool output never lands in one — so the only
remaining exit was the turn ending, which is why a long tool-heavy turn pinned it
on screen.
Carry provenance instead of changing what the status surfaces show: mark the
writes that come from a tool result/error, keep the flag in lockstep with the
value it describes through the listener merge, and have both native-chat
streaming paths ignore a flagged preview. Status cards, dashboard rows and
automation capture are untouched.
The wire field is optional, so an older host that never sends it keeps today's
behavior rather than silently suppressing previews.
* fix(native-chat): preserve tool output provenance through renderer sync
* fix(native-chat): retain preview provenance in Claude roster state
* test(native-chat): cover restored tool preview provenance
---------
Co-authored-by: Merge Sim <sim@local>
* fix(i18n): correct zh-CN translation for editor view toggle buttons
- "Rich Editor" (aff15f94f5): 丰富的编辑器 → 富文本编辑器
- "Source" (4d6ccb7ba6): 来源 → 源码
- Settings description (f80603d293): 丰富的编辑器 → 富文本编辑器
"Source" in the Markdown editor context means source-code view, not
data source. "丰富的编辑器" is an awkward literal translation; the
standard term is "富文本编辑器", already used inconsistently in
nearby keys (5f02e6fb21, 8090:694613d47f).
* fix translation
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
* perf(renderer): bound runtime refresh and sidebar subscriptions
* perf(renderer): widen interactive connect to 15 concurrent probes
The 5-wide bound came from the coalesced background event lane, where repo
events repeat and can storm. Connect is one-shot and the user is waiting on
it, so it gets its own wider lane while still capping fan-out on the remote,
which runs a worktree-detection RPC per repo.
* Avoid Linear read re-fetches when workspace scope is unchanged
Derive a stable scope signature that captures only the connected state
and workspace identity, ignoring volatile metadata like displayName.
Use this in dependency tracking so Linear searches don't re-run on
status updates that don't affect which issues can be queried.
* Expand workspace scope to detect credential and org changes
Cache invalidation key now includes credentialRevision and organizationUrlKey for
both workspace and viewer, ensuring Linear reads re-fetch when credentials rotate or
organizations are renamed — fields that affect what read operations return.
* Include activeWorkspaceId in workspace scope signature
URL lookup falls back to the active workspace even when all workspaces
are selected, so activeWorkspaceId must be part of the scope signature
to ensure reads are keyed correctly.
* fix(terminal): mount one surface per workspace id in the workbench (STA-4846)
* test(terminal): pin the workbench projection against under-selecting
Losing a surface unmounts live terminals, which is worse than the
duplicate mount STA-4846 fixes, so cover every catalog shape that reaches
the workbench: local-only rows that name no host, an unqualified row
colliding with a host-qualified one, two SSH hosts on one id, folder rows
across three hosts, folder ids alongside git worktree ids, and a
whole-catalog assertion that the emitted id set equals the distinct input
id set. Also pin the `useAllWorktrees` -> `useWorktreeMap` swap: both read
the same WeakMap-cached snapshot, so the zustand compare is unchanged.
Harden the folder tie-break to require the row to name its own host.
`getCatalogOwnerHostId` defaults an unstamped row to `local`, which would
let a row that never named a host win the `local` tie and mount another
host's path; it now keeps first-wins instead of guessing.
* fix(terminal): surface the unresolvable folder-surface collision
When two hosts publish the same folder-workspace id and the active workspace's
host cannot be resolved, the projection drops one row's folderPath first-wins.
That path is the PTY cwd for any tab without a startupCwd, so the drop was
silent. Warn on it, and pin the two tie-break branches the unit tests missed:
a colliding row that is not the active workspace, and the same collision with
the rows in swapped order (a host reconnect re-appends its rows, flipping which
row is first mid-session).
* test(e2e): ride out Playwright's spurious main-process evaluate rejection
`e2e / changed e2e specs` failed on `pr11346-selected-runtime-add.spec.ts`
with "Execution context was destroyed, most likely because of a navigation"
from the paired client's first `app.evaluate` — the isolated-HOME assert that
runs one millisecond after `electron.launch()` resolves, which is before the
app is `ready`. Nothing navigates there: Playwright raises that message for
any main-process CDP failure that is neither a JS error nor a closed session,
and `ElectronApplication.evaluate` is unreliable on Electron 27+
(microsoft/playwright#33737). Reproduced locally, and a plain re-run of the
same commit went green.
Extract the retry `installTerminalPtyWriteSpy` already carried for this exact
message into `retryTransientMainEvaluate`, and use it for the launch-time home
read in all three launchers. The read is idempotent and a real boundary escape
still throws on the first successful read.
Also forward the paired client's process logs before the assert instead of
after: this failure reached CI with none of the client's own output, because
forwarding had not started yet.
* test(e2e): wait on the owning group before asserting a Cmd-J browser tab is active
`changed e2e specs` then failed at the remote browser-page step: the store poll
had already seen `activeBrowserTabId` land on the mirrored workspace, but
`[data-tab-id=...][data-active="true"]` never appeared. `data-active` on a
`BrowserTab` is the strip's active tab, which comes from the owning group's
`activeTabId` — not from `activeBrowserTabId` — so the DOM assert was racing an
activation the poll never waited for. The simulator rows in the same spec
already poll the group; the two browser-page rows did not.
Poll the same triple for them, so a genuinely stuck group fails with the ids it
ended on instead of a bare "element(s) not found".
* Add keyboard navigation to automations UI
Improves workflow efficiency by enabling keyboard-driven navigation
across automations list, run history, and detail pane tabs.
* Add Escape key support to automations detail pane
Pressing Escape now clears external and automation run page views,
then returns to the automations list. Also improves cross-browser
compatibility of keyboard event handling by using Element checks and
getAttribute instead of dataset access.
* Fix keyboard navigation to let Enter key reach focused controls
- Enter key now passes through to focused buttons, links, and other interactive controls
- Arrow key navigation through automation run history still works
- Prevents intercepting native keyboard behavior of interactive elements
* improve test
* Move keyboard focus to follow row selection
When navigating automation runs with arrow keys, focus must follow the selection so Enter key acts on the newly selected row rather than the previously focused one.
* fix(diff): close large-diff deferral review findings from #17521
Deferral keyed "no line counts" off the untracked area, which both prompted
ordinary untracked binaries and silently auto-loaded every tracked row when a
status pass skipped counting (entry cap hit, numstat failed) — the freeze case
the deferral exists for. Decide from the path instead: rows that render as a
preview or a binary stub stay automatic, everything Monaco would open as text
defers.
Also give all three combined-diff virtualizers one shared row estimate, so the
PR-review viewers stop estimating a deferred/in-flight large row at 88px while
DiffSectionItem renders it at 188px, and drop the dead isLoadOnDemand
parameter that estimate covered.
* fix(diff): stop deferring cheap uncounted rows the extension list misses
The path-only rule relocated friction rather than removing it: every uncounted
row deferred unless its extension was in BINARY_FILE_EXTENSIONS, so two classes
of tracked row flipped to a "Large diffs are not rendered by default" prompt
they had never shown. Tracked binaries outside the list (this repo's own
resources/build/icon.icns, plus .tiff/.avif/.psd/.parquet and every
extensionless binary) get '-\t-' from `git diff --numstat`, and a submodule
whose only change is untracked content inside it gets no numstat row at all
while porcelain v2 still reports `1 .M S..U ... sub`. Both are cheap, and both
are unreachable from a hardcoded extension list — verified against real git.
OR the extension check with two signals already on the entry. A submodule row
diffs to a "Subproject commit" line or two whatever it contains, so it is
always cheap. And an uncounted row whose siblings in the same pass DID get
counts is uncounted for a reason of its own: for a tracked row that reason can
only be numstat's binary marker. Untracked rows keep deferring either way,
since the scan also skips them past MAX_UNTRACKED_LINE_COUNT_BYTES and their
size is exactly what is unknown. No new field crosses git status, the wire, or
the section cache; `submodule` and the sibling counts are already there.
Fan-out, accepted deliberately: when a pass counts nothing at all — didHitLimit
at DEFAULT_GIT_STATUS_LIMIT, or runNumstat returning null — no row has a
counted sibling, so the whole combined diff renders as Load prompts. Keeping
it. Over 1000 changed entries is precisely the freeze this deferral exists for,
and auto-loading that many unbounded Monaco models is the bug, not the
mitigation; a numstat failure leaves every size genuinely unknown. Each row
still has its own Load diff button, so nothing is unreachable — the only thing
missing is a bulk "load all", which would reinstate the freeze on demand.
* fix(diff): scope the counted-siblings signal to one counting pass
hasCountedSiblings was one boolean over the whole entries array, but that array
is not one counting pass. combined-all — the default whenever a branch compare
exists — concatenates uncommitted rows with branch-compare rows, and even within
the uncommitted set staged and unstaged are separate numstat calls that fail
separately. So a single counted branch row vouched for an uncommitted pass that
counted nothing (numstat null, or didHitLimit at DEFAULT_GIT_STATUS_LIMIT), and
every uncounted row in it auto-loaded into exactly the Monaco freeze the
deferral exists to prevent: the guard was off in the default view.
Collect the passes that actually counted something, keyed by staging area for
status rows and 'compare' for branch/commit rows, and ask that set per row.
Untracked rows are unaffected — they never consult the signal.
Class 1 of the charter (tracked binaries outside BINARY_FILE_EXTENSIONS) stays
open, deliberately. Porcelain v2 reports a modified binary as `1 .M N... 100644`
— indistinguishable from text — so only `git diff --numstat`'s `-\t-` knows, and
that stdout is parsed on the host (shared/git-uncommitted-line-stats.ts) for
both the local and relay status paths. The renderer sees entries, not numstat,
so surfacing it per row means a new field on GitStatusEntry and
GitBranchChangeEntry that also has to be re-applied in two attachLineStats
copies and in the line-stats reuse cache, which persists only {added, removed}
and would silently drop it. The one existing field that could carry it —
added/removed set to 0 — changes what the host publishes to old clients and
mobile, contradicts the documented "undefined for binary files" contract, and
collapses the undefined-vs-zero distinction the virtualizer's height estimate
reads. So a lone tracked .icns still shows the load prompt; not worth a wire
field, and not worth another hardcoded extension.
* fix(diff): stop calling an uncounted diff large in the load prompt
The deferral prompt had one sentence for two different reasons. A row over
MAX_AUTOMATIC_DIFF_CHANGED_LINES really is large. A row with no counts at all —
numstat's binary marker, a pass that skipped counting — is deferred because its
size is unknown, and "Large diffs are not rendered by default." is simply false
for it: a lone tracked resources/build/icon.icns with no counted sibling in its
own pass is 4 KB and still says large.
Split the copy on the counts the section already carries. No new field on the
entry, nothing across the wire, no change to attachLineStats or the line-stats
cache — the predicate is renderer-local and mirrors the uncounted branch of
shouldLoadCombinedDiffOnDemand, so the two stay in step.
Follow-up defect fixes for the batched PTY-inventory evidence path (#17525),
now on main.
- One memoized `ps` capture serves both the lenient and strict views. The two
readers ran byte-identical argv behind separate caches, so a relay serving
both forked `ps` twice per 500ms window — the doubling issue #6288 removed.
- Drop the `byPgid`/`byTpgid` indexes no resolver reads, plus the zero-caller
`parseProcessTableRowsStrict` and `getFreshStrictProcessTableSnapshot`; the
batch resolver now reuses the shared index lookup and candidate score instead
of private copies.
- Restore `getForegroundProcessName`'s ladder contract: the extracted table scan
answers null again, so an unconfirmed wrapper fallback publishes the
recognized (normalized) name rather than node-pty's raw one.
- Pin the SHIPPED `pty.listProcesses` path: one capture and one linear row pass
for N panes, and node-pty's own name (never "shell") when the capture cannot
disambiguate a `node`/`python` wrapper.
- Pin the hidden-pane cadence gate in the production option shape, and move the
strict-parser coverage next to the parser it tests.
* fix(remote): distinguish transport from runtime availability
* fix(remote): preserve transport diagnostics for unavailable runtime
* fix(remote): propagate transport diagnostics to host setups
* fix(remote): keep unavailable runtimes out of ready setups
* fix(remote): preserve unavailable runtime state in settings
* fix(remote): preserve reconnecting runtime state
* fix(remote): guard stale settings connectivity
* fix(remote): preserve diagnostics after main merge
* fix(i18n): preserve translations during runtime status merge
* fix(remote): refresh settings row health from store
* fix(remote): refresh settings row health from store
* fix(remote): clear diagnostics generations in tests
* fix(settings): refresh runtime availability summary
* refactor(runtime): split status slice types
* refactor(runtime): reuse status app state type
---------
Co-authored-by: Merge Sim <sim@local>
* perf(renderer): avoid combined-diff tree rebuilds during progressive loads
* fix(renderer): preserve collapsed combined-diff tree boundaries
* perf(renderer): skip unfiltered combined-diff flatten when hiding viewed files
* fix(renderer): keep reordered viewed keys in the combined-diff delta
The incremental viewedSectionKeys delta walked indices issuing a delete
then an add, so a key added at index i and deleted as the previous key at
a later index was silently dropped. Fall back to a full recompute when any
index's key differs; the progressive-load fast path (stable keys, flipping
loading state) is unchanged.
* perf(terminal): activate splits before cwd resolution
* test(terminal): prove split focus before cwd publish
* fix(terminal): release stale split cwd fence
* test(terminal): add visible split activation latency benchmark
* docs(reliability): clarify split benchmark provenance
* fix: preserve deferred split handoffs across remounts
* fix: fence late deferred split closes
* docs(reliability): record exact split benchmark runs
* test(reliability): fail benchmark on artifact write errors
* test(reliability): attribute split activation phases
* docs(reliability): record schema-v2 split benchmark
* refactor(terminal): collapse duplicated split-handoff and write-queue paths
- Drop the discardDeferredSplitPaneHandoff alias for its identical clear twin.
- Fold the deferred-cwd resolve/reject settle handlers into one applier.
- Extract settlePaneCwdDeferredSpawn for the repeated read-clear-write pattern.
- Share one head-index FIFO primitive between the ordinary and reply queues.
* fix(terminal): stop retaining a promise reaction per acknowledged write
Racing every accepted write against one queue-lifetime cancel promise kept a
reaction record alive until that promise settled: 200k acknowledged writes
retained 88.6MB, now 0.1MB. Give each in-flight write its own cancel, and
split the shared FIFO primitive into its own module.
Also sanitize the split-latency benchmark report at its single serialization
point so shared artifacts no longer carry the machine-local repo path or
unbounded cleanup error text.
* fix(terminal): settle deferred split input when the spawn is abandoned
An abandoned deferred spawn returns before transport.connect(), so nothing
drained the pre-connect buffer: sendInputAccepted's promise never settled and
a paste into that pane hung forever. Clear the buffer on the abandon fence.
Also re-derive the pre-connect retention cap from the clipboard-paste ceiling
rather than the 16MB single-write ceiling; it is held twice per pane across up
to 64 deferred splits, so 5.59M code units guarded the wrong thing.
* fix(terminal): release the deferred cwd fence on a rejected reattach
A daemon createOrAttach can turn an apparent fresh spawn into a reattach; when
that reattach is refused the spawn ends with deferredSplitSpawn/pendingCwd
still set, permanently arming the pre-bind detach refusal. The release no-ops
when a PTY did bind, so it only fires where the fence would otherwise leak.
The stale-generation return above is deliberately left alone: a newer connect
already owns the pane there, and the fence is not generation-scoped.
* Show live tool progress in native chat
* fix(native-chat): scope live tool indicator to current turn
* fix(native-chat): settle orphaned live tool rows
* fix(native-chat): keep live tools running without lifecycle metadata
* fix(native-chat): keep working status stable during streaming
* fix(native-chat): anchor turn status below prompts
* fix(native-chat): preserve turn status and legacy tool activity
* fix(native-chat): limit turn status UI to structured Codex
---------
Co-authored-by: Merge Sim <sim@local>
* fix(worktrees): preserve user workspace names across branch changes
* test(worktrees): cover pinned rename metadata
* fix(workspaces): address display-name review edge cases
* fix(workspaces): keep automatic names fresh across refreshes
* fix(workspaces): preserve legacy CLI labels
* fix(workspaces): preserve display-name provenance across hosts
* fix(workspaces): honor legacy display-name provenance
* fix(workspaces): fence display-name refresh races
* fix(workspaces): accept peer renames from provenance-less hosts
The old-host preserve fence kept a pinned local label on every refresh,
which also suppressed a legitimate rename another client persisted
through the same host until app restart. Narrow it to labels the host
re-derived itself (branch short name, or path basename when detached);
any other changed label in a mode-less response is explicit meta a peer
wrote there. Stale prior-label responses stay covered by the downstream
staleness fence, in-flight writes by the pending fence.
* refactor(workspaces): unify display-name pin derivation
Three call sites (renderer optimistic update, local IPC updateMeta
handler, remote worktree.set handler) each restated the same formula;
a future edit to one would silently skew provenance between paths.
* feat(ssh): batch process evidence in PTY inventory
* fix(ssh): accept Linux kernel process rows and make no-evidence polling push-driven
* fix(ssh): preserve process evidence polling semantics
---------
Co-authored-by: Merge Sim <sim@local>
* fix: satisfy GitLab hook and test lint gates
* Rename electron-vite target config to .cts
The .cts extension keeps the config as CommonJS, allowing electron-vite
to load each parallel target without sharing its timestamp-named ESM
temp file.