docs: remove stale design docs (#1425)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-05-04 23:05:05 -07:00 committed by GitHub
parent a59fb3dea7
commit 56b69f8d8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 0 additions and 9549 deletions

View File

@ -1,138 +0,0 @@
# Back/Forward support for the Tasks page
## Goal
Make the titlebar back/forward buttons and the `Cmd/Ctrl+Alt+←/→` shortcut
traverse Tasks visits in addition to worktree activations. Today both are
no-ops outside `activeView === 'terminal'` (see `App.tsx:616`, `App.tsx:900`).
## Current shape
- History lives in `src/renderer/src/store/slices/worktree-nav-history.ts`.
- Entries are `string[]` (worktree IDs), recorded from
`worktree-activation.ts:96` via `recordWorktreeVisit`.
- `goBackWorktree`/`goForwardWorktree` skip dead worktrees via
`findPrev/NextLiveWorktreeHistoryIndex`, gated by an `activator` ref and
`isNavigatingHistory` to prevent re-recording.
- UI (pre-change): titlebar buttons hidden unless
`activeView === 'terminal'`; shortcut ignored outside terminal view.
## Minimal implementation (downscoped)
1. **History entry type**`string | 'tasks'`.
- `findPrev/NextLiveWorktreeHistoryIndex` must short-circuit on `'tasks'`
before calling `findWorktreeById` (which takes a worktree id, not a
view tag) — Tasks entries are unconditionally live.
- Keep `recordWorktreeVisit(worktreeId: string)` signature to avoid churn
in `terminals-hydration.test.ts`. Add a sibling
`recordViewVisit(entry: 'tasks')` that shares the dedupe/truncate/cap
logic.
- Keep existing names (`worktreeNavHistory`, `goBackWorktree`,
`canGoBackWorktreeHistory`). They're now slight misnomers since
entries may be `'tasks'`, but renaming touches ~20 call sites for no
behavioral win. Add a one-line comment at the top of the slice
stating the entry type and the intentional keeping of the name, so
future readers don't assume it's worktree-only.
2. **Record Tasks visits** → call `recordViewVisit('tasks')` from
`openTaskPage` (`ui.ts:189`). No `isNavigatingHistory` guard needed:
back-to-Tasks routes through `setActiveView('tasks')` per step 3, which
never touches `openTaskPage`. The slice's existing adjacent-entry dedupe
covers any other re-entry. Note: `openTaskPage` is called with varying
`taskPageData` (e.g. `{ taskSource: 'github' }` vs `'linear'` from
`SidebarNav.tsx:88,102`); all collapse to a single `'tasks'` entry and
dedupe against the current entry, so toggling presets produces no extra
history entries. This is consistent with the out-of-scope "no per-entry
snapshotting" decision.
3. **Dispatch in goBack/goForward** → based on entry kind:
- worktreeId → existing `activator(worktreeId)` path. When current entry
is `'tasks'`, no extra view handling is needed:
`activateAndRevealWorktree` at `worktree-activation.ts:82-84` already
switches `activeView` back to `'terminal'`.
- `'tasks'``setActiveView('tasks')` (not `openTaskPage` — avoids
mutating `previousViewBeforeTasks` and the SWR prefetch).
4. **Fix Esc-close history desync.** `closeTaskPage` (`ui.ts:210`) currently
sets `activeView` to `previousViewBeforeTasks` without touching the
history index, so after `A → Tasks → Esc` the index still points at the
`'tasks'` entry and Back becomes a visual no-op (activator re-activates A)
while Forward re-opens Tasks. Fix: in `closeTaskPage`, if the current
history entry is `'tasks'`, move the index to the previous live entry
(same scan as `findPrevLiveWorktreeHistoryIndex`). Sub-case: if there is
no previous live entry (e.g. user's first action was open Tasks, then
Esc — history `['tasks']` at index 0), leave the index unchanged at 0.
Accept the minor cost (Back becomes a visual no-op until a real visit
records a new entry) rather than setting to -1, which would lose the
only forward target. Guard with `isNavigatingHistory` is unnecessary
here — `closeTaskPage` is never invoked from the history path.
5. **Unhide UI on Tasks** (allowlist, not denylist — `activeView` union is
`'terminal' | 'settings' | 'tasks'`, but an allowlist won't silently
include future views):
- Replace the `activeView !== 'terminal'` early-return at `App.tsx:616`
with `activeView !== 'terminal' && activeView !== 'tasks'`. Update the
adjacent comment (`App.tsx:613-615`) — it currently claims the
shortcut is a no-op outside terminal because the buttons are hidden;
replace with: back/forward traverse worktree + Tasks visits, so the
shortcut is active whenever the button cluster is (terminal or
Tasks); still suppressed elsewhere (Settings).
- Change the titlebar cluster guard at `App.tsx:900` to
`activeView === 'terminal' || activeView === 'tasks'`. Update the
adjacent comment (`App.tsx:896-899`) — drop the "terminal view only"
framing; explain the cluster is shown wherever the history shortcut
is live, and hidden in Settings to keep that view modal-ish.
6. **Tests**: extend `worktree-nav-history.test.ts` for mixed entries:
- `A → Tasks → B`, back lands on Tasks, back again on A.
- Tasks dedupe against current entry.
- Dead worktree between Tasks entries is skipped.
- `A → Tasks → closeTaskPage()`: index moves back to A; subsequent Back
is a no-op, Forward re-opens Tasks.
- `Tasks (only entry) → closeTaskPage()`: index stays at 0, Back is a
visual no-op.
## Explicitly out of scope
- **Settings in history.** Stays modal-ish; closes via
`previousViewBeforeSettings`.
- **Per-entry `taskPageData` snapshotting.** Back-to-Tasks opens Tasks with
default filters/source. Revisit if users complain.
- **Session persistence of history.** Keep in-memory only (it already is).
## Edge cases handled
- Worktree deletion mid-session → skipped by existing live-check; Tasks
entries always live.
- Dedupe semantics → same adjacency rule works for `'tasks'`.
- Activator failure on worktree → unchanged (`result !== false` gate). Tasks
dispatch can't fail.
- Editable-target guard at `App.tsx:600` still fires first → typing in the
Tasks search box + `Cmd+Alt+←` remains a no-op.
- `MAX_HISTORY = 50` → Tasks entries consume slots; worst-case effective
worktree depth halves to ~25. Acceptable.
## Known residual quirks (accepted)
1. **Prefetch loss on back-to-Tasks.** Routing through `setActiveView('tasks')`
skips the SWR prefetch at `ui.ts:201-208`; back-to-Tasks is ~300800ms
slower than a fresh open via the sidebar. Not a regression vs today
(back-to-Tasks isn't possible at all today).
2. **Titlebar layout shift.** Revealing the button cluster on Tasks changes
the titlebar — needs a visual check that nothing Tasks-specific collides.
## Files touched
- `src/renderer/src/store/slices/worktree-nav-history.ts` — entry type,
`recordViewVisit`, dispatch in `goBack/goForwardWorktree`.
- `src/renderer/src/store/slices/worktree-nav-history.test.ts` — new cases.
- `src/renderer/src/store/slices/ui.ts``openTaskPage` records via
`recordViewVisit`; `closeTaskPage` rewinds history index when the current
entry is `'tasks'`.
- `src/renderer/src/App.tsx` — widen the two `activeView === 'terminal'`
guards to include `'tasks'`, and update the adjacent "why" comments at
`App.tsx:613-615` and `App.tsx:896-899` to reflect the new invariant.
Estimated diff: ~30 lines of slice logic, 1 call site in `ui.ts`, 2 guards
in `App.tsx`, plus tests.

View File

@ -1,222 +0,0 @@
# Cmd+J empty-query ordering: use visit recency, not activity recency
## Problem
When Cmd+J opens with no query, the `sortedWorktrees` memo in
`WorktreeJumpPalette.tsx` orders the Worktrees section by
`Worktree.lastActivityAt` before the empty-query cap is applied. The cap is
conditional: with browser tabs present, Worktrees is capped at 5 so browser
rows stay visible above the fold (see the `__hint_worktree_cap__` branch in
the same file); with no browser tabs, the list is uncapped.
For worktrees with low background signal — notably SSH-backed worktrees —
`lastActivityAt` can remain old even when the user was just working there.
Those worktrees get pushed below the visible empty-query rows by local
worktrees that emitted incidental PTY/activity events. The user then has to
type a substring to surface the worktree they just visited, which defeats
the purpose of the empty-query switcher.
Reported symptom: an SSH worktree the user was working in minutes ago does
not appear in the visible Cmd+J empty-query list; typing any substring
surfaces it.
## Product model
Cmd+J with an empty query is a fast switcher. It should answer: "where am I
likely to jump next?"
That is different from both existing recency signals:
- `lastActivityAt` answers "where did work happen?" Right for activity-aware
surfaces, wrong for SSH or quiet worktrees where user focus is not
accompanied by local PTY/activity signals.
- `worktreeNavHistory` (see `recordWorktreeVisit` in the
`worktree-nav-history` slice) answers "what is the Back/Forward stack?"
That stack has index, forward-history, duplicate, and `'tasks'` semantics
that are useful for sequential navigation but unrelated to switcher
ranking.
The switcher needs its own persisted focus-recency signal. This doc uses
"focus recency" throughout.
## Proposal
Persist a per-worktree focus-recency timestamp and use it as the primary
ordering signal for Cmd+J's empty-query Worktrees section.
Store shape, added to `src/renderer/src/store/slices/worktrees.ts` (the
slice that already owns `activeWorktreeId` and is the natural home for
per-worktree UI recency):
```ts
lastVisitedAtByWorktreeId: Record<string, number>
markWorktreeVisited: (worktreeId: string, visitedAt?: number) => void
```
`markWorktreeVisited` must be monotonic: if the supplied (or current)
timestamp is not strictly greater than the stored value, it is a no-op. This
matters because CLI-driven and IPC-driven activations can race, and we do
not want an older timestamp to regress recency.
### Stamp site
Stamp from `activateAndRevealWorktree` (`src/renderer/src/lib/worktree-activation.ts`),
**immediately after the `state.setActiveWorktree(worktreeId)` call at
line 91**, synchronously, before any of the later view/terminal/reveal
steps. This guarantees the stamp lands even if a subsequent async step
fails, since the user already perceives the switch as successful once
`activeWorktreeId` flips.
Do this *in addition to*, not gated on, the existing
`state.recordWorktreeVisit(worktreeId)` call; the nav-history slice has
different semantics (see "Why not use `worktreeNavHistory`").
Do **not** stamp from `setActiveWorktree` directly. That raw setter is
invoked by hydration, session restore, and test setup — stamping there
would reset focus recency for the restored workspace on every app launch.
### Activation-path audit
Every user-initiated worktree switch must route through
`activateAndRevealWorktree`. Before landing, audit direct callers of
`setActiveWorktree` and classify each:
- **User switches** (sidebar clicks, Cmd+J selections, CLI activations,
status-bar/session jumps, deep links) — must go through activation.
- **Non-user transitions** (store hydration, session restore, tests) — must
NOT stamp.
The audit output belongs in the PR description. Do not stamp
`Worktree.lastActivityAt`.
### Ordering rule (empty query only)
1. Start from visible worktrees: skip `isArchived`, and keep honoring
`hideDefaultBranchWorkspace` via `isDefaultBranchWorkspace`.
2. Build a separate `switchableWorktrees` list that excludes the currently
active worktree. Keep the full visible list for loading/empty-state/count
logic so the palette never claims there are no worktrees just because the
only visible worktree is current.
3. Sort `switchableWorktrees` by:
- `lastVisitedAtByWorktreeId[id]` descending, when present.
- `lastActivityAt` descending as the fallback for never-visited or
pre-migration worktrees.
- `displayName.localeCompare` as the final stable tie-breaker.
4. Preserve the conditional cap: cap Worktrees at 5 only when browser rows
exist (the existing `__hint_worktree_cap__` logic); otherwise leave the
Worktrees section uncapped.
5. Preserve the existing "Type to see all N worktrees" hint, but compute `N`
from switchable rows. Empty-state copy is based on the full visible list.
Typing any non-empty query still routes through `sortWorktreesSmart`. No
change to the sidebar, `sortEpoch`, or `lastActivityAt` semantics.
### Current worktree handling
Cmd+J is a switch surface. Exclude the current worktree from empty-query
rows in v1. Keep two separate lists so empty-state logic is not affected:
- `visibleWorktreesForState`: includes the current worktree and drives
"loading", "has any worktrees", and empty-state decisions.
- `switchableWorktreesForRows`: excludes the current worktree and drives the
actual empty-query Worktrees rows.
A "Current" row variant is out of scope.
## Why not use `worktreeNavHistory`
`worktreeNavHistory` records activations but is the wrong abstraction for
Cmd+J ordering.
- Back/Forward history is an indexed stack. Cmd+J is an unordered switcher
ranked by likely target.
- History contains `'tasks'` entries; Cmd+J rows should not need to
understand task-page sentinels.
- Back/Forward navigation can leave forward entries in the stack. A raw
newest-to-oldest walk either incorrectly includes future entries or needs
custom interpretation of `worktreeNavHistoryIndex`.
- History dedupe rules are stack-oriented. A per-worktree timestamp is
simpler and directly models the switcher need.
Keep `worktreeNavHistory` for Back/Forward.
## Migration and persistence
Persist `lastVisitedAtByWorktreeId` via the same zustand persist path that
survives app restart.
- **Downgrade:** older builds will drop the unknown key on rehydrate
(zustand `partialize` strips anything the slice doesn't declare). No
custom migration needed; record this explicitly in the PR so nobody
invents one.
- **Pruning:** drop entries whose worktree IDs are no longer present —
**after worktree hydration completes**, not on raw rehydrate. Repos load
async; pruning too early would nuke timestamps for worktrees whose repo
hasn't yet hydrated.
- **Seeding active on restore:** if, after hydration, the active worktree
has no stored timestamp, seed it with the current time from the
hydration-complete handler — not by calling `markWorktreeVisited` from
`setActiveWorktree`. The two paths have intentionally different
semantics (seeding is a migration fixup; stamping is focus recency).
- Never-visited worktrees stay without timestamps and fall back to
`lastActivityAt`.
The map is bounded by live worktree IDs, so no history cap is needed.
## Non-goals
- Changing sidebar sort order.
- Changing `lastActivityAt` semantics or when it is stamped.
- Changing the typed-query path; smart-sort remains authoritative.
- Making Cmd+J mirror Back/Forward history.
- Persisting Cmd+J UI state such as query, scroll position, or selection.
- Adding a "Current" row variant.
## Implementation sketch
- Add `lastVisitedAtByWorktreeId` and `markWorktreeVisited` to the
`worktrees` slice; persist via the existing persist config.
- In `activateAndRevealWorktree`, call `markWorktreeVisited(worktreeId)`
immediately after `state.setActiveWorktree(worktreeId)` (line 91),
synchronously. Focus recency, not work activity.
- Update the `sortedWorktrees` memo in `WorktreeJumpPalette.tsx` so the
empty-query branch uses subscribed store inputs:
`lastVisitedAtByWorktreeId`, `activeWorktreeId`, visible worktrees, and
existing palette filters. Avoid reading `useAppStore.getState()` inside a
memo as the only source of ordering data; that can produce stale UI.
- Keep the typed-query branch on `sortWorktreesSmart`.
- Keep browser-tab search ordering unchanged unless a separate browser
visit-recency issue is discovered.
- Extract a pure function `orderEmptyQueryWorktrees` so ordering,
current-worktree exclusion, and fallback behavior are testable without
mounting the whole palette.
## Tests
Add focused tests for the ordering helper:
- Recently visited SSH/quiet worktree ranks above a locally active worktree
with newer `lastActivityAt`.
- Never-visited worktrees fall back to `lastActivityAt`.
- Current worktree is excluded from empty-query rows but still counted for
empty-state logic.
- Worktrees cap remains conditional on browser rows.
- `hideDefaultBranchWorkspace` and `isArchived` still filter rows.
- Non-empty query still uses `sortWorktreesSmart` order.
- Hydration seeds the active worktree's timestamp when missing.
- `markWorktreeVisited` is monotonic: an older timestamp does not regress
the stored value.
Do not put these tests in `worktree-palette-search.test.ts` unless the pure
search function itself changes. That file verifies matching behavior and
input order preservation, not empty-query ranking.
## Risks
- **Activation paths that bypass `activateAndRevealWorktree`.** Any
user-visible switch that calls `setActiveWorktree` directly will skip the
stamp. Mitigation: the activation-path audit above.
- **False empty states after current-worktree exclusion.** Mitigation:
separate `visibleWorktreesForState` and `switchableWorktreesForRows`.
- **Pruning before hydration.** Mitigation: prune in the
hydration-complete handler, not on raw rehydrate.

View File

@ -1,480 +0,0 @@
# Design Document: Scoped Cmd+J Jump Palette for Worktrees and Browser Tabs
**Status:** Draft
**Date:** 2026-04-15
## 1. Summary
Extend Orca's existing `Cmd+J` / `Ctrl+Shift+J` worktree palette into a single app-wide jump surface with explicit scopes. The first release keeps the palette centered on two jobs:
- jump to a worktree
- jump to an already-open browser page across any worktree
The palette opens into a lightweight scope switcher with three modes:
- `All`
- `Worktrees`
- `Browser Tabs`
Users can press `Tab` / `Shift+Tab` to cycle scopes without leaving the keyboard. The default contract on open remains `Worktrees`, not `All`, so existing users still land in the familiar worktree-first flow before opting into broader search. Search results stay intentionally narrow: browser-only discovery is supported because the user explicitly needs to find already-open pages across worktrees, but the palette does not become a generic "everything" bucket in v1.
## 1.1 Phase 0.5 Direction Lock
Phase 0.5 locked three product decisions for v1:
- `Cmd+J` still opens into `Worktrees` by default.
- Browser discovery indexes live open `BrowserPage`s, not just browser workspace containers.
- Browser ordering uses a simple context-first heuristic instead of true last-focused recency state.
Why these decisions were chosen:
- `Cmd+J` already has a strong worktree-switching contract in Orca. Making `All` the default would silently turn a familiar command into a mixed-ranking surface and force existing users to re-learn the first screen they see.
- The remembered object in the browser case is the page itself. Searching only browser workspace shells would miss the page-level titles and URLs users actually recall when they say "I know I already have this open somewhere."
- True global browser recency would require new focus-tracking state whose only initial consumer is palette ranking. A simple heuristic is easier to ship, easier to explain, and good enough for the first version of this discovery workflow.
## 2. Problem
Orca already supports multiple concurrent worktrees and persistent in-app browser tabs. Users can jump between worktrees with the existing `Cmd+J` palette, but they cannot quickly answer a more specific question:
> "I know I already have this page open somewhere. Which worktree is it in, and how do I get back to it?"
The current worktree palette is container-first. That works when the user remembers the worktree identity, but it breaks down when the remembered thing is a page title, host, or URL path.
This is a discovery problem, not a recency problem. A cycle UI is poor at it because:
- the user often does not know which worktree owns the target tab
- multiple browser tabs can share similar titles
- cycling scales badly once many worktrees have open tabs
## 3. Goals
- Preserve `Cmd+J` / `Ctrl+Shift+J` as Orca's single global jump entry point.
- Let users search open browser pages across all worktrees.
- Keep worktree search fast and familiar for existing users.
- Make scope switching explicit and keyboard-first.
- Avoid overcommitting Orca to a generic "search all open items" model before the product is ready to support terminals, editors, and commands consistently.
- Preserve the existing expectation that `Cmd+J` opens as a worktree-first jump flow.
## 4. Non-Goals
- Searching terminal scrollback or editor contents.
- Adding a persistent sidebar or browser-tab manager panel.
- Replacing local `Ctrl+Tab` behavior or introducing a new cycle UI.
- Expanding `Cmd+J` into files, terminals, editor tabs, or commands in this change.
## 5. UX
### 5.1 Entry Point
Keep the existing shortcut:
- macOS: `Cmd+J`
- Windows/Linux: `Ctrl+Shift+J`
This shortcut already means "global jump" inside Orca and is already forwarded correctly even when an embedded browser guest owns focus. Reusing it preserves muscle memory and avoids proliferating navigation surfaces.
### 5.2 Scope Model
The palette header contains three explicit scope chips:
- `All`
- `Worktrees`
- `Browser Tabs`
Keyboard behavior:
- `Tab`: next scope
- `Shift+Tab`: previous scope
- `Up` / `Down`: move selection within results
- `Enter`: activate selected result
- `Esc`: close palette
Default scope on open: `Worktrees`.
Why this shape:
- one entry point is easier to remember than separate dialogs
- explicit scopes prevent a mixed list from becoming noisy
- `Tab` matches the mental model of "move across modes" without conflicting with list navigation
- opening in `Worktrees` preserves today's default behavior and makes `All` an explicit expansion, not a silent contract change
### 5.3 Scope Semantics
#### `All`
Merged result list of:
- open browser pages across all worktrees
- worktrees across all repos
Ranking rules:
- strong browser title matches rank above weak worktree metadata matches
- host/url matches get boosted when the query resembles a domain, URL, or path fragment
- exact worktree name matches still beat weak browser matches
- current worktree/current browser page receive a small context boost when otherwise tied
- browser results use the same heuristic ordering as `Browser Tabs`; v1 does not add hidden last-focused browser recency state just for this merged scope
`All` is meant to feel smart, not exhaustive.
#### `Worktrees`
Equivalent to today's worktree palette behavior:
- same global search semantics for worktree metadata
- same recent-first ordering for the default empty-query state
- same selection and activation behavior
#### `Browser Tabs`
Shows only open browser pages across all worktrees. The user-facing scope label stays `Browser Tabs`, but each row maps to a live `BrowserPage`, not a browser workspace shell.
Empty query ordering:
1. current browser page, if any
2. other open browser pages in the current worktree
3. browser pages in other worktrees, grouped by the existing worktree ordering and then sorted by title, falling back to URL
This mode is the direct answer to "just show me all open browsers."
Why this ordering:
- it pulls the user's current context to the top without inventing new global recency state
- it stays stable enough that users can learn where results tend to land
- it keeps the implementation honest about what Orca already knows today versus what would require new focus-history plumbing
### 5.4 Result Rows
#### Browser tab row
Primary text:
- current page title, falling back to formatted URL when the title is blank or useless
Secondary text:
- host + trimmed path
Context chips on the right:
- repo name
- worktree display name
Optional badges:
- `Current Tab`
- `Current Worktree`
Why this is required:
- browser tab titles are often duplicated (`localhost`, `Settings`, `Dashboard`)
- users need immediate disambiguation without opening the result
- worktree context is the whole point of the feature
- each row represents the actual page the user remembers, while worktree and repo chips explain where that page lives
#### Worktree row
Keep the existing row structure:
- worktree display name
- branch
- optional supporting text for comment / PR / issue
- repo badge
This avoids making existing users relearn the palette.
### 5.5 Empty States
`All`
- If no worktrees and no browser tabs exist: `No worktrees or open browser tabs`
- If query yields no results: `No matches in worktrees or browser tabs`
`Worktrees`
- Preserve existing copy
`Browser Tabs`
- No open browser tabs: `No open browser tabs`
- No query matches: `No browser tabs match your search`
### 5.6 Activation Behavior
Selecting a worktree result:
- preserve current `activateAndRevealWorktree(worktreeId)` behavior
Selecting a browser result:
1. activate and reveal the owning worktree
2. focus the target browser workspace tab
3. select the target `BrowserPage` inside that workspace
4. set `activeTabType` to `browser`
5. close the palette
6. restore focus into the browser surface, not the terminal/editor fallback
Why this ordering matters:
- browser pages are subordinate to worktree activation in Orca's model
- worktree-first activation restores the right workspace state and sidebar visibility
- selecting a browser result should feel like "take me there directly," not "switch worktree and make me pick again"
## 6. Data Model and Search Inputs
### 6.1 Worktree Results
Use the existing search surface:
- `displayName`
- branch
- repo name
- comment
- linked PR number/title
- linked issue number/title
### 6.2 Browser Tab Results
Search only currently open browser pages, not history and not just browser workspace shells.
For each browser result, index:
- page title
- page URL
- formatted host/path
- owning browser workspace label, if available
- owning worktree display name
- owning repo display name
Each open `BrowserPage` contributes its own result row. Browser workspaces still matter for ownership and activation, but they are context, not the searchable unit.
This is intentionally limited to live open pages because Orca still is not an app-wide browsing history system. The goal is to help users recover something they already have open, not to introduce a second browsing history feature through the palette.
### 6.3 Why Not Terminal Tabs Yet
Terminal tabs are deliberately out of scope for text-first search in this change.
Reasons:
- terminal tab titles are less stable and less descriptive than browser titles
- the meaningful part of a terminal session often lives in scrollback, not tab metadata
- adding terminal tabs only because browser tabs are added would create a low-signal mixed palette
This design keeps the palette honest: it supports browser-page search because that metadata makes the target genuinely searchable. If Orca later wants an "all open items" palette, that should be a deliberate follow-up with result quality standards for each item type.
## 7. Architecture
### 7.1 Existing Pieces Reused
- `WorktreeJumpPalette.tsx` remains the base surface and interaction shell.
- Existing worktree search logic remains intact for the `Worktrees` scope.
- Browser search input comes from the live open `BrowserPage`s already held by renderer browser state.
- Browser activation continues to use the existing browser-workspace activation pathway after worktree activation, then selects the matching page inside that workspace.
- Main-process shortcut forwarding remains unchanged.
### 7.2 New Search Model
Add a palette view-model layer that produces typed results:
```ts
type JumpPaletteScope = 'all' | 'worktrees' | 'browser-tabs'
type JumpPaletteResult =
| { type: 'worktree'; worktreeId: string; score: number; ... }
| {
type: 'browser-page'
worktreeId: string
browserTabId: string
browserPageId: string
score: number
...
}
```
The existing worktree search helper remains responsible for worktree scoring. A new browser-page search helper handles browser result scoring and formatting. The palette shell merges and sorts results only in `All`.
Why split the search helpers:
- worktree matching logic is already non-trivial and should not be regressed
- browser result ranking has different signals than worktree ranking
- typed results keep selection and rendering explicit instead of relying on ad hoc ID prefixes
- page-level browser hits need both workspace ownership and page identity for activation
### 7.3 Focus and Close Semantics
The existing palette already manages focus restoration carefully. That logic should be extended, not replaced.
New rule:
- if the selected result is a browser page, the post-close focus path targets the active browser surface
- otherwise preserve today's terminal/editor focus restoration behavior
### 7.4 System Context
```text
+------------------+ +-----------------------+
| Main Process | | Renderer Store |
| shortcut forward | -----> | activeModal |
| Cmd+J / Ctrl+... | | worktreesByRepo |
+------------------+ | browser state |
| activeWorktreeId |
+-----------+-----------+
|
v
+------------------------+
| Cmd+J Jump Palette |
| scopes + search + list |
+-----+-------------+----+
| |
worktree hit | | browser-page hit
v v
+------------------+ +---------------------+
| activate/reveal | | activate/reveal |
| target worktree | | target worktree |
+------------------+ +----------+----------+
|
v
+----------------------+
| activate browser |
| workspace + page |
| focus browser pane |
+----------------------+
```
### 7.5 Data Flows
#### Happy path: browser-page search and jump
```text
Cmd+J -> palette opens in Worktrees -> user switches to Browser Tabs ->
query matches browser page ->
user presses Enter -> activateAndRevealWorktree(worktreeId) ->
activate target browser workspace and page -> palette closes -> browser surface focused
```
#### Nil path: user opens palette with no browser pages
```text
Cmd+J -> palette opens -> Browser Tabs scope selected ->
search model sees zero browser pages -> empty state shown -> no side effects
```
#### Empty path: query yields no browser or worktree matches
```text
query typed -> search returns [] -> scope-specific empty state rendered ->
selection cleared or pinned to no result -> Enter does nothing
```
#### Upstream error path: selected browser page disappears before activation
```text
user selects browser result -> store lookup fails because page/worktree closed ->
show toast error -> keep palette open if possible, otherwise close safely without switching
```
## 8. Alternatives Considered
### 8.1 Dedicated browser-tab-only dialog
Pros:
- clearer mental model for the browser-specific job
- no mixed-result ranking complexity
Cons:
- adds another shortcut and another navigation surface
- weakens `Cmd+J` as the single place to jump around Orca
Decision: rejected for now. The scoped palette gives the same utility with less surface area.
### 8.2 Browser tabs only inside `Cmd+J`, no scopes
Pros:
- least new UI chrome
Cons:
- mixed results become harder to reason about
- users cannot quickly answer "just show me browser tabs"
Decision: rejected. Explicit scopes are worth the small extra header chrome.
### 8.3 Expand immediately to terminals, files, and commands
Pros:
- one "go to anything" story
Cons:
- scope explosion
- result quality is uneven across item types
- much higher design and implementation complexity
Decision: rejected for v1. Start with the two jobs the user clearly asked for.
### 8.4 Make `All` the default scope
Pros:
- makes browser discovery visible immediately
- creates a more obviously "global" first impression
Cons:
- breaks the current worktree-first contract of `Cmd+J`
- makes the first screen depend on mixed ranking logic instead of today's predictable worktree list
Decision: rejected for v1. `All` remains available, but opening in `Worktrees` preserves muscle memory and keeps the expansion explicit.
### 8.5 Search browser workspaces instead of live pages
Pros:
- simpler indexing model
- reuses the existing browser workspace abstraction directly
Cons:
- misses the page titles and URLs users actually remember
- treats the container as the search target even when the user wants a specific page inside it
Decision: rejected. The palette should index the page the user is trying to recover, then use workspace and worktree context to explain where it lives.
### 8.6 Add true browser recency tracking for v1 ranking
Pros:
- could produce sharper empty-query ordering over time
Cons:
- requires new state and focus bookkeeping for a thin initial payoff
- introduces ranking behavior that is harder to explain and debug
Decision: rejected for v1. Start with a deterministic context-first heuristic and revisit true recency only if usage shows the heuristic is insufficient.
## 9. Rollout
### Phase 1
- Add scoped header to the existing `Cmd+J` palette
- Keep `Worktrees` as the default scope on open
- Preserve worktree-only behavior under the `Worktrees` scope
- Add browser-page search and activation
- Search live open `BrowserPage`s rather than browser workspace shells
- Add `All` merged ranking
### Phase 2 (optional follow-up)
- Evaluate whether users need additional scopes such as editor tabs or commands
- Only add a new scope if it has a clear, high-signal search model
## 10. Open Questions
- Whether a later iteration should remember a last-used non-default scope without changing the default-open `Worktrees` contract
- Whether browser-tab results should expose close actions from the palette in a later pass
- Whether `All` should group results visually by type or keep one flat ranked list

View File

@ -1,451 +0,0 @@
# Codex Account Switching Design
**Status:** Draft
**Date:** 2026-04-17
## Summary
Orca's current Codex account switcher swaps the entire `CODEX_HOME` for each managed account. That isolates authentication, but it also unintentionally forks config, permissions, history, sessions, memories, skills, and other local Codex state. The result is that account switching feels like switching between separate Codex installs instead of switching which account powers the same Codex environment.
The recommended design is:
- Keep `~/.codex` as the shared runtime `CODEX_HOME` for all Codex user state.
- Store only `auth.json` per managed account in Orca-owned storage.
- Introduce a dedicated main-process runtime-home owner that materializes the selected account's `auth.json` into the shared runtime home before any Codex launch, login, or rate-limit fetch.
- Restart live Codex panes after switch; newly launched panes use the new account while preserving the shared Codex state.
This matches the intended product behavior: account switching is for authentication and usage limits, not for creating separate Codex worlds. It also matches how manual Codex account switching already behaves outside Orca: logging out and back in mutates the same `~/.codex` state the user sees in terminal Codex.
## Motivation
The current managed-account design causes user-visible problems:
- `config.toml` diverges per account, so permissions and sandbox defaults reset unexpectedly.
- `history.jsonl` and `sessions/` are scoped per managed home, so chat history appears to disappear after account switches.
- `memories`, `skills`, `rules`, and likely sqlite-backed local state drift per account.
- Live sessions require restart because the active terminal process keeps using the old `CODEX_HOME`.
We already patched the first symptom by syncing `config.toml` into managed homes. That is a tactical fix, not the right long-term model. The full-home-per-account design still leaves history and session continuity split across accounts.
The deeper issue is ownership. Orca currently has no single component that owns Codex runtime state preparation. `CodexAccountService`, `pty.ts`, rate-limit fetchers, and usage scanning each participate in path or environment decisions. The long-term fix must therefore be a runtime-home ownership refactor, not just a storage-layout tweak.
## Current State
### Orca behavior today
- Managed accounts are created under `app.getPath('userData')/codex-accounts/<id>/home`.
- Orca selects an account by updating `settings.activeCodexManagedAccountId`.
- New Codex PTYs inherit the selected managed home's path as `CODEX_HOME`.
- Codex rate-limit fetches also use that selected managed home.
Relevant code:
- [src/main/codex-accounts/service.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/codex-accounts/service.ts)
- [src/main/ipc/pty.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/ipc/pty.ts)
- [src/main/codex-usage/scanner.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/codex-usage/scanner.ts)
### Codex state observed on disk
On a typical install, `CODEX_HOME` contains at least:
- `auth.json`
- `config.toml`
- `history.jsonl`
- `sessions/`
- `memories/`
- `skills/`
- `rules/`
- `shell_snapshots/`
- `models_cache.json`
- `logs_2.sqlite`
- `state_5.sqlite`
- `installation_id`
- `version.json`
Because Orca currently swaps the whole home, all of that becomes account-scoped.
## Goals
- Make Codex account switching feel like swapping credentials, not swapping environments.
- Preserve one continuous Codex history and session store across accounts.
- Keep permissions, sandbox defaults, MCP config, memories, and other user state stable across account changes.
- Preserve Orca's existing account-switch UX: one selected active account at a time, with restart prompts for live Codex panes.
- Keep the solution cross-platform across macOS, Linux, and Windows.
## Non-Goals
- Supporting simultaneous live Codex sessions under different accounts in the same Orca instance.
- Changing Codex upstream behavior or requiring first-class multi-account support from Codex.
- Building a replication system that continuously merges multiple independent `CODEX_HOME` trees.
## Constraints
### Codex does not currently expose a separate auth path
From local CLI inspection, Codex supports:
- `CODEX_HOME`
- config overrides via `-c key=value`
It does not currently expose a first-class "shared config/home plus separate auth profile" interface. There is an upstream feature request for auth profiles, which suggests Orca cannot rely on such a feature today.
### Orca is cross-platform
The design must work on macOS, Linux, and Windows. That rules out relying on symlink-heavy designs as the primary solution:
- Windows symlink/junction behavior is more fragile.
- Atomic copy + rename is simpler and more portable.
- Node path utilities should be used everywhere.
### Orca's current mental model is single selected account
The current switcher already assumes one active account at a time and uses restart prompts for live Codex panes. The recommended design leans into that model rather than trying to support concurrent mixed-account sessions.
## Options Considered
### Option 1: Keep full per-account homes and sync everything
Each managed account keeps its own full `CODEX_HOME`, and Orca syncs:
- `config.toml`
- `history.jsonl`
- `sessions/`
- sqlite state
- memories, skills, rules
**Pros**
- Minimal conceptual change from the current design.
- Per-account auth isolation stays simple.
**Cons**
- Orca becomes a replication system for Codex state.
- `history.jsonl` is mergeable, but `sessions/` and sqlite-backed files are much harder to reconcile safely.
- Concurrent activity can easily cause stale-copy or overwrite bugs.
- More code, more edge cases, weaker guarantees.
**Verdict**
Not recommended. This is the highest-complexity path for the weakest product result.
### Option 2: Shared runtime home, per-account `auth.json`
Keep a single shared runtime `CODEX_HOME` and store one `auth.json` per managed account. On switch, Orca copies the selected account's `auth.json` into the shared runtime home before launching or restarting Codex sessions.
**Pros**
- Cleanest mapping to the product intent.
- `config.toml`, history, sessions, memories, skills, rules, and local state are naturally shared.
- No state replication logic.
- Cross-platform implementation is straightforward with normal file copy and rename.
**Cons**
- Does not support simultaneous different-account live sessions in the same runtime home.
- Existing live sessions still likely need restart because Codex may read auth only at startup.
**Verdict**
Recommended.
### Option 3: Wait for upstream Codex auth profiles
If Codex eventually supports a true auth-profile model, Orca could delegate account isolation to Codex itself.
**Pros**
- Best long-term upstream integration.
- Less Orca-specific state management.
**Cons**
- Not available today.
- Does not solve Orca's user-facing problems now.
**Verdict**
Good future migration target, not a current solution.
## Recommended Design
### High-level model
Introduce two separate concepts:
1. **Shared runtime home**
The single `CODEX_HOME` used for all Codex launches inside Orca. For this design, the canonical shared runtime home is `~/.codex`.
2. **Per-account auth store**
Orca-managed storage that keeps one `auth.json` per account.
3. **Codex runtime-home owner**
A dedicated main-process component that prepares active Codex runtime state before any Codex subprocess, rate-limit fetch, or login flow touches it.
At runtime:
- Orca picks the selected managed account.
- The runtime-home owner copies that account's `auth.json` into `~/.codex/auth.json`.
- All Codex entry points consume the runtime-home owner's resolved home path instead of reasoning about Codex paths independently.
- Orca launches Codex with `CODEX_HOME` pointing to `~/.codex`.
### Shared vs per-account state
**Shared runtime home**
- `config.toml`
- `history.jsonl`
- `sessions/`
- `memories/`
- `skills/`
- `rules/`
- `shell_snapshots/`
- `models_cache.json`
- `logs_2.sqlite`
- `state_5.sqlite`
- `installation_id`
- `version.json`
- transient caches and temp dirs, unless we later discover they must be treated specially
**Per-account storage**
- `auth.json`
- Orca account metadata already stored in Orca settings
### Why this is the right split
`auth.json` is the only file we explicitly know needs to vary by account. The rest of the files represent user environment, session continuity, and local Codex behavior. If those are split by account, the switcher does not feel seamless.
## Detailed Design
### Storage layout
Recommended paths:
- Shared runtime home:
- `~/.codex`
- Per-account auth store:
- `app.getPath('userData')/codex-accounts/<id>/home/auth.json`
The important part is the separation of concerns, not the exact path choice.
### Ownership and API
The design should introduce a dedicated main-process owner for runtime-home preparation. A representative API shape:
```ts
type PreparedCodexRuntime = {
homePath: string
activeAccountId: string | null
}
interface CodexRuntimeHomeService {
prepareForAccountSwitch(accountId: string | null): PreparedCodexRuntime
prepareForCodexLaunch(): PreparedCodexRuntime
prepareForRateLimitFetch(): PreparedCodexRuntime
prepareForLogin(accountId: string): { loginHomePath: string }
}
```
Why: today path/runtime ownership is fragmented across `CodexAccountService`, PTY spawn env injection, rate-limit fetches, and usage scanning. A single owner prevents those code paths from drifting again.
### Serialization contract
Because `~/.codex/auth.json` is shared mutable state, the runtime-home owner must be the only component allowed to mutate active Codex auth. It must serialize these operations behind one coordination primitive:
- `prepareForAccountSwitch`
- `prepareForCodexLaunch`
- `prepareForRateLimitFetch`
- `prepareForLogin`
- any future reauth or logout helpers
Required contract:
- account switch auth materialization is exclusive
- launch and rate-limit preparation must either observe the auth state from before the switch or the fully committed auth state from after the switch
- they must never observe an in-progress partial write
- login preparation must not mutate the active runtime auth in place
Why: without this contract, PTY launch, quota fetch, and auth swap can still race and intermittently bind work to the wrong account.
### Account switch flow
1. User selects a managed account.
2. Orca validates that account's stored `auth.json`.
3. The runtime-home owner writes the selected `auth.json` into `~/.codex/auth.json`.
4. Orca refreshes Codex rate-limit state using the same prepared runtime home.
5. Orca prompts restart for live Codex panes, marks them stale until restarted, and blocks further Codex execution from those panes.
6. New or restarted Codex panes launch with:
- `CODEX_HOME=~/.codex`
- shared config/history/session state
- selected account auth
Interaction states that must be explicit in product/UI copy:
- **Switch in progress**: selection disabled while auth materialization and rate-limit refresh run.
- **Switch complete, restart required**: existing live Codex panes are stale, must show a restart affordance, and must not be allowed to submit further Codex work until restarted.
- **Switch failed**: active account remains unchanged and stale restart notices are not applied.
- **Switch to system default**: Orca clears managed-account selection and restores the system-default auth snapshot into `~/.codex`.
### System default source of truth
This design treats “System default” as a first-class auth source, not as “whatever happens to be left in `~/.codex/auth.json`.”
Rules:
- On first startup of the new architecture, before any managed-account switch mutates `~/.codex/auth.json`, Orca captures a `system-default` auth snapshot from the current `~/.codex/auth.json` when present.
- That snapshot is stored in Orca-owned storage separately from managed account auth blobs.
- Switching to “System default” restores `~/.codex/auth.json` from that stored snapshot.
- If the user changes external Codex auth outside Orca and wants Orcas “System default” target to follow it, Orca should expose an explicit refresh/import action or perform refresh only at startup before any managed account takes ownership in the current app session.
Why: without a defined snapshot-and-restore model, switching back to “System default” is nondeterministic and can leave the last managed account active or overwrite the users expected external Codex auth.
### New account add flow
1. Orca prepares a temporary login home that inherits the current shared config baseline but does not dirty the active runtime state on failure.
2. Orca runs `codex login` against that temporary login home.
3. Orca captures the resulting `auth.json`.
4. Orca stores only that `auth.json` under the managed account's storage.
5. Orca does not change the active runtime home until the user selects that account or explicitly makes it active on completion.
Why: a failed or aborted login must not poison the currently active `~/.codex` runtime state.
### Legacy managed-home migration
Migration of existing managed-home history and sessions is required, not optional.
Rules:
- On first startup after the new architecture lands, Orca scans legacy managed homes for:
- `history.jsonl`
- `sessions/`
- Orca imports legacy history into `~/.codex/history.jsonl` using append/merge semantics that avoid dropping existing shared-home history.
- Orca imports legacy sessions into `~/.codex/sessions/` with an explicit collision policy:
- import non-conflicting legacy session files directly
- for conflicting session files, merge turns when Orca can prove the files represent the same logical session with append-only divergence
- if Orca cannot safely merge a conflicting session file, preserve both copies under deterministic names and emit a diagnostic record rather than silently dropping either side
- Orca records migration completion so the import does not repeat on every startup.
Why: shared history/session continuity is a core goal of the design. Leaving legacy managed-home data unresolved would make the upgrade look like history loss for users who previously used managed accounts.
### Live session behavior
The safe assumption is:
- switching accounts affects new Codex launches
- existing live Codex sessions should still restart
- stale live Codex panes are blocked from further execution until restart completes
If future validation shows Codex hot-reloads `auth.json`, Orca can relax this. The architecture should not rely on hot-reload behavior today.
### Startup and recovery behavior
The design must explicitly cover startup and error handling:
- If `~/.codex` exists but has no `history.jsonl` or `sessions/`, Orca should launch cleanly and let Codex create them lazily.
- If the selected managed account's stored `auth.json` is missing or corrupt, Orca should:
- log a recoverable warning,
- fall back to system-default semantics,
- clear or mark invalid the selected managed account,
- avoid leaving rate-limit UI bound to the wrong identity.
- If a rate-limit refresh fails after account switch, Orca should keep the account switch result but show quota fetch failure separately rather than rolling back auth materialization implicitly.
## Why `~/.codex` Is The Shared Home
This document explicitly chooses `~/.codex` as the canonical shared runtime home.
Reasons:
- It matches the user's existing Codex mental model inside and outside Orca.
- It avoids split-brain between Orca Codex usage and terminal Codex usage.
- It matches what manual account switching already does today: logout/login mutates the same shared Codex state.
Tradeoff:
- Orca account switching will mutate the same Codex state used outside Orca.
That is acceptable for this product direction. Orca is acting as an automated frontend for the user's existing Codex environment, not a separate Codex silo.
## Migration Plan
### Phase 1: Preserve the config sync patch
Keep the existing `config.toml` sync patch as a tactical fix while the broader migration is in progress.
### Phase 2: Introduce runtime-home owner
- Add a dedicated main-process runtime-home owner/service.
- Route PTY spawning, rate-limit fetches, and login preparation through it.
- Make `~/.codex` the explicit resolved runtime home for those flows.
### Phase 3: Move managed accounts to auth-only semantics
- Update account add/reauth logic to persist only the account's `auth.json`.
- Stop treating per-account homes as full runtime environments.
### Phase 4: Account switch writes auth into shared runtime home
- On select, materialize the chosen account's `auth.json` into `~/.codex`.
- Keep the existing restart notice flow for live Codex panes.
### Phase 5: Cleanup / compatibility
- Run the one-time legacy managed-home migration into `~/.codex`.
- Mark old full-home-per-account storage as legacy.
- Remove code paths that assume managed homes are full `CODEX_HOME`s.
## Risks
### Concurrent mixed-account sessions
This design assumes one selected active account at a time. If Orca ever needs simultaneous live sessions under different accounts, a single shared runtime home with one `auth.json` will not be sufficient.
### Unknown Codex coupling
We know `auth.json` is account-specific. We infer that most other files are environment/user-state and should be shared. If Codex later proves that some sqlite or cache files are also account-coupled, Orca may need to carve out a small additional per-account subset. That is still far simpler than syncing whole homes.
### Mutating the shared runtime home
This design intentionally updates `~/.codex/auth.json`. That means Orca and terminal Codex outside Orca share one Codex world. This is a deliberate product choice, not an accidental side effect.
## Testing Strategy
### Unit tests
- account selection writes the selected `auth.json` into `~/.codex`
- PTY spawn uses `~/.codex` instead of the managed account home
- config/history/session paths resolve from `~/.codex`
- invalid or unreadable account auth does not corrupt the shared runtime home
- startup with missing shared runtime files is repaired gracefully
- rate-limit fetches and PTY launches consume the same runtime-home owner output
### Integration tests
- add account A, launch Codex, verify `~/.codex` gets history
- switch to account B, restart pane, verify history remains available
- verify `config.toml` and permissions do not change across account switches
- verify only `auth.json` differs across account switches
### Manual verification
1. Start a Codex session under account A and create visible history.
2. Switch to account B.
3. Restart the Codex pane.
4. Verify the session uses account B for auth/rate limits.
5. Verify history, sessions, config, memories, and skills remain available.
## Open Questions
- Do `logs_2.sqlite` and `state_5.sqlite` behave correctly when fully shared across account switches? This is the expected design, but should be validated during rollout.
- How should Orca expose refresh of the stored `system-default` auth snapshot when the user logs into Codex outside Orca?
## Recommendation
Adopt **`~/.codex` as the shared runtime `CODEX_HOME`, plus per-account `auth.json` only**, and implement it through a dedicated runtime-home owner in the main process.
This is the simplest design that:
- matches the intended account-switching UX,
- avoids fragile replication logic,
- works cross-platform,
- and leaves room to adopt upstream Codex auth-profile support later if it becomes available.

View File

@ -1,355 +0,0 @@
# Engineering Spec: "Start from" field in Create Workspace
## Summary
Add a **"Start from"** field to the Create Workspace composer that lets the user pick a **branch or PR** as the basis for the new workspace. The picker is scoped to the selected repo; changing repo resets the field. This replaces no existing behavior: "Start from" defaults to the repo's **effective base ref** (`repo.worktreeBaseRef` when configured, otherwise the repo's detected default branch), so the current quick-create flow is unchanged.
This spec targets the shared composer flow, not just the modal wrapper. The quick-create modal and the full-page composer both consume `NewWorkspaceComposerCard` + `useComposerState`, so the new field/state should live there unless a piece is truly modal-only.
**Issues are out of scope for this picker.** Picking an issue does not change the base ref — it only sets `linkedIssue`, which the existing Link-work-item UI (`useComposerState.ts:256260`) already handles. Putting Issues in a picker called "Start from" creates a second entry point into the same state and misleads the user. Users who want to link an issue use the existing Link UI.
## User-visible behavior
| Selection | Branch created from | Workspace metadata |
|---|---|---|
| Branch (default or other) | selected branch/ref | — |
| PR | PR's same-repo head branch/ref | `linkedPR = #N` |
Important: Orca's create flow always creates a new worktree branch derived from the workspace name. "Start from PR" therefore means **branch from the PR head**, not "check out the PR branch directly".
**Scope for v1:** local repos support both picker tabs. Remote SSH repos support **Branches** only in this spec; PR start points stay disabled there until GitHub lookup can run without assuming a local `cwd`.
**PR scope for v1:** only PRs whose head branch lives in the selected repo are selectable. Fork PRs render disabled with copy like *"Fork PRs aren't supported yet in Start from"* because the current create flow cannot safely resolve a fork head from `headRefName` alone.
**Repo ↔ Start from contradiction:** the picker is repo-scoped. Changing `repoId` clears the prior selection and resets the field to the new repo's effective base ref. The field renders the reset inline (e.g. trigger reads *"Default branch — was PR #8778"*) instead of a fading toast, so the state is recoverable visually and not missed by users focused on another field.
**Naming:** when the picker selects a PR and the Name field is still auto-managed (matches `lastAutoNameRef.current`, including empty), apply the existing auto-name behavior from `getLinkedWorkItemSuggestedName()` to the actual `name` state. Once the user edits the name, subsequent PR selections leave `name` alone. This matches the existing Link-UI rule exactly — one code path, not two.
**Linked-work-item interaction:** the picker writes into the *same* `linkedWorkItem`/`linkedPR` state that the Link UI owns. A PR selection is a `linkedWorkItem` assignment. Switching back to a branch leaves `linkedWorkItem` alone: the user changed the *start ref*, not the *link*. If the user wants to remove the link, they use the Link UI. No source-tagging, no parallel state, no "whose selection was it" bookkeeping.
---
## Data model
**No new shared type.** The "Start from" picker is a UI affordance that writes into fields the system already has:
- `CreateWorktreeArgs.baseBranch` (existing, `src/shared/types.ts:461`) — carries the resolved git ref the new worktree branches from.
- `WorktreeMeta.linkedPR` (existing, `src/shared/types.ts:56`) — set on PR selection. `useComposerState` already owns `linkedWorkItem` + `linkedPR` state and already writes it via `applyWorktreeMeta` post-create (`useComposerState.ts:880`).
Every picker selection reduces to one of:
| Picker selection | `baseBranch` passed to create | Linked metadata |
|---|---|---|
| Branch, local row | short branch name (e.g. `main`) | — |
| Branch, remote-tracking row | remote-qualified form from the row's full refname (e.g. `origin/main`, `upstream/feat-x`) | — |
| PR #N (same-repo head) | `<remote>/<headRefName>` after main-process `git fetch` of that ref (see PR head resolution) | `linkedPR = N` |
Why full-ref precision without a new type: local and remote-tracking refs are not interchangeable. The picker emits the right short form to `baseBranch` — remote-tracking rows pass the `<remote>/<name>` form so the new branch tracks the remote ref instead of creating a detached HEAD. Classification at the picker must use the row's underlying full refname (`refs/heads/…` vs `refs/remotes/<remote>/…`), never prefix-matching a short name (a local branch literally named `origin/foo` is legal). The remote name comes from the row's full refname, not a hardcoded `origin` — repos can have `upstream` or other remotes.
**Scope note on main-side remote derivation.** `createRemoteWorktree` (`worktree-remote.ts:96`) and `createLocalWorktree` (`worktree-remote.ts:243`) currently derive the remote as `baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'`. For picker selections this is fine — remote-tracking rows and PR refs always contain a slash, so the derived remote is correct. Branch-picker rows without a slash are local heads, and the `'origin'` fallback is unused. Non-picker code paths that pass slashless remote refs still hit the `origin` hardcode; fixing that is *out of scope* for this spec. The `resolvePrBase` resolver (below) must derive the push remote explicitly, since the PR head may live on `upstream` or another remote configured at the repo level.
**PR head resolution lives in main, not the renderer.** `gh pr list` returns `headRefName` as a short branch name that typically does not exist locally. Passing the bare `headRefName` as `baseBranch` will fail `git worktree add` in the common case. Git-ref resolution (`git fetch`, `git rev-parse`) belongs in main; the renderer does not shell out.
New IPC: `worktrees:resolvePrBase`
```ts
window.api.worktrees.resolvePrBase({
repoId,
prNumber,
// Optional cache hints from the renderer's existing PR cache.
// When both are present, main skips the `gh pr view` lookup.
headRefName?: string,
isCrossRepository?: boolean,
}) => Promise<{ baseBranch: string } | { error: string }>
```
On PR selection, the picker calls this resolver. Main:
1. If both hints are present, skip GitHub lookup. Otherwise resolve the PR via the existing GitHub client to obtain `headRefName` + `isCrossRepository`.
2. Reject fork PRs (`isCrossRepository === true`) with `"Fork PRs aren't supported yet"`.
3. Runs `git fetch <remote> <headRefName>` against the repo's default remote (see Default remote selection below).
4. Verifies the fetched ref with `git rev-parse --verify <remote>/<headRefName>`.
5. Returns `{ baseBranch: "<remote>/<headRefName>" }` or a user-readable error.
Pre-submit resolution surfaces "branch deleted on remote" in the picker, not at create time. The resolved string is the one passed to `CreateWorktreeArgs.baseBranch` — no special `kind: 'pr'` at the IPC boundary.
**Concurrency / stale resolves.** A PR selection commits to `baseBranch` only after `resolvePrBase` succeeds. If the user selects a second PR before the first resolves, the first resolve's result is discarded (last-click-wins). Track this in the picker with a per-click token or `AbortController`; do not simply `await` sequentially, or late resolves will clobber newer selections.
**Submit while resolve pending.** If the user triggers create while a `resolvePrBase` is still in flight, the submit path waits for the pending resolve (or fails fast with a visible "Resolving PR head…" state). It must not submit with an unresolved `baseBranch` and it must not submit with the *previous* selection's `baseBranch`.
**Default remote selection.** "Default remote" is not "the one named `origin`." Resolve inside `resolvePrBase` in this order: (1) the remote configured on the repo's default branch (`git config branch.<default>.remote`); (2) `origin` if present; (3) the single remote if the repo has exactly one; (4) error otherwise (ask the user to configure). Centralize this in a helper in `src/main/git/repo.ts` rather than re-deriving per call site.
**WSL repos.** The existing main-process helpers route through `isWslPath` / `parseWslPath` (`src/main/ipc/worktree-remote.ts:19`). `worktrees:resolvePrBase` must use the same routing for its `git fetch` / `rev-parse` calls, not bare `gitExecFileAsync` on a raw path, or WSL repos will fail to resolve.
**Draft restore.** `newWorkspaceDraft.baseBranch` is persisted. On composer mount a restored `baseBranch` may reference a ref that no longer exists (PR closed, branch deleted since yesterday). Current main-process behavior will silently fall back to `worktreeBaseRef` / default branch rather than erroring — this is the same v1 hole tracked in Follow-up chores. Optional v1 nicety: run a cheap `rev-parse --verify` on mount (local repos only); if the ref is gone, clear `baseBranch` in the draft and show a one-line hint in the field ("Previous start ref no longer exists"). Don't block the user.
**Accessibility.** The popover must support keyboard-only operation: arrow keys within a tab, Tab / Shift+Tab between tabs, Enter to commit, Esc to close without committing. Reuse the existing Link-UI popover's keyboard hook rather than re-implementing.
### `GitHubWorkItem` additions (`src/shared/types.ts:390`)
One new field is needed. `GitHubWorkItem` already carries `branchName?: string` (`src/shared/types.ts:400`), which `src/main/github/client.ts:636` populates from `headRefName` for PR rows. **Reuse `branchName` — do not introduce a second field meaning "PR head branch".** Throughout this spec, reads of "the PR's head branch" on `GitHubWorkItem` refer to `branchName`.
```ts
isCrossRepository?: boolean // true = fork PR; disabled in picker
```
**Verification:** `gh pr list` in `src/main/github/client.ts:280,401,610` currently requests `number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName`. Add `headRepositoryOwner` to the field list at all three sites; compute `isCrossRepository` in the mapper as `item.headRepositoryOwner?.login !== <selected repo owner>`. Mapper change lands at `src/main/github/client.ts:636`, where `branchName` is already set — this is the one place fork detection is computed.
### Draft persistence (`src/renderer/src/store/slices/ui.ts:64`)
Extend `newWorkspaceDraft` with `baseBranch?: string`. Absence means "use the repo's effective base ref" — no `null`-plus-conversion step, shape matches `CreateWorktreeArgs.baseBranch`. `linkedPR` / `linkedWorkItem` are already persisted, so PR selections round-trip without further schema changes.
No new `worktrees:create` wire change. The existing contract already carries `baseBranch`.
---
## Main-process changes
### IPC handlers
- `src/main/ipc/worktrees.ts` — wire the new `worktrees:resolvePrBase` handler (signature + algorithm defined in §Data model). Handler module may live adjacent if it grows.
- `src/main/ipc/worktree-remote.ts`**no changes**. The existing `||` fallback chain is preserved as-is. The picker emits validated refs (branch rows from `searchBaseRefs` exist; PR refs are `git fetch`ed + `rev-parse`d inside `resolvePrBase` before the picker commits them to `baseBranch`), so in practice the picker never hands an unresolvable ref to create.
### Base-ref resolution
No main-process branching on "kind". The renderer collapses every picker selection into a `baseBranch` string (and, for PR selections, `linkedPR` metadata). The existing `args.baseBranch || repo.worktreeBaseRef || <detected default>` chain in `createLocalWorktree` / `createRemoteWorktree` is unchanged.
Known limitation (acceptable for v1): if a picker-selected ref is deleted between selection and submit, create silently falls back instead of erroring. In practice this requires the remote branch to disappear in the seconds between picker commit and Create click — vanishingly rare. Tightening this into strict-when-explicit behavior is tracked as a follow-up chore (see `Follow-up chores`).
### Metadata persistence
PR selections set `linkedPR` in the existing composer state. The existing post-create `applyWorktreeMeta` call (`useComposerState.ts:880`) already writes it — no new write path.
If metadata persistence fails after the git worktree already exists, log and continue. The worktree is still valid even if the link badge is missing.
### GitHub data scope
- local repos, PRs: existing `gh:listWorkItems` (cached — see caching rules below)
- local repos, direct number lookup: existing `gh:workItem` (cached)
- branches: existing `repos:searchBaseRefs`
No new GitHub IPC is required for local repos in v1.
### Create-time validation
Unchanged. The picker pre-validates refs (branches via `searchBaseRefs` results, PR heads via `resolvePrBase`'s fetch + `rev-parse`), so most bad paths are caught before submit. The one remaining hole — ref deleted between commit and create — falls through to today's silent fallback; see `Follow-up chores`.
---
## Caching rules (must read)
PR searches and number lookups hit the user's `gh` CLI quota. The picker **must** ride on the existing SWR caches in `src/renderer/src/store/slices/github.ts`; it must not introduce a parallel fetch path.
**Required behavior:**
- **Always call `fetchWorkItems(repoPath, limit, query, options?)`** — never `window.api.gh.listWorkItems(...)` directly. The store already deduplicates in-flight requests (`inflightWorkItemsRequests`) and applies `WORK_ITEMS_CACHE_TTL`. Direct calls bypass both.
- **Use the prefetch path to warm shared keys.** The cache key is `(repoPath, limit, query)`. The picker's query **must** match the prefetch query exactly or cache hits won't share. Use:
- Prefetch on composer mount (local repo): `prefetchWorkItems(repoPath, 36, 'is:pr is:open')`.
- Picker PR tab default list: `fetchWorkItems(repoPath, 36, 'is:pr is:open')`.
- Picker PR tab user query: `fetchWorkItems(repoPath, 36, \`is:pr is:open \${userQuery}\`)` — queries debounced ~150ms (matches existing Link-UI debounce) so rapid typing collapses to one fetch.
- **Render cached results synchronously while revalidating.** Use `getCachedWorkItems(...)` for the first paint so opening the popover is instant and costs zero API calls when the cache is fresh.
- **Direct-number lookup (`#123`, full URL) uses `gh:workItem` via its cache.** Same SWR contract; the picker reads `prCache`/`issueCache` synchronously first.
- **Do not prefetch on every keystroke.** Only prefetch (a) on composer mount and (b) on popover open. Search queries go through the debounced `fetchWorkItems`, which dedupes against the cache anyway.
- **PR resolver (`worktrees:resolvePrBase`) must reuse the renderer-side PR cache when available.** The renderer passes the already-known `headRefName` and `isCrossRepository` to the resolver (as an optional hint); main skips the `gh pr view` call when the hint is present. Only the `git fetch` + `git rev-parse` steps always run, since remote refs can change.
- **Branch search (`repos:searchBaseRefs`) is git-local and cheap**; it does not count against GitHub quota, so fetch-on-demand when the Branches tab becomes active is acceptable. Debounce ~150ms to avoid redundant `git for-each-ref` invocations on large repos.
**Do not** call `prefetchWorkItems(repoPath, 'is:open')` — the second argument is `limit`, not `query`, and this would silently prefetch a different cache key than the picker reads.
**Audit existing prefetch callers.** `ui.ts:195` already calls `prefetchWorkItems(repo.path, 36, presetToQuery(preset))`. Confirm during implementation that at least one active task preset produces `'is:pr is:open'` (the exact string the picker queries) so the sidebar prefetch and the picker fetch share a cache key. If no preset matches exactly, add a dedicated mount-time prefetch in the composer and do not rely on the sidebar's opportunistic warming.
**Cache invalidation.** The existing SWR caches are keyed by `(repoPath, limit, query)` and expire via `WORK_ITEMS_CACHE_TTL`. The picker does **not** call `force: true` on every open — that defeats the cache. It only forces a refresh on explicit user action (e.g. a "Refresh" control in the popover, if added later). Stale-within-TTL is acceptable for picker use.
---
## Renderer changes
### 1. New components
- `src/renderer/src/components/new-workspace/StartFromField.tsx`
- popover trigger (pill + title + chevron)
- `src/renderer/src/components/new-workspace/StartFromPicker.tsx`
- tabs: **Branches · Pull requests**
- search input debounced ~150ms
- PR tab calls `worktrees:resolvePrBase` on selection; shows an inline error on fetch/resolve failure *before* the user submits
- on selection, calls back into `useComposerState` with `{ baseBranch, linkedWorkItem? }` — no new shared type
### 2. Popover state coverage
Each tab must render these states explicitly:
| Flow | Loading | Empty | Error | Success |
|---|---|---|---|---|
| Branches | skeleton rows | "No branches match" | inline error | list |
| Pull requests | skeleton rows (only if no cached data) | "No open PRs" | "gh not available — Branches tab still works" | list (cached first, revalidated in background) |
Cached results must paint immediately; the loading state appears only when nothing is cached. This makes the common case a zero-API-cost open.
### 3. Integrate into shared composer state
Add `baseBranch?: string` state in `src/renderer/src/hooks/useComposerState.ts` (reusing the existing `linkedWorkItem` / `linkedPR` state for PR selections — don't introduce parallel `startFrom` state). The hook already owns:
- repo selection
- `linkedWorkItem` + `linkedPR` (see `useComposerState.ts:209,223`)
- auto-name behavior via `lastAutoNameRef` (`useComposerState.ts:262`)
- full-page draft persistence
- submit / submitQuick, with post-create `applyWorktreeMeta` already writing linked metadata (`useComposerState.ts:880`)
The modal wrapper should stay thin. `NewWorkspaceComposerModal.tsx` continues to pass through `cardProps` to `NewWorkspaceComposerCard`, while the card gets new props for rendering the field.
### 4. Picker data sources
- Branches tab:
- `window.api.repos.searchBaseRefs({ repoId, query })`
- PRs tab:
- `fetchWorkItems(repoPath, 36, 'is:pr is:open')` for the default list
- `fetchWorkItems(repoPath, 36, \`is:pr is:open \${userQuery}\`)` for typed queries
- `getCachedWorkItems(...)` for first paint
Use the selected repo object already derived in `useComposerState`; do not introduce a separate `reposById` dependency unless the store actually gains one.
Filter PR results to same-repo heads only (`!isCrossRepository`). Fork PRs render disabled with explanatory copy, not silently filtered, so the user understands why their PR isn't selectable.
Normalize PR queries before dispatching GitHub lookups. Route by shape:
- bare number (`123`), `#123`, or a full GitHub PR URL for the selected repo → strip to the number and dispatch `gh:workItem` (reads `prCache` first per §Caching rules). `getWorkItem` returns `type: 'pr' | 'issue'`; when `type !== 'pr'`, treat as no-match in the PR tab (number collides with an issue).
- full GitHub PR URL for a *different* repo → silently fall back to free-text search. Do not hard-block; users paste URLs because they want the content.
- anything else → pass through as a free-text query to `fetchWorkItems(..., \`is:pr is:open \${query}\`)`.
### 5. Repo-change reset
On repo change:
- reset `baseBranch` to `undefined` (so the field shows the new repo's effective base ref as placeholder)
- clear any transient picker state tied to the previous repo
- the field's trigger copy shows the reset inline (e.g. *"Default branch — was PR #N"*) when a selection was cleared
The existing `handleRepoChange` callback (`useComposerState.ts:819`) already clears `linkedIssue` / `linkedPR` / `linkedWorkItem` inline; extend it to also clear `baseBranch`. One callback, not a new effect.
### 6. Naming behavior
When the picker selects a PR, it sets the existing `linkedWorkItem` state. The composer's existing auto-name path (which reacts to `linkedWorkItem` via `getLinkedWorkItemSuggestedName` and `lastAutoNameRef`) will update `name` iff `name === '' || name === lastAutoNameRef.current` — i.e. the name is still auto-managed. Once the user edits the name, subsequent selections leave it alone. This is the existing Link-UI rule; no new naming code.
### 7. Submission
Thread `baseBranch` through:
- `useComposerState` submit paths (already constructs `CreateWorktreeArgs` — just add the field)
- persisted `newWorkspaceDraft`
- store `createWorktree(...)` → preload `window.api.worktrees.create(...)` → main `CreateWorktreeArgs` (field already exists)
`linkedPR` needs no new wiring — the post-create `applyWorktreeMeta` call already writes it.
### 8. Prefetch
On composer mount (local repo only), warm the PR cache:
```ts
prefetchWorkItems(repoPath, 36, 'is:pr is:open')
```
Do **not** call `prefetchWorkItems(repoPath, 'is:open')`; the second argument is `limit`, not `query`. Do **not** use a query string that differs from what the picker will fetch — mismatched keys produce a double fetch.
Branch results stay fetch-on-demand when the Branches tab becomes active.
---
## Shortcut discoverability
Out of scope for this spec. A follow-up can add a split "+" button with `CmdOrCtrl+Shift+N` for a more explicit "Create from…" entry point.
## Explicitly out of scope
- **Issues tab.** Issues do not change the start ref; use the existing Link UI to link an issue.
- **Checking out an existing branch without `-b`.** Orca's create flow always derives a new branch from the workspace name; "Start from PR" means branch from the PR head, not open the PR branch directly.
- **Fork PR start points.** Disabled in v1.
- **SSH PR start points.** Disabled in v1.
---
## Edge cases
| Case | Behavior |
|---|---|
| User picks PR, then renames Name manually | Manual name wins (existing `lastAutoNameRef` rule) |
| User picks PR, then picks a different PR without editing name | Name updates to the new PR's suggestion |
| User picks PR from a fork | picker disables it in v1; no create attempt |
| PR head branch has since been deleted at picker open | picker surfaces resolve error before submit; create never attempted |
| PR head fetch fails (network/auth) | picker surfaces the fetch error; selection does not commit |
| User picks branch, switches repo, switches back | no cross-repo picker state is preserved |
| Offline / `gh` CLI missing | PRs tab shows error state; Branches tab still works |
| Remote repo over SSH | only Branches tab is enabled in v1; PRs tab disabled with explanatory copy |
| Repo has `worktreeBaseRef` set to non-default branch | reset behavior uses that configured base ref |
| User pastes `#123` or a full GitHub PR URL | picker normalizes to the work item number and uses `gh:workItem` cache |
| Pasted number resolves to an issue, not a PR, in the PR tab | treated as no-match; user sees empty-state copy |
| User pastes a PR URL for a different repo | picker silently falls back to free-text search |
| Selected ref (branch or PR head) disappears between picker commit and create | falls through to today's `worktreeBaseRef` / default-branch fallback (acceptable v1 hole; tracked in Follow-up chores) |
| Restored draft references a ref that no longer exists | same silent fallback at create; optional v1 nicety clears the draft field with an inline hint on mount |
| Popover opened with fresh cache | renders instantly from `getCachedWorkItems`; zero API calls |
| User selects PR, then quickly selects a different PR before first resolve returns | last-click wins; stale resolve is discarded (AbortController / token) |
| User hits Create while `resolvePrBase` is still pending | submit waits for the in-flight resolve; never submits with a stale `baseBranch` |
| User rapidly toggles between Branches and PRs tabs mid-fetch | in-flight search requests for the prior tab are aborted; stale rows never render |
---
## Test plan
- **Unit / renderer**
- `StartFromField` renders the correct pill for each selection kind
- repo change resets `baseBranch` and the field shows the reset inline
- full-page draft persistence round-trips `baseBranch` (and existing linked-work-item fields)
- PR selection updates the actual `name` state only while it remains auto-managed (`name === '' || name === lastAutoNameRef.current`)
- PR selection mirrors into linked-work-item state; switching back to branch does **not** clear the link
- SSH repos disable PR tab
- cross-repo PRs are disabled with explanatory copy
- `#123` and full GitHub PR URLs normalize to number search
- pasted cross-repo URLs fall back to free-text search (no hard error)
- picker writes short name for local branch rows, `<remote>/<name>` for remote-tracking rows
- PR selection calls `worktrees:resolvePrBase` and threads the resolved ref into `baseBranch`
- **Caching**
- opening the PR tab with a fresh cache triggers **zero** `window.api.gh.listWorkItems` calls (assert via spy)
- prefetch on composer mount and picker default fetch share the same cache key (`(repoPath, 36, 'is:pr is:open')`)
- rapid typing in the PR search debounces to a single fetch
- direct-number lookup (`#123`) reads `prCache`/`issueCache` synchronously before hitting `gh:workItem`
- `worktrees:resolvePrBase` skips `gh pr view` when the renderer passes a cached `headRefName` hint
- rapid re-selection aborts prior `resolvePrBase`; only the latest selection's result commits
- submit while `resolvePrBase` is pending waits for it; never submits a stale `baseBranch`
- **Main-process**
- `worktrees:resolvePrBase` fetches via the repo's default remote (not hardcoded `origin`), returns resolved ref on success
- `worktrees:resolvePrBase` returns a user-readable error when the remote branch is missing or fetch fails
- `worktrees:resolvePrBase` rejects fork PRs
- `isCrossRepository` is populated on `GitHubWorkItem` PR rows from `headRepositoryOwner`
- **Manual**
- quick create without touching the field behaves exactly as today
- create from same-repo PR creates a new branch from the PR head and shows the PR badge
- SSH repo shows branch start points only
- changing repo mid-flow resets the field and shows the inline reset copy
- opening the picker a second time within the cache TTL makes zero network calls
---
## Rollout
Single PR. No feature flag. The change is additive and backward-compatible — `baseBranch` is already optional on `CreateWorktreeArgs`, and `linkedPR` metadata is already written by existing code.
## Files touched
```text
src/shared/types.ts (add isCrossRepository to GitHubWorkItem; branchName already carries PR head)
src/main/github/client.ts (add headRepositoryOwner to gh pr list field set; populate isCrossRepository)
src/main/ipc/worktrees.ts (new worktrees:resolvePrBase handler)
src/main/git/repo.ts (default-remote helper used by resolvePrBase)
src/preload/index.ts (invoke worktrees:resolvePrBase)
src/preload/api-types.d.ts (type worktrees.resolvePrBase)
src/renderer/src/hooks/useComposerState.ts (baseBranch state + repo-change reset)
src/renderer/src/components/NewWorkspaceComposerCard.tsx (render the field)
src/renderer/src/components/new-workspace/StartFromField.tsx (new: pill trigger)
src/renderer/src/components/new-workspace/StartFromPicker.tsx (new: tabs + picker; SWR via fetchWorkItems/getCachedWorkItems)
src/renderer/src/store/slices/ui.ts (baseBranch in newWorkspaceDraft)
src/renderer/src/lib/new-workspace.ts (if a small display helper is needed)
```
Estimated effort: ~1.5 engineering days — new `worktrees:resolvePrBase` IPC, `isCrossRepository` plumbing through the `gh` client and its mappers, picker + cancellation, URL normalization, prefetch key alignment, and renderer + main tests. Fully additive; no behavior change for existing flows.
## Follow-up chores
File these as separate issues at ship time, not in this PR:
- **Strict-when-explicit base-ref validation.** Today `createLocalWorktree` / `createRemoteWorktree` silently fall back when an explicit `args.baseBranch` is unresolvable. The Start-from picker avoids this in practice by pre-validating, but a ref deleted between selection and submit still falls through. Replace the `||` chain with strict-when-explicit / fallback-when-implicit; add `SshGitProvider.verifyRef` for the remote path. Standalone refactor, own test coverage, affects all callers of `CreateWorktreeArgs.baseBranch`.
- **Main-side default-remote derivation.** `createLocalWorktree` / `createRemoteWorktree` derive remote as `origin` when `baseBranch` lacks a slash. Centralize default-remote resolution (push remote of default branch → `origin` → single remote) and share with `resolvePrBase`.

View File

@ -1,203 +0,0 @@
# Mobile Phone-Fit Debug Status
## Architecture Overview
```
Mobile Client Server (orca-runtime) Desktop Renderer
───────────── ──────────────────── ────────────────
subscribeToTerminal(handle) → terminal.subscribe handler → IPC: terminalFitOverrideChanged
sends { client, viewport } calls handleMobileSubscribe() setFitOverride() → banner render
resizes PTY, sets override
serializes scrollback
← scrollback { cols, rows,
serialized, displayMode }
switchTab(handle) → terminal.unsubscribe (old)
unsub old, subscribe new handleMobileUnsubscribe()
starts 300ms restore timer
→ terminal.subscribe (new)
handleMobileSubscribe()
cancels timer, inline-restores old
resizes new PTY
toggleDisplayMode(handle) → terminal.setDisplayMode
applyMobileDisplayMode()
← resized event on stream
```
## Key Data Structures (Server)
- `mobileSubscribers: Map<ptyId, { clientId, viewport, wasResizedToPhone, previousCols, previousRows }>`
- `pendingRestoreTimers: Map<ptyId, { timer, clientId }>` (changed from clientId-keyed to ptyId-keyed)
- `terminalFitOverrides: Map<ptyId, { mode, cols, rows, previousCols, previousRows, clientId }>`
- `mobileDisplayModes: Map<ptyId, 'auto' | 'phone' | 'desktop'>`
## Key Data Structures (Mobile Client)
- `viewportRef: { cols, rows } | null` — measured once from xterm, passed with every subscribe
- `viewportMeasuredRef: boolean` — true after first successful measurement
- `terminalUnsubsRef: Map<handle, unsub()>` — active subscription cleanup closures
- `initializedHandlesRef: Set<handle>` — tracks which terminals have been init'd (prevents double-init)
- `subscribeSeqRef: Map<handle, number>` — monotonic counter to ignore stale scrollback
---
## Bug 1: pendingRestoreTimers lost when 2 unsubscribes happen back-to-back
**Status: FIXED (verification inconclusive — may need more testing)**
**Root cause**: `pendingRestoreTimers` was keyed by `clientId` (one slot per device). When two terminals were unsubscribed in quick succession, the second timer overwrote the first.
**Fix applied**:
1. Changed `pendingRestoreTimers` from `Map<clientId, {timer, ptyId}>` to `Map<ptyId, {timer, clientId}>`
2. `handleMobileSubscribe` cancels only the restore timer for the SAME ptyId (re-subscribe case). Other terminals' timers fire normally so their desktop banners clear.
3. `handleMobileSubscribe` skips resize if PTY is already at target phone dims
4. `handleCreateTerminal` now unsubscribes the old active terminal before setting the new one
5. Restore happens via: 300ms timer (tab switch), or `onClientDisconnected` (full disconnect)
**Bug 8 (banners accumulate on tab switch)**: The original Bug 1 fix was too aggressive — it cancelled ALL pending restore timers for the client, not just the one for the ptyId being re-subscribed. This prevented the 300ms restore timer from firing for the old terminal, so its desktop banner persisted. Fixed by narrowing the cancel scope to only the same ptyId.
**Files**: `orca-runtime.ts`, `[worktreeId].tsx`
---
## Bug 2: Intermittent blank terminal on tab switch
**Status: FIX v2 APPLIED — needs testing**
**Symptom**: After creating a new terminal tab, the original 2 terminals occasionally show blank. Leaving the worktree and re-entering fixes it.
**Root cause**: The WebView loads xterm.js from CDN, which takes time. Messages sent before `web-ready` queue in `pendingMessagesRef` and flush when ready. The original `handleTerminalWebReady` would unsub+resub ALL initialized terminals (even inactive ones), creating stale server-side subscriptions and disrupting the data stream.
**Fix v1 (FAILED)**: Gated `subscribeToTerminal` on webReady. This was too aggressive — it prevented subscriptions entirely, so no scrollback arrived, no init was queued, and the terminal stayed blank.
**Fix v2 (current)**:
1. `subscribeToTerminal` has NO webReady guard — subscriptions start immediately, init messages queue in `pendingMessagesRef`, and flush when `web-ready` fires
2. `handleTerminalWebReady` distinguishes first load vs reload:
- **First load** (`wasAlreadyReady=false`): just marks webReady, triggers viewport measurement for active terminal. Pending messages flush after this callback returns → terminal renders.
- **Reload** (`wasAlreadyReady=true`): unsubscribes and resubscribes to get fresh scrollback (old xterm buffer is gone). Only resubscribes if active.
3. `setTerminalWebViewRef` simplified to just store the ref (no subscription logic)
**Flow (first load)**:
1. `fetchTerminals``subscribeToTerminal(active)` → stream starts
2. Scrollback arrives → `init()` queued in pendingMessages (WebView not ready yet)
3. WebView loads xterm from CDN → `web-ready` fires
4. `handleTerminalWebReady`: marks webReady, triggers viewport measurement (async)
5. `flushPendingMessages()`: sends queued init → terminal renders
6. Viewport measured → resubscribe with dims → server phone-fits → reinit with phone dims
---
## Bug 5: Desktop banner not showing for initial split pane after creating new tab
**Status: FIXED (verified via CDP + e2e testing)**
**Root cause**: The bug was caused by the inline restore mechanism in `handleMobileSubscribe`. When mobile switched tabs, the server would restore the previous terminal to desktop dims (sending `desktop-fit` IPC), which cleared the override from `overridesByPtyId`. After the next subscribe, the override was re-set via `mobile-fit` IPC, but the timing was tight and the banner could flicker or fail to appear.
**Fix**: Removed inline restore from `handleMobileSubscribe` (Bug 1 fix). PTYs now stay at phone dims when the mobile client switches tabs. Overrides persist in `overridesByPtyId` until the mobile client disconnects. This means ALL terminals the mobile client has subscribed to show the banner — which is correct since the mobile client "owns" those terminals.
**Verification**: E2e testing via CDP (desktop renderer) and agent-device (mobile) confirmed:
- `setFitOverride()` is called correctly via IPC
- `onOverrideChange` fires and triggers re-renders
- `getFitOverrideForPane()` finds overrides when `ptyIdByPaneId` bindings exist
- Banners show correctly for all terminals (1, 2, and 3 tabs) including after creating new tabs
- The earlier diagnostic showed HMR clearing module-level maps was a test artifact, not a production issue
---
## Bug 7: Terminals disappear ("0 terminals") during rapid tab switching
**Status: FIXED (verified via e2e testing)**
**Symptom**: During rapid tab switching (3+ toggles in quick succession), all terminals disappear from the mobile UI. The terminal list shows "0 terminals". Leaving the worktree and re-entering fixes it.
**Root cause**: The periodic `fetchTerminals()` (every 2s) calls with `allowEmptyLoaded: true`. The server can transiently return an empty terminal list during rapid operations. The old code's subscription cleanup loop (`liveHandles` check) ran before the empty guard, unsubscribing all terminals, which then made the empty guard's `terminalUnsubsRef.current.size > 0` check fail.
**Fix applied**:
1. Added `lastKnownTerminalCountRef` to track the previous non-zero terminal count
2. When the server returns 0 terminals but `lastKnownTerminalCountRef > 0`, skip the FIRST empty response (set ref to 0 and return early)
3. On the NEXT fetch, if still empty, `lastKnownTerminalCountRef` is 0, so the guard doesn't trigger and terminals are cleared normally
4. This gives a ~2s grace period (one polling interval) to filter out transient empty responses
5. The guard runs BEFORE the subscription cleanup loop, preventing premature unsubscription
**Verification**: Rapid tab switching (8 cycles across 3 terminals in ~1.6s) with 20s wait — terminals survived. Previous code cleared to "0 terminals" within 18s.
**Files**: `[worktreeId].tsx`
---
## Bug 3: Viewport measurement chicken-and-egg
**Status: SOLVED (workaround in place)**
The first subscribe has `viewport=none`. After scrollback init, async measure viewport, resubscribe with dims. Takes 2-3 round-trips. Works reliably.
---
## Bug 4: Desktop terminal focus doesn't follow mobile tab switching
**Status: FIXED**
`switchTab` now calls `terminal.focus` RPC.
---
## Bug 6: Claude not launching on new workspace
**Status: FIXED**
**Root cause**: Mobile's `NewWorktreeModal` sends `startupCommand` (e.g. `'claude'`) in the `worktree.create` RPC call, but the Zod schema (`WorktreeCreate`) did not include `startupCommand`, so it was silently stripped during validation. The runtime's `createManagedWorktree` never received the startup command, so `args.startup` was always undefined and the activation IPC never included a startup payload. The left pane spawned a plain shell instead of the selected agent.
**Fix applied**:
1. Added `startupCommand: OptionalString` to the `WorktreeCreate` Zod schema in `src/main/runtime/rpc/methods/worktree.ts`
2. RPC handler maps `params.startupCommand``{ command }` and passes to `runtime.createManagedWorktree()`
3. Added `startup?: WorktreeStartupLaunch` to `createManagedWorktree`'s args type in `orca-runtime.ts`
**Bug 6b: `waitForLeafPtyId` times out due to handle invalidation**
When a leaf's ptyId changes from null to a real value, `syncWindowGraph` invalidates the old handle (deletes it from `this.handles`). The `waitForLeafPtyId` callback calls `resolveLeafForHandle(handle)` which returns null because the handle no longer exists. The wait never resolves.
**Fix**: `waitForLeafPtyId` now captures the handle's `tabId` and `leafId` before the handle can be invalidated. The callback falls back to direct `this.leaves.get(getLeafKey(tabId, leafId))` lookup when the handle-based lookup fails.
**Files**: `src/main/runtime/rpc/methods/worktree.ts`, `src/main/runtime/orca-runtime.ts`
---
## Diagnostic Logging
All logs prefixed with `[mobile-fit]`.
### Server-side (main process stdout)
| Location | What it logs |
|----------|-------------|
| `terminal.subscribe` handler | handle, ptyId, client type, viewport |
| `handleMobileSubscribe` | ptyId, mode, viewport, skip reasons, resize details |
| `handleMobileUnsubscribe` | ptyId, subscriber state, wasResized |
| `applyMobileDisplayMode` | ptyId, mode, subscriber state |
### Mobile client (React Native console)
| Location | What it logs |
|----------|-------------|
| `subscribeToTerminal` | handle, seq, viewport, measured state |
| scrollback handler | cols, rows, displayMode, hasSerialized, alreadyInit |
| resized handler | cols, rows, displayMode, reason |
| `switchTab` | prev/next handle, hasUnsub, hasRef |
| `toggleDisplayMode` | handle, current mode, next mode |
| `setTerminalWebViewRef` | handle, isActive, activeHandle |
### Desktop renderer (DevTools console)
| Location | What it logs |
|----------|-------------|
| `useIpcEvents.ts` | fitOverrideChanged IPC events received |
| `TerminalPane.tsx` | onOverrideChange callbacks fired |
---
## Priority Order
1. ~~**Bug 2 (blank terminal)**~~**FIXED**. WebView readiness lifecycle corrected.
2. ~~**Duplicate prompt lines**~~**FIXED**. Removed inline restore, added alreadyAtTarget skip.
3. ~~**Bug 7 (0 terminals)**~~**FIXED**. Consecutive-empty guard with `lastKnownTerminalCountRef`.
4. ~~**Bug 5 (banner missing)**~~**FIXED**. Side effect of inline restore removal + verified via CDP.
5. ~~**Bug 1 (timer overwrite)**~~**FIXED**. `pendingRestoreTimers` keyed by ptyId, cancel without restore.
6. **Cleanup** — Remove `[mobile-fit]` diagnostic logs, Debug Test button, dead code.

View File

@ -1,143 +0,0 @@
# Design: Searchable Repository Selection in New Worktree Dialog
## 1. Problem Statement
GitHub issue [#379](https://github.com/stablyai/orca/issues/379): when creating a new worktree, the user must manually scroll a plain `Select` dropdown to pick the target repository. This does not scale once Orca manages many repositories.
The current Radix `Select` in `AddWorktreeDialog` has two product problems:
- It forces serial scanning instead of direct search.
- It makes the most important first step in the worktree-creation flow slower than every subsequent step.
The issue has no explicit acceptance criteria, so implementation requirements are inferred from the existing UX:
- Users must be able to search for a repository by name while creating a worktree.
- Existing auto-preselection behavior (preselectedRepoId, activeWorktreeRepoId, activeRepoId fallback chain) must keep working.
- Keyboard and mouse selection must both work.
- The change must not break the rest of the create-worktree flow: setup lookup, issue-command lookup, validation, and Enter-to-create behavior.
## 2. Approach
Replace the repository `Select` with a searchable combobox built from Orca's existing `Command` primitive (cmdk-based) and a new shared `Popover` primitive (Radix-based).
Rationale:
- A combobox is the standard pattern for "pick one item from a potentially long list, with search."
- Orca already ships `cmdk`-based command surfaces and Radix primitives, so this fits the existing stack without introducing new dependencies.
- Keeping the search scoped to the repo field preserves the current worktree-creation layout instead of turning the entire dialog into a command palette.
- Search logic lives in a pure helper (`searchRepos`) so matching rules are explicit and regression-testable outside React.
### Matching and ranking
- Search is case-insensitive substring matching.
- Primary target is `repo.displayName` (what issue #379 is specifically about). Secondary target is `repo.path` for disambiguating repos with similar display names.
- Ranking uses position-based scoring: matches earlier in the string rank higher. Display-name matches always outrank path-only matches (path scores are offset by 1000). Ties preserve original list order.
- Empty query returns the full eligible repo list in its original order.
## 3. Implementation Plan
### `src/renderer/src/components/ui/popover.tsx`
New shared Popover wrapper around Radix `Popover`. Follows the same styling conventions as existing `dialog.tsx`, `select.tsx`, and `dropdown-menu.tsx`. Generic and reusable — not inlined inside the worktree dialog.
### `src/renderer/src/lib/repo-search.ts`
New pure helper (`searchRepos`) that filters and ranks eligible repositories by a query string. Normalizes trimming and lowercasing in one place. Scoring logic:
- Display-name hit: score = index of substring match within `displayName`.
- Path-only hit: score = 1000 + index of substring match within `path`.
- No hit: excluded from results.
- Stable sort by `(score, originalIndex)` so equivalent matches preserve list order.
### `src/renderer/src/lib/repo-search.test.ts`
Unit tests covering:
- Empty query returns all repos in original order.
- Display-name matching is case-insensitive.
- Path fallback works when the display name does not match.
- Display-name matches rank ahead of path-only matches.
### `src/renderer/src/components/repo/RepoCombobox.tsx`
New reusable combobox component. Props: `repos`, `value`, `onValueChange`, optional `placeholder`.
Key design decisions:
- **External filtering**: `Command` is rendered with `shouldFilter={false}` because filtering is handled by `searchRepos`, not by cmdk's built-in filter. This keeps ranking rules in the pure helper where they are testable.
- **Trigger and content markup**: Both the trigger `Button` and `PopoverContent` carry `data-repo-combobox-root="true"` so the parent dialog's keydown handler can detect events originating inside the combobox surface (see Section 5).
- **Selected repo display**: The trigger renders the selected repo using the existing `RepoDotLabel` component, consistent with how repos appear elsewhere in Orca.
- **Result items**: Each item shows `RepoDotLabel` for the display name plus the full `repo.path` as secondary text, so repos with similar names are visually distinguishable.
- **Popover width**: `PopoverContent` uses `w-[var(--radix-popover-trigger-width)]` to match the trigger width for visual alignment.
- **Focus management**: `CommandInput` has `autoFocus` so the search input receives focus immediately when the popover opens.
- **Query reset on close**: `handleOpenChange` clears the query when the popover closes so stale filter text from a previous interaction does not hide repos on the next open.
- **Empty state**: `CommandEmpty` shows an explicit message when no repo matches.
### `src/renderer/src/components/sidebar/AddWorktreeDialog.tsx`
Changes to the existing dialog:
- Replace the `Select` repository control with `RepoCombobox`.
- All existing repo-selection state and side effects are preserved unchanged: `repoId`, `handleRepoChange`, auto-preselection on open, hook lookup, and issue-command lookup.
- Update the dialog-level `handleKeyDown` to suppress Enter when the event target lives inside a `[data-repo-combobox-root="true"]` element (see Section 5).
## 4. Edge Cases
| Scenario | Handling |
|---|---|
| No eligible git repos | Existing guard closes the dialog; the combobox is never rendered in a broken state. |
| Only one eligible repo | The combobox still renders normally. Search is trivial, but the UI stays consistent. |
| Multiple repos share a similar display name | Search also matches `repo.path`, and each item shows the full path as secondary text for disambiguation. |
| User opens combobox, types a query, closes, reopens | Query resets on close so the next open starts from the full list. |
| Enter pressed while focus is inside the repo search input | The dialog-level create shortcut is suppressed for events originating inside the combobox (see Section 5). |
| Preselected repo is not the first repo in the list | Auto-preselection logic is unchanged; the selected value is still controlled by `repoId` state. |
| Repo list changes while the dialog is open | The combobox renders from the live `eligibleRepos` array and updates reactively. If the selected repo disappears, existing dialog validation prevents create without a valid `selectedRepo`. |
## 5. Regressions and Mitigations
### Enter inside repo search creates a worktree
This is the most important correctness constraint. Without mitigation, typing a search query and pressing Enter to select a repo would bubble up to the dialog's keydown handler and trigger worktree creation.
Mitigation: `data-repo-combobox-root="true"` is placed on both the combobox trigger button and the popover content. The dialog's `handleKeyDown` checks `e.target.closest('[data-repo-combobox-root="true"]')` and returns early when it finds a match. This covers Enter events from:
- The `CommandInput` search field (inside the popover content).
- The trigger button itself when the popover is closed (Enter should open the popover, not submit the form).
An inline comment in `handleKeyDown` documents why this guard exists.
### Stale search query hides repos on reopen
Mitigation: `RepoCombobox.handleOpenChange` resets the query to `''` whenever the popover closes. An inline comment documents the timing interaction with the dialog's own delayed field reset.
### Repo preselection breaks
Mitigation: `repoId` remains the single source of truth for the selected repository. Only the presentation control was swapped; the state management and auto-selection logic in `AddWorktreeDialog` are untouched.
### Users cannot distinguish similarly named repos
Mitigation: Each combobox item shows both the `RepoDotLabel` display name and the full `repo.path` as secondary text.
### Matching behavior drifts over time
Mitigation: Search rules live in the pure `searchRepos` helper with dedicated unit tests, not inlined in JSX.
## 6. Test Plan
### Unit tests (`repo-search.test.ts`)
- Empty query returns all repos in original order.
- Display-name matching is case-insensitive.
- Path fallback works when the display name does not match.
- Display-name matches rank ahead of path-only matches.
### Manual verification
- Open the New Worktree dialog with multiple repos.
- Confirm the repo field opens a searchable combobox instead of a plain dropdown.
- Type part of a repo name and verify the list filters immediately.
- Press Enter to select a filtered repo and verify the dialog does not submit.
- Create a worktree after selecting a repo through search and verify the existing create flow still works end-to-end (setup, issue command, metadata).
- Reopen the dialog and verify the previous search text is cleared.
- Verify the preselected repo still appears when opening from flows that pass `preselectedRepoId`.
- Verify keyboard navigation (arrow keys, Enter to select, Escape to close popover) works within the combobox.

View File

@ -1,213 +0,0 @@
# Design: Preserve Scroll Position Per File in Editor
## Context
When navigating between file tabs, the scroll position resets to the top because each editor component unmounts and remounts. The cursor line is already tracked per file via `editorCursorLine` in the store, but scroll position is not. This causes a poor UX when users switch between files frequently.
## Approach
Use a module-scoped `Map<string, number>` with LRU eviction, following the proven pattern from `CombinedDiffViewer` (lines 3958). The scroll cache is extracted into a shared utility at `src/renderer/src/lib/scroll-cache.ts` so all viewer components share one bounded Map.
**Why not Zustand?** Scroll position updates at high frequency during user interaction. Putting it in Zustand means every write spreads a new object (`{ ...s.editorScrollTop, [fileId]: scrollTop }`), which (a) causes React re-render notifications even though no component subscribes to scroll position for rendering, and (b) generates object allocation churn on every scroll event. A module-scoped Map avoids both problems: zero re-renders, zero GC pressure, O(1) reads and writes. CombinedDiffViewer already validates this approach in production.
**Why LRU at 20 entries?** Without a cap, the Map grows unboundedly as unique file IDs accumulate across a session. 20 entries covers typical tab working sets with headroom. This matches `CombinedDiffViewer`'s existing cap and means no explicit cleanup is needed when files are closed — eviction is automatic.
## Files to Modify
### 1. Shared Utility: `src/renderer/src/lib/scroll-cache.ts` (new file)
Extract `setWithLRU` from `CombinedDiffViewer` into a shared module, and expose a single scroll cache Map:
```ts
const CACHE_MAX_ENTRIES = 20
// Why: Module-scoped Maps grow unboundedly as unique file keys accumulate.
// Cap them with a simple LRU eviction: after each set, if the map exceeds
// this limit, delete the oldest entry (Maps iterate in insertion order).
export function setWithLRU<K, V>(map: Map<K, V>, key: K, value: V): void {
// Re-insert to refresh insertion order (move to end).
map.delete(key)
map.set(key, value)
if (map.size > CACHE_MAX_ENTRIES) {
const oldestKey = map.keys().next().value
if (oldestKey !== undefined) {
map.delete(oldestKey)
}
}
}
// Why: A single shared Map for scroll positions across all editor components.
// Module-scoped so it survives component unmount/remount without triggering
// React re-renders (unlike Zustand, which would broadcast state changes on
// every scroll event even though no component renders from scroll position).
export const scrollTopCache = new Map<string, number>()
```
After extracting, update `CombinedDiffViewer` to import `setWithLRU` from this module instead of defining it inline. `CombinedDiffViewer`'s own `combinedDiffViewStateCache` and `combinedDiffScrollTopCache` stay local since their value types are component-specific.
---
### 2. MonacoEditor: `src/renderer/src/components/editor/MonacoEditor.tsx`
**Save scroll position:**
In `handleMount` (line ~62), add a throttled scroll listener:
```ts
import { scrollTopCache, setWithLRU } from '@renderer/lib/scroll-cache'
// Why: Writing to the Map at 60fps (every scroll frame) is unnecessary since
// we only need the final position when the user stops scrolling or switches
// tabs. A trailing throttle of ~150ms captures the resting position while
// avoiding excessive writes.
let scrollThrottleTimer: ReturnType<typeof setTimeout> | null = null
editorInstance.onDidScrollChange((e) => {
if (scrollThrottleTimer !== null) clearTimeout(scrollThrottleTimer)
scrollThrottleTimer = setTimeout(() => {
setWithLRU(scrollTopCache, filePath, e.scrollTop)
scrollThrottleTimer = null
}, 150)
})
```
Also snapshot the current position synchronously in the cleanup/unmount path (before the timeout fires) so tab switches always capture the latest value — same pattern as `CombinedDiffViewer`'s `updateCachedScrollPosition()` call in its cleanup return.
**Restore scroll position:**
In the `else` branch (line ~118) where there is NO pending reveal:
```ts
const savedScrollTop = scrollTopCache.get(filePath)
if (savedScrollTop !== undefined) {
// Why: Monaco renders synchronously, so a single RAF is sufficient to
// wait for the layout pass. Unlike react-markdown or Tiptap, there is
// no async content loading that would require a retry loop.
requestAnimationFrame(() => editorInstance.setScrollTop(savedScrollTop))
}
```
**Key edge case:** When `pendingEditorReveal` exists (search-result navigation), skip scroll restoration — `performReveal` handles its own scroll.
---
### 3. MarkdownPreview: `src/renderer/src/components/editor/MarkdownPreview.tsx`
**Mode-scoped cache key:**
```ts
// Why: Each markdown viewing mode (source/rich/preview) produces different
// DOM structures and content heights. A scroll position saved in source mode
// (a code block at line 500) has no meaningful correspondence in preview mode
// (rendered HTML at a completely different height). Using mode-scoped keys
// means each mode remembers its own position independently.
const scrollCacheKey = `${filePath}:preview`
```
**Save scroll position:**
Add a throttled scroll listener on `rootRef.current` (the scrollable div, line 192) via `useLayoutEffect`:
```ts
useLayoutEffect(() => {
const container = rootRef.current
if (!container) return
let throttleTimer: ReturnType<typeof setTimeout> | null = null
const onScroll = (): void => {
if (throttleTimer !== null) clearTimeout(throttleTimer)
throttleTimer = setTimeout(() => {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
throttleTimer = null
}, 150)
}
container.addEventListener('scroll', onScroll, { passive: true })
return () => {
// Snapshot final position synchronously before detach.
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
if (throttleTimer !== null) clearTimeout(throttleTimer)
container.removeEventListener('scroll', onScroll)
}
}, [scrollCacheKey])
```
**Restore scroll position (RAF retry loop):**
react-markdown renders asynchronously — content may not be in the DOM when the layout effect first runs, so `scrollHeight` is still small and `scrollTop` gets clamped to 0. Use `CombinedDiffViewer`'s RAF retry pattern (lines 353388) to keep attempting until content has loaded:
```ts
useLayoutEffect(() => {
const container = rootRef.current
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
if (!container || targetScrollTop === undefined) return
let frameId = 0
let attempts = 0
// Why: react-markdown renders asynchronously, so scrollHeight may still be
// too small on the first frame. Retry up to 30 frames (~500ms at 60fps) to
// accommodate content loading. This matches CombinedDiffViewer's proven
// pattern for dynamic-height content restoration.
const tryRestore = (): void => {
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
const nextScrollTop = Math.min(targetScrollTop, maxScrollTop)
container.scrollTop = nextScrollTop
if (Math.abs(container.scrollTop - targetScrollTop) <= 1 || maxScrollTop >= targetScrollTop) {
return
}
attempts += 1
if (attempts < 30) {
frameId = window.requestAnimationFrame(tryRestore)
}
}
tryRestore()
return () => window.cancelAnimationFrame(frameId)
}, [scrollCacheKey])
```
---
### 4. RichMarkdownEditor: `src/renderer/src/components/editor/RichMarkdownEditor.tsx`
Same approach as MarkdownPreview with two differences:
1. **Cache key:** `${filePath}:rich`
2. **Scroll container:** The scrollable container is `<EditorContent>` with `overflow-auto` (line 334). Wrap in a div with a ref to get the scroll container, move `overflow-auto` to wrapper.
The RAF retry loop is needed here too — Tiptap renders asynchronously as it hydrates its ProseMirror document, so `scrollHeight` may be undersized on the initial frame.
Save/restore logic is identical to MarkdownPreview, substituting the container ref and cache key.
---
### 5. DiffViewer — Deferred
DiffViewer doesn't currently receive `filePath` and diff views are typically opened for brief review. Deferring to avoid scope creep. `CombinedDiffViewer` already has its own scroll cache; if DiffViewer needs one later it can import from `scroll-cache.ts`.
## Edge Cases
| Case | Behavior |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pending reveal (search nav) | Scroll restoration skipped; `performReveal` takes priority |
| First open (no saved position) | `undefined` in cache → no restoration, starts at top |
| Markdown mode switch (source → preview) | Mode-scoped keys (`path:source`, `path:preview`, `path:rich`) mean each mode preserves its own position independently. No cross-mode confusion. |
| Scroll at 0 | Always restore (including 0) to guard against Monaco auto-scroll behavior |
| External file content changes | **Known trade-off:** When external changes add/remove content above the viewport, the saved scroll position points to different content. Monaco clamps `scrollTop` if it exceeds the new document length, but does not adjust for insertions above the viewport. HTML containers (`MarkdownPreview`, `RichMarkdownEditor`) behave the same way — the browser clamps `scrollTop` to `scrollHeight - clientHeight`. This is acceptable: scroll position is a best-effort hint, not a semantic anchor. Fixing this would require mapping scroll offsets to content anchors (like line numbers), which is out of scope. |
| LRU eviction (>20 files) | Oldest scroll entry is evicted. User sees top-of-file on return to a very old tab — same as a fresh open. No data corruption or memory leak. |
| Close file, reopen | LRU may or may not still have the entry. If present, position restores. If evicted, starts at top. No explicit cleanup needed. |
| Async content (react-markdown, Tiptap) | RAF retry loop (up to 30 attempts) handles content that renders after the initial layout pass. Falls back to best-effort clamped position if content never reaches the target height. |
## Verification
1. Open a code file, scroll down ~50%, switch to another tab, switch back → verify position preserved
2. Open a markdown file in preview mode, scroll down, switch tabs, return → verify position preserved
3. Open a markdown file in rich mode, scroll down, switch tabs, return → verify position preserved
4. Switch a markdown file from source to preview mode → verify each mode has independent scroll position (scroll in source, switch to preview, preview starts at its own saved position or top)
5. Use Cmd+Shift+F to search, click a result → verify it scrolls to the match (NOT saved position)
6. Open 25+ files to trigger LRU eviction, return to the earliest file → verify it starts at top gracefully
7. `pnpm run typecheck` passes

View File

@ -1,654 +0,0 @@
# Design: Persist and Auto-Restore SSH Sessions Across App Restarts
**Issue:** [#985](https://github.com/stablyai/orca/issues/985)
**Branch:** `Jinwoo-H/persist-ssh-985`
**Author:** Jinwoo Hong
**Status:** Draft
## Problem
When Orca quits (intentionally, via update, or crash), all SSH connections and remote terminal sessions are lost. On restart, users must manually re-open each remote worktree, wait for SSH + relay redeploy, and re-start every remote process — even though the relay daemon on the remote host stays alive during its 5-minute grace window.
Three specific gaps:
1. **SSH connections are not persisted.** `buildWorkspaceSessionPayload()` in `src/renderer/src/lib/workspace-session.ts` captures terminal tabs, layouts, editor files, and browser tabs but has zero SSH state. `WorkspaceSessionState` has no fields for active connection IDs.
2. **Remote PTY session IDs are not persisted.** `reconnectPersistedTerminals()` in `src/renderer/src/store/slices/terminals.ts:1205` explicitly skips SSH-backed repos (`const supportsDeferredReattach = !repo?.connectionId`). Even if the relay keeps PTYs alive, Orca doesn't know their session IDs after restart.
3. **Shutdown doesn't serialize SSH state.** Neither `beforeunload` in `App.tsx` nor `will-quit` in `src/main/index.ts` records which SSH targets were connected or what remote PTY session IDs existed.
## Scope
**In scope (Layer 1 — app-bound lifetime):**
- Persist active SSH connection targets and remote PTY session IDs at shutdown
- Auto-reconnect SSH targets on startup
- Reattach remote PTYs via the relay's existing `pty.attach` RPC
- Make the relay grace window configurable via settings
**Out of scope (Layer 2 — future):**
- Background daemon owning SSH connections independent of app lifecycle
- Credential persistence (passphrases/passwords remain session-only)
## Existing Architecture
### What already works
| Component | File | Behavior |
|-----------|------|----------|
| Relay grace timer | `src/relay/relay.ts:15,70-76` | Keeps PTYs alive 5 min after client disconnect |
| Relay `pty.attach` | `src/relay/pty-handler.ts` | Replays buffered output on reattach |
| In-session reconnect | `src/main/ipc/ssh-relay-helpers.ts` | `reestablishRelayStack()` re-deploys relay, reattaches PTYs for transient drops |
| SSH target persistence | `src/main/persistence.ts:365-398` | Target configs (host, user, identity) survive restarts |
| Local PTY reattach | `src/renderer/src/store/slices/terminals.ts:1167-1266` | Daemon-mode local PTYs reattach via `pendingReconnectPtyIdByTabId` |
| PTY ownership tracking | `src/main/ipc/pty.ts` | `getPtyIdsForConnection()` maps PTY IDs to connection targets (in-memory) |
| SSH connection retry | `src/main/ssh/ssh-connection.ts` | Exponential backoff reconnection for transient errors |
### Connection lifecycle (current)
```
App Start → hydrate repos/worktrees → reconnectPersistedTerminals()
skips SSH repos (connectionId truthy)
SSH tabs show as disconnected
```
```
App Shutdown → beforeunload → buildWorkspaceSessionPayload()
captures local PTY IDs, tabs, layouts
does NOT capture SSH connection state
does NOT capture remote PTY session IDs
```
## Design
### 1. Extend WorkspaceSessionState
**File:** `src/shared/types.ts`
Add two new optional fields:
```typescript
export type WorkspaceSessionState = {
// ... existing fields ...
/** SSH target IDs that were connected at shutdown. Used on startup to
* auto-reconnect before attempting remote PTY reattach. */
activeConnectionIdsAtShutdown?: string[]
/** Maps tab IDs to their remote relay PTY session IDs. Mirrors
* pendingReconnectPtyIdByTabId but for SSH-backed terminals. Populated
* at shutdown from the main-process PTY ownership registry. */
remoteSessionIdsByTabId?: Record<string, string>
}
```
**File:** `src/shared/workspace-session-schema.ts`
Add corresponding Zod schemas:
```typescript
activeConnectionIdsAtShutdown: z.array(z.string()).optional(),
remoteSessionIdsByTabId: z.record(z.string(), z.string()).optional(),
```
### 2. Capture SSH state at shutdown
**File:** `src/renderer/src/lib/workspace-session.ts`
The `WorkspaceSessionSnapshot` type gains two new fields from AppState, and `buildWorkspaceSessionPayload()` populates the new session fields.
The renderer already has the data it needs — no IPC required. `tabsByWorktree` contains each tab's `ptyId`, and the renderer knows which worktrees are SSH-backed via `repo.connectionId`. Building the map renderer-side avoids a synchronous IPC call during `beforeunload` (which is fragile: sync IPC blocks the main process and can be dropped under time pressure on quit).
**File:** `src/renderer/src/lib/workspace-session.ts`
Add `sshConnectionStates` and `repos` / `worktreesByRepo` to the `WorkspaceSessionSnapshot` Pick type, then build both new fields in `buildWorkspaceSessionPayload()`:
```typescript
// Capture active SSH connections.
// Why: sshConnectionStates is a Map<string, SshConnectionState>, not a plain
// object. Object.entries() on a Map returns [] — must use Array.from().
const connectedTargetIds = Array.from(snapshot.sshConnectionStates.entries())
.filter(([, state]) => state.status === 'connected')
.map(([targetId]) => targetId)
// Build remote PTY session IDs from renderer state.
// Why: the renderer already has tab.ptyId for every terminal tab and knows
// which worktrees are SSH-backed via repo.connectionId. Deriving the map
// here avoids a sync IPC round-trip during beforeunload, which is fragile
// (can be dropped by Chromium under shutdown time pressure).
const remoteSessionIdsByTabId: Record<string, string> = {}
for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) {
const worktree = Object.values(snapshot.worktreesByRepo)
.flat()
.find((w) => w.id === worktreeId)
const repo = worktree
? snapshot.repos.find((r) => r.id === worktree.repoId)
: null
if (!repo?.connectionId) continue // not SSH-backed
for (const tab of tabs) {
if (tab.ptyId) {
remoteSessionIdsByTabId[tab.id] = tab.ptyId
}
}
}
return {
// ... existing fields ...
activeConnectionIdsAtShutdown: connectedTargetIds.length > 0
? connectedTargetIds : undefined,
remoteSessionIdsByTabId: Object.keys(remoteSessionIdsByTabId).length > 0
? remoteSessionIdsByTabId : undefined,
}
```
### 3. Auto-reconnect SSH on startup
**File:** `src/renderer/src/App.tsx`
Insert an SSH reconnect pass between workspace hydration and `reconnectPersistedTerminals()`. Targets are split into two categories:
1. **Non-passphrase targets** — reconnected eagerly at startup in parallel.
2. **Passphrase-protected targets** — deferred until the user focuses an SSH-backed terminal tab.
**Why defer passphrase targets:** Eagerly reconnecting passphrase-protected keys would pop credential dialogs before the user has context about which terminal is reconnecting. Stacking multiple passphrase prompts at startup is disorienting and error-prone (wrong passphrase entered for the wrong key). Deferring to tab focus lets the user see the terminal they're about to reconnect and provide the credential with full context.
```typescript
// After repos/worktrees hydrated, before terminal reconnect
const connectionIds = session.activeConnectionIdsAtShutdown ?? []
const SSH_RECONNECT_TIMEOUT_MS = 15_000
if (connectionIds.length > 0) {
// Partition targets: eagerly reconnect non-passphrase targets,
// defer passphrase-protected targets to tab focus.
// Why: use listTargets (which already has an IPC handler) and index
// by ID, rather than adding a per-target IPC call. Fetching all
// targets in one call is simpler and avoids adding a new ssh:getTarget
// handler that would only be used here.
const allTargets = await window.api.ssh.listTargets()
const targetMap = new Map(allTargets.map((t) => [t.id, t]))
const targets = connectionIds.map((targetId) => {
const target = targetMap.get(targetId)
return { targetId, needsPassphrase: target?.lastRequiredPassphrase ?? false }
})
const eagerTargets = targets.filter((t) => !t.needsPassphrase)
const deferredTargets = targets.filter((t) => t.needsPassphrase)
// Store deferred targets so on-demand reconnect can pick them up
if (deferredTargets.length > 0) {
actions.setDeferredSshReconnectTargets(
deferredTargets.map((t) => t.targetId)
)
}
// Reconnect eager targets in parallel with per-target timeout.
// Why: a per-target timeout prevents a single unreachable host from
// blocking the entire startup sequence. 15 seconds is long enough for
// a typical SSH handshake + relay redeploy over broadband, short enough
// to avoid the user staring at a frozen app.
await Promise.allSettled(
eagerTargets.map(({ targetId }) =>
Promise.race([
window.api.ssh.connect({ targetId }),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('SSH reconnect timeout')),
SSH_RECONNECT_TIMEOUT_MS)
)
]).catch((err) => {
console.warn(`SSH auto-reconnect failed for ${targetId}:`, err)
})
)
)
}
// Now proceed with terminal reconnect (which can now include SSH tabs)
await actions.reconnectPersistedTerminals(abortController.signal)
```
**On-demand reconnect for deferred (passphrase-protected) targets:**
When the user focuses a terminal tab backed by a deferred SSH target, the tab's mount logic checks `deferredSshReconnectTargets`. If the tab's `connectionId` is in that set, it triggers `ssh:connect` for that target, shows the passphrase dialog naturally, and on success proceeds with `pty.attach` for that tab's persisted session ID. The target is removed from the deferred set once connected (or after the user cancels).
```typescript
// In terminal pane mount / tab focus handler
const deferredTargets = get().deferredSshReconnectTargets ?? []
if (repo?.connectionId && deferredTargets.includes(repo.connectionId)) {
// Show reconnecting overlay on this tab (see Section 6)
try {
await window.api.ssh.connect({ targetId: repo.connectionId })
actions.removeDeferredSshReconnectTarget(repo.connectionId)
// Proceed to pty.attach with the persisted sessionId
} catch (err) {
// Leave tab in disconnected state with retry affordance
}
}
```
The `ssh:connect` IPC handler already exists and handles the full flow: SSH connect → relay deploy → provider registration. No new main-process code needed for the connect path.
**Error handling:** Each connection attempt is independent. Failed connections show the existing `SshDisconnectedDialog` with manual retry. Successful connections proceed to PTY reattach.
**Credential prompts:** For eager (non-passphrase) targets, the reconnect completes without user interaction. For deferred targets, the existing `SshPassphraseDialog` flow fires at tab focus — `ssh:credential-request` IPC is already wired.
### 4. Enable remote PTY reattach
**File:** `src/renderer/src/store/slices/terminals.ts`
The current gate at line 1205:
```typescript
const supportsDeferredReattach = !repo?.connectionId
```
Changes to:
```typescript
const supportsDeferredReattach = !repo?.connectionId || hasActiveConnection(repo.connectionId)
```
Where `hasActiveConnection` checks the renderer's `sshConnectionStates` store for `status === 'connected'`.
For SSH-backed tabs, the reattach data comes from the new `remoteSessionIdsByTabId` session field instead of `pendingReconnectPtyIdByTabId` (which is populated from the local daemon). The hydration step in `hydrateWorkspaceSession` needs to merge remote session IDs into `pendingReconnectPtyIdByTabId`.
**Critical placement constraint:** The existing `pendingReconnectPtyIdByTabId` population loop (lines 1090-1108) is guarded by `if (daemonEnabled)` and explicitly skips SSH-backed repos (`if (repo?.connectionId) { continue }`). The remote session ID merge must run **outside and after** that guard block — SSH PTY reattach does not depend on the local terminal daemon; it uses the relay's `pty.attach` RPC. If the merge is placed inside the `daemonEnabled` block, users without the experimental daemon flag will never have remote session IDs populated, silently breaking the entire reattach feature.
**File:** `src/renderer/src/store/slices/terminals.ts` — In `hydrateWorkspaceSession`, after the existing `if (daemonEnabled) { ... }` block (line 1108):
```typescript
// Existing code (unchanged):
const daemonEnabled = s.settings?.experimentalTerminalDaemon === true
const pendingReconnectPtyIdByTabId: Record<string, string> = {}
if (daemonEnabled) {
// ... existing loop for local daemon session IDs (skips SSH repos) ...
}
// NEW: merge remote PTY session IDs from SSH persistence.
// Why: this runs outside the daemonEnabled guard because remote PTY reattach
// uses the relay's pty.attach RPC, not the local terminal daemon. SSH-backed
// tabs need their session IDs regardless of the experimentalTerminalDaemon
// setting. The existing loop above correctly skips SSH repos (connectionId
// check), so there is no overlap — local daemon IDs and remote session IDs
// are mutually exclusive per-tab.
const remoteSessionIds = session.remoteSessionIdsByTabId ?? {}
for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) {
if (validTabIds.has(tabId)) {
pendingReconnectPtyIdByTabId[tabId] = sessionId
}
}
```
The inner guard in `reconnectPersistedTerminals` (`if (supportsDeferredReattach && tabLevelPtyId)`) works correctly for SSH tabs because `tabLevelPtyId` is now populated by this merge, and `supportsDeferredReattach` is `true` for connected SSH targets.
**File:** `src/main/providers/ssh-pty-provider.ts` — Modify `spawn()` to detect sessionId:
The current `SshPtyProvider.spawn()` unconditionally calls `pty.spawn` and ignores `opts.sessionId`. This is the critical missing link — without this change, passing a sessionId through `connectPanePty` has no effect on the remote relay.
```typescript
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
// Why: when sessionId is present, the caller is requesting reattach to an
// existing relay PTY (persisted across app restart). Calling pty.spawn would
// create a new shell and discard the buffered output the relay kept alive
// during the grace window. pty.attach replays that buffer instead.
if (opts.sessionId) {
try {
await this.mux.request('pty.attach', {
id: opts.sessionId,
cols: opts.cols,
rows: opts.rows
})
return { id: opts.sessionId }
} catch (err) {
// Why: pty.attach fails when the relay grace window has elapsed and the
// PTY no longer exists (relay returns "not found"). Without this catch,
// the error propagates as an unhandled rejection and the terminal tab
// stays stuck. Falling through to pty.spawn gives the user a fresh
// shell, and sessionExpired lets the renderer show a brief toast
// ("Session expired — new shell started") so the user understands why
// their scrollback is gone.
console.warn(`[ssh-pty] pty.attach failed for ${opts.sessionId}, falling back to fresh spawn:`, err)
}
}
const result = await this.mux.request('pty.spawn', {
cols: opts.cols,
rows: opts.rows,
cwd: opts.cwd,
env: opts.env
})
// Why: sessionExpired is only set when we attempted reattach and it failed.
// The renderer checks this flag to show the "Session expired" toast. When
// sessionId was never provided (fresh terminal), this field is omitted.
return {
...(result as PtySpawnResult),
...(opts.sessionId ? { sessionExpired: true } : {})
}
}
```
The `PtySpawnResult` type (in `src/main/providers/types.ts`) needs a new optional field:
```typescript
export type PtySpawnResult = {
id: string
/** True when the caller requested reattach (sessionId was provided) but the
* relay PTY was gone (grace window elapsed). The renderer uses this to show
* a brief "Session expired — new shell started" toast. */
sessionExpired?: boolean
}
```
This routes through to the relay's existing `pty.attach` RPC, which replays buffered output via the `pty.replay` notification.
**File:** `src/main/ipc/ssh-relay-helpers.ts` — Wire `onReplay` in `wireUpSshPtyEvents()`:
The current `wireUpSshPtyEvents()` only wires `onData` and `onExit`. It does **not** wire `onReplay`. After `pty.attach`, the relay sends a `pty.replay` notification with buffered output, but without this wiring the replay data is silently dropped and the terminal renders blank after reattach.
Why use a dedicated `pty:replay` IPC channel (not `pty:data`): the renderer's `pty-transport.ts` has a `replayingBufferedData` flag that suppresses xterm auto-replies to embedded terminal query sequences (e.g., `\x1b[6n` for cursor position reports) during replay. If relay replay data arrives via the regular `pty:data` channel, this flag is **not set** — xterm processes the data as normal output and auto-replies leak into the remote shell as stray input, potentially executing unintended commands. A dedicated `pty:replay` channel lets the renderer route replay through the guarded `onReplayData` callback, which suppresses auto-replies.
```typescript
export function wireUpSshPtyEvents(
ptyProvider: SshPtyProvider,
getMainWindow: () => BrowserWindow | null
): void {
ptyProvider.onData((payload) => {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:data', payload)
}
})
// Why: after pty.attach, the relay sends pty.replay with buffered output
// from the grace window. Without this, reattached terminals render blank.
// Uses a dedicated pty:replay channel (not pty:data) so the renderer can
// route it through the replay-guarded onReplayData callback, which
// suppresses xterm auto-replies to embedded query sequences.
ptyProvider.onReplay((payload) => {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:replay', payload)
}
})
ptyProvider.onExit((payload) => {
clearProviderPtyState(payload.id)
deletePtyOwnership(payload.id)
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:exit', payload)
}
})
}
```
The renderer needs a corresponding `pty:replay` handler in `pty-dispatcher.ts` that routes through the existing `onReplayData` callback (same path used by the local daemon's eager buffer replay). This ensures `replayingBufferedData` is set and xterm auto-replies are suppressed during relay replay.
The preload bridge (`src/preload/index.ts`) needs a new `onReplay` binding alongside the existing `onData`:
```typescript
pty: {
// ... existing bindings ...
onReplay: (callback: (payload: PtyDataPayload) => void) =>
ipcRenderer.on('pty:replay', (_event, payload) => callback(payload)),
}
```
Then in `reconnectPersistedTerminals`, when the SSH connection is confirmed active, the existing `pty.spawn` call in `connectPanePty` receives the `sessionId` which routes through `SshPtyProvider.spawn()` → relay `pty.attach()`, replaying buffered output.
**Wait for SSH before reattach:** The key ordering constraint is that `reconnectPersistedTerminals` must run *after* SSH connections are established. The sequential await in App.tsx (step 3) guarantees this.
### 5. Configurable relay grace window
**File:** `src/shared/ssh-types.ts` — Extend `SshTarget`:
```typescript
export type SshTarget = {
// ... existing fields ...
/** Grace period in seconds before relay shuts down after disconnect.
* Default: 300 (5 minutes). */
relayGracePeriodSeconds?: number
/** Set to true after a successful connection that triggered a credential
* prompt (passphrase or password), false after one that didn't. Persisted
* with the target config so the startup reconnect logic can partition
* targets into eager (no passphrase) vs deferred (passphrase) without
* attempting a connection first.
*
* Why a persisted flag instead of inspecting the key at startup: detecting
* whether a key is encrypted requires attempting to load it, which is
* expensive and may itself trigger an OS keychain prompt. Recording the
* outcome of the last successful connection is both cheaper and more
* accurate (it reflects the user's actual auth experience). */
lastRequiredPassphrase?: boolean
}
```
**Population:** The `lastRequiredPassphrase` flag is set during `ssh:connect` success handling in the main process. When `SshConnection.connect()` succeeds, the handler checks whether a credential prompt was issued during the attempt (the `onCredentialRequest` callback was invoked). If yes, the flag is set to `true` on the target and persisted; if no, it is set to `false`. This ensures the flag stays up to date as users add/remove passphrases from their keys.
**File:** `src/main/ssh/ssh-relay-deploy.ts` — Pass grace time to relay launch:
```typescript
const graceSeconds = target.relayGracePeriodSeconds ?? 300
const command = `${relayPath} --grace-time ${graceSeconds}`
```
**File:** `src/renderer/src/components/settings/SshTargetForm.tsx` — Add UI field with reasonable bounds (60s3600s).
The relay already accepts `--grace-time` CLI arg (`src/relay/relay.ts:17-29`).
## Sequence Diagram
### Shutdown
```
Renderer Main Process
│ │
├─ beforeunload fires │
├─ capture terminal buffers │
├─ build session payload │
│ (derives remoteSessionIdsByTabId from
│ Zustand state: tabsByWorktree + repo.connectionId;
│ derives activeConnectionIdsAtShutdown from
│ sshConnectionStates — no IPC needed)
├─ session:set-sync ────────────►├─ write to orca-data.json
│ ├─ flush()
└─ window closes └─ SSH connections drop
relay grace timer starts
```
### Startup (eager targets — no passphrase)
```
Renderer Main Process Remote Relay
│ │ │
├─ session:get ─────────────────►├─ read orca-data.json │
│◄─ session ────────────────────┤ │
├─ hydrate repos, tabs, layouts │ │
├─ partition targets: │ │
│ eager vs deferred │ │
│ │ │
├─ ssh:connect(eager1) ─────────►├─ SSH handshake ────────►│
│ (15s timeout per target) ├─ deploy relay ─────────►├─ READY
│ ├─ register roots │
│◄─ connected ──────────────────┤ │
│ │ │
├─ reconnectPersistedTerminals() │ │
│ (SSH tabs no longer skipped │ │
│ for connected targets) │ │
│ │ │
├─ connectPanePty(sessionId) ───►├─ SshPtyProvider.spawn() │
│ │ detects sessionId │
│ ├─ pty.attach(id) ──────►├─ replay buffer
│◄─ pty.replay ─────────────────┤◄─ pty.replay ──────────┤
│ terminal shows restored │ │
│ scrollback + live shell │ │
```
### Startup (deferred targets — passphrase-protected)
```
Renderer Main Process Remote Relay
│ │ │
│ (passphrase targets stored │ │
│ in deferredSshReconnectTargets) │
│ │ │
├─ user focuses SSH tab ─────────│ │
│ tab shows "Reconnecting..." │ │
│ overlay │ │
├─ ssh:connect(deferred1) ──────►├─ SSH handshake │
│ ├─ ssh:credential-request │
│◄─ passphrase dialog ──────────┤ │
├─ user enters passphrase ──────►├─ auth continues ───────►│
│ ├─ deploy relay ─────────►├─ READY
│◄─ connected ──────────────────┤ │
│ │ │
├─ connectPanePty(sessionId) ───►├─ pty.attach(id) ──────►├─ replay buffer
│◄─ pty.replay ─────────────────┤◄─ pty.replay ──────────┤
│ overlay removed, terminal │ │
│ shows restored scrollback │ │
```
## Per-Tab Reconnect Status
During SSH reconnection and PTY reattach, individual terminal tabs need visual feedback so the user knows what's happening and which tabs are still in progress.
**Reconnecting state:** While an SSH target is being reconnected (either eagerly at startup or on-demand at tab focus), each affected terminal tab shows a semi-transparent overlay with a spinner and the text "Reconnecting to [host]...". The terminal content beneath is dimmed but visible — the user can see their previous scrollback through the overlay.
**Reattaching state:** After SSH connects but before `pty.attach` completes and replays the buffer, the overlay updates to "Reattaching session...". This is typically brief (<1 second) but visible on high-latency connections.
**Success:** The overlay is removed and the terminal receives the replayed buffer. No toast or notification — the terminal being live is sufficient signal.
**Failure — target failed:** If the SSH connection fails (timeout, network error, cancelled passphrase), the overlay is replaced with a persistent banner: "SSH connection failed — [reason]" with a "Retry" button. The terminal content remains visible but dimmed and non-interactive.
**Failure — session expired:** If SSH connects but `pty.attach` returns `not-found` (grace window elapsed), the tab shows a brief inline message "Session expired — new shell started" and spawns a fresh PTY. The message auto-dismisses after 5 seconds.
## Partial Failure Behavior
When multiple SSH targets are reconnected at startup, some may succeed and others may fail. The user needs clear per-tab attribution of which connections failed:
- **Tabs backed by a successful target:** Proceed to PTY reattach normally. No special indicator — they work as expected.
- **Tabs backed by a failed target:** Show the persistent failure banner described above. The banner includes the target hostname so the user can identify which remote host is unreachable.
- **Tabs backed by a deferred (passphrase) target:** Show a static overlay: "Waiting for connection — focus this tab to reconnect". This distinguishes intentionally-deferred tabs from failed tabs.
This ensures the user can scan their tab bar and immediately tell which terminals are live, which need attention, and which are waiting for credentials.
## Data Flow Diagrams
### Path 1: Happy path (restart within grace window)
```
SHUTDOWN:
Zustand state ──► buildWorkspaceSessionPayload() ──► orca-data.json
(tabsByWorktree, (derives remoteSessionIdsByTabId, (persisted)
sshConnectionStates, activeConnectionIdsAtShutdown)
repos, worktreesByRepo)
STARTUP:
orca-data.json ──► hydrateWorkspaceSession() ──► ssh:connect(eager) ──► reconnectPersistedTerminals()
(read) (merges remoteSessionIds (SSH + relay up) (pty.attach via SshPtyProvider)
into pendingReconnectPtyIdByTabId) ──► pty.replay ──► terminal live
```
### Path 2: Nil path (no SSH state persisted)
```
SHUTDOWN:
No SSH connections active ──► buildWorkspaceSessionPayload() ──► orca-data.json
(activeConnectionIdsAtShutdown (no SSH fields)
omitted, remoteSessionIdsByTabId
omitted)
STARTUP:
orca-data.json ──► hydrateWorkspaceSession() ──► reconnectPersistedTerminals()
(no SSH fields) (no remote IDs to merge) (SSH tabs still skipped — connectionId
but no active connection)
```
### Path 3: Grace expired (restart after relay grace window)
```
SHUTDOWN:
(same as happy path — SSH state persisted to orca-data.json)
STARTUP:
orca-data.json ──► ssh:connect() ──► relay redeploy ──► pty.attach(sessionId)
(SSH succeeds) (new relay instance; ──► NOT FOUND
old PTYs gone) ──► spawn fresh PTY
──► "Session expired" message
```
### Path 4: Error path (SSH connect fails)
```
SHUTDOWN:
(same as happy path — SSH state persisted to orca-data.json)
STARTUP:
orca-data.json ──► ssh:connect() ──► TIMEOUT/ERROR (15s)
──► tab shows failure banner
──► reconnectPersistedTerminals()
skips tabs for failed targets
(no active connection)
──► user retries manually via banner
```
## Migration & Backwards Compatibility
- New session fields are optional (`z.optional()`), so older sessions parse without error
- Older Orca versions ignore unknown fields, so a downgrade doesn't break
- If `activeConnectionIdsAtShutdown` references a deleted SSH target, the connect call fails gracefully and the tab shows as disconnected
- If `remoteSessionIdsByTabId` references a PTY that expired (grace window elapsed), `pty.attach` returns `not-found` and the tab spawns a fresh shell
## Edge Cases
| Scenario | Behavior |
|----------|----------|
| Relay grace expired before restart | `pty.attach` fails → tab spawns fresh PTY, shows "Session expired — new shell started" |
| SSH target deleted between shutdown and startup | `ssh:connect` fails → tabs for that target remain disconnected, user sees reconnect dialog |
| Network unreachable on startup | `ssh:connect` times out → existing retry/backoff kicks in, shows reconnecting status |
| Passphrase-protected key | Target deferred to on-demand reconnect at tab focus; passphrase dialog fires in context of the specific terminal |
| Multiple SSH targets | Non-passphrase targets reconnect eagerly in parallel via `Promise.allSettled`; passphrase targets deferred; partial success is fine |
| Per-target timeout exceeded | Target treated as failed after 15s; tabs show disconnected state with manual retry affordance |
| Crash (no beforeunload) | No session captured → same as today (no regression). Periodic save (future improvement) could mitigate |
| App update with relay version bump | Relay redeploy triggers naturally; old PTYs lost (acceptable — version change implies incompatibility) |
## Testing Strategy
### Unit Tests
- `workspace-session.test.ts`: Verify `buildWorkspaceSessionPayload` includes SSH fields when connections are active and omits them when none are
- `workspace-session-schema.test.ts`: Verify schema parses sessions with/without new optional fields
- `terminals.test.ts`: Verify `reconnectPersistedTerminals` processes SSH-backed tabs when connection is active and skips when not
### Integration Tests
- `store-session-cascades.test.ts`: Extend existing local daemon reattach test suite (lines 660-960) to cover SSH-backed tabs
- `terminal-restart-persistence.spec.ts`: Add E2E scenario for SSH terminal restore
### Manual Test Plan
1. Connect to SSH target with 2+ terminal tabs running long processes
2. Quit Orca
3. Relaunch within grace window → verify SSH reconnects, terminals show scrollback, processes still running
4. Quit Orca, wait past grace window, relaunch → verify SSH reconnects, terminals show "session expired" and spawn fresh shells
5. Quit Orca with passphrase-protected key → verify tabs show "Waiting for connection" overlay, focus a tab → verify passphrase dialog, enter passphrase → verify terminal restores
6. Kill Orca (force quit) → verify no crash on next launch (no session saved, clean fallback)
## Files to Modify
| File | Change |
|------|--------|
| `src/shared/types.ts` | Add `activeConnectionIdsAtShutdown`, `remoteSessionIdsByTabId` to `WorkspaceSessionState` |
| `src/shared/ssh-types.ts` | Add `relayGracePeriodSeconds` and `lastRequiredPassphrase` to `SshTarget` |
| `src/shared/workspace-session-schema.ts` | Add Zod schemas for new fields |
| `src/main/providers/ssh-pty-provider.ts` | Modify `spawn()` to detect `sessionId`, call `pty.attach` with try/catch fallback to `pty.spawn` |
| `src/main/providers/types.ts` | Add `sessionExpired?: boolean` to `PtySpawnResult` |
| `src/main/ipc/ssh-relay-helpers.ts` | Wire `onReplay` in `wireUpSshPtyEvents()` to forward replay data via dedicated `pty:replay` IPC channel |
| `src/preload/index.ts` | Add `pty.onReplay` binding for the new `pty:replay` IPC channel |
| `src/renderer/src/lib/pty-dispatcher.ts` | Add `pty:replay` handler that routes through replay-guarded `onReplayData` callback |
| `src/main/ipc/ssh.ts` | Set `lastRequiredPassphrase` on target after successful `ssh:connect` based on whether credential prompt was triggered |
| `src/renderer/src/lib/workspace-session.ts` | Add `sshConnectionStates`, `repos`, `worktreesByRepo` to snapshot; build `remoteSessionIdsByTabId` from renderer state |
| `src/renderer/src/App.tsx` | Add SSH reconnect pass (eager + deferred) before `reconnectPersistedTerminals()` |
| `src/renderer/src/store/slices/terminals.ts` | Remove SSH skip gate; merge remote session IDs into pending reconnect map; add `deferredSshReconnectTargets` state + actions |
| `src/main/ssh/ssh-relay-deploy.ts` | Pass configurable grace time to relay |
| `src/renderer/src/components/settings/SshTargetForm.tsx` | Add grace period setting UI |

View File

@ -1,110 +0,0 @@
# Design: Persist Task Source Preference (GitHub / Linear)
## Problem
The Tasks button in the sidebar always defaults to GitHub when clicked without an explicit source. If a user switches to Linear inside the Task page, that choice is lost when they navigate away and return. Users who primarily use Linear must switch every time.
## Goal
Remember which task source (GitHub or Linear) the user last selected and default to it the next time they open the Tasks page without an explicit source override.
## Current Behavior
1. **SidebarNav.tsx** — The main Tasks button calls `openTaskPage()` with no arguments. The small GitHub/Linear icons call `openTaskPage({ taskSource: 'github' })` or `openTaskPage({ taskSource: 'linear' })` respectively.
2. **ui.ts (store)**`openTaskPage(data = {})` stores `data` into `taskPageData`. No `taskSource` field is persisted.
3. **TaskPage.tsx (line 597)** — Initializes source with `useState<TaskSource>(pageData.taskSource ?? 'github')`. The hardcoded `'github'` fallback is the root cause.
4. **TaskPage.tsx (line 1259)** — In-page source toggle calls `setTaskSource(source.id)` — local state only, never persisted.
## Proposed Solution
Piggyback on the existing `GlobalSettings` persistence pattern already used by `defaultTaskViewPreset` and `defaultRepoSelection`.
### Changes
#### 1. `src/shared/types.ts` — Add setting field
Add `defaultTaskSource: 'github' | 'linear'` to the `GlobalSettings` type, alongside the existing `defaultTaskViewPreset` field (~line 818). Use the inline union (matching the existing `taskPageData` pattern in `ui.ts`) rather than importing the file-local `TaskSource` alias from TaskPage.
#### 1b. `src/shared/constants.ts` — Add default value
Add `defaultTaskSource: 'github'` to the `GlobalSettings` defaults object (adjacent to `defaultTaskViewPreset: 'all'` ~line 158).
#### 2. `src/renderer/src/components/TaskPage.tsx` — Read and write the preference
**Read (line 597):** Change the fallback from the hardcoded `'github'` to the persisted setting:
```ts
// Before
const [taskSource, setTaskSource] = useState<TaskSource>(pageData.taskSource ?? 'github')
// After
const defaultTaskSource = settings?.defaultTaskSource ?? 'github'
const [taskSource, setTaskSource] = useState<TaskSource>(pageData.taskSource ?? defaultTaskSource)
```
**Sync effect (after line 606):** `settings` may be `null` on first render (async hydration). Add a sync effect so the persisted preference applies once settings arrive, mirroring the existing `pageData.taskSource` sync pattern on lines 602-606:
```ts
// Why: settings load asynchronously — the useState initializer may
// capture null settings on fast navigation. Sync once settings arrive,
// but only when no explicit source was passed via sidebar icon click.
useEffect(() => {
if (!pageData.taskSource && settings?.defaultTaskSource) {
setTaskSource(settings.defaultTaskSource)
}
}, [settings?.defaultTaskSource, pageData.taskSource])
```
**Write (line 1259):** When the user toggles the source inside the page, persist the choice:
```ts
// Before
onClick={() => setTaskSource(source.id)}
// After
onClick={() => {
setTaskSource(source.id)
void updateSettings({ defaultTaskSource: source.id }).catch(() => {
toast.error('Failed to save default task source.')
})
}}
```
This mirrors the exact pattern used by `handleSetDefaultTaskPreset` (line 859-868).
#### 3. Settings initialization / migration
Wherever `GlobalSettings` defaults are constructed (the main process settings loader), add `defaultTaskSource: 'github'` so existing users get the current behavior with no migration needed. The field is optional in the type — missing means `'github'`.
### No changes needed
- **SidebarNav.tsx** — No changes. The main Tasks button continues to call `openTaskPage()` with no `taskSource`. The explicit GitHub/Linear icons continue to pass their source. TaskPage handles the default resolution.
- **ui.ts store** — No changes. `taskPageData` remains ephemeral. The preference is a setting, not UI state.
- **Prefetch logic** — The prefetch in `openTaskPage` and `SidebarNav` only warms GitHub work items. This is fine — Linear fetches are fast and don't benefit from the same prefetch pattern. Optionally, we could skip the GitHub prefetch when the saved source is Linear, but that's a separate optimization.
## Behavior Matrix
| Action | Result |
|---|---|
| Click main Tasks button (no saved pref) | Opens GitHub (backward-compatible default) |
| Click main Tasks button (saved pref = linear) | Opens Linear |
| Click GitHub icon in sidebar | Opens GitHub (does not change saved preference) |
| Click Linear icon in sidebar | Opens Linear (does not change saved preference) |
| Toggle source inside TaskPage | Switches view, saves new preference |
| Fresh install / no setting | Defaults to `'github'` |
## Scope
- ~20 lines of code across 3 files (`types.ts`, `constants.ts`, `TaskPage.tsx`)
- No new IPC channels
- No migrations
- No UI changes — only behavior change is remembering the last choice
## Resolved Questions
1. **Should clicking the sidebar GitHub/Linear icons also persist the preference?** No — sidebar icons are pure navigation ("go to GitHub tasks" / "go to Linear tasks"). Persisting from a one-off exploratory click would surprise users. Only the in-page toggle persists, keeping the mental model clean: sidebar icons = navigate, in-page toggle = set preference.
2. **Should we skip GitHub prefetch when saved source is Linear?** Deferred to a follow-up. The prefetch is cheap and doesn't hurt.

View File

@ -1,192 +0,0 @@
# Design: Share Text Search Logic Between Local Main and SSH Relay
**Branch:** `fix-ssh-keywords-search`
**Status:** Draft
## Problem
The right-sidebar keywords search (`fs:search`) and the Cmd+P quick-open file search historically shared no code between the local main process and the SSH relay. Each side reinvented: rg argument construction, rg `--json` stdout parsing, the git-grep fallback, the submatch regex, the `SearchFileResult` accumulator, and the "kill previous search on new query" logic.
This drift already caused one user-visible bug: the relay's `searchWithRg` in `src/relay/fs-handler-utils.ts:139` uses `execFile('rg', ..., { maxBuffer: 50 * 1024 * 1024 })`. `execFile` buffers stdout internally and kills the child when `maxBuffer` is exceeded, even when `data` listeners are attached. Under rg's `--json` output (one verbose JSON object per match), 50MB fills well before the match cap in large folders. The `child.once('error', () => resolveOnce())` then silently resolves with whatever was accumulated — users see "some files can't be found" with no error.
The local handler at `src/main/ipc/filesystem.ts:402` uses `wslAwareSpawn` (plain `spawn`) and has never had this bug. The two paths must not be allowed to drift again.
## Scope
**In scope:**
- Extract rg + git-grep search logic into `src/shared/text-search.ts`, matching the pattern already established by `src/shared/quick-open-filter.ts` for listFiles.
- Remove the `execFile`/`maxBuffer` footgun from the relay path.
- Unify the accumulator, truncation semantics, and submatch regex construction.
**Out of scope:**
- Changing the `fs.search` request shape or existing `SearchResult`/`SearchOptions` fields.
- Adding new search features (multiline, semantic, etc).
- Changing how quick-open lists files (already shared via `quick-open-filter.ts`).
- Re-homing WSL path translation — that stays in the local main process; the relay never sees WSL paths.
## Existing Code Map
| Concern | Local (main) | Remote (relay) |
|---|---|---|
| rg `--json` run + parse | `src/main/ipc/filesystem.ts:286-441` (inline in IPC handler) | `src/relay/fs-handler-utils.ts:78-231` (`searchWithRg`) |
| git-grep fallback | `src/main/ipc/filesystem-search-git.ts:43-220` (`searchWithGitGrep`) | `src/relay/fs-handler-git-fallback.ts:140-297` (`searchWithGitGrep`) |
| rg availability check | `src/main/ipc/rg-availability.ts` (`checkRgAvailable`) | `src/relay/fs-handler-utils.ts:241-260` (`checkRgAvailable`) |
| rg arg construction | inline in filesystem.ts | inline in fs-handler-utils.ts |
| git-grep arg construction | inline in filesystem-search-git.ts | inline in fs-handler-git-fallback.ts |
| Submatch regex | `filesystem-search-git.ts:115-119` | `fs-handler-git-fallback.ts:200-204` |
| Accumulator (fileMap, totalMatches, truncated) | duplicated in all four files | duplicated in all four files |
| Relative-path normalization | `normalizeRelativePath` (collapses `\\`/`/`, strips leading slashes) | plain `.replace(/\\/g, '/')` |
| git-grep signature | `searchWithGitGrep(rootPath, args, maxResults)` (maxResults positional) | `searchWithGitGrep(rootPath, query, opts)` (maxResults inside opts) |
| Process spawn | `wslAwareSpawn` (local) / `gitSpawn` (local) | `execFile` (rg, buggy) / `spawn` (git) |
Net duplicate: ~400 lines across four files that do nearly the same thing.
## Design
### New module: `src/shared/text-search.ts`
Pure, IO-agnostic helpers. No Electron, no child_process, no fs. Mirrors `quick-open-filter.ts` — the caller owns process execution and transport-specific path quirks.
```ts
// Types (re-exported from shared/types or defined here)
export type SearchAccumulator = {
fileMap: Map<string, SearchFileResult>
totalMatches: number
truncated: boolean
}
export function createAccumulator(): SearchAccumulator
// ── rg ─────────────────────────────────────────────────────────────
// Returns the full argv including '--', query, and target. Both callers
// pass `rootPath` unchanged as the target — the local side does NOT
// translate the target to a WSL-native path. WSL only affects the
// invocation (via `wslAwareSpawn`) and the *output* paths rg emits,
// which the caller translates back via `transformAbsPath` below.
export function buildRgArgs(
query: string,
target: string,
opts: SearchOptions
): string[]
// Ingest one rg --json stdout line. Mutates `acc`. Returns 'continue'
// or 'stop' (stop = totalMatches hit maxResults). Takes an optional
// path transform so the local caller can apply WSL translation.
export function ingestRgJsonLine(
line: string,
rootPath: string,
acc: SearchAccumulator,
maxResults: number,
transformAbsPath?: (p: string) => string
): 'continue' | 'stop'
// ── git grep ───────────────────────────────────────────────────────
// Also owns include/exclude glob → git pathspec translation
// (`toGitGlobPathspec`), which today is duplicated inline in both
// `filesystem-search-git.ts` and `fs-handler-git-fallback.ts`.
export function buildGitGrepArgs(
query: string,
opts: SearchOptions
): string[]
// Build the submatch regex used to locate column positions within a
// matched line (git grep only reports the first hit per line).
export function buildSubmatchRegex(
query: string,
opts: { useRegex?: boolean; wholeWord?: boolean; caseSensitive?: boolean }
): RegExp
export function ingestGitGrepLine(
line: string,
rootPath: string,
submatchRegex: RegExp,
acc: SearchAccumulator,
maxResults: number
): 'continue' | 'stop'
// ── finalize ───────────────────────────────────────────────────────
export function finalize(acc: SearchAccumulator): SearchResult
```
### What stays environment-specific
| Stays local | Stays in relay |
|---|---|
| `wslAwareSpawn`, `gitSpawn` | plain `spawn` |
| `parseWslPath` / `toWindowsWslPath` transform passed to `ingestRgJsonLine` | no-op transform |
| `activeTextSearches` kill-on-new-query map keyed by `sender.id` | single-search-at-a-time per client (already one channel) |
| `resolveAuthorizedPath` | `context.validatePathResolved` |
| `checkRgAvailable` wrapping `wslAwareSpawn` (accepts a `searchPath` for WSL resolution) | `checkRgAvailable` wrapping plain `execFile` (no WSL) |
### Call sites after refactor
**`src/main/ipc/filesystem.ts`** shrinks from ~180 lines of search logic to ~40:
```
const rgAvailable = await checkRgAvailable(rootPath)
if (!rgAvailable) return searchWithGitGrep(rootPath, args, maxResults)
const acc = createAccumulator()
const rgArgs = buildRgArgs(args.query, rootPath, args)
const child = wslAwareSpawn('rg', rgArgs, { cwd: rootPath, stdio: ... })
activeTextSearches.get(searchKey)?.kill()
activeTextSearches.set(searchKey, child)
// stream stdout → ingestRgJsonLine(line, rootPath, acc, maxResults, wslTransform)
// on 'stop' → child.kill()
// on close/error → resolve(finalize(acc))
// timeout → set acc.truncated = true, child.kill()
```
**`src/main/ipc/filesystem-search-git.ts`** becomes a thin wrapper around `buildGitGrepArgs` + `ingestGitGrepLine`. File drops from 220 → ~80 lines.
**`src/relay/fs-handler.ts::search`** and the relay's rg/git-grep helpers: identical shape to the local caller, minus the WSL transform and `activeTextSearches` tracking. `searchWithRg` in `fs-handler-utils.ts` is deleted; its callers inline the spawn loop or we keep a thin relay-side wrapper (`src/relay/fs-handler-search.ts`).
**Critical:** the relay's rg caller uses `spawn`, not `execFile`. This alone fixes the reported bug.
### Signature + path normalization (unified)
The shared helpers settle two small existing asymmetries:
- git-grep callers in main and relay take `maxResults` differently today (third positional arg vs. folded into opts). The shared `buildGitGrepArgs` / `ingestGitGrepLine` take `maxResults` on the accumulator-ingest side only, so callers no longer invent their own shape.
- Relative-path normalization is unified on `normalizeRelativePath` (collapse mixed separators, strip leading slashes). The relay's plain `replace(/\\/g, '/')` is replaced, removing a drift seam that would surface the first time someone passed a path with a leading slash through the relay.
### Truncation semantics (unified)
One rule: `acc.truncated = true` if and only if rg/git-grep would have emitted more matches after we stopped consuming. Specifically:
- `maxResults` reached while processing submatches for a match record → truncated.
- Kill-timeout fires → truncated.
- rg/git-grep exits with non-zero status → *not* truncated; this is a clean "no results or early termination" path. (Matches current local behavior.)
This removes the existing inconsistency where the relay's `execFile` maxBuffer overflow silently returned `truncated: false` despite dropping matches.
**Ordering invariant (do not break during migration).** Today the caller flips `truncated = true` synchronously in the same tick it calls `child.kill()`, before the `close` handler resolves the promise. The shared module must preserve that ordering: `ingestRgJsonLine` / `ingestGitGrepLine` mutate `acc.truncated` synchronously when they return `'stop'`, and the caller must kill the child *after* that mutation. If a naive refactor moves the kill inside the helper but leaves `truncated` setting in the caller — or vice versa — a `close` event can resolve the promise with `truncated: false` even though matches were dropped. This is the exact silent-truncation footgun the refactor is meant to kill; regressing it reintroduces the original bug in a harder-to-spot form.
### Regex parity
`buildSubmatchRegex` centralizes the "escape literal query, wrap in `\b` for whole-word, add `gi` flags" logic currently in two files. Includes the zero-length-match guard (`matchRegex.lastIndex++` when `m[0].length === 0`) that the relay version also has but that would regress if one side is touched without the other.
## Migration Plan
1. **Land the shared module with tests.** Unit tests live at `src/shared/text-search.test.ts`, modeled on `src/shared/quick-open-filter.test.ts`. Cover: arg construction (every flag combination), rg JSON line ingestion (match/non-match/malformed/multi-submatch/maxResults boundary), git-grep line parsing (null-byte delimiter, colons in filenames, unicode, zero-length regex), and finalize shape.
2. **Migrate the local path.** Replace the inline rg loop in `filesystem.ts` and rewrite `filesystem-search-git.ts` to use the shared helpers. Existing `filesystem-list-files.test.ts` + `filesystem.test.ts` catch regressions; add a test specifically for the `execFile``spawn` equivalence (large result set that would have overflowed 50MB under `execFile`).
3. **Migrate the relay path.** Replace `searchWithRg` (`execFile` → `spawn`) and consolidate `fs-handler-git-fallback.ts`'s search half. Delete `searchWithRg` and the relay's git-grep duplicate once the last caller is gone.
4. **Drift guard.** Add a short comment at the top of `shared/text-search.ts` pointing to this design doc and naming both call sites. The existing comment at the top of `shared/quick-open-filter.ts` is the template.
## Non-goals / Explicit Non-changes
- **Not merging `checkRgAvailable`.** Both versions already agreed (no caching — see the "Why no cache" comments in `src/main/ipc/rg-availability.ts` and `src/relay/fs-handler-utils.ts`), but they wrap different spawn primitives: the local side runs through `wslAwareSpawn` and accepts a `searchPath` so WSL distro resolution works, while the relay uses plain `execFile`. Sharing would force one side to import the other's spawn wrapper. Two thin files, one contract.
- **Not unifying spawn.** `wslAwareSpawn` and `gitSpawn` carry WSL and git-auth concerns the relay has no business with.
- **Not changing the request shape or existing result fields.** `SearchOptions` stays as-is, and
`SearchResult` keeps the same required fields. Long-line clamping may add optional display-only
coordinates to a match so the sidebar can highlight bounded snippets without corrupting the
source `column`/`matchLength` used for editor reveal.
- **Not touching the renderer.** `right-sidebar/Search.tsx` and `QuickOpen.tsx` are unchanged.
## Risks
- **Test coverage for relay search is thin.** Current tests exercise the local path. Plan: port the new shared-module tests plus add a relay-specific integration check (`fs-handler` test that streams a large mock rg stdout through the `spawn`-based loop to confirm no drop).
- **Behavioral parity is not 1:1 today.** The local path has WSL translation; the relay does not. Parity we're preserving is output *shape*, not output *paths*. Tests must not assume absolute-path equality across the two callers.
- **Kill-previous-search is only local.** The relay can process multiple concurrent `fs.search` requests over the mux. If that becomes a problem, add relay-side cancellation later — it is not part of this refactor.

View File

@ -1,317 +0,0 @@
# Design Document: Drag-and-Drop File Import over SSH
## 1. Overview
Orca's file explorer supports dragging external files from the OS into the explorer when working with a local worktree (see `docs/file-explorer-external-drop.md`). However, this feature does not work when connected to an SSH remote. The `fs:importExternalPaths` IPC handler uses Node's local filesystem APIs (`copyFile`, `lstat`, `readdir`, `mkdir`) and has no `connectionId` parameter — the renderer never passes one, and the main process has no code path to route import operations through the SSH filesystem provider.
This document proposes extending the import flow to support SSH connections, enabling users to drop local files onto the explorer and have them uploaded to the remote server.
**Origin:** User feedback — [Slack thread](https://stablygroup.slack.com/archives/C0ASMDT6LQZ/p1777530155421009), [GitHub #200](https://github.com/stablyai/orca-internal/issues/200).
## 2. Current Architecture
### 2.1 Local Import Path
The existing local import flow:
1. **Preload** intercepts native OS `drop` events, extracts `FileList` paths, resolves the destination directory from `data-native-file-drop-dir` DOM markers, and emits one IPC event: `{ target: 'file-explorer', paths, destinationDir }`.
2. **Renderer** (`useFileExplorerImport.ts`) receives the event and calls `window.api.fs.importExternalPaths({ sourcePaths, destDir })`.
3. **Main** (`filesystem-mutations.ts`) runs the import: authorize paths → `lstat` validation → symlink pre-scan → deconflict names → `copyFile`/`recursiveCopyDir`.
All of this is local filesystem only. No `connectionId` is threaded anywhere.
### 2.2 SSH Filesystem Provider
The `SshFilesystemProvider` communicates with a relay binary on the remote host via a JSON-RPC multiplexer (`SshChannelMultiplexer`). It supports:
- `readDir`, `readFile`, `writeFile`, `stat`, `deletePath`, `createFile`, `createDir`, `rename`, `copy`, `realpath`, `search`, `listFiles`, `watch`
`writeFile` accepts a `string` content parameter — it is text-only and unsuitable for binary files (images, compiled assets, etc.).
`copy` is remote-to-remote — it tells the relay to copy a file on the remote side.
### 2.3 Direct SFTP
The codebase already uses direct SFTP for relay deployment (`ssh-relay-deploy-helpers.ts`):
- `uploadFile(sftp, localPath, remotePath)` — streams a local file to the remote via `createReadStream``sftp.createWriteStream`.
- `uploadDirectory(sftp, localDir, remoteDir)` — recursively creates directories and uploads files.
- `mkdirSftp(sftp, remotePath)` — creates remote directories.
These helpers use `ssh2`'s `SFTPWrapper` obtained from `SshConnection.sftp()`.
### 2.4 Other SSH-Aware Mutations
Other filesystem mutations (`createFile`, `createDir`, `rename`) already accept `connectionId` and route through `getSshFilesystemProvider()`. The import handler is the exception.
### 2.5 System Context
```
┌──────────────────────────────────────────────────────────┐
│ Renderer (file-explorer) │
│ useFileExplorerImport ──► IPC: fs:importExternalPaths │
│ { sourcePaths, destDir, connectionId? } │
└──────────────────────┬───────────────────────────────────┘
┌────────────▼────────────┐
│ Main Process │
│ filesystem-mutations │
│ │
│ connectionId present? │
│ ├─ NO → local fs │
│ │ copyFile / mkdir │
│ └─ YES → SFTP │
│ SshConnection │
│ .sftp() │
└──────┬──────────┬───────┘
│ │
┌────────▼──┐ ┌───▼────────────┐
│ Local FS │ │ Remote Host │
│ (source │ │ (destination │
│ always │ │ via SFTP) │
│ local) │ │ │
└───────────┘ └────────────────┘
```
> **Architecture note:** The import handler bypasses `SshFilesystemProvider` and uses `SshConnection.sftp()` directly. This is intentional — the relay's JSON-RPC `fs.writeFile` is text-only and cannot carry binary data without base64 encoding overhead. Future maintainers should not "fix" this to route through the provider.
## 3. Gap Analysis
| Requirement | Local | SSH |
|---|---|---|
| Source path validation (`lstat`) | Local `fs.lstat` | Local `fs.lstat` (source is always local) |
| Symlink pre-scan | Local `fs.readdir` | Local `fs.readdir` (source is always local) |
| Name deconfliction | Local `fs.lstat` on dest | Remote `stat` via relay/SFTP |
| File copy | `fs.copyFile` | SFTP stream upload |
| Directory creation | `fs.mkdir` | SFTP `mkdir` or relay `fs.createDir` |
| Recursive directory copy | Local `readdir` + `copyFile` | Local `readdir` + SFTP upload per file |
Key insight: **source paths are always local** (they come from the user's OS file manager). Only the destination is remote. This means source validation (lstat, symlink pre-scan) stays unchanged — only the copy-to-destination step needs an SSH path.
## 4. Proposed Design
### 4.1 Strategy: Direct SFTP Upload
Use `ssh2`'s SFTP channel directly from the main process, reusing the existing `uploadFile`/`uploadDirectory`/`mkdirSftp` helpers from `ssh-relay-deploy-helpers.ts`. Do NOT route through the relay's JSON-RPC `fs.writeFile` because:
- `fs.writeFile` is text-only (string content over JSON-RPC).
- Binary files (images, PDFs, compiled assets) would require base64 encoding + relay-side decode, adding complexity and ~33% bandwidth overhead.
- The SFTP helpers already exist, are tested, and handle streaming correctly.
### 4.2 IPC Changes
**`api-types.ts`** — Add `connectionId` to the import args:
```ts
importExternalPaths: (args: {
sourcePaths: string[]
destDir: string
connectionId?: string
}) => Promise<{ results: ImportItemResult[] }>
```
**`preload/index.ts`** — Thread `connectionId` through the IPC invoke.
### 4.3 Main-Process Import Handler
Extend the `fs:importExternalPaths` handler in `filesystem-mutations.ts`:
```
if (connectionId) {
→ check SSH connection state; if reconnecting, return user-friendly error
→ guard: if sourcePaths is empty, return { results: [] } immediately
→ get SshConnection from session registry
→ open SFTP channel
→ show indeterminate "Importing files…" toast
→ try:
run SSH import path (4.4)
finally:
close SFTP channel (guaranteed cleanup)
dismiss toast
else
→ resolveAuthorizedPath(destDir)
→ existing local import path (unchanged)
```
**Implementation constraint — `resolveAuthorizedPath` placement:** The current handler calls `resolveAuthorizedPath(destDir)` unconditionally before any copy work. This must be restructured: move `resolveAuthorizedPath(destDir)` inside the `else` (local) branch. For SSH imports, `destDir` is a remote path that does not exist on the local filesystem, so `resolveAuthorizedPath` will throw. The SSH branch skips local path authorization because remote paths are authorized by the SSH connection boundary itself (see Section 9).
**Connection-state check:** Before attempting `connection.sftp()`, the handler must inspect the connection state. If the connection is in `reconnecting` state, fail early with a toast: _"SSH connection is reconnecting — please try again in a moment."_ This avoids an unhelpful generic "Not connected" SFTP error that gives the user no guidance.
**Empty source paths:** If `sourcePaths` is an empty array, return `{ results: [] }` immediately without opening an SFTP channel. This avoids unnecessary channel overhead for a no-op.
**SFTP channel cleanup:** The SFTP channel opened for the import must be closed on all code paths — success, partial failure, or exception. The handler must use `try/finally` semantics around the upload loop. Note that individual `uploadFile` calls receive the `sftp` handle as a parameter and do not manage its lifecycle; the caller (the import handler) is solely responsible for closing the channel.
**In-progress feedback:** Show an indeterminate "Importing files…" toast when the IPC call begins and dismiss it when the import completes (success or failure). This costs almost nothing to implement and significantly improves the experience on slow connections where network latency makes the drop-to-toast gap noticeable.
### 4.4 SSH Import Pipeline
For each source path in the batch:
1. **Source validation** — unchanged. `lstat` the local source path. Reject symlinks, missing, permission-denied. Pre-scan directories for nested symlinks.
2. **Name deconfliction** — use SFTP `lstat` (not `stat`) on the remote destination to check for collisions, matching the local import's use of `lstat`. This ensures consistent collision semantics: a dangling symlink at the destination is still treated as "name taken." SFTP lstat throws `SSH_FX_NO_SUCH_FILE` (code 2) when the path doesn't exist — use this as the "no collision" signal.
3. **Upload** — for files, use `uploadFile(sftp, localPath, remotePath)`. For directories, use recursive SFTP mkdir + uploadFile. Reuse the existing helpers from `ssh-relay-deploy-helpers.ts` after extracting them to a shared location.
4. **Result reporting** — same per-item `ImportItemResult` schema. The renderer doesn't need to know whether the import went local or SSH.
### 4.5 Accessing the SFTP Channel
The `SshConnection` class already exposes `async sftp(): Promise<SFTPWrapper>`. The import handler needs access to the connection for a given `connectionId`.
Current architecture: `SshRelaySession` owns the connection lifecycle but doesn't directly expose the `SshConnection`. The `getSshFilesystemProvider()` dispatch only returns the `IFilesystemProvider` interface.
Options:
**Option A: Expose `SshConnection` via a session registry.**
Add a `getSshConnection(connectionId)` function that returns the `SshConnection` from the `SshRelaySession` map. The import handler calls `connection.sftp()` directly.
**Option B: Add an `uploadFile` method to `IFilesystemProvider`.**
Extend the provider interface with `uploadFile(localPath: string, remotePath: string): Promise<void>` and `uploadDirectory(localDir: string, remoteDir: string): Promise<void>`. The SSH provider implements them via SFTP; the local provider implements them as `copyFile`/`recursiveCopyDir`.
**Recommendation: Option A.** Option B pollutes the provider interface with a local↔remote transfer concern that only applies to import. The relay-based provider should stay focused on remote-side operations. A direct SFTP path from the import handler is simpler and keeps the provider interface clean.
### 4.6 Renderer Changes
**`useFileExplorerImport.ts`** — Pass `connectionId` from the active worktree:
```ts
const connectionId = getConnectionId(activeWorktreeIdRef.current) ?? undefined
const { results } = await window.api.fs.importExternalPaths({
sourcePaths: paths,
destDir: destinationDir,
connectionId
})
```
This is the only renderer change needed. The rest of the import UX (drag state, highlight, toast, reveal) works identically for local and SSH.
### 4.7 Helper Extraction
Move `uploadFile`, `uploadDirectory`, and `mkdirSftp` from `ssh-relay-deploy-helpers.ts` to a shared module (e.g., `src/main/ssh/sftp-upload.ts`). The relay deploy code imports from the new location. This avoids coupling the import feature to relay deployment internals.
**Async filesystem calls:** The existing `uploadDirectory` uses `readdirSync` and `statSync`, which block the event loop. During extraction, replace these with their async counterparts (`readdir` with `{ withFileTypes: true }` from `fs/promises`). The local import's `recursiveCopyDir` already uses async fs calls and serves as the template for this conversion.
## 5. Symlink Policy
Unchanged from the local import design. Source-side symlinks are rejected before upload begins. The pre-scan uses local `readdir` + `lstat`, which works identically regardless of the destination being local or remote.
## 6. Conflict Policy
Same as local: non-destructive, prompt-free deconfliction. The difference is that collision checks use SFTP `lstat` instead of local `lstat`. Using `lstat` (rather than `stat`) matches the local path's semantics: a symlink at the destination is treated as a collision even if its target doesn't exist.
SFTP lstat error handling:
- `SSH_FX_NO_SUCH_FILE` (status code 2) → no collision, name is available.
- `SSH_FX_PERMISSION_DENIED` (status code 3) → fail the item.
- Any other error → fail the item with the error message.
## 7. Performance Considerations
### 7.1 SFTP Channel Lifecycle
Open one SFTP channel per import gesture, not per file. Close it after all items are uploaded using `try/finally` to guarantee cleanup even on partial failure. Opening an SFTP subsystem has ~100ms overhead per channel due to the SSH handshake.
### 7.2 Sequential vs. Parallel Upload
v1: sequential upload (one file at a time). This matches the existing relay deploy behavior and avoids SFTP channel contention. SFTP supports multiple concurrent operations, but managing parallel uploads with error handling and progress adds complexity that isn't needed for v1.
### 7.3 Large File Handling
`uploadFile` uses `createReadStream``sftp.createWriteStream`, which streams data rather than buffering entire files into memory. This handles large files without OOM risk.
### 7.4 Network Latency
Unlike local imports, SSH imports are bounded by network throughput. For large drops, the user may see a noticeable delay between the drop gesture and the toast/reveal. v1 includes an indeterminate "Importing files…" toast during the upload to bridge this gap. Granular per-file progress UI is deferred to v2.
## 8. Error Handling
Same per-item error reporting as local imports, plus SSH-specific failures:
- **SSH connection in `reconnecting` state at drop time:** Fail immediately with toast: _"SSH connection is reconnecting — please try again in a moment."_ Do not attempt to open an SFTP channel.
- **SSH connection lost during upload:** Fail remaining items. Partially uploaded files may be left on the remote — acceptable for v1 since partial files are visible in the explorer and can be deleted manually.
- **SFTP channel failure:** Fail the entire import. The `finally` block still runs to release the channel handle.
- **Remote disk full:** SFTP write stream error — fail the affected item.
- **Permission denied on remote directory:** Fail the affected item.
Toast messages remain the same format:
- `Imported 5 items to ~/project/src`
- `Imported 4 items to ~/project/src. 1 item was skipped.`
- `Could not import dropped items`
- `SSH connection is reconnecting — please try again in a moment`
### 8.1 Data Flow Paths
**Happy path:** Renderer sends `{ sourcePaths: ["/a.txt"], destDir: "/remote/dir", connectionId: "abc" }` → main checks connection state (connected) → opens SFTP → shows "Importing files…" toast → validates source locally → deconflicts name via SFTP stat → uploads via SFTP stream → closes SFTP → dismisses toast → returns `{ results: [{ path: "/remote/dir/a.txt", status: "ok" }] }` → renderer shows success toast and reveals file.
**Empty sourcePaths:** Renderer sends `{ sourcePaths: [], destDir: "/remote/dir", connectionId: "abc" }` → main returns `{ results: [] }` immediately, no SFTP channel opened, no toast shown.
**Nil connectionId (local fallback):** Renderer sends `{ sourcePaths: [...], destDir: "/local/dir" }` → main takes existing local import path unchanged.
**Error — reconnecting:** Renderer sends `{ sourcePaths: [...], destDir: "/remote/dir", connectionId: "abc" }` → main checks connection state → state is `reconnecting` → returns error → renderer shows "SSH connection is reconnecting — please try again in a moment" toast.
**Error — mid-upload failure:** Main opens SFTP → uploads file 1 OK → file 2 throws (e.g., permission denied) → file 2 marked as failed → file 3 continues → SFTP closed in `finally` → toast dismissed → returns mixed results → renderer shows "Imported 2 items … 1 item was skipped."
## 9. Security
- Source paths are still authorized via `authorizeExternalPath()` — unchanged.
- Destination paths on the remote are not subject to local path authorization (they're on the remote host). The SSH connection itself is the authorization boundary.
- SFTP operations run under the SSH user's permissions on the remote host.
## 10. Testing
### 10.1 Unit Tests (Main Process)
- SSH import handler routes to SFTP when `connectionId` is present.
- SSH import handler falls back to local import when `connectionId` is absent.
- Empty `sourcePaths` array returns `{ results: [] }` without opening an SFTP channel.
- Reconnecting connection state returns a user-friendly error without attempting SFTP.
- Name deconfliction works with SFTP stat (mock SFTP stat to simulate collisions).
- Source-side symlink rejection works identically for SSH imports.
- SFTP channel is opened once per gesture, not per file.
- SFTP channel is closed after import completes (success or failure) — verify `finally` cleanup runs even when upload throws.
- Partial failure (some files succeed, some fail) returns correct per-item results.
- Indeterminate "Importing files…" toast is shown during upload and dismissed on completion.
### 10.2 Integration Tests
- Drop a single file into an SSH-connected explorer root → file appears on remote.
- Drop a directory into an SSH-connected explorer → directory tree appears on remote.
- Drop a file that collides with an existing remote file → deconflicted name used.
- Drop onto a subdirectory row → file lands in that directory on remote.
### 10.3 Renderer Tests
- `useFileExplorerImport` passes `connectionId` when active worktree has an SSH connection.
- `useFileExplorerImport` passes `undefined` for `connectionId` when local.
## 11. Implementation Plan
1. **Extract SFTP helpers** — Move `uploadFile`, `uploadDirectory`, `mkdirSftp` from `ssh-relay-deploy-helpers.ts` to `src/main/ssh/sftp-upload.ts`. Update relay deploy imports.
2. **Expose SSH connection accessor** — Add `getSshConnection(connectionId)` to the session registry so the import handler can obtain an SFTP channel.
3. **Add `connectionId` to import IPC** — Update `api-types.ts`, `preload/index.ts`, and the `fs:importExternalPaths` handler signature.
4. **Implement SSH import path** — In `filesystem-mutations.ts` (or a new `filesystem-import-ssh.ts`), add the SSH branch: open SFTP → validate sources locally → deconflict names via SFTP stat → upload via SFTP → close SFTP → return results.
5. **Thread `connectionId` in renderer** — Update `useFileExplorerImport.ts` to pass `connectionId` from the active worktree.
6. **Tests** — Unit tests for the SSH import path, integration tests for end-to-end flow.
## 12. Complexity Assessment
**Estimated difficulty: Medium.**
- The hardest part is already done — the local drag-drop UX, preload routing, and renderer import hook all exist and work.
- SFTP upload helpers exist and are proven in relay deployment.
- The main new work is: (a) wiring `connectionId` through the import IPC, (b) implementing SFTP-based name deconfliction, (c) connecting the import handler to the SSH connection's SFTP channel.
- No relay protocol changes needed. No new renderer UI. No new preload routing.
- Risk areas: SFTP error handling edge cases, ensuring the SFTP channel is cleaned up on all code paths, and testing against real SSH servers.
## 13. Open Questions
- Whether v2 should show granular per-file upload progress for SSH imports (v1 includes an indeterminate toast; per-file progress would require tracking bytes transferred).
- Whether partial uploads should be cleaned up on failure (currently left on remote).
- Whether to support drag-and-drop *from* the SSH explorer to the local OS (reverse direction).

View File

@ -1,374 +0,0 @@
# Design Document: External File Drop Import in File Explorer
## 1. Overview
Orca's file explorer already supports drag-and-drop for moving items that originate inside the explorer, but it does not support dropping files or folders from the OS into the explorer to add them to the active worktree.
This document proposes a native file-drop import flow that works in both places users expect:
- Dropping onto the explorer background imports into the worktree root.
- Dropping onto a directory row imports into that directory.
- Dropping onto a file row imports into that file's parent directory.
The design follows the same high-level split used by VS Code: external/native drops are handled as imports, while in-explorer drags remain move operations. Superset is a useful reference for renderer-side drag-state handling: it treats `Files` drags as a distinct UX path with explicit hover state instead of trying to force them through the in-app DnD codepath.
## 2. Goals
- Let users drop external files and folders from Finder/Explorer/Linux file managers into Orca's file explorer.
- Support root-level drops and nested directory drops.
- Preserve the current in-explorer move behavior for `text/x-orca-file-path`.
- Keep the implementation cross-platform across macOS, Linux, and Windows.
- Avoid destructive overwrites by default.
- Keep the import path performant for large multi-file and directory drops.
## 3. Non-Goals
- No drag-out export from Orca to the OS.
- No cross-worktree move semantics for external drops. External drops always copy/import.
- No full upload/progress manager in v1.
- No overwrite prompt flow in v1.
## 4. Current State
Today the relevant pieces are split across three layers:
- `src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts` and `FileExplorerRow.tsx` handle only internal explorer drags via `text/x-orca-file-path`, and complete the action with `window.api.fs.rename(...)`.
- `src/preload/index.ts` intercepts native OS drops before React sees them and classifies them only as `editor` or `terminal`.
- `src/renderer/src/hooks/useGlobalFileDrop.ts` opens dropped files in the editor, while terminal panes insert dropped paths into the active PTY.
That means the explorer never receives a native-drop route, and there is no filesystem API that copies a dropped file tree into the worktree.
## 5. Reference Behavior
### 5.1 VS Code
VS Code explicitly separates:
- native drag/drop import (`NativeDragAndDropData` -> `ExternalFileImport.import(...)`)
- in-explorer drag/drop move/copy (`handleExplorerDrop(...)`)
The important design takeaway is not the exact API shape. It is that external drops resolve a destination directory first and then run an import pipeline instead of trying to reuse the internal move path.
### 5.2 Superset
Superset's desktop app uses renderer-side `onDragOver` / `onDragLeave` / `onDrop` handling keyed off `e.dataTransfer.types.includes("Files")`, with explicit hover UI and defensive `getPathForFile(...)` handling.
The useful precedent for Orca is the UX structure:
- treat native file drags as their own interaction mode
- show a clear copy/import affordance
- clear drag state reliably on `drop` and `dragend`
## 6. Proposed UX
When the user drags external files over the file explorer:
- The explorer root shows a copy/import highlight when the drop target is the worktree root.
- Directory rows highlight as valid copy targets.
- Hovering a collapsed directory during a native drag auto-expands it after the same delay used for internal moves.
- The cursor uses `dropEffect = "copy"`.
The explorer root drop surface must remain available even when the tree is empty, still loading, or showing a read error. In v1, the right sidebar should keep rendering a root-level explorer container for those states so users can still drop into the worktree root instead of losing the target entirely.
This requires restructuring the current early-return branches in `FileExplorer.tsx`. Today the component returns dedicated loading / error / empty placeholders before it renders the shared `ScrollArea`, so adding dataset markers only to the existing populated-tree path would still leave root import unavailable in those states.
When the user drops:
- On explorer background: import into the worktree root.
- On directory row: import into that directory.
- On file row: import into the parent directory.
In v1, "inside the folder" means dropping on that folder's row. Orca's explorer is a virtualized flat list rather than nested DOM containers, so arbitrary whitespace under a folder's rendered children is not treated as a separate interior drop zone.
After completion:
- Refresh the destination directory.
- Reveal and flash the first imported path.
- Show a summary toast, for example `Imported 3 items to src/components`.
The explorer should not auto-open dropped files in v1. Dropping into the explorer is an "add here" action, not an "open in editor" action.
## 7. Architecture
### 7.1 Extend Native Drop Routing
Add a third native-drop target in preload:
- `editor`
- `terminal`
- `file-explorer`
The current `getNativeFileDropTarget(...)` helper in `src/preload/index.ts` should become a richer resolver that walks `event.composedPath()` and extracts:
- the high-level target kind
- the nearest explorer destination directory, if any
The explorer DOM should expose two dataset markers:
- `data-native-file-drop-target="file-explorer"` on the root scroll area
- `data-native-file-drop-dir="<absolute dir path>"` on the root container and on each row drop target
Routing must fail closed for explorer drops. If preload sees `data-native-file-drop-target="file-explorer"` but cannot resolve a `destinationDir`, it should reject the gesture and emit no fallback `editor` drop event.
Why this is necessary: the preload layer consumes native OS `drop` events before React can read filesystem paths. If preload does not capture the destination directory at drop time, the renderer can no longer tell whether the user meant "root" or "inside this folder".
The relayed payload should become:
```ts
type NativeFileDropEvent =
| { paths: string[]; target: 'editor' }
| { paths: string[]; target: 'terminal' }
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
```
Preload/main must emit exactly one native-drop event per drop gesture.
Why: the preload layer already has the full `FileList`. Re-emitting one IPC message per path and asking the renderer to reconstruct the gesture via timing would be both fragile and slower under large drops.
**Impact on existing listeners:**
Because the relay payload changes from `{ path: string }` to `{ paths: string[] }`, existing `ui.onFileDrop` handlers in:
- `src/renderer/src/hooks/useGlobalFileDrop.ts` (editor target)
- `src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts` (terminal target)
must be updated to loop over the `paths` array.
### 7.2 Renderer Explorer Drag State
`useFileExplorerDragDrop(...)` should handle two drag families:
- internal Orca drags: `text/x-orca-file-path`
- external/native drags: `Files`
The existing move logic stays unchanged for internal drags.
For native drags, the hook should:
- accept `Files` in root and row `onDragOver`
- set copy affordance and hover state
- reuse the existing row auto-expand timer for directory targets
- clear root/row highlight on `dragleave`, `dragend`, and after the import event fires
The hook should use the same explicit destination model as the drop router:
- root background -> worktree root
- directory row -> that directory
- file row -> file's parent directory
This follows Superset's approach of treating external drags as a distinct renderer interaction, even though the final import action is triggered from the preload-delivered event instead of the React `drop` handler.
### 7.3 New Filesystem Import IPC
Add a dedicated filesystem mutation:
```ts
window.api.fs.importExternalPaths({
sourcePaths: string[],
destDir: string
}): Promise<{
results: Array<
| {
sourcePath: string
status: 'imported'
destPath: string
kind: 'file' | 'directory'
renamed: boolean
}
| {
sourcePath: string
status: 'skipped'
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
}
| {
sourcePath: string
status: 'failed'
reason: string
}
>
}>
```
Implementation lives alongside the existing filesystem mutations in `src/main/ipc/filesystem-mutations.ts`.
Behavior:
1. Authorize every source path with the existing external-path mechanism.
2. Validate every source path from its unresolved path using `lstat(...)` before any canonicalization so top-level symlinks are rejected instead of being silently dereferenced by `realpath(...)`.
3. Resolve `destDir` through `resolveAuthorizedPath(...)`.
4. Copy, never rename.
5. Support both files and directories.
6. Return per-top-level-item results so the renderer can produce correct summary UX for success, partial success, and renames.
### 7.4 Copy Semantics
Use recursive copy semantics in the main process:
- File source: copy file bytes.
- Directory source: create the top-level directory, then recursively copy descendants.
This should be implemented in Node-side filesystem code, not in the renderer, so path authorization and cross-platform behavior stay centralized.
### 7.5 Atomic Import Rules
Directory imports must be atomic at the top-level item boundary.
Required behavior:
- Before importing a dropped directory, pre-scan that directory tree for disallowed entries such as symlinks.
- Source validation must inspect the dropped path itself with `lstat(...)` before calling helpers like `resolveAuthorizedPath(...)` that canonicalize existing paths.
- If the pre-scan finds a disallowed entry, skip that top-level source entirely.
- Do not create any destination files or directories for a top-level source that fails pre-scan.
Why: if recursive copy discovers a symlink halfway through, Orca would otherwise leave a partially imported tree behind. Pre-scan is the preferred v1 design because it is simpler and more performant than temp-directory staging while still avoiding partial output.
## 8. Conflict Policy
v1 should be non-destructive and prompt-free:
- Never overwrite an existing file or folder.
- If a top-level dropped item collides with an existing name in `destDir`, generate a unique sibling name before copying.
Examples:
- `logo.png` -> `logo copy.png`
- `logo.png` -> `logo copy 2.png`
- `assets/` -> `assets copy/`
This matches Orca's current bias toward safe filesystem mutations and avoids blocking the drop on a modal confirmation flow.
Top-level deconfliction is sufficient for dropped directories because the copy target becomes a newly created directory. Once that top-level directory name is unique, nested collisions disappear inside that subtree.
If multiple dropped items collide with each other, the same deconfliction pass should run against the union of:
- already existing destination entries
- names reserved earlier in the same import batch
The result payload must preserve whether each successful import was renamed by deconfliction.
## 9. Symlink Policy
Reject symlinks in v1.
Rationale:
- Symlink copy semantics differ across platforms.
- Copying a symlink literal can produce confusing repository state.
- Following symlinks can escape the dropped subtree and import unintended content.
If a dropped source or descendant is a symlink, fail that top-level item and surface a toast summary such as `Skipped 1 item containing symlinks`.
## 10. Renderer Flow
The renderer-side import path should be:
1. `window.api.ui.onFileDrop(...)` receives one gesture-scoped event: `{ target: 'file-explorer', paths, destinationDir }`.
2. The explorer calls `window.api.fs.importExternalPaths({ sourcePaths: paths, destDir: destinationDir })`.
3. On success or partial success, it calls `refreshDir(destinationDir)` once.
4. It reveals and flashes the first successfully imported destination path, if any, by routing through Orca's existing expansion-aware reveal pipeline (`pendingExplorerReveal` / `useFileExplorerReveal`) or an equivalent mechanism that can expand collapsed ancestors before selecting the imported path.
5. It clears drag state and shows one summary toast derived from the returned per-item results.
## 11. Error Handling
Failure modes should be explicit but non-destructive:
- Source path no longer exists: skip and report.
- Permission denied: fail the affected item and report.
- Unsupported symlink: skip and report.
- Destination path unauthorized: fail the whole import.
Toast copy should summarize, not spam:
- Success: `Imported 5 items to src`
- Partial: `Imported 4 items to src. 1 item was skipped.`
- Failure: `Could not import dropped items`
The renderer should derive these counts from the returned result payload rather than inferring them from thrown exceptions.
## 12. Watcher and Refresh Strategy
The filesystem watcher in `useFileExplorerWatch.ts` is a useful backstop, but the import flow should still refresh explicitly after completion.
Why: native drops can create many files quickly, and the UX should not depend on watcher timing to make the destination directory show the new content.
The minimal explicit refresh is:
- `refreshDir(destinationDir)` after import
If imported content lands under directories that were already expanded, the watcher can reconcile the rest.
## 13. Performance
Performance is a core requirement for this feature.
### 13.1 Event Routing
- Preload should extract the native `FileList` once and relay one IPC event per drop gesture.
- The renderer should not do timer-based gesture reconstruction or emit one IPC call per dropped path.
### 13.2 Main-Process Import
- Import work must stay in the main process so the renderer remains responsive.
- The copy path should use native filesystem copy primitives or streaming I/O, not `readFile()` buffering whole files into memory.
- Pre-scan should walk dropped directories once per top-level source to detect symlinks before copy starts.
- Top-level deconfliction should happen once per dropped source before the copy loop, avoiding repeated deep-path collision checks.
### 13.3 UI Updates
- The renderer should call `refreshDir(destinationDir)` once per completed gesture.
- The renderer should emit one summary toast per gesture.
- The renderer should reveal only the first successful import rather than forcing multiple scroll/reveal passes.
### 13.4 Scope Control
To keep v1 fast and predictable, it should not include per-file progress rows, per-item toasts, or overwrite prompts inside the copy loop.
## 14. Testing
### 14.1 Preload / Main
- target resolution picks `file-explorer` when the composed path contains explorer markers
- nearest `data-native-file-drop-dir` wins over outer containers
- relay payload includes `destinationDir`
- relay emits one event containing all dropped `paths`
- file-explorer routing fails closed when the target marker is present but `destinationDir` is missing
- editor-target drops still open every dropped file from the single gesture payload
- terminal-target drops still insert every dropped path from the single gesture payload
### 14.2 Filesystem Mutation Tests
- imports a single file
- imports multiple files in one batch
- imports a directory recursively
- deconflicts top-level filename collisions
- deconflicts top-level directory collisions
- rejects top-level symlink sources before canonicalization
- skips a dropped directory with nested symlinks without leaving partial output
- rejects unauthorized destinations
- returns per-item results including rename metadata
### 14.3 Renderer Tests
- root drop surface remains active while the explorer is empty, loading, or showing a root read error
- root highlight appears for `Files` drag
- directory rows highlight and auto-expand for `Files` drag
- file rows map to parent directory targets
- one drop gesture triggers one import IPC call
- one drop gesture produces one summary toast
- success reveals the first imported destination path
- success reveal still works when the imported path lives under ancestors that were collapsed before the drop
## 15. Implementation Plan
1. Extend native-drop typing and relay payload in preload/main to send one event per drop gesture with `paths[]`.
2. Update existing editor and terminal `ui.onFileDrop` handlers in the renderer to accept `paths[]`.
3. Refactor `FileExplorer.tsx` so the shared root explorer container stays mounted for loading, error, and empty states, then add explorer DOM markers for root and per-row destination directories.
4. Teach `useFileExplorerDragDrop(...)` to track native `Files` drag state separately from internal move state.
5. Add `fs.importExternalPaths(...)` to preload typings and main IPC with a per-item result schema.
6. Implement pre-scan + recursive copy + top-level deconfliction + symlink rejection in `filesystem-mutations.ts`.
7. Wire the explorer import success path through the existing expansion-aware reveal flow so drops into collapsed folders still reveal correctly.
8. Add renderer import handling, refresh, reveal, and one-toast-per-gesture summary handling.
9. Add tests across preload, IPC, and renderer.
## 16. Open Questions
- Whether v2 should support an overwrite confirmation flow like VS Code instead of prompt-free deconfliction.
- Whether v2 should show progress UI for large directory imports.
- Whether symlink rejection should later become a user-visible choice for trusted repos.

View File

@ -1,478 +0,0 @@
# Fix: Claude & Codex accounts intermittently return 401 Unauthorized
**Issue:** [#1284](https://github.com/stablyai/orca/issues/1284)
**Status:** Proposed fix
**Approach:** Add `readBackRefreshedTokens()` to both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService`
## Problem
Codex (and Claude) managed accounts intermittently return `401 Unauthorized` / `token_expired` errors after working normally for some time.
### Root Cause
Both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService` perform a **one-directional** auth sync: they always copy the managed account's credentials into the shared runtime path, but never read back refreshed tokens.
**The problematic flow (identical for both providers):**
1. User authenticates → credentials saved to managed storage
2. `syncForCurrentSelection()` copies managed credentials → runtime path
3. CLI runs using the runtime credentials
4. OAuth access token expires over time. **CLI refreshes it and writes updated tokens back to the runtime path**
5. Next PTY launch or rate-limit fetch triggers `syncForCurrentSelection()` again
6. **The sync overwrites the runtime credentials with the stale managed copy** — the refreshed token is lost
7. Next request uses the expired token → `401 Unauthorized`
**Codex specifics:**
- Runtime path: `~/.codex/auth.json`
- Managed path: `<userData>/codex-accounts/<id>/home/auth.json`
- Affected: `CodexRuntimeHomeService.syncForCurrentSelection()` line 84
**Claude specifics:**
- Runtime path: `~/.claude/.credentials.json` (+ Keychain on macOS)
- Managed path: `<userData>/claude-accounts/<id>/auth/.credentials.json` (+ Keychain)
- Affected: `ClaudeRuntimeAuthService.doSyncForCurrentSelection()` line 125
- Note: Claude already has `lastWrittenCredentialsJson` and `detectExternalLoginAndUpdateSnapshot()` for the managed→system-default transition, but it **does not** read back refreshed tokens during steady-state sync — the same one-directional overwrite happens
### Why it's intermittent
- Tokens have a multi-hour lifetime, so the bug only surfaces after enough time passes for the original token to expire
- The overwrite only happens on PTY launch or rate-limit fetch, not continuously
- If the user re-authenticates, the cycle resets with a fresh token
### Affected code
**Codex:**
- `src/main/codex-accounts/runtime-home-service.ts``syncForCurrentSelection()` (line 49-85)
- Called from `prepareForCodexLaunch()` (line 39) and `prepareForRateLimitFetch()` (line 44)
**Claude:**
- `src/main/claude-accounts/runtime-auth-service.ts``doSyncForCurrentSelection()` (line 96-131)
- Called from `prepareForClaudeLaunch()` (line 47) and `prepareForRateLimitFetch()` (line 52)
## System Context
```
┌──────────────────────────────────────────────────────────────┐
│ Orca (main process) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ CodexRuntimeHomeService │ │
│ │ │ │
│ │ lastSyncedAccountId: string | null │ │
│ │ lastWrittenAuthJson: string | null ← NEW │ │
│ │ │ │
│ │ syncForCurrentSelection() │ │
│ │ ├─ captureSystemDefaultSnapshotIfNeeded() │ │
│ │ ├─ readBackRefreshedTokens() ← NEW │ │
│ │ └─ writeRuntimeAuth() │ │
│ │ │ │
│ │ restoreSystemDefaultSnapshot() │ │
│ │ └─ detectExternalLoginAndUpdateSnapshot() ← NEW│ │
│ └──────────┬──────────────────────────┬─────────────┘ │
│ │ │ │
│ ┌───────▼───────┐ ┌────────▼──────────┐ │
│ │ Managed Home │ │ Runtime Home │ │
│ │ <userData>/ │ │ ~/.codex/ │ │
│ │ codex-accounts│ │ auth.json │ │
│ │ /<id>/home/ │ │ │ │
│ │ auth.json │ │ │ │
│ └───────────────┘ └────────┬──────────┘ │
└──────────────────────────────────────────┼────────────────────┘
┌───────▼───────┐
│ Codex CLI │
│ │
│ reads auth.json
│ refreshes token
│ writes back │
└───────────────┘
```
## Data Flow: Token Refresh Preservation
```
PTY launch / rate-limit fetch
syncForCurrentSelection()
├─ captureSystemDefaultSnapshotIfNeeded()
├─ Is lastSyncedAccountId === activeAccount.id?
│ │
│ YES → readBackRefreshedTokens()
│ │ │
│ │ ├─ Read ~/.codex/auth.json (current runtime)
│ │ ├─ Compare to lastWrittenAuthJson
│ │ │ │
│ │ │ DIFFER → Codex CLI refreshed the token
│ │ │ │ Write runtime auth.json back
│ │ │ │ to managed home auth.json
│ │ │ │
│ │ │ MATCH → No external changes, continue
│ │ │
│ │ └─ (try/catch: failures log + continue)
│ │
│ NO → Skip read-back (account switch in progress)
├─ Read managed auth.json
├─ writeRuntimeAuth(contents)
│ ├─ Write to ~/.codex/auth.json
│ └─ Record lastWrittenAuthJson = contents
└─ lastSyncedAccountId = activeAccount.id
```
## Data Flow: External Login Detection (managed → system-default)
```
User deselects managed account (activeAccount = null)
syncForCurrentSelection()
├─ lastSyncedAccountId !== null? (was managed)
│ │
│ YES → restoreSystemDefaultSnapshot()
│ │ │
│ │ ├─ detectExternalLoginAndUpdateSnapshot()
│ │ │ │
│ │ │ ├─ lastWrittenAuthJson !== null?
│ │ │ ├─ Read current ~/.codex/auth.json
│ │ │ ├─ Compare to lastWrittenAuthJson
│ │ │ │ │
│ │ │ │ DIFFER → External login detected
│ │ │ │ │ (e.g. `codex auth login`)
│ │ │ │ │ Delete stale snapshot
│ │ │ │ │ Clear lastWrittenAuthJson
│ │ │ │ │ Return true (skip restore)
│ │ │ │ │
│ │ │ │ MATCH → No external login
│ │ │ │ Return false (do restore)
│ │ │ │
│ │ │ └─ (try/catch: failures → return false)
│ │ │
│ │ └─ If not external: restore snapshot as before
│ │
│ NO → Skip (was never managed)
└─ lastSyncedAccountId = null
```
## Fix: Add `readBackRefreshedTokens()` to Both Services
The fix adds the same read-back mechanism to both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService`. The core logic is identical: before overwriting runtime credentials, compare the file to what Orca last wrote. If they differ, the CLI refreshed the token — write it back to managed storage.
### What each service needs
| Change | Codex (`runtime-home-service.ts`) | Claude (`runtime-auth-service.ts`) |
|--------|-----------------------------------|-------------------------------------|
| Add `lastWrittenAuthJson` field | **NEW** | Already exists as `lastWrittenCredentialsJson` |
| Track writes in `writeRuntime*()` | **NEW** | Already done (line 271) |
| `readBackRefreshedTokens()` | **NEW** | **NEW** (missing despite having the tracking field) |
| `detectExternalLoginAndUpdateSnapshot()` | **NEW** | Already exists (line 224) |
| `clearLastWritten*()` for re-auth | **NEW** | **NEW** (same re-auth clobbering risk) |
### Key differences between services
| Aspect | Claude | Codex |
|--------|--------|-------|
| Auth storage | Keychain (macOS) + `.credentials.json` | `auth.json` only |
| Sync model | Async (serialized via `mutationQueue`) | Synchronous |
| Managed path | `managedAuthPath/.credentials.json` | `managedHomePath/auth.json` |
| Tracking field | `lastWrittenCredentialsJson` | `lastWrittenAuthJson` |
### Codex implementation
Add a `lastWrittenAuthJson` field and three new behaviors:
1. **`writeRuntimeAuth()`** — record what was written
2. **`readBackRefreshedTokens()`** — before overwriting, check if Codex CLI refreshed the token
3. **`detectExternalLoginAndUpdateSnapshot()`** — on managed→system-default transition, detect external logins
### Pseudocode
```typescript
export class CodexRuntimeHomeService {
private lastSyncedAccountId: string | null = null
// Why: tracks the auth.json content Orca last wrote to ~/.codex/auth.json.
// On managed→system-default transition, if the file differs from this value,
// an external login (e.g. `codex auth login`) overwrote it — so Orca adopts
// the file as the new system default instead of restoring a stale snapshot.
// Between syncs, if the file differs, Codex CLI refreshed the token — so
// Orca writes back the refreshed token to managed storage.
private lastWrittenAuthJson: string | null = null
// ... constructor, prepare methods unchanged ...
syncForCurrentSelection(): void {
this.captureSystemDefaultSnapshotIfNeeded()
const settings = this.store.getSettings()
const activeAccount = this.getActiveAccount(
settings.codexManagedAccounts,
settings.activeCodexManagedAccountId
)
if (!activeAccount) {
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
}
return
}
const activeAuthPath = join(activeAccount.managedHomePath, 'auth.json')
if (!existsSync(activeAuthPath)) {
console.warn(
'[codex-runtime-home] Active managed account is missing auth.json, restoring system default'
)
this.store.updateSettings({ activeCodexManagedAccountId: null })
if (this.lastSyncedAccountId !== null) {
this.restoreSystemDefaultSnapshot()
this.lastSyncedAccountId = null
}
return
}
// NEW: Before overwriting runtime auth, check if Codex CLI refreshed
// the token since our last write. If so, preserve those refreshed tokens
// back to managed storage so they aren't lost.
if (this.lastSyncedAccountId === activeAccount.id) {
this.readBackRefreshedTokens(activeAuthPath)
}
this.lastSyncedAccountId = activeAccount.id
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
}
// Why: Codex CLI refreshes expired OAuth tokens and writes them back to
// ~/.codex/auth.json. If we detect the runtime file differs from what Orca
// last wrote, the CLI must have refreshed — so we write the updated tokens
// back to managed storage before overwriting runtime with managed state.
// This is the Codex analog of the read-back logic implied by
// lastWrittenCredentialsJson in ClaudeRuntimeAuthService.
private readBackRefreshedTokens(managedAuthPath: string): void {
try {
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return
}
// Nothing to compare against — first sync or after restart.
// Skip read-back to avoid capturing stale/unknown state.
if (this.lastWrittenAuthJson === null) {
return
}
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
if (runtimeContents === this.lastWrittenAuthJson) {
return
}
// Codex CLI refreshed tokens at runtime — preserve them in managed storage.
writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 })
} catch (error) {
// Why: read-back is best-effort. A transient fs error (permissions,
// file locked by another process) must not block the forward sync
// path — the worst case is one more stale-token cycle, which is
// strictly better than failing the entire sync.
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
}
}
private restoreSystemDefaultSnapshot(): void {
// Why: detect whether an external tool (e.g. `codex auth login`) overwrote
// auth.json while a managed account was active. If so, that external login
// becomes the new system default — skip the stale snapshot restore.
if (this.detectExternalLoginAndUpdateSnapshot()) {
return
}
const snapshotPath = this.getSystemDefaultSnapshotPath()
if (!existsSync(snapshotPath)) {
return
}
this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8'))
}
// Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot().
// If the runtime auth.json differs from what Orca last wrote, something
// external changed it. That external state should become the new system
// default rather than being overwritten by a potentially stale snapshot.
private detectExternalLoginAndUpdateSnapshot(): boolean {
if (this.lastWrittenAuthJson === null) {
return false
}
const runtimeAuthPath = this.getRuntimeAuthPath()
if (!existsSync(runtimeAuthPath)) {
return false
}
try {
const currentAuth = readFileSync(runtimeAuthPath, 'utf-8')
if (currentAuth === this.lastWrittenAuthJson) {
return false
}
} catch {
return false
}
// External login detected — adopt current state as the new system default.
const snapshotPath = this.getSystemDefaultSnapshotPath()
rmSync(snapshotPath, { force: true })
this.lastWrittenAuthJson = null
return true
}
private writeRuntimeAuth(contents: string): void {
writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 })
// Why: record what we wrote so readBackRefreshedTokens() and
// detectExternalLoginAndUpdateSnapshot() can detect external changes.
this.lastWrittenAuthJson = contents
}
}
```
### Re-auth safety: clearing `lastWrittenAuthJson`
When `CodexAccountService.doReauthenticateAccount()` runs, it writes fresh tokens to managed storage via `codex login`, then calls `syncForCurrentSelection()`. Without intervention, the read-back logic would see that runtime differs from `lastWrittenAuthJson` (because Codex CLI may have refreshed the runtime token between syncs) and write the stale runtime content back to managed — overwriting the fresh re-auth tokens.
**Fix:** `CodexRuntimeHomeService` must expose a method to clear `lastWrittenAuthJson` so that the re-auth caller can signal "managed storage was externally updated, skip read-back on next sync."
```typescript
// Called by CodexAccountService before syncForCurrentSelection() after re-auth or add-account.
clearLastWrittenAuthJson(): void {
this.lastWrittenAuthJson = null
}
```
The same applies to `doAddAccount()`, which also writes managed auth then syncs. Both callers must call `clearLastWrittenAuthJson()` before `syncForCurrentSelection()`.
### Changes to `CodexAccountService`
Both `doAddAccount()` and `doReauthenticateAccount()` write fresh tokens to managed storage (via `codex login`) then call `syncForCurrentSelection()`. They must clear the tracking field first to prevent read-back from overwriting the fresh tokens:
```typescript
// In doAddAccount(), after runCodexLogin succeeds:
this.runtimeHome.clearLastWrittenAuthJson()
this.runtimeHome.syncForCurrentSelection()
// In doReauthenticateAccount(), after runCodexLogin succeeds:
this.runtimeHome.clearLastWrittenAuthJson()
this.runtimeHome.syncForCurrentSelection()
```
### Safety considerations
- **Only reads back when Orca owns the runtime auth** (`lastSyncedAccountId === activeAccount.id`), so external `codex login` changes are not accidentally captured into the wrong managed account
- **Skips read-back when `lastWrittenAuthJson` is null** (first sync, after restart, or after re-auth/add) — avoids capturing stale or unknown state that Orca didn't write
- **try/catch around read-back** — a transient fs error (permissions, locked file) logs a warning but does not block the forward sync. Worst case: one more stale-token cycle, strictly better than a sync failure
- **try/catch around external login detection** — same rationale; detection failure falls through to normal snapshot restore, which is the safe default
- **Atomic write** to managed auth prevents partial-write corruption
- **No behavior change for account switches** — when switching accounts, `lastSyncedAccountId` differs from the new account ID, so no read-back occurs
- **No behavior change for system-default flow** — read-back only runs when a managed account is active and was previously synced
- **External login detection on managed→system-default transition** — if `codex auth login` ran while a managed account was active, the snapshot is stale. Deleting it and clearing `lastWrittenAuthJson` lets the external login persist as the new system default
- **In-memory tracking only**`lastWrittenAuthJson` is not persisted to disk. After an Orca restart, the field is null, and the first sync performs a clean write without read-back. This is intentionally conservative: we'd rather do one redundant overwrite than risk reading back unknown state
### Claude implementation
Claude already has `lastWrittenCredentialsJson` tracking and `detectExternalLoginAndUpdateSnapshot()`. It only needs two additions:
1. **`readBackRefreshedTokens()`** — same logic as Codex, adapted for Claude's async model and Keychain
2. **`clearLastWrittenCredentialsJson()`** — for re-auth safety (same pattern as Codex)
```typescript
// In ClaudeRuntimeAuthService:
// Add to doSyncForCurrentSelection(), before writeRuntimeCredentials():
if (this.lastSyncedAccountId === activeAccount.id) {
await this.readBackRefreshedTokens(activeAccount)
}
// Why: Claude CLI refreshes expired OAuth tokens and writes them back to
// .credentials.json (and Keychain on macOS). If we detect the runtime file
// differs from what Orca last wrote, the CLI must have refreshed.
private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise<void> {
try {
if (this.lastWrittenCredentialsJson === null) {
return
}
const paths = this.pathResolver.getRuntimePaths()
if (!existsSync(paths.credentialsPath)) {
return
}
const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8')
if (runtimeContents === this.lastWrittenCredentialsJson) {
return
}
// CLI refreshed tokens — write back to managed storage.
if (process.platform === 'darwin') {
await writeManagedClaudeKeychainCredentials(account.id, runtimeContents)
} else {
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 })
}
} catch (error) {
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
}
}
// Exposed for ClaudeAccountService to call before sync after re-auth/add.
clearLastWrittenCredentialsJson(): void {
this.lastWrittenCredentialsJson = null
}
```
### Changes to `ClaudeAccountService`
Same pattern as Codex — both `doAddAccount()` and `doReauthenticateAccount()` must clear the tracking field before syncing:
```typescript
// In doAddAccount(), after login succeeds:
this.runtimeAuth.clearLastWrittenCredentialsJson()
await this.runtimeAuth.syncForCurrentSelection()
// In doReauthenticateAccount(), after login succeeds:
this.runtimeAuth.clearLastWrittenCredentialsJson()
await this.runtimeAuth.syncForCurrentSelection()
```
### Structural alignment
The two services should stay structurally aligned. Both now have the same three mechanisms:
| Mechanism | Codex | Claude |
|-----------|-------|--------|
| Track what we wrote | `lastWrittenAuthJson` | `lastWrittenCredentialsJson` |
| Read back refreshed tokens | `readBackRefreshedTokens()` | `readBackRefreshedTokens()` |
| Detect external logins | `detectExternalLoginAndUpdateSnapshot()` | `detectExternalLoginAndUpdateSnapshot()` |
| Clear tracking on re-auth | `clearLastWrittenAuthJson()` | `clearLastWrittenCredentialsJson()` |
Future changes to either service should be cross-checked against the other.
### Files to modify
**Codex:**
- `src/main/codex-accounts/runtime-home-service.ts` — add `lastWrittenAuthJson` field, `readBackRefreshedTokens()`, `detectExternalLoginAndUpdateSnapshot()`, `clearLastWrittenAuthJson()`, update `writeRuntimeAuth()` and `restoreSystemDefaultSnapshot()`
- `src/main/codex-accounts/service.ts` — add `clearLastWrittenAuthJson()` calls in `doAddAccount()` and `doReauthenticateAccount()`
- `src/main/codex-accounts/runtime-home-service.test.ts` — add test cases
**Claude:**
- `src/main/claude-accounts/runtime-auth-service.ts` — add `readBackRefreshedTokens()`, `clearLastWrittenCredentialsJson()`, call read-back in `doSyncForCurrentSelection()`
- `src/main/claude-accounts/service.ts` — add `clearLastWrittenCredentialsJson()` calls in `doAddAccount()` and `doReauthenticateAccount()`
- `src/main/claude-accounts/runtime-auth-service.test.ts` — add test cases
**Test cases (both services):**
- Token read-back when CLI refreshes tokens between syncs
- No read-back on first sync (tracking field is null)
- No read-back on account switch (`lastSyncedAccountId` differs)
- External login detection on managed→system-default transition
- Graceful degradation when read-back throws (fs error)
- Re-auth does not lose fresh tokens: after clearing tracking field + sync, managed storage retains the re-auth tokens
- Add-account does not lose fresh tokens: same pattern as re-auth

View File

@ -1,443 +0,0 @@
# Fix: Missing single-instance lock corrupts `orca-runtime.json` + `endpoint.env` on every relaunch
**Issue:** [#1312](https://github.com/stablyai/orca/issues/1312)
**Status:** Proposed fix
**Approach:** Add `app.requestSingleInstanceLock()`, clear owned metadata on clean exit, and sweep orphaned sockets on startup.
## Problem
Orca v1.3.24 does not call `app.requestSingleInstanceLock()`. Every launch of the AppImage / `.app` bundle starts a new Electron main process that unconditionally:
1. Opens a fresh Unix socket `o-<NEW_PID>-<runtimeId-prefix>.sock`.
2. Atomically rewrites `<userData>/orca-runtime.json` with the new pid / socket / runtimeId / authToken, clobbering the previously-running instance's metadata.
3. Picks a new random port and rewrites `<userData>/agent-hooks/endpoint.env` with the new port + token.
The earlier Orca keeps its socket + hook-port alive, but the canonical metadata files no longer point at it. When the most-recent instance quits, the socket is `rmSync`'d in `OrcaRuntimeRpcServer.stop()` but the metadata file is left pointing at the dead pid. `orca status` then returns `runtime.state = 'stale_bootstrap'` even though earlier Orca instances are still running healthily.
### Root cause
Three distinct gaps in the main-process lifecycle:
1. **No single-instance lock.** `grep -rn requestSingleInstanceLock src/` returns zero matches. Every launch boots a full second Electron process instead of focusing the existing window.
2. **No metadata clear on clean exit.** `src/main/runtime/runtime-metadata.ts:29` exports `clearRuntimeMetadata()` but it is never called. The `will-quit` handler in `src/main/index.ts:516` stops the RPC server (removes the socket) but leaves `orca-runtime.json` on disk pointing at a dead pid + missing socket. The safety comment in `src/main/runtime/runtime-rpc.ts:132-136` explicitly declines to clear metadata, citing the risk of erasing another live runtime's bootstrap during restarts / updates / dev overlap — that concern is real but is the *symptom* of missing single-instance, not a principled design.
3. **No startup sweep of orphaned sockets.** A process killed by SIGKILL / OOM-kill skips `OrcaRuntimeRpcServer.stop()` entirely, leaving `o-<dead-pid>-*.sock` files in `<userData>/` with no cleanup path. The reporter observed three such orphans on a single live system.
### Symptom (from the issue)
Healthy state with one orca running (pid 50926):
```json
{
"app": { "running": true, "pid": 50926 },
"runtime": { "state": "ready", "reachable": true, "runtimeId": "2ad0..." },
"graph": { "state": "ready" }
}
```
After launching a second instance and quitting it while the first stays open:
```json
{
"app": { "running": false, "pid": null },
"runtime": { "state": "stale_bootstrap", "reachable": false, "runtimeId": null },
"graph": { "state": "not_running" }
}
```
The `stale_bootstrap` branch is distinguished at `src/cli/runtime/status.ts:17-20` — "metadata file exists but pid not running" vs. "no metadata at all".
### Affected code
- `src/main/index.ts` — app lifecycle entry; `app.whenReady` at line 315, `will-quit` at line 516.
- `src/main/runtime/runtime-rpc.ts``OrcaRuntimeRpcServer.start()` line 51, `stop()` line 112 (with the deferred-cleanup comment at lines 132-136).
- `src/main/runtime/runtime-metadata.ts``writeRuntimeMetadata` / `readRuntimeMetadata` / `clearRuntimeMetadata`.
- `src/main/agent-hooks/server.ts``writeEndpointFile` line 1299, `stop()` line 1223 (with a parallel deferred-cleanup comment at lines 1230-1237).
- `src/shared/runtime-bootstrap.ts``RuntimeMetadata` shape and `getRuntimeMetadataPath()`.
- `src/main/startup/configure-process.ts``configureDevUserDataPath(isDev)` redirects dev runs to `orca-dev` userData.
- `src/cli/runtime/status.ts` — CLI reader, never writes metadata.
## Architecture
### Current (buggy) state
Every launch boots a new Electron main. All live instances race on the same `orca-runtime.json` / `endpoint.env`, and SIGKILL'd predecessors leave orphaned sockets behind.
```
<userData>/ (e.g. ~/.config/orca)
┌────────────────────────────────────────────┐
│ orca-runtime.json ← clobbered on every │
│ launch (last writer │
│ wins, pid may be │
│ dead) │
│ agent-hooks/endpoint.env ← same race │
│ o-<pid-A>-*.sock (live, owned by A) │
│ o-<pid-B>-*.sock (orphan — B SIGKILL'd) │
│ o-<pid-C>-*.sock (orphan — C OOM-killed)│
└────────────────────────────────────────────┘
▲ ▲ ▲
│ writes │ writes │ writes
│ │ │
┌───────┴──┐ ┌──────┴───┐ ┌─────┴────┐
│ Electron │ │ Electron │ │ Electron │
#1 │ │ #2 │ │ #3
│ (live, │ │ (live, │ │ (quit; │
│ hook │ │ hook │ │ left │
│ HTTP) │ │ HTTP) │ │ stale │
└──────────┘ └──────────┘ │ meta) │
└──────────┘
│ reads orca-runtime.json
│ → sees wrong/dead pid
┌───────┴───────┐
│ CLI (status) │ reports 'stale_bootstrap'
└───────────────┘
```
### Post-fix state
A single Electron owns the userData. Second launches fire `second-instance` and exit. The ownership guard at `clearRuntimeMetadataIfOwned()` protects the auto-updater handoff. Sweep runs at startup.
```
<userData>/
┌────────────────────────────────────────────┐
│ orca-runtime.json ← one writer (pid A) │
│ agent-hooks/endpoint.env │
│ o-<pid-A>-*.sock (live, owned by A) │
│ (orphans swept on next start) │
└────────────────────────────────────────────┘
│ writes
┌───────┴──────────────┐
│ Electron #1 (holds │──── hook HTTP ──►
│ single-instance lock)│
└──────────▲───────────┘
│ 'second-instance' event
┌──────────┴───────────┐
│ Electron #2 (boots, │
│ lock fails, focuses │
#1's window, quits) │── transient, no writes
└──────────────────────┘
│ reads orca-runtime.json
┌───────┴───────┐
│ CLI (status) │ reports 'ready'
└───────────────┘
Ownership guard site: src/main/runtime/runtime-metadata.ts
→ clearRuntimeMetadataIfOwned(userData, ownedPid, ownedRuntimeId)
```
### Data flow
**Happy path — single instance steady state**
```
[user action] → Electron #1 main loop
→ Electron #1 RPC server (Unix socket) handles IPC
→ hook HTTP server answers localhost requests
→ orca-runtime.json unchanged (only written at start)
→ CLI reads orca-runtime.json → 'ready'
```
**First-launch cold start**
```
[launch] → configureDevUserDataPath(is.dev) (startup/configure-process.ts)
→ app.requestSingleInstanceLock() → true (main/index.ts)
→ sweepOrphanedRuntimeSockets(userData) (runtime-rpc.ts start())
→ OrcaRuntimeRpcServer.start() binds o-<pid>-*.sock
→ writeRuntimeMetadata({pid, runtimeId, ...}) (runtime-metadata.ts)
→ agent-hooks server writes endpoint.env
→ openMainWindow()
```
**Second-launch rejected**
```
[launch] → configureDevUserDataPath(is.dev)
→ app.requestSingleInstanceLock() → false (lock held by #1)
→ app.quit() (transient process exits)
→ Electron #1 receives 'second-instance' event
→ Electron #1 restores + focuses mainWindow
→ no writes to orca-runtime.json or endpoint.env
```
**Auto-updater handoff (two orderings, both safe)**
```
Ordering X: old clears first
old.runtimeRpc.stop() rmSync's o-<oldPid>-*.sock
old.clearRuntimeMetadataIfOwned() — current.pid == oldPid → clear
new.writeRuntimeMetadata() — fresh file, no conflict
Ordering Y: new writes first
new.writeRuntimeMetadata() — file now points at newPid/newRuntimeId
old.clearRuntimeMetadataIfOwned() — current.pid != oldPid → SUPPRESSED
new continues undisturbed
Invariant: the ownership guard (pid + runtimeId match) is what makes
both orderings safe. Without it, Ordering Y would erase the new
process's just-written metadata.
```
## Proposed fix
Three surgical changes, in decreasing order of importance:
### 1. Single-instance lock (primary — kills ~90% of the bug surface)
In `src/main/index.ts`, immediately **after** `configureDevUserDataPath(is.dev)` (line 89) and **before** any handler registration or `app.whenReady(...)`, gate the rest of the module on a new helper `acquireSingleInstanceLock()` (see "Helper extraction" in the testing section):
```ts
function focusExistingWindow(): void {
// Why: focus the existing window instead of spawning a parallel Electron
// process that would clobber orca-runtime.json and endpoint.env.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
// Pre-window case: the active instance is still booting and will call
// openMainWindow() from whenReady(). No action needed here.
}
if (!acquireSingleInstanceLock(app, focusExistingWindow)) {
if (is.dev) {
console.log(
'Another Orca instance is already running against this userData path — focusing existing window.'
)
}
app.quit()
// Why: early-return is what prevents the service constructors and handler
// registrations below (whenReady, before-quit, will-quit, etc.) from
// running in a process that is already losing the lock race.
return
}
// All existing app lifecycle setup below.
```
**Placement rationale (from codex review):** the lock must come *after* `configureDevUserDataPath(is.dev)` so dev (`orca-dev` userData) and packaged (`orca` userData) instances lock in separate namespaces — Electron derives the lock identity from the `userData` path. Placing it before would force dev and packaged builds to serialize against each other, which is the opposite of the current dev/prod isolation contract.
**Dev-mode behavior:** kept unconditional. `configureDevUserDataPath` already isolates `pnpm dev` from packaged runs, so devs who want two instances already get the answer by running both a packaged build and a dev build. Allowing multi-instance against the same userData reopens the exact corruption this fix targets.
**Dev-mode ergonomics:** when the lock fails in dev mode, write a single `console.log('Another Orca instance is already running against this userData path — focusing existing window.')` immediately before `app.quit()`. This makes the behavior discoverable in the `pnpm dev` console so internal devs don't mistake a silent exit for a broken launcher. Packaged runs skip the log — there's no attached console.
### 2. Clear owned metadata on clean exit (secondary — hardens `stale_bootstrap` reporting)
Add a guarded clear in `src/main/runtime/runtime-metadata.ts`:
```ts
/**
* Why: clearing metadata unconditionally would race with a sibling Orca
* process during auto-updater handoff or dev/prod overlap — see the comment
* in runtime-rpc.ts:132-136. The ownership guard preserves the original
* safety invariant while still letting us report 'not_running' (not
* 'stale_bootstrap') after a clean exit.
*/
export function clearRuntimeMetadataIfOwned(
userDataPath: string,
ownedPid: number,
ownedRuntimeId: string
): void {
const current = readRuntimeMetadata(userDataPath)
if (!current) return
if (current.pid !== ownedPid) return
if (current.runtimeId !== ownedRuntimeId) return
clearRuntimeMetadata(userDataPath)
}
```
In `src/main/index.ts` `will-quit` handler (line 516-548), the clear must be **awaited before Electron exits**, not fire-and-forget. The existing handler uses a two-pass `preventDefault()` pattern with `disconnectDaemon().finally(app.quit())`. The existing `void runtimeRpc.stop().catch(...)` at line 530-534 currently fires independently of that chain; Electron may exit before `runtimeRpc.stop()` resolves, and a `.then(clear)` appended to it would race the second-pass `app.quit()`. Fold both into the awaited chain instead — and do the work **inside** the `!daemonDisconnectDone` guard so `runtimeRpc.stop()` fires exactly once, on the first pass:
```ts
if (!daemonDisconnectDone) {
e.preventDefault()
// Why: capture ownership synchronously (before any await) so the guard
// still has the right pid/runtimeId to compare against if shutdown
// partially clears global state. Evaluating these inside .then() would
// let a later teardown path null them out mid-chain.
const ownedPid = process.pid
const ownedRuntimeId = runtime?.getRuntimeId()
// Why: the construction AND the allSettled() must both live inside the
// `!daemonDisconnectDone` guard. The will-quit handler re-fires after
// app.quit() below; without this guard, the second pass would re-invoke
// runtimeRpc.stop() (redundant rmSync on an already-removed socket) and
// re-run the ownership-guarded clear against a metadata file that may
// now belong to the auto-updater's replacement process.
const rpcStopAndClear = runtimeRpc
? runtimeRpc
.stop()
.then(() => {
if (ownedRuntimeId) {
clearRuntimeMetadataIfOwned(app.getPath('userData'), ownedPid, ownedRuntimeId)
}
})
.catch((error) => {
console.error('[runtime] Failed to stop local RPC transport:', error)
})
: Promise.resolve()
// Why: Promise.allSettled — we need BOTH the daemon disconnect and the RPC
// stop + owned-metadata clear to complete before Electron exits. Using
// allSettled (not all) preserves the existing fail-open posture: if
// disconnectDaemon rejects, we still quit instead of hanging the app.
Promise.allSettled([disconnectDaemon(), rpcStopAndClear]).then(() => {
daemonDisconnectDone = true
app.quit()
})
}
```
Remove the standalone `void runtimeRpc.stop().catch(...)` at line 530-534 — it is folded into `rpcStopAndClear` above.
**Why compare-before-clear is not over-engineering:** `autoUpdater.quitAndInstall()` quits the old process immediately and relaunches. The new process may already be starting (writing its own metadata) while the old `will-quit` is running. An unconditional clear would delete the new process's fresh metadata. Codex confirmed this is the correct posture.
### 3. Orphaned-socket sweep on startup (tertiary — hygiene)
In `OrcaRuntimeRpcServer.start()` (`src/main/runtime/runtime-rpc.ts:51`), before creating the new listener, enumerate `<userData>/o-*.sock` and remove any whose pid component is dead:
```ts
// Why: processes killed by SIGKILL / OOM-kill skip stop() and leave behind
// o-<pid>-*.sock files. Sweep dead-pid sockets on startup so the userData
// directory does not accumulate orphans over the app's lifetime.
function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void {
let entries: string[]
try {
entries = readdirSync(userDataPath)
} catch {
return
}
for (const entry of entries) {
const match = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/.exec(entry)
if (!match) continue
const pid = Number(match[1])
if (!Number.isFinite(pid) || pid === ownPid) continue
try {
process.kill(pid, 0)
// Pid is alive — leave its socket alone. Another Orca instance owns it.
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
try {
rmSync(join(userDataPath, entry), { force: true })
} catch {
// Best-effort sweep; a permission error is fine to ignore.
}
}
}
}
}
```
**Constraints:** only sweep sockets with the exact `o-<digits>-<base64url-ish>.sock` shape; skip anything else. Never touch the current process's own pid even though the stricter `start()` flow already rmSync's it.
**Windows note:** this sweep is POSIX-only by construction — on Windows the transport is a named pipe (`\\.\pipe\orca-<pid>-<suffix>`) and named pipes don't leave filesystem entries in `userData`. No sweep needed.
**Socket-name invariant:** the sweep regex `^o-(\d+)-[A-Za-z0-9_-]+\.sock$` must stay in lockstep with `createRuntimeTransportMetadata()` in `runtime-rpc.ts`, which emits `o-${pid}-${endpointSuffix}.sock`. The cheap guard — and what this doc recommends — is a unit test that constructs a transport name via `createRuntimeTransportMetadata()` and asserts the sweep regex matches it; any future change to the socket-name shape trips the test. Longer-term, if the transport name grows more fields, the preferred answer is to export a shared constant or factory from `runtime-rpc.ts` that both the creator and the sweep regex consume, so the two can never drift by construction. Deferred here to keep the diff surgical.
## Deliberately not in scope
- **No CLI-side reader fallback.** Scanning for any live `o-*.sock` in `getCliStatus()` would require a per-socket sidecar metadata file (authToken, runtimeId) since today only one shared `orca-runtime.json` exists. That is a multi-instance-by-design change, not a bug fix. The fix above assumes the intended model is one Orca per userData.
- **No per-instance userData.** The env-var override `ORCA_USER_DATA_PATH` at `src/cli/runtime/metadata.js` already exists for power users who genuinely want parallel runtimes. No change.
- **No unlink of `endpoint.env` on stop.** The deferred-cleanup rationale at `src/main/agent-hooks/server.ts:1230-1237` (TOCTOU with a concurrent writer) is still correct under the single-instance lock: auto-update handoff still briefly has two processes sharing the file. Leaving the file unchanged on quit matches the existing fail-open policy and no user symptom motivates changing it.
## Testing strategy
### Unit tests (`src/main/runtime/runtime-metadata.test.ts`, extend)
- `clearRuntimeMetadataIfOwned` with matching `{pid, runtimeId}` → file removed.
- `clearRuntimeMetadataIfOwned` with mismatched pid → file retained.
- `clearRuntimeMetadataIfOwned` with mismatched runtimeId → file retained (simulates another instance having overwritten while we were still alive).
- `clearRuntimeMetadataIfOwned` with no file → no-op, no throw.
### Unit tests (new `src/main/runtime/runtime-socket-sweep.test.ts`)
Seed a temp userData directory with four entries so the three retention branches are distinct:
- `o-1-aaaa.sock` → own-pid-skipped branch (test passes `ownPid: 1`, so this is skipped via the `pid === ownPid` early-exit).
- `o-<process.pid>-bbbb.sock` → alive-but-not-own branch (`process.kill(pid, 0)` succeeds without `ESRCH` → retained).
- `o-99999999-cccc.sock` → dead-pid branch (`process.kill(pid, 0)` throws `ESRCH` → swept).
- `foo.sock` → non-matching-shape branch (regex miss → retained untouched).
Run sweep with `ownPid = 1` and assert: the `o-99999999-*.sock` file is gone; the other three remain. Using a synthetic `ownPid` (init pid `1`, effectively always alive on POSIX and never the test runner's own pid) means each of the three retained-entries covers a *distinct* code path, instead of collapsing "own-pid-skipped" and "alive-non-own-pid-retained" into the same observation.
### Helper extraction for the single-instance lock
Extract the lock acquisition into `src/main/startup/single-instance-lock.ts`:
```ts
export function acquireSingleInstanceLock(
app: Electron.App,
onSecondInstance: () => void
): boolean {
if (!app.requestSingleInstanceLock()) {
return false
}
app.on('second-instance', onSecondInstance)
return true
}
```
`src/main/index.ts` wires it up inline after `configureDevUserDataPath(is.dev)`. Because the module's top-level cannot `return`, gate the file-writing init (`initDataPath` / `initStatsPath` / `initClaudeUsagePath` / `initCodexUsagePath` / `enableMainProcessGpuFeatures` / `installDevParentWatchdog` / `installDevParentDisconnectQuit`) behind an `if (hasSingleInstanceLock)` block and let the lifecycle handler registrations fire unconditionally — `app.quit()` prevents `whenReady` from ever dispatching, so `runtime` / `runtimeRpc` / `store` / `stats` stay `null` and every shutdown handler short-circuits via optional chaining:
```ts
const hasSingleInstanceLock = acquireSingleInstanceLock(app, focusExistingWindow)
if (!hasSingleInstanceLock) {
if (is.dev) {
console.log(
'[single-instance] Another Orca instance is already running against this userData path — focusing existing window.'
)
}
app.quit()
}
if (hasSingleInstanceLock) {
installDevParentDisconnectQuit(is.dev)
installDevParentWatchdog(is.dev)
initDataPath()
initStatsPath()
initClaudeUsagePath()
initCodexUsagePath()
enableMainProcessGpuFeatures()
}
```
### Unit test of the helper (`src/main/startup/single-instance-lock.test.ts`)
With a fake `app` (stub `requestSingleInstanceLock()` + `on()`):
- When `requestSingleInstanceLock()` returns `false` → helper returns `false` and does NOT register `'second-instance'`.
- When it returns `true` → helper returns `true` and registers exactly one `'second-instance'` listener that invokes the callback.
### Integration assertion (narrow)
Do NOT try to assert "downstream service constructors were not invoked" by re-importing `src/main/index.ts`. Module-level side effects at lines 67-101 (dev watchdog, shell path hydration, userData path configuration) run before the lock check and would fire regardless, either failing the test or requiring heavy mocking.
Instead, the integration surface the fix cares about is already covered by: (a) the helper unit test above, and (b) the manual repro below. If deeper coverage is wanted later, the right move is to refactor `src/main/index.ts` so the `whenReady`-guarded setup is exported as a testable function — that is a separate refactor and deliberately out of scope for this fix.
### Manual repro (matches the issue's steps)
1. Launch packaged Orca. `cp ~/.config/orca/orca-runtime.json /tmp/before.json`.
2. Launch the AppImage / `.app` a second time. Verify the existing window receives focus (no new window appears) and `stat` shows `orca-runtime.json` mtime is unchanged.
3. Quit Orca. Verify `~/.config/orca/orca-runtime.json` is gone and `orca status --json` reports `runtime.state = 'not_running'` (not `'stale_bootstrap'`).
4. Kill a running Orca with `SIGKILL`. Verify that the next Orca launch removes the orphaned `o-<killed-pid>-*.sock` during `OrcaRuntimeRpcServer.start()`.
### Regression watch — auto-updater
`autoUpdater.quitAndInstall()` in `src/main/updater.ts:194` triggers the relaunch flow. The handoff is short (~1s) but non-atomic, so the design relies on the ownership guard to keep both interleavings safe:
- **Socket is removed before the new process binds.** The old process's `runtimeRpc.stop()` `rmSync`'s `o-<oldPid>-*.sock` as part of teardown. The new process then binds its own `o-<newPid>-*.sock`. The brief window of inconsistency is "metadata absent" + "no socket", never "metadata points at a live-but-wrong pid with a still-mounted old socket."
- **New writes before old clears (Ordering Y).** If the new process calls `writeRuntimeMetadata()` before the old process's `clearRuntimeMetadataIfOwned()` runs, the guard sees `current.pid !== ownedPid` (and `current.runtimeId !== ownedRuntimeId`) and suppresses the clear. The new metadata survives.
- **Old clears before new writes (Ordering X).** The old process finds its own pid + runtimeId in the file, clears it, and exits. The new process then writes its fresh metadata onto an empty slot. Same end state.
- **CLI behavior during the ~1s window.** `orca status` transitions `ready``not_running` (clean, not `stale_bootstrap`) → `starting``ready`. It never reports a wrong-pid reading, because the only two observable file states are "file for old pid" (pre-teardown) and "file for new pid" (post-write); the guard prevents a mixed state.
A future regression report that catches the transient `not_running` during an auto-update should be recognized as intended behavior.
### Cross-platform coverage
- **macOS / Linux / Windows:** single-instance lock path is identical (Electron handles the OS-level plumbing).
- **Windows:** named-pipe transport means no socket-sweep work; assert the sweep early-returns on `platform === 'win32'` (or simply does nothing because no matching entries exist).
- **Dev mode:** lock acquired against `orca-dev` userData; asserted by running `pnpm dev` twice and seeing the second exit immediately.
## Confidence
High. The diagnosis matches the reported symptom and filesystem evidence one-for-one; the fix adds only calls that Electron and the existing metadata module already expose. Second opinion from codex flagged the `configureDevUserDataPath` ordering constraint and the `clearRuntimeMetadataIfOwned` ownership guard, both incorporated above.
Concretely, the fix closes the reported data-integrity scenarios: double-click relaunch, `gtk-launch` relaunch, clean-quit of a second instance while the first stays open, and orphaned `o-<pid>-*.sock` files left by SIGKILL / OOM-kill. It deliberately leaves two narrow scenarios open: (1) the auto-updater handoff, where `orca status` may transiently report `not_running` for ~1s between the old process's clear and the new process's write — covered in the regression-watch section above; and (2) deliberate multi-instance launches via `ORCA_USER_DATA_PATH` / `ORCA_DEV_USER_DATA_PATH`, which remain an explicit power-user escape hatch with isolated userData per instance and are not in scope for this fix.

View File

@ -1,628 +0,0 @@
# Fix per-session PTY fd leak in the current daemon
## Status: resolved (2026-05-01)
**Native-side root cause confirmed and patched.** E2E validation under both Node and Electron ABIs: 50 spawn/kill cycles, ptmx count stays at baseline (0). Before the patch, the same harness reproduced a 1-fd-per-spawn linear leak (5 → 55 ptmx fds over 50 cycles).
## Root cause (actual)
The leak is a native-side off-by-one in `node-pty@1.1.0`'s `pty_posix_spawn` cleanup loop on macOS. In `src/unix/pty.cc`:
- **Allocation** (lines 697-701) walks `low_fds[0..2]`, allocating via `posix_openpt(O_RDWR)`. The loop `break`s at the first `low_fds[count] >= STDERR_FILENO`, so `count` holds the index of the last allocated slot (0, 1, or 2). These are decoy `/dev/ptmx` handles used as a workaround for a macOS pty race condition that ensures the real master fd lands above `STDERR_FILENO`.
- **Cleanup** (lines 781-783, buggy):
```c
for (; count > 0; count--) {
close(low_fds[count]);
}
```
When the typical case triggers (`break` at `count=0`, i.e. the very first `posix_openpt` returned an fd ≥ 2), the loop body never executes — `low_fds[0]` is never closed. Every spawn leaks one ptmx handle.
Upstream fixed this in commit [`af053f2`](https://github.com/microsoft/node-pty/commit/af053f2) (PR #882, 2026-01-28), but version 1.1.0 (pinned here) predates that fix.
**Applied fix:** minimal 3-line backport in `config/patches/node-pty@1.1.0.patch`:
```c
- for (; count > 0; count--) {
- close(low_fds[count]);
+ for (size_t i = 0; i <= count && i < 3; i++) {
+ close(low_fds[i]);
}
```
The `i < 3` bound is an over-cautious defense against the pathological case where all three `posix_openpt` calls returned fds < `STDERR_FILENO` (loop exhausts to `count=3`, which would otherwise read `low_fds[3]` — UB in upstream's fix too, though unreachable in practice).
## Why the JS-side `destroy()` discipline (below) is still correct
The native patch alone closes the leak. But the JS-side changes landed in `pty-subprocess.ts`, `session.ts`, `terminal-host.ts`, `local-pty-provider.ts`, and `relay/pty-handler.ts` remain load-bearing for separate reasons:
1. **SIGHUP-to-recycled-pid hazard.** `UnixTerminal.destroy()` registers `_socket.once('close', () => this.kill('SIGHUP'))`. After the child is reaped, its pid can be recycled to an unrelated user process (Chrome tab, editor, etc.) before the socket close event fires. Neutralizing `proc.kill` on POSIX before `destroy()` prevents delivering SIGHUP to a stranger.
2. **`forceKill`/`signal` guard against recycled pid.** `process.kill(proc.pid, …)` after `onExit` targets a potentially-recycled pid. The internal `dead` guard converts these into no-ops.
3. **Daemon shutdown determinism.** `TerminalHost.dispose()``Session.forceKillAndDisposeSubprocess()` reaps stubborn children via SIGKILL and releases the master fd synchronously, bypassing the 5s `KILL_TIMEOUT_MS` fallback.
4. **Belt-and-suspenders fd release.** Calling `destroy()` on every teardown path remains the contract that will keep holding if the pinned node-pty is ever replaced or upgraded to a build without the bug.
## Original problem statement (for historical context)
## Problem
The current `daemon-v4` is leaking PTY master file descriptors (`ptmx`) at a rate roughly 20× the live session count. In a real reported case, the live v4 daemon was holding **269 `ptmx` fds for only 13 active terminals** — every closed terminal should have returned its `ptmx` to the OS, but the daemon keeps accumulating them until it hits macOS's `kern.tty.ptmx_max=511` cap and every new terminal (Orca or otherwise) fails with "cannot allocate any more pty devices."
This is a distinct bug from the orphaned-daemon issue handled in [auto-kill-old-daemons.md](./auto-kill-old-daemons.md):
| Bug | Fd leak unit | Lifetime | Fix surface |
|---|---|---|---|
| Orphaned daemons | Whole daemon process | Across app upgrades | `daemon-init.ts`, `daemon-pty-router.ts` |
| **This doc** | Per-PTY `ptmx` | Within any node-pty-hosting process | `pty-subprocess.ts` (daemon), `local-pty-provider.ts` (legacy), `relay/pty-handler.ts` (SSH) + wiring |
Both bugs compound each other (stale daemons × leaky sessions), but either one is sufficient to exhaust the cap in a few days of normal use. Fixing orphan daemons alone would only slow down the bleed — the PTY wrappers themselves must stop leaking.
The same missing `destroy()` call exists in all three node-pty-hosting code paths (daemon, legacy local provider, SSH relay), so the fix is scoped to all three.
### Root cause
The master ptmx fd is owned by a `tty.ReadStream` that node-pty stores on the `UnixTerminal` instance as `_socket` (see `node_modules/node-pty/lib/unixTerminal.js`). That socket wraps the fd via libuv. The fd only gets closed when the socket is destroyed, and the socket is only destroyed by one of:
1. `UnixTerminal.destroy()` — calls `_close()`, then `_socket.destroy()`, then `_writeStream.dispose()`.
2. The socket's own `'close'` / `'error'` event firing, which hits the `self._socket.on('close', ...)` wiring inside node-pty's constructor.
None of our current teardown paths reliably trigger either. Specifically:
1. **`forceKill` via raw `process.kill(pid, 'SIGKILL')`** (`pty-subprocess.ts:159-168`) kills the child but does nothing to the libuv socket on the parent side. The slave fd closing in the kernel does not, by itself, cause the parent's read-side socket to emit `'close'` on all platforms / node versions — and even when it does, it races GC.
2. **`dead = true` after a thrown native error** (`pty-subprocess.ts:127-157`) leaves the `_socket` fully alive. We just stop touching `proc`; the read stream keeps the fd.
3. **Session `dispose()` / `handleSubprocessExit`** (`session.ts:189-228`, `session.ts:250-271`) never call anything that reaches `_socket.destroy()`. They kill the child and move on.
So the fd leaks until the JS wrapper is GC'd and libuv finalizes the stream — which may be much later, or never, within the daemon's lifetime. Evidence from the reported case is consistent with this: 269 ptmx 13 live ≈ 256 orphaned fds from terminals that were opened and closed during the daemon's lifetime, which matches a few days of create/kill churn at ~50-100 terminal ops/day.
node-pty already exposes the fix: `UnixTerminal.prototype.destroy` (unixTerminal.js:219) and `WindowsTerminal.prototype.destroy` (windowsTerminal.js:141). We just never call it.
## Goals
- After a PTY terminates (by any path — natural exit, kill, force-kill, native throw, disposal), the master fd is released to the OS on the same tick as teardown.
- The fix covers every code path in the repo that spawns `node-pty`: the daemon, the legacy local provider, and the SSH relay.
- Zero regressions to the `dead` flag pattern (`pty-subprocess.ts:119-122`) that prevents `Napi::Error` from killing the daemon.
- No renderer-visible change. Public IPC / RPC contracts are untouched.
- Measurable: a repeatable test harness spawns N PTYs, terminates them via each exit path, and asserts the fd count returns to baseline.
## Non-goals
- Fixing the orphaned-daemon issue. Handled in [auto-kill-old-daemons.md](./auto-kill-old-daemons.md).
- Killing daemon-side sessions when a worktree is deleted. Separate follow-up.
- Upgrading or replacing `node-pty`. We are calling node-pty's existing `destroy()` — no library change needed.
- Adding a generic "fd leak monitor" watchdog. Observability belongs in a separate effort (see Related work).
## Design decisions
### Call `node-pty`'s `destroy()` on every teardown path
The fix is exactly that. Each provider already wraps the raw `IPty` in its own small handle/struct. Add a single `dispose()` entry point on those wrappers whose body does:
```ts
try {
;(proc as unknown as { destroy?: () => void }).destroy?.()
} catch {
/* swallow — already torn down, or native-side error we can't recover from */
}
```
That is the primary mechanism. `destroy()` is what drives `_close()``_socket.destroy()``_writeStream.dispose()` inside node-pty, which is what releases the master fd. Everything else the wrapper needs (idempotency, callback nulling, SIGKILL fallback for unresponsive children) is a local concern layered around that single call.
### Why not reinvent `destroy()`
An earlier draft of this design proposed a hand-rolled body: null callbacks + raw SIGKILL + `proc.kill()` in try/catch. That was wrong. None of those steps touch `_socket`, which is the thing holding the fd. The only reliable trigger is the close path node-pty already ships — `UnixTerminal.prototype.destroy` on POSIX, `WindowsTerminal.prototype.destroy` on ConPTY. Duplicating that logic in our wrappers would (a) not work on POSIX anyway because `_socket` is private and (b) drift from upstream on any future node-pty update. Forward to `destroy()`, layer idempotency and error-swallowing on top, stop.
### Shared handle contract
Each provider exposes a dispose entry point with the same semantics: idempotent, synchronous, throws never. The daemon already has a `SubprocessHandle` type that is the right place to hang it; the local provider and relay each have equivalent internal records (`ptyProcesses` / `ManagedPty`) and get a small helper function that wraps the same `destroy()` call plus their provider-specific bookkeeping (listener disposal, map cleanup).
```ts
// src/main/daemon/session.ts
export type SubprocessHandle = {
pid: number
write(data: string): void
resize(cols: number, rows: number): void
kill(): void
forceKill(): void
signal(sig: string): void
onData(cb: (data: string) => void): void
onExit(cb: (code: number) => void): void
/** Release the native PTY handle via node-pty's own destroy() path.
* Idempotent. Safe to call after exit. Called by Session on every teardown
* path (natural exit, kill, force-kill, native throw, session dispose). */
dispose(): void
}
```
### Daemon: `pty-subprocess.ts`
Track a `disposed` flag. `dispose()`:
1. If already disposed, return.
2. Mark `disposed = true` and `dead = true`.
3. Null the JS-side `onDataCb` / `onExitCb` references so the wrapper stops fanning out anything after destroy.
4. **On POSIX only, neutralize `proc.kill` on this instance.** Replace it with a no-op before step 5. This closes the pid-recycle SIGHUP hazard described below. Windows is exempt because `WindowsTerminal.destroy` *is* a call to `kill()` — neutralizing it turns destroy() into a no-op and leaks the ConPTY agent.
5. Call `proc.destroy()` inside try/catch. On Unix this runs `_close()``_socket.destroy()``_writeStream.dispose()`; on Windows it defers to the ConPTY agent's close via `_deferNoArgs(_this.kill())` (`windowsTerminal.js:141-146`).
```ts
dispose(): void {
if (disposed) return
disposed = true
dead = true
onDataCb = null
onExitCb = null
// Why: node-pty's UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
// (unixTerminal.js:219-229). On the dispose-while-alive path (e.g. daemon shutdown
// of an active session), SIGTERM has already been sent. The socket close fires
// asynchronously; by then the child may have exited and its pid been recycled
// to an unrelated process. Without this neutralization, SIGHUP can be delivered
// to a Chrome tab, editor, or other user process — silent cross-app corruption.
// `_socket.destroy()` still releases the fd; only the dangerous SIGHUP is removed.
//
// Platform guard: WindowsTerminal.destroy() implements the ConPTY close by
// CALLING `this.kill()` via `_deferNoArgs` (windowsTerminal.js:141-146). If we
// neutralize `kill` on Windows, destroy() becomes a no-op and the ConPTY
// agent leaks. The SIGHUP hazard is POSIX-only, so the neutralization is too.
if (process.platform !== 'win32') {
;(proc as unknown as { kill?: (sig?: string) => void }).kill = () => {}
}
try {
;(proc as unknown as { destroy?: () => void }).destroy?.()
} catch {
/* swallow — already torn down, or native-side error we can't recover from */
}
}
```
`kill` neutralization lives on this `proc` instance only — the wrapper's exposed `kill()` / `forceKill()` entry points call `process.kill(proc.pid, sig)` directly (`pty-subprocess.ts:149-175`), which is unaffected. Callers that want to send SIGTERM/SIGKILL still can; what they can't do after dispose is have node-pty's internal close listener deliver an unexpected SIGHUP against a stale pid.
No extra SIGKILL is needed from `dispose()`: `forceKill` remains available as a separate entry point for the "we need the child gone now" case. Callers that want both (Session on timeout) call `forceKill()` then `dispose()`.
### Daemon: Session / TerminalHost wiring
`Session.dispose()` (`session.ts:189-228`) and `Session.handleSubprocessExit()` (`session.ts:250-271`) each call `this.subprocess.dispose()`:
- `handleSubprocessExit`: call it immediately after updating `_state = 'exited'` and before fan-out to `attachedClients`. try/catch around the call — a throwing dispose must not prevent exit-code delivery.
- `dispose()`: call it at the end, after `emulator.dispose()` and after the attached-client fanout.
- `forceDispose()` (`session.ts:319-347`): same as `dispose()`.
`subprocess.dispose()` is idempotent because `handleSubprocessExit` and the outer `dispose()` can both fire for the same session (manual kill → child exits → handleSubprocessExit runs → later Session.dispose runs).
`TerminalHost.dispose()` (`terminal-host.ts:215-240`) already kills every session. Replace the `session.kill()` call in the shutdown loop with a new `session.forceKillAndDisposeSubprocess()` method, so daemon shutdown reaps stubborn children (SIGKILL) AND releases the PTY master fd synchronously — without depending on the 5s `KILL_TIMEOUT_MS` eventually calling `forceDispose`:
```ts
for (const [, session] of this.sessions) {
session.detachAllClients()
session.forceKillAndDisposeSubprocess() // synchronous; bypasses the 5s timer
}
// Preserve existing post-loop cleanup: sessions.clear(), killedTombstones.clear(),
// and any cleanup callbacks fire unchanged after the loop. Only the per-session
// kill() → forceKillAndDisposeSubprocess() substitution is new.
```
**Why `forceKillAndDisposeSubprocess` instead of `kill() + disposeSubprocess()`.** Today's shutdown path is `session.kill()` (SIGTERM, start 5s timer) → if the child ignores SIGTERM, `forceDispose()` eventually fires and sends SIGKILL. That 5s fallback is load-bearing: shells with traps, hung compilers, and long-running processes with signal handlers routinely ignore SIGTERM. The earlier draft dropped `killTimer` during disposeSubprocess, which would close the fd but leave the child orphaned at the OS level — worse than today's behavior. `forceKillAndDisposeSubprocess` explicitly sends SIGKILL (via `subprocess.forceKill()`) before releasing the fd, so stubborn children are still reaped synchronously:
```ts
// Public: orderly-shutdown path. Force-kills the child (SIGKILL is not
// ignorable), then releases the PTY master fd synchronously. Used only by
// TerminalHost.dispose() — renderer reconnects cold after daemon exit.
forceKillAndDisposeSubprocess(): void {
// Why: forceKill before the subprocess dispose. disposeSubprocess below
// neutralizes node-pty's kill() on POSIX, which is what the internal SIGHUP
// close listener would call. forceKill uses process.kill(pid, 'SIGKILL')
// directly (pty-subprocess.ts:159-168) — unaffected by the neutralization,
// because it does not go through proc.kill. SIGKILL is not ignorable; any
// child that would have survived the 5s timer is reaped immediately.
try {
this.subprocess.forceKill()
} catch {
/* swallow — child may already be gone */
}
this.#teardownSubprocess()
}
```
`disposeSubprocess()` is a tight helper — it flips the session to the terminal state, cancels any pending timers, and forwards to the subprocess dispose. It deliberately does not fan out `onExit` to attached clients, because `TerminalHost.dispose()` is the orderly-shutdown path and the renderer will reconnect cold.
**Shared `#teardown` helper.** Both `Session.dispose()` and `Session.disposeSubprocess()` mutate the same state (`_disposed`, `_state`, `killTimer`, `shellReadyTimer`) and forward to `this.subprocess.dispose()`. Route them through a single private helper `#teardownSubprocess()` so the state transition is defined exactly once — two dispose paths that drift on which flag gets flipped would be a merge hazard (a future change to one that forgets the other silently drops exit events):
```ts
// Session — shared private helper, called by both public paths.
#teardownSubprocess(): void {
if (this._disposed) return
this._disposed = true
// Note: `_state = 'exited'` is NOT set here — the outer caller (Session.dispose,
// Session.forceDispose) is responsible for the state transition AFTER capturing
// any invariants that depend on the pre-flip value. See the `wasTerminating`
// capture in Session.dispose for the load-bearing example.
if (this.killTimer) { clearTimeout(this.killTimer); this.killTimer = null }
if (this.shellReadyTimer) { clearTimeout(this.shellReadyTimer); this.shellReadyTimer = null }
try {
this.subprocess.dispose()
} catch (err) {
// Why: dispose() is documented never to throw, but if it does we must not
// prevent callers from completing their own cleanup (fanout, map removal).
console.warn('[Session] subprocess.dispose() threw:', err)
}
}
// Public: orderly-shutdown path. Does NOT broadcast onExit to clients; the
// renderer reconnects cold after TerminalHost.dispose(). Sets _state = 'exited'
// after teardown because this path has no pre-flip invariants to preserve.
disposeSubprocess(): void {
this.#teardownSubprocess()
this._state = 'exited'
}
// Public: existing Session.dispose() — MUST capture `wasTerminating` BEFORE
// calling #teardownSubprocess, because the check depends on `_state !== 'exited'`
// being the PRE-flip value. Forgetting this check would orphan processes that
// ignored SIGTERM (the `_isTerminating && _state !== 'exited'` guard at
// session.ts:200 is the only path that calls `subprocess.forceKill()` in the
// dispose-while-terminating case).
dispose(): void {
// Why: captured BEFORE the `_state = 'exited'` flip at line 230. This check
// guards the "dispose while kill() was already in flight" case — if true,
// the child hasn't reaped yet and we need to forceKill it here (the 5s
// killTimer is also about to be cleared by #teardownSubprocess). Matches
// the existing invariant at session.ts:200. Do not move this line below
// #teardownSubprocess or the `_state = 'exited'` assignment.
const wasTerminating = this._isTerminating && this._state !== 'exited'
const clientsToNotify = wasTerminating ? this.attachedClients.slice() : []
if (wasTerminating) {
// Existing forceDispose semantics — child hasn't reaped yet, force it now.
try { this.subprocess.forceKill() } catch { /* already dead */ }
this._exitCode = -1
this._isTerminating = false
}
this.#teardownSubprocess()
this._state = 'exited'
this.attachedClients = []
this.preReadyStdinQueue = []
this.postReadyFlushGate.clear()
this.emulator.dispose()
for (const client of clientsToNotify) {
client.onExit(-1)
}
}
```
Single source of truth for the state transition. If we ever need to add a new piece of tear-down state, one edit keeps both paths in sync. The asymmetry (`_state = 'exited'` lives in the public methods, not the helper) is deliberate: `dispose()` has a pre-flip invariant (`wasTerminating`) to preserve, `disposeSubprocess()` doesn't. Forcing both through a single helper that flipped `_state` would reintroduce the ordering bug.
**`forceDispose()` must also route through the helper.** `forceDispose` fires from the 5s `killTimer` when a SIGTERM'd child refused to exit (`session.ts:319-347`). This is the exact kill-timeout path this fd-leak fix targets — if `forceDispose` doesn't call `subprocess.dispose()`, the ptmx fd leaks on every force-kill. The current body flips `_disposed = true` directly at `session.ts:324`, which would short-circuit a later `Session.dispose()` call from `TerminalHost` tombstone cleanup at `#teardownSubprocess`'s `if (this._disposed) return` guard, silently skipping `subprocess.dispose()`.
```ts
// Private: fires from the 5s killTimer when SIGTERM was ignored.
// `_state === 'exited'` already short-circuits above; below that guard the
// session is still running, so the fd is still open. MUST release it.
private forceDispose(): void {
if (this._state === 'exited') {
return
}
// Why: unlike dispose(), forceDispose has no pre-flip invariants to capture.
// The caller (killTimer) has no attached-client fanout obligation beyond
// what the helper + the existing client loop below cover. Route the
// subprocess tear-down through the shared helper so `proc.destroy()` runs
// exactly once and `_disposed` is flipped in the same transition.
try { this.subprocess.forceKill() } catch { /* already dead */ }
this._exitCode = -1
this._isTerminating = false
this.#teardownSubprocess() // ← sets _disposed = true, calls subprocess.dispose()
this._state = 'exited'
const clients = this.attachedClients
this.attachedClients = []
this.preReadyStdinQueue = []
this.postReadyFlushGate.clear()
this.emulator.dispose()
for (const client of clients) {
client.onExit(-1)
}
}
```
Order matters. `forceKill()` must run BEFORE `#teardownSubprocess()` — the helper's `subprocess.dispose()` neutralizes `proc.kill` on POSIX, but `subprocess.forceKill()` uses `process.kill(proc.pid, 'SIGKILL')` directly and is unaffected by that neutralization. If we reorder, nothing changes functionally, but the explicit order documents the invariant.
After `session.forceKillAndDisposeSubprocess()`, the 5s `killTimer` started by any prior `kill()` is cleared, so `forceDispose` cannot fire against an already-disposed subprocess. Even if the timer somehow survived the clear, `_state` is now `'exited'` and `forceDispose`'s early-return guard (`session.ts:320`) would short-circuit it.
### LocalPtyProvider
`local-pty-provider.ts` stores each spawned `IPty` in a module-level `ptyProcesses` map and has two teardown sites: `shutdown(id)` (single PTY) and `killAll()` / `safeKillAndClean()` (bulk). Both currently call `proc.kill()` and delete the map entry.
Change: add a `destroyPtyProcess(proc)` helper. Same shape as `disposeManagedPty` in the relay: on POSIX, neutralize `proc.kill` before calling `destroy()` to close the same SIGHUP-to-recycled-pid hazard; on Windows, skip the neutralization because `WindowsTerminal.destroy` calls `kill()` internally. Invoke the helper at the end of `safeKillAndClean` (after listener disposal, after `proc.kill()`), at the end of `shutdown` (same spot), and in the natural-exit `onExit` path after `clearPtyState(id)`.
```ts
function destroyPtyProcess(proc: IPty): void {
// Why: same SIGHUP-to-recycled-pid hazard as the daemon and relay paths.
// UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`.
// LocalPtyProvider's own kill() has already fired; if the close listener
// delivers a trailing SIGHUP after pid recycling, it lands on a random user
// process. Local machines recycle pids fast — regression risk is highest here.
// Windows exempt: WindowsTerminal.destroy IS a kill() call via _deferNoArgs.
if (process.platform !== 'win32') {
;(proc as unknown as { kill?: (sig?: string) => void }).kill = () => {}
}
try {
;(proc as unknown as { destroy?: () => void }).destroy?.()
} catch {
/* swallow — already torn down */
}
}
```
The natural-exit `onExit` invocation is load-bearing: without it, a shell that exits cleanly (the common case) never releases its ptmx fd until the next GC. `onExit` is also the only path that runs without an external kill having been issued, so the neutralization is defensive against future reorderings — today `proc.kill` is a no-op after the child is reaped, but the kernel may still emit the close event.
### Relay: `relay/pty-handler.ts`
`PtyHandler` stores each `IPty` on a `ManagedPty` record. Teardown happens in five places:
- `shutdown(params)` — SIGTERM + 5s SIGKILL fallback, or SIGKILL immediately when `immediate === true`.
- The `onExit` wiring inside `wireAndStore` — deletes the map entry.
- `spawn()`'s stale-context path (`pty-handler.ts:144-154`) — client reconnected before the PTY response was delivered, so the PTY is orphaned and gets SIGTERM + 5s SIGKILL fallback.
- `dispose()` — loops every managed pty and shuts it down.
- `sendSignal()` — public entry point that forwards arbitrary signals; must keep working after dispose is queued.
Change (ordering matters):
1. **`onExit` wiring**: after the map-delete, call `disposeManagedPty(managed)`. This is the natural-exit path — the child has already exited and been reaped. Neutralizing `pty.kill` here is defensive: any trailing invocation from the socket close listener is no-op'd regardless of pid-recycle timing. The fd is released synchronously.
2. **`shutdown(params)` with `immediate === true`**: after `managed.pty.kill('SIGKILL')`, call `disposeManagedPty(managed)`. SIGKILL has already reaped the child.
3. **`shutdown(params)` graceful path (SIGTERM)**: do **not** call `destroy()` immediately after the SIGTERM send. The 5s `killTimer` and the natural `onExit` already cover fd release via step 1. Calling `destroy()` right after SIGTERM collapses the graceful-shutdown window and risks interrupting shell `EXIT` traps or cleanup hooks that run between SIGTERM and shell exit. If the `killTimer` fires (SIGKILL fallback), the SIGKILL reaps the child and `onExit` (step 1) calls `disposeManagedPty` on its own — **do not add a redundant call inside the killTimer closure**. The `disposed` guard makes a redundant call harmless but wiring it in both places is a merge hazard.
4. **`spawn()` stale-context killTimer**: this path (`pty-handler.ts:149-153`) already sends SIGTERM then SIGKILL at 5s if the PTY wasn't acked. The relevant teardown path is `onExit` (step 1), which now calls `disposeManagedPty`. No code change needed in the stale-context branch itself — the onExit wiring in `wireAndStore` runs for this PTY the same as for any other. Confirmed covered.
5. **`dispose()` loop**: call `disposeManagedPty` for each active managed pty AFTER `managed.pty.kill('SIGTERM')`. This is the relay-shutdown path — we accept that any in-flight shell cleanup is cut short because the relay process itself is exiting.
The common shape, extracted into a helper:
```ts
function disposeManagedPty(managed: ManagedPty): void {
if (managed.disposed) return
managed.disposed = true
// Why: clear any pending 5s SIGKILL fallback timer. If graceful-shutdown
// armed a killTimer and the child then exited cleanly (firing onExit →
// disposeManagedPty), the timer would otherwise fire later and attempt
// pty.kill('SIGKILL') on an already-disposed instance. The ptys.has(id)
// guard inside the timer short-circuits today, but symmetry is clearer.
if (managed.killTimer) {
clearTimeout(managed.killTimer)
managed.killTimer = undefined
}
// Why: same SIGHUP-to-recycled-pid hazard as the daemon fix — neutralize
// node-pty's kill on the instance before calling destroy(). The relay
// typically runs on Linux remote hosts where pid recycling is fast.
// Windows exempt: WindowsTerminal.destroy calls kill() internally, so
// neutralizing it turns destroy() into a no-op.
if (process.platform !== 'win32') {
;(managed.pty as unknown as { kill?: (sig?: string) => void }).kill = () => {}
}
try {
;(managed.pty as unknown as { destroy?: () => void }).destroy?.()
} catch {
/* swallow */
}
}
```
**`disposed` flag on `ManagedPty`.** The flag is new — add it to the `ManagedPty` type. Purpose: prevent `disposeManagedPty` from running twice (both `onExit` and `shutdown` can fire for the same PTY), and — more importantly — let `sendSignal`, `writeData`, and other pre-existing entry points short-circuit if the caller tries to use a disposed PTY:
```ts
private async sendSignal(params: Record<string, unknown>): Promise<void> {
// ... existing signal validation ...
const managed = this.ptys.get(id)
if (!managed || managed.disposed) {
throw new Error(`PTY "${id}" not found`)
}
managed.pty.kill(signal) // ← on POSIX this is neutralized-to-no-op only
// AFTER disposeManagedPty runs. The early return
// above means we never reach this line post-dispose.
}
```
Without the `disposed` guard, a `sendSignal('SIGTERM')` call landing after `onExit``disposeManagedPty` has neutralized `managed.pty.kill` would silently succeed (return success, do nothing). The `disposed` check converts the silent failure into the existing "not found" error, which is what callers already handle.
The graceful-SIGTERM path of `shutdown` remains unchanged (SIGTERM + 5s killTimer that sends SIGKILL). Fd release on that path happens either via the `onExit` hook (step 1) when the shell exits cleanly, or via the killTimer → SIGKILL → `onExit` chain if SIGTERM was ignored. Under no circumstance does `shutdown`'s graceful branch synchronously call `destroy()`.
### Why not just replace `node-pty`
Tempting, and probably correct long-term. Out of scope here: node-pty's API surface is deeply woven into the daemon, LocalPtyProvider, and the relay. Swapping it is a multi-week effort with its own regression surface. This bug is a one-line miss against an API the library already ships — fix that first.
### Why not a daemon-level fd watchdog
A periodic "count my open fds, restart if over threshold" watchdog would mask the bug, not fix it. It also introduces the exact timer-based lifecycle the orphan-daemon design [explicitly rejected](./auto-kill-old-daemons.md) for the same reasons (cold-restore instead of warm-reattach, false positives against live work). Observability yes, auto-restart no.
## Interaction states
Because this fix sits below the IPC layer, the renderer sees no state changes. All user-visible interaction states are identical to today:
| Trigger | User-visible state | Behind the scenes (new) |
|---|---|---|
| Click "Close terminal" | Tab closes immediately | `terminal:kill` IPC → `Session.kill()``handleSubprocessExit``subprocess.dispose()``proc.destroy()` → fd released |
| Shell exits naturally (`exit` / Ctrl-D) | Tab shows exit code | `handleSubprocessExit``subprocess.dispose()``proc.destroy()` → fd released |
| Daemon is killed mid-session | Renderer reconnects cold on next app launch | On orderly shutdown: `TerminalHost.dispose()` loop → per-session `forceKillAndDisposeSubprocess()` — SIGKILL first (stubborn children reaped), then `proc.destroy()` releases the fd synchronously. Does not depend on the 5s `KILL_TIMEOUT_MS`. |
| Child hits `Napi::Error` race | Silent (today) | Silent (same) — next teardown trigger reaches `subprocess.dispose()`, which forwards to `proc.destroy()` regardless of `dead` |
| Session.kill hits `KILL_TIMEOUT_MS` | User sees "force-closed" log entry (unchanged) | `forceDispose``forceKill` + `subprocess.dispose()` → fd released |
| SSH worktree session closed | Tab closes immediately | Relay `PtyHandler.shutdown` → SIGTERM + `managed.pty.destroy()` on exit → fd released on remote host |
## Data flow
```
Renderer Main (IPC) Daemon (TerminalHost) node-pty
│ │ │ │
│ terminal:kill │ │ │
├────────────────────────▶│ RPC: kill(sessionId) │ │
│ ├───────────────────────────▶│ Session.kill() │
│ │ ├─ subprocess.kill() ─────▶│ (SIGTERM)
│ │ │ │
│ │ │◀── onExit(code) ─────────│
│ │ │ handleSubprocessExit │
│ │ │ ├─ state='exited' │
│ │ │ ├─ subprocess.dispose()─┼─▶ destroy() →
│ │ │ │ │ _close() +
│ │ │ │ │ _socket.destroy() +
│ │ │ │ │ _writeStream.dispose()
│ │ │ │ │ → ptmx fd released ✔
│ │ │ └─ fanout onExit │
│ │◀─── event: 'exit' ─────────│ │
│◀── terminal:exit ───────│ │ │
```
### Failure paths
**Natural exit.** Child dies, `proc` emits `onExit``handleSubprocessExit``subprocess.dispose()``proc.destroy()``_socket.destroy()` → fd released, same tick.
**Explicit kill (`Session.kill`).** SIGTERM → child exits → same path as above.
**Kill timeout.** Child ignores SIGTERM → `KILL_TIMEOUT_MS` elapses → `forceDispose``subprocess.forceKill()` (SIGKILL) + `subprocess.dispose()` (`proc.destroy()`). Fd released within 5s worst case.
**Native throw.** `proc.write/resize/kill` throws `Napi::Error``dead = true`. Session is wedged but still reachable. The next teardown trigger (user close, daemon shutdown, worktree close) reaches `subprocess.dispose()`, which forwards to `proc.destroy()`. The destroy() body itself is try/catch-wrapped — if the native side is already half-torn-down and throws, we swallow; the socket side still gets destroyed by any prior event.
**Daemon shutdown.** `TerminalHost.dispose()` iterates sessions and calls `session.forceKillAndDisposeSubprocess()` synchronously. The ordering is: SIGKILL (`subprocess.forceKill()` → `process.kill(pid, 'SIGKILL')`) → `subprocess.dispose()``proc.destroy()`. SIGKILL is not ignorable, so any child that would have survived the normal 5s `KILL_TIMEOUT_MS` SIGTERM window is reaped before the fd release. On POSIX, every `ptmx` fd is released before the daemon process exits. On Windows, `WindowsTerminal.destroy` defers via `_deferNoArgs` — dispose is enqueued but may not run synchronously; this is acceptable because ConPTY is not subject to the POSIX `ptmx` cap (see Risks).
**Local provider (non-daemon).** `LocalPtyProvider.shutdown(id)` / `killAll()` / natural `onExit` all route through the new `destroyPtyProcess(proc)` helper, same `proc.destroy()` call. Fd released symmetrically with the daemon path.
**Relay (SSH).** `PtyHandler.shutdown` and `PtyHandler.dispose` both call `managed.pty.destroy()` after the existing kill. On relay shutdown the loop is synchronous, so ptmx fds in the remote host's relay process are released before the relay exits.
## Architectural fit
```
┌─ Daemon (forked) ───────────────┐ ┌─ Main process ─────────────┐ ┌─ Relay (remote host) ────┐
│ TerminalHost │ │ LocalPtyProvider │ │ PtyHandler │
│ └─ Session │ │ └─ ptyProcesses map │ │ └─ ManagedPty map │
│ └─ SubprocessHandle │ │ (proc.destroy ←NEW)│ │ (pty.destroy ←NEW)│
│ └─ dispose() ←NEW │ │ │ │ │
│ └─ proc.destroy()│ └────────────────────────────┘ └──────────────────────────┘
└─────────────────────────────────┘ │ │
│ └───────────┬─────────────────────┘
└───────────────┐ │
▼ ▼
┌──────────────────────────────────────────────┐
│ node-pty destroy() │
│ → _close() → _socket.destroy() → fd freed │
└──────────────────────────────────────────────┘
```
Three providers, one shared fix: forward the local dispose call to `node-pty.destroy()`. No new layer, no new dependency direction. Each provider already owns its handle lifecycle — we are filling a gap in three existing chains, not creating them.
## Implementation plan
### Files to change
| File | Change |
|---|---|
| `src/main/daemon/session.ts` | Add `dispose(): void` to `SubprocessHandle` type. Add `forceKillAndDisposeSubprocess()` + `disposeSubprocess()` public methods. Add private `#teardownSubprocess()` helper that does NOT flip `_state` (see `dispose` section). Call `subprocess.dispose()` from `handleSubprocessExit`, `dispose`, `forceDispose`. Preserve the `wasTerminating = this._isTerminating && this._state !== 'exited'` invariant by capturing it BEFORE any state flip in the refactored `dispose`. |
| `src/main/daemon/pty-subprocess.ts` | Implement `dispose()` on the returned handle. Idempotent. Nulls callbacks, sets `dead = true`, gates the `proc.kill = noop` neutralization on `process.platform !== 'win32'`, forwards to `(proc as unknown as { destroy?: () => void }).destroy?.()` in try/catch. |
| `src/main/daemon/terminal-host.ts` | In `dispose()`, call `session.forceKillAndDisposeSubprocess()` instead of `session.kill()`. Preserves the SIGKILL-fallback behavior without depending on the 5s timer. |
| `src/main/providers/local-pty-provider.ts` | Add `destroyPtyProcess(proc)` helper: gates `proc.kill = noop` neutralization on POSIX, then calls `proc.destroy()` in try/catch. Invoke from `safeKillAndClean`, `shutdown`, and the `onExit` path (after `clearPtyState`). |
| `src/relay/pty-handler.ts` | Add `disposed: boolean` field to `ManagedPty`. In `wireAndStore`'s `onExit` (after map delete) and in `dispose()` loop, call `disposeManagedPty(managed)`. In `shutdown` immediate-path (after SIGKILL), call `disposeManagedPty`. In the 5s killTimer SIGKILL fallback (graceful path and spawn stale-context path), also call `disposeManagedPty`. Do **not** call it in the graceful SIGTERM branch. Guard `sendSignal`, `writeData`, `resize`, `getCwd`, `getInitialCwd`, `clearBuffer`, `hasChildProcesses`, `getForegroundProcess`, `attach` with `managed.disposed` checks (treat disposed as "not found"). |
| `src/main/daemon/session.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
| `src/main/daemon/terminal-host.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
| `src/main/daemon/terminal-host-startup.test.ts` | Add `dispose: vi.fn()` to `mockSubprocess`. |
| `src/main/daemon/production-launcher.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
| `src/main/daemon/daemon-pty-provider.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
| `src/main/daemon/reattach-snapshot.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
| `src/main/daemon/__tests__/pty-fd-leak.test.ts` (new) | Integration test for the daemon path: spawn N sessions, tear them down via each path, assert fd count returns to baseline. |
| `src/main/providers/__tests__/local-pty-fd-leak.test.ts` (new) | Same test, targeting `LocalPtyProvider` directly. |
| `src/relay/__tests__/pty-fd-leak.test.ts` (new) | Same test, targeting `PtyHandler` directly. |
### Testing plan
The hard part of this fix is not the code — it's proving the leak is closed in all three providers. Two layers:
#### 1. Unit-level handle behaviour
Per provider, for each exit path (`kill`, `forceKill`, native throw simulated by calling `dispose` after an artificial dead state, natural exit), assert:
- `proc.destroy()` is called exactly once per PTY (spy on the mock `IPty`).
- Calling the provider's dispose a second time is a no-op (idempotency).
- After dispose, subsequent `write/resize/kill` are no-ops and do not throw.
- No `Napi::Error` surfaces from any follow-on method call.
#### 2. Integration-level fd count
One test file per provider (`src/main/daemon/__tests__/pty-fd-leak.test.ts`, `src/main/providers/__tests__/local-pty-fd-leak.test.ts`, `src/relay/__tests__/pty-fd-leak.test.ts`), same shape:
```
baseline = countPtmxFds(process.pid)
for i in 1..50:
pty = provider.spawn(...)
provider.shutdown(pty.id) // or forceKill / dispose / let it exit naturally
await exit event
after = countPtmxFds(process.pid)
assert after === baseline
```
Loop once per exit path per provider.
**Platform-specific `countPtmxFds` implementation:**
- **macOS:** `lsof -p <pid> -Ffn` → parse as (fd, name) pairs → count pairs whose fd is numeric (not `cwd`/`txt`/`rtd`) AND whose name is exactly `/dev/ptmx`. The `-Ffn` machine-readable mode emits one field per line prefixed with `f` (fd) or `n` (name); a naive `startsWith('n')` filter that doesn't cross-check the preceding `f` row false-matches on non-fd rows whose NAME happens to be `/dev/ptmx`. Never use `grep ptmx` unqualified — it false-matches on env vars and any path containing the substring.
- **Linux:** iterate `/proc/<pid>/fd/*`, `readlink` each entry, match against the regex `/^(\/dev\/ptmx$|\/dev\/pts\/ptmx$|anon_inode:\[?ptmx\]?$)/`. The master pty fd does not show as `/dev/ptmx` on modern Linux kernels — depending on the pty backend and kernel version, `readlink` returns `anon_inode:[ptmx]` (devpts backend on many distros) or `/dev/pts/ptmx`. A naive `grep /dev/ptmx` yields zero matches and the test passes vacuously — **zero regression coverage** instead of real coverage. CI runs on `ubuntu-latest`; this matters.
- **Windows:** skip entirely (ConPTY doesn't expose ptmx; rely on the unit-level `destroy()` spy).
**Mandatory `beforeAll` counter smoke.** Before the first assertion, spawn one real PTY, measure `countPtmxFds`, kill it, measure again, and assert `beforeSpawn < afterSpawn` and `afterKill <= beforeSpawn`. If either check fails, the counter itself is broken on this platform — skip the whole suite with a clear message (`skip('ptmx counter not functional on this platform')`). Otherwise, the fd-count assertions could go green on a broken counter and we'd think the fix landed when it didn't.
```ts
beforeAll(async () => {
const baseline = countPtmxFds(process.pid)
const probe = pty.spawn('/bin/sh', ['-c', 'sleep 30'], { /* ... */ })
await new Promise((r) => setTimeout(r, 100))
const withProbe = countPtmxFds(process.pid)
probe.kill('SIGKILL')
await new Promise((r) => setTimeout(r, 200))
const afterKill = countPtmxFds(process.pid)
if (!(withProbe > baseline) || !(afterKill <= baseline)) {
// Why: if the counter can't even observe spawn/kill deltas on a known-good
// PTY, it can't observe the leak we're trying to prove closed. Vacuously
// passing assertions would give false confidence. Skip instead.
return describe.skip('ptmx counter smoke failed; skipping fd-leak suite')
}
})
```
These tests spawn 50 real subprocesses each and must be gated behind an env-var skip (not a vitest tag — vitest 4 doesn't ship built-in tag filtering in this repo's config). Use `describe.skipIf(!process.env.RUN_SLOW_TESTS)`. Every spawn inside the loop must be wrapped in try/finally that force-kills the child regardless of assertion outcome — otherwise a mid-loop failure leaks the remaining subprocesses into the test runner, corrupting the next suite's baseline.
#### 3. Manual verification
Reproduce the reported 269/13 case locally:
1. Start a fresh daemon.
2. Baseline: `lsof -p $(pgrep -f daemon-entry) | grep -c ptmx` → small (one per live session).
3. Churn: open and close 50 terminals via the UI.
4. Verify the count returns to baseline within seconds. Before the fix, it stays at ~50.
5. Repeat against a non-daemon worktree (LocalPtyProvider path) and against an SSH worktree (relay path).
### Rollout
No flag needed. The change is pure bug-fix with strictly smaller resource footprint. Ship with the orphan-daemon fix in the same release — they are complementary and together close the full leak story.
### Regression surface
| Regression | Mitigation |
|---|---|
| `dispose()` throws, breaking exit-code fanout to clients | Wrap every dispose call site in try/catch. `destroy()` call itself is also in try/catch inside the wrapper. |
| `proc.destroy()` is not present on an older/forked node-pty build | Optional chain: `(proc as unknown as { destroy?: () => void }).destroy?.()`. Current pinned version has it on both UnixTerminal and WindowsTerminal; tests assert it's called. |
| Double-dispose double-frees the native handle | Idempotency guard (`if (disposed) return`) at the top of dispose. node-pty's own `destroy()` is also safe to call twice — `_close()` is idempotent (it replaces `write`/`end` with no-ops and flips `_writable`/`_readable` to false the first time; the second call overwrites no-ops with no-ops). `_socket.destroy()` and `_writeStream.dispose()` are both idempotent per Node stream semantics. |
| `dead` flag pattern regresses, `Napi::Error` kills daemon | All existing `dead`-guarded methods stay exactly as-is. `dispose()` sets `dead = true` and then calls `destroy()`; it adds to the pattern, does not replace it. |
| Newly-nulled onData callback drops final data burst | Session already fans out synchronously in `handleSubprocessData` before exit. Nulling after exit is safe. |
| Neutralizing `proc.kill` on Windows turns `destroy()` into a no-op (ConPTY leak) | `WindowsTerminal.destroy` calls `kill()` via `_deferNoArgs` — we gate neutralization on `process.platform !== 'win32'`. Verified in `pty-subprocess.ts` and `local-pty-provider.ts` and `relay/pty-handler.ts` helpers. Test: mock `process.platform = 'win32'`; assert `proc.kill` is NOT replaced with noop; assert `destroy()` is still called. |
| Shared `#teardownSubprocess` helper flips `_state` before callers capture `wasTerminating`, orphaning stubborn children | Helper explicitly does NOT flip `_state`. Each public method (`dispose`, `disposeSubprocess`, `forceKillAndDisposeSubprocess`) owns its own `_state = 'exited'` line and captures any pre-flip invariants before calling the helper. Test: session with `_isTerminating=true`, `_state='running'` calls `dispose()`; assert `subprocess.forceKill()` is called before `subprocess.dispose()`. |
| `sendSignal` / `writeData` / `resize` silently no-op after relay `onExit` neutralized `managed.pty.kill` | Every public entry point on `PtyHandler` that touches a `ManagedPty` checks `managed.disposed` first and treats disposed as "not found" (existing error path). Converts silent failure to an explicit error callers already handle. |
| `TerminalHost.dispose()` drops 5s SIGKILL fallback for stubborn children | New `forceKillAndDisposeSubprocess` method explicitly sends SIGKILL via `subprocess.forceKill()` before releasing the fd. SIGKILL is not ignorable. Test: session whose child ignores SIGTERM; call `forceKillAndDisposeSubprocess`; assert child is reaped AND fd is released, both synchronously. |
| LocalPtyProvider delivers trailing SIGHUP to recycled pid | `destroyPtyProcess(proc)` mirrors the daemon and relay helpers: POSIX-guarded `proc.kill = noop` neutralization before `destroy()`. Covers natural-exit, `safeKillAndClean`, and `shutdown` paths. |
## Risks
| Risk | Severity | Why acceptable |
|---|---|---|
| `destroy()` doesn't actually release the fd | Low | We are literally calling node-pty's canonical close path (`_close()` → `_socket.destroy()``_writeStream.dispose()`). Only way this is ineffective is a node-pty bug or a version regression — and the integration fd-count tests catch both before merge. |
| Idempotent dispose hides a real double-free bug | Low | Idempotency only skips work; it does not suppress errors. |
| `TerminalHost.dispose()` synchronous loop blocks daemon shutdown | Low | `destroy()` on each PTY is a handful of syscalls; 50 sessions ≈ single-digit ms. |
| `destroy()` on Windows defers via `_deferNoArgs` | Low | ConPTY never hit the ptmx cap (it's a POSIX resource). Windows dispose is correctness-for-symmetry, not a load-bearing leak fix. |
| The real leak is elsewhere (e.g., an fd leak outside the PTY code path) | Low | Integration tests directly measure fd reclamation across a full spawn/kill lifecycle. If the fix works there, it addresses the observed symptom. |
## Related work (out of scope)
- **Orphaned legacy daemons**: [auto-kill-old-daemons.md](./auto-kill-old-daemons.md) — complementary fix targeting daemon-level lifecycle.
- **Worktree-delete does not kill sessions**: separate follow-up. Today, deleting a worktree from the sidebar leaves its PTY sessions live in the daemon, which is a different leak vector.
- **Daemon fd observability**: expose an IPC `daemon:stats` handler so Orca can surface fd count in a debug view. Would let us detect regressions in production rather than waiting for user reports.
- **Replace `node-pty`**: out of scope; see "Why not just replace node-pty" above.

View File

@ -1,350 +0,0 @@
# Focus-Follows-Mouse for Terminal Panes
**Status:** Draft
**Date:** 2026-04-08
## Summary
Add a Ghostty-style `focus-follows-mouse` behavior to Orca's terminal panes: when enabled, hovering a split makes it the active pane (cursor focus + input routing + opacity update) without requiring a click. Scoped to splits within a single tab, default off, immediate on `mouseenter`. Safety guards preserve text selections, pane drags, window-focus semantics, and overlays.
## Motivation
Orca's terminal panes already borrow heavily from Ghostty's look and feel (themes, dividers, drag handles, close dialog, URL tooltip). Focus-follows-mouse is one of the few Ghostty interaction conventions Orca doesn't yet mirror. Users coming from Ghostty expect it; users who've never used it can leave the default off and see no change.
## Scope
**In scope:**
- Hovering a terminal split in the current tab activates it when the setting is enabled.
- A new `GlobalSettings.terminalFocusFollowsMouse` boolean persisted in user settings.
- A new toggle in Settings → Terminal → Pane Styling.
- Full unit test coverage of the gating logic.
**Out of scope:**
- Cross-tab hover activation (hovering a background tab does nothing).
- Hovering non-terminal panes (file editor, source control, sidebars) — this setting only affects terminal splits.
- Adjustable hover delay / settle time. Immediate switching matches Ghostty and is what users asking for Ghostty parity expect.
- Migrating `PaneStyleOptions` into a split style-vs-behavior type. Bundling that refactor here would balloon the diff for marginal clarity. Left as a follow-up for when a second behavior flag lands.
## Non-Goals
- Cross-window focus. If Orca isn't the OS-focused window, hovering a pane must not switch focus.
- Breaking existing click-to-focus. Clicking a pane must continue to work exactly as today; this feature is additive.
## Design
### Data model and settings plumbing
**New field on `GlobalSettings`** (`src/shared/types.ts`):
```ts
terminalFocusFollowsMouse: boolean
```
**Default value** (`src/shared/constants.ts` → `getDefaultSettings`):
```ts
terminalFocusFollowsMouse: false
```
Default off matches Ghostty. Existing users upgrading receive the default automatically since `getDefaultSettings` is used as the fallback for missing fields on read — no migration needed.
**Extension of `PaneStyleOptions`** (`src/renderer/src/lib/pane-manager/pane-manager-types.ts`):
```ts
export type PaneStyleOptions = {
splitBackground?: string
paneBackground?: string
inactivePaneOpacity?: number
activePaneOpacity?: number
opacityTransitionMs?: number
dividerThicknessPx?: number
// Why this behavior flag lives on "style" options: this type is already
// the single runtime-settings bag the PaneManager exposes. Splitting into
// separate style vs behavior types is a refactor worth its own change when
// a second behavior flag lands. See docs/focus-follows-mouse-design.md.
focusFollowsMouse?: boolean
}
```
**Plumbing path** (same pattern already used by `terminalInactivePaneOpacity`):
1. `GlobalSettings.terminalFocusFollowsMouse` — persisted
2. `resolvePaneStyleOptions` in `src/renderer/src/lib/terminal-theme.ts:103` extracts the style-related fields from `GlobalSettings` and returns them. Add `'terminalFocusFollowsMouse'` to the `Pick<GlobalSettings, ...>` parameter union, and add `focusFollowsMouse: settings.terminalFocusFollowsMouse` to the returned object (no clamping needed — boolean pass-through, unlike the existing numeric fields that go through `clampNumber`).
3. `applyTerminalAppearance` in `src/renderer/src/components/terminal-pane/terminal-appearance.ts:48` calls `manager.setPaneStyleOptions({...})` — add `focusFollowsMouse: paneStyles.focusFollowsMouse` to that object literal.
4. `PaneManager.setPaneStyleOptions()` stores into `this.styleOptions`. The `handlePaneMouseEnter` method reads `this.styleOptions.focusFollowsMouse` fresh on every event, so toggling the setting takes effect immediately without re-wiring listeners.
### Runtime event wiring
**Pure gate helper** — extracted to a new file so it can be unit tested without a DOM.
`src/renderer/src/lib/pane-manager/focus-follows-mouse.ts`:
```ts
export type FocusFollowsMouseInput = {
featureEnabled: boolean
activePaneId: number | null
hoveredPaneId: number
mouseButtons: number // from MouseEvent.buttons bitmask
windowHasFocus: boolean // from document.hasFocus()
managerDestroyed: boolean
}
/** Pure gate: returns true iff the hovered pane should be activated.
* Isolated from DOM so it can be unit-tested without an environment. */
export function shouldFollowMouseFocus(input: FocusFollowsMouseInput): boolean {
if (!input.featureEnabled) return false
if (input.managerDestroyed) return false
if (input.activePaneId === input.hoveredPaneId) return false
// Why event.buttons !== 0: any held mouse button means a selection or a
// drag is in progress. Switching focus mid-drag would break xterm.js text
// selection and the pane drag-to-reorder flow. This single check also
// covers drag-to-reorder: the drag is always button-held, so buttons is
// always non-zero during it. No separate drag-state gate needed.
if (input.mouseButtons !== 0) return false
// Why document.hasFocus: if Orca isn't the OS-focused window, the mouse
// event is from the user passing through on their way to another app.
// We must not hijack focus in that case. Also returns false when DevTools
// is focused (DevTools runs in a separate WebContents) — accepted.
if (!input.windowHasFocus) return false
return true
}
```
**Updated `createPaneDOM` signature** (`src/renderer/src/lib/pane-manager/pane-lifecycle.ts`):
```ts
export function createPaneDOM(
id: number,
options: PaneManagerOptions,
dragState: DragReorderState,
dragCallbacks: DragReorderCallbacks,
onPointerDown: (id: number) => void,
onMouseEnter: (id: number, event: MouseEvent) => void // NEW
): ManagedPaneInternal
```
**New listener** attached in `createPaneDOM` next to the existing `pointerdown` listener:
```ts
container.addEventListener('mouseenter', (event) => {
onMouseEnter(id, event)
})
```
The pane DOM layer stays dumb — it knows nothing about settings, active state, or window focus. All gating logic lives in the PaneManager callback.
**PaneManager wiring** (`src/renderer/src/lib/pane-manager/pane-manager.ts`, inside `createPaneInternal`):
```ts
private createPaneInternal(): ManagedPaneInternal {
const id = this.nextPaneId++
const pane = createPaneDOM(
id,
this.options,
this.dragState,
this.getDragCallbacks(),
(paneId) => {
if (!this.destroyed && this.activePaneId !== paneId) {
this.setActivePane(paneId, { focus: true })
}
},
(paneId, event) => {
this.handlePaneMouseEnter(paneId, event)
}
)
this.panes.set(id, pane)
return pane
}
/** Focus-follows-mouse entry point. Collects gate inputs from the manager
* and delegates to the pure gate helper.
*
* Invariant for future contributors: modal overlays (context menus, close
* dialogs, command palette) must be rendered as portals/siblings OUTSIDE
* the pane container. If a future overlay is rendered inside a .pane
* element, mouseenter will still fire on the pane underneath and this
* handler will incorrectly switch focus. Keep overlays out of the pane. */
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
if (
shouldFollowMouseFocus({
featureEnabled: this.styleOptions.focusFollowsMouse ?? false,
activePaneId: this.activePaneId,
hoveredPaneId: paneId,
mouseButtons: event.buttons,
windowHasFocus: document.hasFocus(),
managerDestroyed: this.destroyed
})
) {
this.setActivePane(paneId, { focus: true })
}
}
```
**Why no explicit drag-state gate:** An earlier draft of this design included a `dragSourcePaneId !== null` check as belt-and-suspenders. Verification against `src/renderer/src/lib/pane-manager/pane-drag-reorder.ts:77-103` showed it's strictly redundant:
- The drag only activates while the user is holding a mouse button (pointerdown → drag threshold → pointerup). During that entire window, `event.buttons !== 0` on any mouseenter.
- `dragSourcePaneId` is unconditionally cleared at line 101 inside the `if (dragging)` branch of the pointerup handler, so it cannot outlive a pointerup.
- Therefore `mouseButtons !== 0` is sufficient on its own. Adding a second check would be dead code.
### Settings UI
**Placement** — under the existing "Pane Styling" section in `src/renderer/src/components/settings/TerminalPane.tsx`, below the `<div className="grid gap-4 md:grid-cols-2">` that holds Inactive Pane Opacity + Divider Thickness. The new toggle is a full-width row (toggles don't balance well next to numeric fields in a 2-column grid).
**Toggle shape** — mirrors the existing `role="switch"` pattern used by e.g. "Nest Workspaces" in `GeneralPane.tsx`:
```tsx
<SearchableSetting
title="Focus Follows Mouse"
description="Hovering a terminal pane activates it without needing to click. Mirrors Ghostty's focus-follows-mouse setting."
keywords={['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Focus Follows Mouse</Label>
<p className="text-xs text-muted-foreground">
Hovering a terminal pane activates it without needing to click. Mirrors Ghostty&apos;s
focus-follows-mouse setting. Selections and window switching stay safe.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalFocusFollowsMouse}
onClick={() =>
updateSettings({
terminalFocusFollowsMouse: !settings.terminalFocusFollowsMouse
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalFocusFollowsMouse ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalFocusFollowsMouse ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
```
**Settings search integration** — add a new entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` in `src/renderer/src/components/settings/terminal-search.ts:34` (alongside "Inactive Pane Opacity" and "Divider Thickness"):
```ts
{
title: 'Focus Follows Mouse',
description: 'Hovering a terminal pane activates it without needing to click.',
keywords: ['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']
}
```
This surfaces the toggle in the settings search box for queries like "focus", "hover", or "ghostty". No changes needed to `TERMINAL_PANE_SEARCH_ENTRIES` (the aggregator) since it spreads `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` automatically.
### Edge cases
**Gate-covered (code enforces):**
| Case | Gate |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Mouse button held mid-selection | `mouseButtons !== 0` |
| Mid-drag-to-reorder | `mouseButtons !== 0` (drag is always button-held) |
| Orca window unfocused (alt-tab) | `!document.hasFocus()` |
| DevTools panel focused | `!document.hasFocus()` — DevTools runs in a separate WebContents so the main document loses focus. Feature pauses until DevTools is closed or the pane is clicked. Acceptable — same behavior as any other focus loss. |
| Hover the already-active pane | `activePaneId === hoveredPaneId` |
| Manager destroyed mid-event | `managerDestroyed` |
**Implicitly safe (relies on platform behavior, documented):**
- **Context menus / close-confirm dialogs / command palette** — These render as React portals above the pane DOM. `mouseenter` does not reach the pane when an overlay is open. If a future contributor ever renders an overlay _inside_ the pane container, this assumption breaks — a code comment flags this.
- **Tab / worktree switching** — Hidden PaneManagers' DOM doesn't receive events. No cross-manager interference possible.
- **Single-pane layouts** — The `activePaneId === hoveredPaneId` early-return handles this with no special case.
- **Right-click**`contextmenu` doesn't trigger `mouseenter` (no boundary crossing). Existing right-click behavior is preserved.
**Intentionally accepted quirks:**
- **Setting toggled ON while hovering a non-active pane** → feature doesn't kick in until next `mouseenter`. User must wiggle the mouse. The fix (track last hovered pane and replay on setting change) adds persistent state for a rare case. Not worth it.
- **Traversal flicker** (A → C via B on a three-pane layout) → briefly focuses B. Accepted as the cost of Ghostty-parity immediate switching. Confirmed during brainstorming.
- **Window-resize-under-stationary-mouse** → if pane boundaries cross the cursor during a resize, focus can shift to the newly-hovered pane even though the user didn't move the mouse. Arguably correct; adding a "suppress during resize" gate introduces fragile state.
### Error handling
The mouseenter handler does no I/O, no promises, no external state lookups. No error surface worth defending:
- `document.hasFocus()` — synchronous boolean, cannot throw
- `event.buttons` — number, cannot throw
- `this.styleOptions.focusFollowsMouse ?? false` — missing settings fail safe (feature off)
- `setActivePane` — already handles pane-not-found silently at `pane-manager.ts:188`
No try/catch added. If `setActivePane` throws, propagating to the window `error` handler is the correct failure mode — silently swallowing would hide a real bug.
### Testing strategy
**Constraint:** vitest runs with `environment: 'node'` (no jsdom). No synthetic event dispatch in automated tests.
**1. Unit tests — `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`**
Table of inputs against `shouldFollowMouseFocus`, one case per gate plus happy paths. Expected coverage: every branch.
- Happy path: all gates pass → `true`
- Feature disabled → `false`
- Manager destroyed → `false`
- Hover the already-active pane → `false`
- Mouse button held (primary `buttons=1`, secondary `=2`, both `=3`) → `false` each
- Window lacks OS focus → `false`
- `activePaneId === null` (theoretically-unreachable defensive case — `createInitialPane` always sets `activePaneId` before mouse events are possible, but the gate logic must still behave correctly if this state ever occurs) → `true`
**2. Manual smoke test checklist (PR description)**
- [ ] Toggle persists across app restart.
- [ ] Multi-split: hovering an inactive pane activates it.
- [ ] Start text selection in pane A, drag into pane B. Selection extends normally; focus does NOT switch mid-drag.
- [ ] Drag a pane by its drag-handle (the top strip that appears on hover). Release on a drop zone. Focus/activation does NOT flicker during the drag.
- [ ] Cmd-Tab out, move mouse over a different Orca pane, Cmd-Tab back. Previously-active pane still active.
- [ ] Open DevTools, focus the DevTools panel, move mouse over a different Orca pane. Focus does NOT switch. Close DevTools, wiggle mouse → focus-follows-mouse resumes.
- [ ] Open close-terminal confirmation, hover a different pane. No focus shift.
- [ ] Hover a URL in an inactive pane. Focus-follows-mouse activates the pane. Verify the `Cmd+click to open` URL tooltip still appears correctly on the newly-activated pane (tests for interaction between our `setActivePane → terminal.focus()` call and xterm.js's `WebLinksAddon` hover tracking).
- [ ] Disable the setting. Hover does nothing; click still works.
- [ ] Single-pane layout with the setting enabled. No crash, pane stays focused.
- [ ] Three-pane layout, quick A→C sweep. Traversal flicker visible but tolerable.
**Explicitly not tested:**
- DOM event dispatch integration (no jsdom, not worth adding)
- Settings UI render (no precedent in the codebase for per-control UI tests)
- Electron's `document.hasFocus()` semantics (trust the platform)
## Files Touched
| File | Change |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `src/shared/types.ts` | Add `terminalFocusFollowsMouse: boolean` to `GlobalSettings` |
| `src/shared/constants.ts` | Default `terminalFocusFollowsMouse: false` in `getDefaultSettings` |
| `src/renderer/src/lib/pane-manager/pane-manager-types.ts` | Add `focusFollowsMouse?: boolean` to `PaneStyleOptions` with a commented rationale |
| `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` | **New file.** Pure `shouldFollowMouseFocus` gate helper |
| `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` | **New file.** Unit tests for the gate helper |
| `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` | Add `onMouseEnter` param to `createPaneDOM`; attach `mouseenter` listener on pane container |
| `src/renderer/src/lib/pane-manager/pane-manager.ts` | Pass new callback from `createPaneInternal`; add `handlePaneMouseEnter` private method |
| `src/renderer/src/components/settings/TerminalPane.tsx` | Add `SearchableSetting` toggle under Pane Styling |
| `src/renderer/src/components/settings/terminal-search.ts` | Add entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` |
| `src/renderer/src/lib/terminal-theme.ts` | Extend `resolvePaneStyleOptions` to pass `focusFollowsMouse` through |
| `src/renderer/src/components/terminal-pane/terminal-appearance.ts` | Add `focusFollowsMouse: paneStyles.focusFollowsMouse` to the `setPaneStyleOptions` call at line 48 |
## Build Order
1. Add `terminalFocusFollowsMouse` to `GlobalSettings` type and default. Build passes; no behavior change.
2. Add `focusFollowsMouse` to `PaneStyleOptions`. Build passes; no behavior change.
3. Create `focus-follows-mouse.ts` pure helper + `focus-follows-mouse.test.ts`. Tests pass.
4. Update `createPaneDOM` signature and wire `mouseenter` listener.
5. Add `handlePaneMouseEnter` in `PaneManager` and the new callback in `createPaneInternal`.
6. Thread `terminalFocusFollowsMouse → focusFollowsMouse` through `resolvePaneStyleOptions` and `applyTerminalAppearance`.
7. Add the settings toggle UI in `TerminalPane.tsx` and the search entry in `terminal-search.ts`.
8. Run the manual smoke test checklist against a local build.
## Risks & Open Questions
**Risk: traversal flicker on three-pane layouts may feel worse than expected.** Mitigation: the user explicitly chose immediate switching for Ghostty parity. If complaints arrive, adding a `terminalFocusFollowsMouseDelayMs` setting is a clean follow-up without schema breakage.
**Risk: overlay assumption could break if a future contributor renders a modal inside the pane container.** Mitigation: a code comment on `handlePaneMouseEnter` documents the "overlays must live outside the pane container" invariant so it surfaces during review of any future overlay changes.
**Open: should the setting description link to Ghostty's docs?** Currently the description just says "Mirrors Ghostty's focus-follows-mouse setting." No external link. If Orca's settings UI supports hyperlinks in descriptions elsewhere, we could link to Ghostty's config docs — otherwise leave as plain text. Not a blocker.

View File

@ -1,837 +0,0 @@
# Focus-Follows-Mouse Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Ghostty-style `focus-follows-mouse` behavior to Orca's terminal panes. When enabled, hovering a split activates it (cursor focus, input routing, opacity update) without a click.
**Architecture:** Extend `PaneStyleOptions` with a `focusFollowsMouse?: boolean` flag. Attach a `mouseenter` listener on each pane container in `createPaneDOM`. Delegate all gating logic to a pure `shouldFollowMouseFocus` helper (no DOM, unit-testable) that checks five gates: feature enabled, manager not destroyed, not already active, no mouse button held, window has OS focus. Thread the setting through the existing `resolvePaneStyleOptions → applyTerminalAppearance → setPaneStyleOptions` pipeline.
**Tech Stack:** TypeScript, Electron (renderer), React (settings UI), xterm.js (terminal), vanilla DOM (PaneManager), vitest (tests, node environment).
**Spec:** [docs/focus-follows-mouse-design.md](./focus-follows-mouse-design.md) — read this first for design rationale, edge case reasoning, and the full manual smoke test checklist.
---
## File Structure
**New files (2):**
- `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` — Pure gate helper. Exports `FocusFollowsMouseInput` type and `shouldFollowMouseFocus(input)` function. Zero DOM dependencies.
- `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` — Unit tests for the pure helper. Table-style assertions, one case per gate.
**Modified files (9):**
- `src/shared/types.ts` — Add `terminalFocusFollowsMouse: boolean` field to `GlobalSettings` type.
- `src/shared/constants.ts` — Add `terminalFocusFollowsMouse: false` default in `getDefaultSettings`.
- `src/renderer/src/lib/pane-manager/pane-manager-types.ts` — Add `focusFollowsMouse?: boolean` to `PaneStyleOptions`.
- `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` — Add `onMouseEnter` parameter to `createPaneDOM`, attach a `mouseenter` listener on the pane container.
- `src/renderer/src/lib/pane-manager/pane-manager.ts` — Add `handlePaneMouseEnter` private method. Pass the new callback from `createPaneInternal`.
- `src/renderer/src/lib/terminal-theme.ts` — Extend `resolvePaneStyleOptions` to pass `focusFollowsMouse` through.
- `src/renderer/src/components/terminal-pane/terminal-appearance.ts` — Add `focusFollowsMouse: paneStyles.focusFollowsMouse` to the `setPaneStyleOptions` call.
- `src/renderer/src/components/settings/TerminalPane.tsx` — Add a `SearchableSetting` toggle under Pane Styling.
- `src/renderer/src/components/settings/terminal-search.ts` — Add an entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES`.
Each task below produces a standalone, type-checking commit. The sequence is chosen so the build stays green at every step.
---
## Task 1: Add `terminalFocusFollowsMouse` to GlobalSettings
**Files:**
- Modify: `src/shared/types.ts` (insert field in `GlobalSettings` type around line 311)
- Modify: `src/shared/constants.ts` (insert default in `getDefaultSettings` around line 83)
Because `GlobalSettings` is an exact type, both the type declaration and the `getDefaultSettings` initializer must be updated together or `tsc` fails. Combine both edits in one commit.
- [ ] **Step 1: Add the field to the GlobalSettings type**
Edit `src/shared/types.ts`. Find the line with `terminalDividerThicknessPx: number` (near line 311) and add the new field immediately after it:
```ts
terminalDividerThicknessPx: number
terminalFocusFollowsMouse: boolean
terminalScrollbackBytes: number
```
- [ ] **Step 2: Add the default value in getDefaultSettings**
Edit `src/shared/constants.ts`. Find the line `terminalDividerThicknessPx: 3,` in `getDefaultSettings` (near line 83) and add the default immediately after it:
```ts
terminalDividerThicknessPx: 3,
terminalFocusFollowsMouse: false,
terminalScrollbackBytes: 10_000_000,
```
**Why default `false`:** Matches Ghostty's default. Existing users upgrading receive this default automatically because `src/main/persistence.ts:73-75` merges persisted settings over `defaults.settings` (`{ ...defaults.settings, ...parsed.settings }`), so any new field not in the persisted JSON falls through to the default. No explicit migration needed.
- [ ] **Step 3: Run typecheck to verify both edits**
Run: `pnpm run tc:web && pnpm run tc:node`
Expected: Both pass. No output beyond the tsgo banner.
- [ ] **Step 4: Commit**
```bash
git add src/shared/types.ts src/shared/constants.ts
git commit -m "feat: add terminalFocusFollowsMouse to GlobalSettings (default off)"
```
---
## Task 2: Extend `PaneStyleOptions` with `focusFollowsMouse`
**Files:**
- Modify: `src/renderer/src/lib/pane-manager/pane-manager-types.ts` (line 23-30, the `PaneStyleOptions` type)
- [ ] **Step 1: Add the field with a commented rationale**
Edit `src/renderer/src/lib/pane-manager/pane-manager-types.ts`. Replace the existing `PaneStyleOptions` type definition (lines 23-30) with:
```ts
export type PaneStyleOptions = {
splitBackground?: string
paneBackground?: string
inactivePaneOpacity?: number
activePaneOpacity?: number
opacityTransitionMs?: number
dividerThicknessPx?: number
// Why this behavior flag lives on "style" options: this type is already
// the single runtime-settings bag the PaneManager exposes. Splitting into
// separate style vs behavior types is a refactor worth its own change
// when a second behavior flag lands. See docs/focus-follows-mouse-design.md.
focusFollowsMouse?: boolean
}
```
- [ ] **Step 2: Run typecheck**
Run: `pnpm run tc:web`
Expected: Pass.
- [ ] **Step 3: Commit**
```bash
git add src/renderer/src/lib/pane-manager/pane-manager-types.ts
git commit -m "refactor: add focusFollowsMouse to PaneStyleOptions type"
```
---
## Task 3: Create the pure `shouldFollowMouseFocus` gate helper (TDD)
**Files:**
- Create: `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts`
- Create: `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`
This is the only part of the feature with isolated business logic, so it's the only part we can TDD. We write the test file first, run it to see it fail, then create the implementation to make it pass.
- [ ] **Step 1: Write the failing test file**
Create `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` with the following contents:
```ts
import { describe, expect, it } from 'vitest'
import { shouldFollowMouseFocus, type FocusFollowsMouseInput } from './focus-follows-mouse'
describe('shouldFollowMouseFocus', () => {
// Base input where every gate passes. Individual tests flip one field
// at a time to assert that each gate blocks focus independently.
const base: FocusFollowsMouseInput = {
featureEnabled: true,
activePaneId: 1,
hoveredPaneId: 2,
mouseButtons: 0,
windowHasFocus: true,
managerDestroyed: false
}
it('switches focus when all gates pass', () => {
expect(shouldFollowMouseFocus(base)).toBe(true)
})
it('blocks when the feature is disabled', () => {
expect(shouldFollowMouseFocus({ ...base, featureEnabled: false })).toBe(false)
})
it('blocks when the manager is destroyed', () => {
expect(shouldFollowMouseFocus({ ...base, managerDestroyed: true })).toBe(false)
})
it('blocks when hovering the already-active pane', () => {
expect(shouldFollowMouseFocus({ ...base, hoveredPaneId: 1 })).toBe(false)
})
it('blocks while the primary mouse button is held (buttons=1)', () => {
expect(shouldFollowMouseFocus({ ...base, mouseButtons: 1 })).toBe(false)
})
it('blocks while the secondary mouse button is held (buttons=2)', () => {
expect(shouldFollowMouseFocus({ ...base, mouseButtons: 2 })).toBe(false)
})
it('blocks while multiple buttons are held (buttons=3)', () => {
expect(shouldFollowMouseFocus({ ...base, mouseButtons: 3 })).toBe(false)
})
it('blocks when the window does not have OS focus', () => {
expect(shouldFollowMouseFocus({ ...base, windowHasFocus: false })).toBe(false)
})
// Defensive case: createInitialPane always sets activePaneId before any
// mouse events are possible in production, but the gate must still behave
// correctly if the state ever occurs (e.g. future refactor of init flow).
it('switches when activePaneId is null (defensive)', () => {
expect(shouldFollowMouseFocus({ ...base, activePaneId: null })).toBe(true)
})
})
```
- [ ] **Step 2: Run the test file to confirm it fails**
Run: `pnpm exec vitest run src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`
Expected: FAIL with an error resolving `./focus-follows-mouse` (the implementation file doesn't exist yet).
- [ ] **Step 3: Create the implementation file**
Create `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` with the following contents:
```ts
/**
* Pure decision logic for the focus-follows-mouse feature. Kept free of
* DOM/event dependencies so it can be unit-tested under vitest's node env.
*
* See docs/focus-follows-mouse-design.md for rationale behind each gate.
*/
export type FocusFollowsMouseInput = {
featureEnabled: boolean
activePaneId: number | null
hoveredPaneId: number
mouseButtons: number // MouseEvent.buttons bitmask
windowHasFocus: boolean // document.hasFocus()
managerDestroyed: boolean
}
/** Returns true iff the hovered pane should be activated. */
export function shouldFollowMouseFocus(input: FocusFollowsMouseInput): boolean {
if (!input.featureEnabled) return false
if (input.managerDestroyed) return false
if (input.activePaneId === input.hoveredPaneId) return false
// Why event.buttons !== 0: any held mouse button means a selection or
// a drag is in progress. Switching focus mid-drag would break xterm.js
// text selection and the pane drag-to-reorder flow. This single check
// also covers drag-to-reorder, since the drag is always button-held.
// See pane-drag-reorder.ts:77-103 for the drag state lifecycle.
if (input.mouseButtons !== 0) return false
// Why document.hasFocus: if Orca isn't the OS-focused window, the mouse
// event is from the user passing through on their way to another app.
// Also returns false when DevTools is focused (DevTools runs in a
// separate WebContents) — accepted. Users close DevTools or click to
// resume normal behavior.
if (!input.windowHasFocus) return false
return true
}
```
- [ ] **Step 4: Run the test file again to confirm all tests pass**
Run: `pnpm exec vitest run src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`
Expected: PASS — 9 tests passing, 0 failing.
- [ ] **Step 5: Run the full test suite to confirm nothing else broke**
Run: `pnpm run test`
Expected: All pre-existing tests still pass, plus the 9 new ones.
- [ ] **Step 6: Commit**
```bash
git add src/renderer/src/lib/pane-manager/focus-follows-mouse.ts src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts
git commit -m "feat: add pure shouldFollowMouseFocus gate helper"
```
---
## Task 4: Wire the `mouseenter` listener through `createPaneDOM` and `PaneManager`
**Files:**
- Modify: `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` (update `createPaneDOM` signature around line 23-29, attach listener around line 126)
- Modify: `src/renderer/src/lib/pane-manager/pane-manager.ts` (add import around line 28, update `createPaneInternal` around line 287, add `handlePaneMouseEnter` after it)
Both files must be updated in the same commit: `createPaneDOM` gains a required parameter that its sole caller (`createPaneInternal`) must provide, so partial landings break the build.
- [ ] **Step 1: Add the `onMouseEnter` parameter to `createPaneDOM`**
Edit `src/renderer/src/lib/pane-manager/pane-lifecycle.ts`. Find the `createPaneDOM` function signature (around line 23-29) and update it:
```ts
export function createPaneDOM(
id: number,
options: PaneManagerOptions,
dragState: DragReorderState,
dragCallbacks: DragReorderCallbacks,
onPointerDown: (id: number) => void,
onMouseEnter: (id: number, event: MouseEvent) => void
): ManagedPaneInternal {
```
- [ ] **Step 2: Attach the `mouseenter` listener next to the existing `pointerdown` listener**
In the same file, find the existing `pointerdown` listener (around line 126):
```ts
// Focus handler: clicking a pane makes it active and explicitly focuses
// the terminal. We must call focus: true here because after DOM reparenting
// (e.g. splitPane moves the original pane into a flex container), xterm.js's
// native click-to-focus on its internal textarea may not fire reliably.
container.addEventListener('pointerdown', () => {
onPointerDown(id)
})
return pane
```
Replace it with:
```ts
// Focus handler: clicking a pane makes it active and explicitly focuses
// the terminal. We must call focus: true here because after DOM reparenting
// (e.g. splitPane moves the original pane into a flex container), xterm.js's
// native click-to-focus on its internal textarea may not fire reliably.
container.addEventListener('pointerdown', () => {
onPointerDown(id)
})
// Focus-follows-mouse handler: when the setting is enabled, hovering a
// pane makes it active. All gating (feature flag, drag-in-progress,
// window focus, etc.) lives in the PaneManager callback — this layer
// just forwards the event.
container.addEventListener('mouseenter', (event) => {
onMouseEnter(id, event)
})
return pane
```
- [ ] **Step 3: Import the gate helper in `pane-manager.ts`**
Edit `src/renderer/src/lib/pane-manager/pane-manager.ts`. Find the existing imports (around line 20-28) and add a new import immediately after the existing pane-manager-related imports:
```ts
import { createPaneDOM, openTerminal, attachWebgl, disposePane } from './pane-lifecycle'
import { shouldFollowMouseFocus } from './focus-follows-mouse'
import {
findPaneChildren,
removeDividers,
promoteSibling,
wrapInSplit,
safeFit,
refitPanesUnder
} from './pane-tree-ops'
```
- [ ] **Step 4: Pass the new callback from `createPaneInternal`**
In the same file, find the `createPaneInternal` method (around line 287-302):
```ts
private createPaneInternal(): ManagedPaneInternal {
const id = this.nextPaneId++
const pane = createPaneDOM(
id,
this.options,
this.dragState,
this.getDragCallbacks(),
(paneId) => {
if (!this.destroyed && this.activePaneId !== paneId) {
this.setActivePane(paneId, { focus: true })
}
}
)
this.panes.set(id, pane)
return pane
}
```
Replace it with:
```ts
private createPaneInternal(): ManagedPaneInternal {
const id = this.nextPaneId++
const pane = createPaneDOM(
id,
this.options,
this.dragState,
this.getDragCallbacks(),
(paneId) => {
if (!this.destroyed && this.activePaneId !== paneId) {
this.setActivePane(paneId, { focus: true })
}
},
(paneId, event) => {
this.handlePaneMouseEnter(paneId, event)
}
)
this.panes.set(id, pane)
return pane
}
/**
* Focus-follows-mouse entry point. Collects gate inputs from the manager
* and delegates to the pure gate helper.
*
* Invariant for future contributors: modal overlays (context menus, close
* dialogs, command palette) must be rendered as portals/siblings OUTSIDE
* the pane container. If a future overlay is ever rendered inside a .pane
* element, mouseenter will still fire on the pane underneath and this
* handler will incorrectly switch focus. Keep overlays out of the pane.
*/
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
if (
shouldFollowMouseFocus({
featureEnabled: this.styleOptions.focusFollowsMouse ?? false,
activePaneId: this.activePaneId,
hoveredPaneId: paneId,
mouseButtons: event.buttons,
windowHasFocus: document.hasFocus(),
managerDestroyed: this.destroyed
})
) {
this.setActivePane(paneId, { focus: true })
}
}
```
- [ ] **Step 5: Run typecheck to verify both files**
Run: `pnpm run tc:web`
Expected: Pass. If it fails with "Expected 6 arguments, but got 5" at `createPaneDOM`, you forgot to update the caller in `createPaneInternal`.
- [ ] **Step 6: Run the full test suite**
Run: `pnpm run test`
Expected: All tests pass, including the 9 from Task 3.
- [ ] **Step 7: Commit**
```bash
git add src/renderer/src/lib/pane-manager/pane-lifecycle.ts src/renderer/src/lib/pane-manager/pane-manager.ts
git commit -m "feat: wire mouseenter listener through PaneManager for focus-follows-mouse"
```
---
## Task 5: Thread the setting through `resolvePaneStyleOptions` and `applyTerminalAppearance`
**Files:**
- Modify: `src/renderer/src/lib/terminal-theme.ts` (around line 103-120)
- Modify: `src/renderer/src/components/terminal-pane/terminal-appearance.ts` (around line 48-55)
At this point the gate helper reads `this.styleOptions.focusFollowsMouse`, which is always `undefined` because nothing populates it yet. This task plumbs the user setting into the runtime options bag.
- [ ] **Step 1: Extend `resolvePaneStyleOptions` to accept and pass the boolean**
**Callers of `resolvePaneStyleOptions`** (verified via grep — there are two, both pass a full `GlobalSettings` so widening the `Pick` union is non-breaking):
1. `src/renderer/src/components/terminal-pane/terminal-appearance.ts:21` — the runtime call from `applyTerminalAppearance`. You WILL modify this file in Step 2.
2. `src/renderer/src/components/settings/TerminalPane.tsx:68` — the settings-pane preview call that powers the theme preview. You will NOT need to modify this file in this task (it passes full `GlobalSettings`, which will contain `terminalFocusFollowsMouse` after Task 1). The new field will silently appear in the returned object and be ignored by the preview — that's fine.
Edit `src/renderer/src/lib/terminal-theme.ts`. Find the `resolvePaneStyleOptions` function (lines 103-118). The current full body is:
```ts
export function resolvePaneStyleOptions(
settings: Pick<
GlobalSettings,
| 'terminalInactivePaneOpacity'
| 'terminalActivePaneOpacity'
| 'terminalPaneOpacityTransitionMs'
| 'terminalDividerThicknessPx'
>
) {
return {
inactivePaneOpacity: clampNumber(settings.terminalInactivePaneOpacity, 0, 1),
activePaneOpacity: clampNumber(settings.terminalActivePaneOpacity, 0, 1),
opacityTransitionMs: clampNumber(settings.terminalPaneOpacityTransitionMs, 0, 5000),
dividerThicknessPx: clampNumber(settings.terminalDividerThicknessPx, 1, 32)
}
}
```
Replace it with:
```ts
export function resolvePaneStyleOptions(
settings: Pick<
GlobalSettings,
| 'terminalInactivePaneOpacity'
| 'terminalActivePaneOpacity'
| 'terminalPaneOpacityTransitionMs'
| 'terminalDividerThicknessPx'
| 'terminalFocusFollowsMouse'
>
) {
return {
inactivePaneOpacity: clampNumber(settings.terminalInactivePaneOpacity, 0, 1),
activePaneOpacity: clampNumber(settings.terminalActivePaneOpacity, 0, 1),
opacityTransitionMs: clampNumber(settings.terminalPaneOpacityTransitionMs, 0, 5000),
dividerThicknessPx: clampNumber(settings.terminalDividerThicknessPx, 1, 32),
// Why no clamping: boolean pass-through. Both true and false are valid.
focusFollowsMouse: settings.terminalFocusFollowsMouse
}
}
```
- [ ] **Step 2: Pass `focusFollowsMouse` to `setPaneStyleOptions` in `applyTerminalAppearance`**
Edit `src/renderer/src/components/terminal-pane/terminal-appearance.ts`. Find the `setPaneStyleOptions` call (around line 48-55):
```ts
manager.setPaneStyleOptions({
splitBackground: paneBackground,
paneBackground,
inactivePaneOpacity: paneStyles.inactivePaneOpacity,
activePaneOpacity: paneStyles.activePaneOpacity,
opacityTransitionMs: paneStyles.opacityTransitionMs,
dividerThicknessPx: paneStyles.dividerThicknessPx
})
```
Replace it with:
```ts
manager.setPaneStyleOptions({
splitBackground: paneBackground,
paneBackground,
inactivePaneOpacity: paneStyles.inactivePaneOpacity,
activePaneOpacity: paneStyles.activePaneOpacity,
opacityTransitionMs: paneStyles.opacityTransitionMs,
dividerThicknessPx: paneStyles.dividerThicknessPx,
focusFollowsMouse: paneStyles.focusFollowsMouse
})
```
- [ ] **Step 3: Run typecheck**
Run: `pnpm run tc:web`
Expected: Pass. If it fails saying `paneStyles.focusFollowsMouse` is not assignable (e.g. `undefined` vs `boolean`), double-check that Step 1 added the field to `resolvePaneStyleOptions`'s returned object.
- [ ] **Step 4: Run the full test suite**
Run: `pnpm run test`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/renderer/src/lib/terminal-theme.ts src/renderer/src/components/terminal-pane/terminal-appearance.ts
git commit -m "feat: thread focusFollowsMouse through pane-style pipeline"
```
**At this point the feature is functionally complete internally.** If a developer manually flipped `terminalFocusFollowsMouse: true` in the persisted settings file, it would work. Tasks 6 and 7 add the discoverable user-facing surface.
---
## Task 6: Add the settings toggle UI in TerminalPane.tsx
**Files:**
- Modify: `src/renderer/src/components/settings/TerminalPane.tsx` (disable comment at line 1; toggle insertion around line 290, the closing of the Pane Styling grid)
**⚠️ Line-count constraint:** `TerminalPane.tsx` is currently exactly 400 lines total (and oxlint passes, so effective non-blank/non-comment lines are ≤ 400). The project's `.oxlintrc.json` sets `max-lines: 400` for `.tsx` files (line 71). Adding the new toggle (~33 lines) would push the file over the limit and the commit in Step 3 would fail under the lint-staged `oxlint` pre-commit hook.
**The project's established workaround** is a file-scoped `eslint-disable` comment with justification — see `src/renderer/src/components/settings/GeneralPane.tsx:1-3`, which does the same for the same reason (484 lines, all owning general settings UI). Step 1 below applies this pattern to `TerminalPane.tsx` _before_ adding the toggle, so the file never goes over the limit.
- [ ] **Step 1: Add the `max-lines` eslint-disable at the top of TerminalPane.tsx**
Edit `src/renderer/src/components/settings/TerminalPane.tsx`. The file currently starts with:
```tsx
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
```
Prepend a file-level disable comment with an explicit justification (matching the `GeneralPane.tsx` precedent):
```tsx
/* eslint-disable max-lines -- Why: TerminalPane is the single owner of all terminal settings UI;
splitting individual settings into separate files would scatter related controls without a
meaningful abstraction boundary. Mirrors the same decision made for GeneralPane.tsx. */
import { useState } from 'react'
import type { GlobalSettings } from '../../../../shared/types'
```
- [ ] **Step 2: Add the `SearchableSetting` toggle below the Pane Styling grid**
Edit `src/renderer/src/components/settings/TerminalPane.tsx`. Find the end of the Pane Styling grid (around line 290):
```tsx
<SearchableSetting
title="Divider Thickness"
description="Thickness of the pane divider line."
keywords={['pane', 'divider', 'thickness']}
>
<NumberField
label="Divider Thickness"
description="Thickness of the pane divider line."
value={paneStyleOptions.dividerThicknessPx}
defaultValue={1}
min={1}
max={32}
step={1}
suffix="px"
onChange={(value) =>
updateSettings({
terminalDividerThicknessPx: clampNumber(value, 1, 32)
})
}
/>
</SearchableSetting>
</div>
</section>
```
Insert a new `SearchableSetting` toggle between the closing `</div>` of the grid and the closing `</section>`:
```tsx
<SearchableSetting
title="Divider Thickness"
description="Thickness of the pane divider line."
keywords={['pane', 'divider', 'thickness']}
>
<NumberField
label="Divider Thickness"
description="Thickness of the pane divider line."
value={paneStyleOptions.dividerThicknessPx}
defaultValue={1}
min={1}
max={32}
step={1}
suffix="px"
onChange={(value) =>
updateSettings({
terminalDividerThicknessPx: clampNumber(value, 1, 32)
})
}
/>
</SearchableSetting>
</div>
<SearchableSetting
title="Focus Follows Mouse"
description="Hovering a terminal pane activates it without needing to click. Mirrors Ghostty's focus-follows-mouse setting."
keywords={['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<div className="space-y-0.5">
<Label>Focus Follows Mouse</Label>
<p className="text-xs text-muted-foreground">
Hovering a terminal pane activates it without needing to click.
Mirrors Ghostty&apos;s focus-follows-mouse setting. Selections and
window switching stay safe.
</p>
</div>
<button
role="switch"
aria-checked={settings.terminalFocusFollowsMouse}
onClick={() =>
updateSettings({
terminalFocusFollowsMouse: !settings.terminalFocusFollowsMouse
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.terminalFocusFollowsMouse ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.terminalFocusFollowsMouse ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</SearchableSetting>
</section>
```
**Placement rationale:** The new toggle sits _below_ the existing 2-column `grid` (not inside it) because a full-width toggle row doesn't balance well next to numeric input fields in a 2-column layout.
- [ ] **Step 3: Run typecheck and lint**
Run: `pnpm run tc:web && pnpm exec oxlint src/renderer/src/components/settings/TerminalPane.tsx`
Expected: Both pass with 0 errors. The explicit `oxlint` run is a safety net: if the file somehow still trips `max-lines` (e.g., the disable comment was formatted incorrectly), catch it here rather than in the pre-commit hook.
- [ ] **Step 4: Commit**
```bash
git add src/renderer/src/components/settings/TerminalPane.tsx
git commit -m "feat: add Focus Follows Mouse toggle to Terminal settings"
```
---
## Task 7: Add the settings search entry
**Files:**
- Modify: `src/renderer/src/components/settings/terminal-search.ts` (around line 34-45, the `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` array)
- [ ] **Step 1: Add the new entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES`**
Edit `src/renderer/src/components/settings/terminal-search.ts`. Find `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` (around line 34):
```ts
export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Inactive Pane Opacity',
description: 'Opacity applied to panes that are not currently active.',
keywords: ['pane', 'opacity', 'dimming']
},
{
title: 'Divider Thickness',
description: 'Thickness of the pane divider line.',
keywords: ['pane', 'divider', 'thickness']
}
]
```
Replace it with:
```ts
export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Inactive Pane Opacity',
description: 'Opacity applied to panes that are not currently active.',
keywords: ['pane', 'opacity', 'dimming']
},
{
title: 'Divider Thickness',
description: 'Thickness of the pane divider line.',
keywords: ['pane', 'divider', 'thickness']
},
{
title: 'Focus Follows Mouse',
description: 'Hovering a terminal pane activates it without needing to click.',
keywords: ['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']
}
]
```
**Note:** No changes needed to `TERMINAL_PANE_SEARCH_ENTRIES` (the aggregator at line 86) because it spreads `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` automatically.
- [ ] **Step 2: Run typecheck and tests**
Run: `pnpm run tc:web && pnpm run test`
Expected: Both pass.
- [ ] **Step 3: Commit**
```bash
git add src/renderer/src/components/settings/terminal-search.ts
git commit -m "feat: add Focus Follows Mouse to settings search index"
```
---
## Task 8: Manual smoke test verification
This task does not produce a commit. It verifies the feature end-to-end in a running build and catches any issue that couldn't be caught by typecheck or unit tests (DOM event interactions, xterm.js behavior, Electron window focus, visual correctness).
- [ ] **Step 1: Launch the app in dev mode**
Run: `pnpm run dev`
Expected: Orca opens. Wait for it to fully initialize (worktree list populates).
- [ ] **Step 2: Enable the setting**
Open Settings (gear icon or menu) → Terminal → Pane Styling. Find the "Focus Follows Mouse" toggle. Toggle it ON.
Expected: The toggle visibly flips to the on state (dark background, white dot on the right).
- [ ] **Step 3: Restart the app and verify persistence**
Close Orca. Run `pnpm run dev` again. Open Settings → Terminal → Pane Styling.
Expected: The toggle is still ON (the setting persisted to the JSON state file).
- [ ] **Step 4: Verify multi-split hover activation**
Open a worktree with a terminal. Create at least 2 splits (use the split-pane UI or keyboard shortcut). Click on pane A to make it active. Move the mouse over pane B without clicking.
Expected: Pane B's opacity updates to the active value, pane B's cursor starts blinking, and typing on the keyboard routes to pane B's shell.
- [ ] **Step 5: Verify text selection is not broken mid-drag**
In a single pane, click-drag to select text. Start the drag in pane A and continue dragging the selection into pane B.
Expected: The text selection extends normally. Focus does NOT switch from pane A to pane B during the drag. When you release the mouse button, the selection is finalized in pane A. (If focus switches mid-drag, the `mouseButtons !== 0` gate is broken.)
- [ ] **Step 6: Verify pane drag-to-reorder is not broken**
Hover a pane so the drag handle (the top strip) appears. Click-drag the pane by its drag handle to a drop zone.
Expected: The drag animation completes normally. No focus flicker during the drag.
- [ ] **Step 7: Verify window-focus gating with Cmd-Tab**
With 2+ splits and the setting enabled: click pane A to make it active. Cmd-Tab to another app. Move the mouse over pane B (still visible). Cmd-Tab back to Orca.
Expected: Pane A (the previously-active pane) is still active — focus did NOT switch to pane B just because the mouse was over it when Orca regained focus.
- [ ] **Step 8: Verify DevTools focus pauses the feature**
With 2+ splits, open Orca DevTools (View → Toggle Developer Tools, or Cmd-Opt-I). Click inside the DevTools panel to focus it. Move the mouse over an inactive pane.
Expected: Focus does NOT switch. `document.hasFocus()` returns false while DevTools is focused, so the gate blocks. Close DevTools, wiggle the mouse → focus-follows-mouse resumes on the next `mouseenter`.
- [ ] **Step 9: Verify modal overlay safety**
With 2+ splits, open the close-terminal confirmation dialog (Cmd-W or the close button on a pane). While the confirmation dialog is visible, move the mouse over a different pane.
Expected: Focus does NOT switch. The modal portal sits above the pane DOM, so `mouseenter` on the pane never fires while the modal is open.
- [ ] **Step 10: Verify URL hover tooltip still works**
With the setting ON, hover a URL in an inactive pane (run `echo https://example.com` in one pane, then hover that URL from another pane).
Expected: (1) The pane activates via focus-follows-mouse. (2) The `Cmd+click to open` URL tooltip appears in the bottom-left of the newly-activated pane. (If the tooltip fails to appear or flickers, there's an interaction issue between `setActivePane → terminal.focus()` and xterm.js's `WebLinksAddon` hover tracking — worth filing and debugging separately.)
- [ ] **Step 11: Verify disabling the setting stops the behavior**
Toggle the setting OFF in Settings. Return to the terminal view. Hover an inactive pane.
Expected: Focus does NOT switch on hover. Click on the pane — the click still activates it. (Disabling the feature must not regress click-to-focus.)
- [ ] **Step 12: Verify single-pane layout has no regressions**
Close all splits so only one pane remains. Toggle the setting ON. Hover the pane.
Expected: No crash, no console errors. The single pane stays focused. (The `activePaneId === hoveredPaneId` gate makes this a no-op.)
- [ ] **Step 13: Verify three-pane traversal flicker (informational)**
Create a horizontal layout of 3 panes: A | B | C. Click pane A. Move the mouse quickly from A straight to C.
Expected: A brief flicker of activation on B as the mouse traverses it (this is the accepted cost of Ghostty-parity immediate switching, documented in the spec). Confirm the flicker is tolerable. If it feels bad, the spec has a follow-up plan for adding a settle delay.
- [ ] **Step 14: Mark the plan complete**
If all 13 checks above passed, the feature is complete. The PR description should embed this smoke test checklist for the reviewer to verify.
---
## Verification Summary
After all 8 tasks are complete, the final state should be:
- 7 new commits on the branch (one per task, Task 8 is verify-only).
- 9 new unit tests passing (`focus-follows-mouse.test.ts`).
- Full typecheck passes (`pnpm run tc`).
- Full test suite passes (`pnpm run test`).
- Manual smoke test checklist (Task 8 Steps 1-13) passes in `pnpm run dev`.
- The setting is discoverable via Settings → Terminal → Pane Styling AND via the settings search box (queries: "focus", "follows", "mouse", "hover", "ghostty").
- The setting defaults to **off** for both new and upgrading users.

View File

@ -1,90 +0,0 @@
# Homebrew Cask
Orca is distributed on macOS as a Homebrew Cask. This doc covers how the cask
is wired up, how it interacts with Orca's in-app updater, and what to do when
something drifts.
## For users
```bash
brew tap stablyai/orca
brew install --cask orca
```
Or in one command: `brew install --cask stablyai/orca/orca`. Both forms install
`Orca.app` into `/Applications`. Subsequent updates are handled by Orca's
in-app updater (electron-updater) — `brew upgrade` is a no-op because the
cask is marked `auto_updates true`. Users who want brew to force-reinstall
from the cask version can pass `--greedy`.
## How the pieces fit together
There are three moving parts:
1. **`Casks/orca.rb`** in this repo — source of truth for the cask file.
Edited by automation on every stable release; can be edited manually if
metadata (zap list, macOS floor, desc) needs to change.
2. **`stablyai/homebrew-orca`** — the public tap users consume. Mirrors
`Casks/orca.rb` from this repo via the bump workflow. Nothing else lives
there; do not hand-edit.
3. **`.github/workflows/homebrew-bump.yml`** — runs on `release.published`
for stable tags (skips `-rc.*` and GitHub pre-releases). Downloads the
two DMGs, rewrites `version`/`sha256` in `Casks/orca.rb`, pushes a PR to
the tap, and auto-merges it.
### Why the cask uses `auto_updates true`
`electron-updater` (`src/main/updater.ts`) downloads each new release and
swaps `Orca.app` in place. Homebrew-Cask's tracking of installed versions is
based on the cask's `version:` field plus an install receipt — so when the
app mutates itself, brew's metadata drifts. `auto_updates true` tells
Homebrew this is expected: `brew outdated` and `brew upgrade` ignore the
cask unless `--greedy` is passed. Uninstall still works normally.
The hidden requirement: Squirrel.Mac (what electron-updater uses) needs
write access to `/Applications/Orca.app`. Cask installs into `/Applications`
with user ownership by default, so this works out of the box. If a user ever
`sudo`-installs or the bundle becomes root-owned, the in-app updater will
fail silently; they'd need `brew reinstall --cask orca` or `brew upgrade
--cask orca --greedy` to recover.
## One-time setup (already done, documented here for reference)
1. **Tap repo**: `stablyai/homebrew-orca` on GitHub. Must be named
`homebrew-<anything>` so `brew tap stablyai/orca` resolves. Public.
2. **Auto-merge** enabled in tap repo settings.
3. **Seeded** with a copy of `Casks/orca.rb` for the initial version.
The workflow authenticates as the existing `buf0-bot` GitHub App
(installed org-wide on stablyai), reusing the `BUFO_BOT_PRIVATE_KEY`
secret that's already on `stablyai/orca` for `track-community-prs.yaml`.
No PAT rotation, no new secret.
## Submitting to homebrew-cask (the main tap)
The `stablyai/homebrew-orca` tap ships first. Once Orca has stable user
demand and has been on a release cadence for ~30+ days without the version
string breaking conventions (no `-rc`, no date suffixes), we can submit to
`Homebrew/homebrew-cask` so users can `brew install --cask orca` without a
tap prefix. That submission is a one-time PR against
https://github.com/Homebrew/homebrew-cask; subsequent bumps to the main tap
are handled by their own [autobump infrastructure](https://docs.brew.sh/Autobump)
as long as the release cadence matches their expectations. T3 Code's
[cask](https://github.com/Homebrew/homebrew-cask/blob/main/Casks/t/t3-code.rb)
is a close structural analogue.
## Troubleshooting
- **"electron-updater says no update available, but brew says I'm out of
date"** — expected if the user ran `brew upgrade --greedy` or installed
the cask before the in-app updater picked up a newer release. The
`auto_updates true` flag usually prevents this; if a user reports it,
check that their cask file still has the marker.
- **Bump workflow failed to PR the tap** — verify the `buf0-bot` app is
still installed on `stablyai/homebrew-orca` (org-wide install, should
auto-cover any new org repo). Re-run via `workflow_dispatch` with the
tag name.
- **Squirrel-mac fails during update** — almost always bundle permissions
or a signing-identity mismatch. See the `Why: signing identity stability`
comment in `config/electron-builder.config.cjs` and the updater logs in
`~/Library/Application Support/Orca/logs/`.

View File

@ -1,81 +0,0 @@
# 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

@ -1,179 +0,0 @@
# 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

@ -1,101 +0,0 @@
# Preload typecheck hole: why project-owned types live in `.ts`
## The rule
Project-owned type declarations under `src/preload/` and `src/shared/`
**must live in `.ts` files, not `.d.ts`**. The CI step
"Guard against project-owned .d.ts in preload/shared" in
`.github/workflows/pr.yml` enforces this.
## Why
Orca inherits `skipLibCheck: true` from `@electron-toolkit/tsconfig`.
That setting is the ecosystem default — it exists so a broken `.d.ts`
in some random `node_modules` package can't block your build. TypeScript
has no way to scope it to `node_modules`, so **`skipLibCheck`
applies to our own `.d.ts` files too**.
In a project-owned `.d.ts`, any type reference that fails to resolve
silently becomes `any` at its call sites instead of erroring. Downstream
assignments against that `any` are also silently accepted. The error
never surfaces during `pnpm typecheck`.
For example, with `skipLibCheck: true`:
```ts
// in src/preload/index.d.ts — Worktree is never imported
type WorktreesApi = {
list: () => Promise<Worktree[]> // silently becomes Promise<any[]>
}
```
and at the call site:
```ts
// in any renderer file
window.api.worktrees.list().then((arr) => {
setWorktreeName(arr) // setWorktreeName expects string; accepted anyway because arr is any[]
})
```
No compile error. Crashes at runtime.
The standard TS convention that sidesteps this: put project-owned types in
`.ts` (which are always checked), reserve `.d.ts` for ambient shims
(`env.d.ts`, `vite/client.d.ts`, etc.). The CI guard encodes that
convention mechanically.
## Incident that forced the fix
PR #1186 changed the `repos:getBaseRefDefault` IPC return shape from
`Promise<string | null>` to `Promise<BaseRefDefaultResult>` (an envelope
object). Two of three renderer callers were updated; the third
(`StartFromField.tsx`) wasn't. That caller passed the envelope object
into a `setState<string | null>` setter, which rendered as JSX and threw
React error #31 (`Objects are not valid as a React child`).
**The call site should have been a compile error.** It wasn't, because
`src/preload/index.d.ts` (now deleted) was a 246-line project-owned
`.d.ts` that referenced ~20 type names it never imported (`Worktree`,
`PRInfo`, `GlobalSettings`, `BaseRefDefaultResult`, and more). Under
`skipLibCheck`, each unresolved name became `any`, which widened the
`.then((ref) => …)` callback parameter to `any` at the consuming call
site. `setDefaultBaseRef(ref: any)` compiled cleanly.
The crash is fixed by #1189. The typecheck hole is fixed by this PR
(#1197), which collapses the two preload type files (`index.d.ts` +
`api-types.d.ts`) into a single type-checked `api-types.ts`. Full design
discussion, alternatives considered, and rollout notes live in PR #1197.
## Non-obvious subtleties worth remembering
- **It's not the hand-authored types that failed — it was the missing
imports.** The types in the old `index.d.ts` were individually fine;
the file only went wrong because names like `Worktree` and `PRInfo`
weren't imported and `skipLibCheck` swallowed the error. A future
contributor copy-pasting types out of a `.d.ts` into `.ts` may be
surprised by a wall of "Cannot find name 'X'" errors — that's the
flag catching its target, not a real regression.
- **`.d.ts` is still legitimate for ambient shims.** `env.d.ts`,
`mermaid.d.ts`, `hosted-git-info.d.ts` all live *outside* the CI
guard's scan roots (`src/preload/` and `src/shared/`) and stay as
`.d.ts`. If a future file under those roots genuinely needs to be
`.d.ts` (e.g., an ambient module shim for a third-party package that
can't live in `.ts`), add it to an allowlist in `pr.yml` at that
time — don't relax the guard wholesale.
- **Intersection types on `window.api` are what actually widened the
`.then` callback to `any`.** The old layout used
`type Api = PreloadApi & { repos: ReposApi, worktrees: WorktreesApi, … }`.
TypeScript's intersection-of-function-types resolution widens callback
parameters to `any` when one side of the intersection has unresolved
names, *even though static-inspection views
(`ReturnType<typeof fn>`) still report the correct type*. So
`ReturnType<typeof window.api.repos.getBaseRefDefault>` printed
`Promise<BaseRefDefaultResult>` during debugging while the live
`.then((ref) => …)` callback treated `ref` as `any`. Don't re-introduce
intersection typing on the preload surface for any reason.
- **Don't try to fix this by flipping `skipLibCheck: false` globally.**
It would force every transitive `@types/*` package to type-check
cleanly, which is why the ecosystem-wide default is `true`. The
structural fix (project-owned types in `.ts`) removes our last
reason to care about the flag for our code.

View File

@ -1,242 +0,0 @@
# Design Document: Quick Jump to Worktree (Issue #426)
## 1. Overview
As Orca scales to support multiple parallel agents and tasks, users frequently need to switch between dozens of active worktrees. Navigating via the sidebar becomes inefficient at scale.
This document describes the shipped "Quick Jump" palette in Orca: a globally accessible Command Palette-style dialog that lets users jump across active worktrees, open browser tabs that live inside worktrees, and create a new worktree from typed input. Search covers worktree metadata (name, branch, repo, comment, PR metadata, issue metadata) and browser metadata (page title, URL, worktree, repo).
## 2. User Experience (UX)
### 2.1 The Shortcut: `Cmd+J` (macOS) / `Ctrl+Shift+J` (Windows/Linux)
To establish this palette as the central "Switch Worktree" action in Orca, `**Cmd+J**` (macOS) and `**Ctrl+Shift+J**` (Windows/Linux) are the chosen shortcuts.
**Why `Cmd+J` / `Ctrl+Shift+J`?**
- **Matches the action honestly:** This palette switches between existing worktrees. "Jump" is a better semantic fit than "Open" because the user is navigating, not creating a new file-open flow.
- **Avoids `Ctrl+J` (Line Feed) conflict:** On Windows and Linux, `Ctrl+J` translates to a Line Feed (`\n`) in bash, zsh, and almost all readline-based CLI applications. For many terminal power users, `Ctrl+J` and `Ctrl+M` (Carriage Return) are used interchangeably with the physical `Enter` key to execute commands. In Vim, it is used for navigation or inserting newlines, and in Emacs it maps to `newline-and-indent`. Intercepting `Ctrl+J` globally would severely disrupt core terminal workflows. Thus, `Ctrl+Shift+J` is used on these platforms. (On macOS, `Cmd` is an OS-level modifier, so `Cmd+J` safely avoids this issue).
- **Avoids `Cmd+K` conflict:** In terminal-heavy apps, `Cmd+K` is universally expected to "Clear Terminal". Overriding it breaks developer muscle memory.
- **Avoids `Cmd+P` conflict:** `Cmd+P` is already in use for Quick Open File (`QuickOpen.tsx`).
- **Avoids `Ctrl+E` (readline):** `Ctrl+E` is "end of line" in bash/zsh readline. Stealing it in a terminal-heavy app would break shell navigation muscle memory — the same class of conflict that rules out `Cmd+K`.
- **Discoverability:** The shortcut should be registered in the Electron Application Menu (e.g., `View -> Open Worktree Palette`) so users can discover it visually.
### 2.2 The Interface
When the shortcut is pressed, a modal dialog appears at the center top of the screen (similar to VS Code's palette or Spotlight).
- **Input:** A text input focused automatically.
- **List:** A scrollable list constrained to `max-h-[min(460px,62vh)]` to prevent the palette from overflowing the viewport when many results are present.
- **Default state (empty query):** When the palette opens with no query, the full list of non-archived worktrees is shown first, ordered by Orca's smart sort. If browser tabs also exist, they appear as a secondary section preview below the worktree list. The palette intentionally ignores the sidebar's `showActiveOnly` and `filterRepoIds` filters — it is a global jump tool, not a filtered view.
- **Sorting (Smart Semantics):** The palette uses Orca's smart worktree ordering via `sortWorktreesSmart(...)`, not plain recency. In practice this prioritizes active agent work, permission-needed state, unread state, live terminals, PR signal, linked issue, and recent activity, with a cold-start fallback to persisted `sortOrder` until any PTY is live.
- **Visual Hierarchy &amp; Highlights:** Because search covers multiple fields simultaneously, the list items must visually clarify *why* a result matched. If the match is inside a comment, display a truncated snippet of that comment centered around the matched range, with the matching text highlighted.
- **Multi-repo disambiguation:** Each list item always displays the repository name (e.g., `stablyai/orca`) alongside the worktree name. This is required because the palette spans all repos — without it, two worktrees named "main" from different repos would be indistinguishable.
- **Cross-surface scope:** The palette is not limited to worktrees. It also surfaces browser tabs, and when the user types a string that matches no worktree results it offers a "Create worktree" action using the current query.
- **Empty State:** Two cases: (1) If the user has no active worktrees and no browser tabs, display "No active worktrees or browser tabs". (2) If items exist but none match the search query, display "No results match your search." Both use `<Command.Empty>`.
- **Search fields:** The search input will match against:
- Worktree `displayName`
- Worktree `branch`, normalized via `branchName()` to strip the `refs/heads/` prefix (e.g., `refs/heads/feature/auth-fix``feature/auth-fix`)
- Repository name (e.g., `stablyai/orca`)
- Full `comment` text attached to the worktree
- Linked PR number/title. Two paths: (a) auto-detected PR via `prCache` (cache key: `${repo.path}::${branch}`), which has both number and title; (b) manual `linkedPR` fallback, which has number only (no title to search against). If `prCache` has a hit, prefer it; otherwise fall back to `linkedPR` number matching.
- Linked issue number/title. The issue number comes from `w.linkedIssue`; the title comes from `issueCache` (cache key: `${repo.path}::${w.linkedIssue}`). Number matching works even without a cache hit; title matching requires the cache entry to be populated.
- **Cache freshness caveat:** PR and issue data is populated by `refreshGitHubForWorktree`, which runs on worktree activation, and by `refreshAllGitHub`, which runs on window re-focus (`visibilitychange`). On startup, `initGitHubCache` loads previously persisted PR/issue data from disk, so worktrees fetched in prior sessions start with warm caches. Worktrees that have never been activated, were not covered by a `refreshAllGitHub` pass, and have no persisted cache entry will have empty caches — PR/issue title search will silently miss them. This is acceptable: the gap is limited to brand-new worktrees between creation and the next activation or window re-focus cycle. Number-based matching (e.g., `#304`) always works because it checks `w.linkedPR` / `w.linkedIssue` directly, without the cache.
- `**#`-prefix handling:** A leading `#` in the query is stripped before matching PR/issue numbers (e.g., `#304` matches number `304`), with a guard against bare `#` which would produce an empty string and match everything. This mirrors the existing `matchesSearch()` behavior.
- **Browser search fields:** Browser page title, URL/secondary text, worktree name, and repo name.
- **Navigation:** `Up` / `Down` arrows to navigate the list, `Enter` to select. `Escape` closes the modal.
## 3. Technical Architecture
### 3.1 UI Components
Orca uses `shadcn/ui` and ships the **Command** component, which wraps the `cmdk` library.
**Dependency:** `cmdk` is a direct dependency in `package.json`.
```bash
pnpm dlx shadcn@latest add command
```
Note: `CommandDialog` uses Radix Dialog internally. Orca keeps this inside the shared `components/ui/command.tsx` wrapper so both palettes use the same dialog primitives and styling hooks.
**z-index:** The `CommandDialog` must use `z-50` or higher to reliably overlay the terminal and sidebar, consistent with `QuickOpen.tsx` which uses `z-50` on its fixed overlay container.
- `**WorktreeJumpPalette.tsx`:** A new component mounted at the root of the app (inside `App.tsx`, alongside the existing `<QuickOpen />`) to ensure it can be summoned from anywhere.
- `**CommandDialog`:** The shadcn component used to render the modal.
### 3.2 Keyboard Shortcut
The shipped shortcut uses a **hybrid main-process + renderer architecture**:
1. The main window listens in `before-input-event` and resolves the chord through the shared window shortcut policy.
2. On `toggleWorktreePalette`, the main process sends `ui:toggleWorktreePalette` to the renderer.
3. The renderer toggles `activeModal === 'worktree-palette'` in `useIpcEvents.ts`.
This extra main-process hop is required because browser guests and other embedded Chromium surfaces can keep keyboard focus inside a guest `webContents`, bypassing the renderer's `window`-level `keydown` listener. A renderer-only implementation would fail from browser-tab focus.
**Toggle semantics:** If the palette is already open, the shortcut closes it; otherwise it opens it. There is no `activeWorktreeId` or `activeView` guard, so the palette is available from settings, from landing states with no active worktree, and from browser focus.
**Overlay mutual exclusion:** The current app models both Quick Open and the worktree palette inside the existing `activeModal` union in `ui.ts` (`'quick-open'` and `'worktree-palette'`). This keeps the two command palettes mutually exclusive without needing separate booleans.
**Menu registration:** Register a `View -> Open Worktree Palette` entry in `register-app-menu.ts` for discoverability, consistent with Section 2.1. The entry must use a **display-only shortcut hint** — do **not** set `accelerator: 'CmdOrCtrl+J'`. In Electron, menu accelerators intercept key events at the main-process level *before* the renderer's `keydown` handler fires (this is how `CmdOrCtrl+,` for Settings works — its `click` handler runs in the main process via `onOpenSettings`). If `CmdOrCtrl+J` were registered as a real accelerator, the renderer `keydown` handler would never see the event, and the overlay mutual-exclusion logic (which runs in the renderer) would be bypassed. Instead, show the shortcut text in the menu label (e.g., `label: 'Open Worktree Palette\tCmdOrCtrl+J'`) without binding `accelerator`, matching the pattern used by `Cmd+P` (QuickOpen), which has no menu entry at all and relies solely on the renderer handler.
### 3.3 State Management
- **Visibility state:** The palette is represented by `activeModal === 'worktree-palette'` in the UI slice. Quick Open similarly uses `activeModal === 'quick-open'`.
- **Palette session state:** `query` and `selectedIndex` are ephemeral to the palette component and should live in React component state (not Zustand). They reset on every open.
- **Render optimization:** When the modal is closed, `CommandDialog` unmounts its content, which is sufficient.
- **Ordering:** Worktree results are fed through `sortWorktreesSmart(...)`, and browser results are ordered relative to that same worktree ordering so both sections feel consistent.
### 3.4 Data Layer &amp; Search
The palette needs access to all worktrees known to Orca.
- **Data source:** Read from the existing `worktreesByRepo` in Zustand (already populated via `fetchAllWorktrees` on startup and kept in sync via IPC push events). No new IPC channel is needed. Filter out archived worktrees (`!w.isArchived`) before searching or displaying. Do **not** apply the sidebar's `showActiveOnly` or `filterRepoIds` filters — the palette is a global jump tool that surfaces all non-archived worktrees regardless of the sidebar's filter state. Because the palette reads directly from `worktreesByRepo`, it reactively updates if a worktree is created or deleted via IPC push while the palette is open — no special stale-list handling is needed.
#### Search implementation
The sidebar already has a `matchesSearch()` function in `worktree-list-groups.ts` that does **substring matching** (`includes(q)`) against displayName, branch, repo, comment, PR, and issue fields. The palette search builds on this foundation but extends it. Note: `branchName()` (used to strip `refs/heads/` prefixes) is currently exported from `worktree-list-groups.ts` — a sidebar-specific module that imports Lucide icons (`CircleCheckBig`, `CircleDot`, etc.) at the top level. Importing `branchName` from it would pull the entire module (including unused icon components) into the palette's bundle. `smart-sort.ts` has its own duplicate: `branchDisplayName()` doing the identical `branch.replace(/^refs\/heads\//, '')`. Extract `branchName()` to a shared utility (`lib/git-utils.ts`) in Phase 1, and update `worktree-list-groups.ts` and `smart-sort.ts` to import from there. This is a 3-line function — the extraction is trivial and avoids the bundle bloat.
1. **Matching strategy: substring, not fuzzy.** Use case-insensitive substring matching for worktrees and browser entries. True fuzzy matching (ordered-character, like `QuickOpen.tsx`'s `fuzzyMatch`) is not used here.
2. **Structured match metadata:** Unlike `matchesSearch()` (which returns `boolean`), the palette search helper returns a result object:
```ts
type MatchRange = { start: number; end: number }
type PaletteMatchBase = { worktreeId: string }
/** Empty query — all non-archived worktrees shown, no match metadata. */
type PaletteMatchAll = PaletteMatchBase & {
matchedField: null
matchRange: null
}
/** Comment match — includes a truncated snippet centered on the matched range. */
type PaletteMatchComment = PaletteMatchBase & {
matchedField: 'comment'
matchRange: MatchRange
snippet: string
}
/** Non-comment field match — range within the matched field's display value. */
type PaletteMatchField = PaletteMatchBase & {
matchedField: 'displayName' | 'branch' | 'repo' | 'pr' | 'issue'
matchRange: MatchRange
}
type PaletteMatch = PaletteMatchAll | PaletteMatchComment | PaletteMatchField
```
3. **Field priority order:** When multiple fields match, report the first match by priority: `displayName` &gt; `branch` &gt; `repo` &gt; `comment` &gt; `pr` &gt; `issue`. This determines which badge/highlight is shown.
4. **Comment snippet extraction:** Search against the full `comment` text. Only the *rendered snippet* is truncated — extract ~80 characters of surrounding context centered on the matched range. Clamping: `snippetStart = Math.max(0, matchStart - 40)`, `snippetEnd = Math.min(comment.length, matchEnd + 40)`. After clamping, snap to word boundaries: scan `snippetStart` backward (up to 10 chars) to the nearest whitespace or string start; scan `snippetEnd` forward (up to 10 chars) to the nearest whitespace or string end. This avoids cutting words mid-character (e.g., `…e implementation of th…``…the implementation of the…`). Prepend `…` if `snippetStart > 0`; append `…` if `snippetEnd < comment.length`.
5. `**cmdk` wiring:** Render with `shouldFilter={false}` so the palette controls filtering. Pass only the filtered result set to `<Command.Item>`:
```tsx
<Command.Item
key={worktree.id}
value={worktree.id}
onSelect={() => handleSelectWorktree(worktree.id)}
>
{/* Render worktree row with match badge + highlighted range */}
</Command.Item>
```
6. **Performance:** Keep `value` compact and do not stuff full comments into `keywords`. The current implementation debounces the query by 150ms before recomputing result sets. That keeps mixed worktree + browser searching cheap without materially hurting responsiveness at Orca's current scale.
### 3.5 Action (Worktree Activation)
#### Existing callsite analysis
The codebase has several worktree activation paths with inconsistent step coverage:
| Step | `WorktreeCard` click | `Cmd+19` | `AddRepoDialog` | `AddWorktreeDialog` |
| ------------------------------------ | -------------------- | --------- | --------------- | ------------------- |
| Set `activeRepoId` | No | No | Yes | Yes |
| Set `activeView` | No | No | Yes | Yes |
| `setActiveWorktree()` | Yes | Yes | Yes | Yes |
| `ensureWorktreeHasInitialTerminal()` | No | No | Yes | Yes |
| `revealWorktreeInSidebar()` | No | Yes | Yes | Yes |
Sidebar card clicks and `Cmd+19` work without setting `activeRepoId` because `activeRepoId` is only consumed by the "Create Worktree" dialog (to pre-select a repo) and session persistence — it does not gate rendering or data fetching for the switched-to worktree. Similarly, `ensureWorktreeHasInitialTerminal` is only needed for newly created worktrees that have never been opened; existing worktrees already have terminal tabs.
#### Palette activation sequence
The palette should match what `Cmd+19` does today (the closest analog: jumping to a visible worktree from any context), plus a few extras justified by the palette's cross-repo scope:
1. **Set `activeRepoId`:** If the target worktree's `repoId` differs from the current `activeRepoId`, call `setActiveRepo(repoId)`. This keeps session persistence and the "Create Worktree" repo pre-selection accurate. Sidebar clicks skip this because they operate within a single repo group; the palette does not have that constraint.
2. **Switch `activeView`:** If `activeView` is `'settings'`, set it to `'terminal'` so the main content area renders the worktree surface. `Cmd+19` does not handle this because it refuses to fire at all from the settings view (gated on `activeView !== 'settings'` in the `onKeyDown` handler); the palette intentionally has no such guard so users can jump to a worktree directly from settings.
3. **Call `setActiveWorktree(worktreeId)`:** This runs Orca's existing activation sequence: sets `activeWorktreeId`, restores per-worktree editor state (`activeFileId`, `activeTabType`, `activeBrowserTabId`), restores the last-active terminal tab, clears unread state, bumps dead PTY generations, and triggers `refreshGitHubForWorktree` to ensure PR/issue/checks data is current for the newly active worktree.
4. **Ensure a focusable surface:** If the worktree has no renderable tabs, call `ensureWorktreeHasInitialTerminal` (`worktree-activation.ts`). The helper now decides this via the reconciled tab model, not by checking whether the legacy terminal-tab array is empty.
5. **Reveal in sidebar:** Call `revealWorktreeInSidebar(worktreeId)` to ensure the selected worktree is visible (handles collapsed groups and scroll position).
6. **Close the palette.**
#### Shared helper
The five activation steps above overlap heavily with `AddRepoDialog.handleOpenWorktree` and `AddWorktreeDialog`'s post-create flow. With three callsites now sharing the same core sequence, extract a shared `activateAndRevealWorktree(worktreeId: string, opts?: { setup?: WorktreeSetupLaunch })` helper in `worktree-activation.ts` that covers the common steps: set `activeRepoId` (cross-repo), switch `activeView` (from settings), `setActiveWorktree`, `ensureWorktreeHasInitialTerminal`, clear sidebar filters that would hide the target, and `revealWorktreeInSidebar`.
**Sidebar filter clearing:** The helper must clear any sidebar filter state that would prevent the target card from being rendered, because `revealWorktreeInSidebar` relies on the worktree card being *rendered* in the sidebar (the `pendingRevealWorktreeId` effect in `WorktreeList` finds the target in the rendered `rows` array via `findIndex`). If sidebar filters exclude the target, the card is never rendered and the reveal silently no-ops — the user selects a worktree and nothing visually happens. `AddWorktreeDialog` already handles this inline (clears both `searchQuery` and `filterRepoIds` before activation); the shared helper absorbs that responsibility. Specifically:
- Clear `filterRepoIds` if it is non-empty and does not include the target worktree's repo.
- Clear `searchQuery` unconditionally if it is non-empty. Even if the target repo is visible, an active text search might exclude the specific worktree being jumped to.
Callsite-specific extras that remain inline after calling the shared helper:
- `**AddWorktreeDialog`:** `setSidebarOpen(true)`, open right sidebar if `rightSidebarOpenByDefault`.
- `**AddRepoDialog`:** `closeModal()` (the palette closes itself separately).
- **Palette:** close the palette, focus management (Section 3.5 Focus management).
The helper derives `repoId` internally via `findWorktreeById(worktreesByRepo, worktreeId)` (`worktree-helpers.ts:45`) — the caller only passes `worktreeId`. If the worktree is not found (e.g., deleted between palette open and select), the helper returns early without side effects.
#### Focus management
- **On worktree select:** After closing the palette, use a double `requestAnimationFrame` (nested rAF) to focus the active terminal/editor surface for the target worktree. `onCloseAutoFocus` calls `preventDefault()` so Radix does not steal focus.
- **On browser-tab select:** Restore focus to the selected browser page, preferring the address bar for blank/new pages and the webview for loaded pages.
- **On escape/cancel:** Restore focus to the previously active browser page when the palette was opened from browser context; otherwise fall back to the terminal/editor surface for the previously active worktree. If no worktree was active, focus falls to the document body.
### 3.6 Accessibility
The `cmdk` library provides built-in ARIA support:
- `role="combobox"` on the input
- `role="listbox"` / `role="option"` on the list and items
- `aria-activedescendant` for keyboard navigation
- `aria-expanded` on the dialog
**Additional requirements:**
- Announce filtered result count changes to screen readers via an `aria-live="polite"` region (e.g., "3 worktrees found").
- Match-field badges (e.g., `Branch`, `Comment`) should include `aria-label` text so screen readers convey why the result matched.
## 4. Implementation Status
The core design is implemented:
- `cmdk` is a direct dependency and Orca ships a shared `CommandDialog`.
- `WorktreeJumpPalette.tsx` is mounted at the app root.
- The palette opens through main-process shortcut forwarding plus renderer IPC toggle handling.
- Worktree activation is routed through `activateAndRevealWorktree(...)`.
- Search supports comment snippets, PR/issue metadata, browser pages, and a create-worktree action.
- Menu discoverability is implemented with a display-only `View -> Open Worktree Palette` hint and no accelerator binding.
## 5. Remaining Gaps / Future Work
- Add broader integration coverage for the mixed worktree/browser palette behavior; current tests focus on search helper behavior and shortcut/menu plumbing.
- Evaluate whether the 150ms debounce should become adaptive if the palette eventually indexes substantially more browser pages.
- Consider unifying the worktree and browser result models further if future result types are added.
**Future work (out of scope)**
- Evaluate migrating `QuickOpen.tsx` (currently a custom overlay with manual keyboard handling) to `cmdk`/`CommandDialog` for visual and behavioral consistency with the palette. This is a separate project — `QuickOpen` has its own fuzzy matching, file-loading, and keyboard handling that would need reworking.
- Add richer end-to-end coverage for palette interactions launched from browser focus, including focus restoration after browser-tab selection and dismissal.
## 6. Alternatives Considered
- `**Cmd+O` (Open):** Standard app semantic, but less honest for this feature because the palette switches between existing worktrees rather than opening a new file or workspace. Rejected in favor of `Cmd+J`, which better matches the action users are taking.
- `**Ctrl+E` (Explore):** Initially considered for Windows/Linux. Rejected because `Ctrl+E` is "end of line" in bash/zsh readline — stealing it in a terminal-heavy app breaks shell navigation muscle memory.
- `**Ctrl+Alt+O`:** Initially considered for Windows/Linux but rejected to avoid `AltGr` collisions on international keyboards (e.g., Polish, German layouts).
- `**Cmd+1...9` (Direct jumping):** Doesn't scale past 9 worktrees and requires the user to memorize sidebar positions. Already implemented as a complementary feature.
- `**Cmd+K`:** Rejected due to conflict with "Clear Terminal".
- `**Cmd+P`:** Rejected because it is already used for file searching (`QuickOpen.tsx`).
- **Renderer-only shortcut handling:** Initially attractive because it mirrors simpler shortcuts, but rejected for the shipped palette. Browser guests can keep keyboard focus inside a separate `webContents`, so a renderer-only `window` listener would miss `Cmd+J` / `Ctrl+Shift+J` from browser-tab focus.

View File

@ -1,119 +0,0 @@
# Remote SSH Folder Picker — Redesign Proposal
## Context
The "Browse remote filesystem" dialog lets users pick a directory on a connected SSH target to open as a remote project. It's reached from: sidebar → Add project → Open remote project → folder-picker icon next to the Remote path input.
**Source:** `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx`
**Invoked from:** `src/renderer/src/components/sidebar/AddRepoSteps.tsx` (`RemoteStep`, around line 167)
## User complaints
1. **Hard to find the desired folder** — list is flat and unfiltered; on a home directory with 30+ dotfiles/dirs, you scroll.
2. **Nested folders not discoverable as navigable** — current model is single-click = highlight, double-click = navigate. Users don't discover the double-click.
3. **Unclear consequence of pressing "Select"** — button is generic; the dialog doesn't say what happens to the chosen folder next.
## Current behavior (for reference)
- Single-click a row → sets `selectedName`, highlights the row.
- Double-click a folder row → navigates into it.
- "Select" button → if a row is highlighted, returns `<resolvedPath>/<selectedName>`; otherwise returns `resolvedPath` (the current directory).
- Breadcrumb bar at top with ↑, 🏠, and clickable path segments.
- Footer shows the path that will be returned: either current dir or `current/highlighted`.
## Reference: how others do it
- **VS Code** (`simpleFileDialog.ts`): one unified input at the top doubles as (a) current path, (b) filter as you type, (c) editable path entry. Enter key navigates into folders; OK button label is caller-supplied ("Open Folder", etc.). Auto-complete suggests folders inline.
- **Superset, Warp**: no comparable picker.
## Options considered
### Option A — VS Code-style: selection = current directory
Drop the "highlight a row" model entirely. Navigate into the folder you want (single-click enters it), then "Select" always returns the current directory.
**Rejected because:** breaks Finder/Explorer muscle memory (double-click to open is a universal convention), and adds a click for the common case of picking a visible child folder.
### Option B — chevron-on-folders affordance
Add a `` navigation button on folder rows to make "enter this folder" discoverable to mouse users. A permanent chevron is visual noise, so we show it only on row hover (and on keyboard focus for a11y parity).
### Option C — add a filter input
Add a text input at the top of the list that live-filters visible entries by substring. No change to the selection model.
### Chosen: Option C + Option B
The filter input (C) addresses complaint #1 and surfaces `Enter`-to-navigate for keyboard users. The hover/focus chevron (B) addresses complaint #2 for mouse users without the visual noise of a permanent affordance. The two are independent and compose cleanly.
## Proposed change
### 1. Filter input (addresses complaint #1)
- Add a text input above the file list (below the breadcrumb bar) that auto-focuses on mount. The picker only mounts when the outer dialog opens (verified: `AddRepoSteps.tsx` conditionally renders `RemoteFileBrowser` based on dialog-open state), so the auto-focus cannot steal focus from the outer dialog's inputs.
- Live-filter `entries` by case-insensitive substring match on `entry.name`. **Filters both files and folders** — hiding files would confuse users trying to confirm they're in the right directory (e.g. looking for a README). Files remain non-actionable.
- Keyboard (handled on the input's `onKeyDown`):
- `↓` / `↑` — move the highlight (`selectedName`) through the *filtered* list. `preventDefault` so the caret doesn't jump. Clamps at the ends: ArrowUp at the first filtered entry (or with nothing highlighted) stays put; it never triggers parent-directory navigation. Parent-nav is exclusively the breadcrumb `↑` button.
- `Enter` — precedence: (a) if a folder is highlighted, navigate into it; (b) else if a **file** is highlighted, surface the transient footer hint (below) and do not navigate; (c) else if the filtered set contains exactly one folder (regardless of how many files are also in the set), navigate into that folder; (d) else highlight the first filtered entry (file or folder) — this is a highlight-only step and the visible highlight is the feedback; a subsequent `Enter` then re-enters this ladder and hits (a) or (b). Rule (b) is the only path that triggers the hint, so a filter that matches only files and is already highlighted on the first entry keeps yielding the hint on repeated `Enter`, never a silent no-op. The hint text is `Files can't be opened as a project`, shown in the footer for 2s before reverting. Chose the footer hint over an input shake/flash because it needs no animation infrastructure and the footer is already the dialog's status region.
- `Esc` — if filter is non-empty, clear it and `stopPropagation` so the outer dialog doesn't close; otherwise let the event bubble and call `onCancel`.
- **Focus management:** the input retains focus across row clicks. Clicking a row calls `setSelectedName` but does not steal focus (`onMouseDown` with `preventDefault` on the row buttons, or explicit `inputRef.current?.focus()` after selection). This keeps arrow keys and typing working after the user mouses.
- **Navigation helper.** Introduce a `navigate(path)` wrapper that calls `loadDir(path)` *and* clears the filter. All user-initiated navigation (breadcrumb segment click, the breadcrumb `↑` parent-directory button, double-click, chevron click, `Enter`-to-enter-folder) goes through `navigate`. The `↑`/`↓` ArrowUp/ArrowDown *keys* move the filter-list highlight per the keyboard spec above and do not call `navigate`. The mount effect calls `loadDir(path)` directly — it never clears the filter, so a user who types before the first load completes does not lose their input. This removes the `didInitialLoad` ref.
- When the filter changes, if the current `selectedName` is no longer in the filtered list, clear it so the button label doesn't go stale.
- **Empty-state copy.** When `entries.length > 0` but `filteredEntries.length === 0`, render `No matches for '<filter>'` instead of the generic `Empty directory` copy — the latter is misleading when the directory has contents that are simply filtered out.
- Placeholder: `Type to filter…` with a leading `Search` icon (lucide) for scannability, matching the existing icon-prefixed inputs in the sidebar.
### 2. Dynamic button label (addresses complaint #3)
Replace the static "Select" label with the path it will return:
- When a row is highlighted: `Select /home/neil/myproject`
- When no row is highlighted: `Select /home/neil` (the current directory)
- **Left-ellipsis truncation.** Plain Tailwind `truncate` right-ellipsizes, which hides the meaningful tail. Use `direction: rtl; text-align: left;` on an inner span wrapping the path (keep the word "Select" in a separate LTR span), or equivalent CSS. Add the full path as a `title` attribute for hover-tooltip verification. Note: RTL-directionality on the path span can reorder punctuation in filenames that themselves contain RTL characters — accepted as a tiny edge-case risk for SSH target paths.
This makes the two selection modes (child vs. current dir) visible without any model change, and tells the user exactly what will be opened.
### 3. Footer
Keep the existing muted full-path line — it's the unambiguous source of truth when the button label is ellipsized. Prepend a short hint so first-time users know what Select does:
> Opens as a remote project · `/home/neil/myproject`
Single line, muted, truncates with right-ellipsis (the prefix is the fixed part, the path tail can be cut since it's already in the button's `title`).
When `Enter` is pressed on a highlighted file, swap this line for `Files can't be opened as a project` (same muted style) for 2s, then revert. Clear the hint's timer eagerly on any filter change or navigation so the message never outlives the state it describes.
### 4. Folder-row chevron (addresses complaint #2)
Folder rows render a `` icon (lucide `ChevronRight`) on the right edge, shown only on row hover or keyboard focus. Files never render the chevron.
- **Trigger:** CSS `:hover` on the row plus a `:focus-visible` rule so keyboard-focused rows also show it. Never always-visible — a permanent chevron is visual noise across a long list.
- **Click handler:** the chevron is a nested `<button>` with its own `onClick` that calls `navigate(entry.path)`. It must `stopPropagation` so the parent row's single-click-select handler doesn't also fire. Clicking the chevron is equivalent to double-clicking the row.
- **a11y:** `aria-label="Open <name>"`. Reachable via Tab when the row is focused; `Enter`/`Space` on the focused chevron navigates into the folder. Screen readers announce the label distinctly from the row's select action.
- **Hit target:** ≥ 24px square, padded inside the row so touchpad taps land reliably.
## What is NOT changing
- **Selection model** — single-click highlights, double-click navigates. Finder/Explorer conventions preserved.
- **Breadcrumb bar** — already good; nicer than VS Code's path-in-input approach.
- **Non-git-folder handling**`AddRepoSteps.tsx` lines 108117 still opens the `confirm-non-git-folder` modal on `Not a valid git repository`. Out of scope.
## Tests
Add a co-located test file (following the pattern of `smart-sort.test.ts` etc. in the same directory). Minimum coverage:
- Filter substring-matches case-insensitively across files and folders.
- `Enter` with a single folder match navigates into it.
- `Enter` with multiple matches and no highlight highlights the first filtered entry.
- `Enter` on a highlighted file surfaces the transient footer hint (`Files can't be opened as a project`) and does not navigate.
- `Esc` with a non-empty filter clears it and does not call `onCancel`; `Esc` with empty filter calls `onCancel`.
- `selectedName` is cleared when the filter change removes it from the visible list.
- Filter is *not* cleared by the initial mount load; *is* cleared by a subsequent `navigate(path)` call.
- **Path equivalence:** navigating into `foo` (via chevron/double-click/`Enter`) then pressing `Select` returns the same `onSelect` path as highlighting `foo` from its parent and pressing `Select`.
- Clicking a folder row's chevron navigates into the folder and does not leave the row merely highlighted (i.e. chevron click is not swallowed by the row's select handler).
- Empty-state copy: with a non-empty directory and a filter that matches nothing, the list shows `No matches for '<filter>'`, not `Empty directory`.
## Files to change
- `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx` — all UI changes live here (filter input, `navigate` wrapper, chevron on folder rows, footer hint state, empty-state copy).
- `src/renderer/src/components/sidebar/RemoteFileBrowser.test.tsx` — new.
- No IPC, main-process, or `AddRepoSteps.tsx` changes required.
## Follow-ups (not this PR)
- **State bloat.** The component will accumulate several pieces of local state (`entries`, `selectedName`, `filter`, `resolvedPath`, transient footer-hint flag, loading/error). Consider consolidating into a `useReducer` in a later pass; leaving as discrete `useState` hooks for this PR to keep the diff reviewable.

View File

@ -1,26 +0,0 @@
# Split Groups PR 1: Model Foundations
This branch lands the behavior-neutral tab-group model groundwork.
Scope:
- add persisted tab-group layout state
- add active-group persistence and hydration
- add group-aware unified-tab helpers in the store
- connect editor/open-file flows to the unified tab-group model
What Is Actually Hooked Up In This PR:
- the store persists `groupsByWorktree`, `layoutByWorktree`, and `activeGroupIdByWorktree`
- workspace session save/hydration includes tab-group layouts
- editor actions create and activate unified tabs through the group model
- the visible workspace renderer is still the legacy single-surface path
What Is Not Hooked Up Yet:
- `Terminal.tsx` does not render split groups
- no split-group UI components are mounted
- no PTY lifecycle changes land here
- no worktree activation fallback changes land here
Non-goals:
- no split-group UI rollout
- no terminal PTY lifecycle changes
- no worktree activation changes

View File

@ -1,24 +0,0 @@
# Split Groups PR 2: Terminal Lifecycle Hardening
This branch lands the terminal ownership and remount safety work required
before split groups can be exposed.
Scope:
- preserve PTYs across remounts
- fix pending-spawn dedupe paths
- fix split-pane PTY ownership
- keep visible-but-unfocused panes rendering correctly
What Is Actually Hooked Up In This PR:
- the existing terminal path uses the new PTY attach/detach/remount behavior
- split panes inside a terminal tab get distinct PTY ownership
- visible terminal panes continue rendering even when another pane or group has focus
What Is Not Hooked Up Yet:
- no split-group layout is rendered
- `Terminal.tsx` still uses the legacy single-surface host path
- no worktree restore/activation changes land here
Non-goals:
- no split-group UI rollout
- no worktree activation fallback changes

View File

@ -1,23 +0,0 @@
# Split Groups PR 3: Worktree Restore Ownership
This branch moves worktree activation and restore logic onto the reconciled
tab-group model.
Scope:
- reconcile stale unified tabs before restore
- restore active surfaces from the group model first
- fall back to terminal when a grouped worktree has no renderable surface
- create a root group before initial terminal fallback attaches a new tab
What Is Actually Hooked Up In This PR:
- opening an existing worktree restores from the reconciled group/tab model
- reopening an empty grouped worktree falls back to a terminal instead of a blank pane
- initial terminal creation is now driven by renderable grouped content instead of one-time init guards
What Is Not Hooked Up Yet:
- no split-group layout is rendered
- the visible workspace host is still the legacy terminal/browser/editor surface path
- tab-group UI components still are not mounted here
Non-goals:
- no split-group UI enablement yet

View File

@ -1,22 +0,0 @@
# Split Groups PR 4: Split-Group UI Scaffolding
This branch adds the split-group UI pieces, but does not mount them in the
main workspace host yet.
Scope:
- add `TabGroupPanel`, `TabGroupSplitLayout`, and `useTabGroupController`
- add split-group actions to tab menus and tab-bar affordances
- add the follow-up design note for the architecture
What Is Actually Hooked Up In This PR:
- the new split-group components compile and exist in the tree
- tab-bar level split-group affordances are present in the component layer
What Is Not Hooked Up Yet:
- `Terminal.tsx` does not mount `TabGroupSplitLayout` in this branch
- users still see the legacy single-surface renderer
- no feature switch exists here because the code path is not wired in yet
Non-goals:
- no rollout to users
- no main-renderer ownership change yet

View File

@ -1,19 +0,0 @@
# Split Groups PR 5: Hook Group Surfaces Into Flagged Path
This branch wires terminal, editor, and browser surfaces into the split-group
ownership path inside `Terminal.tsx`, but holds that path behind a temporary
local gate.
Scope:
- remove duplicate legacy ownership under the flagged path
- route group-local surface creation and restore through the new model
- preserve existing default behavior while the flag stays off
What Is Actually Hooked Up In This PR:
- `Terminal.tsx` now contains the real split-group surface path
- the new path mounts `TabGroupSplitLayout` and avoids keeping duplicate legacy surfaces mounted underneath
- the old legacy surface path is still present as the active runtime path in this branch
What Is Not Hooked Up Yet:
- the split-group path is still disabled by the temporary local rollout gate in `Terminal.tsx`
- users should still get legacy behavior by default in this branch

View File

@ -1,14 +0,0 @@
# Split Groups PR 6: Enable Split Groups
This branch turns split groups on after the earlier model, lifecycle, restore,
and renderer-ownership branches are in place.
Scope:
- remove the temporary rollout gate from `Terminal.tsx`
- make the split-group ownership path the active renderer path
- keep only the small bootstrap fallback for cases where no layout exists yet
What Is Actually Hooked Up In This PR:
- split groups are live
- `Terminal.tsx` always resolves through the split-group path when layout/group state exists
- the temporary gate introduced in the dark-launch PR is removed instead of left behind as dead config

View File

@ -1,197 +0,0 @@
# SSH Folder Picker — Path-Aware Filter
## Problem
On the "Browse remote filesystem" screen, the filter input only searches entries in the current directory. Users who know where they want to go, for example `Documents/orca-internal`, still have to click through each level manually. Typing a path with `/` currently produces "No matches" because the input treats it as a literal filter string.
## Goal
Let the user type a remote folder path like `Documents/orca-internal`, `~/Documents`, `/var/log`, or `../sibling` and navigate there from the existing filter input. The feature should preserve filter-only behavior for users who type ordinary names, avoid request storms over flaky SSH links, and fit the current `RemoteFileBrowser` model where `Select folder` returns the committed current directory.
## Current Implementation Constraints
The renderer entry point is `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx`. Directory loading goes through `window.api.ssh.browseDir`, backed by `src/main/ipc/ssh-browse.ts`.
The browse IPC currently accepts a `dirPath` string and returns `{ resolvedPath, entries }`, where each entry only has `{ name, isDirectory }`. It uses a remote shell command, `cd <path> && pwd && ls -1ap`, not SFTP `readdir`/`stat`. The design below must therefore be implementable using repeated `browseDir` calls. Anything that requires symlink metadata, file type metadata beyond "directory or not", or a cancellable SSH command needs an explicit IPC contract change.
## Design
### Mode Switch
The input has two modes:
- **Filter mode**: ordinary text filters entries in the current directory.
- **Path mode**: path-like text resolves directory segments and uses the final partial segment as the filter in the resolved parent.
Enter path mode when the input:
- contains `/`
- starts with `~/`, `./`, or `../`
- equals `~`, `.`, or `..`
The explicit `..` cases are required because "presence of `/`" alone would make bare `..` behave like a filter instead of parent navigation.
### Input Parsing
| Input | Meaning |
|---|---|
| `docs` | Filter current directory |
| `Documents/orca-internal` | Resolve `Documents`, then filter by `orca-internal` |
| `Documents/` | Resolve and show `Documents` with no filter |
| `/var/log` | Resolve from remote root |
| `~/Documents` | Resolve from the SSH user's home |
| `..` | Parent of current directory |
| `../sibling` | Parent of current directory, then filter by `sibling` |
Parsing should preserve the raw input. Do not normalize away a trailing slash, because `Documents` and `Documents/` mean different things: the first stays in filter mode until Enter, while the second enters path mode and previews `Documents` with an empty filter. Typing a trailing slash does not by itself commit navigation; Enter, a row click, or a breadcrumb click are the only navigation commit actions.
### Base Path
Resolution starts from:
- `/` for absolute inputs beginning with `/`
- the resolved home directory for inputs beginning with `~`
- the current `resolvedPath` for relative inputs
`browseDir('~')` already resolves the remote user's home and returns the absolute `resolvedPath`. Cache that result, but do not hardcode a home path in the renderer. Treat `~` as a base marker, not as a directory name to match under the current directory.
### Resolution Algorithm
For a path-mode input:
1. Split the input into a base, committed path segments, and a trailing filter segment. A segment is committed when it appears before the final separator or when the input is exactly `~`, `.`, or `..`. Ignore the empty segment created by one leading `/` for absolute paths and by one trailing `/`; other empty segments from repeated separators should produce an inline invalid-path error instead of silently rewriting the user input.
2. Resolve committed segments one at a time from the base path:
- `.` keeps the current base.
- `..` moves to the parent path. If already at `/`, stay at `/`.
- exact directory match descends.
- exact non-directory match stops resolution and shows an inline error.
- unique prefix match among directories descends.
- ambiguous prefix stops resolution and shows an inline error.
- no directory match stops resolution and shows an inline error.
3. Once committed segments resolve, display that directory's cached or fetched listing.
4. Apply the trailing segment as the local filter in that resolved directory.
5. Keep the raw input intact on errors. Do not clear user text unless navigation is committed by Enter, a row click, or a breadcrumb click.
Path-mode typing must not call the existing `navigate(path)` wrapper directly. It should update separate preview state, for example `{ previewResolvedPath, previewEntries, previewFilter, previewError, previewLoading }`, while leaving the committed `resolvedPath` untouched. Otherwise typing `Documents/` would change the `Select folder` target before the user commits the path. All committed navigation still goes through `navigate(path)` so the committed current directory, breadcrumb, loading state, and `Select folder` target remain consistent.
The list can render the preview listing while path mode is active, but the footer and `Select folder` target should make the committed path clear. A simple implementation is:
- `Select folder` keeps today's behavior and selects the committed `resolvedPath` when the input is empty or in filter mode.
- `Select folder` is disabled while a non-empty path-mode preview is visible. This prevents silently selecting the old committed directory while the list is showing a different preview directory.
- A row click in a preview listing commits navigation relative to `previewResolvedPath`, not the old committed `resolvedPath`.
### Enter Key
In filter mode, keep today's behavior:
- one matching folder navigates into it
- only file matches show the file hint
- ambiguous folder matches do nothing
In path mode:
- fully resolved directory, including a trailing `/`, navigates there and clears the input
- resolved parent plus one matching child folder navigates into that child and clears the input
- resolved parent plus one exact non-directory match shows the file hint or an inline "not a directory" error and does not clear the input
- ambiguous or invalid path keeps the input and shows the inline error
### Backspace Out Of Empty Input
Optional. If implemented, Backspace on an empty input navigates to the parent directory, equivalent to the breadcrumb up button. It should not fire when the caret is inside non-empty text.
### Paste
Pasting a path resolves through the same parser as typing. Treat a paste as one logical operation: start resolving immediately, show loading state, and drop stale results if the user edits before it completes.
## Debouncing And Request Control
### Filter Mode
Filtering is local against the current `entries` array. Do not call `browseDir` for filter-only edits. A 60-100ms render debounce is acceptable for large directories, but it is not required for correctness. If directories can contain thousands of entries, list virtualization is the larger performance fix.
### Path Mode
Remote calls are only needed when a committed segment requires a listing that is not already cached.
Rules:
- Debounce typed path resolution by 250-350ms.
- Do not debounce paste.
- Track a monotonically increasing request id and ignore stale responses. `AbortController` alone is insufficient unless the IPC contract is changed to support cancellation.
- Cache directory listings by `targetId + absolute resolved path` for the lifetime of the picker.
- Reuse the already-loaded current directory listing as the first cache entry.
- Keep committed directory state and preview directory state separate. The existing `genRef` pattern in `RemoteFileBrowser` protects committed `loadDir` calls, but path preview needs its own request id so a stale preview cannot overwrite committed navigation after the user clicks a breadcrumb or row.
- Do not fetch for partial trailing segment changes. For `Documents/orc` to `Documents/orca`, `Documents` is already resolved, so only the local filter changes.
- Keep the previous visible listing while resolving the next directory. Show a subtle spinner in the input instead of flashing the list to empty.
Invariant: ordinary typing should cause at most one uncached `browseDir` call per newly committed path segment. Paste may issue multiple sequential `browseDir` calls, one per uncached segment, because resolving `/home/neil/project` requires proving each intermediate directory.
## Errors And Empty States
Path-mode errors render below the input and do not replace the file list:
- unresolved segment: `Documentz isn't a directory in /home/neil`
- ambiguous segment: `Doc matches multiple directories in /home/neil`
- permission denied: `Permission denied: /home/neil/private`
The current `ssh:browseDir` implementation needs a small correctness fix for this to work reliably. It rejects only when `stderr` is present and `stdout` is empty, but `cd <path> && pwd && ls -1ap` can print `pwd` to stdout and then fail `ls` with permission denied. In that case the handler currently looks like a successful empty directory. The handler should reject on non-zero exit status, or use a command shape that emits a machine-readable status for `ls`, before this PR claims permission-denied handling.
Empty-state copy should distinguish filter emptiness from directory emptiness:
- current directory has no entries: `Empty directory`
- path mode resolved to an empty directory: `/home/neil/Documents is empty`
- filter hides every entry: `No matches for 'orca'`
## Edge Cases
- **Symlinks to directories**: the current IPC cannot identify or follow them reliably while also exposing symlink metadata. Do not promise symlink-specific UI in this PR unless `ssh:browseDir` is changed to return richer entry metadata.
- **Case sensitivity**: the remote listing is authoritative. Exact (case-sensitive) match wins first so users with both `Documents` and `documents` get what they typed. When no case-sensitive match exists, fall back to a case-insensitive exact match, then a case-insensitive unique prefix match. Without this fallback, typing `documents/` errors while `documents` (no slash) finds `Documents` via the filter — the two modes must not disagree.
- **Trailing slash**: `foo/` commits `foo` as a path segment for preview resolution and shows that directory with an empty filter. It does not commit picker navigation until Enter or a row click.
- **Repeated separators**: reject `foo//bar` as invalid in path mode. Silently collapsing it would make the visible input disagree with the path being resolved.
- **Whitespace**: filter mode can continue trimming for search, but path mode must preserve spaces inside segments and should not trim the full input before parsing. Remote paths can legitimately begin or end with spaces.
- **Remote Windows paths**: the current browse command and this design are POSIX-path oriented. Do not add partial `C:\...` support in the renderer without first making `ssh:browseDir` shell/path handling Windows-aware.
- **Names containing `/`**: impossible to represent as path segments. Treat `/` as a separator.
## Tests
Add focused unit tests around a pure parser/resolver helper, then keep `RemoteFileBrowser` tests thin:
- no slash stays in filter mode
- `..` enters path mode and resolves to parent
- `../sibling` resolves parent and filters by `sibling`
- `Documents/orca` resolves `Documents` and filters by `orca`
- `Documents/` previews `Documents` with an empty filter, and Enter navigates into it
- `/var/log` resolves from root
- `~/Documents` resolves from remote home
- `~` resolves and commits the remote home on Enter
- `./child` resolves from the committed current directory
- exact file match in a committed segment reports "not a directory" instead of descending by prefix
- repeated separators report an invalid-path error
- path-mode parsing preserves spaces in segments
- path preview does not change `resolvedPath` or the `Select folder` target before commit
- `Select folder` is disabled while a non-empty path preview is visible
- unique prefix descends
- ambiguous prefix reports an error and does not navigate
- missing segment reports an error and does not clear input
- permission denied from `ls` rejects instead of rendering an empty directory
- stale async resolution result is ignored after input changes
- stale async preview result is ignored after committed navigation
- cached directories are not fetched again
- partial trailing filter edits do not call `browseDir`
- paste resolves immediately and sequentially
- Enter in path mode clears input only after successful navigation
- existing filter-mode Enter behavior is unchanged
## Files To Change
- `src/renderer/src/components/sidebar/remote-file-browser-helpers.ts`: add parser and pure resolution decision helpers.
- `src/renderer/src/components/sidebar/remote-file-browser-helpers.test.ts`: expand coverage for path mode.
- `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx`: wire path mode, cache, request ids, inline errors, loading affordance, and Enter behavior.
- `src/main/ipc/ssh-browse.ts`: fix error reporting so a failed `ls` after a successful `pwd` rejects instead of returning an empty listing. No metadata or cancellation contract change is required for the base path-entry feature.
## Non-Goals
- New UI controls such as a "go to path" button.
- Rich symlink display.
- Remote Windows path support beyond what the current SSH browse command already handles.
- Changing the picker selection model. `Select folder` continues to return the current resolved directory.

View File

@ -1,176 +0,0 @@
# Split-Pane CWD Inheritance (Cmd+D / Cmd+Shift+D)
## Problem
When the user presses **Cmd+D** or **Cmd+Shift+D** to split a terminal pane, the new pane spawns at the worktree root instead of the source pane's current working directory. Most visible on **SSH** workspaces, but true locally as well — local only appears to "work" when the user hasn't `cd`'d away from the worktree root.
Reference point: Ghostty, iTerm2, kitty, Warp, and WezTerm all inherit the source pane's live CWD when splitting.
## Current behavior (code-level trace)
1. `src/renderer/src/components/terminal-pane/keyboard-handlers.ts:283` dispatches on `action.type === 'splitActivePane'`; the `manager.splitPane(pane.id, direction)` call is at `:296`.
2. `src/renderer/src/lib/pane-manager/pane-manager.ts:93 splitPane()` creates the new pane and fires `onPaneCreated(newPane)`.
3. `src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts:349 onPaneCreated``connectPanePty(pane, manager, {...ptyDeps})`.
4. `src/renderer/src/components/terminal-pane/pty-connection.ts:272` builds the transport with `cwd: deps.cwd`.
5. `deps.cwd` is set once per tab at mount from the `TerminalPane` prop, always `worktree.path`.
There is **no plumbing** anywhere in the renderer that (a) tracks the live CWD of a pane or (b) threads a per-split CWD override into `splitPane`.
The renderer does not currently register any OSC-7 handler. Only the headless daemon emulator (`src/main/daemon/headless-emulator.ts`) parses OSC-7 — that path only feeds daemon snapshot metadata, never the renderer's pane state.
## Prior art: Superset
Superset (sibling project at `../../superset`) already implements renderer-side OSC-7 tracking:
- `parseCwd.ts` — regex `ESC]7;file://[^/]*(/...)BEL|ST`, URI-decodes the path, returns the most recent match in a data chunk.
- `useTerminalCwd.ts` — per-pane hook: seeds from `initialCwd`/`workspaceCwd`, calls `parseCwd` on every xterm data event, stores `{terminalCwd, cwdConfirmed}` with a 150ms debounce.
Superset's split action accepts `options.initialCwd` but its Cmd+D wiring passes a **preset** CWD, not the source pane's tracked `terminalCwd`. We will close that loop.
## Design
### Overview
Track each pane's live CWD in the renderer via OSC-7, fall back to a one-shot `pty.getCwd` query, and pass the resolved value as `cwd` when spawning the new split PTY.
### 1. Track per-pane CWD via OSC-7
Add an OSC-7 handler alongside the existing OSC-52 handler in `use-terminal-pane-lifecycle.ts` `onPaneCreated` (near line 369):
```ts
const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => {
const cwd = parseOsc7(data)
if (cwd) {
paneCwdRef.current.set(pane.id, cwd)
}
// Return true so xterm marks the sequence handled and does not fall
// through to any builtin behavior. Note: xterm's `registerOscHandler`
// contract is "first handler that returns true wins"; if a future
// consumer (e.g. a shared daemon emulator path) also registers on
// code 7, registration order decides who sees each sequence.
return true
})
```
- New ref on `TerminalPane`: `paneCwdRef = useRef<Map<number, string>>(new Map())`.
- Disposable tracked in an `osc7DisposablesRef` map parallel to `osc52DisposablesRef`, torn down in `onPaneClosed` (see `use-terminal-pane-lifecycle.ts:481-484` for the OSC-52 pattern). `onPaneClosed` must also `paneCwdRef.current.delete(paneId)` so the map doesn't accumulate dead entries across splits/closes.
- **Why `registerOscHandler(7)` rather than scanning raw data:** xterm's OSC parser handles BEL/ST termination, sequence fragmentation across chunks, and nesting. A regex on raw data (as Superset does) misses sequences split across PTY read boundaries.
- **Handler install must stay before PTY attach.** The current `onPaneCreated` body (`use-terminal-pane-lifecycle.ts:349-460`) installs OSC parser handlers synchronously *before* `connectPanePty`. That ordering is load-bearing: it guarantees the first byte of the PTY stream — which in the cold-restore path is replayed scrollback containing OSC-7 — reaches our handler. Add a `// Why:` comment so a future refactor does not move the install after attach.
- **Replay + stale OSC-7.** Replayed scrollback can contain OSC-7 from an earlier `cd` that no longer reflects the live shell cwd. `paneCwdRef` does not distinguish replayed vs. live entries. Store `{cwd, confirmed: boolean}` and mark `confirmed = false` when `isPaneReplaying(...)` is true at handler fire time, `confirmed = true` otherwise. `resolveSplitCwd` prefers a `confirmed` entry and otherwise falls through to `pty.getCwd`, which reflects live shell state via `/proc` or `lsof`. **Why the flag is reliable during replay:** `replayIntoTerminal` (`replay-guard.ts:37-55`) increments the counter *before* `pane.terminal.write(data, cb)` and decrements it inside xterm's write-completion callback. xterm fires parser handlers synchronously as it consumes the buffer, so every OSC-7 parsed out of the replayed chunk sees a non-zero counter. Do **not** change `replay-guard.ts` to a pre-`write` decrement — the whole design depends on the decrement being post-parse.
No debouncing needed — `Map.set` is cheap and the value is read only on demand at split time.
### 2. Resolve CWD at split time
Introduce a helper `resolveSplitCwd(sourcePaneId, sourcePtyId, fallbackCwd): Promise<string>`. Owned by `use-terminal-pane-lifecycle.ts` (co-located with `paneCwdRef`) and passed down to keyboard and context-menu handlers as a dep, symmetric with the existing `paneTransportsRef` wiring.
1. If `paneCwdRef.current.get(sourcePaneId)` has `confirmed === true`, return its `cwd` **synchronously** (no await). OSC-7 from a live shell is authoritative and instant.
2. Otherwise `await window.api.pty.getCwd(sourcePtyId)` with a ~200ms soft timeout enforced renderer-side via `Promise.race` — keeps the "fall through to fallback" semantics in one place rather than threading timeouts into the provider. Use the result if non-empty.
3. Otherwise, if `paneCwdRef.current.get(sourcePaneId)` has `confirmed === false` (replayed OSC-7 only), return its `cwd` as a last-ditch guess before falling back.
4. Otherwise return `fallbackCwd` (the existing worktree root).
**Sync vs async in the keydown handler.** The current Cmd+D handler at `keyboard-handlers.ts:283` is synchronous and calls `e.preventDefault()` / `e.stopImmediatePropagation()` before `splitPane`. The new flow preserves sync ordering:
- Compute `preventDefault` + pane snapshot synchronously.
- Read `paneCwdRef` synchronously. If hit → call `splitPane` immediately (no `await`).
- If miss → fire-and-forget an `async` IIFE that awaits `pty.getCwd` then calls `splitPane`. This is equivalent to today's async pane-creation lifecycle, which is already non-blocking.
Double Cmd+D in the cache-miss window: the second keypress fires before the first fire-and-forget IIFE has called `splitPane`, so both async callbacks still see the original source pane as active, resolve the same `pty.getCwd`, and produce two splits off that source (not a chain). This differs from today's fully-sync behavior, which would have chained the second split off the just-created pane. Accepted tradeoff — the window is ~1 IPC round-trip (≤200 ms) and "both splits inherit the same cwd" is not wrong, just not chained. Cache-hit (common case: an active shell with OSC-7 already seen) is still sync and preserves chaining.
### 3. Thread CWD through splitPane
`PaneManager.splitPane(paneId, direction, opts?)` currently accepts `opts: { ratio?: number }` (`pane-manager.ts:93-97`). Extend to `{ ratio?: number; cwd?: string }`.
Plumb the hint to `onPaneCreated` by widening its signature to `(pane, spawnHints?: { cwd?: string })`. The hint is forwarded synchronously inside `splitPane` and has no reason to outlive that call. The existing `pendingSplitScrollState` field on `ManagedPaneInternal` exists only because rAFs read it *later*; that is not the case here.
**Typed API break — all call sites of `onPaneCreated` must update:**
- `PaneManagerOptions.onPaneCreated` type declaration in `pane-manager.ts`.
- The `createPane` invocation at `pane-manager.ts:89` passes `undefined` for `spawnHints` (new panes aren't splits).
- The `splitPane` invocation at `pane-manager.ts:133` forwards `opts?.cwd` as `{ cwd: opts.cwd }` when set.
In `use-terminal-pane-lifecycle.ts`:
```ts
onPaneCreated: (pane, spawnHints) => {
// ...existing handler install...
const panePtyBinding = connectPanePty(pane, manager, {
...ptyDeps,
...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}),
restoredLeafId
})
}
```
Spread order matters: `spawnHints.cwd` overrides the tab-level `ptyDeps.cwd`.
**Callers of `splitPane` — which inherit, which don't.**
| Call site | File:line | Passes `cwd`? | Why |
|---|---|---|---|
| Cmd+D / Cmd+Shift+D | `keyboard-handlers.ts:296` | **yes** | primary feature |
| Context menu: Split Right / Split Down | `use-terminal-pane-context-menu.ts:114,121` | **yes** | same user intent as Cmd+D |
Both Cmd+D and context-menu call sites receive a new dep `resolveSplitCwd: (paneId, ptyId, fallbackCwd) => Promise<string>` and replace `manager.splitPane(pane.id, dir)` with roughly:
```ts
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
const cached = paneCwdRef.current.get(pane.id)
if (cached?.confirmed) {
manager.splitPane(pane.id, dir, { cwd: cached.cwd })
return
}
void (async () => {
const cwd = await resolveSplitCwd(pane.id, ptyId, fallbackCwd)
managerRef.current?.splitPane(pane.id, dir, { cwd })
})()
```
The keydown handler still calls `e.preventDefault()` / `e.stopImmediatePropagation()` synchronously before dispatching the async resolution — matches existing async pane-creation lifecycle.
| Worktree setup split | `use-terminal-pane-lifecycle.ts:693` | no | bootstrap command runs at worktree root by contract |
| Issue-command split | `use-terminal-pane-lifecycle.ts:719` | no | per-repo automation; runs at worktree root by contract |
| CLI-triggered split (`onCliSplitPane`) | `use-terminal-pane-lifecycle.ts:754,757` | no | CLI protocol today carries only `paneRuntimeId` + `direction`; no cwd field. Preserve today's behavior until the CLI protocol adds one. |
| Layout restore | `layout-serialization.ts:299` | no | restored panes re-emit their own OSC-7 on first prompt |
The setup/issue/CLI sites go through `splitPaneWithOneShotStartup`, which is a wrapper receiving a `() => manager.splitPane(...)` thunk. Widening the `splitPane` opts is backward-compatible; the wrapper is unaffected because the new `spawnHints` is a parameter of the `onPaneCreated` *callback*, not of `splitPane` itself.
`pendingSpawnByPaneKey` (`pty-connection.ts:21`) keys by pane key; each new split gets a fresh pane id, so no collision between the inherited-cwd path and existing dedup.
### 4. Preload / main bridge for `pty.getCwd`
`SshPtyProvider.getCwd` already exists (`ssh-pty-provider.ts:132`) and delegates to the relay which calls `resolveProcessCwd(pid, initialCwd)` via `/proc/<pid>/cwd` or `lsof`. Changes needed:
- **`LocalPtyProvider.getCwd` (`src/main/providers/local-pty-provider.ts:388`) currently `throw`s when the id is unknown and returns `''` otherwise.** Change: return `''` on unknown id (do not throw), and on a known id call `resolveProcessCwd(proc.pid, '')` where `proc = ptyProcesses.get(id)`. Pass `''` as the fallback — **not** the pane's initial cwd — because the renderer uses empty-string to mean "no result, try the next fallback layer"; returning the initial cwd would make the renderer think it had an answer and skip its own fallback chain. Do **not** throw on unknown id: the caller treats that case as non-exceptional.
- **Code sharing for `resolveProcessCwd`.** The helper currently lives in `src/relay/pty-shell-utils.ts` and is bundled into the relay binary. The Electron main process and the relay have separate build graphs; having main import from `src/relay/` (or vice-versa) is not a pattern this repo uses. Rather than force a `src/shared/` split (and touch the relay build), keep the relay's copy in place and add a tiny duplicate in `src/main/providers/process-cwd.ts` for `LocalPtyProvider`. The function is ~30 lines and has no shared state; the cost of duplication is lower than the cost of reshaping two bundle graphs.
- **IPC handler:** add `ipcMain.handle('pty:getCwd', (_e, {id}) => getProviderForPty(id).getCwd(id))` in `src/main/ipc/pty.ts`, and add `ipcMain.removeHandler('pty:getCwd')` to the removeHandler block at `pty.ts:325-331` (next to the existing `pty:hasChildProcesses` / `pty:getForegroundProcess` entries).
- **Preload:** add `pty.getCwd(id: string): Promise<string>` in `src/preload/index.ts` alongside `hasChildProcesses`/`getForegroundProcess`, and mirror in `src/preload/api-types.ts`. Returns `''` when unknown.
### 5. Edge cases
- **Agent TUIs (Claude Code, Codex, cursor-agent).** No OSC-7 because no shell prompt. `paneCwdRef` stays empty; `pty.getCwd` via `/proc/<pid>/cwd` still resolves against the running agent process.
- **Just-split panes (no OSC-7 yet).** A fresh pane before its first prompt has no entry. `pty.getCwd` fallback handles it. Double-Cmd+D inherits from the still-fresh shell's spawn CWD — correct.
- **SSH reconnect / cold-restore.** OSC-7 entries are keyed by ephemeral `paneId`. Replayed OSC-7 in the scrollback re-feeds the handler via `terminal.write` (`replay-guard.ts:47`) and repopulates the map with `confirmed: false`. The first live OSC-7 after the first prompt upgrades it to `confirmed: true`. If the user hits Cmd+D before the first live prompt, `resolveSplitCwd` takes the `pty.getCwd` path (which queries `/proc/<pid>/cwd` on the live shell) and only falls back to the replayed entry if IPC yields nothing.
- **Non-emitting shells.** Minimal `sh` without OSC-7 → `pty.getCwd` fallback carries it. Explicit reason to keep the two-layer strategy.
- **Windows.** `/proc` is absent and `lsof` isn't native. On Windows, `LocalPtyProvider.getCwd` must return `''` (fall through to worktree root) and must **not** throw — after the §4 change, the existing `throw`-on-unknown-id is replaced with `''`, and the Windows branch simply returns `''` unconditionally. Not a regression over today's behavior. OSC-7 from PowerShell/pwsh still works because parsing is renderer-side and platform-agnostic.
- **Expanded-pane mode.** `keyboard-handlers.ts:286` exits expanded mode before splitting. CWD lookup runs on the currently-active pane before that exit, which is the correct source.
### 6. Tests
- Unit: `parseOsc7` — BEL vs ST termination, percent-decoded spaces, empty host, Windows drive-letter URIs (`file:///C:/Users/...`). On Windows, `parseOsc7` must return a string that `node-pty` accepts as `cwd` on `spawn` (backslash- or forward-slash-separated, with drive letter, no leading `/`). Normalize inside `parseOsc7` via `path.win32.normalize` when `process.platform === 'win32'` and assert the test output equals what `spawn`'s `cwd` option tolerates — don't leave the choice implicit.
- Unit: `resolveSplitCwd` priority — OSC-7 cache hit skips IPC; cache miss queries IPC; empty IPC result falls through to `deps.cwd`; IPC timeout falls through to `deps.cwd`.
- Unit: `LocalPtyProvider.getCwd` returns `''` (not throws) for unknown ids.
- Integration (`tests/e2e/terminal-panes.spec.ts`): `cd /tmp && emit OSC-7; Cmd+D → new pane pwd === /tmp`.
- No SSH e2e suite exists in `tests/e2e/` today; skip.
### 7. Rollout / compatibility
- No migration or settings surface.
- Safe default: if OSC-7 and `pty.getCwd` both fail, fall back to worktree root. Users never end up worse off.
- Ships without a flag.
## Out of scope
- Persisting per-pane CWD across app restarts. Each session starts fresh; OSC-7 re-arrives on first prompt.
- **New-tab-from-pane inheritance (Cmd+T).** Deliberately deferred — users will ask why Cmd+D inherits and Cmd+T does not, but new-tab also implies worktree choice and startup payload semantics that are not in scope here. Reuse `paneCwdRef` + `resolveSplitCwd` when the new-tab flow is revisited.
- Using tracked CWD for status-bar display or any other non-split consumer. Separate features that can reuse `paneCwdRef` later.
- CWD for context-menu entries that open external tools (file manager, editor). Those already have their own resolution paths.

View File

@ -1,408 +0,0 @@
# Terminal Search Next/Previous Shortcuts — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `Cmd+G` / `Cmd+Shift+G` shortcuts to navigate terminal search matches when the search bar is open.
**Architecture:** A `searchStateRef` bridges the search query/options from `TerminalSearch` (which owns the state) to `keyboard-handlers.ts` (which handles the shortcut). The `Cmd+G` handler is placed before the `isEditableTarget` guard so it works even when focus is in the search input.
**Tech Stack:** React, TypeScript, xterm.js (`@xterm/addon-search`), Vitest
---
## File Map
| File | Action | Responsibility |
|------|--------|---------------|
| `src/renderer/src/components/terminal-pane/keyboard-handlers.ts` | Modify | Add `Cmd+G` / `Cmd+Shift+G` handler |
| `src/renderer/src/components/TerminalSearch.tsx` | Modify | Accept and sync `searchStateRef` |
| `src/renderer/src/components/terminal-pane/TerminalPane.tsx` | Modify | Create and wire `searchStateRef` |
| `src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts` | Create | Test the new shortcut handler logic |
---
### Task 1: Extract and test the `Cmd+G` / `Cmd+Shift+G` key-matching logic
The keyboard handler uses a capture-phase `window` listener with DOM dependencies (`e.target`, `pane.terminal.focus()`). Rather than mocking all of that, we test the **decision logic** in isolation: given a key event shape + search state, should the handler fire findNext, findPrevious, or do nothing?
**Files:**
- Create: `src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`
- [ ] **Step 1: Write failing tests for the search-navigate decision logic**
We'll test a pure helper function `matchSearchNavigate` that we'll extract in Task 2. For now, write the tests against the expected interface.
```ts
// src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts
import { describe, it, expect } from 'vitest'
import { matchSearchNavigate } from './keyboard-handlers'
function makeKeyEvent(overrides: Partial<{
key: string
metaKey: boolean
ctrlKey: boolean
shiftKey: boolean
altKey: boolean
}>): Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'> {
return {
key: 'g',
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
...overrides
}
}
describe('matchSearchNavigate', () => {
const isMac = true
const searchState = { query: 'hello', caseSensitive: false, regex: false }
it('returns "next" for Cmd+G on macOS', () => {
const e = makeKeyEvent({ metaKey: true })
expect(matchSearchNavigate(e, isMac, true, searchState)).toBe('next')
})
it('returns "previous" for Cmd+Shift+G on macOS', () => {
const e = makeKeyEvent({ metaKey: true, shiftKey: true })
expect(matchSearchNavigate(e, isMac, true, searchState)).toBe('previous')
})
it('returns null when search is closed', () => {
const e = makeKeyEvent({ metaKey: true })
expect(matchSearchNavigate(e, isMac, false, searchState)).toBeNull()
})
it('returns null when query is empty', () => {
const e = makeKeyEvent({ metaKey: true })
expect(matchSearchNavigate(e, isMac, true, { query: '', caseSensitive: false, regex: false })).toBeNull()
})
it('returns null for wrong key', () => {
const e = makeKeyEvent({ metaKey: true, key: 'f' })
expect(matchSearchNavigate(e, isMac, true, searchState)).toBeNull()
})
it('returns null when alt is pressed', () => {
const e = makeKeyEvent({ metaKey: true, altKey: true })
expect(matchSearchNavigate(e, isMac, true, searchState)).toBeNull()
})
it('returns "next" for Ctrl+G on Linux/Windows', () => {
const e = makeKeyEvent({ ctrlKey: true })
expect(matchSearchNavigate(e, false, true, searchState)).toBe('next')
})
it('returns null for Ctrl+G on macOS (wrong modifier)', () => {
const e = makeKeyEvent({ ctrlKey: true })
expect(matchSearchNavigate(e, true, true, searchState)).toBeNull()
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm test -- src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`
Expected: FAIL — `matchSearchNavigate` is not exported from `keyboard-handlers`
- [ ] **Step 3: Commit**
```bash
git add src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts
git commit -m "test: add failing tests for search-navigate key matching"
```
---
### Task 2: Implement `matchSearchNavigate` and wire it into the keyboard handler
**Files:**
- Modify: `src/renderer/src/components/terminal-pane/keyboard-handlers.ts`
- [ ] **Step 1: Add the `SearchState` type and `matchSearchNavigate` function**
Add this above the `useTerminalKeyboardShortcuts` function:
```ts
export type SearchState = {
query: string
caseSensitive: boolean
regex: boolean
}
/**
* Pure decision function for Cmd+G / Cmd+Shift+G search navigation.
* Returns 'next', 'previous', or null (no match).
* Extracted so the key-matching logic is testable without DOM dependencies.
*/
export function matchSearchNavigate(
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>,
isMac: boolean,
searchOpen: boolean,
searchState: SearchState
): 'next' | 'previous' | null {
if (e.altKey) return null
const mod = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
if (!mod) return null
if (e.key.toLowerCase() !== 'g') return null
if (!searchOpen) return null
if (!searchState.query) return null
return e.shiftKey ? 'previous' : 'next'
}
```
- [ ] **Step 2: Add `searchOpen` and `searchStateRef` to the deps type**
Update the `KeyboardHandlersDeps` type — add two new fields:
```ts
type KeyboardHandlersDeps = {
isActive: boolean
managerRef: React.RefObject<PaneManager | null>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
expandedPaneIdRef: React.RefObject<number | null>
setExpandedPane: (paneId: number | null) => void
restoreExpandedLayout: () => void
refreshPaneSizes: (focusActive: boolean) => void
persistLayoutSnapshot: () => void
toggleExpandPane: (paneId: number) => void
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>
onRequestClosePane: (paneId: number) => void
searchOpen: boolean
searchStateRef: React.RefObject<SearchState>
}
```
- [ ] **Step 3: Add the `Cmd+G` handler inside `onKeyDown`, before the `isEditableTarget` guard**
Insert this block in `onKeyDown` right after the `if (e.repeat) return` check (line 62) and before `if (isEditableTarget(e.target))` (line 64):
```ts
// Cmd+G / Cmd+Shift+G navigates terminal search matches.
// Placed before the isEditableTarget guard so it works when focus
// is in the search input. Uses its own mod-key check because this
// runs before the shared `mod` variable is declared.
// preventDefault suppresses macOS/Electron's native "find next".
const direction = matchSearchNavigate(e, isMac, searchOpen, searchStateRef.current)
if (direction !== null) {
e.preventDefault()
e.stopPropagation()
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) return
const { query, caseSensitive, regex } = searchStateRef.current
if (direction === 'next') {
pane.searchAddon.findNext(query, { caseSensitive, regex })
} else {
pane.searchAddon.findPrevious(query, { caseSensitive, regex })
}
pane.terminal.focus()
return
}
```
Note: this block accesses `manager` which is declared further down. Move the `const manager = managerRef.current; if (!manager) return` check above the `isEditableTarget` guard as well, so the search handler can reference it. This reorder is safe — the code between the old `manager` position and `isEditableTarget` only computes `mod` and checks `altKey`, neither of which references `manager`. The minor behavioral change is that `!manager` now short-circuits before `isEditableTarget` runs, which is harmless (no manager means no terminal to handle shortcuts for). The full reordering inside `onKeyDown` becomes:
```
if (e.repeat) return
const manager = managerRef.current // ← moved up from line 72
if (!manager) return // ← moved up from line 73
// Cmd+G / Cmd+Shift+G handler (new)
if (isEditableTarget(e.target)) return
const mod = ...
```
- [ ] **Step 4: Add `searchOpen` and `searchStateRef` to the destructuring and useEffect deps array**
Update the function signature destructuring to include `searchOpen` and `searchStateRef`. Add both to the `useEffect` dependency array (alongside the existing entries):
```ts
export function useTerminalKeyboardShortcuts({
isActive,
managerRef,
paneTransportsRef,
expandedPaneIdRef,
setExpandedPane,
restoreExpandedLayout,
refreshPaneSizes,
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
onRequestClosePane,
searchOpen,
searchStateRef
}: KeyboardHandlersDeps): void {
useEffect(() => {
// ...
}, [
isActive,
managerRef,
paneTransportsRef,
expandedPaneIdRef,
setExpandedPane,
restoreExpandedLayout,
refreshPaneSizes,
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
onRequestClosePane,
searchOpen,
searchStateRef
])
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `pnpm test -- src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`
Expected: All 8 tests PASS
- [ ] **Step 6: Commit**
```bash
git add src/renderer/src/components/terminal-pane/keyboard-handlers.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts
git commit -m "feat: add Cmd+G / Cmd+Shift+G search navigation to keyboard handler"
```
---
### Task 3: Wire `searchStateRef` through `TerminalPane`, `TerminalSearch`, and sync it
Tasks 3 modifies both `TerminalPane.tsx` and `TerminalSearch.tsx` together because `searchStateRef` is a required prop — modifying them separately would break TypeScript compilation between commits.
**Files:**
- Modify: `src/renderer/src/components/terminal-pane/TerminalPane.tsx`
- Modify: `src/renderer/src/components/TerminalSearch.tsx`
- [ ] **Step 1: Import `SearchState` and create the ref in `TerminalPane`**
Add the import at the top of `TerminalPane.tsx` alongside the existing `keyboard-handlers` import:
```ts
import { useTerminalKeyboardShortcuts } from './keyboard-handlers'
```
becomes:
```ts
import { useTerminalKeyboardShortcuts, type SearchState } from './keyboard-handlers'
```
Then add the ref near the other refs (after `const [searchOpen, setSearchOpen] = useState(false)` on line 65):
```ts
const searchStateRef = useRef<SearchState>({ query: '', caseSensitive: false, regex: false })
```
- [ ] **Step 2: Pass `searchOpen` and `searchStateRef` to `useTerminalKeyboardShortcuts`**
Update the call (around line 263) to include the two new fields:
```ts
useTerminalKeyboardShortcuts({
isActive,
managerRef,
paneTransportsRef,
expandedPaneIdRef,
setExpandedPane,
restoreExpandedLayout,
refreshPaneSizes,
persistLayoutSnapshot,
toggleExpandPane,
setSearchOpen,
onRequestClosePane: handleRequestClosePane,
searchOpen,
searchStateRef
})
```
- [ ] **Step 3: Pass `searchStateRef` to `TerminalSearch`**
Update the `TerminalSearch` JSX (around line 567) to include the new prop:
```tsx
<TerminalSearch
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
searchAddon={activePane.searchAddon ?? null}
searchStateRef={searchStateRef}
/>,
```
- [ ] **Step 4: Add `searchStateRef` prop to `TerminalSearch`**
In `TerminalSearch.tsx`, import the `SearchState` type from `keyboard-handlers` and use it in the props type. This keeps the type definition in one place so it can't silently diverge:
```ts
import type { SearchState } from '@/components/terminal-pane/keyboard-handlers'
```
Then update the props type and destructuring:
```ts
type TerminalSearchProps = {
isOpen: boolean
onClose: () => void
searchAddon: SearchAddon | null
searchStateRef: React.MutableRefObject<SearchState>
}
export default function TerminalSearch({
isOpen,
onClose,
searchAddon,
searchStateRef
}: TerminalSearchProps): React.JSX.Element | null {
```
- [ ] **Step 5: Sync the ref inside the existing incremental-search `useEffect`**
The existing `useEffect` (lines 4755) already runs whenever `query`, `caseSensitive`, or `regex` change. Add the ref sync at the top, before the early return on empty query. This ensures the ref stays in sync even when the query is cleared (so it reflects the true current state):
```ts
useEffect(() => {
// Keep the ref in sync so the keyboard handler (Cmd+G / Cmd+Shift+G)
// can read the current search state without lifting it to parent state.
searchStateRef.current = { query, caseSensitive, regex }
if (!query) {
searchAddon?.clearDecorations()
return
}
if (searchAddon && isOpen) {
searchAddon.findNext(query, { caseSensitive, regex, incremental: true })
}
}, [query, searchAddon, isOpen, caseSensitive, regex, searchStateRef])
```
Note: `searchStateRef` is added to the dependency array to satisfy the linter, though as a ref it never changes identity.
- [ ] **Step 6: Run the full test suite to verify nothing is broken**
Run: `pnpm test`
Expected: All tests pass (no regressions)
- [ ] **Step 7: Commit**
```bash
git add src/renderer/src/components/terminal-pane/TerminalPane.tsx src/renderer/src/components/TerminalSearch.tsx
git commit -m "feat: wire searchStateRef through TerminalPane and TerminalSearch"
```
---
### Task 4: TypeScript build verification
**Files:** None (verification only)
- [ ] **Step 1: Run the TypeScript compiler to check for type errors**
Run: `pnpm run typecheck` (or the equivalent — check `package.json` scripts)
If no `typecheck` script exists, run: `npx tsc --noEmit`
Expected: No type errors
- [ ] **Step 2: Run the full test suite one final time**
Run: `pnpm test`
Expected: All tests pass
- [ ] **Step 3: Commit if any fixes were needed, otherwise skip**

View File

@ -1,360 +0,0 @@
# Mermaid File Viewer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Open `.mmd` / `.mermaid` files and render them as live, themed diagrams with a source/diagram toggle in the editor header.
**Architecture:** Orca already has a `MermaidBlock` component that renders mermaid syntax to SVG, and a `MarkdownViewToggle` that switches between source and rich modes. This plan registers `.mmd`/`.mermaid` as a new language (`'mermaid'`), adds a `MermaidViewer` component that wraps `MermaidBlock` for full-file rendering with scroll caching and centering, and wires it into the existing `EditorPanel` / `EditorContent` routing alongside the markdown path. The view mode store (`markdownViewMode`) is reused since it is already keyed by file ID.
**Tech Stack:** React, Zustand (existing store), mermaid (already installed v11.14), DOMPurify (already installed), Monaco (source mode), existing `MermaidBlock` component.
---
## File Map
| Action | Path | Responsibility |
|--------|------|---------------|
| Modify | `src/renderer/src/lib/language-detect.ts` | Register `.mmd` and `.mermaid` extensions |
| Create | `src/renderer/src/components/editor/MermaidViewer.tsx` | Full-file mermaid diagram viewer with scroll caching |
| Modify | `src/renderer/src/components/editor/EditorPanel.tsx` | Add `isMermaid` flag, show view toggle for mermaid files |
| Modify | `src/renderer/src/components/editor/EditorContent.tsx` | Add `isMermaid` prop, route to `MermaidViewer` or Monaco |
| Modify | `src/renderer/src/assets/markdown-preview.css` | Add `.mermaid-viewer` styles |
---
### Task 1: Register `.mmd` and `.mermaid` file extensions
**Files:**
- Modify: `src/renderer/src/lib/language-detect.ts:10-76` (add two entries to `EXT_TO_LANGUAGE`)
- [ ] **Step 1: Add mermaid extensions to the language map**
In `src/renderer/src/lib/language-detect.ts`, add these two entries to the `EXT_TO_LANGUAGE` object (between `.mdx` and `.css`):
```typescript
'.mmd': 'mermaid',
'.mermaid': 'mermaid',
```
- [ ] **Step 2: Verify the change compiles**
Run: `cd src/renderer && npx tsc --noEmit --pretty 2>&1 | head -20`
Expected: No errors related to `language-detect.ts`.
- [ ] **Step 3: Commit**
```bash
git add src/renderer/src/lib/language-detect.ts
git commit -m "feat: register .mmd and .mermaid file extensions"
```
---
### Task 2: Create `MermaidViewer` component
**Files:**
- Create: `src/renderer/src/components/editor/MermaidViewer.tsx`
This component renders the entire file content as a mermaid diagram. It reuses the existing `MermaidBlock` for rendering and follows the same dark-mode detection and scroll-caching patterns as `MarkdownPreview`.
- [ ] **Step 1: Create the `MermaidViewer` component**
Create `src/renderer/src/components/editor/MermaidViewer.tsx`:
```tsx
import React, { useLayoutEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
import MermaidBlock from './MermaidBlock'
type MermaidViewerProps = {
content: string
filePath: string
}
// Why: MermaidViewer is the full-file counterpart to MermaidBlock (which
// renders fenced mermaid blocks inside markdown). When a user opens a .mmd
// or .mermaid file in diagram mode, the entire file content is the diagram
// source — no markdown wrapper, no frontmatter, just mermaid syntax.
export default function MermaidViewer({
content,
filePath
}: MermaidViewerProps): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const settings = useAppStore((s) => s.settings)
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Why: Each viewing mode (source vs diagram) produces different DOM heights.
// Mode-scoped keys prevent restoring a source-mode scroll position in diagram
// mode (same reasoning as MarkdownPreview's scrollCacheKey).
const scrollCacheKey = `${filePath}:mermaid-diagram`
useLayoutEffect(() => {
const container = rootRef.current
if (!container) {
return
}
let throttleTimer: ReturnType<typeof setTimeout> | null = null
const onScroll = (): void => {
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
throttleTimer = setTimeout(() => {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
throttleTimer = null
}, 150)
}
container.addEventListener('scroll', onScroll, { passive: true })
return () => {
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
if (throttleTimer !== null) {
clearTimeout(throttleTimer)
}
container.removeEventListener('scroll', onScroll)
}
}, [scrollCacheKey])
useLayoutEffect(() => {
const container = rootRef.current
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
if (!container || targetScrollTop === undefined) {
return
}
let frameId = 0
let attempts = 0
// Why: mermaid.render() is async, so the SVG may not exist on the first
// frame. Retry up to 30 frames (~500ms) to match MarkdownPreview's pattern.
const tryRestore = (): void => {
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
const nextScrollTop = Math.min(targetScrollTop, maxScrollTop)
container.scrollTop = nextScrollTop
if (Math.abs(container.scrollTop - targetScrollTop) <= 1 || maxScrollTop >= targetScrollTop) {
return
}
attempts += 1
if (attempts < 30) {
frameId = window.requestAnimationFrame(tryRestore)
}
}
tryRestore()
return () => window.cancelAnimationFrame(frameId)
}, [scrollCacheKey, content])
return (
<div
ref={rootRef}
className="mermaid-viewer h-full min-h-0 overflow-auto scrollbar-editor"
>
<div className="mermaid-viewer-canvas">
<MermaidBlock content={content.trim()} isDark={isDark} />
</div>
</div>
)
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd src/renderer && npx tsc --noEmit --pretty 2>&1 | head -20`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add src/renderer/src/components/editor/MermaidViewer.tsx
git commit -m "feat: add MermaidViewer component for .mmd file rendering"
```
---
### Task 3: Add `.mermaid-viewer` styles
**Files:**
- Modify: `src/renderer/src/assets/markdown-preview.css` (append after existing `.mermaid-error` block, around line 242)
- [ ] **Step 1: Add viewer styles**
Append after the `.mermaid-error` rule block (line ~242) in `markdown-preview.css`:
```css
/* Full-file mermaid diagram viewer — used when opening .mmd/.mermaid files
in diagram mode. Centers the rendered SVG and adds padding so diagrams
don't press against viewport edges. */
.mermaid-viewer {
background: var(--color-background, #fff);
}
.mermaid-viewer-canvas {
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100%;
padding: 32px 24px;
}
.mermaid-viewer-canvas .mermaid-block {
max-width: 100%;
}
.mermaid-viewer-canvas .mermaid-block svg {
max-width: 100%;
height: auto;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/renderer/src/assets/markdown-preview.css
git commit -m "feat: add mermaid-viewer CSS for full-file diagram display"
```
---
### Task 4: Wire mermaid files into `EditorPanel` and `EditorContent`
**Files:**
- Modify: `src/renderer/src/components/editor/EditorPanel.tsx:427-519`
- Modify: `src/renderer/src/components/editor/EditorContent.tsx:1-67,262-270`
This task threads the `isMermaid` flag through the same path as `isMarkdown`, reusing the existing `markdownViewMode` store (keyed by file ID, so no collision) and `MarkdownViewToggle` (icon-only, works for both).
- [ ] **Step 1: Update `EditorPanel.tsx`**
**Change 1** — add `isMermaid` flag and widen the view-mode condition (around line 427):
Replace:
```typescript
const isMarkdown = resolvedLanguage === 'markdown'
const mdViewMode: MarkdownViewMode =
isMarkdown && activeFile.mode === 'edit'
? (markdownViewMode[activeFile.id] ?? 'rich')
: 'source'
```
With:
```typescript
const isMarkdown = resolvedLanguage === 'markdown'
const isMermaid = resolvedLanguage === 'mermaid'
// Why: mermaid files reuse the same per-file view mode store as markdown.
// Both default to 'rich' (rendered view) and fall back to 'source' (Monaco).
const hasViewModeToggle = (isMarkdown || isMermaid) && activeFile.mode === 'edit'
const mdViewMode: MarkdownViewMode = hasViewModeToggle
? (markdownViewMode[activeFile.id] ?? 'rich')
: 'source'
```
**Change 2** — update the toggle condition in the header (around line 515):
Replace:
```tsx
{isMarkdown && activeFile.mode === 'edit' && (
<MarkdownViewToggle
```
With:
```tsx
{hasViewModeToggle && (
<MarkdownViewToggle
```
**Change 3** — pass `isMermaid` to `EditorContent` (around line 531):
Add `isMermaid={isMermaid}` after the `isMarkdown` prop:
```tsx
isMarkdown={isMarkdown}
isMermaid={isMermaid}
```
- [ ] **Step 2: Update `EditorContent.tsx`**
**Change 1** — add lazy import for `MermaidViewer` (after the `ImageDiffViewer` lazy import, around line 18):
```typescript
const MermaidViewer = lazy(() => import('./MermaidViewer'))
```
**Change 2** — add `isMermaid` to the component props type and destructuring (around line 34-67):
Add `isMermaid: boolean` to the props type (after `isMarkdown: boolean`), and add `isMermaid` to the destructuring.
**Change 3** — add mermaid routing in the edit-mode branch (around line 266):
Replace:
```tsx
{isMarkdown ? renderMarkdownContent(fc) : renderMonacoEditor(fc)}
```
With:
```tsx
{isMarkdown
? renderMarkdownContent(fc)
: isMermaid && mdViewMode === 'rich'
? <MermaidViewer
key={activeFile.id}
content={editBuffers[activeFile.id] ?? fc.content}
filePath={activeFile.filePath}
/>
: renderMonacoEditor(fc)}
```
- [ ] **Step 3: Verify everything compiles**
Run: `cd src/renderer && npx tsc --noEmit --pretty 2>&1 | head -20`
Expected: No errors.
- [ ] **Step 4: Commit**
```bash
git add src/renderer/src/components/editor/EditorPanel.tsx src/renderer/src/components/editor/EditorContent.tsx
git commit -m "feat: wire mermaid file viewer into editor panel routing"
```
---
### Task 5: Manual verification
- [ ] **Step 1: Create a test `.mmd` file in any worktree**
Place a file like `test-diagram.mmd` in a worktree directory with content:
```
graph TD
A[Open .mmd file] --> B{Rendered?}
B -->|Yes| C[Diagram view]
B -->|No| D[Source view]
C --> E[Toggle to source]
D --> F[Toggle to diagram]
```
- [ ] **Step 2: Open the file in Orca and verify**
Check:
1. File opens in diagram mode by default (rendered SVG, centered)
2. Source/diagram toggle appears in the editor header
3. Clicking the Code icon switches to Monaco with mermaid syntax
4. Clicking the Eye icon switches back to the rendered diagram
5. Dark mode: diagram respects the current theme
6. Large diagrams scroll properly
7. Scroll position is preserved when switching tabs and back
- [ ] **Step 3: Verify no regressions**
Check:
1. Opening a `.md` file still works with source/rich toggle
2. Mermaid fenced blocks inside `.md` files still render
3. Non-markdown, non-mermaid files open normally in Monaco
- [ ] **Step 4: Clean up the test file and commit everything**
```bash
rm test-diagram.mmd
```

View File

@ -1,51 +0,0 @@
# Terminal Search Next/Previous Shortcuts
## Summary
Add `Cmd+G` (find next) and `Cmd+Shift+G` (find previous) keyboard shortcuts to navigate terminal search matches. These follow macOS native conventions and only activate when the search bar is already open.
## Requirements
- `Cmd+G` calls `findNext` on the active pane's `SearchAddon`
- `Cmd+Shift+G` calls `findPrevious` on the active pane's `SearchAddon`
- Shortcuts are no-ops when the search bar is closed
- After navigating, focus moves to the terminal (not the search input)
- Shortcuts are not documented in ShortcutsPane (consistent with `Cmd+F`)
## Architecture
### Data bridge: `searchStateRef`
The search query and options (`caseSensitive`, `regex`) live as local state in `TerminalSearch.tsx`. The keyboard handler in `keyboard-handlers.ts` needs read access to call `searchAddon.findNext(query, opts)`.
A `MutableRefObject<{ query: string; caseSensitive: boolean; regex: boolean }>` is created in `TerminalPane` and passed to both components. `TerminalSearch` writes to it on state changes; the keyboard handler reads from it on `Cmd+G` / `Cmd+Shift+G`. This follows the existing ref-bridge pattern used throughout `TerminalPane` (e.g., `paneTitlesRef`, `isActiveRef`, `settingsRef`).
### Changes by file
**`TerminalPane.tsx`**
- Create `searchStateRef = useRef({ query: '', caseSensitive: false, regex: false })`
- Pass `searchStateRef` to `TerminalSearch` as a new prop
- Pass `searchOpen` and `searchStateRef` to `useTerminalKeyboardShortcuts`
**`TerminalSearch.tsx`**
- Accept `searchStateRef: React.MutableRefObject<{ query: string; caseSensitive: boolean; regex: boolean }>` prop
- Sync the ref whenever `query`, `caseSensitive`, or `regex` changes — inside the existing `useEffect` that already depends on `[query, caseSensitive, regex]`, so all three values are kept in sync together. Note: the existing effect has an early return when query is empty (`clearDecorations`), so the ref won't update on clear — this is benign because the keyboard handler already guards on non-empty query
**`keyboard-handlers.ts`**
- Add `searchOpen` (boolean) and `searchStateRef` to `KeyboardHandlersDeps`
- Exempt `[data-terminal-search-root]` descendants from the `isEditableTarget` early return for `Cmd+G` / `Cmd+Shift+G`. Without this, pressing the shortcut while the search input has focus would be silently swallowed. The paste handler in `TerminalPane` already uses this same `data-terminal-search-root` exemption pattern.
- Add handler for `Cmd+G` / `Cmd+Shift+G` inside `onKeyDown`, placed before the `isEditableTarget` guard. Because this runs before the `const mod = ...` declaration, the handler must perform its own mod-key check (`isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey`) and key match (`e.key.toLowerCase() === 'g'`) inline:
- Guard: mod key active, key is `g`, `searchOpen` is true, `searchStateRef.current.query` is non-empty
- Read `query`, `caseSensitive`, `regex` from `searchStateRef.current`
- Get active pane's `searchAddon` via `manager.getActivePane().searchAddon`
- Call `findNext` or `findPrevious` with `{ caseSensitive, regex }` (no `incremental` — matches the chevron button behavior, not the live-typing behavior)
- Call `pane.terminal.focus()` to return focus to the terminal
- `preventDefault` + `stopPropagation` — important to suppress macOS/Electron's native "find next" which could otherwise trigger the built-in find bar
### Edge cases
- **No query**: no-op (empty string guard)
- **No active pane**: no-op (existing pane guard)
- **Search closed**: no-op (`searchOpen` guard)
- **Key repeat**: filtered by existing `if (e.repeat) return` at top of `onKeyDown`
- **Focus in search input**: `Cmd+G` / `Cmd+Shift+G` must bypass the `isEditableTarget` guard for search input descendants (see keyboard-handlers.ts changes above)

View File

@ -1,216 +0,0 @@
# Tab Group Model Follow-Up
## Goal
Move Orca's split tab-group implementation from a store-plus-controller shape to a true model/service boundary that is closer to VS Code's editor-group architecture, without porting VS Code wholesale.
This follow-up is intentionally **not** part of the current PR. The current PR already moved the feature in the right direction by:
- making split ratios persisted model state
- centralizing move/copy/merge group operations in the tabs store
- thinning `TabGroupPanel` into more of a view
The next step is to make tab-group behavior a first-class model instead of a set of store records plus helper/controller logic.
## Why
Split tab groups are no longer just a rendering concern. They now carry:
- layout structure
- persisted split ratios
- active group state
- cross-group move/copy semantics
- close/merge behavior
- mixed content types per group
As this grows, keeping behavior split across Zustand records, controller hooks, and React components will become harder to reason about and easier to regress.
VS Code handles this by making editor groups a first-class model/service. Orca does not need the full VS Code abstraction surface, but it should adopt the same direction:
- model owns behavior
- views render model state
- imperative operations go through one boundary
## Current Gaps
Even after the current PR, these gaps remain:
1. Group activation is still minimal.
Orca tracks `activeGroupIdByWorktree`, but not true MRU group ordering or activation reasons.
2. Group operations are still store-action centric, not model-object centric.
The store now owns the mutations, but callers still think in terms of raw IDs and records.
3. There are no group lifecycle events.
React consumers read state snapshots, but there is no explicit event surface for add/remove/move/merge/activate.
4. Hydration and runtime behavior are still tightly coupled to raw store shape.
This makes it harder to evolve the model without touching many callers.
5. `TabGroupPanel` is thinner, but still knows too much about worktree/group coordination.
## Target Shape
Introduce a per-worktree tab-group model/controller layer, for example:
- `TabGroupWorkspaceModel`
- `TabGroupModel`
- `TabGroupLayoutModel`
This layer should:
- wrap the normalized store state for a single worktree
- expose typed operations instead of raw state surgery
- centralize MRU group activation
- centralize group lifecycle transitions
- provide derived read models for rendering
React components should consume:
- derived selectors for render state
- a small command surface for mutations
They should not need to understand layout tree mutation details.
## Proposed Responsibilities
### `TabGroupWorkspaceModel`
Owns all tab-group state for one worktree:
- groups
- layout tree
- active group
- MRU group order
Exposes commands like:
- `splitGroup(groupId, direction)`
- `closeGroup(groupId)`
- `mergeGroup(groupId, targetGroupId?)`
- `activateGroup(groupId, reason)`
- `moveTab(tabId, targetGroupId, options?)`
- `copyTab(tabId, targetGroupId, options?)`
- `reorderGroupTabs(groupId, orderedTabIds)`
- `resizeSplit(nodePath, ratio)`
### `TabGroupModel`
Represents one group and exposes:
- `id`
- `tabs`
- `activeTab`
- `tabOrder`
- `isActive`
- `isEmpty`
This can be a thin wrapper over store state rather than a heavy OO abstraction.
### `TabGroupLayoutModel`
Encapsulates layout operations:
- replace leaf with split
- remove leaf and collapse tree
- find sibling group
- update split ratio
- validate layout against live groups
This logic is currently spread across `tabs.ts` helpers and should move into one focused module.
## Migration Plan
### Phase 1: Extract Pure Model Utilities
Create a new module for pure tab-group model operations:
- layout mutation
- group merge/collapse rules
- MRU group bookkeeping
- validation helpers
This phase should not change runtime behavior.
### Phase 2: Add Workspace Model Facade
Introduce a facade over the Zustand store for one worktree:
- input: `worktreeId`
- output: commands + derived state
This can begin as a hook-backed facade, but the logic should live outside React as much as possible.
### Phase 3: Move Components To Render-Only
Reduce `TabGroupPanel` and `TabGroupSplitLayout` to:
- render derived state
- dispatch commands
They should no longer assemble group/tab mutation behavior themselves.
### Phase 4: Add MRU Group Semantics
Track:
- active group
- most recently active group order
Use this for:
- close-group merge target selection
- focus restoration after group removal
- more VS Code-like group activation behavior
### Phase 5: Hydration Boundary Cleanup
Move hydration/restore validation through the model layer so layout and groups are repaired in one place.
## Non-Goals
- Porting VS Code's editor-group implementation directly
- Replacing Zustand
- Introducing a large class hierarchy for its own sake
- Refactoring terminal pane internals as part of the same follow-up
## Risks
1. Terminal/editor/browser tabs currently share the unified tab model.
Refactors must preserve mixed-content behavior across groups.
2. Hydration and worktree switching depend on current store shape.
The migration should preserve persisted session compatibility.
3. Closing and merging groups can easily regress active-tab restoration.
MRU rules need explicit tests.
## Test Plan For Follow-Up
Add focused tests around:
- split + resize + restore
- close empty group
- close non-empty group merges into MRU/sibling target
- move tab between groups
- copy tab between groups
- active group restoration after merge
- hydration repairing invalid layout/group combinations
- worktree switch preserving active group and active tab
## Suggested PR Breakdown
1. `refactor: extract tab group layout model helpers`
2. `refactor: add worktree tab group model facade`
3. `refactor: move tab group components to render-only`
4. `feat: add MRU group activation model`
5. `refactor: route hydration through tab group model`
## Recommendation
Do this as a dedicated follow-up PR sequence, not as an extension of the current PR.
The current PR is already the right stopping point:
- enough model centralization to stabilize the feature
- not so much architectural churn that review and regression risk explode

View File

@ -1,95 +0,0 @@
# Terminal Shortcut Audit And Fix Plan
## Context
Linked reports:
- `#443`: `Ctrl+R` / `Cmd+R` reverse search was blocked in the terminal.
- `#453`: fixed `#443` by removing the app-level reload accelerator conflict.
- `#481`: reports `Ctrl+U` (`unix-line-discard`) being swallowed.
- `#482`: reports `Ctrl+E` (`end-of-line`) being swallowed, but is marked not reproducible.
The symptom across all four links is "terminal control chord does not reach readline", but the current code shows they are not all the same bug.
## Findings
1. `#443` and `#453` are directly related.
`CmdOrCtrl+R` was reserved above the renderer, so the terminal never saw the chord. `#453` fixed that by removing the reload accelerator and keeping only `Shift+CmdOrCtrl+R` for force reload.
2. `#481` and `#482` are related to the same problem class, but not proven to share `#443`'s exact root cause.
Current `mainWindow.webContents.on('before-input-event', ...)` no longer reserves `R`, `U`, or `E`.
Current terminal renderer shortcut handling also does not reserve macOS `Ctrl+R`, `Ctrl+U`, or `Ctrl+E`.
3. The real gap was auditability, not just one missing exception.
Shortcut interception lived in multiple places:
- main window `before-input-event`
- browser guest `before-input-event`
- terminal renderer `keydown` capture
That made it easy to fix one conflict (`Cmd/Ctrl+R`) while leaving the overall reservation surface implicit and hard to verify.
## Reproduction Matrix
Expected behavior from a focused terminal on macOS:
- Pass through to shell/readline:
- `Ctrl+R`
- `Ctrl+U`
- `Ctrl+E`
- `Ctrl+A`
- `Ctrl+W`
- `Ctrl+K`
- `Alt+B`
- `Alt+F`
- `Alt+D`
- Reserved by Orca:
- `Cmd+F`
- `Cmd+K`
- `Cmd+W`
- `Cmd+D`
- `Cmd+Shift+D`
- `Cmd+[`
- `Cmd+]`
- `Cmd+Shift+Enter`
- `Ctrl+Backspace`
- `Cmd+Backspace`
- `Cmd+Delete`
- `Alt+Backspace`
Expected behavior from main-process/browser-guest forwarding:
- Reserved:
- zoom shortcuts
- worktree palette
- quick open
- worktree index jump
- Must never be reserved there:
- `Cmd/Ctrl+R`
- readline control chords like `Ctrl+U`, `Ctrl+E`, `Ctrl+R`
## Plan
1. Centralize the window-level shortcut allowlist into a shared pure helper.
Why: main-window and browser-guest forwarding should not drift apart, because either one can steal terminal input before the renderer sees it.
2. Centralize terminal-pane shortcut classification into a pure helper.
Why: the terminal shortcut layer must stay an explicit allowlist so future shortcuts do not accidentally swallow readline chords.
3. Add regression tests that enumerate both sides:
- allowed Orca shortcuts
- guaranteed shell passthrough chords
4. Keep `#443` fixed and make the current status of `#481` / `#482` testable.
Why: even if one report later turns out to be environment-specific, Orca should still have an executable contract for what it reserves.
## Implementation Notes
- Shared helper added for main-process shortcut resolution.
- Shared helper added for terminal-pane shortcut resolution.
- Tests added to encode the allowlist and passthrough matrix explicitly.
## Follow-Up Risk
This change makes the current reservation surface auditable, but it does not redesign non-macOS terminal shortcuts. Orca still uses `Ctrl` as the primary modifier for several terminal actions on Linux/Windows, which is a separate UX question from the macOS control-chord regressions linked above.

View File

@ -1,452 +0,0 @@
# Terminal drag-and-drop over SSH
## Problem
Dragging a local file onto a terminal pane inserts the file's absolute path
into the PTY, so the user can reference it in a CLI or TUI-agent prompt. On
SSH worktrees the terminal runs remotely, so injecting a **local** path
(`/Users/alice/Desktop/log.txt`) is useless — the remote agent has no access
to it.
The file-explorer drop path was fixed in PR #1279 by routing through SFTP
upload (`importExternalPathsSsh`). The terminal drop path was not touched
and still breaks for SSH worktrees.
Reported: https://stablygroup.slack.com/archives/C0ASMDT6LQZ/p1777530155421009
## Goals
- Dropping a local file onto a terminal connected to an SSH worktree makes
that file available to the remote shell/agent, and injects a path the
remote process can read.
- Local terminal drops keep their current behavior: reference-in-place, no
copy, no authorization, no repo pollution.
- Local and SSH paths share one main-side resolver. The renderer may pass
connection context and show progress UI, but copy/upload/deconfliction
policy stays out of the renderer so the two modes do not drift over time.
## Non-goals
- Unifying terminal drop with file-explorer drop. They have different
semantics (explorer always copies into a user-picked `destDir`; terminal
references a path). They share the SSH upload primitive internally but
remain separate IPCs.
- Cleanup / garbage collection of staged remote files. Tracked as follow-up
(see "Follow-ups" below — file **before merging** and replace this
parenthetical with the issue number so the reference is locatable). Until
GC lands, files uploaded by a drop whose pane is unmounted before the
upload resolves are orphaned in `.orca/drops/` with no injected path.
Users should know uploads are not cancellable.
- Abortable uploads. SFTP transfers run to completion even if the terminal
pane is unmounted mid-flight.
## Considered: Option A (full unification)
Terminal drop calls `fs:importExternalPaths` with
`destDir = worktreePath`, then injects `result.destPath` into the PTY.
Shares exactly the file-explorer code path.
Rejected because it changes **local** terminal-drop UX: today dropping
`~/Desktop/log.txt` into the terminal pastes `/Users/…/Desktop/log.txt`
so the agent reads the file in place; under Option A the file would be
copied into the repo. Users rely on the reference-in-place behavior to
point agents at files without polluting the worktree.
## Design (Option B)
### New IPC
```
fs:resolveDroppedPathsForAgent({
paths: string[],
worktreePath: string,
connectionId?: string,
}) → {
resolvedPaths: string[],
skipped: { sourcePath: string; reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' }[],
failed: { sourcePath: string; reason: string }[],
}
```
Contract:
- **Local (`connectionId == null`):** returns
`{ resolvedPaths: paths, skipped: [], failed: [] }` unchanged. No copy. No
authorization (matches today's behavior — the agent's own read is what gets
authorized, not the drop). Use `args.connectionId == null` (not `!args.connectionId`)
so an empty string cannot silently pick the local branch.
- **SSH (`connectionId` is a non-empty string):** uploads each path via SFTP
into a staging dir under the worktree. Returns remote absolute paths for
items that uploaded successfully, items rejected by policy (symlinks,
missing sources, permission-denied, unsupported file types) in `skipped`,
and hard upload errors in `failed`. The split mirrors `ImportItemResult`'s
existing `'imported' | 'skipped' | 'failed'` and lets the renderer toast
"Skipped N symlinks" distinctly from "Failed to upload N files." Collapsing
skipped into failed would mislabel routine policy rejections as errors.
### SSH staging dir
`${worktreePath}/.orca/drops/` on the remote. `.orca/` is reserved as an
Orca-owned directory for future remote state (GC metadata, cached remote
capability probes, etc.); this is its first use. Future features adding
subpaths under `.orca/` should namespace themselves (`.orca/drops/`,
`.orca/<feature>/`) rather than placing files at the root.
Rationale:
- No need to resolve remote `$HOME` (which would require a round-trip and
caching layer).
- Lives inside the worktree, so cleaned up naturally when the worktree is
deleted.
- The agent has read access by construction (it runs with the worktree's
cwd).
The main process must bootstrap `.orca/.gitignore` with `*\n!.gitignore\n`
before the first successful upload. Otherwise every SSH terminal drop dirties
source control with an untracked `.orca/` directory, which recreates the
repo-pollution problem that ruled out Option A for local terminal drops. The
`!.gitignore` negation keeps the marker file itself trackable if we ever want
to (and costs nothing today — `git status` stays clean either way because
nothing tries to add it).
The staging directory must be created recursively over SFTP before upload:
- create `${worktreePath}/.orca` (ignore "already exists")
- write `${worktreePath}/.orca/.gitignore` as `*\n!.gitignore\n` **only if it
does not already exist** — never overwrite. A user may have added patterns
there, and silently clobbering user-authored content violates least
surprise even inside an Orca-owned directory. Use `sftpPathExists` before
writing. (Two concurrent first-drops racing through the `sftpPathExists`
check will both write the same bytes — last writer wins, idempotent, so
the race is benign and not worth locking.)
- create `${worktreePath}/.orca/drops` (ignore "already exists")
Do not rely on `uploadFile`, `uploadDirectory`, or the existing
`mkdirSftp(destPath)` calls to create missing parents. `uploadFile` writes
directly to the final remote file path, and `mkdirSftp` is not recursive, so
the first terminal drop into a fresh SSH worktree would fail if the parents do
not already exist.
### Main-side implementation
`src/main/ipc/filesystem-mutations.ts`:
```ts
ipcMain.handle('fs:resolveDroppedPathsForAgent', async (_e, args) => {
// Why: `== null` (not `!args.connectionId`) so an empty string is treated
// as an error from the renderer, not silently routed to the local branch.
if (args.connectionId == null) {
return { resolvedPaths: args.paths, skipped: [], failed: [] }
}
const worktreePath = args.worktreePath.replace(/\/+$/, '')
const destDir = `${worktreePath}/.orca/drops`
const { results } = await importExternalPathsSsh(
args.paths,
destDir,
args.connectionId,
{ ensureDir: true },
)
const resolvedPaths: string[] = []
const skipped: { sourcePath: string; reason: ImportSkipReason }[] = []
const failed: { sourcePath: string; reason: string }[] = []
// Iterate in input order so injected paths align with the user's drop order.
for (const r of results) {
if (r.status === 'imported') {
resolvedPaths.push(r.destPath)
} else if (r.status === 'skipped') {
skipped.push({ sourcePath: r.sourcePath, reason: r.reason })
} else {
failed.push({ sourcePath: r.sourcePath, reason: r.reason })
}
}
return { resolvedPaths, skipped, failed }
})
```
Reuses `importExternalPathsSsh` — SFTP upload, symlink pre-scan, name
deconfliction, per-item error reporting are all already there.
**Staging bootstrap lives inside `importExternalPathsSsh`** behind a new
optional `{ ensureDir?: boolean }` parameter. When set, before the first
upload the function creates `${destDir}`'s parent chain (`.orca`, then
`drops`) and writes `.orca/.gitignore` (`*\n`) only if missing, all on the
**same SFTP session** already opened for the upload. Do not add a separate
`ensureSshDropStagingDir` helper that opens its own channel — that would
double the SFTP handshake cost on every drop.
### Renderer-side implementation
**API change to `shellEscapePath`.** Today the second arg is a *userAgent
string* (substring-matched for `"Windows"`), which couples escape rules to
the client OS. For SSH drops we need to escape for the *target shell*,
which is always POSIX on the remote regardless of client OS. Change the
signature to take an explicit target:
```ts
shellEscapePath(path: string, targetShell: 'posix' | 'windows')
```
Callers derive `targetShell` from context: local drops pass
`isWindowsUserAgent() ? 'windows' : 'posix'`; SSH drops always pass
`'posix'`. This makes test #11 (Windows client → Linux SSH worktree)
correct by construction instead of by coincidence.
**Migration — all three call sites + tests must change together:**
- `src/renderer/src/components/terminal-pane/pane-helpers.ts:53` — update
signature; drop the `userAgent` default.
- `src/renderer/src/components/terminal-pane/TerminalPane.tsx:937`
(file-explorer → terminal drop). This is a **local-only** code path
(explorer drag uses a DOM MIME type that the preload SSH bridge does not
forward), so pass `isWindowsUserAgent() ? 'windows' : 'posix'` to preserve
today's behavior exactly.
- `src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts:344`
— replaced by the new SSH-aware handler below; the `shellEscapePath` call
moves inside it with an explicit `targetShell`.
- `src/renderer/src/components/terminal-pane/pane-helpers.test.ts` — the
existing cases pass `'Macintosh'`, `'Linux'`, `'Windows'` as the userAgent
arg. Rewrite to pass `'posix'` (for Mac/Linux) and `'windows'`, so tests
exercise the new contract rather than the legacy substring match.
No local behavior changes if this migration is done in one commit: Mac/Linux
already took the POSIX branch via the userAgent string match; Windows
already took the Windows branch. The new signature just names that
explicitly.
`src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts`:
```ts
return window.api.ui.onFileDrop(async (data) => {
if (data.target !== 'terminal') return
if (data.paths.length === 0) return
const manager = managerRef.current
if (!manager) return
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (!pane) return
const paneId = pane.id
const transport = paneTransportsRef.current.get(paneId)
if (!transport) return
const wtId = worktreeIdRef.current
const worktreePath = worktreePathRef.current
if (!wtId || !worktreePath) return
// Why: getConnectionId (selector on the terminals/repos slice:
// `state.repos.find(r => r.id === <worktree's repoId>)?.connectionId`,
// exposed via the store) returns `string` (SSH), `null` (local repo
// found), or `undefined` (store not hydrated / worktree not found).
// Treat `undefined` as an error, not as "local" — otherwise a drop
// during hydration would silently paste local paths into a remote
// shell.
const connectionId = getConnectionId(wtId)
if (connectionId === undefined) {
toast.error('Worktree not ready — try again in a moment.')
return
}
const isRemote = connectionId !== null
const targetShell: 'posix' | 'windows' = isRemote
? 'posix'
: isWindowsUserAgent()
? 'windows'
: 'posix'
// Local fast path: no IPC round-trip, no toast. Preserves today's
// zero-latency behavior exactly — same code shape as before, only the
// shellEscapePath signature is new (and resolves to the same branch).
if (!isRemote) {
for (const p of data.paths) {
transport.sendInput(`${shellEscapePath(p, targetShell)} `)
}
pane.terminal.focus()
return
}
const pending = toast.loading(
`Uploading ${data.paths.length} file(s) to remote…`,
)
try {
const { resolvedPaths, skipped, failed } =
await window.api.fs.resolveDroppedPathsForAgent({
paths: data.paths,
worktreePath,
connectionId,
})
// Why: pane may have unmounted during the SFTP upload (tab closed,
// worktree switched). Re-check the transport map before writing so
// we don't call sendInput on a torn-down PTY. Orphaned uploads are
// acknowledged in Non-goals.
const liveTransport = paneTransportsRef.current.get(paneId)
if (liveTransport) {
// resolvedPaths preserves input order (main-side iterates results in
// order); injected paths line up with the user's drop gesture.
for (const p of resolvedPaths) {
liveTransport.sendInput(`${shellEscapePath(p, targetShell)} `)
}
pane.terminal.focus()
}
if (skipped.length > 0) {
const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length
const noun = skipped.length === 1 ? 'item' : 'items'
toast.message(
symlinkCount === skipped.length
? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.`
: `Skipped ${skipped.length} ${noun}.`,
)
}
if (failed.length > 0) {
const noun = failed.length === 1 ? 'file' : 'files'
toast.error(`Failed to upload ${failed.length} ${noun}.`)
}
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'))
} finally {
toast.dismiss(pending)
}
})
```
`extractIpcErrorMessage` is the existing helper at
`src/renderer/src/lib/ipc-error.ts:6` (already used by
`useFileExplorerImport.ts`, `Terminal.tsx`, etc.). Reuse it — do not copy
the body locally.
New dependencies on the hook: `worktreeId` and `worktreePath` refs. Use the
`TerminalPane`'s own `worktreeId` prop, not global `activeWorktreeId`. The
drop listener is already gated by `isActive`, and the pane's own
`worktreeId` is the authoritative identity of the terminal being written
to; reading from global state would race during worktree switches. Promote
this reasoning into a `// Why:` comment at the call site per CLAUDE.md.
Derive `worktreePath` the same way `use-terminal-pane-lifecycle.ts` does
today: find the worktree by `worktreeId` in the store, then fall back to
`cwd`.
### Why not branch in the renderer
- Keeps upload semantics in one place. Adding a second terminal consumer
(e.g. a standalone TUI pane) should call the same resolver instead of
deciding where to copy, how to deconflict names, or how to report per-item
failures.
- The renderer can know that the worktree is SSH for progress UI and shell
escaping, but it should not know SFTP details or construct uploaded
destination filenames itself.
### UX details
- **SSH "Uploading…" toast:** SFTP of a few MB can take seconds. Without
feedback the user thinks the drop failed. Dismiss on success, replace
with an error toast on failure.
- **Don't inject until upload resolves.** Injecting the remote path before
the file lands means the agent may try to read a file that doesn't yet
exist and error. Worth the extra perceived latency.
- **Failure policy:** partial failures still inject the succeeded paths
and toast the count of failures (same pattern as the explorer).
- **Escape for the terminal that receives the path.** Local drops keep the
existing platform-specific quoting. SSH drops must use POSIX shell quoting
for the returned remote paths; do not let a Windows client choose Windows
quoting for a Linux/macOS SSH shell.
## System fit
```
[Electron preload native drop]
|
v
[terminal:file-drop IPC relay]
|
v
[active TerminalPane drop handler]
|
v
[fs:resolveDroppedPathsForAgent]
| local | SSH
v v
[return original paths] [SFTP stage into ${worktreePath}/.orca/drops]
| |
| v
| [return remote readable paths]
| |
+---------------+-----------------+
|
v
[PTY sendInput escaped paths]
```
## Testing
1. Local worktree, drop a single file onto the terminal → original
absolute path pasted. No copy. No change from today.
2. Local worktree, drop multiple files → each path pasted separated by a
space. No change from today.
3. SSH worktree, drop a single file → file uploads to
`${worktreePath}/.orca/drops/<file>` on remote; remote path pasted
into PTY; agent can read it.
4. SSH worktree, drop a folder → folder uploads recursively; remote dir
path pasted.
5. SSH worktree, drop a symlink → no injection; toast reads
"Skipped 1 symlink." (not "Failed to upload") because symlink rejection
is policy, not error.
6. SSH worktree, drop 5 files where items 2 and 4 are permission-denied at
the local source → paths 1, 3, 5 are injected **in that order**
(matching input order), toast says "Skipped 2 items." (permission-denied
is classified `skipped`, not `failed`, by `importExternalPathsSsh`).
Additionally, simulate an SFTP write error mid-upload to cover the
`failed` branch → "Failed to upload N files" toast.
7. SSH worktree disconnected mid-drag → user-visible error toast, no
partial injection.
8. Name collision: drop the same file twice in quick succession → second
upload lands as `<name> copy.<ext>` (deconfliction inherited from
`importExternalPathsSsh`).
9. Fresh SSH worktree with no `.orca` directory → first drop creates
`.orca/`, `.orca/.gitignore`, and `.orca/drops/`; upload succeeds.
10. After an SSH drop, `git status --short` in the remote worktree does not
show `.orca/`.
11. Windows client dropping `my file's $draft.txt` into a Linux SSH worktree
pastes a POSIX-escaped remote path, not Windows double-quoted syntax.
Expected output for remote path `/home/u/wt/.orca/drops/my file's $draft.txt`:
`'/home/u/wt/.orca/drops/my file'\''s $draft.txt'` (literal `$draft`
inside single quotes — no expansion, no backslash-escaping).
16. Local terminal drop on macOS / Linux / Windows clients behaves
byte-identically to pre-change: same injected string, no toast, no IPC
call. Run the existing `pane-helpers.test.ts` expectations through the
new `'posix' | 'windows'` API and confirm outputs match the legacy
userAgent-based outputs.
17. File-explorer → terminal drop (`TerminalPane.tsx` onDrop) continues to
work on local and SSH worktrees exactly as today — this path is not
touched by the new IPC and must still use the explorer's own handling.
12. Empty drop (`data.paths.length === 0`) → no IPC call, no toast, no
injection. (Possible on some OSes when a drag contains only non-file
items.)
13. Store unhydrated (`getConnectionId` returns `undefined`) → user-visible
"Worktree not ready" toast, no injection, no IPC call. Not a silent
local fallback.
14. Existing user-authored `.orca/.gitignore` with extra patterns → after
first SSH drop the file is unchanged (bootstrap writes only when
missing).
15. Unit coverage:
- preload/API types expose `resolveDroppedPathsForAgent` and the
channel is registered in the preload `contextBridge` / IPC
allowlist (regression guard — easy to forget).
- main IPC covers: local passthrough, SSH success, partial failure
(order preserved), fresh-worktree staging bootstrap, bootstrap
preserves existing `.gitignore`, and disconnected SSH.
- terminal-pane coverage verifies the resolver is called once per
gesture, no path is injected until the promise resolves, and
`shellEscapePath` is called with `'posix'` for SSH drops regardless
of client userAgent.
## Follow-ups
File each of these as a GitHub issue before merging the implementation PR
so "tracked as follow-up" is actually locatable. Inline the issue number
next to each item once filed (e.g. `- GC drops dir (#1301)`), and update
the GC paragraph in Non-goals to point to that issue directly.
- GC `${worktreePath}/.orca/drops/` on worktree delete / disconnect.
- `AbortController` plumbing so unmounting the terminal pane cancels the
in-flight SFTP upload (related to the pane-unmount guard added in the
renderer handler: today we no-op the injection, but the bytes still
transfer).
- Drag-over affordance on the terminal pane (it has the
`data-native-file-drop-target="terminal"` marker but no hover style),
so users get feedback that dropping into the terminal is supported.

View File

@ -1,66 +0,0 @@
# Extended Key Chords in the Terminal (Shift+Enter, etc.)
## What Orca sends
Orca's builtin terminal already encodes extended key chords using the
[kitty keyboard protocol][kitty] (CSIu). For example, **Shift+Enter** is sent
as the byte sequence:
```
ESC [ 1 3 ; 2 u (i.e. \x1b[13;2u)
```
See `src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts`
for the full table.
Orca also advertises kittyprotocol support to the running program via
`vtExtensions.kittyKeyboard` on xterm.js (see
`src/renderer/src/lib/pane-manager/pane-terminal-options.ts`), so CLIs that
probe with `CSI ? u` learn that the terminal speaks CSIu and enable their
enhanced input handlers.
## Why Shift+Enter may not reach your CLI inside tmux
tmux, by default, strips both extendedkey encodings (modifyOtherKeys
`CSI 27 ; 2 ; 13 ~` *and* kittystyle `CSI 13 ; 2 u`). If you run Claude
Code, Codex, or any other CLI under tmux, Shift+Enter will look like a
plain `Enter` unless tmux is told to pass those bytes through.
Add this to `~/.tmux.conf` (tmux 3.2+):
```tmux
set -s extended-keys on
set -as terminal-features 'xterm*:extkeys'
```
Then reload: `tmux source-file ~/.tmux.conf` (or restart the tmux server).
- `extended-keys on` — tell tmux to accept and forward the extended
encodings instead of collapsing them to the unshifted key.
- `terminal-features 'xterm*:extkeys'` — tell tmux that the surrounding
terminal (Orca, in this case) understands those encodings, so tmux is
willing to emit them.
## Verifying endtoend
Inside an Orca terminal (no tmux), run:
```
cat -v
```
Press **Shift+Enter**. You should see:
```
^[[13;2u
```
That's caret notation for `\x1b[13;2u` — the expected CSIu encoding. If
you see `^M` (or a blank newline) instead, either the chord isn't reaching
the terminal (check `keyboard-handlers.ts` / `terminal-shortcut-policy.ts`)
or you're inside tmux without the config above.
Inside tmux after the config, the same `cat -v` test should print the same
`^[[13;2u`.
[kitty]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/