chore: ignore ephemeral docs (#2004)

This commit is contained in:
Jinjing
2026-05-15 18:09:21 -07:00
committed by GitHub
parent 5c56241e59
commit df44d8aff1
22 changed files with 104 additions and 3305 deletions
+11 -5
View File
@@ -70,11 +70,17 @@ design-docs/
# Machine-local agent hook endpoint files may contain auth tokens.
/agent-hooks/
# Local-only design/planning docs (not checked in)
docs/*.md
docs/**/*.md
!docs/README*.md
!docs/**/README*.md
# Local-only design/planning docs (not checked in).
# Durable docs must live in one of the allow-listed locations below.
docs/**
!docs/
!docs/assets/
!docs/assets/**
!docs/readme/
!docs/readme/**
!docs/reference/
!docs/reference/**
!docs/STYLEGUIDE.md
# Stably CLI (only docs/ are tracked)
.stably/*
+1 -1
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="README.md">English</a> · <a href="docs/README.zh-CN.md">中文</a> · <a href="docs/README.ja.md">日本語</a> · <a href="docs/README.ko.md">한국어</a> · <a href="docs/README.es.md">Español</a>
<a href="README.md">English</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a>
</p>
<p align="center">
-21
View File
@@ -1,21 +0,0 @@
# Orca CLI Docs
Keep this folder focused on the durable references for Orca's public CLI and runtime model.
## Keep These Docs
- `orca-cli-focused-v1-status.md`
- What is actually implemented and intentionally in scope now.
- `orca-cli-v1-spec.md`
- The public CLI contract, selector grammar, JSON envelope, and command semantics.
- `orca-runtime-layer-design.md`
- Why the runtime layer exists and which boundaries it owns.
- `orca-cli-bundled-distribution.md`
- How the bundled desktop-app distribution and PATH registration model works.
## Why The Folder Is Small
Earlier design and implementation work produced several planning and evaluation docs.
Those were useful while the feature was taking shape, but they were intentionally removed
once the implementation converged so future readers are not forced to choose between
multiple overlapping sources of truth.
-36
View File
@@ -1,36 +0,0 @@
# Claude Runtime Auth Switching
Orca switches Claude Code accounts by materializing a selected managed account into Claude's shared runtime auth surfaces. The runtime surfaces are:
- `.claude/.credentials.json`
- macOS scoped Keychain credentials for the active `CLAUDE_CONFIG_DIR`
- macOS legacy `Claude Code-credentials`
- `.claude.json` `oauthAccount`
## Core Invariants
1. Never write a user/runtime surface unless Orca can prove it owns the current value on that surface.
2. Treat each credential surface independently. File, scoped Keychain, and legacy Keychain can each be owned, external, missing, or unknown.
3. `oauthAccount` metadata is restored or cleared only when metadata itself matches Orca's last managed value, or when a credential surface has already proved the current runtime state belongs to the managed account being cleaned up.
4. Invalid or unparsable runtime config is unknown, not null. Unknown config must be preserved.
5. Missing managed account records are unknown. Orca clears the active selection but does not mutate runtime auth without account identity proof.
6. Missing managed credentials for an existing account can be cleaned up using the account record identity. Only surfaces whose current credentials match that account are restored or cleared.
7. Read-back of refreshed tokens must evaluate all runtime credential candidates and persist only a single unambiguous managed-account match.
## Snapshot Policy
Before entering managed mode from system mode, Orca captures the system-default runtime state. On restore, the snapshot is only applied to surfaces whose current value still equals Orca's managed value, except for missing-managed-credential recovery where account identity is the proof.
Snapshots are schema-validated before use. Invalid snapshots are deleted and treated as absent.
When recapturing while the credentials file still equals the managed account, Orca preserves any previous snapshot value for Keychain surfaces that still equal the managed credentials. This prevents a failed restore followed by restart from recapturing managed Keychain values as system defaults.
## Read-Back Policy
Claude can refresh OAuth tokens in any runtime credential surface. Orca reads all available candidates, filters out stale or ambiguous matches, then chooses the freshest accepted candidate. Cold-start read-back is conservative: credentials must be newer than the matched managed account. Warm read-back rejects only metadata-proven older credentials, allowing equal-expiry token rotation.
## Failure Policy
Keychain reads during snapshot capture must succeed for both active services on macOS; otherwise Orca aborts managed entry. Best-effort Keychain reads are acceptable for token read-back because another surface may still contain a fresh candidate.
Add-account cleanup must restore/delete the legacy active Keychain item before reporting success. If cleanup fails, the login is treated as failed so Orca does not silently leave the user's legacy Claude state pointing at the captured account.
@@ -1,198 +0,0 @@
# SSH Remote Workspace Sync Plan
Planning note for refreshing PR #1690 against current `main`. This is not a merge plan for #1690; current `main` already owns same-client SSH PTY persistence through durable remote PTY leases, relay grace reconnect, explicit expired-session behavior, and provider cleanup semantics.
## Product Boundary
Orca should support an opt-in mode for an SSH target where terminal workspace chrome for that target is stored on the remote host and can be restored by another Orca client connecting to the same target.
In scope:
- Per-SSH-target remote workspace snapshots: terminal tabs, split layouts, active worktree/tab state, and remote PTY IDs needed to reattach live relay PTYs.
- Device presence for clients currently connected to the same synced target.
- Cross-client PTY reattach when the remote relay still owns the PTY.
- Conflict-safe snapshot writes with revisions and stale-write handling.
- Packaged app relay asset lookup and deployment reliability.
Out of scope:
- A general cloud sync service.
- Syncing local repos, local PTYs, local browser runtime state, credentials, secrets, shell history, or local filesystem paths.
- Guaranteed survival after the remote relay/host process exits.
- Automatic agent recovery as core PTY persistence.
Keep explicit/user-driven:
- Enabling sync per SSH target.
- Terminating remote sessions through an explicit destructive action. Normal disconnect detaches only.
- Resuming expired Claude/Codex agent sessions. Show an affordance using stored metadata; do not auto-inject resume commands into a shell.
- Resolving true conflicts that cannot be merged by target/worktree scope.
## Gap Analysis
Already covered on current `main`:
- Same-client SSH PTY survival across close/quit/reconnect through `activeConnectionIdsAtShutdown`, `remoteSessionIdsByTabId`, durable `SshRemotePtyLease` records, relay grace reconnect, and renderer deferred reattach.
- Explicit expired-session behavior: failed `pty.attach` marks leases expired and the renderer starts from the expired state rather than silently pretending the old process survived.
- Provider absence and relay teardown semantics: disconnect detaches, terminate/remove is destructive, and provider registration/ownership are centralized in `SshRelaySession`/`pty.ts`.
- Relay-side PTY buffering, attach, shutdown, and grace timers.
#1690 adds beyond that:
- Relay-side `workspace.*` RPCs and JSON snapshot storage on the remote host.
- A target-scoped session filter and scoped merge so one target's snapshot does not overwrite unrelated local/remote workspaces.
- Remote workspace IPC (`remoteWorkspace:*`) and preload API.
- Renderer hydration of remote snapshots and event-driven application of `workspace.changed`.
- Device presence polling and status UI.
- Multi-client relay dispatcher/socket support so more than one Orca client can talk to the same relay daemon.
- Resize ownership logic so background clients do not resize a shared remote PTY.
- Packaged relay asset lookup through `process.resourcesPath`/`extraResources`.
- Agent resume metadata tied to pane keys. This remains deferred for this implementation.
Now redundant or risky from #1690:
- Do not copy the renderer terminal restore changes wholesale. They overlap directly with recently stabilized `reconnectPersistedTerminals`, deferred SSH session IDs, split-leaf PTY mappings, and expired-session handling.
- Do not replace same-client persistence with relay snapshots. Local workspace session remains the source of truth for classic SSH targets and for non-synced targets.
- Do not default sync to on for existing or new targets until product decides retention/privacy behavior.
- Do not use unlimited relay lifetime as an implicit consequence of sync without a visible setting and cleanup story.
- Do not auto-run agent resume commands after an expired PTY.
## Fresh Architecture
Use two separate layers:
1. **Local session persistence remains authoritative for this Orca install.** It continues to write normal workspace sessions and durable SSH PTY leases exactly as current `main` does.
2. **Remote workspace sync is an opt-in target-level overlay.** When enabled and connected, Orca pushes only that target's scoped workspace slice to the relay and hydrates only that target's slice from the relay.
Data ownership:
- Main process owns target configuration, active SSH sessions, remote workspace IPC, snapshot revision checks, ID translation, and old-relay compatibility handling.
- Relay owns synced snapshot storage under a per-user remote directory, keyed by a sanitized namespace derived from stable target identity.
- Renderer owns local Zustand workspace state and applies remote snapshots only through a guarded hydration path.
- PTY lifetime remains owned by the relay and current `SshRemotePtyLease`/PTY ownership maps. Remote snapshots store PTY IDs as references, not as process ownership.
Remote snapshot shape:
- `namespace`, `revision`, `updatedAt`, `schemaVersion`, `clientId`, and `session`.
- `session` is an explicit v1 projection, not raw `WorkspaceSessionState`.
- v1 includes only terminal/worktree state, keyed by stable remote worktree path. Import/export translates between remote worktree paths and local `repoId::path` IDs at the main/renderer boundary.
- Editor/browser chrome, browser URL history, browser profile IDs, and global active connection IDs are excluded from v1 to avoid syncing local-install identifiers or privacy-sensitive local state.
- Additive fields are okay; wrong-typed core fields must fall back safely.
Sync flow:
- On connect for sync-enabled target: register providers, fetch worktrees, fetch `workspace.get`, translate remote path IDs to local worktree IDs, scope-merge into local state, then let existing terminal reattach paths consume the resulting PTY IDs.
- Local state remains authoritative immediately after startup. A remote snapshot is applied only if this target has no local dirty state newer than the last synced revision, or the user explicitly refreshes from remote.
- On local session write: if workspace session is ready and the target was hydrated, export only that target's terminal/worktree slice using remote worktree paths and `workspace.patch` with the last known revision.
- On stale revision: if both local and newer remote touched the same worktree since the base revision, set conflict state. Otherwise preserve out-of-scope/untouched remote state, merge local touched worktrees, and retry once.
- On relay `workspace.changed`: ignore self-originated events, queue per target, apply only newer revisions, and avoid triggering a resize unless there is trusted local foreground intent.
Failure semantics:
- Old relay without `workspace.*`: keep classic SSH behavior, mark sync "unavailable until reconnect/update", and do not fail SSH connection.
- Remote snapshot parse failure: ignore the remote snapshot, leave local session intact, and show sync error for that target.
- Patch failure: keep local session as-is and retry on next session write or reconnect.
- Expired PTY referenced by remote snapshot: use the current expired-session path; do not silently replace with a live shell unless the existing UI path already does that intentionally.
- Explicit disconnect keeps remote PTYs according to current detach semantics. `Terminate remote sessions` kills PTYs. Target removal is destructive after confirmation and must clear or tombstone that target's synced PTY references.
## Phased Implementation Plan
1. **Relay packaging and compatibility PR**
- Add relay `extraResources` packaging and `getLocalRelayCandidates(platform)` lookup using `process.resourcesPath`.
- Keep current dev path and `ORCA_RELAY_PATH`.
- Add tests for packaged candidate order and missing package diagnostics.
2. **Target setting and main IPC skeleton PR**
- Add `remoteWorkspaceSyncEnabled` and bounded `remoteWorkspaceSyncGracePeriodSeconds` to `SshTarget`.
- Default to false unless product explicitly chooses otherwise.
- Add preload/main `remoteWorkspace:*` IPC behind feature availability.
- No renderer hydration yet; verify old targets normalize cleanly.
3. **Relay workspace snapshot RPC PR**
- Add relay `workspace.get`, `workspace.patch`, and `workspace.presence`.
- Store snapshots atomically under a user-private remote directory.
- Include namespace sanitization, revisions, schema version, and stale-revision responses.
- Wire old-relay method-not-found handling in main as "sync unavailable", not connection failure.
4. **Projected session filtering/merge PR**
- Implement target-scoped session extraction in main using repo `connectionId`.
- Export/import terminal workspace state through a path-keyed projected schema.
- Implement merge that replaces only known in-scope worktrees and preserves out-of-scope remote state.
- Cover terminal tabs, split layouts, active IDs, `remoteSessionIdsByTabId`, and last-visited timestamps. Defer editor/browser chrome.
5. **Renderer guarded hydrate/push PR**
- Fetch synced snapshots only after target worktrees are loaded.
- Apply snapshots through a remote-hydration guard and per-target queue.
- Push snapshots from the existing debounced session writer only for hydrated, connected, sync-enabled targets.
- Reuse existing `reconnectPersistedTerminals`; only feed it scoped remote session IDs.
- Do not enable live cross-client PTY attach unless the multi-client dispatcher and resize ownership from phase 6 are also present.
6. **Multi-client relay + resize ownership PR**
- Extend relay dispatcher/socket bridge to support independent client frame state.
- Broadcast notifications to all clients, but keep request/response routing per client.
- Add foreground/local-intent resize ownership so background hydration or non-focused clients do not resize shared PTYs.
- Treat mobile fit locks and current PTY locks as higher priority than desktop resize.
7. **Presence and status UI PR**
- Add lightweight presence polling and status display for sync-enabled connected targets.
- Show sync phase: idle, pulling, pushing, synced, conflict, error, unavailable/offline.
- Follow `docs/STYLEGUIDE.md` tokens and existing status bar patterns.
8. **Agent resume metadata polish PR**
- Persist provider/session/cwd metadata as recovery hints only.
- Add an explicit "Resume Claude/Codex session" action when a synced PTY expired.
- Avoid auto-sending commands on mount or after snapshot hydration.
## Test Plan
Unit:
- Target normalization preserves existing targets and defaults sync off.
- Namespace derivation is stable and sanitization rejects path traversal/control chars.
- Relay snapshot read/write handles missing, corrupt, stale, and atomic-write cases.
- Target-scoped filter excludes local repos and other SSH targets.
- Projected snapshot import/export translates stable remote worktree paths to local `repoId::path` IDs.
- Scoped merge preserves out-of-scope remote worktrees and deletes in-scope records when the remote snapshot says they are gone.
- Stale revision retry preserves edits from the newer remote snapshot.
- Old relay `-32601` maps to sync unavailable.
- Packaged relay candidate lookup searches `process.resourcesPath` and dev paths.
- Resize ownership gates remote resize on focused/local user intent.
Integration:
- Current same-client persistence still passes: close/quit/reconnect restores SSH PTYs without remote workspace sync enabled.
- Sync-enabled target push writes only that target's session slice.
- Cross-device simulation with two mux clients: client A pushes, client B receives `workspace.changed`, hydrates, and attaches the same relay PTY.
- Two clients patch concurrently: one stale response, one retry, no out-of-scope data loss.
- Explicit disconnect detaches but preserves leases; terminate sessions kills remote PTYs and marks leases terminated.
- Relay restart or expired grace marks PTY references expired and does not auto-resume agents.
- Provider-registration wait, if still needed, waits only for restored SSH reattach and does not mask normal spawn failures.
E2E/manual:
- Packaged macOS/Linux/Windows app can deploy the relay from packaged resources.
- Old running relay continues classic SSH behavior and shows sync unavailable until reconnected with a new relay.
- Two Orca clients on different machines connect to the same target, open a shared remote workspace, and verify tab/layout sync.
- Resize a focused terminal on one client while another client is backgrounded; full-screen TUI dimensions follow only the focused client.
- Mobile/desktop mixed client does not fight PTY dimensions when mobile fit lock is active.
- Passphrase-protected targets defer reconnect without losing remote session IDs.
## Risks And Decisions
Decisions before implementation:
- Default: should remote workspace sync be opt-in off for all targets? Recommendation: yes.
- Retention: is `0` unlimited relay lifetime acceptable, or should sync use a large bounded default with visible cleanup?
- Namespace identity: include `configHost`, host, port, and username. Label changes do not affect namespace.
- Snapshot scope: v1 is terminal + layout + active worktree/tab only. Editor/browser comes later with explicit allowlists and translation rules.
- Conflict UX: retry once automatically, then surface "Refresh from remote" / "Overwrite remote" choices.
- Agent resume UX: explicit action location in expired terminal state or status toast.
Main risks:
- Cross-device resize fights can corrupt TUI layout; ship ownership gates with the first cross-client PTY attach.
- Snapshot merge bugs can delete unrelated local or remote workspace state; keep target filtering/merge isolated and heavily tested.
- Unlimited remote relay lifetime can leave processes running unexpectedly; make retention visible and terminate explicit.
- Old relays may live across app updates; every new RPC must fail open.
- Renderer hydration can trigger existing session writers and feedback loops; use hydration guards and per-target revision tracking.
- Remote snapshots contain path/workspace metadata on the SSH host; treat as local-to-remote-user data and document the storage path.
-132
View File
@@ -1,132 +0,0 @@
# GitHub Work Item Drawer: Fix Reopen Flash
## Symptom
After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears.
## Verified root cause
`GitHubItemDialog` currently paints from component state (`details`, `loading`, `error`) that is updated in an effect (`src/renderer/src/components/GitHubItemDialog.tsx:2480+`).
Current sequence on close/reopen of the same item:
1. Close path runs effect branch with no `workItem` and calls `setDetails(null)`.
2. Reopen renders once with `details === null`.
3. Effect reads `workItemDetailsCache` and then calls `setDetails(painted)`.
4. Second render shows cached content.
With sheet animation, that first empty paint is visible as a flash.
## Refactor direction
Move drawer paint state to the module cache and subscribe with `useSyncExternalStore`. The drawer should read the cache entry synchronously during render.
This is not optional if we want reopen to paint cached content on first render.
## Required cache-store changes
Add a store subscription layer around `workItemDetailsCache`:
- `subscribeWorkItemDetailsCache(listener)`
- `notifyWorkItemDetailsCache()`
Call `notifyWorkItemDetailsCache()` after every actual cache mutation:
- `touchWorkItemDetailsCache`
- `invalidateWorkItemDetailsCacheForKey`
- `invalidateWorkItemDetailsCacheByMatch`
Also increment `workItemDetailsCacheGeneration` on **all** invalidations, not only `invalidateWorkItemDetailsCacheByMatch`. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data.
## `useSyncExternalStore` contract details
Use:
```ts
const cachedEntry = useSyncExternalStore(
subscribeWorkItemDetailsCache,
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined)
)
```
Important constraints:
- `getSnapshot` must return a referentially stable value when store data has not changed.
- Returning the map entry object is valid **only if** writes replace entry identity (`delete+set` with a new entry object).
- Do not build fresh wrapper objects in `getSnapshot` (that would violate stability and can cause render loops).
## Component state changes
Remove component-local `details/loading/error` state for work-item details.
Derive view state from `cachedEntry`:
- `details = cachedEntry?.details ?? null`
- blocking `loading = !!cachedEntry?.pending && !cachedEntry?.details`
- blocking `error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null`
Keep stale-on-error behavior by preserving `details` in cache while setting `error`.
## Effect responsibilities after refactor
The main effect should:
- handle item-change bookkeeping (`prevItemIdRef`, `optimisticCommentsRef`, `setTab`)
- decide freshness and in-flight dedupe
- launch fetch and write results/errors/pending back into cache
The effect should not call `setDetails`, `setLoading`, or `setError` for work-item details.
## Optimistic comment race and merge rules
`appendOptimisticComment` must write through cache only (no local `setDetails`).
Rules:
- push comment into `optimisticCommentsRef`
- patch cache entry comments (if present), mark stale (`fetchedAt: 0`), notify
- derive rendered comments from cached details plus optimistic refs
Dependency pitfall:
- do not depend on a freshly-created array from `optimisticCommentsRef.current` in `useMemo`
- memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway
This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps.
## Invalidation while drawer is open
When `invalidateWorkItemDetailsCacheByMatch` deletes the current key while drawer is mounted:
- subscription notifies immediately
- render sees `cachedEntry === undefined`
- UI falls back to loading/empty state and effect triggers refetch
This is expected and preferred over showing stale state.
## Fetch race constraints (must keep)
Keep current protections:
- per-key in-flight dedupe via `entry.pending`
- generation guard to block stale writes after invalidation
- stale-on-null handling (`result === null` must not wipe existing valid cached details)
Add one missing consistency rule:
- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation.
## Scope of deletion
Delete only the work-item-detail local state and call sites:
- `useState<GitHubWorkItemDetails | null>` (`details`)
- `useState<boolean>` (`loading`) for this data path
- `useState<string | null>` (`error`) for this data path
- corresponding `setDetails`/`setLoading`/`setError` calls in the details effect and optimistic comment append path
Do not touch unrelated loading/error state in other subviews.
## Expected result
Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative.
-118
View File
@@ -1,118 +0,0 @@
# GitHub Work Item Drawer: Cache & Latency
## Problem
`GitHubItemDialog` clears `details` to `null` on every open and waits for `gh:workItemDetails` before rendering. Reopening the same item therefore pays full IPC + `gh` process startup latency again.
The slow path is mostly process overhead and fan-out in `src/main/github/work-item-details.ts`, not GitHub API quota.
## What is true today
- `gh api --cache` reduces network/API pressure, but it does not remove local process startup, JSON parsing, or IPC serialization.
- A 304 response does not consume primary REST rate-limit quota, but it is still a network round-trip. With `--cache`, many calls are served from local cache anyway.
- The issue details path is currently multi-call:
1. `getWorkItem` (`/issues/:n` or PR fallback).
2. `getIssueBodyAndComments` (`/issues/:n` and `/issues/:n/comments`).
3. `getWorkItemParticipants` (GraphQL participants).
4. `getMentionParticipants` (GraphQL user hydration for visible authors).
So “3 spawns” understates the real hot path.
## Goals
- Reopen latency should be near-instant for recently viewed items.
- Cold issue-open latency should drop by reducing `gh` command count.
- Correctness must survive local mutations, repo/source switches, and multi-window usage.
## Non-goals
- No disk-persistent cache in this iteration.
- No attempt to replace PR files/diff path with GraphQL.
## Design
### 1. Renderer SWR cache with paint-first reads
Add a module-level LRU cache in `GitHubItemDialog.tsx` keyed by:
`repoPath + issueSourcePreference + type + number`
Cache value:
- `details`
- `fetchedAt`
- `pending?: Promise`
- `error?: string`
Behavior:
1. On open, render cached `details` immediately when present (do not clear to `null`).
2. If entry age <= `FRESH_MS` (30s), skip fetch.
3. If stale or missing, fetch in background and replace cache + UI when resolved.
4. On fetch failure with cached data, keep stale data visible and show non-blocking error state.
5. On fetch failure without cached data, show blocking error state.
### 2. In-flight dedupe in renderer
Store a single pending promise per key in the same cache entry. Concurrent opens/re-renders for the same key must await the same promise.
This dedupe must be keyed the same as the data cache key to avoid cross-repo or cross-source collisions.
### 3. Explicit invalidation rules
“Background refetch is authoritative” is necessary but not sufficient.
Invalidate (or patch + mark stale) on successful local mutations:
- issue state/labels/assignees/body edits
- new comments/reactions
- PR review comment create/resolve
Scope:
- per-item key in current window
- broadcast to other windows via main-process event (`gh:workItemMutated`) so their caches invalidate too
Also invalidate on context switches:
- `repoPath` change
- issue source preference change (`origin`/`upstream`/`auto`)
- sign-out/account change
For out-of-band mutations (web UI/other tools), rely on TTL + manual refresh action in drawer header.
### 4. Main-process issue fetch collapse (GraphQL-first)
Do not claim “one call returns everything” unless we actually ship and verify it.
Feasible single GraphQL issue query fields:
- issue body
- labels
- assignees
- participants
- comments(first: N) with author login + avatarUrl + body + createdAt + url
Limits and required fallbacks:
- GraphQL pagination still applies (`first: 100`). More comments require paging.
- Some comment authors can be null/ghost; renderer must keep existing fallback behavior.
- If GraphQL fails (permissions, partial errors), fall back to current REST+GraphQL path.
- Keep `getMentionParticipants` only if query omits non-participant visible authors; otherwise remove it.
PR path remains unchanged in this doc. PR file/diff/check behavior is intentionally out of scope.
## Edge cases this design must handle
- Reopen same item after optimistic comment: optimistic comment must survive stale cache reads until authoritative fetch includes it.
- Switching between upstream/origin issue source with same issue number must never reuse the wrong cache entry.
- Item-type collision (`issue #123` vs `pr #123`) must never reuse cache entry.
- Drawer close/open races: stale request responses must still be dropped (`requestIdRef` guard stays).
- Unauthorized/404 should not overwrite valid cached data with empty shells.
## Rollout
1. Implement renderer SWR + in-flight dedupe + stale-on-error behavior.
2. Add mutation-driven invalidation and cross-window invalidation event.
3. Implement GraphQL-first issue details with strict fallback.
4. Keep telemetry: measure open-to-first-paint and open-to-fresh-data before/after.
-651
View File
@@ -1,651 +0,0 @@
# Mobile scrollback parity: hydrate headless emulator from renderer on first PTY data
## Problem
When a mobile companion client subscribes to a terminal, it sees substantially less scrollback than the desktop renderer for the same PTY. In the worst case observed (a `claude`-then-shell session in the `edit-issues` worktree), the desktop shows ~30 lines of post-exit agent summary while mobile shows a single bare prompt.
The mismatch is reproducible and not a transient race: it persists across resubscribes, app return-from-background, and PTY-clean shell prompts.
## Where data lives today
Two separate xterm.js instances exist for every connected PTY:
1. **Renderer xterm** — owned by `TerminalPane` in the renderer process. Created when the user mounts the pane. Listens to `pty:data` IPC for the lifetime of the pane. 50,000-row scrollback. Source of truth for the visible desktop terminal.
2. **Runtime headless emulator**`HeadlessEmulator` in `src/main/runtime/orca-runtime.ts`. Created lazily on first `runtime.onPtyData(ptyId, …)` call. 5,000-row scrollback. Used to serve `terminal.read`, `terminal.subscribe`, `lastAgentStatus`, and TUI-idle detection. This is what mobile, the CLI, and any non-renderer consumer get.
| Consumer | Reads from | Lifetime |
|---|---|---|
| Desktop pane | Renderer xterm | Pane mount → unmount |
| Mobile (`terminal.subscribe`) | Headless emulator (primary), renderer (fallback) | Runtime startup → PTY exit |
| `terminal.read` (CLI / agent) | Headless emulator | Runtime startup → PTY exit |
| `lastAgentStatus` | Headless emulator (via OSC detection on `onPtyData`) | Runtime startup → PTY exit |
The two xterms are fed by the same provider stream but are "born" at different moments:
- The renderer xterm gets hydrated on attach via cold-restore data (`result.coldRestore.scrollback`) or warm reattach (`result.snapshot`) in `pty-connection.ts`, plus all live bytes since.
- The headless emulator only sees bytes that flow through `runtime.onPtyData` since *runtime* startup. After an Orca relaunch, the headless emulator starts empty and only catches up on new live data; it never replays the cold-restore payload.
- Commit `8a5ea4b7` added `seedHeadlessTerminal(ptyId, data, size?)` which runs from `ipc/pty.ts` on `provider.spawn` and seeds the emulator from `result.snapshot` / `result.coldRestore.scrollback`. That helps daemon-restored sessions but **does not help when the daemon's persisted state is itself near-empty** — the `edit-issues` reproduction has `checkpoint.json` with `scrollbackAnsi: ""` because the daemon checkpoint only stores the visible screen, not real scrollback.
So the dominant residual case is: daemon checkpoint is near-empty, the user opens a desktop pane (which hydrates from the live PTY post-spawn and accumulates a rich 50k-row buffer), then the user opens mobile — and mobile reads from the headless emulator, which has only seen bytes since runtime startup with nothing to replay.
## Goal
Mobile, CLI, and `lastAgentStatus` should display the same scrollback as the desktop renderer for any PTY whenever the user has a desktop pane mounted for it. The fix must:
- Be a small, targeted change to the runtime (no daemon, IPC schema, or persistence-format changes).
- Not regress the "no desktop pane mounted" cases (background terminals, headless agent runs, CLI on a freshly-restored daemon).
- Not introduce reflow / SGR drift between desktop and mobile.
- Not introduce a per-keystroke or per-subscribe renderer round-trip on the hot path.
- Reinforce — not violate — the existing invariant that the main process is authoritative for terminal state served to mobile/CLI/`terminal.read`.
## Non-goals
- **Mobile-only-after-relaunch (Scenario A).** A user who relaunches Orca and opens mobile *without* ever opening the desktop pane will still see only what the daemon checkpoint stored (often the visible screen and nothing more). Solving this requires daemon-side persisted scrollback, which is a separate, larger effort with its own privacy, format-stability, retention, and IO-cost considerations. This design explicitly does not solve Scenario A.
- Persisting full scrollback to disk (the "option 2" architecture).
- Unifying the renderer and headless emulators into a single main-process service.
- Changing mobile's xterm-side replay logic. Today it correctly handles either source.
- Changing the read-priority order in `serializeTerminalBufferFromAvailableState`. Headless stays primary; renderer stays the no-headless fallback.
## Proposed change
When the runtime first sees `pty:data` for a `ptyId` that already has a renderer pane registered (detected via a new `ptyController.hasRendererSerializer(ptyId)` predicate, backed by the `serializersByPtyId` registry in `pty-buffer-serializer.ts`), perform a one-time IPC round-trip to the renderer, get its serialized buffer, and seed the headless emulator with it **before** writing the live byte that just arrived. Subsequent `pty:data` calls for the same `ptyId` skip hydration via a per-PTY guard. If no renderer serializer is registered yet on the first byte, the guard is left absent so a later byte can retry once the pane mounts.
This is structurally the same operation `seedHeadlessTerminal` already does on `provider.spawn` from daemon-restored state. The new path covers the gap the existing seeding doesn't: the renderer has a richer buffer than the daemon does, and we want that richer buffer mirrored into the headless emulator on first runtime touch.
After hydration, all consumers (`terminal.read`, `terminal.subscribe`, `lastAgentStatus`, mobile, CLI) get the same fuller scrollback through the existing read paths. No priority flip is required. No per-consumer fallback. No per-subscribe IPC.
### System context — before
```
┌─────────────────────────────────────────────┐
│ Renderer process │
│ │
│ TerminalPane → xterm.js (50k rows) │
│ ▲ │
│ │ replayIntoTerminal(coldRestore. │
│ │ scrollback) on pane mount │
│ │ + live pty:data writes │
└─────┼───────────────────────────────────────┘
pty:data│IPC
┌─────┴───────────────────────────────────────┐
│ Main process │
│ │
│ ipc/pty.ts (provider.spawn) │
│ ├─→ seedHeadlessTerminal(snapshot) │
│ │ ↓ │
│ │ HeadlessEmulator (5k rows) ───┐ │
│ │ ▲ │ │
│ │ │ trackHeadlessTerminalData│ │
│ │ │ on every onPtyData │ │
│ └─→ runtime.onPtyData(...)────────┘ │
│ ▼ │
│ serializeHeadless │
│ (primary read source) │
│ │ │
│ ▼ │
│ mobile / CLI / lastStatus │
└─────────────────────────────────────────────┘
pty:data│from daemon
┌─────┴───────────────────────────────────────┐
│ Daemon │
│ checkpoint.json (visible screen only) │
└─────────────────────────────────────────────┘
Bug: when daemon checkpoint is near-empty but the renderer xterm has
accumulated a rich live buffer, mobile reads from the headless emulator
which only saw bytes since runtime startup — and so sees less than desktop.
```
### System context — after
```
┌─────────────────────────────────────────────┐
│ Renderer process │
│ │
│ TerminalPane → xterm.js (50k rows) │
│ │ ▲ │
│ │ │ unchanged hydration path │
│ │ └─── live pty:data writes │
│ │ │
│ │ ptyController.serializeBuffer(ptyId) │
│ │ called ONCE per (ptyId, runtime) │
│ │ on first onPtyData │
│ ▼ │
└─────┼───────────────────────────────────────┘
pty:data│IPC + one-time serialize round-trip
┌─────┴───────────────────────────────────────┐
│ Main process │
│ │
│ onPtyData(ptyId, data) (first time) │
│ ├─→ if ptyController.serializeBuffer │
│ │ returns non-empty: │
│ │ hydrateHeadlessFromRenderer → │
│ │ seedHeadlessTerminal(rendered) │
│ │ mark hydrated │
│ ├─→ trackHeadlessTerminalData(data) │
│ │ (chained via writeChain after seed) │
│ │ │
│ HeadlessEmulator (5k rows, now seeded │
│ with renderer's view) │
│ ↓ │
│ serializeHeadless (UNCHANGED PRIMARY) │
│ ↓ │
│ mobile / CLI / lastAgentStatus / read │
└─────────────────────────────────────────────┘
Read priorities are unchanged. Headless emulator is still the primary
source of truth; renderer is consulted exactly once, at hydration time,
not per consumer or per subscribe.
```
### Ordering invariant (DO NOT REORDER)
In `onPtyData(ptyId, data, at)`, the call sequence is:
```ts
this.agentDetector?.onData(ptyId, data, at)
this.maybeHydrateHeadlessFromRenderer(ptyId) // ← MUST come BEFORE
this.trackHeadlessTerminalData(ptyId, data) // ← this line
```
**`maybeHydrateHeadlessFromRenderer` MUST be invoked before `trackHeadlessTerminalData`.** If reordered, `trackHeadlessTerminalData`'s lazy-create branch will populate `headlessTerminals` with a fresh empty emulator first, and `maybeHydrateHeadlessFromRenderer`'s `!headlessTerminals.has(ptyId)` precondition (the "live bytes already arrived" guard) will short-circuit — silently disabling hydration for the lifetime of the PTY. There is no test that catches this regression cheaply (the failure mode is "less scrollback than expected", which is the original bug); the guarantee lives in the call-site comment in `orca-runtime.ts`.
A `// DO NOT REORDER — see "Ordering invariant" in mobile-prefer-renderer-scrollback.md` comment is mandatory at this call site.
### Pseudocode
```ts
// Per-PTY hydration tracker. Key absent = idle (next byte may attempt).
// 'pending' = round-trip in flight; 'done' = succeeded or unrecoverably
// finished; 'skipped' is no longer used — instead we leave the key absent
// when the precondition fails so a later byte can retry.
private headlessHydrationState = new Map<string, 'pending' | 'done'>()
onPtyData(ptyId: string, data: string, at: number): void {
this.agentDetector?.onData(ptyId, data, at)
this.maybeHydrateHeadlessFromRenderer(ptyId)
this.trackHeadlessTerminalData(ptyId, data)
// …existing OSC / tail / leaf bookkeeping unchanged
}
private async maybeHydrateHeadlessFromRenderer(ptyId: string): Promise<void> {
if (this.headlessHydrationState.has(ptyId)) return
if (this.headlessTerminals.has(ptyId)) {
// Live bytes already arrived for this ptyId — re-seeding now would
// duplicate them. Mark done; never retry. (Daemon-snapshot seeding
// is handled separately: see "Cooperation with seedHeadlessTerminal"
// — that path is gated on `!hasRendererSerializer(ptyId)` so it does
// not pre-empt this hydration when a renderer pane is mounted.)
this.headlessHydrationState.set(ptyId, 'done')
return
}
// Why: leave the state absent (not 'done') when there's no renderer
// serializer or controller. The pane may mount one byte later; the
// next onPtyData call gets another chance. This is the contract change
// motivated by Open Question P2-2.
if (!this.ptyController?.serializeBuffer) return
if (!this.ptyController.hasRendererSerializer?.(ptyId)) return // not yet registered
this.headlessHydrationState.set(ptyId, 'pending')
// Why: eagerly create the headless state at PTY dims so concurrent live
// writes from trackHeadlessTerminalData chain onto the same writeChain.
// This matches seedHeadlessTerminal's pattern (orca-runtime.ts:780-803);
// without it, a live byte arriving during the serializeBuffer await would
// lazy-create a separate state that we'd later overwrite, dropping the
// live byte.
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state: RuntimeHeadlessTerminal = {
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
writeChain: Promise.resolve()
}
this.headlessTerminals.set(ptyId, state)
// Why: append the renderer-fetch + seed-write to the chain. Live writes
// that arrive before the chain head resolves are queued behind it via
// trackHeadlessTerminalData's existing `state.writeChain.then(...)` pattern.
state.writeChain = state.writeChain.then(async () => {
try {
const rendered = await this.ptyController!.serializeBuffer!(ptyId, {
scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS,
altScreenForcesZeroRows: true
})
if (!rendered || rendered.data.length === 0) {
this.headlessHydrationState.set(ptyId, 'done')
return
}
// Why: resize to the renderer's reported dims before writing the seed
// so the serialized layout reflows correctly. Then resize back to the
// PTY's current dims after the seed lands so subsequent live writes
// use the right cell grid.
state.emulator.resize(rendered.cols, rendered.rows)
await state.emulator.write(rendered.data)
const ptyDims = this.getTerminalSize(ptyId)
if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) {
state.emulator.resize(ptyDims.cols, ptyDims.rows)
}
// Why: status parity. SerializeAddon does NOT round-trip OSC 0/1/2
// title bytes, so extracting from rendered.data would always be null.
// applySeededAgentStatus runs detectAgentStatusFromTitle and writes
// leaf.lastAgentStatus only when the result is non-null; it MUST NOT
// call resolveTuiIdleWaiters or deliverPendingMessages because seed-
// derived status reflects historical state.
if (rendered.lastTitle) {
this.applySeededAgentStatus(ptyId, rendered.lastTitle)
}
} catch {
// Hydration is best-effort. Live writes continue via the same
// writeChain that this catch-arm leaves intact.
} finally {
this.headlessHydrationState.set(ptyId, 'done')
}
})
}
```
**Eager-state ordering invariant.** Even though the chain head blocks live writes until the seed completes, the LIVE BYTE ITSELF (the one that triggered `onPtyData`) is enqueued onto `state.writeChain` BY `trackHeadlessTerminalData` AFTER `maybeHydrateHeadlessFromRenderer` returns synchronously and registers its seed-write link. The live byte's chain link executes after the seed-write resolves. That is the ordering invariant the eager-state pattern preserves.
**Resize-during-hydration note.** `resizeHeadlessTerminal` (orca-runtime.ts:828) deliberately bypasses `writeChain` and calls `state.emulator.resize(...)` directly — that's existing behavior we preserve. If a `pty:resize` IPC arrives during the hydration window (between `headlessTerminals.set` and `seedPromise` resolution), the resize lands on the emulator while the seed-write is mid-flight; this can produce a one-frame visual artifact in the seed but cannot corrupt subsequent live writes (which the seed-then-resize-back invariant restores). The narrow window — typically <200ms — is bounded by the renderer-IPC round-trip duration.
The cap and alt-screen rule on the renderer side mirror `serializeHeadlessTerminalBuffer`: 1000-row limit on rehydration payload, force `scrollbackRows = 0` when the renderer xterm reports `buffer.active.type === 'alternate'`. This unifies semantics between hydration source and live-serialize source — the user sees the same depth regardless of which path served them, and an alt-screen TUI never bleeds normal-buffer scrollback into the seed.
### IPC contract changes
Hydration relies on the renderer-side serializer accepting an option bag. Today the chain is single-arg end to end, so changes must land in lockstep:
- `src/preload/index.ts` — the `pty:serializeBuffer:request` payload extends to `{ requestId, ptyId, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } }`. Renderer-side type for the listener callback widens accordingly.
- `src/main/ipc/pty.ts``requestSerializedBuffer(ptyId, opts?)` accepts the opts object and forwards it on the IPC `webContents.send` payload. Snapshot-shape validation widens to accept an optional `lastTitle: string`. Existing 750 ms timeout unchanged.
- `src/main/runtime/orca-runtime.ts` — the `RuntimePtyController.serializeBuffer?` signature widens to `(ptyId, opts?) => Promise<{ data: string, cols: number, rows: number, lastTitle?: string } | null>`. A new optional `hasRendererSerializer?(ptyId): boolean` lets the runtime check pane mount status before paying the IPC cost (implementation: a getter against `serializersByPtyId` in `pty-buffer-serializer.ts`).
- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — the registered `SerializeFn` widens to `(opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`. The pane's serializer reads the opts: if `altScreenForcesZeroRows && pane.terminal.buffer.active.type === 'alternate'`, call `pane.serializeAddon.serialize({ scrollback: 0 })`; otherwise `pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows })`. The response payload includes `lastTitle: lastTitleByPtyId.get(ptyId)?.title` (omitted when no title has been observed for this PTY). Default behavior (no opts) preserves today's full-buffer serialize for any caller that hasn't migrated.
- `serializeTerminalBufferFromAvailableState` (the renderer-fallback read path in `orca-runtime.ts:843`) forwards `scrollbackRows` so the fallback's depth matches the primary path. **It does NOT pass `altScreenForcesZeroRows: true`** — see "alt-screen flag scoping" below.
**alt-screen flag scoping (only on hydration path).** `altScreenForcesZeroRows: true` is passed ONLY by `maybeHydrateHeadlessFromRenderer` (the hydration path). The read-fallback path (`serializeTerminalBufferFromAvailableState`) passes `altScreenForcesZeroRows: false` (or omits the flag, default false). Reasoning:
- **Hydration:** wants the *normal* buffer's scrollback. xterm discards alt-screen content when alt-screen exits anyway, so suppressing scrollback during alt-screen prevents transient TUI bytes from bleeding into the seed that the headless emulator will hold for the rest of the session.
- **Read fallback:** is invoked by `terminal.read` / `terminal.subscribe` for an actively-running TUI when the headless emulator returned null. The caller wants whatever is currently visible — including alt-screen content — so suppressing it would produce a worse result than today.
Both call sites pass explicit opts in the pseudocode (no implicit defaults relied on). The read-fallback shape:
```ts
// In serializeTerminalBufferFromAvailableState (read-fallback):
rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, {
scrollbackRows: opts.scrollbackRows,
altScreenForcesZeroRows: false // read-fallback wants visible alt-screen content
}) ?? Promise.resolve(null))
```
### Renderer-side prerequisite
Today, `registerPtySerializer(ptyId, …)` is only called inside `handleReattachResult` (`pty-connection.ts:571`). Fresh spawns — the dominant case — never register, so `hasRendererSerializer(ptyId)` would always return false and hydration would no-op. The renderer change required:
- Move (or duplicate) the `registerPtySerializer` call so it fires for **both** reattach and fresh spawn paths. Cleanest shape: extract the body (closure capturing `pane`, `deps.pendingWritesRef`, `deps.replayingPanesRef`) into a `registerPaneSerializerFor(ptyId)` helper and call it from `handleReattachResult` AND from `startFreshSpawn`'s `.then(spawnedPtyId => …)` once a `ptyId` is known.
- The helper accepts the new `opts` arg and threads it into `pane.serializeAddon.serialize({ scrollback: … })` per the IPC contract above.
**Cleanup contract & StrictMode safety.** Two requirements, both motivated by React 18 StrictMode double-mount and async pane disposal:
1. **`if (disposed) return` guard at the top of every post-spawn registration call** (both reattach and fresh-spawn paths). Rationale: StrictMode mounts the pane twice; the first mount is disposed before the second runs, but its `pty:spawn` IPC may have already resolved by the time `disposed` flips to true. Without the guard, the disposed first mount would call `registerPaneSerializerFor` after teardown — replacing the live registration from the second mount or registering a closure over a torn-down `pane.terminal`.
2. **Unregister via ownership token, not unconditional delete.** `serializersByPtyId` becomes `Map<ptyId, { fn: SerializeFn, owner: symbol }>`. Each call to `registerPtySerializer` mints a fresh `owner` symbol and returns an unregister closure that **only deletes the entry when the current entry's `owner === thisOwner`**. If a later registration has overwritten the entry (StrictMode second mount, fast pane swap), the unregister is a no-op so the live registration is preserved. The body of the unregister closure also clears any module-level state keyed off the same owner (lastTitle bookkeeping introduced below) AND disposes the xterm `onTitleChange` IDisposable that was installed at register time.
```ts
export function registerPtySerializer(ptyId, fn): () => void {
const owner = Symbol(ptyId)
serializersByPtyId.set(ptyId, { fn, owner })
ensureSerializerListener()
return () => {
const current = serializersByPtyId.get(ptyId)
if (current?.owner === owner) {
serializersByPtyId.delete(ptyId)
}
}
}
```
3. **Spawn-rejection cleanup is mandatory.** The renderer MUST call `pty:clearPendingPaneSerializer(paneKey, gen)` from the catch-arm of `pty:spawn` AND from pane unmount AFTER pre-signal but BEFORE settle. Without this, a failed-spawn paneKey leaks a pending intent that suppresses the next legitimate daemon-snapshot seed for that paneKey.
4. **Pre-signal applies to BOTH fresh-spawn AND reattach paths.** `declarePendingPaneSerializer(paneKey)` is mandatory on both — mirroring the `registerPaneSerializerFor(ptyId)` requirement. Reattach paths today already register the serializer in `handleReattachResult`, but the pre-signal must also fire on the reattach path so the cooperation gate in `pty:spawn` behaves consistently regardless of whether the renderer is taking over a pre-existing PTY or a fresh one.
The `unregisterSerializer()` call is wired into `onDataDisposable.dispose` so the registration is torn down on pane unmount, regardless of which path registered it.
### Renderer pre-signal handshake
The cooperation gate (`ipc/pty.ts` skipping `seedHeadlessTerminal` when a renderer serializer is registered) has a fundamental timing problem on **fresh spawns**: the renderer cannot have registered the serializer for the new ptyId yet, because it doesn't know the ptyId until `pty:spawn` resolves. So at the moment the cooperation gate runs (immediately after `provider.spawn` returns inside `pty:spawn`'s handler), `runtime.hasRendererSerializerFor(ptyId) === false` for every fresh spawn — and the daemon-snapshot seed always wins, defeating the gate for the dominant case.
The renderer does, however, know its **paneKey** before spawn: every pane has a stable `ORCA_PANE_KEY` env var that is part of `args.env` on `pty:spawn`. We use the paneKey as a pre-signal, on a channel that doesn't depend on knowing the ptyId yet:
1. **Pre-signal IPC with generation token.** Renderer calls `pty:declarePendingPaneSerializer(paneKey)` BEFORE invoking `pty:spawn`. Main process maintains `pendingByPaneKey: Map<paneKey, generation: number>` keyed by paneKey. On each pre-signal, main mints a fresh monotonic `gen` value (incrementing a module-level `genSeq` counter) and overwrites the entry, then returns `gen` to the renderer. The renderer awaits this IPC (capturing `gen`), then awaits the spawn IPC — guaranteeing the main process has the pending intent recorded by the time `pty:spawn` runs.
```ts
let genSeq = 0
const pendingByPaneKey = new Map<string, number>() // paneKey -> generation
function declarePending(paneKey: string): number {
const gen = ++genSeq
pendingByPaneKey.set(paneKey, gen)
return gen // returned to renderer, echoed back on settle/clear
}
function settlePending(paneKey: string, gen: number): void {
if (pendingByPaneKey.get(paneKey) === gen) pendingByPaneKey.delete(paneKey)
}
paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))
```
Why a generation counter: today's design hooks `pendingByPaneKey.delete(paneKey)` into `clearProviderPtyState` via `paneKeyTeardownListeners`. If the OLD PTY's `clearProviderPtyState` fires AFTER mount-2's pre-signal but BEFORE mount-2's spawn, an unconditional teardown deletes mount-2's pending entry — defeating the gate. The generation counter solves this: the `paneKeyTeardownListeners` wiring captures the generation at the time the old PTY was REGISTERED (via the existing `registerPty` call site) and the teardown only fires `settlePending` with that generation. If mount-2 has already replaced the entry with a new gen, `pendingByPaneKey.get(K) !== oldGen` and the teardown is a no-op.
2. **Cooperation gate consults the pre-signal.** Inside `pty:spawn`, after `provider.spawn` returns, the gate becomes:
```ts
const paneKey = args.env?.ORCA_PANE_KEY
const rendererPreSignaled =
typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256
&& pendingByPaneKey.has(paneKey)
const rendererAlreadyRegistered =
runtime?.hasRendererSerializerFor?.(result.id) ?? false
if (!rendererPreSignaled && !rendererAlreadyRegistered) {
// No renderer is or will be authoritative for this PTY — daemon-snapshot
// seed runs as today.
runtime?.seedHeadlessTerminal(result.id, /* …snapshot/coldRestore… */)
}
```
The `rendererAlreadyRegistered` branch covers reattach, where the pane mounted before spawn returned and registration completed in the meantime. The `rendererPreSignaled` branch covers fresh spawn, where registration has not happened yet but is imminent. Note: the gate matches when `pendingByPaneKey.has(paneKey)` is true *regardless of generation*; the generation is only used by cleanup paths (teardown listeners, settle, clear) to prevent cross-generation deletion. **Invariant:** fresh-spawn always relies on the pre-signal branch (`hasRendererSerializerFor(ptyId)` is necessarily false at gate-time since the renderer hasn't received `ptyId` yet); the registered branch only fires for reattach where the pane mounted before spawn returned.
3. **Settle / clear (echo gen back).** After the spawn IPC resolves and the renderer calls `registerPtySerializer(ptyId, …)`, it calls `pty:settlePaneSerializer(paneKey, gen)` to remove the pending intent (echoing the `gen` value it captured from declarePending). The handler runs `settlePending(paneKey, gen)` which only deletes if the stored gen still matches. (The simpler "clear on first onPtyData" or "clear on receipt of `pty:registerPaneSerializer`" alternatives work too — the design picks the explicit `settle` IPC because it makes the contract auditable from the renderer side and avoids coupling main-process state to onPtyData flow.) On pane unmount or registration failure, the renderer also calls `pty:clearPendingPaneSerializer(paneKey, gen)` (sibling handler with the same `settlePending` semantics) to drop the pending intent.
**Sequence diagram (fresh spawn, happy path):**
```
Renderer Main / ipc-pty.ts Runtime
│ │ │
│ pty:declarePendingPaneSerializer │ │
│ (paneKey) ───────────────────────────▶│ pendingByPaneKey.set(K, gen) │
│ ◀──────────────────────── ack(gen) ──│ │
│ │ │
│ pty:spawn(args incl. ORCA_PANE_KEY) ─▶│ │
│ │ provider.spawn(...) → result │
│ │ pendingByPaneKey.has(paneKey)│
│ │ === true → SKIP │
│ │ seedHeadlessTerminal │
│ │ │
│ ◀──────────────────────────── ptyId ──│ │
│ │ │
│ registerPtySerializer(ptyId, fn) │ │
│ pty:settlePaneSerializer(paneKey, │ │
│ gen) ──────────────────────────────▶│ settlePending(paneKey, gen) │
│ │ if gen matches → delete │
│ │ │
│ │ first onPtyData(ptyId,data)──▶│
│ │ │ maybeHydrateFromRenderer
│ │ │ (headlessTerminals empty,
│ │ │ hasRendererSerializer true
│ │ │ → IPC round-trip → seed)
```
**Renderer-side call shape.** The renderer threads `gen` through every call site so cleanup (settle/clear) can be matched against the generation it captured at declare time:
```ts
// In pty-connection.ts startFreshSpawn / handleReattachResult:
const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)
try {
const ptyId = await window.api.pty.spawn(args)
registerPaneSerializerFor(ptyId)
await window.api.pty.settlePaneSerializer(paneKey, gen)
} catch (err) {
await window.api.pty.clearPendingPaneSerializer(paneKey, gen)
throw err
}
```
**Correctness properties:**
1. **Pre-signal arrives before `pty:spawn`.** The renderer awaits the pre-signal IPC, then awaits the spawn IPC. Electron `ipcRenderer.invoke`/`send` ordering on a single renderer is preserved on the main side, so the main process processes the pre-signal before `pty:spawn`'s handler runs.
2. **Missing paneKey degrades to today's behavior.** If `args.env?.ORCA_PANE_KEY` is absent (e.g., a pane that isn't yet wired to set it, an old renderer build, or a non-pane spawn path), the pre-signal lookup returns false, `rendererAlreadyRegistered` is also false on a fresh spawn, and the daemon-snapshot seed runs as today. No regression for callers that don't participate in the handshake.
3. **Pre-signal without settle (renderer crashes between spawn and registerPtySerializer).** The pending intent stays in the map, but it has no observable effect: the cooperation gate already ran, so the daemon snapshot was suppressed; the headless emulator stays empty; the first `onPtyData` falls through `hasRendererSerializer(ptyId) === false` and hydration's precondition leaves the state map absent. `trackHeadlessTerminalData` then lazy-creates the emulator and live writes populate it. We lose the daemon snapshot for this PTY in that narrow window — accepted limitation, since the renderer was clearly going to be authoritative but didn't make it. (To bound staleness, the entry in `pendingByPaneKey` is cleared whenever a paneKey teardown event fires via the existing `paneKeyTeardownListeners` registry, gated by the generation token.)
**Files-touched implications:** see "IPC contract changes" and "Files touched" — both `pty:declarePendingPaneSerializer` and `pty:settlePaneSerializer` need preload bindings AND main handlers.
### Status parity
Seeding the emulator bypasses `onPtyData`'s OSC-extraction path, so without intervention `leaf.lastAgentStatus` stays whatever it was (typically `null` for daemon-restored leaves) until the next live byte happens to carry an OSC title. Mobile would then see a stale or missing agent badge despite a rich seed.
A previous draft of this design called `extractLastOscTitle(rendered.data)` on the seed payload to recover the title. **That doesn't work**: xterm's `SerializeAddon.serialize()` writes out the visible buffer as a stream of cursor moves, SGR resets, and printable characters — it does NOT round-trip OSC 0/1/2 (window/icon title) escape sequences. The renderer xterm consumes the OSC and sets `terminal.options.title`, but the bytes never appear in `serialize()`'s output. So `extractLastOscTitle` on a seed payload is dead code — it always returns null even when the renderer's pane currently shows a perfectly good title. We need the title via a different channel.
**Approach: ship now via IPC widening, document as best-effort.** The renderer pane already wires `pane.terminal.onTitleChange` (in `pty-connection.ts`) to keep the tab title in sync. We capture the latest observed title in module state alongside the serializer registration, and widen the serialize-buffer IPC payload to carry it back to main:
1. **Renderer side** (`pty-buffer-serializer.ts`):
- Module-level `lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }>` keyed by the same ownership-token symbol as `serializersByPtyId` (see "Renderer-side prerequisite"). Populated by a wrapper around `pane.terminal.onTitleChange` installed at the same time as `registerPtySerializer`. The entry stores BOTH the IDisposable returned from `pane.terminal.onTitleChange(...)` AND the owner symbol alongside the latest title.
- When the pane unregisters, the `unregisterSerializer` closure disposes the IDisposable AND deletes the map entry, both gated by the owner-token match — same ownership semantics as the serializer map, so a StrictMode disposed first-mount cannot wipe the live second-mount's title or leak a dangling listener.
- The IPC response payload widens from `{ data, cols, rows }` to `{ data, cols, rows, lastTitle?: string }`. Default omitted when no title was observed for this PTY (e.g., fresh shell pre-prompt).
2. **Main side** (`ipc/pty.ts`, `orca-runtime.ts`):
- `requestSerializedBuffer` snapshot-shape validation accepts `lastTitle?: string`.
- `RuntimePtyController.serializeBuffer(ptyId, opts?)` returns `Promise<{ data, cols, rows, lastTitle?: string } | null>`.
- `maybeHydrateHeadlessFromRenderer` calls `applySeededAgentStatus(ptyId, rendered.lastTitle)` only when `rendered.lastTitle` is a non-empty string (the helper itself also short-circuits on null/empty for safety). The helper internally runs `detectAgentStatusFromTitle` and only writes `leaf.lastAgentStatus` when the result is one of `'idle' | 'working' | 'permission'`.
3. **`applySeededAgentStatus(ptyId, title)`** (new helper in `orca-runtime.ts`). Behavior:
- If `title == null` or empty, no-op.
- Look up the leaf for `ptyId` via the existing pty→leaf mapping. Run `detectAgentStatusFromTitle(rendered.lastTitle)` (the helper from `src/shared/agent-detection.ts`). If the result is non-null, set `leaf.lastAgentStatus = result`. If null (title is not a recognizable agent title), leave `lastAgentStatus` untouched — the next live OSC byte will populate it through the normal onPtyData path.
- **MUST NOT** call `resolveTuiIdleWaiters(leaf)` or `deliverPendingMessages(leaf)`. The seeded status reflects the renderer's last-observed historical title, not a live transition; resolving idle waiters or delivering pending messages on stale data could mistakenly unblock orchestration callers that should only react to a fresh prompt-return.
- **Why** (mandatory comment in the helper definition): `// seed-derived status reflects historical state; orchestration waiters must only react to live transitions to avoid resolving on stale data.`
- **Rationale for routing through `detectAgentStatusFromTitle`:** we mirror the live path (`onPtyData` already uses `detectAgentStatusFromTitle`) so seeded and live paths produce the same union value, and downstream `leaf.lastAgentStatus === 'idle'` consumers (orchestration `tui-idle` waiters, pending-message gating) keep working. `leaf.lastAgentStatus` is typed `AgentStatus | null` where `AgentStatus = 'working' | 'idle' | 'permission'`; assigning the raw title string would be type-incorrect.
This delivers status parity for the dominant case via the `lastTitle` field (any pane that has rendered a prompt or had a TUI emit OSC titles will have populated `lastTitleByPtyId` long before mobile subscribes). Edge cases where the renderer never observed a title — fresh shell pre-prompt, pane that mounted but never received bytes — leave `lastAgentStatus` as `null`; that's acceptable since `lastAgentStatus` is documented best-effort and the next live OSC title will populate it through the normal `onPtyData` path.
**Status parity / accepted limitations.** Side effect of seeding `lastAgentStatus`: if the seed sets status to `'idle'`, the next live OSC byte that re-emits the same `'idle'` title will see `prevStatus === 'idle'` and the existing transition gate (`agentStatus === 'idle' && prevStatus !== 'idle'`) skips. Orchestration `tui-idle` waiters registered between seeding and the next idle transition will hang until either (a) a non-idle status arrives followed by another idle transition, or (b) the waiter times out. This is an accepted limitation. If it becomes a real problem, future work could split into `lastSeededAgentStatus` (informational, not gating) vs `lastAgentStatus` (gates transitions).
### Files touched
- `src/main/runtime/orca-runtime.ts` — add `headlessHydrationState`, `maybeHydrateHeadlessFromRenderer`, wire into `onPtyData` (with mandatory `// DO NOT REORDER` comment per "Ordering invariant"). Widen `RuntimePtyController.serializeBuffer?` to `(ptyId, opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null>` and add `hasRendererSerializer?(ptyId)`. Add `applySeededAgentStatus(ptyId, title)` helper that writes `leaf.lastAgentStatus` only — must NOT call `resolveTuiIdleWaiters` or `deliverPendingMessages` (mandatory "Why" comment in the helper definition). Add `hasRendererSerializerFor(ptyId)` runtime-service-level method that delegates to `ptyController.hasRendererSerializer` (consumed by `ipc/pty.ts`'s cooperation gate). Forward `scrollbackRows` from `serializeTerminalBufferFromAvailableState` into the renderer-fallback call (do NOT pass `altScreenForcesZeroRows: true` on this path — see "alt-screen flag scoping").
- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts opts and forwards them on the `pty:serializeBuffer:request` IPC payload. Wire `hasRendererSerializer` through a renderer-registry getter. **Cooperate with hydration:** when a renderer serializer is registered for the spawning PTY *or* the renderer pre-signaled intent for `args.env.ORCA_PANE_KEY`, skip seeding from `result.snapshot` / `result.coldRestore.scrollback` so the daemon's bare-prompt state cannot pre-empt the richer renderer hydration that follows on first byte. **Add three new IPC handlers** with consistent paneKey validation (`typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256` — invalid input is rejected before mutating state): `pty:declarePendingPaneSerializer(paneKey): Promise<number>` (renderer → main, before `pty:spawn`) mints a fresh generation `gen`, stores `pendingByPaneKey.set(paneKey, gen)` in a module-level `pendingByPaneKey: Map<string, number>`, and returns `gen` to the renderer; `pty:settlePaneSerializer(paneKey, gen)` (renderer → main, after `registerPtySerializer`) runs `settlePending(paneKey, gen)` which deletes only if the stored gen still matches; `pty:clearPendingPaneSerializer(paneKey, gen)` (renderer → main, on spawn-rejection / unmount-before-settle) has the same `settlePending` semantics. Hook `paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))` so a torn-down PTY's listener captures the generation it was registered under and only clears the map when that generation still matches — ensuring a teardown for the OLD PTY cannot delete a NEW PTY's pending intent if mount-2's pre-signal landed in between. (See "Renderer pre-signal handshake" and "Cooperation with seedHeadlessTerminal" in Open questions.)
- `src/preload/index.ts` — extend the `pty:serializeBuffer:request` listener payload type to include `opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }`. Add three new bindings under `pty`: `declarePendingPaneSerializer(paneKey: string): Promise<number>` (returns the `gen` token), `settlePaneSerializer(paneKey: string, gen: number): Promise<void>`, and `clearPendingPaneSerializer(paneKey: string, gen: number): Promise<void>`, all forwarded as `ipcRenderer.invoke('pty:declarePendingPaneSerializer', …)` / `'pty:settlePaneSerializer'` / `'pty:clearPendingPaneSerializer'` so the renderer can `await` them and rely on main-side ordering before issuing `pty:spawn` / after `registerPtySerializer`. The renderer captures `gen` from declare and threads it through every settle/clear call.
- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — widen `SerializeFn` to `(opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`; default to today's full-buffer behavior when opts is absent. Convert `serializersByPtyId: Map<ptyId, SerializeFn>` to `Map<ptyId, { fn: SerializeFn, owner: symbol }>` and have `registerPtySerializer` mint a fresh owner symbol per call, returning an unregister closure that deletes only when `current?.owner === thisOwner` (StrictMode safety — see "Renderer-side prerequisite"). Add a parallel `lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }>` populated by a wrapper around `pane.terminal.onTitleChange`, with the unregister closure both disposing the IDisposable AND deleting the entry under the same owner-token check. The serialize listener reads from `lastTitleByPtyId` to populate `lastTitle` in the IPC response.
- `src/renderer/src/components/terminal-pane/pty-connection.ts` — register the serializer for **fresh spawns** as well as reattach. Extract the registration body into a shared `registerPaneSerializerFor(ptyId)` helper called from both `handleReattachResult` AND the post-spawn callback in `startFreshSpawn`. Thread opts (scrollback cap, alt-screen flag) into `pane.serializeAddon.serialize`. Add `if (disposed) return` guard at the top of every post-spawn registration call (StrictMode safety — see "Renderer-side prerequisite"). Track the latest `pane.terminal.onTitleChange` value in module state alongside the serializer registration so the serializer's IPC response can include `lastTitle` (see "Status parity"). Call `const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)` BEFORE `pty:spawn` on every fresh-spawn path (capturing the returned generation token), and `settlePaneSerializer(paneKey, gen)` AFTER `registerPaneSerializerFor(ptyId)` succeeds. On spawn-rejection (catch-arm) AND on pane unmount AFTER pre-signal but BEFORE settle, call `clearPendingPaneSerializer(paneKey, gen)` — this is mandatory (see "Renderer-side prerequisite" item 3). Unregister on pane unmount via the existing `onDataDisposable.dispose` hook (now using the ownership-token contract from "Renderer-side prerequisite").
- `src/main/runtime/scrollback-limits.ts` (new) — main-process module exporting `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000`. Imported by both `src/main/runtime/rpc/methods/terminal.ts` and `src/main/runtime/orca-runtime.ts`. (The constant is main-process-only; promoting it to `src/shared/` would expose a runtime cap to the renderer for no benefit.)
- `src/main/runtime/orca-runtime.test.ts` — add hydration unit tests (see "Test plan").
- `src/main/runtime/mobile-subscribe-integration.test.ts` — extend with cross-process hydration scenario.
No changes to:
- `pty:data` batching, daemon adapter.
- `serializeTerminalBufferFromAvailableState` priority order (headless still primary; renderer still no-headless fallback).
- Mobile RPC method signatures (`terminal.subscribe`, `terminal.resizeForClient`).
- Mobile WebView / `TerminalWebView.tsx`.
- Persistence (`HistoryManager`, `checkpoint.json` shape).
## Why this is structurally sound
The renderer xterm is, in practice, the fuller and more authoritative state for any mounted pane:
1. **It hydrates on attach.** `pty-connection.ts` calls `replayIntoTerminal(pane, …, coldRestore.scrollback)` and writes `snapshot` immediately after pane mount. The headless emulator never replays either. So even on a session that started before the current Orca run, the renderer xterm contains the full history; the headless emulator does not.
2. **It has a bigger buffer.** 50,000 rows vs 5,000.
3. **It is updated on the same IPC tick as the visible UI.** No staleness vs what the user sees.
Hydrating the headless emulator from the renderer on first runtime touch is the same shape of operation as the existing `seedHeadlessTerminal(provider.spawn snapshot)` path. We are not introducing a new architectural pattern; we are extending an existing one to the case where the daemon snapshot is empty but the renderer is rich. The runtime remains authoritative for everything mobile, CLI, `terminal.read`, and `lastAgentStatus` see — the renderer is consulted exactly once per `(ptyId, runtime)` pair.
This is a structural-adjacent improvement that fixes the dominant case (user has both desktop and mobile open) while consciously deferring Scenario A (mobile-only after relaunch) to a separate daemon-persistence design. It is honest about what it does and does not solve.
## Data flow paths
### Happy path — renderer mounted, hydration succeeds
```
T0 user opens desktop pane.
- Fresh spawn: pty-connection.ts calls
const gen = await pty:declarePendingPaneSerializer(paneKey)
BEFORE pty:spawn, then awaits both. Main mints a fresh gen and
records the pending intent under it. After spawn resolves with
ptyId, registerPaneSerializerFor(ptyId) +
pty:settlePaneSerializer(paneKey, gen) clear the intent (only if
the stored gen still matches). On spawn-rejection or unmount-
before-settle, pty:clearPendingPaneSerializer(paneKey, gen) runs
instead (mandatory — see "Renderer-side prerequisite" item 3).
- Reattach: registerPaneSerializerFor(ptyId) runs in
handleReattachResult once the ptyId is known.
Either way, ipc/pty.ts's cooperation gate sees that the renderer
is or will be authoritative for this PTY (via either
hasRendererSerializerFor(ptyId) === true OR a pre-signaled paneKey)
and SKIPS the daemon-snapshot seedHeadlessTerminal call.
T1 PTY emits live byte → ipc/pty.ts forwards to renderer (xterm grows)
→ ipc/pty.ts forwards to runtime.onPtyData
T2 onPtyData: maybeHydrateHeadlessFromRenderer
- hasRendererSerializer(ptyId) === true → state: absent → pending
- serialize round-trip with opts {scrollbackRows, altScreenForcesZeroRows}
- on resolve: HeadlessEmulator built at rendered.cols/rows,
applySeededAgentStatus(ptyId, rendered.lastTitle), write(rendered.data)
- resize emulator to PTY dims if they diverge from rendered dims
T3 trackHeadlessTerminalData(data) chains AFTER the seed via writeChain,
so the live byte is appended on top of the seeded buffer — order
preserved.
T4 hydration state → done. Subsequent onPtyData calls skip the guard.
T5 user opens mobile → terminal.subscribe → serializeHeadless returns
seeded buffer + everything since. Mobile == desktop, and
leaf.lastAgentStatus is populated from rendered.lastTitle
(sourced from pane.terminal.onTitleChange, NOT from the seed payload —
SerializeAddon does not round-trip OSC titles).
```
### Nil path — Scenario A: no renderer ever mounts
```
T0 user relaunches Orca, opens mobile only.
T1 ipc/pty.ts: provider.spawn returns coldRestore.scrollback = ""
(daemon checkpoint had scrollbackAnsi: "" — visible screen only)
or a thin visible-screen snapshot. hasRendererSerializerFor(ptyId)
is false (no pane mounted), so the cooperation gate does NOT skip:
seedHeadlessTerminal runs as today.
T2 PTY emits live byte → onPtyData → maybeHydrateHeadlessFromRenderer
- hasRendererSerializer(ptyId) is false → state map left absent.
(Pane could still mount later in this runtime; we'd retry then.)
T3 trackHeadlessTerminalData populates from live bytes; emulator
already contains the daemon-snapshot seed.
T4 Mobile sees daemon's visible-screen snapshot + live bytes since.
KNOWN LIMITATION; out of scope for this design.
```
### Error path — renderer throws or unmounts mid-hydration
```
T0T1 same as happy path through `pending` state.
T2 ptyController.serializeBuffer rejects (renderer disposed,
channel closed, IPC timeout, etc.).
- catch swallows the error.
- hydration state → done (we do not loop on every byte).
T3 Live bytes continue to populate the emulator via
trackHeadlessTerminalData (which lazily creates the headless
state at PTY dims). We lose the seed for this runtime but do
not block live data flow.
```
The "advance to done on serialize failure" rule is intentional. Re-trying on every subsequent byte would create a renderer round-trip on every byte until success — too costly for a best-effort path. The "leave state absent when the renderer isn't ready yet" rule (the precondition checks before `pending`) is the inverse trade-off: those checks are cheap (Map lookups), so retrying them is fine.
### SSH note
SSH-backed PTYs flow through the same `runtime.onPtyData` entry point as local and daemon-backed PTYs (see `ipc/pty.ts` provider listener — all providers call `runtime.onPtyData(id, data, at)` uniformly). Hydration applies to SSH PTYs without special-casing: when a desktop pane is mounted on an SSH-backed terminal, its `registerPtySerializer` registration is identical to the local case, and the runtime sees `hasRendererSerializer(ptyId) === true` regardless of provider. The mobile + SSH + relaunch case (rare in practice) inherits Scenario A's known-limitation behavior.
### Concurrent path — live data arrives during hydration
The renderer round-trip is asynchronous; live `pty:data` will continue to arrive before `serializeBuffer` resolves. The pattern from `seedHeadlessTerminal` already handles this: every emulator write is appended to `state.writeChain`, so:
```
writeChain: Promise.resolve()
→ seed write ← scheduled by maybeHydrateHeadlessFromRenderer
→ live byte 1 ← scheduled by trackHeadlessTerminalData
→ live byte 2
→ live byte N
```
`emulator.write` is invoked in chain order, preserving the byte sequence exactly as the wire delivered it. The seed always lands before the live bytes that triggered hydration, because the trigger logic schedules the seed write *before* it returns control to `onPtyData`, which then schedules the live write.
## Performance
`ptyController.serializeBuffer(ptyId)` does an IPC round-trip to the renderer where xterm's SerializeAddon runs synchronously over the buffer. Cost is roughly proportional to scrollback size: empirically ~50200 ms for a full 50k-row buffer.
The cost profile is materially different from the rejected priority-flip approach:
| Approach | Renderer round-trip frequency |
|---|---|
| Priority flip (rejected) | Once per `terminal.subscribe` AND `terminal.resizeForClient` |
| Hydrate-on-first-touch (this) | Once per `(ptyId, runtime)` pair, ever |
For a typical session — Orca starts, user opens desktop pane, agent runs for hours, user opens mobile periodically — this approach pays the IPC cost exactly once *for hydration* per `(ptyId, runtime)` pair. The existing renderer-fallback inside `serializeTerminalBufferFromAvailableState` is preserved — it can still hit the renderer on `terminal.read` / `terminal.subscribe` when the headless emulator returns null — but that path is the read-time fallback, not the hydration path. The priority-flip approach paid the IPC cost on every mobile re-subscribe (foreground/background, navigation between worktrees, phone-fit toggle). The "first byte after pane mount" trigger ties the hydration cost to a moment that is already doing IO work, so the latency is hidden inside the existing flow.
There is no per-keystroke cost; `trackHeadlessTerminalData` is unchanged.
## Reflow asymmetry
Hydration happens at whatever cols the renderer is currently at — typically the desktop's natural size (e.g., 105 cols). When mobile later subscribes and triggers a 49-col phone-fit resize, the headless emulator (now seeded with 105-col data) reflows to 49 cols just like the renderer would. Both buffers reflow from the same starting content, so they stay in sync.
This is a real improvement over the priority-flip proposal, which was sensitive to the order in which renderer and headless reflow completed across the IPC tick boundary. With hydrate-on-mount the seed has already landed before any phone-fit resize is even requested, so the reflow asymmetry concerns largely go away: at subscribe time both buffers contain the same content and respond to the same resize event in the same `handleMobileSubscribe` synchronous block.
## Risks
1. **Live data arrives during pending hydration.** Mitigated by chaining through `state.writeChain`, exactly as `seedHeadlessTerminal` already does. Order is preserved.
2. **Renderer pane unmount mid-round-trip.** Caught by `try/catch`; the guard advances to `done` so we don't loop on every subsequent byte, and live writes continue via lazy emulator creation in `trackHeadlessTerminalData`. We lose the seed for that PTY, which is acceptable given how rare unmount-during-IPC is.
3. **Wire-payload size on hydration.** Capped at 1000 rows from day one via the shared `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` constant. Alt-screen forces 0 rows. This matches the existing serialize path's behavior, so users see identical depth from both sources. The headless emulator's own 5,000-row `DEFAULT_SCROLLBACK` (in `HeadlessEmulator`) is comfortably above the 1k hydration cap; if a future change raises the hydration cap, `HeadlessEmulator`'s default must be bumped in lockstep or the seed will be silently truncated as it lands in the emulator.
4. **Renderer xterm is *behind* the runtime.** Theoretically possible if `pty:data` is delivered to the runtime before the renderer (depends on dispatch order in `ipc/pty.ts`). The renderer's xterm uses an asynchronous `write(callback)`, so even when bytes are delivered the buffer may not yet contain them. In practice this is sub-millisecond and the seed will be at most one frame behind the live byte that triggered it — better than today's headless-only path which has none of those bytes.
5. **Hydration races registerPty.** If `onPtyData` fires before `runtime.registerPty` has been called for this PTY, `getTerminalSize` may return null and we default to 80×24 for the post-seed PTY dims. The seed itself is constructed at the renderer's reported dims (see Pseudocode), so xterm reflow only happens on the post-seed `resize` call — at most one reflow, and only when PTY and renderer dims actually diverge.
6. **PTY-size vs renderer-dim divergence (resize-after-seed reflow).** Under mobile-fit overrides, or in the brief window before a `pty:resize` lands on the main process, the renderer's xterm dims and the runtime's `ptySizes` entry can disagree. Constructing the headless emulator at PTY-size and then writing a seed captured at renderer-dim would force xterm to wrap/reflow the seed in the wrong column count — visible as broken line breaks or doubled prompts. Mitigation: build the emulator at `rendered.cols / rendered.rows`, then resize to PTY-size after the seed lands. See Pseudocode. **At extreme size deltas (e.g., 105→32), the seed-then-reflow path can produce minor visible divergence vs the renderer's own reflow** (xterm reflow is not strictly invertible across very different widths, and the headless emulator runs the reflow on already-laid-out cells rather than the original byte stream). This is an accepted limitation, not a correctness issue: the reflow result is still valid xterm output, just potentially line-broken slightly differently than what the desktop pane shows for the same scrollback. Mobile users have always seen a phone-fit reflow distinct from desktop's; this design does not change that.
7. **Note: writes are flushed before serialize.** The renderer-side serializer (`pty-buffer-serializer.ts:registerPtySerializer`) flushes `pendingWritesRef` via `replayIntoTerminalAsync` before calling `pane.serializeAddon.serialize()`, so the seed faithfully includes hidden-pane buffered writes. No additional sequencing logic is needed in the runtime.
8. **`pendingWritesRef` double-write window (severity-adjusted P2).** When a pane is hidden at the moment bytes arrive, the renderer-side `pendingWritesRef` buffers them while `runtime.onPtyData` simultaneously routes the same bytes into the headless emulator via `trackHeadlessTerminalData`. The renderer's serializer flushes `pendingWritesRef` before serializing, so the seed payload includes those bytes. If hydration runs after `trackHeadlessTerminalData` has already written the same bytes, the headless emulator ends up with a duplicated stretch.
**Mitigation: the P0-A2 pre-signal handshake makes this rare in practice.** With pre-signal, hydration is the FIRST writer for a fresh-spawn-with-pre-signal — `trackHeadlessTerminalData`'s lazy-create check sees the emulator already exists (created at hydration time) and just appends, so live bytes between seed-fetch and seed-resolve are queued behind the seed via `state.writeChain`. The double-write only manifests in the narrow combo of: (a) reattach path, (b) hidden pane buffering live bytes into `pendingWritesRef`, AND (c) hydration runs after `trackHeadlessTerminalData` has already populated the headless emulator. The impact is bounded: at most one ~8 ms PTY-batch's worth of bytes can be duplicated, the duplicate is visible only in mobile/CLI scrollback (desktop renders from its own xterm which never duplicates), and a future shell prompt overwrites the area anyway. Document as a known limitation; not a correctness-blocking issue. If it becomes user-visible, the fix is to track a per-pty "live bytes seen since last serialize request" cursor on the renderer and trim the seed payload to bytes the renderer observed before hydration started — out of scope for this design.
## Test plan
### Unit (`orca-runtime.test.ts`)
- Hydration runs on the *first* `onPtyData` for a `ptyId` when `hasRendererSerializer` is true and `serializeBuffer` returns non-empty data. Assert the emulator's serialized output contains both the seeded prefix and the live byte.
- Subsequent `onPtyData` calls do **not** re-trigger hydration after a successful run. Mock `serializeBuffer` and assert it was called exactly once across N `onPtyData` invocations for the same ptyId.
- "Exactly once" claim is qualified: the renderer round-trip happens once per `(ptyId, runtime)` for hydration. The existing `serializeTerminalBufferFromAvailableState` renderer-fallback (consulted only when the headless emulator returns null) is preserved and may still hit the renderer on `terminal.read` / `terminal.subscribe` paths.
- When `ptyController` is null (CLI / headless test runs), hydration leaves the state map untouched, headless emulator is created lazily as today, no IPC attempt.
- When `hasRendererSerializer(ptyId)` returns false on the first byte but true on a later byte, hydration **does** run on the later byte (state is left absent on idle, not consumed). Confirms the contract change for Open Question P2-2.
- When `ptyController.serializeBuffer` rejects, the guard advances to `done` (no infinite retry), the emulator is created lazily by `trackHeadlessTerminalData`, live bytes still write through.
- Live data arriving during pending hydration is queued behind the seed write. Schedule the seed-write as a long pending promise, fire several `onPtyData` calls, await `writeChain`, assert the final emulator content has the seed prefix followed by the live bytes in correct order.
- The 1000-row cap is applied to the renderer-sourced hydration: mock `serializeBuffer` to return a payload representing >1000 rows, assert the requested option bag includes `scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS`.
- Alt-screen flag is forwarded: assert the request includes `altScreenForcesZeroRows: true`. (The alt-screen branch is exercised in renderer-side serializer tests where it has access to a real xterm `buffer.active.type`.)
- **Status parity (lastTitle field):** mock `serializeBuffer` to return `{ data, cols, rows, lastTitle }` where `lastTitle` is a recognizable agent title. Fire `onPtyData`. Assert `leaf.lastAgentStatus` is one of `'idle' | 'working' | 'permission'` (whatever `detectAgentStatusFromTitle` returns for that title) after hydration and before any live OSC title bytes are observed. Conversely, when `lastTitle` is absent / undefined OR `detectAgentStatusFromTitle(lastTitle)` returns `null` (unrecognizable title), assert `leaf.lastAgentStatus` is left unchanged from its prior value (no overwrite).
- **Multi-PTY concurrent hydration:** fire `onPtyData` for ptyA and ptyB before either `serializeBuffer` resolves. Assert each PTY's hydration completes with its own payload (no cross-talk through the IPC response listener pool — each request has a unique `requestId`).
- **Re-entrant `onPtyData`:** when `agentDetector.onData` synchronously triggers another `onPtyData` for the same ptyId (or a different ptyId), confirm the hydration state machine still holds — no double-pending, no skipped seed, no out-of-order emulator writes.
- **Cooperation with `seedHeadlessTerminal`:** when a renderer serializer is registered for `ptyId`, the spawn-time call from `ipc/pty.ts` skips seeding (no daemon-snapshot bytes written to the emulator). When no serializer is registered, the daemon-snapshot path runs as today.
- **Fresh-spawn timing race (P0-A2 pre-signal handshake):** simulate the renderer pre-signaling intent for paneKey via `const gen = await pty:declarePendingPaneSerializer(paneKey)`, then call the `pty:spawn` handler with `args.env.ORCA_PANE_KEY === paneKey`. Assert `seedHeadlessTerminal` is **NOT** invoked even though `hasRendererSerializerFor(result.id)` is false at that moment (the renderer hasn't received the ptyId yet). The renderer mock then `registerPtySerializer(ptyId, …)` and `pty:settlePaneSerializer(paneKey, gen)`. Fire `onPtyData(ptyId, liveByte)`. Assert `maybeHydrateHeadlessFromRenderer` runs and the headless emulator is populated from the renderer's serialize payload, with the live byte appended after the seed via `writeChain`.
- **Pre-signal without settle (renderer crash):** simulate `pty:declarePendingPaneSerializer(paneKey)` followed by `pty:spawn` (gate skips daemon seed) but the renderer never calls `registerPtySerializer` or `settlePaneSerializer`. Fire `onPtyData`. Assert hydration's preconditions leave the state map absent, `trackHeadlessTerminalData` lazy-creates the emulator, and live bytes populate it. The pending-paneKey entry is dropped on the next paneKey teardown event.
- **Pre-signal preserved across paneKey reuse during teardown:** simulate (a) old PTY for paneKey K is exiting, its `paneKeyTeardownListener` is queued (captured at registration time with `gen1`). (b) Mount-2 calls `pty:declarePendingPaneSerializer(K)` and stores generation `gen2` (`gen2 > gen1`). (c) The queued teardown for the old PTY runs; the listener invokes `settlePending(K, gen1)` and SKIPS the delete because `pendingByPaneKey.get(K) === gen2`. (d) Mount-2 calls `pty:spawn`; the cooperation gate matches (`pendingByPaneKey.has(K) === true`) and seed is suppressed. Assert daemon-snapshot seed did NOT run.
- **Test imports for `scrollback-limits.ts`:** tests in `orca-runtime.test.ts` and `mobile-subscribe-integration.test.ts` import `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` from `../scrollback-limits` so cap-assertion lines match the production constant (no hardcoded `1000`).
- **Status parity (real `SerializeAddon` round-trip):** in a renderer-side unit test (using a real `xterm` `Terminal` + `SerializeAddon`), write OSC 0/1/2 title bytes (e.g., `\x1b]0;some-title\x07`) into the terminal, await the `onTitleChange` listener, then call `serializeAddon.serialize()`. **Assert the serialize output does NOT contain the OSC title bytes** — this codifies the limitation that motivated the `lastTitle` field. Then, in a runtime-side unit test, mock the renderer-IPC response to include `{ data, cols, rows, lastTitle }` where `lastTitle` is a known agent title, fire `onPtyData`, and assert `applySeededAgentStatus` was called with that title and `leaf.lastAgentStatus` is the corresponding `'idle' | 'working' | 'permission'` value returned by `detectAgentStatusFromTitle` afterward.
- **`applySeededAgentStatus` does not resolve waiters:** with a leaf that has pending TUI-idle waiters and pending orchestration messages registered, call `applySeededAgentStatus(ptyId, title)` where `title` is a recognizable agent title. Assert `leaf.lastAgentStatus` matches `detectAgentStatusFromTitle(title)` (one of `'idle' | 'working' | 'permission'`), AND assert `resolveTuiIdleWaiters` and `deliverPendingMessages` were **NOT** invoked. (Spy on the methods or assert the waiter / pending-message queues are unchanged.) This guards against future drift where a refactor lifts shared logic between the live and seeded paths.
### Integration (`mobile-subscribe-integration.test.ts`)
- Cross-process scenario with a mocked renderer that registers a richer scrollback than the headless adapter would produce. After the runtime sees its first live byte, mobile subscribes; assert the `scrollback` frame's `serialized` field includes the seeded prefix.
- Scenario A regression guard: no renderer registered, mobile subscribes, assert mobile receives only what the daemon snapshot + live bytes provide, hydration was never invoked. (Documents the known limitation.)
### Manual
On a real device, reproduce the original `edit-issues` post-claude-exit scenario, confirm the mobile WebView now shows the same agent summary the desktop shows.
## Open questions
- **Hydration trigger point.** Three plausible options: (a) first `onPtyData`, (b) `runtime.registerPty`, (c) an explicit renderer-pane-mount notification from the renderer. (a) is the chosen approach: cheap, ties cost to a moment already paying IO, and **retryable within a runtime**. The state map is left absent (not `done` or `skipped`) when the renderer serializer isn't yet registered, so a later byte can attempt hydration once the pane mounts. The only consume-on-idle case is `headlessTerminals.has(ptyId)` (live bytes already arrived), where re-seeding would duplicate. (b) fires before the renderer has any data to serialize. (c) needs a new wire from `pty-connection.ts → ipc/pty.ts → runtime`.
- **Cooperation with `seedHeadlessTerminal`.** Today `seedHeadlessTerminal` writes to the headless emulator on `provider.spawn` from `result.snapshot` / `result.coldRestore.scrollback`. The daemon's `result.snapshot` is the visible-screen-only ANSI from `checkpoint.json` — almost always non-empty (it includes the bare prompt) but typically much thinner than what the renderer pane has. If both fire for the same PTY, the daemon path lands first and `headlessTerminals.has(ptyId)` becomes true; the hydration guard would then mark itself `done` and the richer renderer payload is lost. **Decision: gate `seedHeadlessTerminal` on (a) an already-registered renderer serializer OR (b) a renderer pre-signal for the spawning PTY's paneKey.** In `ipc/pty.ts`, before calling `runtime.seedHeadlessTerminal(...)`, check both `runtime.hasRendererSerializerFor(result.id)` AND `pendingByPaneKey.has(args.env?.ORCA_PANE_KEY)` (after validating the paneKey shape — see P1-B2 in the cooperation gate snippet). If either is true, skip the daemon-snapshot seed entirely and let renderer hydration be canonical. (a) covers reattach, where the pane mounted before spawn returned. (b) covers fresh spawn, where the renderer pre-signaled intent before `pty:spawn` but cannot have registered yet because it doesn't know the ptyId. If neither is true (Scenario A — mobile-only, no desktop pane), the daemon-snapshot path runs unchanged. See "Renderer pre-signal handshake" for the full sequence and correctness properties. This is cleaner than a length-comparison "richness heuristic" and lets the renderer be authoritative whenever it's mounted *or* about to mount.
## Rollout
Single change touching the runtime and the renderer-side serialize controller. No feature flag — the hydration is best-effort and degrades to today's behavior when the renderer cannot answer or has nothing useful to share.
Ready for implementation review. Scenario A remains a known limitation tracked for a future daemon-persistence design.
-698
View File
@@ -1,698 +0,0 @@
# Mobile Presence Lock for Desktop Terminal
Design doc for repurposing the existing "phone-fit" banner into a general
presence-based interaction lock between the desktop renderer and mobile
clients sharing a single PTY.
## Problem
The desktop and mobile clients share full read/write access to the same
underlying PTY. There is no synchronization between desktop's xterm `onData`
(which calls `provider.write(ptyId, bytes)` via the `pty:write` IPC) and
mobile's `terminal.send` RPC (which also calls `provider.write` through the
runtime). When both parties type at the same time, bytes interleave at the
TTY level — `ls<enter>` and `pwd<enter>` typed concurrently can produce
`lpws<enter>d<enter>` and execute unintended commands.
The same is true for resize: desktop `pty:resize` and mobile
`terminal.resizeForClient` both reach `provider.resize(ptyId, ...)` without
coordination. The renderer-side `safeFit` has a partial guard, but the
`pty:resize` IPC handler does not check the override server-side.
A teammate using the original mobile build reported "odd behavior using
laptop + mobile at the same time." The most visible symptom is the desktop
terminal jumping to phone dimensions; the more dangerous symptom is silent
input interleaving.
## Today's banner
The existing banner in `TerminalPane.tsx` is keyed on
`getFitOverrideForPty(ptyId)`:
- It only appears when mobile is in `auto`/`phone` display mode AND a phone
resize actually happened.
- It says "Terminal resized for phone (W×H)" with a "Restore" button that
calls `runtime:restoreTerminalFit(ptyId)`.
- That IPC handler sets the display mode to `desktop` and applies the
display mode, which resizes the PTY back to desktop dims and clears the
override. The banner unmounts.
In `desktop` display mode (mobile is subscribed but viewing at desktop dims)
there is no override and no banner — the desktop user has no idea mobile is
watching.
## Goal
Repurpose the banner into a general presence-based lock with this single
rule:
> At any moment, each PTY has exactly one _driver_ — desktop, a specific
> mobile client, or nobody. While mobile drives, desktop input and resize
> are dropped; the desktop banner explains why and offers **Take back** to
> hand the floor over. While desktop drives, mobile keystrokes silently
> reclaim the floor (no mobile-side banner — see "Asymmetric UX" below).
The lock exists at two layers:
- **Renderer guard** (primary UX): `pty-connection.ts` drops xterm `onData`
and `onResize` while a mobile client is the driver. Banner explains why.
- **Server-side defense** (defense-in-depth): the `pty:write` and
`pty:resize` IPC handlers consult the runtime's driver state and drop
desktop-side calls while a mobile client is the driver.
## Behavioral model
### Driver state machine
Instead of a binary lock, each PTY carries a tagged driver state. There is
exactly one driver per PTY at any moment, and transitions are atomic
(updated and emitted together from the runtime).
```ts
type DriverState = { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string }
```
- `idle` — no mobile subscribers; desktop input and resize flow through
normally. (We don't bother distinguishing "no one is here" from "only
desktop is here" since they behave identically; `idle` is the catch-all
for "nothing is currently locking the desktop out.")
- `desktop` — at least one mobile client is subscribed but the desktop has
reclaimed the floor; desktop input and resize flow through.
- `mobile{clientId}``clientId` is the mobile actor that most recently
drove this PTY. The banner is mounted on desktop; desktop input and
resize are dropped.
Invariants:
- `currentDriver(ptyId)` is always exactly one of the three kinds.
- On the main process, a transition mutates `currentDriver(ptyId)` and
emits `terminalDriverChanged(ptyId, driver)` in the same critical
section. The renderer's mirror in `mobile-driver-state.ts` is updated
only when the IPC arrives — there is a brief IPC-hop window during
which the renderer's `getDriverForPty(ptyId)` returns a stale value.
This is why the server-side `pty:write` / `pty:resize` defenses are
**load-bearing, not redundant**: a desktop keystroke fired during
that window passes the renderer guard and is dropped by the server
guard.
- Only one driver per PTY at any moment. Multiple mobile clients can
_subscribe_ simultaneously (see "Multi-mobile subscriber model"), but
only the most recent mobile actor is the driver.
### Transitions
| Current driver | Trigger | Next driver | Side effect |
| -------------- | ---------------------------------------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------- |
| `idle` | mobile subscribes with `displayMode='auto'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims |
| `idle` | mobile subscribes with `displayMode='desktop'` (first client for this ptyId) | `desktop` | inner subscriber map populated; **no** banner; PTY stays at desktop dims |
| `idle` | desktop input or first PTY data after no subscribers | `idle` (no transition) | — |
| `mobile{A}` | desktop clicks **Take back** | `desktop` | banner unmounts; PTY snaps to desktop dims if at phone dims |
| `mobile{A}` | mobile A sends input/resize/setDisplayMode | `mobile{A}` (no transition) | — |
| `mobile{A}` | mobile B sends input | `mobile{B}` | (no banner change; both are "mobile") |
| `mobile{A}` | last mobile client unsubscribes | `idle` | banner unmounts |
| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto |
| `desktop` | mobile sets `displayMode` to `auto` or `phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture) |
| `desktop` | mobile sets `displayMode` to `desktop` | `desktop` (no transition) | — (already desktop-mode watching) |
| `desktop` | mobile subscribes-fresh with `auto`/`phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims |
| `desktop` | mobile subscribes-fresh with `desktop` | `desktop` (no transition) | inner map updated; no banner |
| `desktop` | last mobile client unsubscribes | `idle` | (banner already unmounted) |
**Subscribe-in-desktop-mode rule.** A mobile client subscribing in
`displayMode='desktop'` is treated as a passive watch, not a take-floor
gesture. The driver stays at `idle`/`desktop`, so the desktop user is not
interrupted. The instant that client (or any peer) sends input, sets the
display mode to `auto`/`phone`, or sends a resize, the runtime transitions
to `mobile{thatClient}` and the banner appears. This matches the rest of
the design: the lock engages on _interaction_, not on _presence_.
The protocol is "first-mover wins until the other party acts." Desktop
clicks Take back, mobile types, desktop types again — banner ping-pongs as
each side acts. The `clientId` carried in `mobile{clientId}` is updated
each time a mobile actor takes the floor; this is the wire channel by
which the runtime knows _which_ phone last drove (useful for the
forward-path coordinator described at the bottom of this doc, and for the
multi-mobile semantics described below).
### Take back and PTY dimensions
Take back has two sub-cases:
1. **Mobile was in `phone`/`auto` mode (PTY at phone dims)**. Take back
resizes PTY back to desktop dims (existing behavior of
`applyMobileDisplayMode('desktop')` when `wasResizedToPhone` is true).
2. **Mobile was in `desktop` mode (PTY already at desktop dims)**. Take
back is a pure lock-flag flip; no resize. Today's
`applyMobileDisplayMode('desktop')` already short-circuits the resize in
this case.
In the symmetric direction, mobile reclaim:
1. **Mobile is in `phone`/`auto` mode but PTY is at desktop dims** (because
desktop just clicked Take back): mobile reclaim re-applies the phone
resize via `applyMobileDisplayMode(currentMode)`.
2. **Mobile is in `desktop` mode**: pure lock-flag flip; no resize.
## End-user UX
| Driver | Overlay (desktop) | Desktop input | Desktop resize |
| ----------- | ------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------- |
| `idle` | hidden | allowed | allowed |
| `mobile{*}` | loud lock overlay with **Take back** and **Collapse**; collapsed chip keeps **Take back** visible | blocked at xterm.onData (silent drop) | blocked in renderer + dropped in `pty:resize` IPC server-side |
| `desktop` | hidden | allowed | allowed |
Walkthroughs:
**Phone connects while you're typing.** Overlay appears. PTY may resize to
phone dims (existing). Your next keystroke is dropped. Click Take back to
unlock and (if needed) restore desktop dims.
**You click Take back.** Overlay gone. You type freely. Mobile stays
connected; mobile sees the desktop-sized terminal because PTY snapped back
(or stayed at desktop dims if mobile was in desktop mode).
**Mobile types something while you're reclaimed.** Overlay reappears. Your
next keystroke is blocked. PTY may snap back to phone dims (if mobile is in
auto mode).
**Mobile disconnects.** The active-driver overlay is gone. If the PTY is
still held at phone dimensions, the held-fit Restore overlay remains until
desktop restores or the finite auto-restore timer fires.
**Multiple panes.** Driver state is per-pane. Phone on pane A doesn't
affect pane B.
**Text selection / scrollback / copy.** Always allowed. The lock is
keystroke and resize only.
**Output continues to render.** While the overlay is mounted, terminal
output streams to xterm normally — it is the mobile actor's bytes you
are seeing. Only desktop-side keystrokes and resize are dropped. This
matters because "your input is paused" can read ambiguously; output
flow is unaffected.
## Asymmetric UX (accepted tradeoff)
Mobile sees no banner in this PR. The mobile UI is visually unchanged: no
"Desktop is driving" indicator, no analog to the Take back affordance.
This is asymmetric and we are accepting it deliberately:
- **Smaller mobile UI surface.** Mobile already has limited screen real
estate and a constrained component set; introducing a presence banner
there is a non-trivial design + i18n + dismissibility task that we want
to defer until the lock model has settled in production.
- **Faster ship.** Driver state machine + multi-mobile subscriber fix +
desktop banner is the smallest change that fixes the dangerous collision
(silent input interleaving). Adding a mobile-side banner doubles the UI
scope.
- **Mobile reclaim is naturally signaled.** When a mobile user types
while desktop drives, the runtime flips the driver to
`mobile{thatClient}`, the desktop banner remounts, and (if mobile is in
auto mode) the PTY snaps back to phone dims. The mobile user sees
the pane reflow and their keystrokes appear in the output stream. There
is no silent black-hole condition on mobile that a banner would
resolve.
- **Mobile keystrokes always reclaim implicitly.** The protocol on mobile
is "just type to take the floor." There is no mobile-side button to
surface; a banner with no actionable control is closer to noise than
signal.
The flip side: a mobile user typing into a stale view _won't_ be told
"hold on, desktop is driving" before their keystroke lands. They just take
the floor. We consider that acceptable because (a) they're explicitly
acting, and (b) the desktop user gets the warning side of the tradeoff
where the risk of unintended action is much higher.
If usage data later shows mobile users frequently being surprised by
desktop activity, a mobile banner can be added without changing the wire
format — `terminalDriverChanged(ptyId, driver)` already carries everything
needed.
## Architecture
### Where the truth lives
The `mobileSubscribers` map (re-keyed; see "Multi-mobile subscriber model"
below) plus a new `currentDriver: Map<ptyId, DriverState>` on the runtime
are the source of truth. The renderer learns the driver state via a new
IPC event `terminal-driver-changed(ptyId, driver: DriverState)` emitted
from the runtime through the existing notifier path. The structured
payload is intentional: a binary `locked: boolean` would lose the
`clientId` we need for multi-mobile semantics today and for the unified
write coordinator on the forward path.
The runtime exposes `getDriver(ptyId): DriverState`, replacing the old
`isLocked(ptyId): boolean`. Server-side `pty:write` / `pty:resize`
defenses check `runtime.getDriver(ptyId).kind === 'mobile'` to drop
desktop-side calls.
A new renderer module `mobile-driver-state.ts` mirrors
`mobile-fit-overrides.ts`: keyed by ptyId, supports
`getDriverForPty(ptyId): DriverState` and a subscribe-style change
listener. The `TerminalPane` banner mounts when
`getDriverForPty(ptyId).kind === 'mobile'`, and `pty-connection`'s
`onData`/`onResize` guards drop input/resize under the same predicate.
### Why not reuse `getFitOverrideForPty`?
The fit override only fires when the PTY was actually resized
(`mode='auto'` and `wasResizedToPhone=true`). It misses the
desktop-mode case where mobile is subscribed but no resize happened. The
driver state is broader than the fit override.
### Why a new IPC event vs extending `terminal-fit-override-changed`?
Cleaner separation. The fit override is about dimensions; the driver
state is about ownership. They happen to overlap in the auto/phone
subcase, but conflating them long-term ties future work on either to the
other.
### Why a structured payload vs `locked: boolean`?
The runtime needs to know _which_ mobile client most recently drove (for
multi-mobile semantics today and for the future write coordinator). A
structured `DriverState` payload makes that information first-class on
the wire instead of smuggling it through a side channel. The cost is ~70
LoC over a binary lock; the payoff is a symmetric UX foundation and a
future-proof wire format.
## Multi-mobile subscriber model
The `mobileSubscribers` map is today shaped `Map<ptyId, Subscriber>` and
each `terminal.subscribe` call overwrites the previous subscriber for that
ptyId. That overwrite is fine for a binary `has(ptyId)` presence check but
breaks the moment we treat presence as a real set: phone B subscribing
silently evicts phone A, so phone A unsubscribing or its WS dropping looks
like "no mobile clients" even while phone B is still on the line. The
driver state machine sits directly on top of presence, so soundness here
is a prerequisite, not a polish.
We rekey to `Map<ptyId, Map<clientId, Subscriber>>` and update each
callsite:
- **`handleMobileSubscribe(ptyId, clientId, subscriber)`** — get-or-create
the inner map, then `inner.set(clientId, subscriber)`. Do not overwrite
peer clients. If the inner map was empty before insert, this is the
first subscriber for the PTY; emit the `idle → mobile{clientId}` driver
transition.
- **`handleMobileUnsubscribe(ptyId, clientId)`** — `inner.delete(clientId)`,
then if the inner map is now empty, delete the outer entry, run the
existing restore-resize logic, and emit the `* → idle` driver
transition. If the inner map is non-empty, do **not** fire restore /
driver-change; peers still have the floor.
- **`applyMobileDisplayMode(ptyId)`** — iterate the inner map. The
restore-resize semantics need a single representative subscriber (the
desktop dims to restore _to_ are stored on the subscriber record). Pick
the earliest by subscribe time so the restore target is stable as
later phones come and go. The display mode itself is per-PTY runtime
state, not per-client.
**Active phone-fit dim selection (multi-mobile).** When two or more
mobile clients subscribe with different viewports (e.g., iPhone +
iPad), the PTY can only be at one phone-fit size at a time. Rule:
**the most recent mobile actor's viewport wins**. This matches the
driver state machine — whoever last took the floor (`mobile{X}`)
also dictates the active phone-fit dims. When that client
unsubscribes, the next-most-recent surviving subscriber's viewport
wins; on the last client leaving, the inner map empties and we run
the existing restore back to the earliest-recorded desktop dims. We
do not use `min(cols, rows)` across subscribers because that
produces no clear principal — multiple TUIs are forced to render
for an aggregate viewport that nobody actually has, which is worse
than picking a single owner. Most-recent-actor is consistent with
the rest of the protocol (whoever acted last has the floor) and is
cheap to reason about.
**Subscriber record extension.** "Most-recent actor" and
"earliest-by-subscribe-time" both require timestamps that today's
Subscriber record does not carry. Extend it with two fields:
```ts
interface Subscriber {
clientId: string
viewport: { cols: number; rows: number }
wasResizedToPhone: boolean
previousCols: number | null
previousRows: number | null
subscribedAt: number // ms since epoch, set on insert
lastActedAt: number // ms since epoch, init = subscribedAt;
// updated on every mobileTookFloor for this client
}
```
- `applyMobileDisplayMode(ptyId)` for active phone-fit dims:
iterate the inner map, pick `argmax(lastActedAt)`, use that
subscriber's `viewport`.
- For restore-to-desktop semantics on last-client-leaves: pick
`argmin(subscribedAt)` **among subscribers with non-null
`previousCols`/`previousRows`** and use that subscriber's
`previousCols/Rows` as the restore target. Desktop-mode
subscribers carry `previousCols/Rows = null` by design (the
existing `handleMobileSubscribe` short-circuits dim capture for
desktop-mode joins to avoid capturing a stale full-width size),
so they are not viable restore targets. If no surviving
subscriber has captured dims, fall back to
`lastRendererSizes.get(ptyId)` (the desktop renderer's most
recent reported size) — same fallback the existing first-insert
capture path uses.
Both operations are O(n) over the inner map, which is bounded by
the number of concurrently subscribed mobile clients (typically 1,
rarely 2-3). No indexing needed.
- **`isMobileSubscriberActive(ptyId)`** — returns `true` iff the inner map
is non-empty.
- **Driver state for the mobile side** stores the `clientId` of the most
recent mobile actor. Whenever any mobile client sends
input/resize/setDisplayMode/subscribes-fresh, that client becomes the
`currentDriver` (`mobile{thatClient}`). Phone B taking over from phone A
is an internal mobile→mobile transition: no banner change, but the
recorded `clientId` updates so the runtime always knows who is the
authoritative mobile actor.
The semantic upshot is that the desktop banner is governed by "is the
inner map non-empty AND has the desktop not reclaimed?" rather than the
old single-slot heuristic, and Take back / mobile-reclaim correctly
ping-pong even when two phones are on the same PTY.
## Implementation
Files touched:
1. `src/main/runtime/orca-runtime.ts` — rekey `mobileSubscribers` to
`Map<ptyId, Map<clientId, Subscriber>>`, add `currentDriver` map,
`getDriver(ptyId)` getter, transition methods, notifier hook. Update
`handleMobileSubscribe` / `handleMobileUnsubscribe` /
`applyMobileDisplayMode` / `isMobileSubscriberActive` per the
multi-mobile model above. **Extend `onPtyExit` cleanup** to
`currentDriver.delete(ptyId)` and emit `terminalDriverChanged(ptyId,
{ kind: 'idle' })` so any banner mounted on the dead pane unmounts.
Without this, a dead PTY's last driver state lingers and the
renderer banner could persist on a dead pane until tab teardown.
2. `src/main/runtime/orca-runtime.ts` interface `RuntimeNotifier` — add
`terminalDriverChanged(ptyId, driver: DriverState)`.
3. `src/main/window-manager.ts` (or wherever `RuntimeNotifier` is wired) —
forward to renderer via `webContents.send('terminal-driver-changed', …)`.
4. `src/preload/index.ts` + `src/preload/api-types.ts` — expose the new
event with the structured `DriverState` payload.
5. `src/main/ipc/runtime.ts` — add `runtime:reclaimTerminal` IPC (or
extend `runtime:restoreTerminalFit`) which drives the
`mobile{*} → desktop` transition.
6. `src/main/runtime/rpc/methods/terminal.ts` — drive the
`* → mobile{clientId}` transition in `terminal.send`,
`terminal.resizeForClient`, `terminal.setDisplayMode`, and the
subscribe handler (with the subscribe-in-desktop-mode exception
above).
**Wire-format changes required.** The driver state machine tags
the active mobile actor with `clientId`, so every mobile RPC method
that can take the floor must carry the caller's identity. Today
only `terminal.subscribe` and `terminal.resizeForClient` do; we
extend the others.
**Canonical identity shape.** Use the existing
`terminal.subscribe` shape: `client: { id: string; type: 'mobile'
\| 'desktop' }` (with `type` optional). `TerminalResizeForClient`
keeps its grandfathered top-level `clientId: string` field for
backward compatibility — implementers may add an aliased nested
`client.id` setter for consistency at the call site, but the wire
shape is preserved.
New fields on existing schemas:
- `TerminalSend` gains `client: { id: string; type?: 'mobile' \|
'desktop' }` (optional for backward compatibility — falls back
to "the most recent mobile actor" when absent).
- `TerminalSetDisplayMode` gains the same `client` field.
- `TerminalUnsubscribe` gains `client: { id: string }` so the
server can derive the composite cleanup key (see below); the
existing `subscriptionId` field is kept and remains the primary
lookup, with `client.id` used only when the caller passed a
bare-handle `subscriptionId`.
**Subscribe-side composite key.** Change `subscriptionId` from
`params.terminal` to `${params.terminal}:${params.client.id}` so
two phones subscribing to the same terminal handle do not evict
each other via `registerSubscriptionCleanup`. Without this fix,
the multi-mobile rekey to `Map<ptyId, Map<clientId, Subscriber>>`
would be silently defeated at the RPC subscription layer (phone
B's subscribe runs phone A's cleanup → A's data listener tears
down → A's `handleMobileUnsubscribe` fires).
**Unsubscribe-side composite key.** The mobile RPC client today
emits `terminal.unsubscribe` with `params: { subscriptionId:
stream.params.terminal }` — the bare handle. With the subscribe
side now keying by composite, a bare-handle unsubscribe will miss
in `subscriptionCleanups.get(bareHandle)` and silently no-op,
leaking the data listener and leaving `mobileSubscribers`
populated forever (banner stuck, driver never returns to `idle`).
Fix: the mobile RPC client emits `{ subscriptionId:
${terminal}:${clientId}, client: { id: clientId } }`, and the
server's `cleanupSubscription` uses whichever the caller sent —
if both `subscriptionId` and `client.id` are present and the
caller passed only `terminal` in `subscriptionId`, the server
reconstructs the composite key from `client.id`. Belt-and-braces
so a stale older mobile build still cleans up correctly.
This is a coordinated client/server wire-format change. The
mobile app must ship in lockstep; if a stale mobile client emits
bare-handle unsubscribe to a new server, the server's
reconstruction path catches it. If a new mobile client emits
composite-handle unsubscribe to a stale server, the stale server
ignores `client.id` and the composite key fails to match — but
that direction is moot because the stale server has not adopted
composite keys on subscribe either. No client newer than server,
so this is safe.
7. `src/main/ipc/pty.ts` — defense in depth: drop `pty:write` and
`pty:resize` calls when
`runtime.getDriver(id).kind === 'mobile'`. **Preserve the existing
`runtime.isResizeSuppressed()` short-circuit at the top of the
`pty:resize` handler** — the new driver-state guard is _in
addition to_, not in place of, the suppression window. The two
guards have different purposes: `isResizeSuppressed()` blocks the
safeFit cascade after a take-back transition (preventing
collateral resize corruption of background panes), while the
driver-state check blocks desktop-side resizes whenever mobile is
driving. Both must apply.
8. `src/renderer/src/lib/pane-manager/mobile-driver-state.ts` — new
renderer store mirroring `mobile-fit-overrides.ts`. Exposes
`getDriverForPty(ptyId): DriverState` and a change listener.
9. `src/renderer/src/components/terminal-pane/pty-connection.ts` — guard
`onData` and `onResize` on
`getDriverForPty(ptyId).kind === 'mobile'`.
10. `src/renderer/src/components/terminal-pane/TerminalPane.tsx` — switch
banner to consume driver state, update copy, rename Restore → Take
back.
### Server-side detail
```ts
// orca-runtime.ts
private currentDriver = new Map<string, DriverState>()
getDriver(ptyId: string): DriverState {
return this.currentDriver.get(ptyId) ?? { kind: 'idle' }
}
private setDriver(ptyId: string, next: DriverState): void {
this.currentDriver.set(ptyId, next)
this.notifier?.terminalDriverChanged(ptyId, next)
}
reclaimTerminalForDesktop(ptyId: string): void {
if (!this.isMobileSubscriberActive(ptyId)) return
// Snap PTY back to desktop dims if currently at phone dims.
// applyMobileDisplayMode is a no-op resize when already at desktop dims.
this.setMobileDisplayMode(ptyId, 'desktop')
this.applyMobileDisplayMode(ptyId)
this.setDriver(ptyId, { kind: 'desktop' })
}
private mobileTookFloor(ptyId: string, clientId: string): void {
// If mobile is in phone/auto mode, re-apply phone resize on the first
// hand-off back from desktop. Mobile-to-mobile hand-offs are no-ops
// for resize.
const prev = this.getDriver(ptyId)
if (prev.kind === 'desktop') this.applyMobileDisplayMode(ptyId)
this.setDriver(ptyId, { kind: 'mobile', clientId })
}
handleMobileUnsubscribe(ptyId: string, clientId: string) {
const inner = this.mobileSubscribers.get(ptyId)
if (!inner) return
inner.delete(clientId)
if (inner.size > 0) return // peer mobile clients still drive
this.mobileSubscribers.delete(ptyId)
// Drive transition fires synchronously so the desktop banner
// unmounts immediately. The PTY restore runs inside the existing
// 300ms pendingRestoreTimers debounce — see "Restore-debounce
// window" under Edge cases.
this.setDriver(ptyId, { kind: 'idle' })
// Existing restore-resize logic (300ms debounced timer) runs here.
}
```
The mobile-took-floor path is invoked from the RPC method handlers (not
from `sendTerminal` directly) so that the driver flip is bound to
deliberate mobile actions, not to internal runtime calls.
### Renderer guards
```ts
// pty-connection.ts onData
if (currentPtyId && getDriverForPty(currentPtyId).kind === 'mobile') {
return // Mobile is driving this PTY; banner explains.
}
// existing transport.sendInput(data)
// pty-connection.ts onResize
// Why: keep both predicates. getFitOverrideForPty fires synchronously
// in the renderer the moment safeFit runs; getDriverForPty arrives via
// IPC and lags by one round-trip. Removing the fit-override predicate
// would re-introduce the resize-over-mobile-fit bug mobile-fit-overrides
// was added to prevent.
if (
currentPtyId &&
(getFitOverrideForPty(currentPtyId) || getDriverForPty(currentPtyId).kind === 'mobile')
) {
return
}
```
### Overlay copy
The lock UI is a full-pane overlay (not a thin banner) so users cannot
miss it. The driving state can be collapsed to a corner chip while the
session is being watched; the held-fit state stays loud because there is
no live output to monitor (see `MobileDriverOverlay.tsx`).
Driving state, expanded:
- Eyebrow: `Mobile is driving this terminal`
- Title: `Your keyboard is paused`
- Body: `Output below is being typed from your phone. Take back to resume typing on the desktop, or collapse to keep watching.`
- Buttons: `Collapse`, `Take back`
Driving state, collapsed (corner chip): `● Mobile driving [ Take back ]`.
Clicking the label re-expands.
Held-at-phone-fit (no active mobile subscriber, PTY still at phone dims):
- Eyebrow: `Held at phone size`
- Title: `This terminal is sized for your mobile app`
- Body: `The session is still being held at the dimensions your phone last reported. Restore to use it on your desktop.`
- Button: `Restore desktop size`
There is no auto-unlock-on-WS-silence and no liveness probing; the overlay
stays up until the desktop user takes back / restores, until mobile actor
sends input (which keeps mobile as driver), or until the last mobile
subscriber disconnects.
## Edge cases
- **Mobile drops network**. WS doesn't immediately know. The inner
subscriber map retains the entry for ~30s until ping timeout. Banner
stays. Take back still works during this window — it transitions to
`desktop` immediately. When mobile eventually reconnects with
`terminal.subscribe`, that client takes the floor again
(`desktop → mobile{thatClient}`).
- **Two phones on same PTY**. The inner map carries both subscribers.
Driver state is `mobile{whicheverActedLast}`. Phone A unsubscribing
while phone B is still on does _not_ drop the banner; only the last
client leaving the inner map transitions to `idle`.
- **Pre-locked input in flight**. `transport.sendInput` is
fire-and-forget; there is no in-flight queue to drain. The first
dropped keystroke is on the renderer side, which is good enough.
- **Take back while phone is offline**. Works (transitions to `desktop`).
When phone reconnects with a fresh subscribe, driver flips to
`mobile{thatClient}`.
- **Display mode toggle from mobile**. `terminal.setDisplayMode` is
treated as mobile interaction → that client takes the floor. Aligns
with "any deliberate mobile action takes the floor."
- **Subscribe from a fresh client (reconnect with new clientId)**. The
subscribe path runs the `desktop → mobile{thatClient}` (or
`idle → mobile{thatClient}`) transition. A mobile reconnect takes back
the floor from desktop — correct semantic since the user actively
reopened the mobile view.
- **Held-fit window on last-subscriber-leaves**. When the last mobile
subscriber unsubscribes, the driver transitions to `idle`
synchronously. The active-driver overlay swaps to the held-fit Restore
overlay while the phone-fit override remains. With the default
indefinite hold, that Restore overlay stays up until desktop restores;
with finite auto-restore, it stays up until the timer restores the PTY
back to desktop dims. This keeps the "why is my terminal phone-sized?"
explanation visible even after the mobile driver is gone.
## Tests
- `orca-runtime.test.ts` (driver state machine):
- Every transition row in the table above has a unit test asserting
`(prev, trigger) → (next, side effect, emitted event payload)`.
- `getDriver` defaults to `{ kind: 'idle' }` for unknown ptyIds.
- `terminalDriverChanged` is emitted exactly once per state change and
carries the structured `DriverState` payload (not a boolean).
- `orca-runtime.test.ts` (multi-mobile subscriber sequencing):
- Phone A subscribes (`auto`) → driver is `mobile{A}`, banner emit
fires.
- Phone A subscribes (`desktop`) from `idle` → driver stays `desktop`
(subscribe-in-desktop-mode does not take the floor); inner map
has A.
- Phone B subscribes (`auto`) while A is still on (`auto`) → driver
flips to `mobile{B}` (subscribe-fresh-with-auto/phone counts as
take-floor); inner map has both subscribers; active phone-fit
dims switch to B's viewport.
- Phone B unsubscribes while A is still on → driver remains mobile;
active phone-fit dims revert to A's viewport (next-most-recent
actor); banner stays up; no `idle` transition.
- Phone A unsubscribes (last client leaves) → driver transitions to
`idle`, banner unmounts, PTY restores to earliest-recorded
desktop dims.
- `applyMobileDisplayMode` picks the **most-recent-actor's**
viewport for active phone-fit dims and the
**earliest-by-subscribe-time** desktop dims for restore.
- Mobile sets `displayMode` to `desktop` from `mobile{*}` does _not_
transition to `desktop` automatically (existing setDisplayMode
semantics stand) — but the runtime's _driver_ transition rule for
`desktop → desktop` (mode change to desktop while already in
desktop driver) is a no-op.
- `terminal.test.ts` (subscriptionId per-client keying):
- Phone A subscribes to terminal handle `T`, phone B subscribes to
the same handle `T` → A's data listener still receives bytes
after B's subscribe (subscriptionId is `${T}:${clientId}` so
`registerSubscriptionCleanup` does not collide).
- Regression: prior to the fix, the second subscribe ran the first
subscribe's cleanup and tore down phone A's stream.
- `pty-connection.test.ts`:
- `onData` is dropped while `getDriverForPty(id).kind === 'mobile'`.
- `onData` is delivered when driver flips to `desktop` or `idle`.
- `onResize` is dropped while driver is `mobile`.
- `pty.test.ts` (or new `pty-driver-state.test.ts`):
- `pty:write` IPC is dropped when `runtime.getDriver(id).kind ===
'mobile'`.
- `pty:resize` IPC is dropped under the same predicate.
## Out of scope
- Mobile yanking desktop tab focus via `terminal.focus`.
- Cold-restore ack from mobile-only attach.
- Mobile-side "Desktop is driving" banner (see "Asymmetric UX" above).
These are independent collisions tracked separately.
## Forward path
The natural next step (not in this PR) is unifying both writers — the
desktop renderer's `pty:write` IPC and the mobile RPC's `terminal.send`
— into a single coordinator that queues bytes through a runtime-owned
write path. The driver state machine cleanly supports this: the
coordinator can use `currentDriver(ptyId)` as the admission predicate
(only the current driver's bytes are dequeued), and the
`mobile{clientId}` payload tells it which queue head to drain when a
specific phone is the active actor. Today's PR keeps two independent
write paths and uses driver state purely as a drop-filter; the same
state machine becomes the scheduling input on the forward path without
a wire-format change.
## Rollout
Single PR. No feature flag needed; behavior is strictly additive (locks
an existing collision surface) and the existing banner UX continues to
work under the new driver-state model with sharper copy.
-406
View File
@@ -1,406 +0,0 @@
# Single Shared RPC Client per Host (Mobile)
Design doc for collapsing the per-screen WebSocket connection model into a
single shared `RpcClient` per paired host, owned by a React context that
sits above the route tree.
## Problem
The mobile app today opens **one WebSocket per screen per host**:
| Screen | Connections per host |
|---|---|
| Home (`app/index.tsx`) | 1 (with persistent `accounts.subscribe` stream) |
| Host detail (`app/h/[hostId]/index.tsx`) | +1 |
| Worktree session (`app/h/[hostId]/session/[worktreeId].tsx`) | +1 |
| Accounts (`app/h/[hostId]/accounts.tsx`) | +1 |
| Pair confirm (briefly) | +1 |
A user actively browsing one host typically holds **34 simultaneous
sockets** to the desktop runtime. Each call to `connect()` runs its own
E2EE handshake, allocates an ephemeral keypair, and runs an independent
reconnect loop with exponential backoff.
This causes three observable problems:
1. **Stuck-connecting / reconnecting for minutes.** The desktop's
`MAX_WS_CONNECTIONS = 32` is shared across all clients. A user who
navigates rapidly accumulates stale sockets faster than they can be
reaped by TCP keepalive (which can take 60300s on default systems
for half-open connections from a phone leaving Wi-Fi range or
backgrounding). Once the cap is hit, new sockets are rejected with WS
close code `1013 Maximum connections reached`. The mobile reconnect
loop does not recognize 1013 as terminal — it retries with backoff,
each retry also dropped, until enough stale sockets are reaped.
Result: a screen that should connect in <1s is stuck for 15 minutes.
2. **Tab create/delete hangs.** `client.sendRequest('terminal.create')`
awaits `waitForConnected()`. If the session-screen client is in
`connecting`/`reconnecting` state because its socket lost the cap
race, the await blocks until the 30s `REQUEST_TIMEOUT_MS` fires.
The user sees nothing happen.
3. **Triple cost on every cold-start.** Three E2EE handshakes,
three Curve25519 keypair generations, three subscription
re-registrations on every app launch. On low-end Android, this
visibly delays first paint by hundreds of milliseconds.
The architecture also wastes server resources: each socket carries its
own E2EE channel, its own subscription set, its own driver-state-machine
client identity (cf. `docs/mobile-presence-lock.md`). The server already
has logic to reconcile multi-socket-per-token tear-down
(`hasOtherConnections` in `ws-transport.ts`) — that logic exists *because*
this design forced the question; with a single client per host, it becomes
unnecessary.
## Today's transport ownership
Five files independently call `connect(endpoint, deviceToken, publicKeyB64)`:
```
mobile/app/pair-confirm.tsx # one-shot during pairing
mobile/app/pair-scan.tsx # one-shot during pairing
mobile/app/index.tsx # N (one per paired host)
mobile/app/h/[hostId]/index.tsx # 1 (host detail)
mobile/app/h/[hostId]/session/[worktreeId].tsx # 1 (session)
mobile/app/h/[hostId]/accounts.tsx # 1 (accounts)
```
Each owns a `useRef<RpcClient | null>` and calls `client.close()` from a
cleanup function. The home screen additionally maintains a
`clientsRef: Array<{ hostId, client }>` so its own usage of the per-host
client survives across navigation events.
This pattern works for *correctness* — every cleanup eventually closes
its socket — but it breaks under three real-world conditions:
1. **Rapid navigation.** Mounts spawn before unmounts complete; cleanup
`client.close()` runs after a new screen has already opened a fresh
socket to the same host. Two sockets briefly coexist for the same
token, multiplied across screens.
2. **Network drops.** A backgrounded/locked phone on a flaky network
leaves sockets half-open. The server doesn't get a FIN; cleanup
relies on TCP keepalive timing. Meanwhile the foreground app, on
resume, opens fresh sockets. The half-open ones eat the cap until
reaped.
3. **Hot reload during dev.** Each Metro hot reload fires a new render
tree without unmounting the old, so connections leak.
## Goal
> A paired host has at most **one active WebSocket** at any time, owned
> by a context provider above the route tree. All screens for that host
> share that client. The pair flows are the only places that create
> short-lived clients (and they explicitly close those after pairing
> completes).
This is the architectural fix to the symptoms above. Combined with the
two recently-shipped hotfixes (token cache + stable `useEffect`
dependency on home screen), this completes the connection-lifecycle
work.
## Design
### Layered ownership
```
RootLayout (<RpcClientProvider>)
└── routes
└── <HostScopedClientGate hostId={...}> // mounts when route has hostId
├── h/[hostId]/ // host detail
├── h/[hostId]/session/[worktreeId]/ // session
└── h/[hostId]/accounts/ // accounts
```
Two providers, layered:
1. **`RpcClientProvider` (root)** — owns one `RpcClient` per host,
keyed by `hostId`. Lifecycle: opens on first request for that
host's client, holds open until app shutdown OR until the host is
removed (`removeHost(hostId)` triggers explicit close). Reuses
existing `loadHosts()` cache from the recently-merged
`host-store.ts` work.
2. **`HostScopedClientGate` (per host)** — a thin route-layout
component placed at `app/h/_layout.tsx`. Reads `hostId` from
route params, requests the client for that host from the root
provider, exposes it via context to descendants, and renders a
loading state until the client reaches `connected`. Guarantees
every descendant screen sees the same client instance for that
host — no per-screen `connect()` calls.
The home screen (`app/index.tsx`) lives outside `HostScopedClientGate`
since it spans all hosts; it consumes the root provider directly via
a multi-host hook (see API below).
### API
```ts
// New file: mobile/src/transport/client-context.tsx
type RpcClientContext = {
// Get-or-open. Returns the singleton client for hostId; opens it
// lazily on first call, reuses it for all subsequent callers. Never
// returns null (returns a placeholder client in 'connecting' state
// if open hasn't completed).
getClient: (hostId: string) => RpcClient
// Connection state for a given host (driven by client.onStateChange).
useHostState: (hostId: string) => ConnectionState
// Useful for the home screen which renders all hosts at once.
useAllClients: () => Array<{ hostId: string; client: RpcClient }>
}
export const RpcClientProvider: React.FC<{ children: React.ReactNode }>
export const useHostClient: (hostId: string) => {
client: RpcClient
state: ConnectionState
}
```
Internal store (single `useRef` in the provider):
```ts
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number // number of active screens holding this client
closeTimer: NodeJS.Timeout | null
}
const store = useRef(new Map<string, StoreEntry>())
```
### Lifecycle rules
1. **Open on first read.** First `getClient(hostId)` call for a host
reads the host record (uses cached `loadHosts()`), then calls
`connect()` and stores the client. Subsequent calls return the
cached entry.
2. **Idle close timer.** When `refCount` drops to 0 (all screens for
that host unmounted), schedule a 30-second close timer. If a screen
for the same host mounts within 30s, cancel the timer. Otherwise,
close the client and remove from the store.
- Why 30s: covers fast tab-switching and back-navigation without
keeping idle sockets forever. Tunable based on observed behavior.
3. **Forced close on host removal.** `removeHost(hostId)` from
`host-store.ts` calls into the provider to close the client
immediately and delete the store entry.
4. **App backgrounded.** No special action — let TCP keepalive and
server-side reaping handle it. Reconnect happens on foreground.
5. **App foregrounded.** Trigger a `getState()` poll on every
non-closed entry; if any are in `disconnected` (TCP died while
backgrounded), the existing reconnect loop handles it. No new
client allocations.
### Public surface for screens
Each screen replaces:
```ts
// Before
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
useEffect(() => {
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host) return
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState)
setClient(rpcClient)
})()
return () => {
rpcClient?.close()
}
}, [hostId])
```
with:
```ts
// After
const { client, state } = useHostClient(hostId)
```
Total LoC reduction across screens: ~150 lines.
### Pair flow exception
`app/pair-confirm.tsx` and `app/pair-scan.tsx` continue to call `connect()`
directly with **explicit `client.close()`** after the test request returns.
Reason: the host record doesn't yet exist in `loadHosts()` during pairing,
so the provider has nothing to look up. The pair flow's client is a
short-lived transient that delivers `getStatus()` once and then dies.
After `saveHost()` succeeds, the user is navigated away; the next time
they enter `/h/[hostId]/...`, the provider opens a fresh client through
the normal path.
### Streaming subscription handling
The home screen's `accounts.subscribe` stream and the session screen's
terminal subscriptions remain owned by their respective screens — the
provider doesn't manage subscriptions, only the underlying transport.
Each screen's effect calls `client.subscribe(...)` and stores the
returned unsubscribe function. On unmount, the screen unsubscribes
(returns to the existing per-screen pattern, just over a shared
transport). The transport's `subscribe()` already correctly multiplexes
multiple listeners on one WebSocket via the `id` field.
### State propagation
`useHostState(hostId)` returns the live `ConnectionState`. The provider
maintains a per-host `useState` keyed by hostId; the `client.onStateChange`
listener is wired once at client creation and updates the corresponding
state slot. `useHostState` reads from this state via `useSyncExternalStore`
or a context selector — the choice is mostly preference; in this
codebase, given the small state shape, a simple `useContext + useMemo`
of the matching slot is fine.
## Migration
Step-by-step, each step independently shippable:
1. **Add `RpcClientProvider` and `useHostClient`.** No callers yet.
Wire into `app/_layout.tsx`. Existing screens unchanged.
2. **Migrate session screen** (highest-risk, most-used). Replace
per-screen `connect()` with `useHostClient`. Test connection
behavior, terminal create/delete, scrollback hydration.
3. **Migrate host detail and accounts screens.** Same pattern.
4. **Migrate home screen.** Replace `clientsRef` with
`useAllClients()`. The home screen's per-host streaming
subscriptions move into a hook that runs per-host.
5. **Add `HostScopedClientGate`** at `app/h/_layout.tsx` to centralize
the gate and remove duplicated loading-state logic.
6. **Delete legacy code.** Remove the dead `connect()` import paths
from each screen. Codepoint reduction.
7. **Remove server-side `hasOtherConnections` complexity** in a
follow-up: with one socket per token, the multi-socket reconciliation
in `runtime-rpc.ts` `wsTransport.onConnectionClose` simplifies.
This is a desktop-side cleanup PR done after mobile rolls out.
Each step is tested in isolation; rollback per step is trivial.
## Risks
### R1: Connection loss while screens are mounted
**Risk.** Today, when a screen unmounts due to network loss, its
client closes and reopens on remount. Under the new design, a
network-loss-during-screen-mounted means the client lives but is in
`reconnecting` state.
**Mitigation.** The existing `RpcClient` already handles this — its
internal reconnect loop runs invisibly. Screens already render based
on `connState === 'connected'`, so they stay in their `connecting`
UI until the loop succeeds. No regression.
### R2: One bad host poisons the singleton
**Risk.** If the client for one host is wedged in `reconnecting` due
to a desktop-side issue, all screens for that host inherit the wedged
state. Under the per-screen design, navigating to a different screen
gave a fresh client with a chance to connect cleanly.
**Mitigation.** "Force reconnect" affordance: a button on the host
detail "Connection issues" UI calls
`provider.forceReconnect(hostId)` — close + reopen the client. Users
who hit a stuck state get a one-tap recovery without uninstalling.
Implemented as part of step 2.
### R3: Idle close timer races
**Risk.** A user navigates from session → home → back to session
within 35 seconds. The 30s idle timer fires between hops and closes
the client; the back-navigation has to wait for a fresh handshake.
**Mitigation.** Cancel the timer at `getClient(hostId)` time, not at
mount time. As long as the consumer holds a reference to the client,
the timer is paused. Standard refcount pattern.
### R4: Memory / state leak on rapid host removal
**Risk.** User removes a host while a screen for it is mounted.
The screen's reference is now dangling.
**Mitigation.** `removeHost(hostId)` triggers an explicit close +
delete from the store. Screens already handle `auth-failed` /
`disconnected` states (the client transitions to one of them on
forced close). Add a navigation-bounce in those states so the screen
returns to the host list.
### R5: Pair-flow socket leaks through provider
**Risk.** If pair-confirm crashes mid-handshake before its explicit
`close()`, the socket leaks.
**Mitigation.** Independent of the provider — same risk exists today.
Add a try/finally + cleanup useEffect in pair-confirm.
### R6: Provider re-initialization on hot reload (dev only)
**Risk.** Metro hot reload re-runs `RpcClientProvider`, possibly
spawning new clients while old ones are still in the store.
**Mitigation.** On provider mount, scan the store for entries whose
clients report `closed` state and prune. Acceptable dev-only friction.
## Test Plan
### Unit / hook tests
- `useHostClient` returns the same client instance across multiple
consumers for the same hostId.
- `removeHost(hostId)` immediately closes the client (assert via
`client.getState()`).
- Idle close timer: zero refcount → 30s wait → client closed.
- Idle close timer cancellation: zero refcount → 15s wait →
consumer subscribes → no close.
### Integration / manual
- Create one host; navigate home → host detail → session → back ×10
rapidly. Single socket on desktop (verify via desktop debug log).
- Background app for 5 min; foreground; verify reconnect uses same
client instance, no leak.
- Remove host while session screen mounted; screen bounces back to
home; client closed.
- Force-reconnect button on host detail; client closes and reopens
cleanly.
- Hot reload during development; no socket leak (verify desktop
active-connection count).
### Regression
- Terminal create/delete works during normal browse (Bug A from
initial reports — should never recur once cap pressure is removed).
- Scrollback hydration unchanged.
- Phone-fit / driver-lock state machine unchanged
(`docs/mobile-presence-lock.md` invariants hold).
## Out of scope
- **Desktop-side connection LRU eviction.** Useful as a defense-in-depth
but not needed once mobile self-limits to one socket per host.
- **Application-level ping/pong.** Worth adding but separate concern;
helps server reap dead sockets faster regardless of how many a single
client opens.
- **iOS share extension or system-wide deep-link integration.** Future
product work.
## Effort estimate
34 hours including tests and incremental migration. Each step
independently mergeable.
## References
- `docs/mobile-presence-lock.md` — driver-state-machine that depends
on per-client identity. Single-client-per-host simplifies but
doesn't break this contract.
- `docs/mobile-prefer-renderer-scrollback.md` — scrollback hydration
flow. Subscriptions remain per-screen; transport changes are
transparent.
- `mobile/src/transport/rpc-client.ts` — existing `connect()`
implementation; reconnect loop, E2EE handshake, subscription
multiplexing all preserved as-is.
- `src/main/runtime/rpc/ws-transport.ts` — server-side
`MAX_WS_CONNECTIONS = 32`, `hasOtherConnections` reconciliation
that becomes simpler post-migration.
-76
View File
@@ -1,76 +0,0 @@
# Quick Open Gitignored Files
## Problem
Cmd/Ctrl+P Quick Open does not list arbitrary gitignored files in the active workspace.
- `src/renderer/src/components/QuickOpen.tsx:257` calls `window.api.fs.listFiles(...)`; filtering in the renderer only fuzzy-matches the returned list, so missing files are already absent from the backend result.
- `src/main/ipc/filesystem-list-files.ts:46` uses `buildRgArgsForQuickOpen(...)` for local worktrees, then merges the primary rg pass with `envPass`.
- `src/relay/fs-handler-list-files.ts:37` uses the same shared rg args for SSH worktrees.
- `src/shared/quick-open-filter.ts:252` builds the primary rg pass without `--no-ignore-vcs`, so gitignored files are hidden.
- `src/shared/quick-open-filter.ts:265` builds a second `--no-ignore-vcs` pass, but it is restricted to `.env*` and `**/.env*`.
- `src/shared/quick-open-filter.ts:357` builds the git fallback primary pass with `--exclude-standard`; `src/shared/quick-open-filter.ts:365` only adds `.env*` pathspecs.
## Root Cause
Quick Open intentionally mirrors `rg --files --hidden` with gitignore respect, then adds a special-case pass for gitignored `.env*` files. That policy lives in shared code used by local main-process listing, SSH relay listing, and git fallback listing, so non-env ignored files are excluded consistently in every workspace type.
## Non-goals
- Do not change right-sidebar text search behavior.
- Do not add a new UI preference or mode switch.
- Do not follow symlinks or broaden traversal outside the authorized workspace root.
- Do not surface heavy generated directories already excluded by Quick Open policy, including `node_modules`, `.git`, `.cache`, `.next`, and other `HIDDEN_DIR_BLOCKLIST` entries.
- Do not change fuzzy ranking, selection, file-opening behavior, or shortcut handling.
## Design
1. Replace the `.env*` rg second pass with a general ignored-files pass.
- Keep `--files --hidden --no-ignore-vcs`, hidden-dir blocklist globs, nested-worktree exclude globs, `searchRoot='.'`, and path-separator handling.
- Remove positive `.env*` globs so the second pass is no longer a whitelist.
- Keep the primary pass unchanged.
2. Rename pass naming from `envPass` to `ignoredPass` in shared/main/relay code.
- This is a behavior change, not cosmetic: the second pass now returns all gitignored candidates.
3. Replace git fallback `.env*` pass with ignored-files pass.
- Keep primary as `['--cached', '--others', '--exclude-standard', ...]`.
- Use ignored pass as `['--others', '--ignored', '--exclude-standard', ...]`.
- Keep nested-worktree pathspec exclusion semantics (`--`, `.`, excludes when exclude prefixes exist).
- Do not assume both runtimes have identical error semantics today:
- local main fallback currently resolves on most git failures;
- relay fallback rejects on spawn/signal failures.
Preserve each runtimes current behavior unless explicitly changing it in a separate doc.
4. Keep post-filters unchanged.
- `shouldIncludeQuickOpenPath` remains final blocklist enforcement.
- `shouldExcludeQuickOpenRelPath` remains nested-worktree correctness backstop.
- Set-based merge still dedupes cross-pass overlap.
5. Do not change renderer request-lifecycle logic in this doc.
- Current `QuickOpen.tsx` effect cleanup cancels the prior request before the next effect body runs.
- This change is backend listing policy only.
6. Update tests.
- `quick-open-filter.test.ts`: assert rg ignored pass has `--no-ignore-vcs` and no `.env*` globs; assert git ignored pass has `--others --ignored --exclude-standard` and no `.env*` pathspec whitelist.
- `filesystem-list-files.test.ts`: update pass-detection helpers that currently key off `'**/.env*'`; cover ignored non-env files; keep local fallbacks resolve-on-failure behavior unchanged.
- Add relay coverage in `src/relay/fs-handler.test.ts` (or new focused relay tests) for ignored-pass args and current reject-on-signal behavior.
## Edge Cases
- Gitignored files inside `node_modules`, `.git`, `.cache`, `.next`, `.npm`, `.npm-global`, `.gvfs`, and other blocklisted dirs must remain hidden.
- Nested linked worktree paths passed as `excludePaths` must remain excluded from both rg and git passes.
- Local/SSH candidate sets should match when both run the same backend path (rg or git) and complete successfully; timeout/error behavior remains intentionally different today.
- Windows and WSL path normalization must remain unchanged; output still passes through `normalizeQuickOpenRgLine`.
- If rg is unavailable, git fallback should include ignored files only when Git can enumerate them; non-git roots keep existing fallback limits.
- Timeout/signal behavior must not regress into partial false-empty results.
- Keep existing timeout asymmetry unless intentionally changed: local rg/git fallback uses 10s timeouts, relay rg uses 25s.
- `--no-ignore-vcs` also includes files ignored by parent/global excludes; blocklists are the guardrail against accidental heavy trees.
## Rollout
1. Update `src/shared/quick-open-filter.ts` types, rg args, git args, and comments.
2. Update local main-process and SSH relay callers/tests for `ignoredPass`.
3. Run focused tests for quick-open filters and list-files (main + relay).
4. Run `pnpm typecheck` and `pnpm lint`.
5. Validate in Electron on local + SSH worktrees with gitignored non-env files and nested linked worktrees.
+19 -19
View File
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
</p>
<p align="center">
@@ -23,7 +23,7 @@
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Captura de Orca" width="800" />
<img src="../assets/file-drag.gif" alt="Captura de Orca" width="800" />
</p>
## Agentes compatibles
@@ -31,7 +31,7 @@
Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -47,7 +47,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
@@ -103,7 +103,7 @@ yay -S stably-orca-git
Controla tus agentes desde el teléfono.
<p align="center">
<picture><source srcset="assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="720" /></picture>
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="720" /></picture>
</p>
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
@@ -116,19 +116,19 @@ Controla tus agentes desde el teléfono.
Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Worktrees en paralelo</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminales divididas</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas de nivel Ghostty" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Modo diseño</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub y Linear nativos</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Flujos de GitHub y Linear en Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Cualquier agente CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="Compatible con cualquier agente CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>Worktrees por SSH</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Archivos a agentes</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Anotar diffs de IA</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Búsqueda nativa</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="Búsqueda nativa en los flujos de Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Cambio de cuenta y seguimiento de uso</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="Cambio de cuenta y seguimiento de uso" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Previews ricos del repo</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Previsualización de Markdown, imágenes, PDFs y documentos del repo" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Divide cualquier cosa</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="Paneles divididos para agentes, terminales, navegadores y archivos" width="390" /></picture><br/></kbd></a>
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Worktrees en paralelo</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminales divididas</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas de nivel Ghostty" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Modo diseño</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub y Linear nativos</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Flujos de GitHub y Linear en Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Cualquier agente CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="Compatible con cualquier agente CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>Worktrees por SSH</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Archivos a agentes</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Anotar diffs de IA</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Búsqueda nativa</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Búsqueda nativa en los flujos de Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Cambio de cuenta y seguimiento de uso</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="Cambio de cuenta y seguimiento de uso" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Previews ricos del repo</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Previsualización de Markdown, imágenes, PDFs y documentos del repo" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Divide cualquier cosa</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="Paneles divididos para agentes, terminales, navegadores y archivos" width="390" /></picture><br/></kbd></a>
</p>
---
+19 -19
View File
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
</p>
<p align="center">
@@ -23,7 +23,7 @@
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Orca Screenshot" width="800" />
<img src="../assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 対応するエージェント
@@ -31,7 +31,7 @@
Orca は任意の CLI エージェントに対応しています(*このリストに限定されません*)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -47,7 +47,7 @@ Orca は任意の CLI エージェントに対応しています(*このリス
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
@@ -103,7 +103,7 @@ yay -S stably-orca-git
スマートフォンからエージェントを操作できます。
<p align="center">
<picture><source srcset="assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="720" /></picture>
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="720" /></picture>
</p>
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
@@ -116,19 +116,19 @@ yay -S stably-orca-git
各タイルをクリックすると、そのワークフローを確認できます。
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>並列ワークツリー</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>ターミナル分割</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Ghostty クラスのターミナル分割" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>デザインモード</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub と Linear をネイティブに</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear ワークフロー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意の CLI エージェント</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="任意の CLI エージェントに対応" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH ワークツリー</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>ファイルをエージェントへ</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 注釈</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>ネイティブ検索</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="Orca ワークフロー全体のネイティブ検索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>アカウント切り替えと使用量トラッキング</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="アカウント切り替えと使用量トラッキング" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>リッチなリポジトリプレビュー</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Markdown、画像、PDF、リポジトリ文書のプレビュー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>何でも分割表示</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="エージェント、ターミナル、ブラウザ、ファイルの分割表示" width="390" /></picture><br/></kbd></a>
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>並列ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>ターミナル分割</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty クラスのターミナル分割" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>デザインモード</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub と Linear をネイティブに</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear ワークフロー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意の CLI エージェント</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="任意の CLI エージェントに対応" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>ファイルをエージェントへ</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 注釈</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>ネイティブ検索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca ワークフロー全体のネイティブ検索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>アカウント切り替えと使用量トラッキング</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="アカウント切り替えと使用量トラッキング" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>リッチなリポジトリプレビュー</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、画像、PDF、リポジトリ文書のプレビュー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>何でも分割表示</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="エージェント、ターミナル、ブラウザ、ファイルの分割表示" width="390" /></picture><br/></kbd></a>
</p>
---
+19 -19
View File
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
</p>
<p align="center">
@@ -23,7 +23,7 @@
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Orca 스크린샷" width="800" />
<img src="../assets/file-drag.gif" alt="Orca 스크린샷" width="800" />
</p>
## 지원 에이전트
@@ -31,7 +31,7 @@
Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정되지 않습니다*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -47,7 +47,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
@@ -103,7 +103,7 @@ yay -S stably-orca-git
휴대폰에서 에이전트를 제어하세요.
<p align="center">
<picture><source srcset="assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="720" /></picture>
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="720" /></picture>
</p>
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
@@ -116,19 +116,19 @@ yay -S stably-orca-git
타일을 클릭해 각 워크플로를 살펴보세요.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>병렬 Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>터미널 분할</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Ghostty급 터미널 분할" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>디자인 모드</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 및 Linear 네이티브</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 워크플로" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>모든 CLI 에이전트</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="모든 CLI 에이전트 지원" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>에이전트로 파일 드래그</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 주석</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>네이티브 검색</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="Orca 워크플로 전반의 네이티브 검색" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>계정 전환 및 사용량 추적</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="계정 전환 및 사용량 추적" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>풍부한 리포지토리 미리보기</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Markdown, 이미지, PDF, 리포지토리 문서 미리보기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>무엇이든 분할</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="에이전트, 터미널, 브라우저, 파일을 위한 분할 패널" width="390" /></picture><br/></kbd></a>
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>병렬 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>터미널 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty급 터미널 분할" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>디자인 모드</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 및 Linear 네이티브</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 워크플로" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>모든 CLI 에이전트</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="모든 CLI 에이전트 지원" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>에이전트로 파일 드래그</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 주석</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>네이티브 검색</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca 워크플로 전반의 네이티브 검색" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>계정 전환 및 사용량 추적</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="계정 전환 및 사용량 추적" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>풍부한 리포지토리 미리보기</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown, 이미지, PDF, 리포지토리 문서 미리보기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>무엇이든 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="에이전트, 터미널, 브라우저, 파일을 위한 분할 패널" width="390" /></picture><br/></kbd></a>
</p>
---
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
</p>
<p align="center">
@@ -23,7 +23,7 @@
</p>
<p align="center">
<img src="assets/file-drag.gif" alt="Orca Screenshot" width="800" />
<img src="../assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 支持的智能体
@@ -31,7 +31,7 @@
Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -47,7 +47,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
@@ -103,7 +103,7 @@ yay -S stably-orca-git
用手机控制你的智能体。
<p align="center">
<picture><source srcset="assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="720" /></picture>
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="720" /></picture>
</p>
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
@@ -116,19 +116,19 @@ yay -S stably-orca-git
点击任意卡片了解对应工作流。
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>并行 Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>终端分屏</strong><br/><br/><picture><source srcset="assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="assets/feature-wall/terminal-splits.jpg" alt="Ghostty 级终端分屏" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>设计模式</strong><br/><br/><picture><source srcset="assets/feature-wall/design-mode.gif" type="image/gif"><img src="assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 与 Linear 原生集成</strong><br/><br/><picture><source srcset="assets/feature-wall/github-linear.gif" type="image/gif"><img src="assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 工作流" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意 CLI 智能体</strong><br/><br/><picture><source srcset="assets/feature-wall/cli-agents.gif" type="image/gif"><img src="assets/feature-wall/cli-agents.jpg" alt="支持任意 CLI 智能体" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>拖文件给智能体</strong><br/><br/><picture><source srcset="assets/feature-wall/file-drag.gif" type="image/gif"><img src="assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>标注 AI Diff</strong><br/><br/><picture><source srcset="assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="assets/feature-wall/orca-cli.gif" type="image/gif"><img src="assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>原生搜索</strong><br/><br/><picture><source srcset="assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="assets/feature-wall/keyboard-native.jpg" alt="贯穿 Orca 工作流的原生搜索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>账号切换与用量追踪</strong><br/><br/><picture><source srcset="assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="assets/feature-wall/codex-accounts.jpg" alt="账号切换与用量追踪" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>丰富仓库预览</strong><br/><br/><picture><source srcset="assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="assets/feature-wall/markdown-editor.jpg" alt="Markdown、图片、PDF 和仓库文档预览" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>任意分屏</strong><br/><br/><picture><source srcset="assets/feature-wall/split-screen.gif" type="image/gif"><img src="assets/feature-wall/split-screen.jpg" alt="为智能体、终端、浏览器和文件分屏" width="390" /></picture><br/></kbd></a>
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>并行 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>终端分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty 级终端分屏" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>设计模式</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 与 Linear 原生集成</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 工作流" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意 CLI 智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="支持任意 CLI 智能体" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>拖文件给智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>标注 AI Diff</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>原生搜索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="贯穿 Orca 工作流的原生搜索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>账号切换与用量追踪</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="账号切换与用量追踪" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>丰富仓库预览</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、图片、PDF 和仓库文档预览" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>任意分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="为智能体、终端、浏览器和文件分屏" width="390" /></picture><br/></kbd></a>
</p>
---
+16
View File
@@ -0,0 +1,16 @@
# Durable Docs
Keep this folder for versioned reference docs that are meant to survive past a
single design or implementation pass.
## What Goes Here
- Stable reference material.
- Public-facing docs that are not part of the root README.
- Docs that other checked-in files link to.
## What Stays Out
Ephemeral design notes, implementation sketches, and planning docs should stay as
local Markdown files under `docs/`. They are ignored by default so they do not get
checked in accidentally.
-165
View File
@@ -1,165 +0,0 @@
# Auto-refresh on entering the Checks tab
## Problem
The Checks panel fetches on visibility, but those fetches are cache-respecting:
- `CACHE_TTL = 300_000` for PRs and comments.
- `CHECKS_CACHE_TTL = 60_000` for checks.
- `ChecksPanel.tsx` fetches PRs on repo/branch changes without `force`.
- Checks polling and the comments effect also call the store without `force`.
That means entering the Checks tab can render stale PR metadata, a cached `null`
"no PR" result, stale comments, or stale checks. The checks path is partially
protected by a shorter renderer TTL, but the main process also uses `gh api
--cache 60s` for the REST checks endpoint unless `noCache` is set. Manual refresh
already bypasses these renderer and `gh api` caches for the requests it starts.
## Goal
When the user enters the Checks tab, run one freshness check for the active
worktree and force-refresh only when the relevant cache timestamps are older
than a small grace window. "Entering" means:
- opening the right sidebar while the Checks tab is selected;
- switching from another right-sidebar tab to Checks;
- switching active worktree/repo/branch while Checks is already visible.
The refresh must cover PR discovery too, including cached `null`, so a PR opened
outside Orca can appear without waiting up to 5 minutes.
## Non-goals
- Changing the existing polling cadence while the panel stays visible.
- Refreshing on window focus, network reconnect, or arbitrary background events.
- Changing the manual refresh button.
- Prefetching checks for non-active worktrees.
- Adding cross-renderer or cross-window request coordination. Orca currently has
one live main window; renderer in-flight maps are not a multi-window primitive.
## Design
1. **Entry trigger.** Add an effect in `ChecksPanel.tsx` keyed by:
```ts
const entryKey = isPanelVisible && repo && !isFolder && branch
? `${activeWorktreeId ?? ''}::${repo.path}::${branch}`
: ''
```
Track the last processed visible `entryKey` in a ref. When `entryKey` is empty,
reset the ref to `''`. When it becomes non-empty and differs from the ref, this
is an entry. This is required; a `prevKey !== currentKey` check that does not
reset on hide would miss closing and reopening the same PR.
2. **Grace window.** Define `ENTRY_REFRESH_GRACE_MS = 15_000` in
`ChecksPanel.tsx`. Select only timestamps, not whole cache records:
```ts
const prFetchedAt = useAppStore(
(s) => (prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined)
)
const checksFetchedAt = useAppStore(
(s) => prNumber
? s.checksCache[`${repo?.path ?? ''}::pr-checks::${prNumber}`]?.fetchedAt
: undefined
)
const commentsFetchedAt = useAppStore(
(s) => prNumber
? s.commentsCache[`${repo?.path ?? ''}::pr-comments::${prNumber}`]?.fetchedAt
: undefined
)
```
Missing PR cache is stale. A cached PR value of `null` is still a PR cache
entry and should be refreshed on entry once outside the grace window. When a
PR number is known, missing checks/comments timestamps are stale. When no PR is
known, checks/comments are not relevant yet.
Run `handleRefresh()` if the oldest relevant timestamp is missing or older
than `Date.now() - ENTRY_REFRESH_GRACE_MS`; otherwise skip.
3. **Reuse `handleRefresh`, but pass the refreshed head SHA through.** The
manual refresh flow is the right shape: force `fetchPRForBranch`, then if a
PR is returned, force checks and comments for the returned PR. Extend
`fetchChecks()` to accept a `headShaOverride` (or call `fetchPRChecks`
directly from `handleRefresh`) so the checks request uses
`refreshedPR.headSha`, not the stale `pr?.headSha` captured before the PR
refresh completed. This handles PR number changes, cached `null`, and
external force-pushes correctly.
4. **Reset polling attention state before refresh.** When the entry refresh runs,
set `pollIntervalRef.current = 30_000` and `prevChecksRef.current = ''` before
calling `handleRefresh()`. `fetchChecks()` will then write the new signature
from the forced result.
5. **Do not overstate in-flight behavior.** Current store behavior is:
- `fetchPRForBranch({ force: true })` bypasses a non-forced in-flight PR
request and uses a generation guard so the older result cannot overwrite the
newer cache entry.
- `fetchPRChecks({ force: true })` and `fetchPRComments({ force: true })` do
**not** bypass any in-flight request for the same key. If a non-forced poll
is already in flight, entry refresh will join it and may not pass
`noCache: true` to the main process.
Accept that tradeoff for this feature. It avoids duplicate `gh` calls during a
visible polling race. If strict "entry always bypasses gh cache" semantics are
required later, change checks/comments in-flight maps to track `{ promise,
force, generation }` like PRs.
## API cost and feasibility
This is not free. A cold entry refresh can start:
- one PR lookup (`gh:prForBranch`);
- one checks request (`gh:prChecks`);
- one comments request (`gh:prComments`).
`gh:prChecks` usually calls `gh api repos/{owner}/{repo}/commits/{sha}/check-runs`
and uses `--cache 60s` unless forced; if that fails it falls back to `gh pr
checks`, which does not use the `--cache` flag.
`gh:prComments` is heavier than "one GitHub API call": it runs issue comments
REST, review threads GraphQL, and reviews REST in parallel. `noCache` only
removes `--cache 60s` from the REST `gh api` calls; the GraphQL call is always
made.
The 15 s grace window is therefore required, not cosmetic.
## Edge cases
- **Cached no-PR result.** Do not skip just because `prNumber` is null. Refresh
the PR cache entry on tab entry when its timestamp is outside the grace window.
- **First Checks entry after app start.** Only `prCache` and `issueCache` are
persisted. `checksCache` and `commentsCache` start empty, so a known PR should
force checks/comments on entry.
- **Worktree switch while Checks is visible.** Include `activeWorktreeId` in the
entry key so same repo/branch switches still count as a new entry. The existing
render-time local reset handles stale title/loading state.
- **Rapid tab toggles.** Hiding the panel resets the processed entry key; showing
it again re-evaluates timestamps. The grace window suppresses duplicate calls.
- **Concurrent polling tick.** Checks/comments may reuse the in-flight poll
request instead of forcing a new `noCache` request. This is intentional for now.
- **Conflicting PR refresh.** The existing `mergeable === 'CONFLICTING'` effect
can still force a PR refresh. Entry refresh may also run, but PR generation
guards prevent older PR responses from overwriting newer cache entries.
- **PR head changed externally.** `handleRefresh()` must use the refreshed PR
returned by `fetchPRForBranch` before fetching checks, so checks are requested
with the current `headSha`.
- **Component unmount mid-fetch.** The comments auto-fetch guards stale
responses; the checks fetch path and `handleRefresh()` do not cancel local
setters. This feature should keep that behavior unless tests expose a warning
or stale-state regression.
- **SSH / remote repo.** No path manipulation or platform-specific shortcut code
is needed. The same IPC-backed `gh` path is used.
## Rollout
1. Add timestamp selectors, `ENTRY_REFRESH_GRACE_MS`, and the entry effect in
`ChecksPanel.tsx`.
2. Keep `handleRefresh()` as the single refresh implementation.
3. Add `ChecksPanel.test.tsx` if absent. Cover stale PR/null cache refreshes,
fresh-within-grace skips, known PR with missing checks/comments refreshes,
hidden panel no-op, and hide/show same PR re-evaluation.
4. Run `pnpm typecheck && pnpm lint`.
-387
View File
@@ -1,387 +0,0 @@
# Resource Usage popover: merge Resources + Sessions into one view
## Problem
The status-bar Resource Usage popover currently has two tabs:
- **Resources** — per-worktree CPU/memory grouped by repo, sourced from
`MemorySnapshot.worktrees`. Local PTYs only (SSH excluded by design at
`src/main/ipc/pty.ts:832` because remote process trees are not visible
to the local `ps`/`wmic` sweep).
- **Sessions** — flat list of every PTY the daemon tracks (local **and**
SSH), sourced from `pty.listSessions()`. Adds tab-binding info, click-
to-navigate, kill-X per row, and "Kill N orphans".
The data sources only partially overlap: Sessions can show worktrees
(e.g. `orca/Porpoise`, `orca/Stingray`) that never appear in Resources
because they're SSH-backed. Users see this as a confusing inconsistency
between two adjacent tabs in the same popover.
The user wants one unified view that:
1. Lists every PTY the daemon knows about (the union, not the
intersection).
2. Shows CPU/memory **when available** (i.e. for local PTYs) and `—` for
remote ones, rather than silently dropping remote sessions.
3. Keeps tab-binding affordances (click row → navigate, kill X with
confirm dialog) on every row.
4. Surfaces orphans (PTYs not bound to any tab) with the "Kill N
orphans" bulk action.
## Direction (already decided by user)
> Merge into one tab, drop the switcher entirely.
Out of scope:
- Adding new IPC. The merge uses **only** the data already available:
`MemorySnapshot` and `pty.listSessions()`.
- Changing what the memory collector tracks. SSH PTYs remain unsampled.
The unified view simply shows them with empty metric cells instead of
hiding them.
- Adding new actions (e.g. "stop all in repo", per-repo restart, etc.).
The exact action set today (kill one with confirm, kill orphans, sleep
worktree, restart daemon, kill-all-sessions) is preserved.
## Data model
### Inputs
- `snapshot: MemorySnapshot | null` — local-only CPU/Mem data, grouped
by `worktreeId`. May be `null` while the daemon is unreachable.
- `sessions: DaemonSession[]` — every PTY id the daemon knows, with
`cwd`, `title`, and a sticky `isAlive`-equivalent.
- `tabsByWorktree`, `ptyIdsByTabId`, `runtimePaneTitlesByTabId`,
`workspaceSessionReady` — store-side context used to (a) compute
bound/orphan and (b) resolve human labels.
### Output: a renderer-local view model
The merge produces a renderer-local view-model — **not** a widening of
the shared `WorktreeMemory` shape. `src/shared/types.ts` and the
collector are untouched. New types live next to the merge helper:
```ts
// src/renderer/src/components/status-bar/unifiedRow.ts
type Metric = number | null // null === "no local sample" (e.g. SSH)
type UnifiedSessionRow = {
sessionId: string
label: string
bound: boolean
tabId: string | null
cpu: Metric
memory: Metric
hasLocalSamples: boolean
}
type UnifiedWorktreeRow = {
worktreeId: string
worktreeName: string
repoId: string
repoName: string
cpu: Metric
memory: Metric
history: number[] // empty when hasLocalSamples is false
hasLocalSamples: boolean
sessions: UnifiedSessionRow[]
}
type UnifiedRepoGroup = {
repoId: string
repoName: string
cpu: Metric // null if every child has hasLocalSamples false
memory: Metric
hasRemoteChildren: boolean // drives the "· remote" badge on the row
worktrees: UnifiedWorktreeRow[]
}
```
A small adapter inside `mergeSnapshotAndSessions` converts each
`WorktreeMemory` from the shared type into a `UnifiedWorktreeRow` with
numeric metrics and `hasLocalSamples: true`. Synthetic remote rows are
constructed directly with `null` metrics. The existing `MetricPair` and
`Sparkline` callsites in the popover get a thin wrapper that renders
`—` when its input is `null`; nothing in `src/shared/` or `src/main/`
changes.
### Merge algorithm
The merge is renderer-only and pure. Inputs in priority order:
- **Snapshot first.** `snapshot.worktrees` is the authoritative source
for any local PTY's CPU/Mem and for the worktree's identity (it
already carries `worktreeId`, `worktreeName`, `repoId`, `repoName`,
and a `sessions[]` array with per-session metrics).
- **Sessions augments.** `sessions: DaemonSession[]` is the union over
the snapshot — it adds (a) SSH PTYs that the local memory collector
cannot see and (b) any local PTY that registered too late to make
this snapshot's sweep.
#### Join key (snapshot session ↔ daemon session)
Both layers identify a session by the **same string**: the PTY id.
- `SessionMemory.sessionId` (collector) is `pty.sessionId ?? pty.ptyId`
from `pty-registry.ts`. In-process local PTYs spawn with
`args.sessionId === undefined` (`pty.ts:684`), so the registry stores
`sessionId: null` and the fallback `pty.ptyId` is used — which equals
`result.id` from the local provider's spawn, which is also what
`LocalPtyProvider.listProcesses()` returns as `id`
(`local-pty-provider.ts:518-522`).
- For daemon-hosted spawns, `mintPtySessionId(worktreeId)` produces the
`${worktreeId}@@${shortUuid}` form
(`src/main/daemon/pty-session-id.ts`); both the registry and
`pty.listSessions()` carry that exact string as the id.
Therefore the dedup key is simply `session.id` on both sides. The
merge MUST build `Set<string>` of all `SessionMemory.sessionId`s from
the snapshot and skip any `DaemonSession` whose `id` is already in the
set (so a local session never appears twice — once with metrics, once
as a `—` placeholder).
#### Worktree resolution for `DaemonSession`s
When a `DaemonSession` is **not** already accounted for by the
snapshot, we need to bucket it under a worktree group. Try in order:
1. **Tab-store walk** (existing logic in `SessionsTabPanel`): look up
`ptyIdsByTabId``tabId``tabsByWorktree``worktreeId`. This
resolves any session bound to a live tab in *this* renderer.
2. **Session-id parse**: if the id contains `@@`, take
`id.slice(0, id.lastIndexOf('@@'))` as a candidate `worktreeId`.
This is the convention enforced by `mintPtySessionId` (see
`pty-session-id.ts:5-7` and the symmetric parser in
`daemon-pty-adapter.ts:328-331`). It correctly recovers the
worktreeId for SSH sessions that haven't been bound to a tab in
this Orca instance — which is the user's primary scenario
(`orca/Stingray`, `orca/Sawfish`, etc.).
3. **Unattributed**: if neither resolves, bucket under a synthetic
`unattributed` repo group at the bottom of the list.
The `repoId` for each new worktree group is recovered the same way the
collector does it (`collector.ts:resolveWorktreeNames`): split the
worktreeId on the first `::`. Resolved repo display name comes from
the renderer-side store (`store.repos[repoId]?.displayName`) when
available; otherwise the bare `repoId` is used.
#### Step-by-step
1. Initialize `repos: Map<repoId, UnifiedRepoGroup>` empty.
2. Insert all `snapshot.worktrees` (if any) into the map, grouped by
their `repoId`. Each worktree carries `hasLocalSamples: true` and
numeric metrics; its sessions inherit numeric metrics.
3. Build `seenSessionIds = new Set<string>(...all session ids in
snapshot.worktrees[].sessions)`.
4. For each `DaemonSession` in `sessions: DaemonSession[]`:
- If `seenSessionIds.has(session.id)`, skip (already merged from
snapshot).
- Resolve `worktreeId` per the three-step rule above.
- If the resolved `worktreeId` is already a worktree group in the
map, append the session to its `sessions[]` with `cpu: null`,
`memory: null`, `hasLocalSamples: false`. (Reachable for SSH
sessions whose worktree happens to match a locally-active
worktree id — rare but possible.)
- Otherwise, create a new worktree group with `cpu: null`,
`memory: null`, `history: []`, `hasLocalSamples: false`, and
append the session.
5. Compute per-repo aggregates: sum `cpu`/`memory` from worktrees with
`hasLocalSamples === true`. If the repo has *any* worktree with
`hasLocalSamples === false`, set `repoHasRemoteChildren: true` (used
by the UI to render a `· remote` badge on the repo header so the
user knows the displayed totals exclude remote worktrees).
6. The Orca app section (Main / Renderer / Other) renders unchanged
below the list.
The whole pass is O(W + S) where W = snapshot worktrees and S =
sessions. No nested scans.
### Why this is safe with current data
- Every existing local session continues to render with the same
numbers. The merge **adds** rows; it does not change values.
- The snapshot rebuilds every poll (10s), so SSH sessions transitioning
to local (or vice versa) flip representation on the next tick. No
reconciliation logic needed.
- Orphan detection still works: a session is "bound" iff its
`sessionId` appears in `boundPtyIds`. Sessions in repos we couldn't
resolve still get the X-kill affordance.
## UI
### Trigger (status-bar badge) — unchanged
Mac/Linux/Windows safe (no platform-specific glyphs introduced).
### Popover — single panel, fixed height
```
┌─────────────────────────────────────────────────────────────┐
│ Resource Usage ⟳ 🗑 ✕ │ ← header
├─────────────────────────────────────────────────────────────┤
│ 1.9% · 955.2 MB · 3% of system RAM │ ← summary
├─────────────────────────────────────────────────────────────┤
│ Name CPU Memory · │ ← sort row
├─────────────────────────────────────────────────────────────┤
│ ▾ Triton 0.0% 125.5 MB │
│ Terminal 1 0.0% 63.2 MB ✕│
│ Terminal 1 0.0% 1.3 MB ✕│
│ ▾ Stingray ⓘ remote — — │
│ orca/Stingray — — ✕│
│ ▾ Sawfish ⓘ remote — — │
│ orca/Sawfish — — ✕│
│ ───────────────────────── │
│ ▸ Orca app 0.5% 829.7 MB │
└─────────────────────────────────────────────────────────────┘
↑ fixed 420px body, owns its own scroll
↓ orphan bulk-kill pill renders here when N>0
```
The header keeps the icon-only **Restart daemon** (RotateCw) and
**Kill all sessions** (Trash2) buttons in the top-right.
### Interaction states (4 paths)
| Scenario | Resource cells | Kill X | Click row |
|-----------------------------|----------------|-------------|--------------|
| Local PTY, bound | numeric | hover only | navigates |
| Local PTY, orphan | numeric | always | no-op |
| Remote PTY, bound | `` | hover only | navigates |
| Remote PTY, orphan | `` | always | no-op |
`` is rendered as `text-muted-foreground/50`. The worktree row carries
a small `· remote` badge when `hasLocalSamples === false`; the repo row
carries the same badge when `hasRemoteChildren === true` (so users
know the displayed repo totals exclude remote worktrees).
**`bound` semantics are unchanged from today:** a session is bound iff
`boundPtyIds.has(session.id)` evaluated against this renderer's
`ptyIdsByTabId`. SSH sessions bound to tabs in *another* Orca process
look identical to local orphans here — same as before the merge.
### Empty / loading / error states
- Daemon unreachable → existing banner remains, body shows nothing.
- Sessions error AND no snapshot AND zero sessions → "Resource data
unavailable. Restart daemon."
- Snapshot present but zero worktrees AND zero sessions → "Nothing
running right now."
- Loading (no snapshot, no error) → "Loading…".
### Sort
Three buttons in the sort row: **Name**, **CPU**, **Memory**. CPU/Mem
sort puts `null` metrics last (stable). Name uses `localeCompare`.
Selection is repo-group aware: sorting by CPU sorts repos by aggregate
CPU and worktrees within a repo by their CPU.
### Confirm dialogs (unchanged)
- Per-row kill X: confirm Dialog with copy "Kill this session? Force-
quits `<id>`. Any unsaved work in that pane is lost. This can't be
undone."
- Header Trash2: existing "Kill all sessions" Dialog from
`useDaemonActions`.
- Header RotateCw: existing "Restart daemon" Dialog from
`useDaemonActions`.
- Worktree-level Sleep / Delete: unchanged.
## Implementation plan
1. **New helper** `mergeSnapshotAndSessions.ts` (renderer-side, pure
function) that takes `(snapshot, sessions, storeContext)` and
returns the unified `RepoGroup[]` shape above. Unit-testable.
2. **Refactor `ResourceUsageStatusSegment.tsx`**:
- Remove the tab switcher (drop `activeTab` state, both
`'resources'` / `'sessions'` branches, the inline pill component,
and the `Trash2` import for the old switcher).
- Replace the body with a single render path that consumes the
unified `RepoGroup[]` from the helper.
- Keep the existing `WorktreeSection`/`AppSection` patterns — extend
them to render `null` metrics as `` and to render the kill-X on
each session row (with bound-vs-orphan visibility rule).
- Promote `killConfirm` state and the confirm Dialog from
`SessionsTabPanel` to the segment level since there's only one
panel now.
- Keep `onSessionsChanged` plumbing — call it from kill / kill-
orphans handlers to trigger an immediate `refreshSessions()`.
- Delete the now-unused `SessionsTabPanel` component.
3. **Wire metric formatting** to render `` when metric is `null`. Use
the `null`-vs-`0` distinction already present in the data model;
don't conflate them.
4. **Migration**: status-bar item id stays `'resource-usage'`. The
`migrateStatusBarItems` helper is unchanged. No persistence
migration needed for this UI-only change.
## Testing
- Unit: `mergeSnapshotAndSessions` with synthetic inputs covering:
- All 4 interaction-state paths (local-bound, local-orphan,
remote-bound, remote-orphan).
- **Dedup**: a local session that appears in both the snapshot and
the daemon list is rendered exactly once with numeric metrics.
- **`@@` parse**: an SSH session id `repoX::/path/wtA@@abcd` with no
matching tab resolves to repoId `repoX`, worktreeId
`repoX::/path/wtA`.
- **Tab walk wins over `@@` parse**: a session bound to a tab whose
worktreeId differs from the `@@` prefix uses the tab's worktreeId
(defensive against id-format drift).
- **Repo aggregate excludes remote children**: a repo with one
local (125 MB) and one remote (`null`) worktree reports
`cpu/memory = 125 MB` and `hasRemoteChildren = true`.
- Edge cases: snapshot null, sessions empty, both empty, sessionId
without `@@` and no tab match → falls into Unattributed.
- Manual:
- Open with mixed local + SSH worktrees → both render, SSH rows show
`` and a `· remote` badge on their worktree and repo headers.
- Kill a remote session via the X → confirm dialog → optimistic
removal so row disappears immediately (does not wait 10s).
- Kill all (header trash) → orphans gone, bound sessions remain.
- Resize window / scroll the body → fixed 420px holds, no reflow.
- Accessibility: tab through rows → kill X focusable; after kill
confirm, focus lands back on the popover content root, not on
`<body>`. Dialog is keyboard-dismissable except while in-flight.
## Risks & mitigations
- **Risk**: SSH worktrees the user never opened locally still appear in
Resources (potentially noisy if the daemon retains many idle SSH
sessions). **Mitigation**: bucketing under a `· remote` badge keeps
them visually distinct from local-active groups; sort-by-memory pushes
them to the bottom. If noise becomes a real complaint, a future PR
can add a "hide remote" toggle.
- **Risk**: Some sessions can't be resolved to any worktreeId (e.g.
daemon-internal sessions whose ids don't follow the `@@` convention,
or sessions whose worktreeId resolves to a repo store doesn't know
about). **Mitigation**: synthetic `Unattributed` repo group at the
bottom, which already exists in spirit as `ORPHAN_WORKTREE_ID` in the
memory collector.
- **Risk**: Kill-X on a remote session fires `pty.kill(id)`, which the
IPC layer routes through `ptyOwnership` to the right SSH provider.
Behavior is identical to the old Sessions tab — no new failure mode.
- **Risk: poll-rate skew between snapshot (2s) and sessions (10s).**
After killing an SSH session, the daemon-side `sessions` list can
retain the dead row for up to 10s while the snapshot has already
moved on; conversely a freshly-spawned local PTY shows up in
`sessions` first and is rendered as a remote placeholder for up to
2s before the next snapshot promotes it.
**Mitigation**: per-row kill optimistically removes the session id
from the renderer's local `sessions` state immediately after the IPC
resolves (in addition to calling `onSessionsChanged()`), so the
killed row never lingers on screen. The new-session-flash is
bounded by `POLL_MS` and acceptable.
- **Risk: focus dropped to `<body>` after kill confirm.** When the
killed session disappears on the next refresh, the per-row X that
had focus is unmounted. With the confirm Dialog now mounted at the
segment level, this regression is more visible.
**Mitigation**: after `runKillConfirmed` resolves, focus the
popover's content root via a stable ref so keyboard users land back
in the list, not on `<body>`.
## Non-goals
- No new IPC.
- No collector changes.
- No new persisted UI prefs (the per-tab visibility split is gone, not
replaced with a per-section toggle).
- No remote memory sampling — that's a separate, much bigger project.
-54
View File
@@ -1,54 +0,0 @@
# Right Sidebar Header Drag Region
## Problem
Right sidebar header blank space does not drag the window.
- In top mode, the header row (`right-sidebar/index.tsx`, current line ~313) has no `-webkit-app-region: drag`.
- In side mode, the title row (`right-sidebar/index.tsx`, current line ~332) has no `-webkit-app-region: drag`.
- The app already uses draggable titlebar surfaces elsewhere (`.titlebar`, `.titlebar-left` in `main.css`), so this is an inconsistency.
## Current Behavior (Verified)
- `ActivityBarButton` renders plain `<button>` elements with no `no-drag` class.
- Close button is already safe because `.sidebar-toggle` sets `-webkit-app-region: no-drag`.
- Top-mode activity bar context menu is currently bound to the full header row via `ContextMenuTrigger`.
- Side-mode activity bar context menu is bound to the side icon strip, not the side-mode title row.
## Constraints That Matter
- `-webkit-app-region: drag` is required for Electron window drag hit-testing.
- Interactive descendants inside a drag region must be `-webkit-app-region: no-drag`, or click behavior becomes unreliable.
- Do not depend on blank-space right-click inside a drag region for opening menus; attach context-menu trigger to a guaranteed `no-drag` target.
- No visual/layout changes; keep existing Windows inset behavior (`right-sidebar-header-inset`, `right-sidebar-header-side-inset`, `side-activity-bar-windows-inset`).
## Design
1. Add utility classes in `src/renderer/src/assets/main.css`:
- `.right-sidebar-header-drag { -webkit-app-region: drag; user-select: none; }`
- `.right-sidebar-header-no-drag { -webkit-app-region: no-drag; }`
2. Apply `.right-sidebar-header-drag` to both header wrappers:
- top activity-bar row
- side-mode title row
3. Apply `.right-sidebar-header-no-drag` to all interactive header descendants:
- `ActivityBarButton` root button
- top-mode icon-row wrapper that owns context-menu trigger
- close button is already covered by `.sidebar-toggle` (keep as-is)
4. Move top-mode `ContextMenuTrigger` from the whole header row to a `no-drag` target (icon-row wrapper). Do not bind it to the drag surface.
5. Keep side-mode context-menu wiring on the side icon strip unchanged.
## Validation
- Top mode: blank area drags window.
- Top mode: tab icons click reliably; close button click remains reliable.
- Top mode: context menu opens from icon row (not blank drag area).
- Side mode: title/blank area drags; close button remains clickable.
- Left-edge resize handle remains usable (`z-10`, absolute overlay).
- Windows: overlay controls do not cover close button/icons after insets.
- Sidebar closed (`width: 0`, `overflow-hidden`): no leaked hit targets.
## Concurrency / Consistency
- Multi-window safe: renderer-local CSS/DOM only, no shared mutable state.
- Store updates (`activityBarPosition`, `rightSidebarTab`, `rightSidebarOpen`) are safe: drag/no-drag is static class wiring on re-rendered elements.
- No IPC or async cross-process dependency, so no invalidation races introduced.
-87
View File
@@ -1,87 +0,0 @@
# Sidebar Repo Filter Redesign
## Problem
The current sidebar filter UI does not scale beyond a small repo count. Rendering every repo as a checkbox row in a menu creates poor scanability, high click cost, and clipping/scroll friction.
Constraints from current code:
- Filter semantics are store-driven (`showActiveOnly`, `hideDefaultBranchWorkspace`, `filterRepoIds`) and consumed by `computeVisibleWorktreeIds`.
- `searchRepos()` already provides ranking (display name first, path fallback).
- The trigger badge is the primary “filters active” affordance and must stay.
## Goals
1. Make repo filtering usable with large repo sets.
2. Keep toggle filters one click.
3. Add fast bulk actions (`All`, `None`, `Clear all`).
4. Preserve existing filter semantics and badge behavior.
## Non-goals
- No semantic changes to filtering logic in `visible-worktrees.ts`.
- No sticky-all persistence model like `RepoMultiCombobox`.
- No store schema migration.
## Correctness Notes (from code audit)
- `searchRepos()` is not debounced; it is synchronous `useMemo` filtering.
- Popover wheel rescue in `popover.tsx` only runs when the wheel handler is attached to a `PopoverContent` element that has class `popover-scroll-content`. Putting that class on an inner div does not activate the rescue path.
- `command.tsx` already includes a wheel fallback on `CommandList` for scroll-locked Radix Dialog parents.
- Sidebar filters are persisted via `window.api.ui.set` (debounced in `App.tsx`) and restored from persisted UI on launch.
## Proposed UI
Use `Popover` and keep trigger button/badge unchanged.
Popover sections:
1. Header: `Filters` + `Clear all` (shown only when any filter is active).
2. Toggle rows: `Active only`, `Hide default branch`.
3. Repo section (only when `repos.length > 1`):
- Label + selected count.
- `All` and `None` actions.
- Search box (`Search repos...`).
- Scrollable repo list with checkmark, repo dot/name, and SSH badge when `connectionId` exists.
- Secondary path line for disambiguation when names collide.
4. Bottom action: `Add project` pinned below repo list.
## Implementation Decision: cmdk vs plain input
Use `Command` primitives (as in `repo-multi-combobox.tsx`) with `shouldFilter={false}` and feed pre-ranked `searchRepos()` results.
Why:
- Better keyboard behavior out of the box (arrow navigation, enter selection).
- Existing `CommandList` wheel fallback handles scroll-lock contexts that currently break plain inner-scroll regions.
- Consistent behavior with an existing repo selector pattern in the app.
## Edge Cases / Invalidation
- Repo removed while popover is open: selection count and rows derive from live `repos`; stale ids must not count toward badge or selected count.
- Repo added while open: `All` should include the new repo immediately (derive from current `repos` each render).
- External repo mutations (sync/import) during search: filtered list updates from live `repos`; empty-state must not hide `Add project`.
- SSH repos: show SSH indicator to avoid ambiguity with local repos of same display name.
- 0 or 1 repo: hide repo filter section. Keep `Add project` visible outside the repo-count gate so users can still recover from low-repo states.
- Multi-window: filter state is window-local; this redesign does not introduce cross-window synchronization.
## Accessibility / Focus
- Do not use `role="menuitemcheckbox"` unless the container is a true menu. Use semantic buttons or cmdk items with explicit `aria-selected`/checked indicators.
- Autofocus search on open.
- Esc closes via Radix Popover default.
- Ensure focus returns to trigger on close.
## Rollout
1. Replace sidebar filter content with Popover + Command-based repo list.
2. Keep `searchRepos()` as the only ranking source.
3. Keep current filter setters and badge derivation semantics.
4. Ensure scroll behavior works inside dialog parents by using `CommandList` (or by moving wheel handling to the actual scroll container).
5. Validate with `pnpm typecheck` and `pnpm lint`.
## Test Scope
- Unit coverage remains in `visible-worktrees` for filter semantics.
- Add/adjust component tests for:
- keyboard navigation and selection,
- stale repo id handling,
- `All`/`None` behavior with live repo mutations,
- `Add project` visibility at repo count 0/1.
-18
View File
@@ -1,18 +0,0 @@
# Sidekick animation state mapping
Orca's normalized agent status model has four hook-reported states: `working`, `blocked`, `waiting`, and `done`. `interrupted` is an optional flag on `done`, not a separate failure state.
Codex pet spritesheets can expose more visual rows than Orca has agent states. Sidekick should treat those row names as an asset contract, not as proof that Codex or Orca reports matching runtime states.
The current mapping is:
| Orca condition | Sidekick animation |
| --- | --- |
| Sidekick is being dragged | `jumping` |
| Any fresh agent is `blocked` or `waiting` | `waiting` |
| Any fresh agent is `working` | `running` |
| Any fresh agent is `done` | `review` |
| Any retained completed agent exists | `review` |
| No fresh or retained agent state exists | `idle` |
`SidekickAnimationName` deliberately omits `failed`. Orca distinguishes interrupted completions from normal completions, but interruption can mean user cancellation rather than agent failure, so mapping it to `failed` would overstate the status until Orca has a real failure/error signal. Codex spritesheets may still expose a `failed` row — that row stays as part of the asset contract but is never selected at runtime.
-176
View File
@@ -1,176 +0,0 @@
# Tasks Page Resume State
## Goal
When a user leaves the Tasks page and comes back later, Orca should reopen the page in the same working context instead of falling back to a generic GitHub Issues/PRs list.
The resume behavior should cover:
- GitHub vs Linear.
- GitHub Issues/PRs vs GitHub Project mode.
- The selected GitHub Issues/PRs preset or custom search.
- The selected GitHub Project and Project view.
- The selected repos for GitHub Issues/PRs.
- The selected Linear teams.
This should be lightweight. It should not introduce a second project-selection model or duplicate state that is already persisted elsewhere.
## Existing Persisted State
The current settings model already remembers most durable task choices:
- `settings.defaultTaskSource` remembers GitHub vs Linear.
- `settings.defaultRepoSelection` remembers GitHub repo selection for cross-repo Issues/PRs.
- `settings.defaultLinearTeamSelection` remembers Linear team selection.
- `settings.githubProjects.activeProject` remembers the active GitHub Project.
- `settings.githubProjects.lastViewByProject` remembers the last selected Project view per Project.
- `settings.defaultTaskViewPreset` remembers the user's default GitHub Issues/PRs preset.
These should stay in place. They are already wired into the page and, for repo/team/project selection, they represent actual user preferences rather than purely transient page state.
## Missing State
The missing piece is the user's current page position inside Tasks:
- Whether GitHub is showing `Issues/PRs` or `Project`.
- Which GitHub Issues/PRs preset is currently active.
- The currently applied GitHub Issues/PRs query when the user has typed a custom search.
- Which Linear preset is currently active.
- The currently applied Linear query when the user has typed a search.
Linear presets are visible task tabs and drive the fetch path, so they must be restored too.
## Proposed Shape
Add a small optional field to `PersistedUIState`:
```ts
export type TaskResumeState = {
githubMode?: 'items' | 'project'
githubItemsPreset?: TaskViewPresetId | null
githubItemsQuery?: string
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
linearQuery?: string
}
```
Then add:
```ts
taskResumeState?: TaskResumeState
```
to `PersistedUIState`.
Why `PersistedUIState`: this is page-position UI state, similar to sidebar filters and widths. It is not an app-wide setting and should not be presented as a configurable default.
Do not add `source` to `TaskResumeState`. `settings.defaultTaskSource` already represents the user's last-used task source today, and duplicating it would create two persisted sources of truth. Keep source changes on the existing settings path.
Do not reset `githubMode` just because the current source is Linear. Source selection and GitHub sub-mode are independent pieces of context: if the user was in GitHub Project mode, switched to Linear, then later switches back to GitHub, GitHub should still reopen in Project mode.
## Restore Rules
On Tasks page mount:
1. If `taskPageData.taskSource` was passed by a caller, use it. Explicit navigation intent should win.
2. Otherwise fall back to `settings.defaultTaskSource`.
3. Apply these restore rules only after both settings and persisted UI state have hydrated. The current app loads those asynchronously before setting `persistedUIReady`; a `useState` initializer that reads `settings === null` or an unhydrated `taskResumeState` will capture defaults and miss the restored context.
The restore should run once per Tasks page mount, after hydration is ready. It should not keep reapplying persisted state over local user interactions while the page remains open.
For GitHub:
1. Restore `githubMode` from `taskResumeState.githubMode`.
2. If mode is `items`, restore `githubItemsPreset` and `githubItemsQuery`.
3. Repo selection continues to come from `settings.defaultRepoSelection`.
4. If mode is `project`, use `settings.githubProjects.activeProject` and `settings.githubProjects.lastViewByProject`.
5. If Project mode has no active project or view, show the Project picker empty state instead of silently switching to Issues/PRs.
For Linear:
1. Restore `linearPreset`.
2. Restore `linearQuery`.
3. Team selection continues to come from `settings.defaultLinearTeamSelection`.
Fallback behavior:
- If resume state is absent, use today's defaults.
- If `githubItemsPreset` is non-null, derive the query from the preset unless `githubItemsQuery` disagrees because of a future migration. The preset should be treated as authoritative.
- If `githubItemsPreset` is null, use `githubItemsQuery` as a custom search.
- If `linearQuery` is non-empty, treat it as custom search and keep the restored `linearPreset` as the preset to return to after the search is cleared. The visible active preset should be suppressed while the search input is non-empty, matching today's UI.
## Write Rules
Update `taskResumeState` only when the user changes the active page context:
- Switching GitHub mode between Issues/PRs and Project.
- Clicking a GitHub Issues/PRs preset.
- Applying the debounced GitHub Issues/PRs search.
- Clearing the GitHub Issues/PRs search.
- Clicking a Linear preset.
- Applying the debounced Linear search.
- Clearing the Linear search.
Do not persist every raw search keystroke. The current UI applies search after a 300 ms debounce, so the persisted value should follow the debounced applied query, not the raw input. A user who types, waits for results, leaves Tasks, and comes back should see the same applied query.
Task source, repo selection, Linear team selection, active Project, and active Project view should continue writing through their existing settings paths.
The resume setter should merge partial updates with the existing resume state so changing one dimension does not erase the others. It should persist via `window.api.ui.set({ taskResumeState })` after updating local Zustand state.
When writing preset selections, clear stale custom-query ambiguity explicitly:
- GitHub preset click: write `{ githubItemsPreset: presetId, githubItemsQuery: undefined }` or the canonical preset query. Restore must still use the preset as authoritative.
- GitHub custom search apply/debounce: write `{ githubItemsPreset: null, githubItemsQuery: trimmedQuery }`.
- GitHub search clear: write `{ githubItemsPreset: null, githubItemsQuery: '' }`.
- Linear preset click: write `{ linearPreset: presetId, linearQuery: '' }`.
- Linear custom search apply/debounce: write `{ linearQuery: trimmedQuery }` without changing `linearPreset`.
- Linear search clear: write `{ linearQuery: '' }` without changing `linearPreset`.
Do not persist mode changes caused only by hiding Project mode while another source is active. In practice, avoid an effect that changes persisted `githubMode` from `project` to `items` when `taskSource !== 'github'`.
## Implementation Notes
Primary files:
- `src/shared/types.ts`: add `TaskResumeState` and `taskResumeState?: TaskResumeState`.
- `src/shared/constants.ts`: no required default beyond leaving the field absent. If a concrete default is preferred, use GitHub Issues/PRs with the existing default preset.
- `src/renderer/src/store/slices/ui.ts`: hydrate `taskResumeState` defensively, expose a setter such as `setTaskResumeState`, and use it in `openTaskPage` prefetch selection.
- `src/renderer/src/components/TaskPage.tsx`: initialize source from existing settings/page data, and initialize GitHub mode, GitHub item preset/query, Linear preset, and Linear query from the resume state.
- `src/renderer/src/App.tsx`: no new work should be needed if the field rides the existing `ui:get` hydration.
The setter should call `window.api.ui.set({ taskResumeState })` through the same persistence path used by other persisted UI fields, or follow the existing store pattern if there is already a central UI save effect for the relevant slice.
Hydration must sanitize the nested object because `PersistedUIState` comes from disk. Invalid `githubMode`, invalid GitHub preset ids, invalid Linear preset ids, or non-string queries should fall back field-by-field rather than entering Zustand as untrusted values.
`TaskPage` should subscribe to `persistedUIReady`, `settings`, and `taskResumeState`, then perform a one-shot local initialization once both settings and UI hydration are available. This avoids the current class of bugs where `useState(settings?.defaultTaskViewPreset ?? 'all')` captures `'all'` before settings arrive.
`openTaskPage` currently prefetches the settings default GitHub preset. After this change, it should prefetch the query the Tasks page will actually mount with:
1. If explicit `taskPageData.taskSource` is `linear`, skip GitHub prefetch.
2. If explicit `taskPageData.taskSource` is `github`, or the resolved source is GitHub, prefetch only when the resolved GitHub mode is `items`.
3. If `githubItemsPreset` is non-null, prefetch that preset query.
4. If `githubItemsPreset` is null and `githubItemsQuery` is non-empty, prefetch the custom query.
5. Otherwise prefetch `settings.defaultTaskViewPreset`.
The prefetch should also use the same repo selection the page will use: `taskPageData.preselectedRepoId` when present, otherwise `settings.defaultRepoSelection`, otherwise all eligible repos. Warming only `activeRepoId` is acceptable as an optimization fallback, but it is not equivalent to the mounted cross-repo query.
## Non-Goals
Do not remember scroll position, selected table row, open dialogs, pagination page, Project search overrides, or cached API results in this pass.
Those states are more fragile because they depend on live remote data. The first version should only restore the user's broad working context.
## Acceptance Criteria
- Open Tasks, switch to Linear, leave Tasks, return to Tasks: Linear is selected.
- Open Tasks, switch to GitHub Project mode, switch to Linear, leave Tasks, return to Tasks, then switch back to GitHub: GitHub is still in Project mode.
- Open Tasks, switch to GitHub Project mode, select a Project view, leave Tasks, return to Tasks: Project mode opens on the same Project view.
- Open Tasks, choose GitHub Issues/PRs `My PRs`, leave Tasks, return to Tasks: GitHub Issues/PRs opens with `My PRs` active.
- Open Tasks, type and apply a custom GitHub search, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that custom query and no preset selected.
- Open Tasks, type a GitHub search, wait for the debounced results, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that applied query and no preset selected.
- Open Tasks, choose Linear `Completed`, leave Tasks, return to Tasks: Linear opens with `Completed` active.
- Open Tasks, type a Linear search, wait for the debounced results, leave Tasks, return to Tasks: Linear opens with that applied query.
- Open Tasks before hydration has completed, then wait for hydration: Tasks applies the persisted source/mode/preset/query exactly once and does not overwrite subsequent user changes.
- Corrupt `taskResumeState` on disk with invalid modes/preset ids/non-string queries, restart, and open Tasks: invalid fields are ignored field-by-field without breaking the page.
- Existing repo selection, Linear team selection, and GitHub Project view persistence keep working unchanged.
- Restart the app after each source/mode/preset/custom-query scenario above: the same Tasks context is restored from persisted UI/settings state.