perf: systematic performance optimizations (#623)

This commit is contained in:
Jinwoo Hong 2026-04-14 03:28:56 -04:00 committed by GitHub
parent ef75e8d0c2
commit 60a092686b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 711 additions and 311 deletions

81
docs/performance-audit.md Normal file
View file

@ -0,0 +1,81 @@
# Orca Performance Audit
Audit date: 2026-04-13
Branch: `Jinwoo-H/performance-improvement`
---
## Tier 1 — High Impact, Low Complexity
| # | Area | Issue | File(s) | Status |
|---|------|-------|---------|--------|
| 1 | Renderer | `App.tsx` has 53 separate Zustand subscriptions — nearly every state change re-renders the root and cascades through the entire tree | `src/renderer/src/App.tsx:40-180` | DONE (53→22 subs, memo barriers on 4 children) |
| 2 | Renderer | `Terminal.tsx` has unscoped `useAppStore.subscribe()` — fires on every store mutation, does O(worktrees×tabs) scan each time | `src/renderer/src/components/Terminal.tsx:645-659` | DONE |
| 3 | Terminal | No IPC batching — every PTY data chunk is a separate `webContents.send` call, hundreds/sec under load | `src/main/ipc/pty.ts:173-177` | DONE |
| 4 | Terminal | Divider drag calls `fitAddon.fit()` on every `pointermove` pixel — xterm reflow can take 500ms+ with large scrollback | `src/renderer/src/lib/pane-manager/pane-divider.ts:90-119` | DONE |
| 5 | Main | Startup blocks: `openCodeHookService.start()` and `runtimeRpc.start()` awaited sequentially before window opens | `src/main/index.ts:131-168` | DONE |
| 6 | Main | `persistence.ts` uses `readFileSync`/`writeFileSync`/`renameSync` on the main thread — blocks during startup and every 300ms save | `src/main/persistence.ts:59-125` | DONE |
| 7 | Worktree | `worktrees:listAll` iterates repos sequentially with `await` — total time = sum of all repos instead of max | `src/main/ipc/worktrees.ts:53-84` | DONE |
| 8 | Browser | Reverse Map scan O(N) on every mouse/load/permission event in `BrowserManager` | `src/main/browser/browser-manager.ts:91-96` | DONE |
| 9 | Browser | `before-mouse-event` listener fires for ALL mouse events on ALL guests, even background ones | `src/main/browser/browser-guest-ui.ts:78-93` | DONE |
| 10 | Worktree | `refreshGitHubForWorktree` bypasses 5-min cache TTL on every worktree switch — fires GitHub API calls on rapid tab switching | `src/renderer/src/store/slices/worktrees.ts:467-489` | DONE |
---
## Tier 2 — Medium Impact
| # | Area | Issue | File(s) | Status |
|---|------|-------|---------|--------|
| 11 | Main | `git/repo.ts`: `execSync('gh api user ...')` and chains of 5 sync git processes block main thread | `src/main/git/repo.ts:87-138` | TODO |
| 12 | Main | `hooks.ts`: `readFileSync`/`writeFileSync`/`mkdirSync`/`gitExecFileSync` in IPC handlers | `src/main/hooks.ts:113-416` | TODO |
| 13 | Renderer | `useSettings` returns entire `GlobalSettings` (~30+ fields) — any setting change re-renders all consumers | `src/renderer/src/store/selectors.ts` | TODO |
| 14 | Renderer | Tab title changes bump `sortEpoch` for ALL worktrees → triggers WorktreeList re-sort on every PTY title event | `src/renderer/src/store/slices/terminals.ts:325` | TODO |
| 15 | Renderer | `CacheTimer`: one `setInterval` per mounted card (20 cards = 20 intervals/sec), each with O(n) selector work | `src/renderer/src/components/sidebar/CacheTimer.tsx:28-48` | TODO |
| 16 | Renderer | Three simultaneous 3-second polling intervals per active worktree (git status, worktrees, stale conflict) | `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts` | TODO |
| 17 | Renderer | 6 components missing `React.memo`: `SourceControl`, `RightSidebar`, `EditorPanel`, `ChecksPanel`, `FileExplorer`, `TabBar` | Various files in `src/renderer/src/components/` | DONE (8 components wrapped) |
| 18 | Worktree | Git polling fires immediately on every worktree switch (burst of `git status` + `git worktree list`) | `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts:69-103` | REVERTED (150ms debounce caused visible flash on switch) |
| 19 | Worktree | `FileExplorer` `dirCache` discarded on every worktree switch — re-fetches entire tree from scratch | `src/renderer/src/components/right-sidebar/useFileExplorerTree.ts:27,81-96` | TODO |
| 20 | Terminal | `pty:resize` uses `ipcRenderer.invoke` (round-trip) instead of fire-and-forget `send` | `src/preload/index.ts:203-205` | DONE |
| 21 | Terminal | Flow control watermarks defined but never enforced for local PTYs — unbounded output floods renderer | `src/main/providers/local-pty-provider.ts:330-332` | TODO |
| 22 | Browser | `BrowserPane` subscribes to entire `browserPagesByWorkspace` map — any tab's navigation re-renders all panes | `src/renderer/src/components/browser-pane/BrowserPane.tsx:312` | TODO |
| 23 | Browser | `findPage`/`findWorkspace` do O(N) `Object.values().flat().find()` scans on every navigation event | `src/renderer/src/store/slices/browser.ts:221-240` | TODO |
| 24 | Browser | Download progress IPC fires at full Chromium frequency (many/sec) | `src/main/browser/browser-manager.ts:421-430` | TODO |
| 25 | Main | `detectConflictOperation` runs 4 `existsSync` + `readFile` on every 3s poll before git status | `src/main/git/status.ts:236-265` | DONE |
| 26 | Main | `getBranchCompare`: `loadBranchChanges` and `countAheadCommits` run sequentially | `src/main/git/status.ts:374-375` | DONE |
| 27 | Main | `addWorktree` calls 4-5 synchronous git processes from IPC handler | `src/main/git/worktree.ts:116-157` | DONE |
---
## Tier 3 — Lower Impact / Architectural
| # | Area | Issue | File(s) | Status |
|---|------|-------|---------|--------|
| 28 | Terminal | SSH relay `FrameDecoder` uses `Buffer.concat` on every chunk (quadratic copy) | `src/relay/protocol.ts:84` | TODO |
| 29 | Terminal | SSH relay replay buffer uses string concatenation (quadratic allocation) | `src/relay/pty-handler.ts:68-72` | TODO |
| 30 | Terminal | `extractLastOscTitle` regex runs on every PTY chunk with no fast-path bail | `src/shared/agent-detection.ts:23-39` | TODO |
| 31 | Terminal | Binary search calls `serialize()` up to 16× at shutdown | `src/renderer/src/components/terminal-pane/TerminalPane.tsx:579-596` | TODO |
| 32 | Browser | `capturePage()` captures full viewport then crops — should pass rect directly | `src/main/browser/browser-grab-screenshot.ts:36` | TODO |
| 33 | Browser | Parked webviews retain full 100vw×100vh compositor surfaces | `src/renderer/src/components/browser-pane/BrowserPane.tsx:94-107` | TODO |
| 34 | Browser | `onBeforeSendHeaders` intercepts every HTTPS request even when UA override unused | `src/main/browser/browser-session-registry.ts:126-137` | TODO |
| 35 | Main | `setBackgroundThrottling(false)` wastes CPU when window is minimized | `src/main/window/createMainWindow.ts:97` | TODO |
| 36 | Main | `warmSystemFontFamilies()` competes with startup I/O | `src/main/system-fonts.ts:30-32` | TODO |
| 37 | Renderer | Session persistence effect has 15 deps — fires on every tab title change | `src/renderer/src/App.tsx:239-283` | TODO |
| 38 | Renderer | Per-card `fetchPRForBranch` on mount — 30 worktrees = 30 simultaneous IPC calls | `src/renderer/src/components/sidebar/WorktreeCard.tsx:128-132` | TODO |
| 39 | Renderer | Synchronous full `monaco-editor` import blocks EditorPanel chunk evaluation | `src/renderer/src/components/editor/EditorPanel.tsx:7` | TODO |
| 40 | Worktree | `removeWorktree` runs `git worktree list` twice (pre and post removal) | `src/main/git/worktree.ts:163-208` | TODO |
| 41 | Worktree | `worktrees:list` can trigger duplicate `git worktree list` when cache is dirty | `src/main/ipc/worktrees.ts:86-116` | TODO |
| 42 | Renderer | SSH targets initialized sequentially in a `for...await` loop | `src/renderer/src/hooks/useIpcEvents.ts:232-252` | TODO |
---
## Key Themes
1. **Zustand subscription granularity**`App.tsx` subscribes to 53 slices, `Terminal.tsx` subscribes to everything, `useSettings` returns the full object. Almost any state change cascades through the entire tree.
2. **Synchronous I/O on the main thread**`persistence.ts`, `hooks.ts`, `git/repo.ts`, and `git/worktree.ts` use `readFileSync`/`writeFileSync`/`execSync` in startup and IPC handler paths.
3. **Unthrottled high-frequency events** — PTY data (no IPC batching), divider drag (fit on every pixel), download progress (no throttle), `before-mouse-event` (all mouse events on all guests), CacheTimer (20 intervals/sec).
4. **Sequential operations that could be parallel** — Startup server binds, repo iteration in `listAll`, git ref probing, `loadBranchChanges`/`countAheadCommits`, SSH target init.
5. **Aggressive polling** — Three 3-second intervals per worktree, per-card 1-second cache timers, per-card 5-minute issue polling, 250ms error-page detection.

View file

@ -0,0 +1,179 @@
# Performance Improvement — Implementation Plan
Branch: `Jinwoo-H/performance-improvement`
---
## Completed (Tier 1)
### Fix 3 — PTY IPC Data Batching
**Files:** `src/main/ipc/pty.ts`
**Change:** Added 8ms flush-window batching for PTY data. Instead of calling `webContents.send('pty:data', ...)` on every `node-pty` onData event, data is accumulated per-PTY in a `Map<string, string>` and flushed once per 8ms interval. Reduces IPC round-trips from hundreds/sec to ~120/sec under high throughput. Interactive latency stays below one frame (16ms).
### Fix 4 — Divider Drag Throttled to rAF
**Files:** `src/renderer/src/lib/pane-manager/pane-divider.ts`
**Change:** Wrapped `refitPanesUnder` calls in `requestAnimationFrame` guard during `onPointerMove`. Previously `fitAddon.fit()` ran on every pointer event (~250Hz), each triggering a full xterm.js reflow (500ms+ with large scrollback). Now capped at 60fps. Cleanup on `onPointerUp` cancels pending rAF and runs one final refit.
### Fix 5 — Startup Parallelization
**Files:** `src/main/index.ts`
**Change:** `openCodeHookService.start()`, `runtimeRpc.start()`, and `openMainWindow()` now run concurrently via `Promise.all` instead of three sequential `await`s. Window creation no longer blocked by server bind operations.
### Fix 6 — Async Persistence Writes
**Files:** `src/main/persistence.ts`, `src/main/persistence.test.ts`
**Change:** Debounced `scheduleSave()` now calls `writeToDiskAsync()` using `fs/promises` (writeFile, rename, mkdir) instead of `writeFileSync`/`renameSync`. Synchronous `writeToDiskSync()` retained only for `flush()` at shutdown. Added `waitForPendingWrite()` for test await support.
### Fix 7 — Parallel Worktree Listing
**Files:** `src/main/ipc/worktrees.ts`
**Change:** `worktrees:listAll` handler now uses `Promise.all(repos.map(...))` instead of sequential `for...of` loop. Total time = slowest repo, not sum of all repos. Each repo's `listRepoWorktrees` spawns `git worktree list` subprocess independently.
### Fix 8 — BrowserManager Reverse Map
**Files:** `src/main/browser/browser-manager.ts`
**Change:** Added `tabIdByWebContentsId` reverse Map maintained in sync with `webContentsIdByTabId`. Replaced two O(N) `[...entries()].find()` scans with O(1) `.get()` lookups. Updated `registerGuest`, `unregisterGuest`, and `unregisterAll` to keep both maps in sync.
### Fix 9 — Context Menu Listener Scoping
**Files:** `src/main/browser/browser-guest-ui.ts`
**Change:** `before-mouse-event` listener is now installed only when a context menu is open (on `context-menu` event) and removed on first `mouseDown` (dismiss). Previously fired for every mouse event on every guest surface.
### Fix 10 — GitHub Cache TTL on Worktree Switch
**Files:** `src/renderer/src/store/slices/github.ts`, `src/renderer/src/store/slices/worktrees.ts`, `src/renderer/src/store/slices/store-cascades.test.ts`
**Change:** Added `refreshGitHubForWorktreeIfStale()` that checks cache age before fetching. `setActiveWorktree` now calls this instead of `refreshGitHubForWorktree` (which always force-refreshes). Eliminates unnecessary GitHub API calls on rapid worktree switching. Force-refresh still available via explicit user action.
### Fix 20 (Tier 2 bonus) — PTY Resize Fire-and-Forget
**Files:** `src/main/ipc/pty.ts`, `src/preload/index.ts`
**Change:** `pty:resize` changed from `ipcMain.handle`/`ipcRenderer.invoke` (round-trip) to `ipcMain.on`/`ipcRenderer.send` (fire-and-forget). Halves IPC traffic for terminal resize events since the renderer never awaited the response anyway.
---
## Completed (Tier 1 — continued)
### Fix 1b — Session Persistence Extracted from React
**Files:** `src/renderer/src/App.tsx`
**Change:** Replaced the session-persistence `useEffect` (which had ~15 Zustand subscriptions as deps) with a single `useAppStore.subscribe()` call that runs outside React's render cycle. The subscriber debounces writes to disk via `window.setTimeout(150ms)`. This removed 12 `useAppStore` subscriptions from App's render cycle (`activeRepoId`, `terminalLayoutsByTabId`, `openFiles`, `activeFileIdByWorktree`, `activeTabTypeByWorktree`, `activeTabIdByWorktree`, `browserTabsByWorktree`, `browserPagesByWorkspace`, `activeBrowserTabIdByWorktree`, `unifiedTabsByWorktree`, `groupsByWorktree`, `activeGroupIdByWorktree`) — none of which ever drove JSX.
### Fix 1a — Consolidated Action Subscriptions
**Files:** `src/renderer/src/App.tsx`
**Change:** Consolidated 19 stable action-ref subscriptions (`toggleSidebar`, `fetchRepos`, `openModal`, `setRightSidebarTab`, etc.) into a single `useShallow` selector returning an `actions` object. Since Zustand actions are referentially stable, the shallow equality check always passes and this subscription never triggers a re-render. All call sites updated to `actions.fetchRepos()`, `actions.toggleSidebar()`, etc.
### Fix 1c — React.memo Barriers for Children
**Files:** `src/renderer/src/components/sidebar/index.tsx`, `src/renderer/src/components/Terminal.tsx`, `src/renderer/src/components/right-sidebar/index.tsx`, `src/renderer/src/components/status-bar/StatusBar.tsx`
**Change:** Wrapped `Sidebar`, `Terminal`, `RightSidebar`, and `StatusBar` in `React.memo`. These components accept no props from App — they read state from the store directly. The memo barrier prevents App's remaining re-renders (from layout state like `sidebarWidth`, `activeView`) from cascading into the full component tree.
### Fix 2 — Terminal.tsx Scoped Subscribe
**Files:** `src/renderer/src/components/Terminal.tsx`
**Change:** The `useAppStore.subscribe()` that destroys orphaned browser webviews now short-circuits with a reference equality check (`state.browserTabsByWorktree === prevBrowserTabs`). Previously fired on every store mutation; now only runs the O(tabs) scan when `browserTabsByWorktree` actually changes.
**Total App.tsx subscription reduction: 53 → 22 (58% fewer)**
---
## Planned — Tier 2
### Fix 11 — Async Git Username/Login
**Files:** `src/main/git/repo.ts`
**Change:** Replace `execSync('gh api user ...')` and `gitExecFileSync` chains in `getGhLogin`, `getGitUsername`, and `getDefaultBaseRef` with their async equivalents. `getDefaultBaseRefAsync` already exists — remove the sync variant and migrate all callers. The `Store.hydrateRepo` call in `getRepos()` is synchronous and uses `getGitUsername` — convert to a lazy-populate pattern where the `gitUsername` field is initially empty and filled by an async hydration pass after construction.
### Fix 12 — Async Hooks File I/O
**Files:** `src/main/hooks.ts`
**Change:** Convert `loadHooks`, `hasHooksFile`, `hasUnrecognizedOrcaYamlKeys`, `readIssueCommand`, `writeIssueCommand`, and `createWorktreeRunnerScript` from `readFileSync`/`writeFileSync`/`mkdirSync`/`gitExecFileSync` to `fs/promises` + `gitExecFileAsync`. Update all callers in `src/main/ipc/worktrees.ts` to await the new async versions.
### Fix 13 — Narrow `useSettings` Selector
**Files:** `src/renderer/src/store/selectors.ts`, all 10 consumers
**Change:** The current `useSettings = () => useAppStore((s) => s.settings)` returns the entire GlobalSettings object (~30 fields). Any setting change re-renders every consumer. Replace with field-specific selectors or use `useShallow` at each call site to select only the fields used by that component:
- `App.tsx` only uses `settings.theme``useAppStore((s) => s.settings?.theme)`
- `MonacoEditor.tsx` uses font/tab/theme → `useShallow` for those 3 fields
- `AddWorktreeDialog.tsx` uses one field → direct selector
### Fix 14 — Narrow sortEpoch Bumping
**Files:** `src/renderer/src/store/slices/terminals.ts`
**Change:** `updateTabTitle` currently bumps `sortEpoch` on every title string change for background worktrees (line 354). Title strings change frequently during agent runs (shell prompts, command names). Only bump `sortEpoch` when the agent working/idle status boundary is actually crossed. This requires comparing the old and new title against the agent-status detection logic before deciding to increment.
### Fix 15 — Shared CacheTimer Interval
**Files:** `src/renderer/src/components/sidebar/CacheTimer.tsx`
**Change:** Each `CacheTimer` instance creates its own 1-second `setInterval`. With 20 visible cards this means 20 intervals firing per second, each running a Zustand selector that iterates `Object.keys(s.cacheTimerByKey)`. Replace with a single shared interval at the module level (or in the store slice) that updates a `remainingByWorktreeId` map in one `set()` call. Components subscribe to only their specific worktree's entry.
### Fix 16 — Consolidate Git Status Polling
**Files:** `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts`
**Change:** Three `setInterval(fn, 3000)` calls run simultaneously: git status, fetchWorktrees, and stale conflict poll. The worktree list poll (every 3s) is aggressive — branch changes inside terminals are low-frequency. Consolidate into a single interval:
- Git status poll: keep at 3s (drives diff gutter, status badge)
- Worktree list poll: increase to 15s (only needed when user runs `git checkout` in terminal)
- Stale conflict poll: keep at 3s but only when stale worktrees exist (already gated)
### Fix 17 — React.memo on Heavy Components
**Files:** `SourceControl.tsx`, `EditorPanel.tsx`, `FileExplorer.tsx`, `TabBar.tsx`
**Change:** Wrapped all four in `React.memo`. Combined with the Tier 1 memo barriers on Sidebar/Terminal/RightSidebar/StatusBar, a total of 8 heavy components now prevent parent re-render cascades.
### Fix 18 — Debounce Git Polling on Worktree Switch
**Files:** `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts`
**Change:** Replaced the immediate `void fetchStatus()` and `void fetchWorktrees(activeRepoId)` calls with 150ms `setTimeout` debounces. The interval polling continues as before. Rapid worktree switching now only fires one git status + one git worktree list subprocess instead of N.
### Fix 19 — Cache FileExplorer dirCache Per Worktree
**Files:** `src/renderer/src/components/right-sidebar/useFileExplorerTree.ts`
**Change:** `dirCache` is local `useState` — reset on every worktree switch. Cache the directory tree per worktree in a `useRef<Map<string, DirCache>>()` at the hook level. On switch, restore from cache instantly (with a background revalidation fetch). This makes repeated worktree switches O(1) for the file explorer.
### Fix 21 — Local PTY Flow Control
**Files:** `src/main/providers/local-pty-provider.ts`, `src/renderer/src/components/terminal-pane/pty-dispatcher.ts`
**Change:** Wire up the already-defined `PTY_FLOW_HIGH_WATERMARK` (100KB) and `PTY_FLOW_LOW_WATERMARK` (5KB) constants. Track pending bytes per PTY in the renderer's `EagerPtyBuffer`. When pending exceeds high watermark, send an IPC message to pause the node-pty stream. Resume when acknowledged down to low watermark. The `acknowledgeDataEvent` channel is already plumbed — just needs implementation.
### Fix 22 — Narrow BrowserPane Selector
**Files:** `src/renderer/src/components/browser-pane/BrowserPane.tsx`
**Change:** Line 312: `useAppStore((s) => s.browserPagesByWorkspace)` subscribes to the entire map. Any tab's navigation re-renders all BrowserPane instances. Narrow to: `useAppStore((s) => s.browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES)`.
### Fix 23 — Index-Based findPage/findWorkspace
**Files:** `src/renderer/src/store/slices/browser.ts`
**Change:** `findWorkspace` and `findPage` (lines 221-240) use `Object.values().flat().find()` on every navigation event. Accept `worktreeId`/`workspaceId` as a hint parameter and do direct key access: `browserTabsByWorktree[worktreeId]?.find(...)` instead of flattening across all worktrees.
### Fix 24 — Throttle Download Progress
**Files:** `src/main/browser/browser-manager.ts`
**Change:** `download.item.on('updated', ...)` fires at full Chromium frequency. Add per-download throttle timer — only call `sendDownloadProgress` at most once per 250ms. Clear throttle timer on download done/cancel.
### Fix 25 — Parallelize detectConflictOperation with git status
**Files:** `src/main/git/status.ts`
**Change:** `getStatus()` now kicks off both `detectConflictOperation()` and `git status` concurrently. The conflict detection promise is started first and awaited before the status result, preserving error semantics while overlapping I/O.
### Fix 26 — Parallelize getBranchCompare
**Files:** `src/main/git/status.ts`
**Change:** `loadBranchChanges` and `countAheadCommits` now run via `Promise.all` instead of sequentially. These are independent git subprocess calls.
### Fix 27 — Async addWorktree
**Files:** `src/main/git/worktree.ts`, `src/main/ipc/worktree-remote.ts`, `src/main/runtime/orca-runtime.ts`
**Change:** Converted `addWorktree` from synchronous (`gitExecFileSync`) to async (`gitExecFileAsync`). This was the last major sync git operation on the main thread — 4-5 sequential subprocess calls that blocked the event loop during worktree creation. Updated callers and all 7 tests.
---
## Benchmark Validation Results (2026-04-14)
Ran 6 targeted benchmarks to validate optimization claims. Full scripts in `benchmarks/`.
| Fix | What was measured | Result | Verdict |
|-----|-------------------|--------|---------|
| 3 — PTY Batching | IPC calls/sec: unbatched vs 8ms window | 5000→125 calls/sec (98% reduction) | **Validated — high impact** |
| 5/7 — Parallelization | Sequential vs parallel subprocess at N=5 | 119ms→46ms (2.6x, 73ms saved) | **Validated — high impact** |
| 6 — Async I/O | Main-thread blocking: sync vs fire-and-forget | 439µs→21µs per write (21x less blocking) | **Validated — high impact** |
| 1 — Zustand Subs | Selector cost: 53 subs vs 18 subs | 2.61µs→1.59µs per mutation (1.6x) | **Validated — moderate** (real win is cascade prevention via React.memo) |
| 8 — Reverse Map | entries().find() vs Map.get() at N=10 | 409ns→1ns (479x) but 0.04ms/sec total | **Deprioritize** — micro-optimization |
| 23 — flat().find() | Object.values().flat().find() vs direct at 50 tabs | 1958ns→62ns (31x) but 0.02ms/sec total | **Deprioritize** — micro-optimization |
### Deprioritized based on benchmarks
The following fixes are technically correct but save <0.1ms/sec at realistic load. Moved to "nice to have":
- Fix 8 (Reverse Map) — already implemented, keep as-is
- Fix 14 (sortEpoch) — per-mutation overhead is negligible
- Fix 15 (CacheTimer shared interval) — 20 intervals/sec is fine for modern JS engines
- Fix 22 (BrowserPane selector) — sub-microsecond per render
- Fix 23 (findPage/findWorkspace) — 0.02ms/sec at 10 nav/sec
- Fix 24 (Download progress throttle) — infrequent event
- Fix 26 (getBranchCompare parallel) — implemented anyway since it was a one-liner
---
## Key Themes
1. **Zustand subscription granularity** — Fixes 1, 2, 13, 14, 17, 22 all reduce the blast radius of state changes on React re-renders.
2. **Synchronous I/O on main thread** — Fixes 6, 11, 12, 27 convert blocking filesystem and git operations to async.
3. **Unthrottled high-frequency events** — Fixes 3, 4, 9, 15, 20, 24 cap event processing to reasonable rates.
4. **Sequential → parallel** — Fixes 5, 7, 25, 26 run independent async operations concurrently.
5. **Aggressive polling** — Fixes 16, 18 consolidate and debounce polling intervals.

View file

@ -74,22 +74,46 @@ export function setupGuestContextMenu(args: {
})
}
guest.on('context-menu', handler)
const dismissHandler = (_event: Electron.Event, mouse: Electron.MouseInputEvent): void => {
if (mouse.type !== 'mouseDown') {
return
// Why: `before-mouse-event` fires for every mouse event (move, down, up,
// scroll) on the guest. Installing the dismiss listener only while a context
// menu is open avoids an IPC dispatch per mouse event on idle guests.
let dismissHandler: ((_event: Electron.Event, mouse: Electron.MouseInputEvent) => void) | null =
null
const removeDismissListener = (): void => {
if (dismissHandler) {
try {
guest.off('before-mouse-event', dismissHandler)
} catch {
/* guest may already be destroyed */
}
dismissHandler = null
}
const renderer = resolveRenderer(browserTabId)
if (!renderer) {
return
}
renderer.send('browser:context-menu-dismissed', { browserPageId: browserTabId })
}
guest.on('before-mouse-event', dismissHandler)
const contextMenuHandler = (_event: Electron.Event, params: Electron.ContextMenuParams): void => {
handler(_event, params)
removeDismissListener()
dismissHandler = (_evt: Electron.Event, mouse: Electron.MouseInputEvent): void => {
if (mouse.type !== 'mouseDown') {
return
}
const renderer = resolveRenderer(browserTabId)
if (renderer) {
renderer.send('browser:context-menu-dismissed', { browserPageId: browserTabId })
}
removeDismissListener()
}
guest.on('before-mouse-event', dismissHandler)
}
guest.on('context-menu', contextMenuHandler)
return () => {
try {
guest.off('context-menu', handler)
guest.off('before-mouse-event', dismissHandler)
guest.off('context-menu', contextMenuHandler)
removeDismissListener()
} catch {
// Why: browser tabs can outlive the guest webContents briefly during
// teardown. Cleanup should be best-effort instead of throwing while the

View file

@ -73,6 +73,9 @@ function safeOrigin(rawUrl: string): string {
class BrowserManager {
private readonly webContentsIdByTabId = new Map<string, number>()
// Why: reverse map enables O(1) guest→tab lookups instead of O(N) linear
// scans on every mouse event, load failure, permission, and popup event.
private readonly tabIdByWebContentsId = new Map<number, string>()
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
@ -89,10 +92,7 @@ class BrowserManager {
private readonly grabSessionController = new BrowserGrabSessionController()
private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null {
return (
[...this.webContentsIdByTabId.entries()].find(([, id]) => id === guestWebContentsId)?.[0] ??
null
)
return this.tabIdByWebContentsId.get(guestWebContentsId) ?? null
}
private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null {
@ -221,6 +221,7 @@ class BrowserManager {
}
this.webContentsIdByTabId.set(browserTabId, webContentsId)
this.tabIdByWebContentsId.set(webContentsId, browserTabId)
this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId)
this.setupContextMenu(browserTabId, guest)
@ -261,6 +262,10 @@ class BrowserManager {
this.cancelDownloadInternal(downloadId, 'Tab closed before download was accepted.')
}
}
const wcId = this.webContentsIdByTabId.get(browserTabId)
if (wcId !== undefined) {
this.tabIdByWebContentsId.delete(wcId)
}
this.webContentsIdByTabId.delete(browserTabId)
this.rendererWebContentsIdByTabId.delete(browserTabId)
}
@ -275,6 +280,7 @@ class BrowserManager {
this.unregisterGuest(browserTabId)
}
this.policyAttachedGuestIds.clear()
this.tabIdByWebContentsId.clear()
this.pendingLoadFailuresByGuestId.clear()
this.pendingPermissionEventsByGuestId.clear()
this.pendingPopupEventsByGuestId.clear()
@ -477,6 +483,7 @@ class BrowserManager {
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
this.webContentsIdByTabId.delete(browserTabId)
this.tabIdByWebContentsId.delete(webContentsId)
return false
}
guest.openDevTools({ mode: 'detach' })
@ -506,6 +513,7 @@ class BrowserManager {
const guest = webContents.fromId(guestId)
if (!guest || guest.isDestroyed()) {
this.webContentsIdByTabId.delete(browserTabId)
this.tabIdByWebContentsId.delete(guestId)
return null
}
return guest
@ -666,9 +674,7 @@ class BrowserManager {
guestWebContentsId: number,
loadError: { code: number; description: string; validatedUrl: string }
): void {
const browserTabId = [...this.webContentsIdByTabId.entries()].find(
([, webContentsId]) => webContentsId === guestWebContentsId
)?.[0]
const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId)
if (!browserTabId) {
// Why: some localhost failures happen before the renderer finishes
// registering which tab owns this guest. Queue the failure by guest ID so

View file

@ -23,13 +23,17 @@ const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
*/
export async function getStatus(worktreePath: string): Promise<GitStatusResult> {
const entries: GitStatusEntry[] = []
const conflictOperation = await detectConflictOperation(worktreePath)
// Why: detectConflictOperation (4 existsSync + readFile) and git status are
// independent. Running them concurrently saves one round-trip of I/O latency.
const conflictPromise = detectConflictOperation(worktreePath)
const statusPromise = gitExecFileAsync(['status', '--porcelain=v2', '--untracked-files=all'], {
cwd: worktreePath
})
const conflictOperation = await conflictPromise
try {
const { stdout } = await gitExecFileAsync(
['status', '--porcelain=v2', '--untracked-files=all'],
{ cwd: worktreePath }
)
const { stdout } = await statusPromise
// [Fix]: Split by /\r?\n/ instead of '\n' to correctly parse git output on Windows,
// avoiding trailing \r characters in parsed paths.
@ -371,8 +375,10 @@ export async function getBranchCompare(
}
try {
const entries = await loadBranchChanges(worktreePath, mergeBase, headOid)
const commitsAhead = await countAheadCommits(worktreePath, baseOid, headOid)
const [entries, commitsAhead] = await Promise.all([
loadBranchChanges(worktreePath, mergeBase, headOid),
countAheadCommits(worktreePath, baseOid, headOid)
])
summary.changedFiles = entries.length
summary.commitsAhead = commitsAhead
summary.status = 'ready'

View file

@ -206,29 +206,29 @@ describe('addWorktree', () => {
translateWslOutputPathsMock.mockClear()
})
it('creates the worktree without touching the local base ref by default', () => {
gitExecFileSyncMock.mockReturnValueOnce(undefined)
it('creates the worktree without touching the local base ref by default', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main')
expect(gitExecFileSyncMock.mock.calls).toEqual([
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['worktree', 'add', '-b', 'feature/test', '/repo-feature', 'origin/main'], { cwd: '/repo' }]
])
})
it('fast-forwards with reset --hard when localBranch is checked out in primary worktree', () => {
it('fast-forwards with reset --hard when localBranch is checked out in primary worktree', async () => {
const worktreeListOutput =
'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /repo-other\nHEAD def456\nbranch refs/heads/feature\n'
gitExecFileSyncMock
.mockReturnValueOnce('') // merge-base --is-ancestor
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
.mockReturnValueOnce('') // status --porcelain (in /repo)
.mockReturnValueOnce(undefined) // reset --hard (in /repo)
.mockReturnValueOnce(undefined) // worktree add
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo)
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileSyncMock.mock.calls).toEqual([
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['merge-base', '--is-ancestor', 'main', 'origin/main'], { cwd: '/repo' }],
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }],
[['status', '--porcelain', '--untracked-files=no'], { cwd: '/repo' }],
@ -237,57 +237,57 @@ describe('addWorktree', () => {
])
})
it('fast-forwards with reset --hard in sibling worktree when localBranch is checked out there', () => {
it('fast-forwards with reset --hard in sibling worktree when localBranch is checked out there', async () => {
const worktreeListOutput =
'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n\nworktree /repo-main-wt\nHEAD def456\nbranch refs/heads/main\n'
gitExecFileSyncMock
.mockReturnValueOnce('') // merge-base --is-ancestor
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
.mockReturnValueOnce('') // status --porcelain (in /repo-main-wt)
.mockReturnValueOnce(undefined) // reset --hard (in /repo-main-wt)
.mockReturnValueOnce(undefined) // worktree add
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: '' }) // reset --hard (in /repo-main-wt)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileSyncMock.mock.calls[2]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([
['status', '--porcelain', '--untracked-files=no'],
expect.objectContaining({ cwd: '/repo-main-wt' })
])
expect(gitExecFileSyncMock.mock.calls[3]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[3]).toEqual([
['reset', '--hard', 'origin/main'],
expect.objectContaining({ cwd: '/repo-main-wt' })
])
})
it('uses update-ref when localBranch is not checked out in any worktree', () => {
it('uses update-ref when localBranch is not checked out in any worktree', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/develop\n'
gitExecFileSyncMock
.mockReturnValueOnce('') // merge-base --is-ancestor
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
.mockReturnValueOnce(undefined) // update-ref
.mockReturnValueOnce(undefined) // worktree add
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // update-ref
.mockResolvedValueOnce({ stdout: '' }) // worktree add
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileSyncMock.mock.calls[2]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[2]).toEqual([
['update-ref', 'refs/heads/main', 'origin/main'],
expect.objectContaining({ cwd: '/repo' })
])
})
it('skips update when the owning worktree is dirty', () => {
it('skips update when the owning worktree is dirty', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileSyncMock
.mockReturnValueOnce('') // merge-base --is-ancestor
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
.mockReturnValueOnce(' M package.json\n') // status --porcelain (dirty)
.mockReturnValueOnce(undefined) // worktree add
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: ' M package.json\n' }) // status --porcelain (dirty)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
// No reset --hard or update-ref — just merge-base, worktree list, status, worktree add
expect(gitExecFileSyncMock.mock.calls).toHaveLength(4)
expect(gitExecFileSyncMock.mock.calls[3]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls).toHaveLength(4)
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual([
'worktree',
'add',
'-b',
@ -297,15 +297,13 @@ describe('addWorktree', () => {
])
})
it('skips updating the local branch when it has diverged', () => {
gitExecFileSyncMock.mockImplementationOnce(() => {
throw new Error('not a fast-forward')
})
gitExecFileSyncMock.mockReturnValueOnce(undefined)
it('skips updating the local branch when it has diverged', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(gitExecFileSyncMock.mock.calls).toEqual([
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['merge-base', '--is-ancestor', 'main', 'origin/main'],
expect.objectContaining({ cwd: '/repo' })
@ -317,23 +315,23 @@ describe('addWorktree', () => {
])
})
it('uses the remote name from the base ref instead of hardcoding origin', () => {
it('uses the remote name from the base ref instead of hardcoding origin', async () => {
const worktreeListOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileSyncMock
.mockReturnValueOnce('') // merge-base --is-ancestor
.mockReturnValueOnce(worktreeListOutput) // worktree list --porcelain
.mockReturnValueOnce('') // status --porcelain
.mockReturnValueOnce(undefined) // reset --hard
.mockReturnValueOnce(undefined) // worktree add
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' }) // merge-base --is-ancestor
.mockResolvedValueOnce({ stdout: worktreeListOutput }) // worktree list --porcelain
.mockResolvedValueOnce({ stdout: '' }) // status --porcelain
.mockResolvedValueOnce({ stdout: '' }) // reset --hard
.mockResolvedValueOnce({ stdout: '' }) // worktree add
addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true)
await addWorktree('/repo', '/repo-feature', 'feature/test', 'upstream/main', true)
expect(gitExecFileSyncMock.mock.calls[0]?.[0]).toEqual([
expect(gitExecFileAsyncMock.mock.calls[0]?.[0]).toEqual([
'merge-base',
'--is-ancestor',
'main',
'upstream/main'
])
expect(gitExecFileSyncMock.mock.calls[3]?.[0]).toEqual(['reset', '--hard', 'upstream/main'])
expect(gitExecFileAsyncMock.mock.calls[3]?.[0]).toEqual(['reset', '--hard', 'upstream/main'])
})
})

View file

@ -1,6 +1,6 @@
import { posix, win32 } from 'path'
import type { GitWorktreeInfo } from '../../shared/types'
import { gitExecFileAsync, gitExecFileSync, translateWslOutputPaths } from './runner'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
function normalizeLocalBranchRef(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
@ -91,13 +91,13 @@ export async function listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]
* @param branch - Branch name for the new worktree
* @param baseBranch - Optional base branch to create from (defaults to HEAD)
*/
export function addWorktree(
export async function addWorktree(
repoPath: string,
worktreePath: string,
branch: string,
baseBranch?: string,
refreshLocalBaseRef = false
): void {
): Promise<void> {
// Why: Some users want Orca-created worktrees to make plain commands like
// `git diff main...HEAD` work out of the box, while others do not want
// worktree creation to mutate their local main/master ref at all. Keep this
@ -113,16 +113,17 @@ export function addWorktree(
// would silently destroy unpushed local commits if the branch has diverged from
// remote. `merge-base --is-ancestor` returns exit 0 when localBranch is an
// ancestor of baseBranch — i.e. the update is a safe fast-forward.
gitExecFileSync(['merge-base', '--is-ancestor', localBranch, baseBranch], {
await gitExecFileAsync(['merge-base', '--is-ancestor', localBranch, baseBranch], {
cwd: repoPath
})
// Why: We need to find which worktree (if any) has localBranch checked
// out, because moving the ref without updating that worktree's files would
// leave it looking massively dirty. A sibling worktree we don't control is
// just as vulnerable as the primary one.
const worktreeListOutput = gitExecFileSync(['worktree', 'list', '--porcelain'], {
cwd: repoPath
})
const { stdout: worktreeListOutput } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
{ cwd: repoPath }
)
const worktrees = parseWorktreeList(translateWslOutputPaths(worktreeListOutput, repoPath))
const fullRef = `refs/heads/${localBranch}`
const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef)
@ -131,16 +132,17 @@ export function addWorktree(
// Why: localBranch is checked out in a worktree. We can only safely
// update if that worktree is clean, and we must use `reset --hard`
// (run inside that worktree) so the files move with the ref.
const status = gitExecFileSync(['status', '--porcelain', '--untracked-files=no'], {
cwd: ownerWorktree.path
})
const { stdout: status } = await gitExecFileAsync(
['status', '--porcelain', '--untracked-files=no'],
{ cwd: ownerWorktree.path }
)
if (!status.trim()) {
gitExecFileSync(['reset', '--hard', baseBranch], { cwd: ownerWorktree.path })
await gitExecFileAsync(['reset', '--hard', baseBranch], { cwd: ownerWorktree.path })
}
} else {
// Why: localBranch is not checked out anywhere, so there is no working
// tree to desync. `update-ref` is safe here.
gitExecFileSync(['update-ref', fullRef, baseBranch], { cwd: repoPath })
await gitExecFileAsync(['update-ref', fullRef, baseBranch], { cwd: repoPath })
}
} catch {
// merge-base fails if the local branch doesn't exist or has diverged;
@ -154,7 +156,7 @@ export function addWorktree(
if (baseBranch) {
args.push(baseBranch)
}
gitExecFileSync(args, { cwd: repoPath })
await gitExecFileAsync(args, { cwd: repoPath })
}
/**

View file

@ -128,14 +128,6 @@ app.whenReady().then(async () => {
rateLimits.setCodexHomePathResolver(() => codexAccounts!.getSelectedManagedHomePath())
runtime = new OrcaRuntimeService(store, stats)
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
try {
await openCodeHookService.start()
} catch (error) {
// Why: OpenCode hooks only enrich status detection. Orca should still boot
// even if the local loopback server cannot bind on this launch.
console.error('[opencode] Failed to start local hook server:', error)
}
registerAppMenu({
onCheckForUpdates: () => checkForUpdatesFromMenu(),
onOpenSettings: () => {
@ -158,14 +150,18 @@ app.whenReady().then(async () => {
runtime,
userDataPath: app.getPath('userData')
})
try {
await runtimeRpc.start()
} catch (error) {
// Why: the local RPC transport enables the future CLI, but Orca should
// still boot as an editor if the socket cannot be opened on this launch.
console.error('[runtime] Failed to start local RPC transport:', error)
}
const win = openMainWindow()
// Why: both server binds are independent and neither blocks window creation.
// Parallelizing them with the window open shaves ~100-200ms off cold start.
const [win] = await Promise.all([
Promise.resolve(openMainWindow()),
openCodeHookService.start().catch((error) => {
console.error('[opencode] Failed to start local hook server:', error)
}),
runtimeRpc.start().catch((error) => {
console.error('[runtime] Failed to start local RPC transport:', error)
})
])
// Why: the macOS notification permission dialog must fire after the window
// is visible and focused. If it fires before the window exists, the system

View file

@ -121,7 +121,6 @@ export function registerPtyHandlers(
// Remove any previously registered handlers so we can re-register them
// (e.g. when macOS re-activates the app and creates a new window).
ipcMain.removeHandler('pty:spawn')
ipcMain.removeHandler('pty:resize')
ipcMain.removeHandler('pty:kill')
ipcMain.removeHandler('pty:hasChildProcesses')
ipcMain.removeHandler('pty:getForegroundProcess')
@ -172,13 +171,46 @@ export function registerPtyHandlers(
// Wire up provider events → renderer IPC
localDataUnsub?.()
localExitUnsub?.()
// Why: batching PTY data into short flush windows (8ms ≈ half a frame)
// reduces IPC round-trips from hundreds/sec to ~120/sec under high
// throughput, with no perceptible latency increase for interactive use.
const pendingData = new Map<string, string>()
let flushTimer: ReturnType<typeof setTimeout> | null = null
const PTY_BATCH_INTERVAL_MS = 8
const flushPendingData = (): void => {
flushTimer = null
if (mainWindow.isDestroyed()) {
pendingData.clear()
return
}
for (const [id, data] of pendingData) {
mainWindow.webContents.send('pty:data', { id, data })
}
pendingData.clear()
}
localDataUnsub = localProvider.onData((payload) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', payload)
if (mainWindow.isDestroyed()) {
return
}
const existing = pendingData.get(payload.id)
pendingData.set(payload.id, existing ? existing + payload.data : payload.data)
if (!flushTimer) {
flushTimer = setTimeout(flushPendingData, PTY_BATCH_INTERVAL_MS)
}
})
localExitUnsub = localProvider.onExit((payload) => {
if (!mainWindow.isDestroyed()) {
// Why: flush any batched data for this PTY before sending the exit event,
// otherwise the last ≤8ms of output is silently lost because the renderer
// tears down the terminal on pty:exit before the batch timer fires.
const remaining = pendingData.get(payload.id)
if (remaining) {
mainWindow.webContents.send('pty:data', { id: payload.id, data: remaining })
pendingData.delete(payload.id)
}
mainWindow.webContents.send('pty:exit', payload)
}
})
@ -258,7 +290,11 @@ export function registerPtyHandlers(
getProviderForPty(args.id).write(args.id, args.data)
})
ipcMain.handle('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
// Why: resize is fire-and-forget — the renderer doesn't need a reply.
// Using ipcMain.on (not .handle) halves IPC traffic by avoiding the
// empty acknowledgement message back to the renderer.
ipcMain.removeAllListeners('pty:resize')
ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => {
getProviderForPty(args.id).resize(args.id, args.cols, args.rows)
})

View file

@ -193,7 +193,7 @@ export async function createLocalWorktree(
// Fetch is best-effort — don't block worktree creation if offline
}
addWorktree(
await addWorktree(
repo.path,
worktreePath,
branchName,

View file

@ -4,12 +4,7 @@ import { rm } from 'fs/promises'
import type { Store } from '../persistence'
import { isFolderRepo } from '../../shared/repo-kind'
import { deleteWorktreeHistoryDir } from '../terminal-history'
import type {
CreateWorktreeArgs,
CreateWorktreeResult,
Worktree,
WorktreeMeta
} from '../../shared/types'
import type { CreateWorktreeArgs, CreateWorktreeResult, WorktreeMeta } from '../../shared/types'
import { removeWorktree } from '../git/worktree'
import { gitExecFileAsync } from '../git/runner'
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
@ -57,31 +52,36 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
// itself calls listWorktrees for every repo below.
await ensureAuthorizedRootsCache(store)
const repos = store.getRepos()
const allWorktrees: Worktree[] = []
for (const repo of repos) {
let gitWorktrees
if (isFolderRepo(repo)) {
gitWorktrees = [createFolderWorktree(repo)]
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
// Why: when SSH is disconnected the provider is null. Skip this repo
// so the renderer keeps its cached worktree list instead of clearing it.
if (!provider) {
continue
// Why: repos are listed in parallel so total time = slowest repo, not
// the sum of all repos. Each listRepoWorktrees spawns `git worktree list`.
const results = await Promise.all(
repos.map(async (repo) => {
try {
let gitWorktrees
if (isFolderRepo(repo)) {
gitWorktrees = [createFolderWorktree(repo)]
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
if (!provider) {
return []
}
gitWorktrees = await provider.listWorktrees(repo.path)
} else {
gitWorktrees = await listRepoWorktrees(repo)
}
return gitWorktrees.map((gw) => {
const worktreeId = `${repo.id}::${gw.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gw, meta, repo.displayName)
})
} catch {
return []
}
gitWorktrees = await provider.listWorktrees(repo.path)
} else {
gitWorktrees = await listRepoWorktrees(repo)
}
for (const gw of gitWorktrees) {
const worktreeId = `${repo.id}::${gw.path}`
const meta = store.getWorktreeMeta(worktreeId)
allWorktrees.push(mergeWorktree(repo.id, gw, meta, repo.displayName))
}
}
})
)
return allWorktrees
return results.flat()
})
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {

View file

@ -355,7 +355,8 @@ describe('Store', () => {
// The 300ms debounce hasn't elapsed yet
vi.advanceTimersByTime(300)
// Now the save should have fired
// The timer fired; wait for the async disk write to complete
await store.waitForPendingWrite()
const persisted = readDataFile() as { repos: Repo[] }
expect(persisted.repos).toHaveLength(1)

View file

@ -1,5 +1,6 @@
import { app } from 'electron'
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'fs'
import { writeFile, rename, mkdir, rm } from 'fs/promises'
import { join, dirname } from 'path'
import { homedir } from 'os'
import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types'
@ -54,6 +55,8 @@ function normalizeSortBy(sortBy: unknown): 'name' | 'recent' | 'repo' {
export class Store {
private state: PersistedState
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | null = null
private writeGeneration = 0
private gitUsernameCache = new Map<string, string>()
constructor() {
@ -100,25 +103,50 @@ export class Store {
}
this.writeTimer = setTimeout(() => {
this.writeTimer = null
try {
this.writeToDisk()
} catch (err) {
console.error('[persistence] Failed to write state:', err)
}
this.pendingWrite = this.writeToDiskAsync()
.catch((err) => {
console.error('[persistence] Failed to write state:', err)
})
.finally(() => {
this.pendingWrite = null
})
}, 300)
}
private writeToDisk(): void {
/** Wait for any in-flight async disk write to complete. Used in tests. */
async waitForPendingWrite(): Promise<void> {
if (this.pendingWrite) {
await this.pendingWrite
}
}
// Why: async writes avoid blocking the main Electron thread on every
// debounced save (every 300ms during active use).
private async writeToDiskAsync(): Promise<void> {
const gen = this.writeGeneration
const dataFile = getDataFile()
const dir = dirname(dataFile)
await mkdir(dir, { recursive: true }).catch(() => {})
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
await writeFile(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
// Why: if flush() ran while this async write was in-flight, it bumped
// writeGeneration and already wrote the latest state synchronously.
// Renaming this stale tmp file would overwrite the fresh data.
if (this.writeGeneration !== gen) {
await rm(tmpFile).catch(() => {})
return
}
await rename(tmpFile, dataFile)
}
// Why: synchronous variant kept only for flush() at shutdown, where the
// process may exit before an async write completes.
private writeToDiskSync(): void {
const dataFile = getDataFile()
const dir = dirname(dataFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
// Why: synchronous flushes can race the debounced writer during shutdown or
// beforeunload persistence. A shared `.tmp` path lets one rename steal the
// temp file from the other, which surfaces as ENOENT even though the final
// state may already be on disk. Use a unique temp file per write so atomic
// replaces remain race-safe across platforms.
const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, dataFile)
@ -323,8 +351,12 @@ export class Store {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
// Why: bump writeGeneration so any in-flight async writeToDiskAsync skips
// its rename, preventing a stale snapshot from overwriting this sync write.
this.writeGeneration++
this.pendingWrite = null
try {
this.writeToDisk()
this.writeToDiskSync()
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}

View file

@ -627,7 +627,7 @@ export class OrcaRuntimeService {
// Why: matching the editor behavior keeps CLI creation usable offline.
}
addWorktree(
await addWorktree(
repo.path,
worktreePath,
branchName,

View file

@ -260,7 +260,7 @@ const api = {
},
resize: (id: string, cols: number, rows: number): void => {
ipcRenderer.invoke('pty:resize', { id, cols, rows })
ipcRenderer.send('pty:resize', { id, cols, rows })
},
kill: (id: string): Promise<void> => ipcRenderer.invoke('pty:kill', { id }),

View file

@ -61,10 +61,36 @@ function isEditableTarget(target: EventTarget | null): boolean {
}
function App(): React.JSX.Element {
const toggleSidebar = useAppStore((s) => s.toggleSidebar)
// Why: Zustand actions are referentially stable, but each individual
// useAppStore(s => s.someAction) still registers a subscription that React
// must check on every store mutation. Consolidating 19 action refs into one
// useShallow subscription means one equality check instead of 19.
const actions = useAppStore(
useShallow((s) => ({
toggleSidebar: s.toggleSidebar,
fetchRepos: s.fetchRepos,
fetchAllWorktrees: s.fetchAllWorktrees,
fetchSettings: s.fetchSettings,
initGitHubCache: s.initGitHubCache,
refreshAllGitHub: s.refreshAllGitHub,
hydrateWorkspaceSession: s.hydrateWorkspaceSession,
hydrateEditorSession: s.hydrateEditorSession,
hydrateBrowserSession: s.hydrateBrowserSession,
fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles,
fetchDetectedBrowsers: s.fetchDetectedBrowsers,
reconnectPersistedTerminals: s.reconnectPersistedTerminals,
hydratePersistedUI: s.hydratePersistedUI,
openModal: s.openModal,
closeModal: s.closeModal,
toggleRightSidebar: s.toggleRightSidebar,
setRightSidebarOpen: s.setRightSidebarOpen,
setRightSidebarTab: s.setRightSidebarTab,
updateSettings: s.updateSettings
}))
)
const activeView = useAppStore((s) => s.activeView)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeRepoId = useAppStore((s) => s.activeRepoId)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabId = useAppStore((s) => s.activeTabId)
const activeAgentCount = useAppStore((s) =>
@ -86,21 +112,7 @@ function App(): React.JSX.Element {
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const canExpandPaneByTabId = useAppStore((s) => s.canExpandPaneByTabId)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
const fetchRepos = useAppStore((s) => s.fetchRepos)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
const fetchSettings = useAppStore((s) => s.fetchSettings)
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
const refreshAllGitHub = useAppStore((s) => s.refreshAllGitHub)
const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession)
const hydrateEditorSession = useAppStore((s) => s.hydrateEditorSession)
const hydrateBrowserSession = useAppStore((s) => s.hydrateBrowserSession)
const fetchBrowserSessionProfiles = useAppStore((s) => s.fetchBrowserSessionProfiles)
const fetchDetectedBrowsers = useAppStore((s) => s.fetchDetectedBrowsers)
const reconnectPersistedTerminals = useAppStore((s) => s.reconnectPersistedTerminals)
const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI)
const openModal = useAppStore((s) => s.openModal)
const repos = useAppStore((s) => s.repos)
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
@ -109,27 +121,10 @@ function App(): React.JSX.Element {
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
// Editor state for session persistence
const openFiles = useAppStore((s) => s.openFiles)
const activeFileIdByWorktree = useAppStore((s) => s.activeFileIdByWorktree)
const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree)
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
const activeBrowserTabIdByWorktree = useAppStore((s) => s.activeBrowserTabIdByWorktree)
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
// Right sidebar + editor state
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
const closeModal = useAppStore((s) => s.closeModal)
const isFullScreen = useAppStore((s) => s.isFullScreen)
const settings = useAppStore((s) => s.settings)
// Subscribe to IPC push events
useIpcEvents()
@ -140,9 +135,6 @@ function App(): React.JSX.Element {
useGitStatusPolling()
useGlobalFileDrop()
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
// Fetch initial data + hydrate GitHub cache from disk
useEffect(() => {
let cancelled = false
@ -153,24 +145,24 @@ function App(): React.JSX.Element {
void (async () => {
try {
await fetchRepos()
await fetchAllWorktrees()
await actions.fetchRepos()
await actions.fetchAllWorktrees()
const persistedUI = await window.api.ui.get()
const session = await window.api.session.get()
if (!cancelled) {
hydratePersistedUI(persistedUI)
hydrateWorkspaceSession(session)
hydrateEditorSession(session)
hydrateBrowserSession(session)
await fetchBrowserSessionProfiles()
await fetchDetectedBrowsers()
await reconnectPersistedTerminals(abortController.signal)
actions.hydratePersistedUI(persistedUI)
actions.hydrateWorkspaceSession(session)
actions.hydrateEditorSession(session)
actions.hydrateBrowserSession(session)
await actions.fetchBrowserSessionProfiles()
await actions.fetchDetectedBrowsers()
await actions.reconnectPersistedTerminals(abortController.signal)
syncZoomCSSVar()
}
} catch (error) {
console.error('Failed to hydrate workspace session:', error)
if (!cancelled) {
hydratePersistedUI({
actions.hydratePersistedUI({
lastActiveRepoId: null,
lastActiveWorktreeId: null,
sidebarWidth: 280,
@ -187,7 +179,7 @@ function App(): React.JSX.Element {
dismissedUpdateVersion: null,
lastUpdateCheckAt: null
})
hydrateWorkspaceSession({
actions.hydrateWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
@ -197,30 +189,18 @@ function App(): React.JSX.Element {
// Why: hydrateWorkspaceSession no longer sets workspaceSessionReady.
// The error path has no worktrees to reconnect, but must still flip
// the flag so auto-tab-creation and session writes are unblocked.
await reconnectPersistedTerminals()
await actions.reconnectPersistedTerminals()
}
}
void fetchSettings()
void initGitHubCache()
void actions.fetchSettings()
void actions.initGitHubCache()
})()
return () => {
cancelled = true
abortController.abort()
}
}, [
fetchRepos,
fetchAllWorktrees,
fetchSettings,
initGitHubCache,
hydratePersistedUI,
hydrateWorkspaceSession,
hydrateEditorSession,
hydrateBrowserSession,
fetchBrowserSessionProfiles,
fetchDetectedBrowsers,
reconnectPersistedTerminals
])
}, [actions])
useEffect(() => {
setRuntimeGraphStoreStateGetter(useAppStore.getState)
@ -238,51 +218,30 @@ function App(): React.JSX.Element {
}
}, [workspaceSessionReady])
// Why: session persistence never drives JSX — it only writes to disk.
// Using a Zustand subscribe() outside React removes ~15 subscriptions from
// App's render cycle, eliminating re-renders on every tab/file/browser change.
useEffect(() => {
if (!workspaceSessionReady) {
return
let timer: number | null = null
const unsub = useAppStore.subscribe((state) => {
if (!state.workspaceSessionReady) {
return
}
if (timer) {
window.clearTimeout(timer)
}
timer = window.setTimeout(() => {
timer = null
void window.api.session.set(buildWorkspaceSessionPayload(state))
}, 150)
})
return () => {
unsub()
if (timer) {
window.clearTimeout(timer)
}
}
const timer = window.setTimeout(() => {
void window.api.session.set(
buildWorkspaceSessionPayload({
activeRepoId,
activeWorktreeId,
activeTabId,
tabsByWorktree,
terminalLayoutsByTabId,
activeTabIdByWorktree,
openFiles,
activeFileIdByWorktree,
activeTabTypeByWorktree,
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabIdByWorktree,
unifiedTabsByWorktree,
groupsByWorktree,
activeGroupIdByWorktree
})
)
}, 150)
return () => window.clearTimeout(timer)
}, [
workspaceSessionReady,
activeRepoId,
activeWorktreeId,
activeTabId,
tabsByWorktree,
terminalLayoutsByTabId,
openFiles,
activeFileIdByWorktree,
activeTabTypeByWorktree,
activeTabIdByWorktree,
browserTabsByWorktree,
browserPagesByWorkspace,
activeBrowserTabIdByWorktree,
unifiedTabsByWorktree,
groupsByWorktree,
activeGroupIdByWorktree
])
}, [])
// On shutdown, capture terminal scrollback buffers and flush to disk.
// Runs synchronously in beforeunload: capture → Zustand set → sendSync → flush.
@ -397,12 +356,12 @@ function App(): React.JSX.Element {
useEffect(() => {
const handler = (): void => {
if (document.visibilityState === 'visible') {
refreshAllGitHub()
actions.refreshAllGitHub()
}
}
document.addEventListener('visibilitychange', handler)
return () => document.removeEventListener('visibilitychange', handler)
}, [refreshAllGitHub])
}, [actions])
const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : []
const hasTabBar = tabs.length >= 2
@ -462,14 +421,14 @@ function App(): React.JSX.Element {
// Cmd/Ctrl+B — toggle left sidebar
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'b') {
e.preventDefault()
toggleSidebar()
actions.toggleSidebar()
return
}
// Cmd/Ctrl+L — toggle right sidebar
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault()
toggleRightSidebar()
actions.toggleRightSidebar()
return
}
@ -479,23 +438,23 @@ function App(): React.JSX.Element {
return
}
e.preventDefault()
openModal('create-worktree')
actions.openModal('create-worktree')
return
}
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'e') {
e.preventDefault()
setRightSidebarTab('explorer')
setRightSidebarOpen(true)
actions.setRightSidebarTab('explorer')
actions.setRightSidebarOpen(true)
return
}
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f') {
e.preventDefault()
setRightSidebarTab('search')
setRightSidebarOpen(true)
actions.setRightSidebarTab('search')
actions.setRightSidebarOpen(true)
return
}
@ -509,24 +468,14 @@ function App(): React.JSX.Element {
return
}
e.preventDefault()
setRightSidebarTab('source-control')
setRightSidebarOpen(true)
actions.setRightSidebarTab('source-control')
actions.setRightSidebarOpen(true)
}
}
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [
activeView,
activeWorktreeId,
openModal,
closeModal,
repos,
toggleSidebar,
toggleRightSidebar,
setRightSidebarTab,
setRightSidebarOpen
])
}, [activeView, activeWorktreeId, actions, repos])
return (
<div className="flex flex-col h-screen w-screen overflow-hidden">
@ -549,7 +498,7 @@ function App(): React.JSX.Element {
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={toggleSidebar}
onClick={actions.toggleSidebar}
aria-label="Toggle sidebar"
>
<PanelLeft size={16} />
@ -658,7 +607,7 @@ function App(): React.JSX.Element {
<button
className="titlebar-agent-hovercard-hide"
onClick={() => {
void updateSettings({ showTitlebarAgentActivity: false })
void actions.updateSettings({ showTitlebarAgentActivity: false })
toast('Agent activity badge hidden', {
description: 'You can turn it back on in Settings → Appearance.',
duration: Infinity,
@ -703,7 +652,7 @@ function App(): React.JSX.Element {
<TooltipTrigger asChild>
<button
className="sidebar-toggle mr-2"
onClick={toggleRightSidebar}
onClick={actions.toggleRightSidebar}
aria-label="Toggle right sidebar"
>
<PanelRight size={16} />

View file

@ -1,6 +1,6 @@
/* eslint-disable max-lines */
import { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
import React, { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
import { createPortal } from 'react-dom'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { useAppStore } from '../store'
@ -30,7 +30,7 @@ import { reconcileTabOrder } from './tab-bar/reconcile-order'
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
export default function Terminal(): React.JSX.Element | null {
function Terminal(): React.JSX.Element | null {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeView = useAppStore((s) => s.activeView)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
@ -716,7 +716,12 @@ export default function Terminal(): React.JSX.Element | null {
// and destroys orphaned webview elements to prevent memory leaks.
const prevBrowserTabIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
let prevBrowserTabs = useAppStore.getState().browserTabsByWorktree
return useAppStore.subscribe((state) => {
if (state.browserTabsByWorktree === prevBrowserTabs) {
return
}
prevBrowserTabs = state.browserTabsByWorktree
const currentIds = new Set(
Object.values(state.browserTabsByWorktree)
.flat()
@ -1023,3 +1028,5 @@ export default function Terminal(): React.JSX.Element | null {
</div>
)
}
export default React.memo(Terminal)

View file

@ -56,7 +56,7 @@ type FileContent = {
type DiffContent = GitDiffResult
export default function EditorPanel({
function EditorPanelInner({
activeFileId: activeFileIdProp
}: {
activeFileId?: string | null
@ -761,3 +761,5 @@ export default function EditorPanel({
</div>
)
}
export default React.memo(EditorPanelInner)

View file

@ -23,7 +23,7 @@ import { useFileExplorerImport } from './useFileExplorerImport'
import { useFileExplorerTree } from './useFileExplorerTree'
import { useFileExplorerWatch } from './useFileExplorerWatch'
export default function FileExplorer(): React.JSX.Element {
function FileExplorerInner(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const expandedDirs = useAppStore((s) => s.expandedDirs)
@ -433,3 +433,5 @@ export default function FileExplorer(): React.JSX.Element {
</>
)
}
export default React.memo(FileExplorerInner)

View file

@ -96,7 +96,7 @@ const CONFLICT_KIND_LABELS: Record<GitConflictKind, string> = {
both_deleted: 'Both deleted'
}
export default function SourceControl(): React.JSX.Element {
function SourceControlInner(): React.JSX.Element {
const sourceControlRef = useRef<HTMLDivElement>(null)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
@ -857,6 +857,9 @@ export default function SourceControl(): React.JSX.Element {
)
}
const SourceControl = React.memo(SourceControlInner)
export default SourceControl
function CompareSummary({
summary,
onChangeBaseRef,

View file

@ -108,7 +108,7 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
}
]
export default function RightSidebar(): React.JSX.Element {
function RightSidebarInner(): React.JSX.Element {
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth)
const setRightSidebarWidth = useAppStore((s) => s.setRightSidebarWidth)
@ -246,6 +246,9 @@ export default function RightSidebar(): React.JSX.Element {
)
}
const RightSidebar = React.memo(RightSidebarInner)
export default RightSidebar
// ─── Status indicator dot color mapping ──────
const STATUS_DOT_COLOR: Record<CheckStatus, string> = {
success: 'bg-emerald-500',

View file

@ -18,7 +18,7 @@ import AddRepoDialog from './AddRepoDialog'
const MIN_WIDTH = 220
const MAX_WIDTH = 500
export default function Sidebar(): React.JSX.Element {
function Sidebar(): React.JSX.Element {
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
const setSidebarWidth = useAppStore((s) => s.setSidebarWidth)
@ -82,3 +82,5 @@ export default function Sidebar(): React.JSX.Element {
</TooltipProvider>
)
}
export default React.memo(Sidebar)

View file

@ -2,7 +2,7 @@
interaction menus, and compact-layout behavior together so the hover/click
states stay consistent across Claude and Codex. */
import { AlertTriangle, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
ContextMenu,
@ -409,7 +409,7 @@ function ProviderDetailsMenu({
// StatusBar
// ---------------------------------------------------------------------------
export function StatusBar(): React.JSX.Element | null {
function StatusBarInner(): React.JSX.Element | null {
const rateLimits = useAppStore((s) => s.rateLimits)
const refreshRateLimits = useAppStore((s) => s.refreshRateLimits)
const statusBarVisible = useAppStore((s) => s.statusBarVisible)
@ -541,3 +541,5 @@ export function StatusBar(): React.JSX.Element | null {
</ContextMenu>
)
}
export const StatusBar = React.memo(StatusBarInner)

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import {
DndContext,
closestCenter,
@ -70,7 +70,7 @@ type TabItem =
| { type: 'editor'; id: string; data: OpenFile & { tabId?: string } }
| { type: 'browser'; id: string; data: BrowserTabState }
export default function TabBar({
function TabBarInner({
tabs,
activeTabId,
worktreeId,
@ -356,3 +356,5 @@ export default function TabBar({
</div>
)
}
export default React.memo(TabBarInner)

View file

@ -87,6 +87,11 @@ function attachDividerDrag(
nextFlex = nextSize
}
// Why: fitAddon.fit() triggers a full xterm.js reflow which can take
// hundreds of ms with large scrollbacks. Gating behind rAF caps refit
// to once per paint frame instead of once per pointer event (~250Hz).
let refitRafId: number | null = null
const onPointerMove = (e: PointerEvent): void => {
if (!dragging || !prevEl || !nextEl) {
return
@ -113,9 +118,16 @@ function attachDividerDrag(
prevEl.style.flex = `${newPrev} 1 0%`
nextEl.style.flex = `${newNext} 1 0%`
// Refit terminals in affected panes
callbacks.refitPanesUnder(prevEl)
callbacks.refitPanesUnder(nextEl)
// Refit terminals in affected panes (throttled to one per animation frame)
if (refitRafId === null) {
const p = prevEl
const n = nextEl
refitRafId = requestAnimationFrame(() => {
refitRafId = null
callbacks.refitPanesUnder(p)
callbacks.refitPanesUnder(n)
})
}
}
const onPointerUp = (e: PointerEvent): void => {
@ -123,8 +135,19 @@ function attachDividerDrag(
return
}
dragging = false
if (refitRafId !== null) {
cancelAnimationFrame(refitRafId)
refitRafId = null
}
divider.releasePointerCapture(e.pointerId)
divider.classList.remove('is-dragging')
// Final refit at the exact drop position
if (prevEl) {
callbacks.refitPanesUnder(prevEl)
}
if (nextEl) {
callbacks.refitPanesUnder(nextEl)
}
prevEl = null
nextEl = null

View file

@ -85,6 +85,7 @@ export type GitHubSlice = {
initGitHubCache: () => Promise<void>
refreshAllGitHub: () => void
refreshGitHubForWorktree: (worktreeId: string) => void
refreshGitHubForWorktreeIfStale: (worktreeId: string) => void
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
@ -392,5 +393,45 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
if (worktree.linkedIssue) {
void get().fetchIssue(repo.path, worktree.linkedIssue)
}
},
// Why: worktree switches previously force-refreshed GitHub data on every
// click, bypassing the 5-min TTL. This variant only fetches when stale,
// avoiding unnecessary API calls and latency during rapid switching.
refreshGitHubForWorktreeIfStale: (worktreeId) => {
const state = get()
let worktree: Worktree | undefined
for (const worktrees of Object.values(state.worktreesByRepo)) {
worktree = worktrees.find((w) => w.id === worktreeId)
if (worktree) {
break
}
}
if (!worktree) {
return
}
const repo = state.repos.find((r) => r.id === worktree.repoId)
if (!repo) {
return
}
const now = Date.now()
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const prKey = `${repo.path}::${branch}`
const prEntry = state.prCache[prKey]
const prStale = !prEntry || now - prEntry.fetchedAt >= CACHE_TTL
if (!worktree.isBare && branch && prStale) {
void get().fetchPRForBranch(repo.path, branch, { force: true })
}
if (worktree.linkedIssue) {
const issueKey = `${repo.path}::${worktree.linkedIssue}`
const issueEntry = state.issueCache[issueKey]
if (!issueEntry || now - issueEntry.fetchedAt >= CACHE_TTL) {
void get().fetchIssue(repo.path, worktree.linkedIssue)
}
}
}
})

View file

@ -310,7 +310,8 @@ describe('setActiveWorktree', () => {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', sortOrder: 123, isUnread: false })]
},
refreshGitHubForWorktree: vi.fn()
refreshGitHubForWorktree: vi.fn(),
refreshGitHubForWorktreeIfStale: vi.fn()
})
store.getState().setActiveWorktree(worktreeId)
@ -569,7 +570,8 @@ describe('setActiveWorktree', () => {
activeFileIdByWorktree: { [wt]: fileId },
// User was on the terminal, not the editor
activeTabTypeByWorktree: { [wt]: 'terminal' },
refreshGitHubForWorktree: vi.fn()
refreshGitHubForWorktree: vi.fn(),
refreshGitHubForWorktreeIfStale: vi.fn()
})
store.getState().setActiveWorktree(wt)

View file

@ -475,10 +475,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
}
// Refresh GitHub data (PR + issue status) on every explicit worktree selection.
// Re-selecting the active worktree is a user-driven refresh path for stale PR state.
// Why: force-refreshing GitHub data on every switch burned API rate limit
// quota and added 200-800ms latency. Only refresh when cache is actually
// stale (>5 min old). Users can still force-refresh via the sidebar button.
if (worktreeId) {
get().refreshGitHubForWorktree(worktreeId)
get().refreshGitHubForWorktreeIfStale(worktreeId)
}
if (!worktreeId || !findWorktreeById(get().worktreesByRepo, worktreeId)) {