Skip to content

perf(tabs): index tab agent status by tab instead of scanning the global map - #12413

Open
brennanb2025 wants to merge 2 commits into
mainfrom
brennanb2025/perf-tab-agent-status-index
Open

perf(tabs): index tab agent status by tab instead of scanning the global map#12413
brennanb2025 wants to merge 2 commits into
mainfrom
brennanb2025/perf-tab-agent-status-index

Conversation

@brennanb2025

Copy link
Copy Markdown
Contributor

Summary

Tab-bar agent-icon resolution no longer scans the entire global agent-status map once per tab per render. resolveAnyCompletedTabAgent (and its live/retained twins in src/renderer/src/lib/tab-agent.ts) did Object.entries(agentStatusByPaneKey) plus a parsePaneKey on every key, and useTabAgent calls them for every mounted tab on every render — with 200 worktrees/tabs and hundreds of status entries that is ~10^5 parsePaneKey calls per render pass.

CPU profiles taken on the prod app with 200 worktrees showed resolveSiblingCompletedTabAgent / resolveAnyCompletedTabAgent in every profile: 4.6% self-time during a cold worktree switch (~12% of the switch window including the surrounding frames in the same chunk) and 1.5% during steady-state churn.

This adds src/renderer/src/lib/tab-agent-status-index.ts: a per-tab index of icon-capable panes, cached on the identity of the source map. The store replaces agentStatusByPaneKey / retainedAgentsByPaneKey on every write (and keeps the identity when nothing changed), so identity is an exact invalidation signal — one scan per store write instead of one per tab per render. This is the same idiom already used by terminal-tab-agent-type-index.ts.

Behavior-preserving: the index keeps each tab's panes in the source map's insertion order, because the resolvers return the FIRST match and a split tab with two done panes running different agents would otherwise flip its icon. Focused-pane lookups were already O(1) and are unchanged.

Measured

Micro-benchmark (scratch script, not committed): 800 entries across 200 tabs × 4 leaves, mixed done/working; per render pass a fresh map identity (the real pattern: a store write replaces the map, then all tabs re-render) then 200 tabs × (resolveFocusedCompletedTabAgent + resolveSiblingCompletedTabAgent); 100 render passes; best of two timed runs after warm-up.

total (100 passes) per render pass
old (full-map scan) 1203.9 ms 12.039 ms
new (identity-cached index) 25.2 ms 0.252 ms

47.9× faster, 0.252 ms per simulated render pass (target was ≥10× and <1 ms). Both implementations returned identical results over the whole run (parity sink 23400 = 23400).

Screenshots

No visual change.

Testing

  • pnpm lint (full gate, passed)
  • pnpm typecheck (pnpm run tc, after rm -f config/*.tsbuildinfo)
  • pnpm test — targeted: all of src/renderer/src/lib + src/renderer/src/components/tab-bar (407 files, 3794 passed / 1 skipped). Full-repo pnpm test not run locally; CI covers it.
  • pnpm build (not run; no build-surface change — renderer-only TS)
  • Added or updated high-quality tests

New src/renderer/src/lib/tab-agent-status-index.test.ts:

  • Parity oracle suite: the pre-index full-map scans are kept in the test as an oracle and 250 seeded-random cases (varying tab counts, leaf counts, done/working/blocked/waiting mixes, shuffled insertion orders, iconable + non-iconable + missing agent types, malformed pane keys, present/absent/invalid activeLeafId) assert all six exported resolvers return identical results.
  • Targeted edge cases: multiple done siblings running different agents (including a reversed-insertion-order map to prove order is load-bearing), excluded active leaf, non-iconable agent skipped in favour of a later pane, pane keys parsePaneKey rejects, empty maps, and re-indexing when the store replaces the map identity.
  • Mutation-checked: reversing the index iteration order makes 2 of the 5 tests fail, so the suite really does pin insertion order.

Also ran, since the pre-commit hook was not executable in this worktree: config/scripts/check-changed-code-quality.mjs (0 new findings across 3 changed files, incl. type-aware + the react-doctor oxlint plugin) and config/scripts/check-react-doctor-changed.mjs (0 issues).

AI Review Report

Self-review of the diff with the following risks checked:

  • Semantic equivalence — the old loop parsed the key, matched tabId, excluded leafId, then took the first entry whose agent resolved non-null; the index applies exactly those filters at build time and preserves order. Entries that fail parsePaneKey or whose agentType is not iconable (undefined, 'unknown', custom names) are dropped at build time, which is equivalent to the old continue. Verified empirically by the 250-case oracle suite rather than by inspection alone.
  • Iteration order — the resolver returns the FIRST match, so order is behavior, not an implementation detail. Pinned by a dedicated test with a reversed-insertion-order map.
  • Cache invalidation — audited every write to agentStatusByPaneKey / retainedAgentsByPaneKey in store/slices/agent-status.ts: all replace the record (nextLive, spread copies, removePaneKeys, movePaneKeyedRecord); no in-place mutation, no immer drafts. Identity-keying is therefore exact. A stale index would require mutating a map in place, which no code path does.
  • Cache thrash — the live and completed indexes share one cache slot (one pass builds both) and the retained index has its own, so useTabAgent's four calls per tab hit the cache. Only alternating between two different map objects would thrash, which the store never does.
  • Retention — the cache holds a reference to the current store map only (single slot, overwritten on the next identity change), so no unbounded growth and nothing is kept alive that the store had already dropped.
  • Cross-platform (macOS / Linux / Windows) — no platform-dependent code touched: no keyboard shortcuts or modifier keys, no shortcut labels, no filesystem paths or path separators, no shell/process invocation, no Electron main-process or IPC surface. Pure renderer-side data structures (Map, Object.entries, string compare) with identical behavior on all three platforms. Array#toReversed in the test is ES2023, available in the Electron/Node runtimes the repo already targets (already used elsewhere in src/renderer). Nothing here is specific to local vs SSH/remote worktrees or to git-worktree vs folder workspaces — both feed the same pane-key-shaped map.
  • Public APItab-agent.ts exports are unchanged; its only non-test importer (use-tab-agent.ts) needed no edit.

Flagged and addressed: an initial version used Array#reverse() in a test, which oxlint rejects (unicorn/no-array-reverse) → switched to toReversed().

Security Audit

Low risk; no new attack surface.

  • Input handling — pane keys still go through the existing parsePaneKey validator (single-colon, UUID leaf); malformed keys are dropped exactly as before, and a test covers that. No new parsing, no regex added, no user-controlled string is interpolated anywhere.
  • Command execution / path handling / auth / secrets / dependencies — none touched. No new dependencies, no child_process, no filesystem access, no credentials.
  • IPC — none touched. This code only reads already-materialized renderer store state; it does not receive, forward, or trust any new IPC payload.
  • Denial-of-service shape — the change strictly reduces work (O(tabs × entries) → O(entries) per store write). Memory grows by one small index (tabId → [{leafId, agent}]) over entries that were already retained in the store, bounded by the map that already exists.

No follow-up needed.

Notes

  • The benchmark was a scratch vitest script (vitest swallows console, so it wrote results to /tmp/tab-agent-bench.txt); it is intentionally not committed, since it duplicates the pre-index implementation which now lives in the parity test as the oracle.
  • The same identity-cache idiom already exists in src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts; that selector is left alone — this PR is scoped to the tab-agent.ts resolvers.
  • The live (non-done) resolvers resolveSiblingTabAgent / resolveFocusedTabAgent had the identical full-map-scan shape and are indexed here too, since one pass builds both the live and completed indexes.

…bal map

resolveAnyCompletedTabAgent and its live/retained twins scanned the whole
agentStatusByPaneKey map and parsed every pane key, once per tab per render —
~10^5 parsePaneKey calls per render pass with 200 tabs. Cache a per-tab pane
index on the map's identity (the store replaces it on every write) so a render
pass scans once instead of once per tab. Insertion order is preserved because
the resolvers return the first match.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6716f61a-08f2-4bd0-bc32-931c6f13ec20

📥 Commits

Reviewing files that changed from the base of the PR and between 7c40c35 and bc3a947.

📒 Files selected for processing (1)
  • src/renderer/src/lib/tab-agent-status-index.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/renderer/src/lib/tab-agent-status-index.test.ts

📝 Walkthrough

Walkthrough

The change adds cached indexes for live, completed, and retained tab-agent status entries. The indexes preserve insertion order, filter invalid pane keys and unsupported agents, and refresh when source-map identity changes. Tab-agent resolution now uses shared selectors and exclusion logic. Tests compare indexed results with scan-based oracles and cover randomized fixtures, ordering, filtering, malformed keys, empty maps, active-leaf exclusion, and map replacement.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main optimization: indexing tab agent status by tab instead of repeatedly scanning the global map.
Description check ✅ Passed The description covers the required sections, explains the optimization, documents testing and risks, and states why the build was not run.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Electron QA evidence from an isolated dev profile on the PR worktree.

Working agent status — Codex identity remains visible in the tab bar and worktree card:

Working agent status

Completed agent status — the completed marker and Codex identity remain visible with the surrounding terminal UI intact:

Completed agent status

Validated in an isolated Electron instance over CDP; this PR intentionally has no visual design change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant