diff --git a/AGENTS.md b/AGENTS.md index 2c117478e84..1fd213f742e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,7 @@ All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. ## Code Comments: Document the "Why", Briefly - -When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does. - -Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point. - -The comment's audience is the next person reading that line, not a PR description. If a comment lists call sites, recounts the crash/history, or re-describes the code below it, cut it to the one non-obvious fact: +When code is non-obvious, Add code comments explaining **why** (not HOW). BE CONCISE — ideally 1 line. ```ts // 🚫 Over-explained — narrates the crash, the mechanism, where the helper lives, and every caller: diff --git a/README.md b/README.md index 87ccc4995ff..98a1e21b4a1 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,16 @@

- GitHub stars + GitHub stars Total downloads across all releases - License + License: MIT Join the Orca Discord Follow Orca on X Supported platforms: macOS, Windows, and Linux

- Español · Português · 中文 · 日本語 · 한국어 + 中文 · 日本語 · 한국어 · Español · Français · Português

@@ -22,6 +22,10 @@

Download Orca

+

+ Windows? Grab the latest RC — has Windows fixes. +

+

Orca desktop app running agents in parallel worktrees, with the Orca mobile companion app in the corner

@@ -212,7 +216,8 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. ### Desktop — macOS, Windows, Linux - **[Download from onOrca.dev](https://onorca.dev/download)** -- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest) +- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/download/v1.4.147-rc.4/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest) +- **Windows users:** use the [latest RC (`v1.4.147-rc.4`)](https://github.com/stablyai/orca/releases#release-v1.4.147-rc.4) — it includes Windows-specific bug fixes not yet in stable. - Running `orca serve` on a headless Linux server? See the [headless Linux server guide](docs/reference/headless-linux-server.md). _Or via a package manager:_ diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs index dc593b650b2..4ed155e8c5f 100644 --- a/config/scripts/daemon-boot-smoke.mjs +++ b/config/scripts/daemon-boot-smoke.mjs @@ -36,10 +36,11 @@ function log(message) { // Why: the daemon rejects a hello whose protocol version differs, so read the // current version from source rather than hardcoding a number that can drift. function readProtocolVersion() { - const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8') + const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts' + const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8') const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/) if (!match) { - throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts') + throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`) } return Number(match[1]) } diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs index be920ca7192..1385ffc17d2 100644 --- a/config/scripts/windows-daemon-workspace-close-repro.mjs +++ b/config/scripts/windows-daemon-workspace-close-repro.mjs @@ -22,7 +22,10 @@ function log(message) { } function readProtocolVersion() { - const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8') + const source = readFileSync( + join(projectDir, 'src/main/daemon/daemon-protocol-version.ts'), + 'utf8' + ) const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/) if (!match) { throw new Error('Could not read the daemon protocol version') @@ -30,19 +33,99 @@ function readProtocolVersion() { return Number(match[1]) } +function createStreamSocket(socketPath, token, protocolVersion, clientId, onFailure) { + const socket = connect(socketPath) + let buffer = '' + + return new Promise((resolveStream, rejectStream) => { + let handshakeComplete = false + const rejectHandshake = (error) => { + if (handshakeComplete) { + onFailure(error) + return + } + handshakeComplete = true + clearTimeout(timer) + socket.removeListener('data', onData) + socket.removeListener('error', onError) + socket.removeListener('close', onClose) + socket.destroy() + rejectStream(error) + } + const onError = (error) => rejectHandshake(error) + const onClose = () => rejectHandshake(new Error('Daemon stream socket closed')) + const onData = (chunk) => { + buffer += chunk.toString('utf8') + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const message = JSON.parse(buffer.slice(0, newline)) + if (message.type !== 'hello') { + return + } + if (!message.ok) { + rejectHandshake(new Error(message.error ?? 'Daemon rejected stream hello')) + return + } + handshakeComplete = true + clearTimeout(timer) + socket.removeListener('data', onData) + // Why: drain terminal events even though this lifecycle repro only asserts through RPC. + socket.on('data', () => {}) + resolveStream(socket) + } + const timer = setTimeout( + () => rejectHandshake(new Error('Daemon stream hello timed out')), + requestTimeoutMs + ) + socket.on('error', onError) + socket.on('close', onClose) + socket.on('data', onData) + socket.once('connect', () => { + socket.write( + `${JSON.stringify({ + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'stream' + })}\n` + ) + }) + }) +} + function createRpcClient(socketPath, tokenPath) { const socket = connect(socketPath) + const clientId = randomUUID() + const protocolVersion = readProtocolVersion() + const token = readFileSync(tokenPath, 'utf8').trim() const pending = new Map() let buffer = '' let requestId = 0 + let streamSocket + let connectionError let helloResolve let helloReject + let helloTimer const hello = new Promise((resolveHello, rejectHello) => { - helloResolve = resolveHello - helloReject = rejectHello + helloResolve = () => { + clearTimeout(helloTimer) + resolveHello() + } + helloReject = (error) => { + clearTimeout(helloTimer) + rejectHello(error) + } }) + helloTimer = setTimeout(() => { + helloReject(new Error('Daemon control hello timed out')) + socket.destroy() + }, requestTimeoutMs) const rejectPending = (error) => { + connectionError ??= error helloReject(error) for (const { reject, timer } of pending.values()) { clearTimeout(timer) @@ -82,25 +165,40 @@ function createRpcClient(socketPath, tokenPath) { } }) - const connected = new Promise((resolveConnected, rejectConnected) => { + const socketConnected = new Promise((resolveConnected, rejectConnected) => { socket.once('connect', resolveConnected) socket.once('error', rejectConnected) - }).then(() => { - socket.write( - `${JSON.stringify({ - type: 'hello', - version: readProtocolVersion(), - token: readFileSync(tokenPath, 'utf8').trim(), - clientId: randomUUID(), - role: 'control' - })}\n` + }) + const connected = Promise.all([ + socketConnected.then(() => { + socket.write( + `${JSON.stringify({ + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'control' + })}\n` + ) + }), + hello + ]).then(async () => { + // Why: v24 only admits terminals for the same complete control+stream pair as production. + streamSocket = await createStreamSocket( + socketPath, + token, + protocolVersion, + clientId, + rejectPending ) - return hello }) return { async request(type, payload) { await connected + if (connectionError) { + throw connectionError + } const id = `repro-${++requestId}` return new Promise((resolveRequest, rejectRequest) => { const timer = setTimeout(() => { @@ -112,6 +210,7 @@ function createRpcClient(socketPath, tokenPath) { }) }, close() { + streamSocket?.destroy() socket.destroy() } } diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 1825ee96d32..a21f01fca97 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 6.5m + + downloads: 6.7m @@ -15,7 +15,7 @@ downloads downloads - 6.5m - 6.5m + 6.7m + 6.7m diff --git a/docs/bug-reproductions/8979-screenshots/after-fable-visible.png b/docs/bug-reproductions/8979-screenshots/after-fable-visible.png new file mode 100644 index 00000000000..0e8d49a8e34 Binary files /dev/null and b/docs/bug-reproductions/8979-screenshots/after-fable-visible.png differ diff --git a/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png b/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png new file mode 100644 index 00000000000..4100383237e Binary files /dev/null and b/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png differ diff --git a/docs/claude-scoped-oauth-usage-limits.md b/docs/claude-scoped-oauth-usage-limits.md index e5eb3c9e2d3..bac2679f86c 100644 --- a/docs/claude-scoped-oauth-usage-limits.md +++ b/docs/claude-scoped-oauth-usage-limits.md @@ -25,7 +25,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri 2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite. 3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp. 4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks. -5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility. +5. Keep malformed, unrelated, or absent entries non-fatal. Do NOT gate on `is_active`: it marks which limit is currently binding, not whether the entry's data is valid, so an `is_active: false` Fable entry with a finite `percent` must still render (#8979). Accept a missing activity flag for compatibility. ## Data Flow @@ -40,7 +40,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri - `limits` is missing, null, malformed, or contains null entries. - A scoped entry names another model. - Fable percent is missing, non-numeric, or non-finite. -- Fable is inactive and should not be rendered. +- Fable is inactive (`is_active: false`) but still carries a finite `percent`/reset, so it must render (#8979). - `is_active` is omitted by an older server response but the remaining scoped entry is valid. - Both current and legacy fields exist; the current scoped entry wins. - Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent. @@ -50,7 +50,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri - Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt. - Unit: assert scoped data wins over a legacy field. -- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available. +- Unit: assert malformed and unrelated scoped entries are ignored while legacy fallback remains available; assert an inactive-but-valid Fable entry still surfaces (#8979). - Regression: retain existing legacy-field and bare-`fable` behavior tests. - Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet. - Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface. @@ -76,8 +76,8 @@ No UI implementation changes. The existing Fable row must reappear with the same - Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable. - Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement. -- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution. -- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback. +- Failure modes covered: malformed optional data, unrelated models, inactive-but-valid limits (still rendered, #8979), missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution. +- Test coverage required: current-schema success without PTY, precedence, inactive-but-valid rendering, malformed/unrelated entries, and legacy fallback. - Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work. - UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row. - Required review screenshots: Live Claude details with all three rows; surrounding status-bar context. diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 5e6638196da..e29c91a7972 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -3,16 +3,16 @@

- Estrellas en GitHub + Estrellas en GitHub Descargas totales en todas las versiones - Licencia + Licencia: MIT Únete al Discord de Orca Sigue a Orca en X Plataformas compatibles: macOS, Windows y Linux

- English · Português · 中文 · 日本語 · 한국어 + English · 中文 · 日本語 · 한국어 · Français · Português

diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md new file mode 100644 index 00000000000..a6e4e50b773 --- /dev/null +++ b/docs/readme/README.fr.md @@ -0,0 +1,274 @@ +

+ Orca Orca +

+ +

+ Étoiles GitHub + Téléchargements totaux sur toutes les versions + Licence + Rejoindre le Discord Orca + Suivre Orca sur X + Plateformes prises en charge : macOS, Windows et Linux +

+ +

+ English · 中文 · 日本語 · 한국어 · Español · Português +

+ +

+ L'orchestrateur d'IA pour les builders 100x.
+ Lancez Codex, Claude Code, OpenCode ou Pi côte à côte — chacun dans son propre worktree, le tout suivi au même endroit. +

+ +

Télécharger Orca

+ +

+ Sous Windows ? Prenez la dernière RC — elle inclut des correctifs Windows. +

+ +

+ Application de bureau Orca exécutant des agents dans des worktrees parallèles, avec l'app companion mobile Orca dans le coin +

+ +## Fonctionnalités + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +### Companion mobile + +Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez. + +[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) + + + Orca desktop avec l'app companion mobile +
+ +### Worktrees parallèles + +Lancez un même prompt sur cinq agents, chacun dans son propre worktree git isolé — comparez les résultats et mergez le gagnant. + +[Docs →](https://www.onorca.dev/docs/model/worktrees) + + + Orchestration de worktrees parallèles +
+ +### Splits de terminal + +Terminaux de niveau Ghostty avec rendu WebGL, splits infinis et un scrollback qui survit aux redémarrages. + +[Docs →](https://www.onorca.dev/docs/terminal) + + + Splits de terminal +
+ +### Mode Design + +Cliquez sur n'importe quel élément d'UI dans une vraie fenêtre Chromium pour envoyer son HTML, son CSS et une capture recadrée directement dans le prompt de votre agent. + +[Docs →](https://www.onorca.dev/docs/browser/design-mode) + + + Navigateur intégré et Mode Design +
+ +### GitHub & Linear, natifs + +Parcourez PRs, issues et boards de projet dans l'app — ouvrez un worktree depuis n'importe quelle tâche et reviewz sans changer de contexte. + +[Docs →](https://www.onorca.dev/docs/review/linear) + + + Workflows GitHub et Linear dans Orca +
+ +### Worktrees SSH + +Faites tourner des agents sur une machine distante costaude, avec édition de fichiers, git et terminaux complets — reconnexion auto et port forwarding inclus. + +[Docs →](https://www.onorca.dev/docs/ssh) + + + Worktrees distants via SSH +
+ +### Annoter les diffs IA + +Posez des commentaires sur n'importe quelle ligne de diff et renvoyez-les à l'agent — review, édition et commit sans quitter Orca. + +[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff) + + + Annoter les diffs générés par l'IA +
+ +### Glisser-déposer vers les agents + +L'éditeur VS Code avec autosave partout — glissez fichiers ou images directement dans le prompt d'un agent. + +[Docs →](https://www.onorca.dev/docs/editing/file-explorer) + + + Glisser des fichiers et images dans le prompt d'un agent +
+ +### Orca CLI + +Les agents pilotent aussi Orca — scriptez n'importe quel workflow avec `orca worktree create`, `snapshot`, `click` et `fill`. + +[Docs →](https://www.onorca.dev/docs/cli/overview) + + + Scripter Orca depuis la CLI +
+ +**Aussi dans la boîte :** + +- **[Quick open](https://www.onorca.dev/docs/model/quick-open)** — Cherchez parmi worktrees, fichiers, agents, commandes et contexte du repo sans quitter votre flow. +- **[Sélecteur de comptes & suivi d'usage](https://www.onorca.dev/docs/agents/usage-tracking)** — Suivez l'usage Claude et Codex et les resets de rate limit, et basculez de compte à chaud sans vous reconnecter. +- **[Aperçus riches du repo](https://www.onorca.dev/docs/editing/markdown)** — Prévisualisez Markdown, images, PDF et docs du repo dans le workspace. +- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Laissez les agents piloter des apps desktop et l'UI visible quand un workflow demande une vraie interaction. +- **[Notifications et non-lus](https://www.onorca.dev/docs/notifications)** — Sachez quand un agent termine ou a besoin d'attention, puis marquez des fils comme non lus pour y revenir plus tard. +- **Et bien plus encore** — on ship tous les jours, donc cette liste est toujours en retard. Le [changelog](https://github.com/stablyai/orca/releases) est la vraie liste des features. + +--- + +## Agents pris en charge + +Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, il tourne dans Orca. + +

+ Logo Claude Code Claude Code   + Logo Codex Codex   + Logo Grok Grok   + Logo Cursor Cursor   + Logo GitHub Copilot GitHub Copilot   + Logo OpenCode OpenCode   + Logo MiMo Code MiMo Code   + Logo Amp Amp   + Logo OpenClaude OpenClaude   + Logo Antigravity Antigravity   + Logo Pi Pi   + Logo oh-my-pi oh-my-pi   + Logo Hermes Agent Hermes Agent   + Logo Devin Devin   + Logo Goose Goose   + Logo Auggie Auggie   + Logo Autohand Code Autohand Code   + Logo Charm Charm   + Logo Cline Cline   + Logo Codebuff Codebuff   + Logo Command Code Command Code   + Logo Continue Continue   + Logo Droid Droid   + Logo Kilocode Kilocode   + Logo Kimi Kimi   + Logo Kiro Kiro   + Logo Mistral Vibe Mistral Vibe   + Logo Qwen Code Qwen Code   + Logo Rovo Dev Rovo Dev   + + n'importe quel agent CLI +

+ +--- + +## Installation + +### Desktop — macOS, Windows, Linux + +- **[Télécharger depuis onOrca.dev](https://onorca.dev/download)** +- Ou récupérez un build directement : [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/download/v1.4.147-rc.3/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Tous les builds](https://github.com/stablyai/orca/releases/latest) +- **Sous Windows :** utilisez la [dernière RC (`v1.4.147-rc.3`)](https://github.com/stablyai/orca/releases#release-v1.4.147-rc.3) — elle inclut des correctifs Windows absents de la stable. +- Vous lancez `orca serve` sur un serveur Linux headless ? Consultez le [guide serveur Linux headless](../reference/headless-linux-server.md). + +_Ou via un gestionnaire de paquets :_ + +```bash +# macOS (Homebrew) +brew install --cask stablyai/orca/orca + +# Arch Linux (AUR) — ou stably-orca-git pour compiler depuis les sources +yay -S stably-orca-bin +``` + +### Companion mobile — iOS, Android + +Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone. + +- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA) +- **Android :** [Télécharger l'APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) + +--- + +## Communauté & support + +- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**. +- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces. +- **WeChat :** Les groupes 1 et 2 sont complets — vous pouvez rejoindre le troisième. + + QR code WeChat de la communauté Orca + +- **Feedback & idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues). +- **Confidentialité :** Voir la [doc confidentialité & télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie. +- **Soutenez-nous :** [Mettez une star](https://github.com/stablyai/orca) sur ce repo pour suivre nos ships quotidiens. + +--- + +## Développement + +Envie de contribuer ou de lancer le projet en local ? Consultez notre guide [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). + + + Contributeurs Orca + + +

+ Graphique d'historique des étoiles GitHub pour stablyai/orca +

+ +## Builds signés + +Signature de code Windows sponsorisée / fournie par [SignPath.io](https://signpath.io), certificat par [SignPath Foundation](https://signpath.org). + +## Licence + +Orca est libre et open source sous la [licence MIT](../../LICENSE). diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index e2516f34d12..6da07e51cd2 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -3,16 +3,16 @@

- GitHub スター数 + GitHub スター数 全リリースの合計ダウンロード数 - ライセンス + ライセンス: MIT Orca の Discord に参加 X で Orca をフォロー 対応プラットフォーム: macOS、Windows、Linux

- English · Español · Português · 中文 · 한국어 + English · 中文 · 한국어 · Español · Français · Português

diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index c03089e9091..1194226915a 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -3,16 +3,16 @@

- GitHub 스타 + GitHub 스타 전체 릴리스 누적 다운로드 수 - 라이선스 + 라이선스: MIT Orca Discord 참여 X에서 Orca 팔로우 지원 플랫폼: macOS, Windows, Linux

- English · Español · Português · 中文 · 日本語 + English · 中文 · 日本語 · Español · Français · Português

diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 3f61245ff8c..d0d95b38b9f 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -3,16 +3,16 @@

- Estrelas no GitHub + Estrelas no GitHub Total de downloads em todas as versões - Licença + Licença: MIT Entre no Discord do Orca Siga o Orca no X Plataformas compatíveis: macOS, Windows e Linux

- English · Español · 中文 · 日本語 · 한국어 + English · 中文 · 日本語 · 한국어 · Español · Français

diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index c2323d8ebf2..595ca2461d1 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -3,16 +3,16 @@

- GitHub Star 数 + GitHub Star 数 所有版本的总下载量 - 许可证 + 许可证: MIT 加入 Orca Discord 在 X 上关注 Orca 支持的平台:macOS、Windows 和 Linux

- English · Español · Português · 日本語 · 한국어 + English · 日本語 · 한국어 · Español · Français · Português

diff --git a/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md new file mode 100644 index 00000000000..104f6409929 --- /dev/null +++ b/docs/reference/plans/2026-07-18-daemon-lifecycle-retirement.md @@ -0,0 +1,279 @@ +# Daemon lifecycle retirement for issue #9138 + +## Status + +Implemented and locally validated for PR #9277. + +The earlier full ownership/audit prototype is preserved at: + +- branch: `Jinwoo-H/issue-9138-full-ownership-audit-snapshot` +- commit: `7c915909bd26670b8af36aa683cff85077395dd1` +- GitHub: + +That branch is the recovery point for ownership persistence, cross-profile raw extraction, startup +audit, the candidate journal, profile-transfer recovery, natural-exit reconciliation, and future +legacy enforcement. None of those systems ship in this PR. + +Current `main` assigned protocol v23 to the macOS login-shell preparation change while this work was +in progress. The lifecycle contract therefore ships as v24; v23 is preserved as a legacy generation +alongside v22 and older versions. + +## Inputs and scope decision + +Issue: + +Reviewed design comment by AmethystLiang: + + +The reviewed comment correctly identifies two different problems: + +1. current-generation daemons have no daemon-owned empty lifecycle; +2. legacy sessions need complete cross-profile ownership evidence before an app-side reaper can act. + +This PR solves only the first problem. The second problem is much larger, adds steady-state +persistence and startup-audit cost, and is audit-only until field evidence can justify enforcement. +Keeping it out makes the user-visible fix small enough to review and benchmark independently. + +The narrow implementation retains these important rules from AmethystLiang's design: + +- daemon lifecycle behavior requires a protocol bump because old running daemons cannot acquire it; +- live sessions always win over cleanup; +- absence from a worktree, pane layout, profile, or failed listing is never destructive evidence; +- exact PID, process-start time, and per-launch nonce identify a v24 endpoint incarnation; +- the daemon, not app startup, makes the atomic empty decision; +- pre-v24 daemons stay reattachable and are not automatically shut down; +- SSH, WSL, remote-runtime, degraded-provider, sleep/wake, and profile behavior stay unchanged. + +The narrow implementation changes one policy from the original comment: it does not use a blanket +30-minute idle timer. Any loss of the last fully authenticated app client is an exact lifecycle +event, so a daemon retires as soon as it can atomically prove it is empty. This removes runtime +inactivity heuristics and periodic ownership work. + +## User-visible behavior + +```text +Before + +app disconnects ──> daemon stays forever + ├── live sessions stay (wanted) + └── zero sessions also stay (leak) + +After + +clean app detach ──> daemon atomically checks itself + ├── any live session/work/client ──> stay alive + └── exactly empty ──> exit immediately + +unexpected drop ──> daemon atomically checks itself + ├── live session/work/connection ──> stay alive + └── exactly empty ──> exit immediately + +v23 and older ──> existing reattach behavior; no automatic retirement +``` + +An end user with live terminals should notice no change. An end user who quits with no daemon-backed +terminals should no longer accumulate the new v24 generation. If Orca crashes or loses its socket, +live terminals still keep the daemon alive indefinitely. An empty daemon exits immediately; a later +app restart launches a fresh daemon instead of reusing an empty process. + +## Protocol and lifecycle design + +### Endpoint identity + +Protocol v24 hello responses include: + +```ts +type DaemonEndpointIdentity = { + pid: number + startedAtMs: number + launchNonce: string +} +``` + +The parent generates the launch nonce, passes the nonce and PID-record path to the daemon, and writes +the daemon's self-reported start time plus the same nonce to the PID record. Both authenticated client +sockets must report the same valid identity. v24 rejects a missing or malformed identity; v23 keeps +the previous identity-free handshake. + +PID publication is fail-closed. Missing readiness identity, invalid PID, an existing PID record, or a +write failure terminates the new child and fails launch instead of leaving an untracked daemon. + +### Clean detach + +At the end of `DaemonPtyAdapter.disconnectOnly()`, after final checkpoints and producer resumes, a +v24 adapter establishes a full connection if necessary and sends `shutdownIfIdle` within one shared +250 ms budget. v23 and older adapters skip it. + +Initialization establishes one authenticated v24 lifecycle lease even before the first terminal is +opened. This cancels the initial launch-adoption watchdog and ensures a never-used daemon can still +receive clean retirement on quit. If startup fallback has already won, the late daemon is not +installed; it instead receives the same bounded retirement attempt, which an adopted live session +will reject. + +The daemon accepts retirement only when, in one event-loop turn: + +- the requesting authenticated client has both control and stream sockets; +- it is the only authenticated client; +- every accepted transport belongs to that client; +- no `createOrAttach` operation is in flight; +- the terminal host has zero sessions. + +When all conditions hold, the daemon synchronously closes the listening server before replying. That +is the admission fence: a new socket or terminal cannot appear after the empty proof. Cleanup then +runs asynchronously and the process exits. A failed RPC is non-fatal to app quit and falls back to +the same event-driven empty check when the authenticated sockets close. + +### Initial adoption watchdog + +A freshly launched v24 daemon gets up to two minutes to receive its first complete authenticated +client pair. Without this startup-only watchdog, the daemon would prove itself empty and exit in the +normal launch handoff before the parent could connect; without a bound, a parent crash during that +handoff would orphan the new daemon forever. + +A complete pair permanently cancels this watchdog, and terminal admission requires that complete +pair. Raw and partial transports pause it without extending its original deadline. It is never +rearmed after adoption and is not a terminal inactivity or crash-reconnect timer. + +### Unexpected disconnect + +When the last client that completed both authenticated sockets loses its control connection, the +daemon records an event-driven retirement request with no wall-clock grace. + +- A complete authenticated reconnect cancels the request if existing work or a transport kept the + daemon alive long enough to reconnect. +- Only a complete authenticated pair may admit a terminal; completing that pair cancels the request + before admission can begin. +- A raw socket, one-socket health probe, or partial authenticated connection blocks retirement but + cannot erase evidence that the last fully connected app left. +- Replacing a client ID first records the old full connection's loss; completing the replacement + stream cancels that evidence, while an incomplete replacement only blocks retirement. +- A live session prevents shutdown indefinitely. When the last session exits, the daemon immediately + rechecks every guard and retires only if it is then exactly empty. + +The adapter remembers an authenticated unexpected disconnect. If self-retirement later removes the +token, a token-file `ENOENT` is respawnable only with that prior evidence. An initial missing token is +not broadened into destructive or respawn authority. + +### Artifact cleanup + +The daemon removes only artifacts it can claim as its own: + +- token contents must match the daemon's in-memory token; +- PID and launch nonce must match the daemon process and launch nonce; +- cleanup first renames the canonical entry to a unique claim, validates that claim, and never + overwrites or unlinks a replacement installed at the canonical path. + +Current-protocol external cleanup waits for v24 self-shutdown and does not unconditionally remove v24 +PID/socket artifacts. Legacy cleanup behavior is unchanged. + +## Performance design + +There is no polling, profile enumeration, ownership checksum, candidate journal, process scan, or +steady-state persistence write in this PR. + +The steady-state terminal hot path adds no timer work and no per-byte hashing. Initialization adds one +two-socket authenticated lifecycle handshake; the only new RPC is on app/provider detach, and its +connect-plus-request path shares a 250 ms cap, including when quit joins an existing connection +attempt; teardown fences that attempt from resurrecting sockets afterward. Unexpected-disconnect +bookkeeping changes only socket and session lifecycle events. Each unadopted daemon owns at most one +unref'ed startup watchdog, which is canceled permanently on adoption. + +Validation compares current main and the branch for: + +- daemon connect plus two-socket hello latency; +- repeated `listSessions` RPC latency; +- terminal echo/stream throughput through a real socket daemon; +- clean empty-detach latency; +- event-loop delay under repeated RPC/stream work; +- idle CPU/RSS and timer count where observable. + +The acceptance target is no statistically meaningful terminal throughput regression and no new +steady-state disk writes. Results are recorded in the PR body. + +### Local performance regression screen + +The final local host was not quiet enough for publication-grade absolute numbers: load averages were +17-38 and unrelated Orca, browser, simulator, and VM processes occupied several cores. A paired +same-host screen still found no large regression. Five `main` v23 samples were bracketed by ten v24 +branch samples; medians across sample medians were: + +| Measure | `main` v23 | branch v24 | +| -------------------- | ---------: | ---------: | +| two-socket connect | 1.38 ms | 1.32 ms | +| `listSessions` RPC | 0.0366 ms | 0.0374 ms | +| terminal echo stream | 3.28 MiB/s | 3.14 MiB/s | + +All three medians were within about 5%. Individual stream samples varied from 0.66 to 4.04 MiB/s and +event-loop-delay samples had similar load-driven outliers, so these results are a coarse regression +screen, not evidence of an exact performance delta. Static hot-path review confirms lifecycle work +runs on connection, disconnection, session admission/exit, and quit events, with no new work per PTY +byte and no steady-state persistence or polling. + +## Verification and validation + +### Focused unit and integration tests + +- v24 requires valid matching endpoint identity on both sockets. +- v23 and v22 accept the prior identity-free handshake and remain listed as previous protocols. +- production launch passes PID path and nonce and writes the exact readiness identity. +- incomplete readiness identity or failed exclusive PID publication kills and rejects the child. +- clean empty detach exits immediately. +- a never-used current adapter connects and retires cleanly on quit. +- initial adoption cancels the launch watchdog and keeps first-terminal spawn working after its old + deadline. +- a live session, another client, raw transport, or in-flight admission rejects clean retirement. +- a control-only overlapping client blocks but cannot erase the last full-client retirement request. +- a same-client-ID control replacement cannot erase the prior full connection's retirement request. +- a control-only client cannot admit a terminal or erase startup/retirement evidence with a failed + request. +- startup fail-open performs bounded empty retirement without installing a late provider. +- quit remains bounded while a prior handshake is stalled and cannot resurrect client sockets later. +- the synchronous listener fence rejects post-fence connections as retryable. +- an unexpected empty disconnect retires immediately without a runtime inactivity timer. +- a real reconnect cancels pending retirement while live work keeps the daemon available. +- raw and health probes block but cannot erase pending retirement. +- final session exit triggers an immediate guarded retirement check. +- token/PID cleanup preserves malformed, stale, and replacement artifacts. +- authenticated token disappearance performs one coalesced respawn; initial token absence does not. + +### Process/E2E tests + +- start a real isolated v24 daemon with real socket/named-pipe, token, and PID artifacts; +- authenticate, disconnect the last empty client, and verify the exact process and owned artifacts + exit; +- prove a live session rejects retirement and remains reattachable; +- run a protocol-v22 fixture beside v24 and prove v22 remains connectable/reattachable; +- never target a production runtime directory or signal a process not created by the fixture. + +### Repository validation + +- Node typecheck; +- oxlint and repository max-lines policy; +- focused daemon, adapter, launcher, restart, and legacy-routing suites; +- full daemon test suite; +- desktop and web production builds; +- `git diff --check` and review of every changed file against `origin/main`; +- independent review-until-clean, with review loops recorded in `.orca/bug-factory.json`; +- packaged Windows/Linux validation where CI is available; local macOS process E2E before publication. + +Final local results on macOS arm64 after the event-driven policy revision: + +- full daemon suite: 56 files passed, 2 skipped; 939 tests passed, 5 skipped; +- process E2E: v22 remained live and reattachable while the exact empty v24 process and its owned + artifacts retired immediately after its final authenticated client disconnected; +- full Node typecheck, focused oxlint, max-lines ratchet, and `git diff --check` passed; +- full desktop, web, and native production build passed with existing build warnings; +- three independent post-revision review tracks covering architecture/state machines, + ownership/adoption, and process lifecycle ended clean after actionable findings were fixed. + +The local shell used Node 26.5.0 while the repository requests Node 24; the commands completed +successfully, and repository CI remains responsible for the supported Node/platform matrix. + +## Rollout and rollback + +This PR changes only v24. Existing v23 and older daemons are preserved. Rolling back the app leaves +v24 as another legacy generation and does not give an older app authority to shut it down. + +If the lifecycle behavior must be disabled, revert the v24 protocol/lifecycle commit. The separated +ownership/audit prototype remains recoverable from the archived branch and commit above; it should +return only as a separately reviewed, benchmarked follow-up. diff --git a/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md new file mode 100644 index 00000000000..1a0c9922a35 --- /dev/null +++ b/docs/reference/plans/2026-07-19-windows-conpty-startup-query-and-focus-authority.md @@ -0,0 +1,523 @@ +# Windows ConPTY Startup Query and Focus Authority Design + +Date: 2026-07-19 + +Status: Final decision; implementation pending + +## Problem + +On native Windows ConPTY sessions, a new agent can sometimes show terminal protocol bytes as user +text: + +```text +]10;rgb:2e2e/3434/3434\]11;rgb:ffff/ffff/ffff\ +``` + +An independent symptom can prefix user input with `[I`, the printable tail of the standard terminal +focus-in report `CSI I`. + +The OSC text is an Orca-generated reply to the agent's OSC 10/11 foreground/background query. The +agent can issue that query before a daemon-backed `spawn()` resolves to the renderer, and waits only +about 100 ms for the answer. Orca therefore has a short-lived main-side startup responder in +addition to the normal renderer/model query authorities. On affected ConPTY timing, the reply sent +to the PTY is returned as cooked output with its ESC bytes removed. The current ingestion order +records that cooked echo in the authoritative runtime model before any renderer-bound filtering. + +The focus symptom must not be treated as the same bug without evidence. ConPTY deliberately emits +DECSET 1004 (focus reporting) and DECSET 9001 (Win32 input mode) at startup. A direct native +PowerShell ConPTY capture consistently produced the bootstrap pair, while an injected `CSI I` was +consumed as a focus event and did not render as `[I`. The bootstrap is valid transport protocol; +the failure requires an additional agent, timing, input-mode, or replay condition. + +## Root Cause + +The startup OSC responder currently runs in Electron main only after runtime ingestion +([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public +data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while +daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out +data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A +renderer-only filter can therefore hide the symptom without removing it from the authoritative +model, daemon history, snapshots, or remote delivery. + +The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer +downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met +that evidentiary bar, so this design fixes the proven OSC path and adds the real focus-path harness +without pre-approving a speculative focus workaround. + +## Data Flow + +```text +node-pty / remote relay PTY + -> shell-ready marker scan + -> source-owned serialized ingress transaction + -> consume an early OSC query and write one canonical reply + -> match or losslessly release the measured native-Windows echo projection + -> authoritative emulator + persistence/history + -> runtime side effects and mobile/remote stream + -> renderer delivery or hidden-drop decision + -> live terminal / snapshot restore +``` + +Raw sequence spans travel beside cleaned strings through every downstream hop. Empty transformed +spans advance sequence and flow-control state without writing bytes into an emulator or view. + +## Rejected Prototype + +The current working-tree prototype is not the implementation of this design: + +- It removes ConPTY's leading `?1004h` in the renderer. This changes valid native-console focus + semantics for every Windows PTY, including programs unrelated to agents. +- It filters OSC echo text only after runtime/model ingestion. The live pane can look clean while a + hidden restore, reconnect, mobile view, or CLI snapshot still contains the garbage. +- Its echo state activates only after both color slots are answered. An echo of the first response + can escape if it arrives before the second query. +- Its partial-match buffer can be lost on timeout, and its substring search can remove a later + legitimate string that happens to equal a reply. + +Implementation starts by removing the prototype's bootstrap output filter and renderer-only cooked +echo filtering. Existing unrelated Windows focus-idle safeguards remain. + +## Existing Contracts Preserved + +This design extends, rather than replaces: + +- [`terminal-model-view-contract.md`](../terminal-model-view-contract.md), especially singular query + authority, authoritative model restore, and raw sequence ordering; +- [`terminal-query-authority.md`](../terminal-query-authority.md), especially delivered-versus- + hidden-dropped ownership and replay silence; +- [`terminal-side-effect-authority.md`](../terminal-side-effect-authority.md), especially parsing PTY + bytes once in main before renderer delivery. + +The startup responder is a bounded exception needed before normal delivered/dropped ownership can be +established. It must join the same ingestion decision, not operate as an unrelated renderer scrub. + +## Decision 1: Source-Owned PTY Ingress Transaction + +Sanitization must run on the host that owns the PTY, before that host mutates any authoritative +model or persistence. Electron main is too late for daemon sessions: `Session` has already advanced +its sequence, written its emulator, recorded pending output, and broadcast the data before main's +provider callback runs. + +The transaction therefore has three installations of the same shared state machine: + +- in `LocalPtyProvider`, before its configured runtime callback and data listeners; +- in daemon `Session`, before `outputSequence`, emulator writes, pending/checkpoint records, and + attached-client fanout; +- in relay `PtyHandler`, before relay replay/history buffering and `pty.data` fanout. Main's + `SshPtyProvider` is only an RPC proxy and never sanitizes relay output. + +Electron main receives already-classified daemon data. A remote-runtime desktop client does not +reinterpret the stream; the remote Orca host owns its transaction. WSL sessions follow their actual +provider owner but never enable the native-Windows compatibility projection. + +Fresh-session creation carries startup-transaction intent atomically. Daemon `createOrAttach` +receives the recognized-agent intent, execution-host kind, deadline, and validated renderer-pushed +color attributes. It first decides fresh versus reattach; only a fresh result constructs the +transaction before releasing the subprocess's already-buffered early output. A reattach discards +the intent without arming state. Cancellation, spawn failure, and teardown clear intent before any +PTY id can be reused. + +Local creation installs the transaction before subscribing to node-pty output. Relay `pty.spawn` +carries the same fresh-session intent and installs it before releasing relay PTY output; relay +`pty.attach` never accepts it. If any owner cannot establish this ordering, the early responder is +not armed and normal query authority handles the query. + +Increment `DAEMON_PROTOCOL_VERSION` for the new create/wire shape and add a named numeric-threshold +predicate in the daemon adapter/router. Current exact-version hello behavior remains; supported +legacy versions route through their existing adapters and fail the predicate. The SSH relay gets a +separate versioned spawn/data capability because daemon protocol support says nothing about relay +support. + +An old daemon or relay keeps the legacy behavior and is never treated as having sanitized snapshots +or history. Main must not apply a second best-effort scrub to legacy output. New sessions receive +the invariant only after the owning daemon/relay is upgraded or restarted; an already-attached +legacy session remains explicitly outside it. + +### Composition with shell-ready preprocessing + +The ingress sequence domain begins after the existing shell-ready scanner. That scanner may hold +transport bytes and removes its private ready marker; marker bytes have never belonged to terminal +model or delivery sequence space and continue not to count. Its released non-marker bytes enter the +new ingress transaction in original order. + +Snapshot and teardown barriers drain in pipeline order: the shell-ready scanner first releases its +non-marker buffer, then the ingress transaction resolves or abandons its candidate, then the model, +persistence, and views observe the resulting emissions. No later stage may introduce an unmetered +string transform. + +### Raw sequence and emission contract + +The state machine accepts source chunks with an explicit raw half-open range and returns zero or +more ordered emissions: + +```ts +type PtyIngressSourceChunk = { + data: string + rawStartSeq: number + rawEndSeq: number +} + +type PtyIngressEmission = { + data: string + rawStartSeq: number + rawEndSeq: number + transformed: boolean +} +``` + +The raw range is the post-shell-ready ingress sequence domain and remains contiguous even when +`data` is shorter after sanitization. The ordered emissions partition accepted source ranges without +overlap. Producer flow-control acknowledgement follows those emitted raw spans and is counted once +from `rawEndSeq - rawStartSeq`, never from emitted string length. A held prefix delays its ACK; the +buffer is strictly bounded and the serialized queue cannot acknowledge later output ahead of it. + +The ingress raw high-water advances when a source chunk is accepted. Model-applied and +view-delivered high-waters advance only through ordered emissions, including empty transformed +emissions that consume a raw range. Runtime/mobile listener metadata carries +`rawLength = rawEndSeq - rawStartSeq`, not `data.length`. A restore that overlaps a transformed span +cannot slice cleaned text by raw offset and must request a fresh authoritative snapshot, matching +the existing `rawLength !== data.length` safety rule. + +This metadata is end-to-end, not local to the state machine. Implementation changes the complete +path: + +1. daemon `Session` or relay `PtyHandler` emission callback; +2. daemon/relay batching, coalescing, splitting, and wire notification; +3. daemon adapter or `SshPtyProvider` decode; +4. `OrcaRuntimeService` model and runtime/mobile listeners; +5. main renderer batching, pending/drop accounting, and preload payload; +6. renderer reconciliation and remote-runtime binary/live frame decoding. + +The wire representation carries `data`, `seq = rawEndSeq`, `rawLength`, and `transformed`. A +span-only emission has empty `data` but non-zero `rawLength`; no layer may drop it before advancing +its high-water and producer ACK. It is not written to an emulator or xterm. Coalescing is permitted +only for contiguous spans and sums raw lengths independently of string lengths. A transformed +emission is indivisible because there is no byte-for-byte raw-to-clean offset; splitters must flush +it as its own frame or request snapshot reconciliation instead of slicing it. + +If a snapshot is requested while a partial candidate is held, the transaction first abandons that +candidate and releases its bytes unchanged as an ordered emission. The authoritative emulator and +snapshot sequence therefore describe the same raw high-water. + +### Serialization and teardown + +Each PTY has a non-reentrant serialized ingress queue. A provider write may synchronously produce a +nested callback, but that callback is appended after the current source chunk rather than delivered +ahead of its remaining bytes. Timeout releases and snapshot barriers enter the same queue. + +On exit, the queue releases all buffered bytes, applies those emissions to the authoritative model +and persistence, and fans them out before `onPtyExit`, `pty:exit`, or PTY state cleanup. Relay +disposal must flush both ingress prefixes and its existing pending output batches before clearing +them or killing PTYs, matching the natural-exit flush-before-`pty.exit` order. No drain may recreate +state after teardown. Ordinary chunks pass through as one unchanged emission. + +## Decision 2: Startup OSC Queries Are Consumed Authoritatively + +The early OSC 10/11 responder remains because removing it would regress daemon-hosted agent startup +and the agent's short color-query timeout. Its implementation moves into the source-owned ingress +transaction. + +When registered for an agent spawn, it: + +1. recognizes exact OSC 10/11 query grammar across provider chunks; +2. builds replies from validated renderer-pushed foreground/background attributes; +3. emits the canonical ST-terminated, 16-bit-channel reply used by renderer/model query authority, + regardless of whether the query ended with BEL or ST; +4. records each reply transaction before writing it to the provider; +5. consumes the answered query from the authoritative model and view emissions so neither the + hidden model nor a delivered renderer can answer it a second time; +6. begins echo recognition as soon as each individual reply is written. + +If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged +to normal query authority. A reattach never registers startup response state, matching current +behavior. + +### Exact authority transfer + +Startup query interception opens only for a fresh session whose atomic creation installed the +transaction before buffered output release. It closes at the first of: + +- both OSC 10 and OSC 11 slots have been answered; +- the startup deadline expires; +- main sends an ordered authority-close control after either the consuming-view handshake or the + hidden-runtime ownership mark is established; +- the spawn fails, is cancelled, reattaches, or exits. + +Closing query interception does not discard already-written reply candidates. Echo recognition has +its own bounded lifetime and may finish or drain after normal authority takes over. A close and a +provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to +a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control +method, and the source owner acknowledges the applied ingress sequence. Queries before that ordered +boundary are either consumed at source or removed from emissions; queries after it pass unchanged +to the normal delivered/hidden decision. Each query therefore belongs to exactly one authority. + +The regular authority rules continue after the bounded startup window: + +- delivered live bytes are answered by the live view; +- hidden-dropped live bytes are answered by the runtime model; +- replayed, seeded, and snapshot bytes are answered by nobody; +- the daemon's persistence emulator never writes replies. + +Implementation must amend `terminal-model-view-contract.md` and `terminal-query-authority.md` to +name this source-owner startup authority, its opening/closing events, and its no-replay rule. It is a +real third responder class, not an undocumented exception. + +## Decision 3: Matched Echo Suppression Is Lossless and Pre-Model + +Only native Windows ConPTY agent spawns that pass the deterministic provider harness enable a +compatibility projection for replies written by the startup transaction. WSL and POSIX SSH PTYs do +not. A remote-runtime PTY can enable it only on its owning Windows host under the same evidence and +capability gate. + +The harness records the exact projection ConPTY returns for the canonical ST reply, including its +chunking and any console transformation. The implementation must not assume that the projection is +always merely "remove ESC", and BEL or other reply forms are not added without separate evidence. +For every written reply, the transaction records that exact expected projection. Recognition is: + +- FIFO in reply-write order; +- anchored at the next possible output position, not an unbounded substring search; +- active immediately for each reply instead of waiting for both color slots; +- bounded by the startup deadline and maximum reply length; +- streaming across chunk boundaries. + +If incoming bytes diverge from the expected projection, all buffered bytes are released unchanged +and that candidate is abandoned. If the deadline expires or the PTY is cleared while a prefix is +buffered, the prefix is released through the serialized ingestion queue; it is never discarded. A +match advances the raw span but emits no bytes to the model, persistence, or views. + +Projection matching cannot prove provenance. An application can print the same projected text at +the candidate position, causing a false-positive removal while the later real echo remains visible. +This is the central drawback of the workaround. It is accepted only if the real provider harness +shows a stable, immediate projection inside the narrow registered-agent startup window; otherwise +Orca disables the projection and keeps the visible output rather than risking deletion. + +When the projection is enabled, the exact-collision behavior is explicit: the first identical +anchored candidate is removed and a later real echo is allowed through. The test fixture must assert +that result. This accepts a narrowly bounded false-positive risk instead of pretending provenance +is knowable; the release gate must document the observed timing window and justify that tradeoff. +Unknown, delayed, or interleaved transformations pass through visibly. + +## Decision 4: Preserve ConPTY Focus Protocol + +Orca must deliver the ConPTY bootstrap `?1004h`/`?9001h` to live terminal emulators unchanged. It +must not remove, reorder, or fabricate transport bootstrap modes. + +The `[I` investigation gets a deterministic harness before a behavior change. The harness must +exercise the actual renderer focus callback and provider write path, not only inject `CSI I` +directly into node-pty. It records: + +- raw provider output and its order; +- whether the focus report came from live ConPTY bootstrap state, an application-owned DECSET 1004, + or replayed snapshot state; +- the exact bytes written to the provider; +- the exact bytes returned by the provider and stored in the model; +- agent lifecycle state when the report was emitted. + +The implementation gate is strict: + +- If stale snapshot modes cause the report, fix snapshot rehydration. Transport bootstrap focus mode + is not persisted as application ownership. +- If a live agent receives transport focus before it owns terminal focus reporting, record that + evidence without shipping a suppression heuristic from this document. +- If provider input/output transformation corrupts a correctly owned focus report, fix or sanitize + that transformation at the same pre-model ingress boundary used for OSC replies. + +No global `?1004h` filter ships under any outcome. + +If the harness proves a focus-ownership race, a follow-up design is required before implementation. +That design must define the output-to-input ownership signal, distinguish xterm focus events from +identical typed/pasted/programmatic bytes, specify startup transitions and deadlines, define any +snapshot/wire metadata, and cover the separate explicit reattach-focus write path. This document +does not pre-approve an ownership state machine whose input provenance cannot yet be represented. + +## Snapshot and Replay Rules + +Interactive modes in a snapshot are capabilities of the live application, not proof that a new +view should emit input immediately. + +- Cold restore into a fresh shell keeps the existing full mode reset. +- Reattach to a live agent may rehydrate application-owned focus mode, but never transport-only + bootstrap ownership. +- Snapshot serialization/replay must not mutate live ownership trackers. +- A snapshot containing an OSC query or an old Orca reply never produces a provider write. +- Model and renderer snapshots must both be free of matched cooked reply projections. + +Any future focus ownership metadata must be explicit; it cannot be inferred from serialized +`?1004h` text. + +## Failure Policy + +Safety is asymmetric: + +- This design does not intentionally suppress a focus notification; evidence of a focus-ownership + race triggers a follow-up design instead. +- Passing through an unrecognized OSC echo is safer than deleting output that may belong to the + application. +- Missing a startup color reply falls back to the existing renderer/model authority. Duplicate + replies are forbidden. +- Losing buffered output on timeout or teardown is forbidden. + +## Cross-Platform and Remote Scope + +- Native Windows local and daemon ConPTY: startup response plus evidence-gated echo compatibility + path; focus investigation only until a proven root cause has its own complete design. +- WSL: normal Linux terminal semantics; no ConPTY echo or focus workaround. +- SSH: the same ingress/query ownership ordering, but no native Windows echo projection unless the + remote host protocol later supplies explicit equivalent evidence. +- Remote runtime: the remote Orca host owns ingestion and must implement the same contract there; + desktop local main does not reinterpret its stream. +- Mobile/web views: consume the sanitized authoritative model stream and retain exactly-one query + response authority through the existing terminal-driver election. + +## Edge Cases + +- A query or echo split at any byte boundary, including OSC ST split across chunks. +- The first reply echo arriving before the second color query. +- Unrelated output, an exact application-text collision, or a partial match before the real echo. +- Timeout, snapshot, detach, process exit, daemon shutdown, or relay disposal while bytes are held. +- A provider write causing a synchronous nested output callback. +- Fresh spawn versus reattach, cancellation, PTY-id reuse, and an authority-close control racing + with output. +- BEL-terminated queries still receiving the canonical ST reply. +- Empty transformed spans crossing coalescing, splitting, ACK, mobile, and remote-runtime layers. +- Old daemon/relay protocol versions and sessions that survive an application upgrade. +- WSL or POSIX SSH running from a Windows desktop without inheriting local ConPTY workarounds. +- Focus gained through normal terminal input versus the separate explicit reattach-focus write path. + +## Test Plan + +### Deterministic provider harness + +- Capture the native ConPTY bootstrap across natural and forced chunk boundaries. +- Reproduce the OSC reply echo with the real provider and an agent query fixture. +- Exercise BEL and ST queries and assert the same canonical ST reply, plus separate OSC 10/11 + queries and combined OSC 10 `?;?`. +- Force the first reply echo before the second query. +- Split every byte boundary in query and echo fixtures. +- Print the expected projection immediately before the real echo and assert that the first + identical candidate is removed while the later echo passes through. +- Exercise focus gain/loss through the actual xterm focus path. + +### Ingress integration + +- Assert local runtime ingestion and daemon emulator/pending/checkpoint persistence receive + classified data before storing it. +- Assert renderer and mobile delivery receive the same visible output. +- Assert raw start/end spans advance by original provider length, producer ACK contribution is + counted exactly once, and mobile/runtime metadata reports the raw span rather than string length. +- Assert a partial candidate is released unchanged on mismatch, timeout, snapshot barrier, move, + and teardown, including a prefix held from an earlier callback. +- Assert re-entrant provider callbacks remain ordered behind the source chunk that caused the + write. +- Assert restore overlap across a transformed or delayed span requests a fresh snapshot rather + than slicing cleaned text. +- Assert `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` install the source-side seam; + assert `SshPtyProvider` does not reinterpret relay data. +- Assert WSL, SSH, and remote-runtime streams do not enable the native-Windows projection. +- Assert a capable daemon sanitizes live output, snapshots, pending records, checkpoints, and cold + restore; assert capable relay replay/history has the same property. Assert old daemon and relay + versions are detected and never represented as sanitized. + +### Authority and restore + +- Assert an early-consumed query is answered once by its source owner and never by renderer or + hidden model. +- Assert normal delivered/dropped query authority resumes after startup state clears. +- Assert the main snapshot, renderer snapshot, hidden reveal, reconnect, and mobile subscription do + not contain cooked OSC text. +- Assert replay never sends OSC or focus replies. +- Assert ordinary native console focus behavior remains enabled. +- Assert the focus harness captures both terminal `onData` and explicit reattach-focus writes. Any + later ownership implementation defines its tests in the required follow-up design. + +### End-to-end acceptance + +On native Windows, repeatedly create, hide, reveal, and reconnect new and resumed agent sessions. +Before typing, neither live output nor any restore path may contain `]10;rgb`/`]11;rgb`. Repeat the +focus/blur scenarios to classify `[I`; if it reproduces through an ownership race, this work stops +at the follow-up-design gate rather than claiming the symptom fixed. Application-owned focus +behavior must still move the TUI caret correctly after focus and reattach. Repeat with a plain +PowerShell terminal and a native focus-event consumer to prove no global regression. + +Run renderer and main PTY suites, runtime snapshot/query suites, node/web typechecks, lint, +formatting, max-lines ratchet, reliability gates, and Electron validation. SSH ingestion changes also +require the repository's SSH end-to-end procedure. + +## UI Quality Bar + +This is not a layout, styling, or copy change. Existing terminal rendering and focus behavior must +look unchanged except that matched protocol garbage is absent. A passing terminal screenshot has a +clean prompt, no clipped or duplicated startup output, no restore flash, and the existing cursor and +focus presentation. + +## Review Screenshots + +1. A fresh native-Windows agent prompt after startup, with no OSC reply text. +2. The same session after hide/reveal restore, still clean and without duplicated output. +3. The session after focus, blur, and reconnect, showing the focus-harness outcome and prompt state. +4. A plain native PowerShell terminal after focus/blur, showing unchanged adjacent behavior. + +## Rollout + +1. **Correct seam and rollback.** Remove the prototype filters. Add the shared serialized ingress + state machine, explicit raw-span emissions, and separate ACK accounting after shell-ready + preprocessing. Install pass-through mode in `LocalPtyProvider`, daemon `Session`, and relay + `PtyHandler` before their models, persistence/replay, and fanout. +2. **Protocol and authority contract.** Bump the daemon protocol, add relay capability/version gates, + carry atomic fresh-spawn intent, define legacy fallback, and amend the canonical model/query + authority documents with exact transfer events. +3. **Startup transaction.** Move OSC startup recognition/reply into source ingress, retain canonical + ST replies, consume answered queries for model and views, and retain raw sequence accounting. +4. **Windows compatibility projection.** Gate the measured projection on provider evidence; add FIFO + anchored recognition, serialized re-entrant delivery, lossless drains, false-positive coverage, + and authoritative daemon/model/snapshot tests. +5. **Focus evidence.** Land the real focus-path harness. If it proves a focus-ownership race, stop + for the required follow-up design; a direct snapshot or provider-transformation bug may be fixed + only with a failing regression test that selects that branch. +6. **Electron and SSH gates.** Validate visible, hidden, restored, mobile-owned, native console, WSL, + and SSH scenarios before removing the old startup implementation. + +Each slice must keep query authority singular. The compatibility projection does not ship without a +model-snapshot assertion, and no focus behavior change ships before the deterministic focus harness +fails on the old behavior and passes on the new behavior. + +## Lightweight Eng Review + +- Scope: limited to the proven OSC startup corruption plus deterministic focus evidence. Global + focus filtering and an unproven ownership state machine remain out of scope. +- Architecture/data flow: classification belongs at each PTY source owner after shell-ready + preprocessing and before every authoritative model, persistence, replay, or delivery consumer. +- Failure modes covered: partial/mismatched projections, false-positive collision, nested writes, + authority races, snapshot and teardown drains, protocol-version skew, reattach, and host + isolation. +- Test coverage required: byte-boundary unit tests for the shared transaction; local, daemon, relay, + runtime/mobile, restore, and legacy-protocol integration tests; real native-Windows provider and + renderer-focus harnesses; Electron and SSH end-to-end validation. +- Performance/blast radius: ordinary output is a pass-through emission. Buffering is bounded by one + startup reply candidate and its deadline. Protocol and sequence metadata touch every delivery + path, so existing high-throughput, ACK, hidden-drop, and reconnect tests are mandatory. +- UI quality bar: terminal layout and styling are unchanged; only matched startup garbage + disappears, without cursor/focus regressions or restore flashes. +- Required review screenshots: the four terminal states in `Review Screenshots`. +- Residual risks: the native projection may be too unstable to enable; exact projected application + output can collide; `[I` may require a separately reviewed focus-ownership design. + +## Non-Goals + +- Replacing xterm's general query parser. +- Filtering arbitrary escape-looking terminal output. +- Disabling ConPTY focus or Win32 input mode globally. +- Changing WSL, SSH, or remote-runtime terminal semantics to imitate local Windows. +- Solving unrelated MCP startup warnings reported beside the terminal garbage. + +## Final Invariants + +1. Provider bytes are classified once before model ingestion and view delivery. +2. A live query has exactly one responder; replay has none. +3. Source-owner persistence, runtime/model state, and every view agree on removal of a matched + startup-reply projection. +4. Raw provider sequence accounting survives sanitization. +5. Buffered unmatched output is always released; the sanitizer cannot silently lose user data. +6. ConPTY bootstrap modes reach live emulators unchanged. +7. No focus suppression ships from this design; a proven ownership race requires a follow-up design. +8. PTY teardown clears all startup-transaction buffers and focus-harness measurement state. diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index fe89a82c4a0..d5cbd0e0f1b 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -179,7 +179,7 @@ export default function RootLayout() { diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 442066ca962..126744a984e 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -108,14 +108,9 @@ function isErrorVerdict(v: ConnectionVerdict): boolean { const REPO_METADATA_REFRESH_MS = 60_000 type HostScreenProps = { - // Why: when true, this worktree list is rendered as the persistent tablet - // sidebar by the host layout rather than as its own routed screen. That - // swaps the back button for a hide-sidebar control, drives data fetching - // from a plain mount effect (the sidebar is never the "focused" route), and - // opens sessions into the detail pane instead of pushing a new full screen. + // When true, rendered as the persistent tablet sidebar by the host layout, not as its own routed screen. embedded?: boolean - // Route params aren't in scope when rendered from the layout, so the caller - // passes hostId/action explicitly; falls back to the local route params. + // Route params aren't in scope when rendered from the layout, so the caller passes these explicitly. hostId?: string action?: string onHideSidebar?: () => void @@ -133,16 +128,12 @@ export function HostScreen({ const router = useRouter() const pathname = usePathname() const insets = useSafeAreaInsets() - // Why: cap and center the worktree list on wide/tablet canvases; on phones - // isWideLayout is false so the list stays edge-to-edge as before. When - // embedded as the sidebar the list already lives in a narrow pane, so the - // cap is skipped (see the SectionList contentContainerStyle below). + // Why: cap and center the list on wide/tablet canvases; on phones isWideLayout is false so it stays edge-to-edge. const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [initialCache] = useState(() => hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null ) - // Why: shared client per host owned by RpcClientProvider. See - // docs/mobile-shared-client-per-host.md. + // Shared client per host owned by RpcClientProvider. See docs/mobile-shared-client-per-host.md. const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) @@ -156,9 +147,7 @@ export function HostScreen({ const forceReconnectHost = useForceReconnect() const [worktrees, setWorktrees] = useState(initialCache ?? []) const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null) - // Why: opening a worktree activates it on the host, but the active-row - // highlight otherwise waits for the next worktree.ps poll to reflect it. - // Track the locally-opened worktree so the highlight moves instantly. + // Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll. const [optimisticActiveWorktreeId, setOptimisticActiveWorktreeId] = useState(null) // One tick drives every visible agent row's relative timestamp. const now = useNow(30_000) @@ -180,9 +169,7 @@ export function HostScreen({ const [workspaceStatuses, setWorkspaceStatuses] = useState( DEFAULT_MOBILE_WORKSPACE_STATUSES ) - // displayName → repo id, populated from repo.list. The filter model keys on - // repo ids (desktop's PersistedUIState), but the section headers/rows key on - // displayName, so we bridge the two here. + // displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two. const [repoIdsByName, setRepoIdsByName] = useState>(new Map()) const [showSortPicker, setShowSortPicker] = useState(false) const [showGroupPicker, setShowGroupPicker] = useState(false) @@ -201,9 +188,7 @@ export function HostScreen({ }, [router]) const [pinnedIds, setPinnedIds] = useState>(new Set()) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) - // Why: snapshot of the synced view settings so the focus-effect ui.get merge - // and the optimistic ui.set writes read the latest values without forcing the - // callbacks to re-create on every state change. + // Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change. const viewStateRef = useRef({ groupMode: 'repo', sortMode: 'recent', @@ -226,8 +211,7 @@ export function HostScreen({ } }, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses]) - // Apply a MobileViewState (e.g. from a desktop ui.get) onto the individual - // states and the snapshot ref in one shot. + // Apply a MobileViewState onto the individual states and the snapshot ref in one shot. const applyViewState = useCallback((next: MobileViewState) => { viewStateRef.current = next setGroupMode(next.groupMode) @@ -241,8 +225,7 @@ export function HostScreen({ }) }, []) - // Optimistically apply a partial change locally, then push the full mapped - // settings to the desktop's shared store via ui.set so both apps stay in sync. + // Apply the change locally, then push full settings to the desktop's shared store (ui.set) so both apps stay in sync. const persistViewSettings = useCallback( (patch: Partial) => { const next: MobileViewState = { ...viewStateRef.current, ...patch } @@ -275,8 +258,7 @@ export function HostScreen({ }, []) const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action) - // Why: `action=newWorktree` is a route-derived open edge. Resolve it before - // commit, but don't reopen after the user closes while the same URL remains. + // Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists. if (resolvedRouteActionState !== routeActionState) { setRouteActionState(resolvedRouteActionState) } @@ -285,8 +267,7 @@ export function HostScreen({ setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible)) }, []) - // Load persisted pins from the local cache. View settings are no longer - // stored locally — they sync from the desktop's shared store via ui.get. + // Load persisted pins from local cache; view settings are no longer local (they sync via ui.get). useEffect(() => { if (!hostId) { return @@ -304,9 +285,7 @@ export function HostScreen({ } }, [hostId]) - // Read the desktop's shared view settings (PersistedUIState) and merge them - // onto local state. Runs on connect and on screen focus so changes made on - // desktop appear on the phone. + // Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here. const syncViewSettingsFromDesktop = useCallback(async () => { if (!client || connState !== 'connected') { return @@ -328,9 +307,7 @@ export function HostScreen({ } }, [client, connState, hostId, applyViewState]) - // Why: keep clientRef in sync so existing imperative call sites work - // unchanged. Also re-seed the cached worktree list on hostId change - // since the useState initializer only runs on first mount. + // Why: mirror client into a ref so imperative call sites read it without re-subscribing. useEffect(() => { clientRef.current = client }, [client]) @@ -342,9 +319,7 @@ export function HostScreen({ setRepoColorsByName(new Map()) setRepoIconsByName(new Map()) repoMetadataFetchedAtRef.current = 0 - // Why: re-seed from the current host's cache on every hostId change. - // The useState initializer only runs on first mount, so if Expo Router - // reuses this screen with a different hostId, we must reset here. + // Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId. const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null if (freshCache) { setWorktrees(freshCache) @@ -432,8 +407,7 @@ export function HostScreen({ if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) { return } - // The embedded sidebar polls for the whole split-view session; keep slow - // remote hosts from stacking overlapping expensive list requests. + // Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling. if (fetchWorktreesInFlightRef.current) { return } @@ -442,8 +416,7 @@ export function HostScreen({ const requestHostId = hostId try { - // Why: worktree.ps defaults to 200 and silently truncates; match the - // desktop's high cap so large hosts don't drop workspaces on mobile. + // Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces. const response = await requestClient.sendRequest('worktree.ps', { limit: 10000 }) if (clientRef.current !== requestClient || hostId !== requestHostId) { return @@ -453,9 +426,7 @@ export function HostScreen({ } if (response.ok) { const result = (response as RpcSuccess).result as { worktrees: Worktree[] } - // Why: large hosts can return identical worktree.ps snapshots every - // poll. Preserving the existing array keeps SectionList/sort rebuilds - // off the JS tap path unless something actually changed. + // Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path. setWorktrees((current) => areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees ) @@ -463,25 +434,18 @@ export function HostScreen({ areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees ) setWorktreesLoaded(true) - // Why (#8498): the host detail screen seeds its list from the - // home-written cache, so a partial home fetch could poison it until a - // focus poll corrected it. Write the confirmed snapshot back through - // the same cache so a reconnect refetch (or a remount) can't serve a - // stale worktree list. + // Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list. if (hostId) { setCachedWorktrees(hostId, result.worktrees) } - // Drop the optimistic active override once the host confirms it (the - // activate RPC has landed and worktree.ps now reports it active), so we - // stop overriding and respect any later desktop-driven change. + // Drop the optimistic active override once the host reports it active, so later desktop changes win. setOptimisticActiveWorktreeId((pending) => pending && result.worktrees.some((w) => w.worktreeId === pending && w.isActive) ? null : pending ) - // Clear optimistic sleep overrides once the server confirms the - // worktree is actually inactive (liveTerminalCount dropped to 0). + // Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0). setSleptIds((prev) => { if (prev.size === 0) { return prev @@ -496,8 +460,7 @@ export function HostScreen({ return still.size === prev.size ? prev : still }) - // Sync local pin state from server so desktop-initiated pins/unpins - // are reflected without relying on stale AsyncStorage. + // Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage. const serverPinned = new Set( result.worktrees.filter((w) => w.isPinned).map((w) => w.worktreeId) ) @@ -520,17 +483,10 @@ export function HostScreen({ [client, connState, hostId] ) - // Why: read desktop's protocol version from status.get on every connect - // and re-evaluate compatibility. If the desktop declares this mobile - // build too old (or vice versa via the local minimum), the host detail - // screen swaps to a hard-block screen instead of the worktree list. - // Today's compat constants are wide-open so this never blocks; the wire - // format is in place to flip a switch in a future release. + // Why: re-evaluate protocol compat on connect; today's constants are wide-open so this never blocks yet. useEffect(() => { if (connState !== 'connected' || !client) { - // Why: drop the prior host's capabilities while disconnected/switching so - // a capability-gated action (e.g. Agent Session History) can't linger for - // a host that doesn't support it. + // Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host. setHostCapabilities([]) return } @@ -556,8 +512,7 @@ export function HostScreen({ }) setCompatVerdict(verdict) if (verdict.kind === 'blocked') { - // Why: deterministic breadcrumb so support can confirm a block - // actually fired (vs a render bug). No PII — just version ints. + // Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints. console.warn('[protocol-compat] blocked', { reason: verdict.reason, desktopVersion: verdict.desktopVersion, @@ -566,8 +521,7 @@ export function HostScreen({ }) } } catch { - // Why: rare path — sendRequest can throw on transport tear-down. - // Treat as transient; verdict stays at previous value. + // Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict. } })() return () => { @@ -577,29 +531,22 @@ export function HostScreen({ useFocusEffect( useCallback(() => { - // Why: opening the host is a strong user signal — reset a backed-off or - // trickling reconnect loop (and probe a possibly half-open socket) - // immediately instead of waiting out its timer. Deps stay empty so this - // fires per focus transition, not per connection-state change; nudging - // on every reconnecting↔connecting flip would defeat the backoff. + // Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff). clientRef.current?.notifyForeground() }, []) ) useFocusEffect( useCallback(() => { - // The embedded sidebar drives its own polling below; focus never fires - // for it since it isn't a routed screen. + // The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below. if (embedded || connState !== 'connected') { return } void fetchWorktrees() void fetchRepoMetadata() - // Pull desktop's shared view settings on focus so desktop-side changes - // show up here without a manual refresh. + // Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh. void syncViewSettingsFromDesktop() - // Why: React Navigation keeps previous stack screens mounted; only - // poll the host list while this route is visible. + // Why: React Navigation keeps prior screens mounted; only poll while this route is visible. const interval = setInterval(() => { void fetchWorktrees() void fetchRepoMetadata() @@ -608,9 +555,7 @@ export function HostScreen({ }, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop]) ) - // Why: as the persistent tablet sidebar this list is never the focused - // route, so useFocusEffect won't fetch/poll. Mirror that behavior from a - // plain mount effect while connected instead. + // Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect. useEffect(() => { if (!embedded || connState !== 'connected') { return @@ -625,10 +570,7 @@ export function HostScreen({ return () => clearInterval(interval) }, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop]) - // Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to - // useWorktreeResync so this screen stays under its max-lines budget. The - // steady-state focus/embedded polls don't cover the transition INTO - // 'connected' after a background/sleep, which is when the cache is stalest. + // Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest. const { refreshing, onRefresh } = useWorktreeResync({ client, connState, @@ -720,8 +662,7 @@ export function HostScreen({ await removeHostAndCloseClient(hostId, closeHostClient) leaveHost() } catch { - // Why: metadata commit can fail while the host is still paired; keep the - // screen mounted and re-open confirm (ConfirmModal closes on confirm). + // Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm). setConfirmRemoveHost(true) Alert.alert('Could not remove host', 'Please try again.') } @@ -748,11 +689,9 @@ export function HostScreen({ const openWorktreeSession = useCallback( (item: Worktree) => { - // Highlight the row immediately; the next worktree.ps poll confirms it. setOptimisticActiveWorktreeId(item.worktreeId) if (client && connState === 'connected') { - // Why: opening a mobile session should hydrate host-owned tabs without - // pulling other paired clients, especially desktop, into this worktree. + // Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree. void client .sendRequest('worktree.activate', { worktree: `id:${item.worktreeId}`, @@ -831,8 +770,7 @@ export function HostScreen({ const slept = sleptIds.has(w.worktreeId) ? { liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const } : null - // Force the just-opened worktree active (and the rest inactive) until the - // next poll confirms it, so the highlight doesn't lag the navigation. + // Force the just-opened worktree active until the next poll confirms it, so the highlight doesn't lag. const active = optimisticActiveWorktreeId !== null ? { isActive: w.worktreeId === optimisticActiveWorktreeId } @@ -912,12 +850,7 @@ export function HostScreen({ {connState !== 'connected' && (() => { - // Why: status label removed in favor of just the dot + - // Reconnect button — the home screen already surfaces the - // verdict text per host, and the dot color already - // signals severity here. Auth-failed routes through its - // dedicated banner so we still want to suppress the - // Reconnect button for that case. + // Why: auth-failed has its own banner, so suppress the Reconnect button for that verdict. const verdict = headerVerdict const isError = isErrorVerdict(verdict) const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed' @@ -1166,8 +1099,7 @@ export function HostScreen({ onChangeText={setSearch} placeholder="Search worktrees…" autoFocus - // Why: new key each open remounts focus effect if the field stays mounted - // across rapid toggles; pairs with delayed focus so the keyboard appears. + // Why: new key per open remounts the focus effect across rapid toggles so the keyboard reappears. focusKey={showSearch} accessibilityLabel="Search worktrees" /> @@ -1206,13 +1138,10 @@ export function HostScreen({ keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" onScrollToIndexFailed={onScrollToIndexFailed} - // Why: edge-to-edge — the list scrolls under the system nav bar - // while reserving insets.bottom keeps the last worktree row reachable - // above the Samsung 3-button nav / iOS home indicator. + // Why: edge-to-edge under the system nav bar; insets.bottom keeps the last row above it. contentContainerStyle={[ styles.list, - // Phone shows a floating "+" button bottom-right; reserve room so the - // last row stays tappable above it. Embedded sidebars keep the toolbar +. + // Reserve room so the last row stays tappable above the phone's floating "+" (embedded uses the toolbar +). { paddingBottom: (embedded ? spacing.lg : FAB_SIZE + spacing.xl) + insets.bottom }, isWideLayout && !embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } @@ -1252,8 +1181,7 @@ export function HostScreen({ ) }} ItemSeparatorComponent={ListSeparator} - // Why (#8498): manual pull-to-refresh forces a fresh worktree - // snapshot after a reconnect or whenever the cache looks stale. + // Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect. refreshControl={ void +} + +export function QuickCommandsTabButton({ disabled, onPress }: Props) { + return ( + <> + + [ + styles.newTerminalButton, + pressed && styles.newTerminalButtonPressed, + disabled && styles.newTerminalButtonDisabled + ]} + disabled={disabled} + onPress={onPress} + accessibilityLabel="Quick commands" + > + + + + ) +} diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index eaa04a7436a..bd361b93c63 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -75,6 +75,12 @@ import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr- import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton' import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet' +import { QuickCommandsSheet } from '../../../../src/session/QuickCommandsSheet' +import { + buildMobileQuickCommandLaunch, + supportsMobileQuickCommands, + type MobileQuickCommandLaunch +} from '../../../../src/terminal/quick-commands' import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result' @@ -233,7 +239,8 @@ import { } from '../../../../src/session/mobile-session-create-warning-state' import { colors, spacing } from '../../../../src/theme/mobile-theme' import { styles } from './mobile-session-styles' -import type { DiffComment } from '../../../../../src/shared/types' +import { QuickCommandsTabButton } from './QuickCommandsTabButton' +import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types' import type { DiffCommentActions, DiffNotesDelivery, @@ -277,8 +284,7 @@ function MarkdownReader({ onDiscard: () => void keyboardLift: number }) { - // The editor lives in a WebView; native Keyboard events under-report its - // covered area, so prefer the inset measured inside the WebView when larger. + // Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset. const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0) const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset) if (!doc || doc.status === 'loading') { @@ -326,8 +332,7 @@ function MarkdownReader({ pointerEvents="box-none" style={[ styles.markdownFloatingBar, - // Why: the editor focus lives inside a WebView, so keep native - // Save/Discard controls lifted instead of resizing that surface. + // Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it. { bottom: resolveMarkdownFloatingActionsBottom({ keyboardLift: effectiveKeyboardLift, @@ -416,8 +421,7 @@ function DiffLineRow({ const commentLine = line.newLineNumber const isCommenting = commentLine !== undefined && activeCommentLine === commentLine const canComment = commentLine !== undefined - // Why: review notes are anchored to the modified side, so the single mobile - // gutter should show the same line number the note will reference. + // Why: review notes anchor to the modified side, so show that line number in the single mobile gutter. const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? '' return ( @@ -640,8 +644,7 @@ function FileReader({ return undefined } - // Why: highlighting can create many nested Text nodes; defer it one tick so - // large files show immediately as plain text before colors are applied. + // Why: defer highlighting one tick so large files show as plain text immediately before colors are applied. const timer = setTimeout(() => { // file + html share the syntax-segment source view (html's "Source" toggle). if (doc.kind === 'file' || doc.kind === 'html') { @@ -812,11 +815,10 @@ export default function SessionScreen() { created?: string warning?: string }>() - const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') + const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') // Synthetic ids have no repo scope. const router = useRouter() const insets = useSafeAreaInsets() - // Why: shared client per host owned by RpcClientProvider. See - // docs/mobile-shared-client-per-host.md. + // Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md). const { client, state: connState } = useHostClient(hostId) const reconnectAttempts = useReconnectAttempt(hostId) const lastConnectedAt = useLastConnectedAt(hostId) @@ -827,8 +829,7 @@ export default function SessionScreen() { routeName: routeWorktreeName, worktreeId }) - // Master-detail host state (U5/KTD2): on wide layouts a tapped panel docks beside the - // session content; on narrow it stays null and the icons push full-screen routes. + // Master-detail state: wide layouts dock a tapped panel beside the session; narrow keeps it null and pushes full-screen routes. const { isWideLayout } = useResponsiveLayout() const [activePanel, setActivePanel] = useState(null) const [sessionContentRowWidth, setSessionContentRowWidth] = useState(0) @@ -837,17 +838,13 @@ export default function SessionScreen() { availableWidth: sessionContentRowWidth, dockWidth: HOST_DOCK_MIN_WIDTH }) - // Why: docking needs enough measured row width. If rotation/split-screen makes - // the session row too narrow while a panel is docked, clear activePanel so the - // icon state and live mounted panel do not survive into overlay/push mode. + // Why: if rotation/split-screen makes the docked row too narrow, clear activePanel so it doesn't survive into overlay/push mode. useEffect(() => { if (!canDockPanel && activePanel !== null) { setActivePanel(null) } }, [canDockPanel, activePanel]) - // Session-level GitHub remote probe gates the PR dock icon so non-GitHub - // providers do not open the hosted-review surface. Branch/head/status for the - // hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs. + // GitHub remote probe gates the PR dock icon so non-GitHub providers can't open the hosted-review surface; skip the unused identity RPCs. const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } = useMobilePrBranchContext({ client, @@ -865,23 +862,15 @@ export default function SessionScreen() { const terminalsRef = useRef([]) const [sessionTabs, setSessionTabs] = useState([]) const sessionTabsRef = useRef([]) - // Why: subscription, 2s polling, and post-mutation refetch race to apply tab - // snapshots. Track the last applied (publicationEpoch, snapshotVersion) so a - // late-arriving older snapshot from the same publisher can't overwrite (and - // resurrect closed tabs in) a newer one. See session-tab-snapshot-gate. + // Why: track the last applied (epoch, version) so a late older snapshot can't overwrite a newer one and resurrect closed tabs (session-tab-snapshot-gate). const appliedSnapshotMarkerRef = useRef({ epoch: null, version: -1 }) - // Why: after an optimistic local close, suppress the tab until the publisher - // confirms its absence, so an in-flight snapshot generated before the close - // propagated (and thus newer by version) can't flash the tab back. Maps tab id - // to an expiry timestamp so a failed host-side close can't hide a tab forever. + // Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back. const closedTabTombstonesRef = useRef>(new Map()) const [terminalsLoaded, setTerminalsLoaded] = useState(false) const [input, setInput] = useState('') - // Why: baseline terminal zoom, reloaded on focus so a Settings → Terminal change - // applies in place (the terminal panes stay mounted). + // Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted). const [terminalTextScale, setTerminalTextScale] = useState(1) - // Why: local opt-in for keyboard autocomplete/autocorrect on the terminal - // command bar; reloaded on focus so a Settings → Terminal toggle takes effect on return. + // Why: terminal command-bar autocomplete opt-in, reloaded on focus so a Settings → Terminal toggle takes effect on return. const [autocompleteEnabled, setAutocompleteEnabled] = useState(false) const [terminalLinkOpenMode, setTerminalLinkOpenMode] = useState('orca-browser') @@ -897,8 +886,7 @@ export default function SessionScreen() { const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) - // Auto-scroll the tab strip so the active tab (synced from desktop on - // worktree entry) is revealed without a manual scroll. + // Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll. const tabStripRef = useRef(null) const tabStripOffsetRef = useRef(0) const tabStripViewportWidthRef = useRef(0) @@ -913,9 +901,7 @@ export default function SessionScreen() { const [pendingDiffNotesDelivery, setPendingDiffNotesDelivery] = useState(null) const [creating, setCreating] = useState(false) - // Why: React state isn't a synchronous lock — a fast double-tap can fire two - // creates before `creating` re-renders. This ref blocks the second one in the - // same tick (server idempotency only dedupes identical clientMutationIds). + // Why: React state isn't a synchronous lock; this ref blocks a double-tap's second create in the same tick before `creating` re-renders. const creatingTerminalRef = useRef(false) const [creatingBrowser, setCreatingBrowser] = useState(false) const [creatingMarkdown, setCreatingMarkdown] = useState(false) @@ -924,6 +910,7 @@ export default function SessionScreen() { createMobileSessionCreateWarningState(initialCreateWarning) ) const [showCreateTabDrawer, setShowCreateTabDrawer] = useState(false) + const [showQuickCommands, setShowQuickCommands] = useState(false) const [createTabAgentLoadState, setCreateTabAgentLoadState] = useState('idle') const [createTabAgentOptions, setCreateTabAgentOptions] = useState([]) @@ -958,14 +945,9 @@ export default function SessionScreen() { () => getVisibleTerminalAccessoryKeys(visibleBuiltInIds), [visibleBuiltInIds] ) - // Why: in Expo SDK 55 edge-to-edge mode the OS does NOT resize the window when - // the IME opens — the keyboard draws on top of the app. We track the keyboard - // height ourselves and translate the input/accessory area above the IME without - // changing the terminal frame height, so keyboard open/close does not resize - // the desktop PTY. + // Why: Expo SDK 55 edge-to-edge doesn't resize the window on IME open, so track keyboard height ourselves and lift the input without resizing the desktop PTY. const [keyboardHeight, setKeyboardHeight] = useState(0) - // Why: server-authoritative display mode per terminal. The runtime is the - // single source of truth — this state is populated from subscribe responses. + // Why: server-authoritative display mode per terminal, populated from subscribe responses. const [terminalModes, setTerminalModes] = useState>(new Map()) const [terminalKeyboardMetrics, setTerminalKeyboardMetrics] = useState< Map @@ -973,15 +955,13 @@ export default function SessionScreen() { const [selectModeActive, setSelectModeActive] = useState(false) const [canPaste, setCanPaste] = useState(false) const [showDictationSetup, setShowDictationSetup] = useState(false) - // 'hold' makes the mic press-and-hold; 'toggle' makes it tap-to-start/stop. - // Mirrors Settings ▸ Voice ▸ Dictation Mode so the button matches the setting. + // 'hold' = press-and-hold mic, 'toggle' = tap-to-start/stop; mirrors Settings ▸ Voice ▸ Dictation Mode. const [dictationMode, setDictationMode] = useState<'toggle' | 'hold'>('toggle') const [toastMessage, setToastMessage] = useState(null) const toastOpacityRef = useRef(new Animated.Value(0)) const toastHideTimerRef = useRef | null>(null) const toastSeqRef = useRef(0) - // Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every - // change so paste reads a synchronous snapshot — no round-trip required. + // Why: WebView pushes terminal modes on every change so paste reads a synchronous snapshot — no round-trip. const ptyModesRef = useRef>(new Map()) const terminalGestureInputBucketsRef = useRef>(new Map()) const terminalGestureInputQueuesRef = useRef>(new Map()) @@ -989,13 +969,11 @@ export default function SessionScreen() { const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) - // Why: state (not a ref) — the connection verdict needs a re-render once - // the endpoint loads so the Tailscale hint can appear. + // Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear. const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) const connStateRef = useRef(connState) - // Why: measured once from TerminalWebView on mount, then passed with every - // subscribe call so the server can auto-fit the PTY to phone dimensions. + // Why: measured once on mount, then passed with every subscribe so the server can auto-fit the PTY to phone dims. const viewportRef = useRef<{ cols: number; rows: number } | null>(null) const viewportMeasuredRef = useRef(false) const terminalRefs = useRef>(new Map()) @@ -1015,53 +993,31 @@ export default function SessionScreen() { const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics()) - // Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews - // (opacity:0) may have delayed JS execution on iOS. We must not subscribe - // until the WebView has fired web-ready, otherwise init() messages queue - // and may not render reliably. + // Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered. const webReadyHandlesRef = useRef>(new Set()) const activeHandleRef = useRef(null) const activeSessionTabTypeRef = useRef(null) const pendingActiveSessionTabIdRef = useRef(null) const pendingActiveTerminalHandleRef = useRef(null) - // Why: a browser tab opened from a terminal-tapped HTML must be focused as an - // Orca session tab (bridge auto-activate only flags the live webContents, not - // the app-level active tab). We remember the page id and, once its session tab - // syncs, activate it through the normal switchSessionTab path (which also makes - // switching back to the terminal work). A ref breaks the callback dep cycle. + // Why: remember the page id to activate its session tab once it syncs (bridge auto-activate flags only webContents, not the app-level active tab). const pendingBrowserFocusPageIdRef = useRef(null) const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null) const pendingTerminalActivationAttemptRef = useRef(null) - // Why: handleTerminalOpenUrl is memoized on terminalLinkOpenMode, but - // handleCreateBrowser is a per-render closure that captures the live `client`. - // A terminal URL tap must run the CURRENT closure (the memoized one can hold a - // render where client was still null/connecting, silently no-opping the - // in-app-browser open). Route through a ref kept current every render. + // Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render). const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise) | null>(null) const initialEmptySessionAutoCreateRef = useRef(null) const markdownSaveSeqRef = useRef>(new Map()) const markdownSaveInFlightRef = useRef>(new Set()) const subscribeSeqRef = useRef>(new Map()) - // Why: post-RPC refresh timers capture this screen and must not survive - // route reuse or unmount. + // Why: post-RPC refresh timers capture this screen and must not survive route reuse or unmount. const delayedActionTimersRef = useRef>>(new Set()) - // Why: server-side layout state machine emits a monotonic seq on every - // applyLayout. Track the highest seq we've observed per handle and drop - // any scrollback/resized event with a strictly older seq — these are - // late-arriving events from a superseded layout (e.g. phone-fit dims - // landing after the user toggled to desktop). Drops below `>20`-window - // gap reset (treat as a fresh subscription, e.g. server restart). + // Why: highest applyLayout seq seen per handle; drop older scrollback/resized as stale, but a >20 gap resets (fresh subscription/server restart). const layoutSeqRef = useRef>(new Map()) const sendingRef = useRef(false) - // Why: tracks the pixel height of the terminal frame so measureFitDimensions - // can use the exact container height instead of relying on window.innerHeight, - // which can overstate the visible area due to layout timing. + // Why: exact terminal-frame height for measureFitDimensions; window.innerHeight can overstate the visible area. const terminalFrameHeightRef = useRef(0) - // Why: the terminal frame's width changes when EITHER sidebar is resized (the - // left worktree sidebar shrinks the detail pane; the right dock takes a slice of - // the row) without any window-dim change. Tracking the measured width lets the - // refit hook re-fit the PTY on those resizes — see terminal-viewport-refit.ts. + // Why: sidebar resizes change the terminal frame width without a window-dim change; track it so the refit hook re-fits (see terminal-viewport-refit.ts). const [terminalFrameWidth, setTerminalFrameWidth] = useState(0) const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null const { @@ -1090,18 +1046,16 @@ export default function SessionScreen() { activeSessionTab?.type !== 'browser' const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) - // Why: hosts without aiVault.v1 reject aiVault.listSessions, so the header - // entry stays hidden there (mirrors the gated host-list action) instead of - // opening a dead-end "update this host" panel. + // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState( null ) + const [quickCommandsSupported, setQuickCommandsSupported] = useState(null) // Why: stable callbacks (handleFileTap) read the live value via this ref, since // the capability probe resolves after the callbacks are created. const browserScreencastSupportedRef = useRef(browserScreencastSupported) browserScreencastSupportedRef.current = browserScreencastSupported - // Why: terminal gesture/input callbacks are intentionally stable and - // imperative; keep their refs current before commit instead of one effect later. + // Why: terminal gesture/input callbacks are stable/imperative, so keep their refs current before commit, not in a later effect. clientRef.current = client connStateRef.current = connState activeSessionTabTypeRef.current = activeSessionTab?.type ?? null @@ -1112,8 +1066,7 @@ export default function SessionScreen() { createWarningState, initialCreateWarning ) - // Why: Expo can reuse this screen for a new route. Reconcile before paint - // so a dismissed old creation warning never flashes for the next session. + // Why: Expo can reuse this screen for a new route; reconcile before paint so a dismissed old warning doesn't flash. if (reconciledCreateWarningState !== createWarningState) { setCreateWarningState(reconciledCreateWarningState) } @@ -1205,8 +1158,7 @@ export default function SessionScreen() { client, enabled: canSend, onTranscript: (text) => { - // Why: dictation belongs to the visible composer. Native chat consumes it - // locally; terminal mode retains the live-input routing and flush contract. + // Why: dictation belongs to the visible composer — native chat consumes it locally, terminal mode keeps live-input routing. if (showNativeChatRef.current) { nativeChatController.setChatComposerText((current) => appendBufferedDictation(current, text) @@ -1214,9 +1166,7 @@ export default function SessionScreen() { showToast('Dictation inserted') return } - // Live mode inserts the transcript straight into its originating PTY as - // text (no Return — the user sends it themselves), matching live keystroke - // semantics; buffered mode keeps appending to the command field. + // Live mode inserts the transcript into its PTY as text (no Return); buffered mode appends to the command field. const routeContext = dictationRouteContextRef.current dictationRouteContextRef.current = null const route = routeDictationTranscript( @@ -1245,8 +1195,7 @@ export default function SessionScreen() { }, onError: (err) => { dictationRouteContextRef.current = null - // Dictation isn't set up on the desktop yet → open the setup sheet so the - // user can download a model + enable it from here, instead of a dead-end toast. + // Dictation not set up on desktop → open the setup sheet instead of a dead-end toast. if (isDictationSetupRequiredError(err.message)) { setShowDictationSetup(true) return @@ -1316,8 +1265,7 @@ export default function SessionScreen() { } }, [client]) - // Re-read on focus so a Dictation Mode change made in Settings ▸ Voice is - // reflected when the user returns to the session. + // Re-read on focus so a Settings ▸ Voice dictation-mode change is reflected on return. useFocusEffect( useCallback(() => { void refreshDictationMode() @@ -1339,9 +1287,7 @@ export default function SessionScreen() { subscribingHandlesRef.current.delete(handle) terminalDiagnosticsRef.current.terminalUnsubscribed(handle) subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) - // Why: a fresh subscription will land on a new server-side state machine - // run (or the same one with a higher seq); reset the high-water mark so - // the first scrollback isn't accidentally dropped as stale. + // Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale. layoutSeqRef.current.delete(handle) clearNativeChatInputLease(handle) }, @@ -1367,9 +1313,7 @@ export default function SessionScreen() { } }, [clearNativeChatInputLease]) - // Why: measures the phone viewport once from the first available TerminalWebView. - // The viewport dims are passed with every subscribe call so the server can - // auto-fit the PTY without a separate RPC round-trip. + // Why: measure the phone viewport once from the first TerminalWebView; dims ride every subscribe so the server auto-fits without a separate RPC. const measureViewportOnce = useCallback( async (handle: string) => { if (viewportMeasuredRef.current) { @@ -1409,8 +1353,7 @@ export default function SessionScreen() { activeHandleRef.current, handle ) - // Why: a native-chat-covered terminal subscribes as the input-floor lease - // without a mounted xterm webview, so only gate on the webview when NOT covered. + // Why: a native-chat-covered terminal has no mounted webview, so only gate on the webview when not covered. if (!covered) { if (!getTerminalRef(handle)) { logSkippedGate('no-webview-ref') @@ -1427,10 +1370,7 @@ export default function SessionScreen() { subscribeSeqRef.current.set(handle, seq) diagnostics.streamArmed(handle, seq, viewportRef.current) - // Why: server handles auto-fit on subscribe — no terminal.focus call needed. - // The viewport is embedded in the subscribe params so the server resizes - // the PTY before serializing scrollback. This eliminates the focus→safeFit - // race and the measure→resize→resubscribe pipeline. + // Why: viewport is embedded in the subscribe params so the server auto-fits before serializing scrollback (no focus→safeFit race). const unsub = subscribeMobileTerminalSafely( client, { @@ -1453,8 +1393,7 @@ export default function SessionScreen() { markNativeChatInputLeaseReady(handle) return } - // Why: retain the subscription as the mobile input-floor lease, but - // do not mutate covered xterm state; return-to-terminal resubscribes. + // Why: keep the subscription as the input-floor lease but don't mutate covered xterm state; return-to-terminal resubscribes. if ( nativeChatTerminalStream.isTerminalCoveredByNativeChat( showNativeChatRef.current, @@ -1464,15 +1403,7 @@ export default function SessionScreen() { ) { return } - // Why: stale-event filter. Server-side state machine bumps a - // monotonic seq on every applyLayout. Drop `resized` events - // whose seq is strictly older than what we've already observed - // for this handle — they're late-arriving from a superseded - // layout. `scrollback` is the response to a fresh subscribe, - // so it always resets the high-water mark regardless of seq - // (post-WS-reconnect or post-resubscribe the server may emit - // scrollback at a seq lower than what we'd seen pre-reconnect; - // dropping it would leave the user with a blank terminal). + // Why: drop `resized` events older than the seen seq (superseded layout); scrollback always resets the mark, else reconnect blanks the terminal. const eventSeq = typeof data.seq === 'number' ? data.seq : null if (eventSeq != null && data.type === 'resized') { const last = layoutSeqRef.current.get(handle) @@ -1508,11 +1439,7 @@ export default function SessionScreen() { : '' const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined const ref = getTerminalRef(handle) - // Why: previously we set `initializedHandlesRef` even when the - // WebView wasn't mounted yet (ref=null). The init message went - // nowhere, but the flag stayed true, so any subsequent scrollback - // for THIS handle was silently dropped → blank terminal. Only - // mark initialized if init() actually reached the WebView. + // Why: only mark initialized once init() reaches the WebView, else later scrollback is dropped and the terminal stays blank. if (!ref) { console.log('[fit][session] scrollback DROPPED — no terminal ref', { handle: handle.slice(-8), @@ -1528,21 +1455,9 @@ export default function SessionScreen() { new Map(prev).set(handle, data.displayMode as MobileDisplayMode) ) } - // Why: belt-and-suspenders cold-start fit. The applyFitScale - // queued by init() runs after writes drain, but on cold start - // xterm's scrollWidth can still be transient when it commits. - // Re-fire after a short delay so it runs against a settled DOM. - // Mirrors the 'resized' handler below. + // Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM. scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) - // Why: viewport measurement needs xterm to be initialized (cell - // dimensions come from the renderer). On the first subscribe the - // WebView hasn't loaded yet, so viewportRef is null and the server - // can't auto-fit. After the first init we can measure, then - // resubscribe so the server gets the viewport and phone-fits. - // If viewport was measured by a parallel path BUT the scrollback - // we just received came back at desktop dims, our subscribe - // beat the measure; the server still has a null viewport for - // this subscriber record — resubscribe so it gets stored. + // Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit. const needsResubscribe = !viewportMeasuredRef.current || (viewportRef.current != null && @@ -1550,14 +1465,7 @@ export default function SessionScreen() { scrollbackRows !== viewportRef.current.rows)) if (needsResubscribe) { void (async () => { - // Why: wait for the WebView's init() rAF chain to fully - // run (term.open → renderService population → first - // paint) before measuring. Without this, the measure - // postMessage races ahead of init's async work and - // returns null (term not ready / cells size 0), the - // resubscribe never fires, and the server never gets - // phone dims. See log dump 2026-05-06 confirming the - // race + measure-result null pattern. + // Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06). await getTerminalRef(handle)?.awaitReady() if (subscribeSeqRef.current.get(handle) !== seq) { return @@ -1565,25 +1473,14 @@ export default function SessionScreen() { const dims = await getTerminalRef(handle)?.measureFitDimensions( terminalFrameHeightRef.current || undefined ) - // Why: re-check seq after the awaits — awaitReady (up to - // 3s) and measureFitDimensions can take hundreds of ms, - // during which a newer subscribe cycle may have armed - // its own subscription. Tearing it down here would reset - // the freshly-armed initialized flag and re-subscribe a - // stale generation. + // Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation. if (subscribeSeqRef.current.get(handle) !== seq) { return } if (!getTerminalRef(handle)) { return } - // Why: we just got `scrollback` with cols=80 (server's - // default fallback for null viewport). That means the - // server-side subscriber record was registered before we - // could send viewport. Even if `viewportMeasuredRef` - // raced ahead via a parallel `measureViewportOnce`, the - // server still has a null viewport for THIS subscriber - // record — we MUST resubscribe so the server stores it. + // Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it. if (dims) { diagnostics.streamResubscribing(handle, seq, dims) viewportRef.current = dims @@ -1598,11 +1495,7 @@ export default function SessionScreen() { updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) } else if (data.type === 'data') { updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) - // Why: log when data arrives but the WebView ref is missing - // — this is the most likely cause of "blank but input works": - // server stream is alive, sends flow, but writes are dropped - // because the WebView ref disappeared (unmount mid-flight) or - // the scrollback never landed (so xterm has no buffer). + // Why: missing ref is the likely cause of "blank but input works" — writes dropped after mid-flight unmount or scrollback never landed. const dataRef = getTerminalRef(handle) if (!dataRef) { console.log('[fit][session] data DROPPED — no terminal ref', { @@ -1621,12 +1514,7 @@ export default function SessionScreen() { dataRef.write(data.chunk as string) } else if (data.type === 'resized') { updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current) - // Why: inline resize event — the server changed the PTY dimensions - // (mode toggle, desktop restore, or a width reflow). When the server - // includes a fresh full-buffer snapshot (width reflow), reinitialize - // xterm at the new dims so the hard-wrapped scrollback rewraps; - // preserve the reader's scroll position across the replay. Otherwise - // resize xterm geometry and let the TUI's own redraw repaint. + // Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry. const cols = (data.cols as number) || 80 const rows = (data.rows as number) || 24 const serialized = typeof data.serialized === 'string' ? data.serialized : null @@ -1670,9 +1558,7 @@ export default function SessionScreen() { unsubscribe: unsubscribeTerminal }) - // Why: toggles between phone and desktop mode via server RPC. The server - // handles the actual resize and emits a 'resized' event on the existing - // subscription stream — no client-side state tracking needed. + // Why: server does the resize and emits 'resized' on the existing subscription — no client-side state tracking needed. const toggleInFlightRef = useRef>(new Set()) const toggleDisplayMode = useCallback( async (handle: string) => { @@ -1683,8 +1569,7 @@ export default function SessionScreen() { return } const current = terminalModes.get(handle) ?? 'auto' - // Why: 'phone' on the wire is an observation ("currently phone-fitted"), - // not a setting. The toggle only ever requests 'auto' or 'desktop'. + // Why: 'phone' is an observed state, not a setting; the toggle only requests 'auto' or 'desktop'. const next: 'auto' | 'desktop' = current === 'auto' || current === 'phone' ? 'desktop' : 'auto' toggleInFlightRef.current.add(handle) @@ -1692,14 +1577,11 @@ export default function SessionScreen() { await client.sendRequest('terminal.setDisplayMode', { terminal: handle, mode: next, - // Why: presence-lock take-floor signal — requesting 'auto' is the - // explicit "I want to drive at phone dims" gesture. + // Why: presence-lock take-floor — requesting 'auto' is the explicit "drive at phone dims" gesture. ...(deviceTokenRef.current ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}), - // Why: late-bind viewport for terminals whose subscribe record - // was registered before measurement landed. Without this the - // server's stored viewport is null and auto toggles no-op. + // Why: late-bind viewport for terminals subscribed before measurement, or auto toggles no-op on a null stored viewport. ...(viewportRef.current && next === 'auto' ? { viewport: viewportRef.current } : {}) }) } catch { @@ -1735,19 +1617,14 @@ export default function SessionScreen() { if (result.terminals.length === 0 && !allowEmptyLoaded) { return } - // Why: protect against transient empty responses from the server - // during rapid tab switching or RPC timing. If we previously had - // terminals and the server now says 0, require a second consecutive - // empty to confirm. This prevents the UI from flashing empty during - // rapid interactions while still allowing genuine cleanup. + // Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty. if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) { lastKnownTerminalCountRef.current = 0 return } const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) - // Why: terminal.list is the lifetime signal; session-tab snapshots can lag - // mobile-created tabs and must not erase a user's buffered-mode opt-out. + // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. pruneTerminalHandlesFromLiveInput(liveHandles) defaultTerminalHandlesToLiveInput([...liveHandles]) for (const handle of Array.from(terminalUnsubsRef.current.keys())) { @@ -1767,11 +1644,7 @@ export default function SessionScreen() { } } lastKnownTerminalCountRef.current = result.terminals.length - // Why: defense-in-depth dedupe. If the server ever returns a list - // with the same handle twice (race during rename/split, or stale - // process tracking), React would throw 'two children with same - // key' on render. Keep the first occurrence — list order matters - // for the tab strip, and createParams puts new tabs at the end. + // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. const seen = new Set() const deduped = result.terminals.filter((t) => { if (seen.has(t.handle)) { @@ -1791,8 +1664,7 @@ export default function SessionScreen() { ) terminalsRef.current = mergedTerminals - // Session tabs are the UI authority. terminal.list only refreshes - // per-handle metadata for existing ready terminal surfaces. + // Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces. } } catch { // Failed to list terminals @@ -1814,8 +1686,7 @@ export default function SessionScreen() { const applySessionTabs = useCallback( (result: SessionTabsResult) => { const diagnostics = terminalDiagnosticsRef.current - // Reject out-of-order snapshots, then suppress just-closed tabs until the - // publisher confirms their absence. See session-tab-snapshot-gate. + // Reject stale snapshots; suppress just-closed tabs until the publisher confirms absence — see session-tab-snapshot-gate. if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) { return } @@ -1837,8 +1708,7 @@ export default function SessionScreen() { tab.type === 'markdown' && tab.id === tabId ) if (draftTab) { - // Why: save-only mobile edits live only on the phone until Save. If the - // desktop tab disappears, keep every local draft reachable for copy/discard. + // Why: mobile edits live on the phone until Save; if the desktop tab vanishes, keep drafts reachable for copy/discard. orphanedDraftTabs.push({ ...draftTab, isActive: tabId === activeSessionTabIdRef.current }) } } @@ -1846,8 +1716,7 @@ export default function SessionScreen() { nextTabs = [...orphanedDraftTabs, ...nextTabs] } sessionTabsRef.current = nextTabs - // Why: subscribe snapshots often repeat identical tab payloads. Avoid a - // render loop where the subscription effect tears down and replays itself. + // Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop. setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs)) const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs) const terminalTabHandles = terminalTabs.map((terminal) => terminal.handle) @@ -1881,8 +1750,7 @@ export default function SessionScreen() { } else { const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId) if (pendingTab) { - // Why: desktop tab snapshots can lag a mobile tap while activate RPC - // is in flight. Keep the locally selected tab to avoid snapping back. + // Why: desktop tab snapshots can lag a mobile tap mid-activate-RPC; keep the local selection to avoid snapping back. active = pendingTab selectionSource = 'pending-tab' } else { @@ -1908,9 +1776,7 @@ export default function SessionScreen() { selectionSource = 'pending-handle-local-ack' } } else if (pendingTerminalTab) { - // Why: desktop active flags can lag a mobile terminal tap. Key by - // terminal handle too, because fallback PTY tabs may not yet have a - // stable session tab id during new-worktree startup. + // Why: desktop active flags lag a mobile tap; key by handle too, as fallback PTY tabs lack a stable tab id at startup. active = pendingTerminalTab selectionSource = 'pending-handle-tab' } else if (pendingTerminalExists) { @@ -1997,10 +1863,7 @@ export default function SessionScreen() { if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) { throw new Error((response as RpcFailure).error.message) } - // Why: a headless host (no desktop renderer) can't serve the live editor - // document and fails markdown.readTab with renderer_unavailable. Fall back - // to the on-disk file so markdown still renders read-only, matching how - // other file types load via files.read. + // Why: a headless host fails markdown.readTab (renderer_unavailable); fall back to the on-disk file for read-only render. const fallback = await client.sendRequest('files.read', { worktree: `id:${worktreeId}`, relativePath: tab.relativePath @@ -2261,8 +2124,7 @@ export default function SessionScreen() { router.back() return } - // Why: Android back can arrive when this session is the root route; using - // replace avoids React Navigation's dev-only unhandled GO_BACK warning. + // Why: Android back can fire at the root route; replace avoids React Navigation's dev-only GO_BACK warning. router.replace(`/h/${hostId}`) }, [hostId, router]) @@ -2409,8 +2271,7 @@ export default function SessionScreen() { const result = (response as RpcSuccess).result as SessionTabsResult terminalDiagnosticsRef.current.tabsFetchSucceeded(result) applySessionTabs(result) - // Focus a just-opened browser tab once it appears in the snapshot, via the - // normal activate path so it sticks and the user can still switch away. + // Focus a just-opened browser tab when it appears, via the normal activate path so it sticks yet stays switchable. const pendingPageId = pendingBrowserFocusPageIdRef.current if (pendingPageId) { const browserTab = result.tabs.find( @@ -2448,9 +2309,15 @@ export default function SessionScreen() { if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + setQuickCommandsSupported(null) + setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false return } + // Why: a client swap can keep the route connected while moving to an older + // host; clear the prior capability before exposing host-specific actions. + setQuickCommandsSupported(null) + setShowQuickCommands(false) let stale = false void client .sendRequest('status.get') @@ -2465,6 +2332,7 @@ export default function SessionScreen() { setAgentSessionHistorySupported( status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true ) + setQuickCommandsSupported(supportsMobileQuickCommands(status.capabilities)) // Why: hosts without this capability strip inputKind from terminal.send, // so a forwarded xterm reply would become floor-stealing shell input. hostQueryReplyInputSupportedRef.current = @@ -2474,6 +2342,8 @@ export default function SessionScreen() { if (!stale) { setBrowserScreencastSupported(false) setAgentSessionHistorySupported(false) + setQuickCommandsSupported(false) + setShowQuickCommands(false) hostQueryReplyInputSupportedRef.current = false } }) @@ -2482,10 +2352,7 @@ export default function SessionScreen() { } }, [client, connState]) - // Why: deviceToken is read from host record so feature code can pass - // `client.id` on subscribe/send for driver-state-machine identity. - // The shared client itself stays alive across screens; we just need - // the token alongside the client. + // Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity. useEffect(() => { if (!hostId) { return @@ -2560,9 +2427,7 @@ export default function SessionScreen() { for (const terminalRef of terminalRefs.current.values()) { terminalRef.prepareForForegroundRecovery() } - // Why: iOS can resume a live WKWebView with a blank xterm backing store - // without firing web-ready/reconnect; invalidate the native readiness - // latch before replay so init waits for the document's pong. + // Why: iOS can resume a WKWebView with a blank xterm store and no web-ready; invalidate the latch so init waits for the pong. const outcome = recoverActiveTerminalAfterForeground({ activeHandleRef, terminalRefs, @@ -2579,10 +2444,7 @@ export default function SessionScreen() { } }, [scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal]) - // Why: resume usually lands mid-reconnect (the socket dies after ~60-80s of - // background), so the recovery above defers. Re-run it once the connection - // is back; otherwise a blanked WKWebView whose socket was merely probed (no - // stream replay) stays stale until a manual tab switch. + // Why: resume lands mid-reconnect (socket dies in bg); re-run recovery once connected or a blanked WKWebView stays stale. useEffect(() => { if (connState !== 'connected' || !pendingForegroundRecoveryRef.current) { return @@ -2602,9 +2464,7 @@ export default function SessionScreen() { }) }, [connState, scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal]) - // Why: viewport refits for layout changes outside the subscribe path - // (tab strip toggling, fold/unfold, rotation) live in a dedicated hook — - // see terminal-viewport-refit.ts for the full rationale. + // Why: non-subscribe layout refits (tab strip, fold, rotation) live in a dedicated hook — see terminal-viewport-refit.ts. const { notifyTerminalFrameHeight, notifyKeyboardVisibility } = useTerminalViewportRefit({ activeHandleRef, terminalRefs, @@ -2662,8 +2522,7 @@ export default function SessionScreen() { } }, []) - // Reveal the active tab whenever it changes (e.g. desktop's open tab synced on - // worktree entry). Defer one frame so freshly mounted tab layouts are recorded. + // Reveal the active tab on change; defer one frame so freshly mounted tab layouts are recorded. useEffect(() => { const id = requestAnimationFrame(() => scrollActiveTabIntoView(activeSessionTabId, true)) return () => cancelAnimationFrame(id) @@ -2693,9 +2552,7 @@ export default function SessionScreen() { }, [router]) useEffect(() => { - // Why: Expo can reuse this screen across worktrees. Reset pending - // keyboard listeners, snapshot floors, and tombstones so prior route state - // cannot open stale UI or reject the next worktree's first snapshot. + // Why: Expo reuses this screen across worktrees; reset route state so it can't open stale UI or reject the next snapshot. sessionTabActionSheetRequestSeqRef.current += 1 sessionTabActionSheetKeyboardHideSubRef.current?.remove() sessionTabActionSheetKeyboardHideSubRef.current = null @@ -2744,18 +2601,11 @@ export default function SessionScreen() { if (connState !== 'connected') { return } - // Why: the RPC client auto-resends terminal.subscribe on reconnect. - // Keep the current xterm visible while the binary snapshot hydrates, - // instead of clearing to a blank "Loading terminals" surface. + // Why: keep the current xterm visible while the reconnect snapshot hydrates, not a blank "Loading terminals" surface. if (initializedHandlesRef.current.size === 0) { setTerminalsLoaded(false) } - // Why: on reconnect the RPC client auto-resends terminal.subscribe and - // the server sends a fresh scrollback frame. The subscribe handler drops - // scrollback when initializedHandlesRef already contains the handle, so - // we'd keep stale pre-disconnect content (and lose any output emitted - // during the disconnect). Clear the flag so the fresh snapshot calls - // ref.init(...) and replaces the buffer. + // Why: clear the initialized flag so the reconnect scrollback replaces stale content instead of being dropped. initializedHandlesRef.current.clear() let disposed = false const timers: ReturnType[] = [] @@ -2772,8 +2622,7 @@ export default function SessionScreen() { } } if (client && created !== '1') { - // Why: mobile needs host-owned tabs hydrated for this route, but should - // not pull other paired clients, especially desktop, into this worktree. + // Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree. void client .sendRequest('worktree.activate', { worktree: `id:${worktreeId}`, @@ -2864,8 +2713,7 @@ export default function SessionScreen() { } void fetchSessionTabs() void fetchTerminals() - // Why: the live tab subscription stays mounted for stream ownership, - // but the fallback list poll should stop while this route is hidden. + // Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden. const interval = setInterval(() => { void fetchSessionTabs() void fetchTerminals() @@ -2874,8 +2722,7 @@ export default function SessionScreen() { }, [connState, fetchSessionTabs, fetchTerminals]) ) - // Why: pick up the Settings → Terminal text size when returning here — the - // terminal panes stay mounted, so they update in place. + // Why: pick up Settings → Terminal text size on return; panes stay mounted and update in place. useFocusEffect( useCallback(() => { let active = true @@ -2920,10 +2767,7 @@ export default function SessionScreen() { }, []) ) - // Why: unsubscribe the old terminal so the server restores its desktop dims - // (clearing the phone-fit banner), then subscribe the new terminal with the - // measured viewport so the server phone-fits it. Also call terminal.focus - // so the desktop renderer follows the mobile user's active terminal. + // Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one; terminal.focus makes desktop follow. const switchTab = useCallback( (handle: string) => { triggerSelection() @@ -2953,8 +2797,7 @@ export default function SessionScreen() { if (client) { void focusMobileTerminal(client, handle).catch(() => {}) if (matchingTab) { - // Why: persist selection for headless hosts; the snapshot gate keeps - // this phone-local acknowledgement from impersonating desktop focus. + // Why: persist selection for headless hosts; snapshot gate stops this phone-local ack from impersonating desktop focus. void activateMobileSessionTab(client, { worktree: `id:${worktreeId}`, tabId: matchingTab.id, @@ -3034,20 +2877,15 @@ export default function SessionScreen() { if (cached?.status === 'ready' && cached.isDirty) { return } - // Why: desktop clean saves do not carry a reliable content version in the - // lightweight tab list. Re-read on revisit unless the phone has a draft. + // Why: tab list lacks a reliable version for desktop clean saves; re-read on revisit unless the phone has a draft. void readMarkdownTab(tab) }, [client, markdownDocs, readFileTab, readMarkdownTab, switchTab, unsubscribeTerminal, worktreeId] ) - // Keep the ref pointing at the latest switchSessionTab so fetchSessionTabs can - // activate a freshly-synced browser tab without a callback dependency cycle. + // Ref to latest switchSessionTab so fetchSessionTabs can activate a synced browser tab without a dependency cycle. switchSessionTabRef.current = switchSessionTab - // Why: just store the ref. Subscription is deferred to handleTerminalWebReady - // which fires after the WebView has loaded xterm.js and is ready to process - // init messages. This prevents the blank terminal race where init() was - // queued before the WebView loaded. + // Why: only store the ref; subscribe on web-ready to avoid the blank-terminal race (init queued before xterm.js loaded). const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => { terminalDiagnosticsRef.current.webViewRef(handle, ref != null) if (ref) { @@ -3075,12 +2913,7 @@ export default function SessionScreen() { handle === activeHandleRef.current ) if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) { - // Why: the native WebView reloaded (Metro hot reload or Android - // process churn). The old xterm buffer is gone, so force a fresh - // scrollback snapshot. Only resubscribe if this is a reload — on - // first load the subscription is already running and pendingMessages - // will flush the queued init after this callback returns. - // (unsubscribeTerminal also clears layoutSeqRef for this handle.) + // Why: WebView reloaded (hot reload / Android churn); old xterm buffer is gone, so resubscribe for a fresh scrollback. unsubscribeTerminal(handle) initializedHandlesRef.current.delete(handle) if (handle === activeHandleRef.current) { @@ -3088,17 +2921,8 @@ export default function SessionScreen() { } return } - // Why: on first web-ready, the initial subscribeToTerminal call from - // fetchTerminals may have been skipped (reason=no-ref, WebView wasn't - // mounted yet). Now that the WebView is ready, subscribe if this is the - // active terminal and no subscription is running. Await measure before - // subscribe so the very first subscribe carries the viewport — without - // this, subscribe(viewport=null) lands on the server first and the - // post-scrollback measure path's resubscribe sees alreadyMeasured=true - // (because measureViewportOnce won the race) and silently skips. - // Why: a just-created tab can briefly lose activeHandleRef to a lagging - // session-tab snapshot; honor the pending marker so its one web-ready - // subscribe still fires (see handleCreateTerminal). + // Why: first subscribe may skip (no WebView ref); await measure so it carries the viewport, else it races measureViewportOnce and skips. + // Why: a just-created tab can lose activeHandleRef to a lagging snapshot; honor the pending marker so its web-ready subscribe still fires. const isIntendedActive = () => handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) { @@ -3147,9 +2971,7 @@ export default function SessionScreen() { terminal: activeHandle, text, enter: true, - // Why: presence-lock take-floor signal. Identifies this phone as - // the active mobile actor so the runtime can resolve multi-mobile - // contention (most-recent-actor's viewport wins). + // Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor. ...(deviceTokenRef.current ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}) @@ -3268,8 +3090,7 @@ export default function SessionScreen() { const openSessionTabActionSheetAfterKeyboardDismiss = useCallback( (tab: MobileSessionTab) => { - // Why: live input can have a queued refocus; action sheets should open after - // the terminal keyboard is gone, not race it under the drawer. + // Why: live input may queue a refocus; open the action sheet after the keyboard is gone, not racing it under the drawer. sessionTabActionSheetRequestSeqRef.current += 1 const requestSeq = sessionTabActionSheetRequestSeqRef.current clearSessionTabActionSheetKeyboardListener() @@ -3327,9 +3148,7 @@ export default function SessionScreen() { [focusLiveInput] ) - // Tap on a file path in terminal output → resolve it on the host and open it - // as a file tab (mirrors desktop Cmd/Ctrl-click). Silent on a miss; the - // WebView only emits this when the tap landed on a detected path. + // Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss. const handleFileTapActivationSeqRef = useRef(0) const handleFileTap = useCallback( (handle: string, pathText: string, line: number | null, column: number | null) => { @@ -3369,9 +3188,7 @@ export default function SessionScreen() { ) const handleOpenedFileDiffActivationSeqRef = useRef(0) - // Active tab captured at tap time (before the openDiff RPC). Capturing it when - // the diff finishes opening would misread a tab the user switched to mid-RPC - // as the tap-time tab, letting the retry steal focus back to the diff. + // Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus. const fileOpenStartActiveTabIdRef = useRef(null) const handleFileOpenStart = useCallback(() => { fileOpenStartActiveTabIdRef.current = activeSessionTabIdRef.current @@ -3383,8 +3200,7 @@ export default function SessionScreen() { let activated = false const activateOpenedTab = async (): Promise => { - // Route matching through the shared helper so the deterministic repro - // test exercises the same logic production runs. + // Route matching through the shared helper so the repro test exercises the same logic production runs. const settled = await activateOpenedSourceControlDiffTab({ relativePath, activeTabIdAtTap, @@ -3451,8 +3267,7 @@ export default function SessionScreen() { current.tokens + elapsedSeconds * TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND ) - // Why: tokens represent terminal control sequences, not WebView messages; - // one legitimate gesture message may batch up to 32 wheel/key reports. + // Why: tokens count terminal control sequences, not WebView messages; one gesture may batch up to 32 wheel/key reports. if (tokens < sequenceCount) { terminalGestureInputBucketsRef.current.set(handle, { tokens, lastRefillMs: now }) return false @@ -3538,12 +3353,7 @@ export default function SessionScreen() { if (!terminalGestureInputInFlightRef.current.has(handle)) { void flushTerminalGestureInput(handle) } else { - // Why: an RPC is in-flight and the new batch would overflow the - // pending-sequences cap. Appending preserves the already-queued - // bytes (which would otherwise be dropped) — the in-flight flush's - // finally block will pick up the merged queue. The cap is a soft - // guideline; brief overflow during in-flight is preferable to - // silently dropping user input. + // Why: cap is a soft guideline — append instead of dropping queued bytes; the in-flight flush picks up the merged queue. current.bytes += bytes current.sequenceCount += sequenceCount current.lastUpdatedMs = now @@ -3579,8 +3389,7 @@ export default function SessionScreen() { return } const modes = ptyModesRef.current.get(handle) - // Why: WebView gesture bytes can become PTY input here, so mouse-aware - // reports stay behind validation and SSH-safe rate limiting. + // Why: WebView gesture bytes can become PTY input, so gate mouse reports behind validation and SSH-safe rate limiting. if (!modes?.altScreen && !isGestureMouseTrackingMode(modes?.mouseTrackingMode)) { return } @@ -3623,18 +3432,10 @@ export default function SessionScreen() { } } - // Why: press-and-hold key repeat for keys flagged repeatable (arrows, - // backspace, forward-delete). Matches iOS keyboard cadence: instant first - // fire, then ~400ms before the second, then ~45ms between subsequent - // repeats. Non-repeatable keys (Tab, Esc, Ctrl-*) intentionally fire once - // because holding them is destructive or meaningless. + // Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive). const repeatTimeoutRef = useRef | null>(null) const repeatIntervalRef = useRef | null>(null) - // Why: hold the latest handleAccessoryKey in a ref so the repeat interval - // always invokes the current callback. Otherwise a held key keeps firing - // through the callback captured when the interval started, which can route - // bytes to a stale terminal/RPC client after a tab switch or reconnect - // mid-hold. + // Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal. const handleAccessoryKeyRef = useRef(handleAccessoryKey) handleAccessoryKeyRef.current = handleAccessoryKey const stopAccessoryRepeat = useCallback(() => { @@ -3663,9 +3464,7 @@ export default function SessionScreen() { if (node !== null) { return } - // Why: terminal subscriptions and route-level timers must clear only on - // real route detach; client churn during mount can otherwise wipe xterm - // state mid-subscribe. + // Why: clear only on real route detach; client churn during mount would wipe xterm state mid-subscribe. toastSeqRef.current += 1 clearTerminalCache() clearToastHideTimer() @@ -3708,10 +3507,7 @@ export default function SessionScreen() { try { await Clipboard.setStringAsync(text) triggerSuccess() - // Why: Android 13+ shows its own system "Copied to clipboard" toast on - // every clipboard write, so our toast would be redundant; iOS shows - // nothing on copy (it only banners on paste), so the in-app toast is - // the only success signal there. + // Why: Android 13+ shows its own system copy toast; iOS shows none, so only iOS needs our in-app toast. if (Platform.OS === 'ios') { showToast('Copied') } @@ -3939,7 +3735,10 @@ export default function SessionScreen() { async function handleCreateTerminal( agent?: MobileNewTabAgentOption['agent'], - options?: { initialPrompt?: string; onPromptSent?: () => void } + options?: MobileQuickCommandLaunch['options'] & { + onPromptSent?: () => void + errorToast?: string + } ) { if (!client || creatingTerminalRef.current) { return @@ -3949,10 +3748,7 @@ export default function SessionScreen() { setCreating(true) setCreateError('') - // Why: idempotency key so a transport-level retry (reconnect replay) of this - // create resolves to the same terminal instead of spawning a duplicate. Kept - // compact (no worktree id) to stay under the schema's length cap; the ref - // guard above blocks concurrent taps synchronously. + // Why: idempotency key so a transport retry (reconnect replay) resolves to the same terminal, not a duplicate; kept compact (no worktree id) for the schema length cap. const clientMutationId = `mobile-create:${Date.now().toString(36)}-${Math.random() .toString(36) .slice(2, 10)}` @@ -3962,14 +3758,17 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, afterTabId: activeSessionTabId ?? undefined, clientMutationId, + ...(options?.startupCommand ? { command: options.startupCommand } : {}), + ...(options?.startupCommandDelivery + ? { startupCommandDelivery: options.startupCommandDelivery } + : {}), + ...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}), ...(agent ? { agent } : {}) }) if (response.ok) { const result = (response as RpcSuccess).result as TerminalCreateResult const created = result.tab - // Why: unsubscribe the old active terminal so the server restores its - // desktop dims. Without this, the old terminal's mobile subscription - // stays alive and its restore timer is never set. + // Why: unsubscribe the old terminal so the server restores its desktop dims; otherwise its restore timer is never set. const prev = activeHandleRef.current if (prev) { unsubscribeTerminal(prev) @@ -3987,10 +3786,7 @@ export default function SessionScreen() { if (typeof created.terminal === 'string') { const createdHandle = created.terminal defaultTerminalHandlesToLiveInput([createdHandle]) - // Why: session-tab snapshots can lag the create RPC. Without the - // handle marker, applySessionTabs snaps activeHandleRef back to the - // previous terminal, and the new pane's web-ready subscribe (gated - // on the active handle) is skipped — blank tab until a manual switch. + // Why: snapshots lag the create RPC; without this marker applySessionTabs reverts the active handle, blanking the new pane. pendingActiveTerminalHandleRef.current = createdHandle activeHandleRef.current = createdHandle setActiveHandle(createdHandle) @@ -4019,7 +3815,7 @@ export default function SessionScreen() { .sendRequest('terminal.send', { terminal: createdHandle, text: options.initialPrompt, - enter: true, + enter: options.enter !== false, ...(deviceTokenRef.current ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } : {}) @@ -4037,33 +3833,76 @@ export default function SessionScreen() { throw new Error('Terminal input is locked by another client.') } triggerSuccess() - showToast('Notes sent') + showToast(options.successToast ?? 'Notes sent') options.onPromptSent?.() }) .catch((err) => { triggerError() - showToast(err instanceof Error ? err.message : "Couldn't send notes", 1800) + showToast( + options.errorToast ?? + (err instanceof Error ? err.message : "Couldn't send notes"), + 1800 + ) }) + } else if (options?.successToast) { + triggerSuccess() + showToast(options.successToast) } } else { - // Why: a prior pending handle must not outlive a create that returned - // no terminal; web-ready subscribe gates on this ref as active. + // Why: a prior pending handle must not outlive a create that returned no terminal; web-ready subscribe gates on this ref. pendingActiveTerminalHandleRef.current = null activeHandleRef.current = null setActiveHandle(null) } scheduleDelayedAction(() => void fetchSessionTabs(), 500) } else { - setCreateError('Failed to create terminal') + const message = options?.errorToast ?? 'Failed to create terminal' + setCreateError(message) + if (options?.errorToast) { + triggerError() + showToast(message, 1800) + } } } catch { - setCreateError('Failed to create terminal') + const message = options?.errorToast ?? 'Failed to create terminal' + setCreateError(message) + if (options?.errorToast) { + triggerError() + showToast(message, 1800) + } } finally { creatingTerminalRef.current = false setCreating(false) } } + // Quick commands spawn a fresh terminal tab, mirroring desktop's + // run-quick-command-in-new-tab: agent prompts and runnable terminal commands + // use the host's shell-ready startup path; insert-only commands stay drafts. + function launchQuickCommand(command: TerminalQuickCommand): boolean { + if ( + !client || + connState !== 'connected' || + creatingTerminalRef.current || + creatingBrowser || + creatingMarkdown + ) { + return false + } + const launch = buildMobileQuickCommandLaunch(command) + if (!launch) { + triggerError() + showToast('Edit this quick command before running it', 1800) + return false + } + const label = command.label.trim() || 'Quick command' + void handleCreateTerminal(launch.agent, { + ...launch.options, + errorToast: `Couldn't run ${label}` + }) + return true + } + async function handleCreateMarkdownNote() { if (!client || creatingMarkdown) { return @@ -4114,8 +3953,7 @@ export default function SessionScreen() { if (!client || creatingBrowser) { return false } - // Why: read via ref so a tap that fires before the capability probe resolves - // (or from a stale callback) still sees the live support value. + // Why: read via ref so a tap before the capability probe resolves (or a stale callback) still sees the live value. if (browserScreencastSupportedRef.current !== true) { showToast('Desktop update required for mobile browser streaming', 1600) return false @@ -4144,9 +3982,7 @@ export default function SessionScreen() { if (!response.ok) { throw new Error((response as RpcFailure).error.message) } - // Focus the new browser tab once it syncs (fetchSessionTabs activates it - // via the normal path). Refresh a few times since the desktop registers - // the tab asynchronously. + // Focus the new browser tab once it syncs; refresh a few times since the desktop registers the tab asynchronously. const created = (response as RpcSuccess).result as { browserPageId?: string } if (created.browserPageId) { pendingBrowserFocusPageIdRef.current = created.browserPageId @@ -4164,8 +4000,7 @@ export default function SessionScreen() { setCreatingBrowser(false) } } - // Keep the ref pointing at the latest handleCreateBrowser so a terminal URL - // tap (handleTerminalOpenUrl) always runs the current closure. + // Keep the ref at the latest handleCreateBrowser so a terminal URL tap always runs the current closure. handleCreateBrowserRef.current = handleCreateBrowser async function handleBrowserNavigationCommand( @@ -4275,9 +4110,7 @@ export default function SessionScreen() { clearTerminalLiveInputDefault(terminalHandle) } setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) - // Why: tombstone the closed tab and rely on the subscription/poll - // snapshot (gated by snapshotVersion) instead of a blind 300ms refetch - // that re-applied whatever the host had — often the not-yet-closed list. + // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) if (activeSessionTabId === tab.id) { activeSessionTabTypeRef.current = null @@ -4319,8 +4152,7 @@ export default function SessionScreen() { if (pendingTerminalActivationAttemptRef.current === activationKey) { return } - // Why: a hydrated headless/server-owned tab can already be active but still - // pending; activation is the RPC that materializes or focuses its PTY handle. + // Why: a server-owned tab can be active but still pending; activation is the RPC that materializes its PTY handle. pendingTerminalActivationAttemptRef.current = activationKey void activateMobileSessionTab(client, { worktree: `id:${worktreeId}`, @@ -4369,16 +4201,13 @@ export default function SessionScreen() { ) { return } - // Why: a sleeping/new workspace can hydrate with zero session tabs. Create - // the first terminal once on initial load instead of leaving mobile blank. + // Why: a sleeping/new workspace can hydrate with zero tabs; create the first terminal once so mobile isn't blank. initialEmptySessionAutoCreateRef.current = worktreeId setCreateError('') void handleCreateTerminal() }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) - // Why: the reconnect loop slows to a 90s trickle at its give-up cap; - // surface tap-to-retry once the verdict escalates so recovery doesn't - // wait out the trickle timer (issue #5049). + // Why: reconnect trickles to 90s at its give-up cap; surface tap-to-retry so recovery needn't wait it out (issue #5049). const connectionVerdict = classifyConnection({ state: connState, reconnectAttempts, @@ -4399,9 +4228,7 @@ export default function SessionScreen() { ? `${verdictDisplayLabel(connectionVerdict)} — tap to retry` : MOBILE_SESSION_STATUS_LABELS[connState] - // Why: keep safe-area padding in layout at all times, then visually translate - // the controls over the terminal when the keyboard appears. iOS keyboard - // height includes the home-indicator inset; Android IME height does not. + // Why: iOS keyboard height includes the home-indicator inset; Android IME height does not. const keyboardLift = keyboardHeight > 0 ? Platform.OS === 'ios' @@ -4423,8 +4250,7 @@ export default function SessionScreen() { const cursorBottom = (metrics.cursorY + 1) * rowHeight const dockTop = terminalFrameHeightRef.current - keyboardLift const margin = rowHeight - // Why: only move the terminal when the active cursor would sit under the - // raised input dock. Short shell output near the top should stay put. + // Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put. return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop)) })() const toastAnimatedStyle = { @@ -4522,8 +4348,7 @@ export default function SessionScreen() { ] : [] - // Routes a header panel-icon tap through the pure dock-vs-push decision (U1): - // measured dock-capable rows toggle/swap, constrained rows push full-screen. + // Panel-icon taps route through the dock-vs-push decision (U1): dock-capable rows dock, constrained rows push. const handleSessionContentRowLayout = useCallback((event: LayoutChangeEvent) => { const width = Math.round(event.nativeEvent.layout.width) setSessionContentRowWidth((prev) => (prev === width ? prev : width)) @@ -4542,8 +4367,7 @@ export default function SessionScreen() { hostId, worktreeId, name: worktreeName || '', - // SC + PR both land on the source-control hub; post-diff-open dismissal - // keys off origin: 'session' (U2). Files keeps its own route without origin. + // SC + PR both land on the source-control hub with origin:'session' for post-diff-open dismissal (U2); Files opts out. ...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}), // The PR panel routes into the hub's Pull Request segment via descriptor params. ...descriptor.params @@ -4625,10 +4449,7 @@ export default function SessionScreen() { {visibleTabs.length > 0 && ( - {/* Why: tab taps must register on the first press while the live - keyboard is open instead of being eaten by keyboard dismissal - (#5106); leaving a non-live tab still closes the keyboard - because the live input unmounts. */} + {/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */} ))} - {/* Why: pinned outside the scroll strip so the new-agent button - stays reachable no matter how far the tabs scroll. */} + {/* Why: pinned outside the scroll strip so the new-agent button stays reachable however far the tabs scroll. */} [ styles.newTerminalButton, @@ -4715,14 +4535,19 @@ export default function SessionScreen() { > + {quickCommandsSupported === true ? ( + setShowQuickCommands(true)} + /> + ) : null} )} - {/* Content-row host (KTD2): the header/tab chrome stays a full-width sibling - above; on wide the post-chrome content shares this row with the docked panel. - There is no single terminal node, so the entire conditional block is the - flex-1 left child. On narrow the dock never renders and layout is unchanged. */} + {/* Content-row host (KTD2): on wide, content shares this row with the docked panel as the flex-1 left child. */} {createWarning ? ( @@ -4820,8 +4645,7 @@ export default function SessionScreen() { ) : activeBrowserTab ? ( - {/* Why: the pane owns imperative frame refs; browser tabs should - never render a stale frame while the old stream effect cleans up. */} + {/* Why: pane owns imperative frame refs; don't render a stale frame while the old stream effect cleans up. */} { terminalFrameHeightRef.current = e.nativeEvent.layout.height - // Why: notify height imperatively so dock settling re-fits the - // PTY without rerendering SessionScreen for layout callbacks. + // Why: notify height imperatively so dock settling re-fits the PTY without rerendering SessionScreen. const nextWidth = Math.round(e.nativeEvent.layout.width) const nextHeight = Math.round(e.nativeEvent.layout.height) setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth)) @@ -4867,8 +4690,7 @@ export default function SessionScreen() { terminalTheme={terminal.terminalTheme} textScale={terminalTextScale} onTextScaleChange={(scale) => { - // Why: pinch-to-zoom in the WebView reports a new preset; persist - // it so the size sticks across panes and app launches. + // Why: pinch-to-zoom reports a new preset; persist it so the size sticks across panes and launches. setTerminalTextScale(scale) void saveTerminalTextScale(scale) }} @@ -4907,9 +4729,7 @@ export default function SessionScreen() { )} - {/* Why: translate instead of resizing so keyboard open/close does not - trigger a server-side PTY viewport change. The dock hides in native - chat because that view supplies its own composer. */} + {/* Why: translate instead of resize so keyboard toggles don't trigger a server-side PTY viewport change. */} {!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && ( {/* Accessory keys */} - {/* Why: a fixed, always-visible escape hatch from the open - keyboard. Kept outside the horizontal ScrollView so it does - not scroll away, and out of the terminal-byte shortcut path so - it cannot be hidden by user shortcut customization (#5106). */} + {/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */} {keyboardLift > 0 && ( [ @@ -4946,9 +4763,7 @@ export default function SessionScreen() { )} - {/* Why: with default tap handling the first tap on any accessory - key dismisses the open keyboard and is swallowed, so live - input lost its keyboard on every Esc/Tab press (#5106). */} + {/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */} setShowHeaderMoreActions(false)} /> + setShowQuickCommands(false)} + client={client} + repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null} + repoName={worktreeName || null} + onLaunch={launchQuickCommand} + /> + () let nextClientIdentity = 1 function clientKey(client: RpcClient): number { @@ -164,11 +164,7 @@ function fetchWorktreeInfo( ) => void, disposed: () => boolean ) { - // Why: only seed an empty zeroed entry when this host has no prior info - // at all (e.g., first ever load before any cache hydration). On a - // transient failure for a host that already has cached data, leave the - // cached entry alone so the Resume card and host-meta line don't - // momentarily flip to "0 worktrees" / disappear during reconnects. + // Why: only seed a zeroed entry when the host has no prior info; keep cached data on transient failure so counts don't flip to 0 during reconnects. const markLoadedIfMissing = () => { setInfo((prev) => { if (prev[hostId]) { @@ -187,8 +183,7 @@ function fetchWorktreeInfo( } client - // Why: worktree.ps defaults to 200 and silently truncates; request the full - // set so the host worktree count and active count are accurate. + // Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate. .sendRequest('worktree.ps', { limit: 10000 }) .then((response) => { if (disposed()) { @@ -286,8 +281,7 @@ function fetchTaskProviders( }) } -// Why: repo names get a stable color derived from hashing, matching the -// host detail page's colored dots for visual consistency. +// Why: hash repo name to a stable color, matching the host detail page's dots. const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] function repoColor(name: string): string { let hash = 0 @@ -300,8 +294,7 @@ function repoColor(name: string): string { export default function HomeScreen() { const router = useRouter() const insets = useSafeAreaInsets() - // Why: cap and center content on wide/tablet canvases so cards don't stretch - // edge-to-edge on iPad; on phones isWideLayout is false and layout is unchanged. + // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad. const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [hosts, setHosts] = useState([]) const [actionTarget, setActionTarget] = useState(null) @@ -316,11 +309,11 @@ export default function HomeScreen() { const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( null ) - const notificationOptInCheckedRef = useRef(false) + // Why: focus can fire repeatedly while an async gate is pending; one probe per + // mount avoids duplicate storage/permission reads and competing navigation. + const onboardingOptInCheckedRef = useRef(false) - // Why: read shared clients from the per-host store. Replaces the prior - // pattern of opening N independent WebSockets here. See - // docs/mobile-shared-client-per-host.md. + // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) const allClients = useAllHostClients(hostIds) const hostPaths = useMemo( @@ -330,27 +323,20 @@ export default function HomeScreen() { const closeHostClient = useCloseHost() const forceReconnectHost = useForceReconnect() const primeHosts = usePrimeHosts() - // Why: feed the loaded HostProfiles into the provider's prime cache as - // soon as we have them. This avoids a second Keychain pass inside - // openEntry on cold start (which serialised behind the first one and - // showed up as multi-second connect latency). + // Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start. useEffect(() => { if (hosts.length > 0) { primeHosts(hosts) } }, [hosts, primeHosts]) const allClientsRef = useRef>([]) - // Why: the focus callback stays stable to avoid refetching on every - // client-store render, but it still needs the latest host clients. + // Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients. allClientsRef.current = allClients.map((entry) => ({ hostId: entry.hostId, client: entry.client })) - // Why: hydrate the home page from a persisted snapshot on cold-start so - // Resume + Account-usage cards paint immediately with last-known data - // instead of flashing empty for ~1s while the WebSocket reconnects. - // Stream/list responses overwrite this seed in place when they arrive. + // Why: hydrate from a persisted snapshot on cold-start so Resume + Account cards paint immediately instead of flashing empty. const hydratedRef = useRef(false) useEffect(() => { if (hydratedRef.current) { @@ -367,8 +353,7 @@ export default function HomeScreen() { for (const [hostId, info] of Object.entries(snap.worktreeInfo)) { const wt = info.lastActiveWorktree if (wt) { - // Why: also seed the in-memory worktree cache so resumeWorktree's - // lastVisited fast-path can find the cached worktree object. + // Why: seed the in-memory cache so resumeWorktree's lastVisited fast-path finds the worktree object. setCachedWorktrees(hostId, [wt]) } } @@ -378,9 +363,7 @@ export default function HomeScreen() { } }, []) - // Why: persist the merged snapshot whenever either piece updates so the - // next cold-start has fresh seed data. The cache module debounces writes - // internally so a flurry of streamed updates doesn't hammer disk. + // Why: persist the merged snapshot on each update so the next cold-start has fresh seed data (cache debounces writes). useEffect(() => { if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) { return @@ -400,13 +383,16 @@ export default function HomeScreen() { return } setHosts(h) - if (h.length === 0 || notificationOptInCheckedRef.current) { + if (h.length === 0 || onboardingOptInCheckedRef.current) { return } - notificationOptInCheckedRef.current = true - const showNotificationOptIn = await shouldPresentNotificationOptIn() - if (!stale && showNotificationOptIn) { - router.replace('/notification-opt-in') + onboardingOptInCheckedRef.current = true + const onboardingSteps = await loadMobileOnboardingSteps() + if (stale) { + return + } + if (onboardingSteps.length > 0) { + router.replace(mobileOnboardingDestination(onboardingSteps)) } }) void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => { @@ -436,8 +422,7 @@ export default function HomeScreen() { [hosts] ) - // Why: mirror per-host connection state into hostStates so existing - // render code (status dots, connecting indicators) keeps working. + // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working. useEffect(() => { setHostAttempts((prev) => { const next: Record = { ...prev } @@ -473,11 +458,7 @@ export default function HomeScreen() { changed = true } } - // Why: when a paired host disappears from allClients (because the - // user tapped Disconnect, or the host record was invalid) the card - // must reflect that. We only force-update hosts whose state was - // already tracked — otherwise the initial-acquire frame (entry not - // yet materialised) would briefly flip every host to 'disconnected'. + // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'. for (const host of hosts) { if (liveIds.has(host.id)) { continue @@ -506,11 +487,7 @@ export default function HomeScreen() { }) }, [allClients, hosts]) - // Why: per-host streaming subscriptions (notifications + accounts) and - // one-shot stats fetches when each host transitions to 'connected'. - // Runs once per (hostId, client) pair and tears down when that pair - // changes. The provider keeps the underlying socket open across - // resubscription cycles so this is cheap. + // Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap. useEffect(() => { const cleanups: Array<() => void> = [] for (const entry of allClients) { @@ -563,12 +540,7 @@ export default function HomeScreen() { c() } } - // Why: depend on the host-id set AND each entry's client identity, so - // resubscriptions don't fire on every render that produces a new - // array reference, but DO fire when forceReconnect swaps the - // underlying client for a host (otherwise wireUp would keep firing - // on a closed client and never re-attach to the fresh one, leaving - // notifications/accounts subs broken until the user navigates). + // Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ allClients @@ -577,22 +549,10 @@ export default function HomeScreen() { .join(',') ]) - // Why: prefer the worktree the user last opened on this device so the - // "Resume" card reflects their mobile session history, not just the - // desktop's most-recently-outputting worktree. - // Why: rendering used to be gated on hostStates === 'connected', which - // caused the Resume card to vanish for ~1s on every cold-start / - // resume-from-background while the WebSocket reconnected, even though we - // had perfectly good cached worktree data. Now the card stays visible as - // long as we have a cached lastActiveWorktree for any known host; the - // tap target is still the same and a fresher snapshot from the live RPC - // overwrites the card's contents in place when it lands. + // Why: prefer the worktree last opened on this device so Resume reflects mobile session history. + // Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC lands. const resumeWorktree = useMemo(() => { - // Why: only surface Resume for hosts that are currently connected. - // Showing a stale cached worktree for a disconnected host is - // misleading — the user would tap into a session route that can't - // load anything until the host reconnects. Once the host reconnects, - // the card reappears with fresh data. + // Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load. if (lastVisited && hostStates[lastVisited.hostId] === 'connected') { const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId) @@ -612,9 +572,7 @@ export default function HomeScreen() { return null }, [sortedHosts, hostStates, worktreeInfo, lastVisited]) - // Why: only show the Account usage section for hosts that are currently - // connected. Showing stale cached usage for a disconnected host implies - // live data; better to hide until the host reconnects and we can refresh. + // Why: only show Account usage for connected hosts; stale cached usage would imply live data. const accountsHosts = useMemo(() => { const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = [] for (const host of sortedHosts) { @@ -625,9 +583,7 @@ export default function HomeScreen() { if (!snap) { continue } - // Why: also show hosts whose only usage is the system-default login - // (no Orca-managed accounts but live rate-limit data for the active - // target), otherwise system-default users see no usage section at all. + // Why: also show hosts whose only usage is the system-default login, else those users see no usage section. if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) { items.push({ host, snapshot: snap }) } @@ -716,8 +672,7 @@ export default function HomeScreen() { setConfirmRemove(null) setHosts(await loadHosts()) } catch { - // Why: ConfirmModal closes on confirm; re-open for retry and surface the - // failure instead of silently leaving the host listed. + // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent. setConfirmRemove(hostToRemove) Alert.alert('Could not remove host', 'Please try again.') } @@ -782,9 +737,7 @@ export default function HomeScreen() { h.id} - // Why: edge-to-edge — let the list scroll under the system nav bar - // but reserve insets.bottom so the last row stays reachable above - // the Samsung 3-button nav / iOS home indicator. + // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator. contentContainerStyle={[ styles.list, { paddingBottom: spacing.xl + insets.bottom }, @@ -961,10 +914,7 @@ export default function HomeScreen() { ? snapshot.claude.accounts : snapshot.codex.accounts const limits = getActiveProviderRateLimits(snapshot, provider) - // Why: with no managed accounts, still render a - // "System default" row when the active target has - // live usage data; the row label already falls back - // to "System default" below. + // Why: with no managed accounts, still render the row when the active target has live usage data. if (accounts.length === 0 && !hasActiveProviderUsage(limits)) { return null } @@ -1027,11 +977,7 @@ export default function HomeScreen() { state === 'connecting' || state === 'handshaking' || state === 'reconnecting' - // Why: "Reconnect" implies "you were connected, try again". If - // the client has never reached 'connected' this session (cold - // start, unreachable host, or after Disconnect) the action is - // functionally a fresh Connect — using the right verb makes - // the affordance match what tapping it actually does. + // Why: label "Connect" (not "Reconnect") when never connected this session, so the verb matches the action. const hasEverConnected = (hostLastConnected[host.id] ?? null) != null const items: ActionSheetAction[] = [] items.push({ diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx new file mode 100644 index 00000000000..50957a465fc --- /dev/null +++ b/mobile/app/mobile-onboarding.tsx @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AccessibilityInfo, + Animated, + BackHandler, + Text, + useWindowDimensions, + View +} from 'react-native' +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' +import { OrcaLogo } from '../src/components/OrcaLogo' +import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications' +import { + MobileOnboardingPage, + type MobileOnboardingBusyChoice, + type NotificationOnboardingChoice +} from '../src/onboarding/MobileOnboardingPage' +import { parseMobileOnboardingSteps } from '../src/onboarding/mobile-onboarding-plan' +import { mobileOnboardingStyles as styles } from '../src/onboarding/mobile-onboarding-styles' +import { + saveDefaultSessionView, + type MobileSessionView +} from '../src/storage/session-view-preferences' +import { savePushNotificationsEnabled } from '../src/storage/preferences' + +const SLIDE_DURATION_MS = 280 + +export default function MobileOnboardingScreen() { + const params = useLocalSearchParams<{ + hostId?: string | string[] + steps?: string | string[] + }>() + const hostId = firstParam(params.hostId) + const rawSteps = firstParam(params.steps) + + return ( + + ) +} + +function MobileOnboardingFlow({ + hostId, + rawSteps +}: { + hostId: string | undefined + rawSteps: string | undefined +}) { + const router = useRouter() + const steps = useMemo(() => parseMobileOnboardingSteps(rawSteps), [rawSteps]) + const { width } = useWindowDimensions() + const [activeIndex, setActiveIndex] = useState(0) + const [busyChoice, setBusyChoice] = useState(null) + const [error, setError] = useState(null) + const choiceInFlightRef = useRef(false) + const slideProgress = useRef(new Animated.Value(0)).current + const reducedMotionEnabled = useReducedMotionEnabled() + + // Why: onboarding requires an explicit choice for every planned step; disabling + // stack gestures alone still leaves Android hardware Back able to skip the flow. + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => true) + return () => subscription.remove() + }, []) + ) + + const continueToApp = useCallback(() => { + router.replace(hostId ? `/h/${hostId}` : '/') + }, [hostId, router]) + + const advanceOrContinue = useCallback(() => { + const nextIndex = activeIndex + 1 + if (nextIndex >= steps.length) { + continueToApp() + return + } + setActiveIndex(nextIndex) + setError(null) + Animated.timing(slideProgress, { + toValue: nextIndex, + // Why: the carousel should preserve continuity without overriding the + // device's reduced-motion preference. + duration: reducedMotionEnabled ? 0 : SLIDE_DURATION_MS, + useNativeDriver: true + }).start(() => { + // Why: a cancelled cosmetic transition must not leave the next decision + // permanently disabled after the previous choice was already persisted. + setBusyChoice(null) + choiceInFlightRef.current = false + }) + }, [activeIndex, continueToApp, reducedMotionEnabled, slideProgress, steps.length]) + + const chooseSessionView = useCallback( + async (view: MobileSessionView) => { + // Why: state does not disable both buttons synchronously; the ref prevents + // rapid taps from persisting conflicting choices or advancing twice. + if (choiceInFlightRef.current) { + return + } + choiceInFlightRef.current = true + setBusyChoice(view) + setError(null) + try { + await saveDefaultSessionView(view) + advanceOrContinue() + } catch { + setError('Your choice could not be saved. Try again.') + setBusyChoice(null) + choiceInFlightRef.current = false + } + }, + [advanceOrContinue] + ) + + const chooseNotifications = useCallback( + async (choice: NotificationOnboardingChoice) => { + if (choiceInFlightRef.current) { + return + } + choiceInFlightRef.current = true + setBusyChoice(choice) + setError(null) + try { + const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false + await savePushNotificationsEnabled(enabled) + advanceOrContinue() + } catch { + setError('Notification settings could not be updated. Try again.') + setBusyChoice(null) + choiceInFlightRef.current = false + } + }, + [advanceOrContinue] + ) + + const translateX = useMemo(() => Animated.multiply(slideProgress, -width), [slideProgress, width]) + + return ( + + + + Orca + {steps.length > 1 ? ( + + {steps.map((step, index) => ( + + ))} + + ) : null} + + + + + {steps.map((step, index) => ( + void chooseSessionView(view)} + onNotificationChoice={(choice) => void chooseNotifications(choice)} + /> + ))} + + + + ) +} + +function firstParam(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value +} + +function useReducedMotionEnabled(): boolean { + const [enabled, setEnabled] = useState(false) + + useEffect(() => { + let mounted = true + void AccessibilityInfo.isReduceMotionEnabled() + .then((nextEnabled) => { + if (mounted) { + setEnabled(nextEnabled) + } + }) + .catch(() => undefined) + const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setEnabled) + return () => { + mounted = false + subscription.remove() + } + }, []) + + return enabled +} diff --git a/mobile/app/notification-opt-in.tsx b/mobile/app/notification-opt-in.tsx deleted file mode 100644 index a4476687e09..00000000000 --- a/mobile/app/notification-opt-in.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useCallback, useState } from 'react' -import { - ActivityIndicator, - BackHandler, - Pressable, - ScrollView, - StyleSheet, - Text, - View -} from 'react-native' -import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' -import { SafeAreaView } from 'react-native-safe-area-context' -import { BellRing } from 'lucide-react-native' -import { OrcaLogo } from '../src/components/OrcaLogo' -import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications' -import { savePushNotificationsEnabled } from '../src/storage/preferences' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' - -export default function NotificationOptInScreen() { - const router = useRouter() - const params = useLocalSearchParams<{ hostId?: string | string[] }>() - const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId - const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null) - const [error, setError] = useState(null) - - // Why: this one-time screen requires an explicit Enable or Not now choice; - // disabling back gestures alone would still leave Android hardware back open. - useFocusEffect( - useCallback(() => { - const subscription = BackHandler.addEventListener('hardwareBackPress', () => true) - return () => subscription.remove() - }, []) - ) - - const continueToApp = useCallback(() => { - router.replace(hostId ? `/h/${hostId}` : '/') - }, [hostId, router]) - - const choose = useCallback( - async (choice: 'enable' | 'skip') => { - if (busyChoice) { - return - } - setBusyChoice(choice) - setError(null) - try { - const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false - await savePushNotificationsEnabled(enabled) - continueToApp() - } catch { - setError('Notification settings could not be updated. Try again.') - setBusyChoice(null) - } - }, - [busyChoice, continueToApp] - ) - - return ( - - - - - Orca - - - - - - - Notifications - Stay updated while away - - Get notified on this device when an agent needs your input or finishes a task. - - - - - {error ? ( - - {error} - - ) : null} - [ - styles.primaryButton, - pressed && styles.buttonPressed, - busyChoice !== null && styles.buttonDisabled - ]} - onPress={() => void choose('enable')} - > - {busyChoice === 'enable' ? ( - - ) : ( - Enable notifications - )} - - [ - styles.secondaryButton, - pressed && styles.buttonPressed, - busyChoice !== null && styles.buttonDisabled - ]} - onPress={() => void choose('skip')} - > - {busyChoice === 'skip' ? ( - - ) : ( - Not now - )} - - You can change this any time in Settings. - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.xl - }, - // Why: this decision screen cannot be dismissed with Back, so every action - // must remain reachable in landscape and with accessibility text scaling. - scrollContent: { - flexGrow: 1 - }, - brandRow: { - minHeight: 52, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - brandName: { - color: colors.textPrimary, - fontSize: 17, - fontWeight: '700' - }, - content: { - flexGrow: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.xl - }, - iconSurface: { - width: 64, - height: 64, - borderRadius: radii.card, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised, - marginBottom: spacing.xl - }, - eyebrow: { - color: colors.textMuted, - fontSize: 11, - fontWeight: '600', - letterSpacing: 0.55, - textTransform: 'uppercase', - marginBottom: spacing.sm - }, - title: { - maxWidth: 420, - color: colors.textPrimary, - fontSize: 26, - fontWeight: '700', - letterSpacing: -0.3, - textAlign: 'center' - }, - body: { - maxWidth: 420, - color: colors.textSecondary, - fontSize: typography.bodySize, - lineHeight: 21, - textAlign: 'center', - marginTop: spacing.md - }, - footer: { - width: '100%', - maxWidth: 420, - alignSelf: 'center', - paddingBottom: spacing.lg - }, - primaryButton: { - minHeight: 44, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button, - backgroundColor: colors.surfaceBright, - paddingVertical: spacing.sm - }, - primaryButtonText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '600' - }, - secondaryButton: { - minHeight: 44, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button, - marginTop: spacing.xs, - paddingVertical: spacing.sm - }, - secondaryButtonText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' - }, - buttonPressed: { - opacity: 0.72 - }, - buttonDisabled: { - opacity: 0.58 - }, - footerNote: { - color: colors.textMuted, - fontSize: typography.metaSize, - lineHeight: 18, - textAlign: 'center', - marginTop: spacing.sm - }, - error: { - color: colors.statusRed, - fontSize: typography.metaSize, - lineHeight: 18, - textAlign: 'center', - marginBottom: spacing.sm - } -}) diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index d4505b82411..0621caa4f01 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -12,7 +12,10 @@ import type { ConnectionLogEntry } from '../src/transport/types' import { useCloseHost } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' -import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate' +import { + loadMobileOnboardingSteps, + mobileOnboardingDestination +} from '../src/onboarding/mobile-onboarding-plan' type Status = 'awaiting-confirm' | 'connecting' | 'error' @@ -113,15 +116,11 @@ export default function PairConfirmScreen() { // profile — the removeHost() path already refreshes on re-pair, and a // brand-new host has no cached entry so this is a no-op. closeHost(hostId) - const showNotificationOptIn = await shouldPresentNotificationOptIn() + const onboardingSteps = await loadMobileOnboardingSteps() if (!mountedRef.current) { return } - router.replace( - showNotificationOptIn - ? { pathname: '/notification-opt-in', params: { hostId } } - : `/h/${hostId}` - ) + router.replace(mobileOnboardingDestination(onboardingSteps, hostId)) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 6b980ee0be6..892c1b359a1 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -22,7 +22,10 @@ import { useCloseHost } from '../src/transport/client-context' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' -import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate' +import { + loadMobileOnboardingSteps, + mobileOnboardingDestination +} from '../src/onboarding/mobile-onboarding-plan' // Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken // route surfaces as a real error with the log visible instead of a @@ -157,15 +160,11 @@ export default function PairScanScreen() { // profile — the removeHost() path already refreshes on re-pair, and a // brand-new host has no cached entry so this is a no-op. closeHost(hostId) - const showNotificationOptIn = await shouldPresentNotificationOptIn() + const onboardingSteps = await loadMobileOnboardingSteps() if (!mountedRef.current) { return } - router.replace( - showNotificationOptIn - ? { pathname: '/notification-opt-in', params: { hostId } } - : `/h/${hostId}` - ) + router.replace(mobileOnboardingDestination(onboardingSteps, hostId)) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index 22b725ad129..6e889e5ac23 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -3,6 +3,11 @@ import { DESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION } from '../../src/shared/protocol-version' +import { + applyTerminalQuickCommandMutation, + type TerminalQuickCommandMutation +} from '../../src/shared/terminal-quick-commands' +import type { TerminalQuickCommand } from '../../src/shared/types' import { handleMockFilePreviewRequest } from './mock-server-file-preview-data' import { handleMockGitRequest } from './mock-server-git-state' import { FAKE_SCROLLBACK, STREAMING_CHUNKS } from './mock-server-terminal-fixtures' @@ -15,6 +20,27 @@ const MOCK_RPC_DELAY_MS = readScenarioNumber('MOCK_RPC_DELAY_MS', 0) const FAKE_REPOS = createMockRepos(MOCK_REPO_COUNT) let fakeWorktrees = createMockWorktrees(FAKE_REPOS, MOCK_WORKTREE_COUNT) +// Mutable quick-command list so the mobile Quick Commands sheet can add/edit/ +// delete against the mock the same way it does a paired desktop. +let fakeQuickCommands: TerminalQuickCommand[] = [ + { + id: 'qc-codex-review', + label: 'codex review', + action: 'agent-prompt', + agent: 'codex', + prompt: 'please review this diff for correctness and edge cases.', + scope: { type: 'global' } + }, + { + id: 'qc-dev-server', + label: 'dev server', + action: 'terminal-command', + command: 'pnpm dev', + appendEnter: true, + scope: { type: 'global' } + } +] + const FAKE_TERMINALS = [ { handle: 'term-1', @@ -145,6 +171,19 @@ export function handleRequest( ) break + case 'settings.getTerminalQuickCommands': + respond(success(request.id, { terminalQuickCommands: fakeQuickCommands })) + break + + case 'settings.updateTerminalQuickCommands': { + const updates = (request.params ?? {}) as { mutation?: TerminalQuickCommandMutation } + if (updates.mutation) { + fakeQuickCommands = applyTerminalQuickCommandMutation(fakeQuickCommands, updates.mutation) + } + respond(success(request.id, { terminalQuickCommands: fakeQuickCommands })) + break + } + case 'ui.get': respond( success(request.id, { diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index d3866393116..7e1444d9b0c 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -17,9 +17,7 @@ type NotificationEvent = { body: string worktreeId?: string notificationId?: string - // Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used - // for reconnect catch-up (#8129). Optional because older runtimes / non- - // replay events may omit it. + // Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it. notificationSeq?: number } @@ -42,12 +40,7 @@ type ScheduledNotificationState = { const scheduledNotificationsByHostAndNotificationId = new Map() -// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId), -// so every agent-task-complete inserts a new, never-reused key. Entries are only -// removed when the desktop sends a matching dismiss — which a remote mobile user -// (not at the desktop) frequently never gets — so the map grew for the app's whole -// life. Bound it; a settled entry only retains a small identifier used for later -// programmatic dismissal, unnecessary for long-past completions. +// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth. const MAX_SCHEDULED_NOTIFICATIONS = 256 let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS @@ -55,8 +48,7 @@ function getStoredNotificationKey(hostId: string, notificationId: string): strin return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}` } -// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap. -// Map iteration is insertion order, so the first match is the oldest. +// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest. function boundScheduledNotifications(): void { while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) { let evicted = false @@ -91,16 +83,13 @@ export async function getNotificationPermissionState(): Promise= 33) } } -// Why: permissions must be requested before scheduling any local notification. -// Read the OS state every time because users can change it in Settings while -// Orca remains alive in the background. +// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded. export async function ensureNotificationPermissions(): Promise { const existing = await getNotificationPermissionState() if (existing.granted) { @@ -225,8 +214,7 @@ async function dismissLocalNotification( return } if (state.pending) { - // Why: desktop can send dismiss while iOS/Android is still scheduling the - // matching local notification. Remember it so no stale banner survives. + // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives. state.dismissAfterSchedule = true return } @@ -237,29 +225,15 @@ async function dismissLocalNotification( await Notifications.dismissNotificationAsync(state.identifier).catch(() => {}) } -// Why: each host connection gets its own notification subscription. When the -// connection drops, the unsubscribe function cleans up the streaming RPC. -// On reconnect the same subscribe stream is re-established by the RPC client; -// we use its `ready` event to trigger catch-up (#8129): fetch notifications -// dispatched while the socket was reaped, watermarked by the last seq we -// already delivered so the desktop never re-sends an already-pushed one. -// Returns an unsubscribe function. +// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent. export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void { configureNotificationChannel() let subscriptionId: string | null = null let disposed = false - // Highest seq delivered on the live stream or replay for this connection. - // Persisted per-host so a cold app start still resumes from the right cut. + // Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut. let lastDeliveredSeq = 0 - // Why: per-connection dedup guard applied ONLY to the replay path - // (fetchMissed), never the live stream. The desktop already guarantees the - // replay cannot contain an event with seq <= lastDeliveredSeq (both live and - // replay advance the same watermark), so live + replay never overlap there. - // This set is defense-in-depth: if the desktop's bounded buffer evicted an - // old entry and a reconnect re-fetches across a boundary, an id delivered in - // the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a - // long-lived session can't grow without limit. + // Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary. const seenReplay = createSeenNotificationGuard() function deliverLive( @@ -270,10 +244,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin lastDeliveredSeq = event.notificationSeq void saveLastSeenSeq(hostId, lastDeliveredSeq) } - // Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND - // replay) so a replay that re-includes an id already pushed live in this - // connection is dropped instead of double-pushed. fetchMissed also - // pre-checks seenReplay, but without this the live path never populated it. + // Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing. const key = seenKeyForEvent(event) if (key) { seenReplay.add(key) @@ -284,12 +255,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin return dismissLocalNotification(event as DismissNotificationEvent, hostId) } - // Why: on a reconnect `ready` the desktop has already dispatched whatever we - // missed; ask for it from our persisted watermark. Because the desktop cuts - // by seq > lastSeenSeq this is idempotent — we only ever get events we have - // not delivered before. The seenReplay guard is a second layer so a replay - // that somehow re-includes an id already delivered this connection is - // dropped instead of double-pushed. + // Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap). async function fetchMissed(): Promise { if (disposed) { return @@ -321,10 +287,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin } } - // Why: lazily seed the watermark from durable storage on first use so we - // don't block subscribe() on an AsyncStorage read. The first `ready` (cold - // open) does NOT need catch-up — the live stream starts fresh; only - // subsequent reconnect `ready` events fetch missed notifications. + // Why: seed the watermark lazily so subscribe() doesn't block on an AsyncStorage read. let watermarkLoaded = false void loadLastSeenSeq(hostId).then((seq) => { lastDeliveredSeq = Math.max(lastDeliveredSeq, seq) @@ -352,10 +315,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin unsubscribeStream() return } - // Why: first ready is the cold-open live stream — no catch-up needed. - // Every later ready is a reconnect; fetch what we missed from the - // watermark. Guard on watermarkLoaded so a fast reconnect doesn't - // fetch from a stale 0 watermark (which would re-push everything). + // Why: only reconnects fetch missed; watermarkLoaded guards against fetching from a stale 0 (which re-pushes everything). if (reconnectReadyCount > 1 && watermarkLoaded) { void fetchMissed() } @@ -379,12 +339,7 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin return () => { disposed = true - // Why: the client may already be closed when this cleanup runs (component - // unmount races with disconnect). sendRequest rejects immediately on a - // closed client — swallow it since server-side cleanup happens via - // connection-close anyway. - // Always drop the local stream first; readiness can race unmount and we - // must not retain the callback while waiting for a subscription id. + // Why: drop the local stream first — readiness can race unmount; don't hold the callback while a subscription id is pending. unsubscribeStream() if (subscriptionId) { unsubscribeServer(subscriptionId) diff --git a/mobile/src/onboarding/MobileOnboardingPage.test.ts b/mobile/src/onboarding/MobileOnboardingPage.test.ts new file mode 100644 index 00000000000..f3683105160 --- /dev/null +++ b/mobile/src/onboarding/MobileOnboardingPage.test.ts @@ -0,0 +1,103 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileOnboardingPage } from './MobileOnboardingPage' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('lucide-react-native', () => ({ + BellRing: 'BellRing', + MessageSquare: 'MessageSquare' +})) + +describe('MobileOnboardingPage', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + async function renderPage( + step: 'session-view' | 'notifications', + options: { active?: boolean; busyChoice?: 'chat' | 'enable' | null } = {} + ) { + const onSessionChoice = vi.fn() + const onNotificationChoice = vi.fn() + const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { + throw new Error(String(args[0])) + } + }) + await act(async () => { + renderer = create( + createElement(MobileOnboardingPage, { + step, + width: 390, + active: options.active ?? true, + busyChoice: options.busyChoice ?? null, + error: null, + onSessionChoice, + onNotificationChoice + }) + ) + }) + consoleError.mockRestore() + return { onSessionChoice, onNotificationChoice } + } + + function button(label: string) { + return renderer!.root.find( + (node) => node.type === 'Pressable' && node.props.accessibilityLabel === label + ) + } + + it('renders the session choices and sends exactly one selected view', async () => { + const callbacks = await renderPage('session-view') + + act(() => button('Open sessions in native chat').props.onPress()) + expect(callbacks.onSessionChoice).toHaveBeenCalledWith('chat') + expect(callbacks.onNotificationChoice).not.toHaveBeenCalled() + }) + + it('renders the notification choices and sends the selected option', async () => { + const callbacks = await renderPage('notifications') + act(() => button('Skip notifications for now').props.onPress()) + + expect(callbacks.onNotificationChoice).toHaveBeenCalledWith('skip') + expect(callbacks.onSessionChoice).not.toHaveBeenCalled() + }) + + it('disables both notification choices while permission is pending', async () => { + await renderPage('notifications', { busyChoice: 'enable' }) + const enable = button('Enable agent notifications') + const secondary = button('Skip notifications for now') + + expect(enable.props.disabled).toBe(true) + expect(secondary.props.disabled).toBe(true) + }) + + it('hides an off-screen page from assistive technology', async () => { + await renderPage('notifications', { active: false }) + const scrollView = renderer!.root.findByType('ScrollView') + + expect(scrollView.props.accessibilityElementsHidden).toBe(true) + expect(scrollView.props.importantForAccessibility).toBe('no-hide-descendants') + }) +}) diff --git a/mobile/src/onboarding/MobileOnboardingPage.tsx b/mobile/src/onboarding/MobileOnboardingPage.tsx new file mode 100644 index 00000000000..f7bbff0cf0d --- /dev/null +++ b/mobile/src/onboarding/MobileOnboardingPage.tsx @@ -0,0 +1,173 @@ +import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native' +import { BellRing, MessageSquare } from 'lucide-react-native' +import type { MobileOnboardingStep } from './mobile-onboarding-plan' +import { mobileOnboardingStyles as styles } from './mobile-onboarding-styles' +import type { MobileSessionView } from '../storage/session-view-preferences' +import { colors } from '../theme/mobile-theme' + +export type NotificationOnboardingChoice = 'enable' | 'skip' +export type MobileOnboardingBusyChoice = MobileSessionView | NotificationOnboardingChoice | null + +type Props = { + step: MobileOnboardingStep + width: number + active: boolean + busyChoice: MobileOnboardingBusyChoice + error: string | null + onSessionChoice: (view: MobileSessionView) => void + onNotificationChoice: (choice: NotificationOnboardingChoice) => void +} + +export function MobileOnboardingPage({ + step, + width, + active, + busyChoice, + error, + onSessionChoice, + onNotificationChoice +}: Props) { + const busy = busyChoice !== null + const isSessionView = step === 'session-view' + + return ( + + + + {isSessionView ? ( + + ) : ( + + )} + + + {isSessionView ? 'How should sessions open?' : 'Stay updated while away'} + + + {isSessionView + ? 'Choose whether supported agent sessions open in the terminal or native chat on this device. Press and hold a session tab to switch its view, or change the default later in Settings.' + : 'Get notified on this device when an agent needs your input or finishes a task.'} + + + + + {error ? ( + + {error} + + ) : null} + {isSessionView ? ( + + ) : ( + + )} + + + ) +} + +function SessionViewChoices({ + busyChoice, + disabled, + onChoice +}: { + busyChoice: MobileOnboardingBusyChoice + disabled: boolean + onChoice: (view: MobileSessionView) => void +}) { + return ( + <> + onChoice('chat')} + /> + onChoice('terminal')} + /> + + ) +} + +function NotificationChoices({ + busyChoice, + disabled, + onChoice +}: { + busyChoice: MobileOnboardingBusyChoice + disabled: boolean + onChoice: (choice: NotificationOnboardingChoice) => void +}) { + return ( + <> + onChoice('enable')} + /> + onChoice('skip')} + /> + + ) +} + +function ChoiceButton({ + label, + accessibilityLabel, + primary = false, + busy, + disabled, + onPress +}: { + label: string + accessibilityLabel?: string + primary?: boolean + busy: boolean + disabled: boolean + onPress: () => void +}) { + return ( + [ + primary ? styles.primaryButton : styles.secondaryButton, + pressed && styles.buttonPressed, + disabled && styles.buttonDisabled + ]} + onPress={onPress} + > + {busy ? ( + + ) : ( + {label} + )} + + ) +} diff --git a/mobile/src/onboarding/mobile-onboarding-plan.test.ts b/mobile/src/onboarding/mobile-onboarding-plan.test.ts new file mode 100644 index 00000000000..d04396bdef7 --- /dev/null +++ b/mobile/src/onboarding/mobile-onboarding-plan.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate' +import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate' +import { + loadMobileOnboardingSteps, + mobileOnboardingDestination, + parseMobileOnboardingSteps +} from './mobile-onboarding-plan' + +vi.mock('../notifications/notification-opt-in-gate', () => ({ + shouldPresentNotificationOptIn: vi.fn() +})) +vi.mock('../session/session-view-opt-in-gate', () => ({ + shouldPresentSessionViewOptIn: vi.fn() +})) + +describe('mobile onboarding plan', () => { + beforeEach(() => { + vi.mocked(shouldPresentNotificationOptIn).mockReset().mockResolvedValue(false) + vi.mocked(shouldPresentSessionViewOptIn).mockReset().mockResolvedValue(false) + }) + + it.each([ + [true, true, ['session-view', 'notifications']], + [true, false, ['session-view']], + [false, true, ['notifications']], + [false, false, []] + ] as const)( + 'builds the exact plan for session=%s notifications=%s', + async (showSession, showNotifications, expected) => { + vi.mocked(shouldPresentSessionViewOptIn).mockResolvedValue(showSession) + vi.mocked(shouldPresentNotificationOptIn).mockResolvedValue(showNotifications) + + await expect(loadMobileOnboardingSteps()).resolves.toEqual(expected) + expect(shouldPresentSessionViewOptIn).toHaveBeenCalledOnce() + expect(shouldPresentNotificationOptIn).toHaveBeenCalledOnce() + } + ) + + it.each([ + [[], undefined, '/'], + [[], 'paired-host', '/h/paired-host'], + [ + ['session-view', 'notifications'], + undefined, + { + pathname: '/mobile-onboarding', + params: { steps: 'session-view,notifications' } + } + ], + [ + ['notifications'], + 'paired-host', + { + pathname: '/mobile-onboarding', + params: { steps: 'notifications', hostId: 'paired-host' } + } + ] + ] as const)('maps %j with host %s to the correct destination', (steps, hostId, destination) => { + expect(mobileOnboardingDestination(steps, hostId)).toEqual(destination) + }) + + it('parses route steps in canonical order without duplicates', () => { + expect(parseMobileOnboardingSteps('notifications,unknown,session-view,notifications')).toEqual([ + 'session-view', + 'notifications' + ]) + expect(parseMobileOnboardingSteps('notifications')).toEqual(['notifications']) + }) + + it('defaults missing or invalid route state to the complete wizard', () => { + expect(parseMobileOnboardingSteps(undefined)).toEqual(['session-view', 'notifications']) + expect(parseMobileOnboardingSteps('unknown')).toEqual(['session-view', 'notifications']) + }) +}) diff --git a/mobile/src/onboarding/mobile-onboarding-plan.ts b/mobile/src/onboarding/mobile-onboarding-plan.ts new file mode 100644 index 00000000000..3435034c467 --- /dev/null +++ b/mobile/src/onboarding/mobile-onboarding-plan.ts @@ -0,0 +1,51 @@ +import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate' +import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate' + +export const MOBILE_ONBOARDING_STEPS = ['session-view', 'notifications'] as const +export type MobileOnboardingStep = (typeof MOBILE_ONBOARDING_STEPS)[number] +export type MobileOnboardingDestination = + | '/' + | `/h/${string}` + | { + pathname: '/mobile-onboarding' + params: { steps: string; hostId?: string } + } + +/** Loads every outstanding decision in the order the wizard presents them. */ +export async function loadMobileOnboardingSteps(): Promise { + // Why: the wizard needs the complete plan for accurate progress dots; run the + // independent gates together so adding the second decision does not add latency. + const [showSessionView, showNotifications] = await Promise.all([ + shouldPresentSessionViewOptIn(), + shouldPresentNotificationOptIn() + ]) + return MOBILE_ONBOARDING_STEPS.filter( + (step) => + (step === 'session-view' && showSessionView) || + (step === 'notifications' && showNotifications) + ) +} + +/** Preserves a paired host while routing through outstanding decisions. */ +export function mobileOnboardingDestination( + steps: readonly MobileOnboardingStep[], + hostId?: string +): MobileOnboardingDestination { + if (steps.length === 0) { + return hostId ? `/h/${hostId}` : '/' + } + return { + pathname: '/mobile-onboarding', + params: { steps: steps.join(','), ...(hostId ? { hostId } : {}) } + } +} + +/** Restores the canonical order and ignores duplicate or unknown route values. */ +export function parseMobileOnboardingSteps(raw: string | undefined): MobileOnboardingStep[] { + if (!raw) { + return [...MOBILE_ONBOARDING_STEPS] + } + const requested = new Set(raw.split(',')) + const steps = MOBILE_ONBOARDING_STEPS.filter((step) => requested.has(step)) + return steps.length > 0 ? steps : [...MOBILE_ONBOARDING_STEPS] +} diff --git a/mobile/src/onboarding/mobile-onboarding-screen.test.ts b/mobile/src/onboarding/mobile-onboarding-screen.test.ts new file mode 100644 index 00000000000..d571ce28a01 --- /dev/null +++ b/mobile/src/onboarding/mobile-onboarding-screen.test.ts @@ -0,0 +1,165 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import MobileOnboardingScreen from '../../app/mobile-onboarding' + +const mocks = vi.hoisted(() => ({ + params: { hostId: 'paired-host', steps: 'session-view,notifications' }, + replace: vi.fn(), + reducedMotionEnabled: false, + animatedTiming: vi.fn(), + ensureNotificationPermissions: vi.fn(), + saveDefaultSessionView: vi.fn(), + savePushNotificationsEnabled: vi.fn() +})) + +vi.mock('react-native', () => ({ + AccessibilityInfo: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + isReduceMotionEnabled: vi.fn(() => Promise.resolve(mocks.reducedMotionEnabled)) + }, + Animated: { + Value: class {}, + View: 'AnimatedView', + multiply: vi.fn(() => 0), + timing: mocks.animatedTiming + }, + BackHandler: { + addEventListener: vi.fn(() => ({ remove: vi.fn() })) + }, + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View', + useWindowDimensions: () => ({ width: 390, height: 844 }) +})) + +vi.mock('expo-router', () => ({ + useFocusEffect: vi.fn(), + useLocalSearchParams: () => mocks.params, + useRouter: () => ({ replace: mocks.replace }) +})) + +vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' })) +vi.mock('../components/OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' })) +vi.mock('./MobileOnboardingPage', () => ({ MobileOnboardingPage: 'MobileOnboardingPage' })) +vi.mock('../notifications/mobile-notifications', () => ({ + ensureNotificationPermissions: mocks.ensureNotificationPermissions +})) +vi.mock('../storage/session-view-preferences', () => ({ + saveDefaultSessionView: mocks.saveDefaultSessionView +})) +vi.mock('../storage/preferences', () => ({ + savePushNotificationsEnabled: mocks.savePushNotificationsEnabled +})) + +describe('MobileOnboardingScreen', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' } + mocks.replace.mockReset() + mocks.reducedMotionEnabled = false + mocks.animatedTiming.mockReset().mockReturnValue({ + start: (callback: (result: { finished: boolean }) => void) => callback({ finished: true }) + }) + mocks.ensureNotificationPermissions.mockReset().mockResolvedValue(true) + mocks.saveDefaultSessionView.mockReset().mockResolvedValue(undefined) + mocks.savePushNotificationsEnabled.mockReset().mockResolvedValue(undefined) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + async function renderScreen() { + const consoleError = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { + throw new Error(String(args[0])) + } + }) + await act(async () => { + renderer = create(createElement(MobileOnboardingScreen)) + }) + consoleError.mockRestore() + } + + function pages() { + return renderer!.root.findAllByType('MobileOnboardingPage') + } + + it('advances from session view to notifications before opening the paired host', async () => { + await renderScreen() + expect(pages().map((page) => page.props.active)).toEqual([true, false]) + + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(mocks.saveDefaultSessionView).toHaveBeenCalledWith('chat') + expect(pages().map((page) => page.props.active)).toEqual([false, true]) + expect(mocks.replace).not.toHaveBeenCalled() + + await act(async () => pages()[1].props.onNotificationChoice('skip')) + expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled() + expect(mocks.savePushNotificationsEnabled).toHaveBeenCalledWith(false) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + }) + + it('finishes immediately when the plan contains only one outstanding step', async () => { + mocks.params = { hostId: 'paired-host', steps: 'session-view' } + await renderScreen() + + await act(async () => pages()[0].props.onSessionChoice('terminal')) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + }) + + it('keeps the current step retryable when persistence fails', async () => { + mocks.params = { hostId: 'paired-host', steps: 'session-view' } + mocks.saveDefaultSessionView + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockResolvedValueOnce(undefined) + await renderScreen() + + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(pages()[0].props.error).toBe('Your choice could not be saved. Try again.') + + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(mocks.saveDefaultSessionView).toHaveBeenCalledTimes(2) + expect(mocks.replace).toHaveBeenCalledWith('/h/paired-host') + }) + + it('resets carousel state when the route supplies a new onboarding plan', async () => { + await renderScreen() + await act(async () => pages()[0].props.onSessionChoice('chat')) + expect(pages().map((page) => page.props.active)).toEqual([false, true]) + + mocks.params = { hostId: 'paired-host', steps: 'notifications' } + await act(async () => renderer!.update(createElement(MobileOnboardingScreen))) + + expect(pages()).toHaveLength(1) + expect(pages()[0].props).toMatchObject({ step: 'notifications', active: true }) + }) + + it('skips the slide animation when the device requests reduced motion', async () => { + mocks.reducedMotionEnabled = true + await renderScreen() + + await act(async () => pages()[0].props.onSessionChoice('chat')) + + expect(mocks.animatedTiming).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 0, useNativeDriver: true }) + ) + }) + + it('keeps the next decision available if the cosmetic transition is interrupted', async () => { + mocks.animatedTiming.mockReturnValue({ + start: (callback: (result: { finished: boolean }) => void) => callback({ finished: false }) + }) + await renderScreen() + + await act(async () => pages()[0].props.onSessionChoice('chat')) + + expect(pages()[1].props).toMatchObject({ active: true, busyChoice: null }) + }) +}) diff --git a/mobile/src/onboarding/mobile-onboarding-styles.ts b/mobile/src/onboarding/mobile-onboarding-styles.ts new file mode 100644 index 00000000000..20f36ec0f5b --- /dev/null +++ b/mobile/src/onboarding/mobile-onboarding-styles.ts @@ -0,0 +1,132 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const mobileOnboardingStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + brandRow: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.xl + }, + brandName: { + color: colors.textPrimary, + fontSize: 17, + fontWeight: '700' + }, + progress: { + position: 'absolute', + left: '50%', + flexDirection: 'row', + alignItems: 'center', + gap: 7, + transform: [{ translateX: -18 }] + }, + progressDot: { + width: 7, + height: 7, + borderRadius: 999, + backgroundColor: colors.borderSubtle + }, + progressDotActive: { + width: 22, + backgroundColor: colors.textPrimary + }, + carouselViewport: { + flex: 1, + overflow: 'hidden' + }, + carouselTrack: { + height: '100%', + flexDirection: 'row' + }, + page: { + height: '100%' + }, + // Why: every decision remains reachable in landscape and with accessibility + // text scaling even though Back and swipe-to-skip are intentionally disabled. + pageContent: { + flexGrow: 1, + paddingHorizontal: spacing.xl + }, + content: { + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.xl + }, + iconSurface: { + width: 64, + height: 64, + borderRadius: radii.card, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised, + marginBottom: spacing.xl + }, + title: { + maxWidth: 420, + color: colors.textPrimary, + fontSize: 26, + fontWeight: '700', + letterSpacing: -0.3, + textAlign: 'center' + }, + body: { + maxWidth: 420, + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 21, + textAlign: 'center', + marginTop: spacing.md + }, + footer: { + width: '100%', + maxWidth: 420, + alignSelf: 'center', + paddingBottom: spacing.lg + }, + primaryButton: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + backgroundColor: colors.surfaceBright, + paddingVertical: spacing.sm + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + secondaryButton: { + minHeight: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + marginTop: spacing.xs, + paddingVertical: spacing.sm + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + buttonPressed: { + opacity: 0.72 + }, + buttonDisabled: { + opacity: 0.58 + }, + error: { + color: colors.statusRed, + fontSize: typography.metaSize, + lineHeight: 18, + textAlign: 'center', + marginBottom: spacing.sm + } +}) diff --git a/mobile/src/session/QuickCommandEditorForm.tsx b/mobile/src/session/QuickCommandEditorForm.tsx new file mode 100644 index 00000000000..171bf8ef2ce --- /dev/null +++ b/mobile/src/session/QuickCommandEditorForm.tsx @@ -0,0 +1,317 @@ +import { useState } from 'react' +import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native' +import { ChevronDown, ChevronRight } from 'lucide-react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { MobileAgentIcon } from '../components/MobileAgentIcon' +import { + getQuickCommandAgentLabel, + MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, + MAX_QUICK_COMMAND_LABEL_LENGTH, + MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH +} from '../terminal/quick-commands' +import type { QuickCommandDraft } from './quick-command-draft' +import { isQuickCommandDraftValid } from './quick-command-draft' + +type Props = { + draft: QuickCommandDraft + mode: 'add' | 'edit' + saving: boolean + error: string | null + // A mobile session lives in one repo, so "Project" scope means the current + // worktree's repo — no cross-repo picker like desktop. + repoId: string | null + repoName: string | null + onChange: (patch: Partial) => void + onOpenAgentPicker: () => void + onCancel: () => void + onSave: () => void +} + +function ActionToggle({ + value, + onChange +}: { + value: QuickCommandDraft['action'] + onChange: (action: QuickCommandDraft['action']) => void +}) { + return ( + + {(['terminal-command', 'agent-prompt'] as const).map((action) => { + const selected = value === action + return ( + [ + styles.toggleItem, + selected && styles.toggleItemSelected, + pressed && !selected && styles.pressed + ]} + onPress={() => onChange(action)} + accessibilityRole="button" + accessibilityState={{ selected }} + > + + {action === 'terminal-command' ? 'Terminal Command' : 'Agent Prompt'} + + + ) + })} + + ) +} + +export function QuickCommandEditorForm({ + draft, + mode, + saving, + error, + repoId, + repoName, + onChange, + onOpenAgentPicker, + onCancel, + onSave +}: Props) { + const hasRepoScope = repoId !== null + const [advancedOpen, setAdvancedOpen] = useState(draft.scope.type === 'repo') + const isAgent = draft.action === 'agent-prompt' + const canSave = isQuickCommandDraftValid(draft) && !saving + + return ( + + + Label + onChange({ label })} + placeholder="Start dev server" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + maxLength={MAX_QUICK_COMMAND_LABEL_LENGTH} + /> + + + + Action + onChange({ action })} /> + + + {isAgent ? ( + + Agent + [styles.select, pressed && styles.pressed]} + onPress={onOpenAgentPicker} + accessibilityRole="button" + > + {draft.agent ? ( + + + {getQuickCommandAgentLabel(draft.agent)} + + ) : ( + Choose agent + )} + + + + ) : null} + + + {isAgent ? 'Prompt' : 'Command Text'} + onChange(isAgent ? { prompt: text } : { command: text })} + placeholder={isAgent ? 'Ask the agent to investigate this workspace' : 'npm run dev'} + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + multiline + maxLength={ + isAgent ? MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH : MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH + } + /> + {isAgent ? ( + Supports skills, file paths, and built-in commands. + ) : null} + + + + [styles.advancedToggle, pressed && styles.pressed]} + onPress={() => setAdvancedOpen((open) => !open)} + accessibilityRole="button" + accessibilityState={{ expanded: advancedOpen }} + > + {advancedOpen ? ( + + ) : ( + + )} + Advanced + + + {advancedOpen ? ( + + {!isAgent ? ( + + + Append Enter + + Submit immediately instead of only inserting text. + + + onChange({ appendEnter })} + trackColor={{ false: colors.bgRaised, true: colors.accentBlue }} + thumbColor={colors.surfaceBright} + /> + + ) : null} + + + Scope + + {(['global', 'repo'] as const).map((scopeType) => { + const selected = draft.scope.type === scopeType + const disabled = scopeType === 'repo' && !hasRepoScope + return ( + [ + styles.toggleItem, + selected && styles.toggleItemSelected, + disabled && styles.toggleItemDisabled, + pressed && !selected && !disabled && styles.pressed + ]} + onPress={() => + onChange({ + scope: + scopeType === 'repo' && repoId + ? { type: 'repo', repoId } + : { type: 'global' } + }) + } + accessibilityRole="button" + accessibilityState={{ selected, disabled }} + > + + {scopeType === 'global' ? 'Global' : 'Project'} + + + ) + })} + + {draft.scope.type === 'repo' && repoName ? ( + {repoName} + ) : null} + + + ) : null} + + + {error ? {error} : null} + + + [styles.button, styles.cancelButton, pressed && styles.pressed]} + onPress={onCancel} + accessibilityRole="button" + > + Cancel + + + + {mode === 'edit' ? 'Save' : 'Add Quick Command'} + + + + + ) +} + +const styles = StyleSheet.create({ + form: { gap: spacing.md, paddingTop: spacing.xs, paddingBottom: spacing.sm }, + field: { gap: spacing.sm }, + label: { fontSize: 12, fontWeight: '600', color: colors.textSecondary }, + input: { + backgroundColor: colors.bgPanel, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + fontSize: 14, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + textarea: { minHeight: 92, textAlignVertical: 'top' }, + mono: { fontFamily: typography.monoFamily }, + hint: { fontSize: 12, color: colors.textMuted }, + error: { fontSize: 13, color: colors.statusRed, marginTop: spacing.xs }, + pressed: { backgroundColor: colors.bgRaised }, + toggleGroup: { flexDirection: 'row', gap: spacing.sm }, + toggleItem: { + flex: 1, + height: 40, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel, + alignItems: 'center', + justifyContent: 'center' + }, + toggleItemSelected: { backgroundColor: colors.bgRaised, borderColor: colors.textMuted }, + toggleItemDisabled: { opacity: 0.4 }, + toggleText: { fontSize: 13, fontWeight: '500', color: colors.textSecondary }, + toggleTextSelected: { color: colors.textPrimary }, + select: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2 + }, + selectValue: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + selectValueText: { fontSize: 14, color: colors.textPrimary }, + selectPlaceholder: { fontSize: 14, color: colors.textMuted }, + scopeRepoName: { + fontSize: 13, + color: colors.textSecondary, + fontFamily: typography.monoFamily, + paddingHorizontal: spacing.xs + }, + advancedToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.xs + }, + advancedText: { fontSize: 13, fontWeight: '600', color: colors.textSecondary }, + advancedBody: { gap: spacing.md, paddingTop: spacing.xs }, + switchRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, + switchText: { flex: 1 }, + switchTitle: { fontSize: 14, color: colors.textPrimary }, + switchDesc: { fontSize: 12, color: colors.textMuted, marginTop: 1 }, + footer: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }, + button: { flex: 1, borderRadius: 8, paddingVertical: spacing.md, alignItems: 'center' }, + cancelButton: { borderWidth: 1, borderColor: colors.borderSubtle }, + cancelText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, + saveButton: { backgroundColor: colors.textPrimary }, + saveButtonDisabled: { backgroundColor: colors.bgRaised }, + saveText: { fontSize: 14, fontWeight: '700', color: colors.bgBase }, + saveTextDisabled: { color: colors.textMuted } +}) diff --git a/mobile/src/session/QuickCommandsList.test.ts b/mobile/src/session/QuickCommandsList.test.ts new file mode 100644 index 00000000000..eb2e2c787c0 --- /dev/null +++ b/mobile/src/session/QuickCommandsList.test.ts @@ -0,0 +1,90 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH, QuickCommandsList } from './QuickCommandsList' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + StyleSheet: { + create: (styles: T) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Check: 'Check', + Pencil: 'Pencil', + Play: 'Play', + Plus: 'Plus', + Search: 'Search', + Trash2: 'Trash2' +})) + +vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' })) + +describe('QuickCommandsList search', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('keeps an active filter clearable when only one command remains', async () => { + await act(async () => { + renderer = create( + createElement(QuickCommandsList, { + repoCommands: [], + globalCommands: [], + totalCount: 1, + query: 'missing', + loading: false, + disabled: false, + canAdd: true, + error: null, + onQueryChange: vi.fn(), + onLaunch: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + onAdd: vi.fn() + }) + ) + }) + + const search = renderer!.root.findByType('TextInput') + expect(search.props.value).toBe('missing') + expect(search.props.maxLength).toBe(QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH) + }) + + it('omits idle search chrome for a single command', async () => { + await act(async () => { + renderer = create( + createElement(QuickCommandsList, { + repoCommands: [], + globalCommands: [], + totalCount: 1, + query: '', + loading: false, + disabled: false, + canAdd: true, + error: null, + onQueryChange: vi.fn(), + onLaunch: vi.fn(), + onEdit: vi.fn(), + onDelete: vi.fn(), + onAdd: vi.fn() + }) + ) + }) + + expect(renderer!.root.findAllByType('TextInput')).toHaveLength(0) + }) +}) diff --git a/mobile/src/session/QuickCommandsList.tsx b/mobile/src/session/QuickCommandsList.tsx new file mode 100644 index 00000000000..2c76884d335 --- /dev/null +++ b/mobile/src/session/QuickCommandsList.tsx @@ -0,0 +1,332 @@ +import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native' +import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { MobileAgentIcon } from '../components/MobileAgentIcon' +import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog' +import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' +import { + getQuickCommandDisplayPreview, + isAgentQuickCommand, + supportsTerminalAgentQuickCommand +} from '../terminal/quick-commands' + +export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) => + supportsTerminalAgentQuickCommand(entry.id) +) + +export const QUICK_COMMAND_SEARCH_QUERY_MAX_LENGTH = 2048 + +type ListProps = { + repoCommands: TerminalQuickCommand[] + globalCommands: TerminalQuickCommand[] + totalCount: number + query: string + loading: boolean + disabled: boolean + canAdd: boolean + error: string | null + onQueryChange: (value: string) => void + onLaunch: (command: TerminalQuickCommand) => void + onEdit: (command: TerminalQuickCommand) => void + onDelete: (command: TerminalQuickCommand) => void + onAdd: () => void +} + +export function QuickCommandsList({ + repoCommands, + globalCommands, + totalCount, + query, + loading, + disabled, + canAdd, + error, + onQueryChange, + onLaunch, + onEdit, + onDelete, + onAdd +}: ListProps) { + const hasVisible = repoCommands.length + globalCommands.length > 0 + const addDisabled = disabled || !canAdd + // Why: keep an active filter clearable if a delete or paired desktop edit + // leaves only one command while the sheet is open. + const showSearch = totalCount > 1 || query.length > 0 + return ( + + {showSearch ? ( + + + + + ) : null} + + {error ? {error} : null} + + {loading && !hasVisible ? ( + + ) : null} + + {!loading && totalCount === 0 ? ( + No quick commands yet. + ) : null} + + {!loading && totalCount > 0 && !hasVisible ? ( + No matching quick commands. + ) : null} + + {repoCommands.length > 0 ? ( + + ) : null} + + {globalCommands.length > 0 ? ( + + ) : null} + + [ + styles.addRow, + addDisabled && styles.disabled, + pressed && !addDisabled && styles.pressed + ]} + disabled={addDisabled} + onPress={onAdd} + accessibilityRole="button" + > + + + {canAdd ? 'New quick command' : 'Quick command limit reached'} + + + + ) +} + +function QuickCommandGroup({ + label, + commands, + onLaunch, + onEdit, + onDelete, + disabled +}: { + label: string + commands: TerminalQuickCommand[] + onLaunch: (command: TerminalQuickCommand) => void + onEdit: (command: TerminalQuickCommand) => void + onDelete: (command: TerminalQuickCommand) => void + disabled: boolean +}) { + return ( + + {label} + + {commands.map((command, index) => ( + + ))} + + + ) +} + +function QuickCommandRow({ + command, + first, + onLaunch, + onEdit, + onDelete, + disabled +}: { + command: TerminalQuickCommand + first: boolean + onLaunch: (command: TerminalQuickCommand) => void + onEdit: (command: TerminalQuickCommand) => void + onDelete: (command: TerminalQuickCommand) => void + disabled: boolean +}) { + const isAgent = isAgentQuickCommand(command) + return ( + + [styles.rowMain, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onLaunch(command)} + accessibilityRole="button" + accessibilityLabel={`Run ${command.label}`} + > + + {isAgent ? ( + + ) : ( + + )} + + + + {command.label} + + + {getQuickCommandDisplayPreview(command)} + + + + [styles.rowAction, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onEdit(command)} + accessibilityLabel={`Edit ${command.label}`} + > + + + [styles.rowAction, pressed && !disabled && styles.pressed]} + disabled={disabled} + onPress={() => onDelete(command)} + accessibilityLabel={`Delete ${command.label}`} + > + + + + ) +} + +export function QuickCommandAgentPicker({ + selected, + onSelect +}: { + selected: TuiAgent | null + onSelect: (agent: TuiAgent) => void +}) { + return ( + + {QUICK_COMMAND_SUPPORTED_AGENTS.map((agent, index) => ( + [ + styles.row, + index > 0 && styles.rowBorder, + pressed && styles.pressed + ]} + onPress={() => onSelect(agent.id)} + accessibilityRole="button" + accessibilityState={{ selected: selected === agent.id }} + > + + + + {agent.label} + {selected === agent.id ? : null} + + ))} + + ) +} + +const styles = StyleSheet.create({ + pressed: { backgroundColor: colors.bgRaised }, + disabled: { opacity: 0.45 }, + listBody: { gap: spacing.sm, paddingBottom: spacing.sm }, + search: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + }, + searchInput: { flex: 1, color: colors.textPrimary, fontSize: 14, padding: 0 }, + error: { color: colors.statusRed, fontSize: 13, paddingHorizontal: spacing.xs }, + loading: { paddingVertical: spacing.lg }, + empty: { + color: colors.textMuted, + fontSize: 14, + textAlign: 'center', + paddingVertical: spacing.lg + }, + groupLabel: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.6, + paddingHorizontal: spacing.xs, + paddingTop: spacing.xs, + paddingBottom: spacing.xs + }, + group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' }, + row: { flexDirection: 'row', alignItems: 'center' }, + rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle }, + rowMain: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.md, + paddingLeft: spacing.md, + minWidth: 0 + }, + rowIcon: { + width: 26, + height: 26, + borderRadius: 6, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + rowText: { flex: 1, minWidth: 0 }, + rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, + rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 }, + mono: { fontFamily: typography.monoFamily }, + rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }, + agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary }, + addRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgPanel, + borderRadius: 12, + borderWidth: 1, + borderStyle: 'dashed', + borderColor: colors.borderSubtle, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md, + marginTop: spacing.xs + }, + addText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary } +}) diff --git a/mobile/src/session/QuickCommandsSheet.test.ts b/mobile/src/session/QuickCommandsSheet.test.ts new file mode 100644 index 00000000000..654882cd2ad --- /dev/null +++ b/mobile/src/session/QuickCommandsSheet.test.ts @@ -0,0 +1,225 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import type { TerminalQuickCommand } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands' +import { QuickCommandsSheet } from './QuickCommandsSheet' + +const mocks = vi.hoisted(() => ({ + alert: vi.fn(), + commands: [] as TerminalQuickCommand[], + persist: vi.fn() +})) +const quickCommandEditorForm = 'QuickCommandEditorForm' +const quickCommandsList = 'QuickCommandsList' +const command: TerminalQuickCommand = { + id: 'command', + label: 'Test', + action: 'terminal-command', + command: 'pnpm test', + appendEnter: true, + scope: { type: 'global' } +} + +vi.mock('react-native', () => ({ + Alert: { alert: mocks.alert }, + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ ChevronLeft: 'ChevronLeft' })) + +vi.mock('../components/BottomDrawer', () => ({ + BottomDrawer: ({ children }: { children: ReactNode }) => children +})) + +vi.mock('./QuickCommandEditorForm', () => ({ + QuickCommandEditorForm: 'QuickCommandEditorForm' +})) + +vi.mock('./QuickCommandsList', () => ({ + QuickCommandAgentPicker: 'QuickCommandAgentPicker', + QuickCommandsList: 'QuickCommandsList' +})) + +vi.mock('./use-quick-commands', () => ({ + useQuickCommands: () => ({ + commands: mocks.commands, + loading: false, + ready: true, + error: null, + persist: mocks.persist + }) +})) + +function deferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('QuickCommandsSheet', () => { + let renderer: ReactTestRenderer | null = null + let consoleSpy: MockInstance + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + mocks.alert.mockReset() + mocks.commands = [] + mocks.persist.mockReset() + const originalConsoleError = console.error + consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + consoleSpy.mockRestore() + }) + + it('keeps the sheet open when a launch is rejected', async () => { + const onClose = vi.fn() + const onLaunch = vi.fn(() => false) + await act(async () => { + renderer = create( + createElement(QuickCommandsSheet, { + visible: true, + onClose, + client: {} as RpcClient, + repoId: 'repo-1', + repoName: 'Repo', + onLaunch + }) + ) + }) + + act(() => renderer!.root.findByType(quickCommandsList).props.onLaunch(command)) + + expect(onLaunch).toHaveBeenCalledWith(command) + expect(onClose).not.toHaveBeenCalled() + }) + + it('submits one full-list mutation for a same-frame double tap', async () => { + const save = deferred() + mocks.persist.mockReturnValue(save.promise) + await act(async () => { + renderer = create( + createElement(QuickCommandsSheet, { + visible: true, + onClose: vi.fn(), + client: {} as RpcClient, + repoId: 'repo-1', + repoName: 'Repo', + onLaunch: () => true + }) + ) + }) + + act(() => renderer!.root.findByType(quickCommandsList).props.onAdd()) + const editor = renderer!.root.findByType(quickCommandEditorForm) + act(() => { + editor.props.onChange({ label: 'Test' }) + editor.props.onChange({ command: 'pnpm test' }) + }) + const readyEditor = renderer!.root.findByType(quickCommandEditorForm) + act(() => { + readyEditor.props.onSave() + readyEditor.props.onSave() + }) + + expect(mocks.persist).toHaveBeenCalledTimes(1) + await act(async () => { + save.resolve(true) + await save.promise + }) + }) + + it('keeps creation closed when the host command limit is reached', async () => { + mocks.commands = Array.from({ length: MAX_QUICK_COMMANDS }, (_, index) => ({ + ...command, + id: `command-${index}` + })) + await act(async () => { + renderer = create( + createElement(QuickCommandsSheet, { + visible: true, + onClose: vi.fn(), + client: {} as RpcClient, + repoId: 'repo-1', + repoName: 'Repo', + onLaunch: () => true + }) + ) + }) + + const list = renderer!.root.findByType(quickCommandsList) + expect(list.props.canAdd).toBe(false) + act(() => list.props.onAdd()) + expect(renderer!.root.findAllByType(quickCommandEditorForm)).toHaveLength(0) + }) + + it('shows only global commands and defaults new commands to global without a repo', async () => { + const repoCommand: TerminalQuickCommand = { + ...command, + id: 'repo-command', + scope: { type: 'repo', repoId: 'repo-1' } + } + mocks.commands = [command, repoCommand] + await act(async () => { + renderer = create( + createElement(QuickCommandsSheet, { + visible: true, + onClose: vi.fn(), + client: {} as RpcClient, + repoId: null, + repoName: 'Folder workspace', + onLaunch: () => true + }) + ) + }) + + const list = renderer!.root.findByType(quickCommandsList) + expect(list.props.globalCommands).toEqual([command]) + expect(list.props.repoCommands).toEqual([]) + act(() => list.props.onAdd()) + expect(renderer!.root.findByType(quickCommandEditorForm).props.draft.scope).toEqual({ + type: 'global' + }) + }) + + it('does not delete a shared command until the destructive action is confirmed', async () => { + await act(async () => { + renderer = create( + createElement(QuickCommandsSheet, { + visible: true, + onClose: vi.fn(), + client: {} as RpcClient, + repoId: 'repo-1', + repoName: 'Repo', + onLaunch: () => true + }) + ) + }) + + act(() => renderer!.root.findByType(quickCommandsList).props.onDelete(command)) + expect(mocks.persist).not.toHaveBeenCalled() + + const actions = mocks.alert.mock.calls[0]?.[2] as + | Array<{ style?: string; onPress?: () => void }> + | undefined + act(() => actions?.find((action) => action.style === 'destructive')?.onPress?.()) + + expect(mocks.persist).toHaveBeenCalledTimes(1) + expect(mocks.persist).toHaveBeenCalledWith({ type: 'delete', id: command.id }) + }) +}) diff --git a/mobile/src/session/QuickCommandsSheet.tsx b/mobile/src/session/QuickCommandsSheet.tsx new file mode 100644 index 00000000000..c4dbff9473d --- /dev/null +++ b/mobile/src/session/QuickCommandsSheet.tsx @@ -0,0 +1,251 @@ +import { useMemo, useRef, useState } from 'react' +import { Alert, View, Text, Pressable, StyleSheet } from 'react-native' +import { ChevronLeft } from 'lucide-react-native' +import { colors, spacing } from '../theme/mobile-theme' +import { BottomDrawer } from '../components/BottomDrawer' +import type { RpcClient } from '../transport/rpc-client' +import type { TerminalQuickCommand } from '../../../src/shared/types' +import { + getQuickCommandPreview, + MAX_QUICK_COMMANDS, + quickCommandMatchesRepo +} from '../terminal/quick-commands' +import { useQuickCommands } from './use-quick-commands' +import { QuickCommandEditorForm } from './QuickCommandEditorForm' +import { QuickCommandAgentPicker, QuickCommandsList } from './QuickCommandsList' +import { + createEmptyQuickCommandDraft, + draftToQuickCommand, + quickCommandToDraft, + type QuickCommandDraft +} from './quick-command-draft' + +type Props = { + visible: boolean + onClose: () => void + client: RpcClient | null + repoId: string | null + repoName: string | null + onLaunch: (command: TerminalQuickCommand) => boolean +} + +type SheetView = 'list' | 'editor' | 'agent' + +export function QuickCommandsSheet({ + visible, + onClose, + client, + repoId, + repoName, + onLaunch +}: Props) { + const { commands, loading, ready, error, persist } = useQuickCommands({ + client, + enabled: visible + }) + const [view, setView] = useState('list') + const [query, setQuery] = useState('') + const [draft, setDraft] = useState(null) + const [saving, setSaving] = useState(false) + const savingRef = useRef(false) + + const [wasVisible, setWasVisible] = useState(visible) + if (visible !== wasVisible) { + setWasVisible(visible) + if (visible) { + setView('list') + setQuery('') + setDraft(null) + } + } + + // Why: prompt bodies can total ~240 KB. Lowercase them once per settings + // update instead of allocating the same search text on every keystroke. + const searchableCommands = useMemo(() => { + return commands + .filter((command) => quickCommandMatchesRepo(command, repoId)) + .map((command) => ({ + command, + searchText: `${command.label} ${getQuickCommandPreview(command)}`.toLowerCase() + })) + }, [commands, repoId]) + + const visibleCommands = useMemo(() => { + const trimmed = query.trim().toLowerCase() + return searchableCommands + .filter((entry) => !trimmed || entry.searchText.includes(trimmed)) + .map((entry) => entry.command) + }, [query, searchableCommands]) + + const repoCommands = visibleCommands.filter((command) => command.scope?.type === 'repo') + const globalCommands = visibleCommands.filter((command) => command.scope?.type !== 'repo') + + const openEditor = (command?: TerminalQuickCommand) => { + // Why: the host rejects full-list updates above this cap; existing rows + // must remain editable/deletable when creation is no longer possible. + if (!command && commands.length >= MAX_QUICK_COMMANDS) { + return + } + setDraft( + command + ? quickCommandToDraft(command) + : createEmptyQuickCommandDraft(repoId ? { type: 'repo', repoId } : { type: 'global' }) + ) + setView('editor') + } + + const handleLaunch = (command: TerminalQuickCommand) => { + if (onLaunch(command)) { + onClose() + } + } + + const handleDelete = (command: TerminalQuickCommand) => { + // Why: quick commands sync with desktop, so an accidental one-tap delete + // removes shared data rather than only dismissing a local row. + Alert.alert( + `Delete "${command.label || 'Untitled'}"?`, + 'This quick command will be removed from your saved list.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + void persist({ type: 'delete', id: command.id }) + } + } + ] + ) + } + + const handleSave = async () => { + if (!draft || savingRef.current) { + return + } + const built = draftToQuickCommand(draft) + if (!built) { + return + } + // Why: state cannot lock out a second tap until React commits the disabled UI. + savingRef.current = true + setSaving(true) + try { + const ok = await persist({ type: 'upsert', command: built }) + if (ok) { + setView('list') + setDraft(null) + } + } finally { + savingRef.current = false + setSaving(false) + } + } + + const title = + view === 'editor' + ? draft?.id + ? 'Edit Quick Command' + : 'Add Quick Command' + : view === 'agent' + ? 'Choose Agent' + : 'Quick Commands' + + return ( + + + {view === 'list' ? ( + + ) : ( + [styles.backButton, pressed && styles.pressed]} + onPress={() => setView(view === 'agent' ? 'editor' : 'list')} + accessibilityLabel="Back" + > + + + )} + {title} + + + + {view === 'editor' && draft ? ( + + + Save terminal commands or agent prompts for quick access. + + + ) : null} + + {view === 'list' ? ( + openEditor()} + /> + ) : null} + + {view === 'editor' && draft ? ( + + setDraft((current) => (current ? { ...current, ...patch } : current)) + } + onOpenAgentPicker={() => setView('agent')} + onCancel={() => { + setView('list') + setDraft(null) + }} + onSave={() => void handleSave()} + /> + ) : null} + + {view === 'agent' && draft ? ( + { + setDraft((current) => (current ? { ...current, agent } : current)) + setView('editor') + }} + /> + ) : null} + + ) +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', paddingBottom: spacing.sm }, + backButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center' + }, + backSpacer: { width: 30 }, + title: { + flex: 1, + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary, + textAlign: 'center' + }, + pressed: { backgroundColor: colors.bgRaised }, + editorDesc: { paddingHorizontal: spacing.xs, paddingBottom: spacing.sm }, + descText: { fontSize: 12, color: colors.textMuted } +}) diff --git a/mobile/src/session/quick-command-draft.ts b/mobile/src/session/quick-command-draft.ts new file mode 100644 index 00000000000..2afdcf74620 --- /dev/null +++ b/mobile/src/session/quick-command-draft.ts @@ -0,0 +1,116 @@ +import type { + TerminalQuickCommand, + TerminalQuickCommandAction, + TerminalQuickCommandScope, + TuiAgent +} from '../../../src/shared/types' +import { + isAgentQuickCommand, + MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, + MAX_QUICK_COMMAND_LABEL_LENGTH, + MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH, + supportsTerminalAgentQuickCommand +} from '../terminal/quick-commands' + +// A single mutable draft covering both quick-command actions. Both the command +// text and the agent/prompt are kept while editing so toggling Action back and +// forth doesn't discard what the user already typed (mirrors desktop's draft). +export type QuickCommandDraft = { + id: string | null + label: string + action: TerminalQuickCommandAction + command: string + appendEnter: boolean + agent: TuiAgent | null + prompt: string + scope: TerminalQuickCommandScope +} + +export function createEmptyQuickCommandDraft(scope: TerminalQuickCommandScope): QuickCommandDraft { + return { + id: null, + label: '', + action: 'terminal-command', + command: '', + appendEnter: true, + agent: null, + prompt: '', + scope + } +} + +export function quickCommandToDraft(command: TerminalQuickCommand): QuickCommandDraft { + const scope: TerminalQuickCommandScope = + command.scope?.type === 'repo' && command.scope.repoId + ? { type: 'repo', repoId: command.scope.repoId } + : { type: 'global' } + if (isAgentQuickCommand(command)) { + return { + id: command.id, + label: command.label, + action: 'agent-prompt', + command: '', + appendEnter: true, + agent: command.agent, + prompt: command.prompt, + scope + } + } + return { + id: command.id, + label: command.label, + action: 'terminal-command', + command: command.command, + appendEnter: command.appendEnter !== false, + agent: null, + prompt: '', + scope + } +} + +export function isQuickCommandDraftValid(draft: QuickCommandDraft): boolean { + if (!draft.label.trim()) { + return false + } + if (draft.action === 'agent-prompt') { + return Boolean( + draft.agent && + supportsTerminalAgentQuickCommand(draft.agent) && + draft.prompt.trim().length > 0 + ) + } + return draft.command.trim().length > 0 +} + +// Build the persisted command from a draft. Returns null when incomplete so the +// caller can keep the editor open. The server re-normalizes, but we trim/cap +// here so optimistic local state matches what will be saved. +export function draftToQuickCommand(draft: QuickCommandDraft): TerminalQuickCommand | null { + if (!isQuickCommandDraftValid(draft)) { + return null + } + // Why: timestamps alone collide when desktop and mobile add commands in the + // same millisecond, which would turn an atomic upsert into an overwrite. + const id = + draft.id ?? + `quick-command-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + const label = draft.label.trim().slice(0, MAX_QUICK_COMMAND_LABEL_LENGTH) + if (draft.action === 'agent-prompt' && draft.agent) { + return { + id, + label, + action: 'agent-prompt', + agent: draft.agent, + prompt: draft.prompt.trimEnd().slice(0, MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH), + scope: draft.scope + } + } + return { + id, + label, + action: 'terminal-command', + command: draft.command.trimEnd().slice(0, MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH), + appendEnter: draft.appendEnter, + scope: draft.scope + } +} diff --git a/mobile/src/session/session-view-opt-in-gate.test.ts b/mobile/src/session/session-view-opt-in-gate.test.ts new file mode 100644 index 00000000000..8a31f466e8a --- /dev/null +++ b/mobile/src/session/session-view-opt-in-gate.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { readDefaultSessionViewPreference } from '../storage/session-view-preferences' +import { shouldPresentSessionViewOptIn } from './session-view-opt-in-gate' + +vi.mock('../storage/session-view-preferences', () => ({ + readDefaultSessionViewPreference: vi.fn() +})) + +describe('session view opt-in gate', () => { + beforeEach(() => { + vi.mocked(readDefaultSessionViewPreference).mockReset() + }) + + it('presents when no default has ever been saved', async () => { + vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({ + value: null, + loaded: true, + hasStoredValue: false + }) + await expect(shouldPresentSessionViewOptIn()).resolves.toBe(true) + }) + + it.each(['terminal', 'chat'] as const)('preserves an existing %s choice', async (value) => { + vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({ + value, + loaded: true, + hasStoredValue: true + }) + await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false) + }) + + it('does not overwrite an unknown stored value', async () => { + vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({ + value: null, + loaded: true, + hasStoredValue: true + }) + await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false) + }) + + it('does not block startup when storage is unreadable', async () => { + vi.mocked(readDefaultSessionViewPreference).mockResolvedValue({ + value: null, + loaded: false, + hasStoredValue: false + }) + await expect(shouldPresentSessionViewOptIn()).resolves.toBe(false) + }) +}) diff --git a/mobile/src/session/session-view-opt-in-gate.ts b/mobile/src/session/session-view-opt-in-gate.ts new file mode 100644 index 00000000000..b7e6cb1a036 --- /dev/null +++ b/mobile/src/session/session-view-opt-in-gate.ts @@ -0,0 +1,10 @@ +import { readDefaultSessionViewPreference } from '../storage/session-view-preferences' + +/** Whether to show the one-time screen that lets the user pick terminal vs native + * chat. Present it only when the read succeeded and no default was ever saved, so + * people who already chose (in the opt-in or Settings) are never prompted again, + * and a transient storage failure doesn't trap startup behind the screen. */ +export async function shouldPresentSessionViewOptIn(): Promise { + const preference = await readDefaultSessionViewPreference() + return preference.loaded && !preference.hasStoredValue +} diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts index 752b2ecb3b8..6c97b9e0b04 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts @@ -24,21 +24,17 @@ type PrSidebarControllerInput = { client: RpcClient | null connState: ConnectionState worktreeId: string - // Head branch + SHA come from git.status (`branch`/`head`) via the review screen, - // not the branchCompare base ref nor worktree metadata (which carries no branch). + // branch/headSha come from git.status (not the branchCompare base ref nor worktree metadata, which carries no branch). branch: string | null headSha: string | null } -// Load options for the shared PR controller. The Source Control hub chip only needs -// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the -// Pull Request segment is actually open. +// Load options: the hub chip needs only phase 1 (PR + checks); phase 2 (comments/body) is heavy and waits until the PR segment opens. export type PrSidebarLoadOptions = { includeDetails?: boolean } -// Identity is worktree + branch only. Head SHA advances on every commit and must not -// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead. +// Identity is worktree + branch only: headSha advances every commit and must not wipe a ready chip to "loading" (soft-refresh handles it). export function buildMobilePrSidebarIdentity(args: { worktreeId: string branch: string | null @@ -48,33 +44,23 @@ export function buildMobilePrSidebarIdentity(args: { export function useMobilePrSidebarController(input: PrSidebarControllerInput) { const { client, connState, worktreeId, branch, headSha } = input - // The dedicated PR icon is available whenever the repo has a GitHub remote — - // independent of whether the branch has an open PR (a no-PR branch shows an - // empty state rather than hiding the icon). + // PR icon shows for any GitHub remote, regardless of an open PR — a no-PR branch shows an empty state rather than hiding the icon. const [isGithubRepo, setIsGithubRepo] = useState(false) - // False until the probe resolves for this worktree. Consumers gate "unavailable - // for this provider" copy on it — isGithubRepo=false is meaningless mid-probe. + // False until the probe resolves — isGithubRepo=false is meaningless mid-probe, so consumers gate "unavailable" copy on this. const [repoProbeLoaded, setRepoProbeLoaded] = useState(false) const [state, setState] = useState({ kind: 'hidden' }) const [showPRSidebar, setShowPRSidebar] = useState(false) const loadSeqRef = useRef(0) - // Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent - // phase-1 soft refresh (and vice versa) when chip bootstrap left details null. + // Separate seq for phase-2 fetches so they can't cancel a concurrent phase-1 soft refresh (and vice versa). const detailsSeqRef = useRef(0) - // The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's - // fill-in effect fires as soon as phase 1 renders ready with null details — - // exactly when load()'s own phase 2 just started. Without this claim, every - // cold PR-segment open fetched the heavy details payload twice. + // (seq, prNumber) of the in-flight phase-2 fetch; without this claim every cold PR-segment open fetched the heavy details twice. const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null) const stateIdentityRef = useRef(null) const stateRef = useRef(state) stateRef.current = state const headShaRef = useRef(headSha) - // Repo eligibility (a GitHub remote) is independent of the branch, so the probe - // must not require one: a detached HEAD / mid-rebase worktree (branch === null) - // would otherwise never set repoProbeLoaded, stranding the PR segment on a - // forever spinner instead of the "Current branch unavailable" state. + // Probe is branch-independent (repo eligibility is): requiring a branch would strand a detached-HEAD worktree on a forever spinner. const probeReady = client !== null && connState === 'connected' const identity = buildMobilePrSidebarIdentity({ worktreeId, branch }) @@ -91,9 +77,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { } }, [client]) - // Probe whether this is a GitHub repo to decide icon availability (GitHub-only). - // Worktree change must reset eligibility; a brief disconnect must not — otherwise - // the hub shows "unavailable for this provider" and hides the chip mid-session. + // Probe GitHub-repo eligibility for the icon; a worktree change resets it, a brief disconnect must not (else the chip hides mid-session). useEffect(() => { setIsGithubRepo(false) setRepoProbeLoaded(false) @@ -112,8 +96,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { } }) .catch(() => { - // Why: sendGithubPrRead already normalizes throws, but a cancelled - // unmount + any unexpected rejection must not surface as LogBox. + // Why: sendGithubPrRead normalizes throws, but a stray rejection on unmount must not surface as LogBox. if (!cancelled) { setIsGithubRepo(false) setRepoProbeLoaded(true) @@ -133,8 +116,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { return } if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) { - // Why: ready/loading data is scoped to branch. A branch switch must not let - // the open panel keep rendering the previous PR as "fresh." + // Why: data is scoped to branch; a branch switch must not keep rendering the previous PR as "fresh." loadSeqRef.current += 1 detailsSeqRef.current += 1 stateIdentityRef.current = null @@ -152,16 +134,10 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { } const seq = loadSeqRef.current + 1 loadSeqRef.current = seq - // In-flight phase-2 work is NOT invalidated here: this load only takes phase-2 - // ownership when its own phase 2 actually starts (the bump below), so a load - // superseded at the phase-1 guard can never orphan a detailsSeq and silently - // discard the only details fetch. A stale ensure applying mid-phase-1 is safe — - // its identity/number/kind guards only let matching details through. + // Don't invalidate in-flight phase 2 here: it's only claimed when this load's own phase 2 starts, so a superseded phase-1 load can't orphan the details fetch. const previousIdentity = stateIdentityRef.current stateIdentityRef.current = loadIdentity - // Soft refresh: same branch already showing ready/none stays visible while - // checks re-fetch (head advanced after commit). Hard loading only on first load - // or after a real identity wipe. + // Soft refresh: same-branch ready/none stays visible while checks re-fetch; hard "loading" only on first load or identity wipe. const keepVisible = previousIdentity === loadIdentity && (stateRef.current.kind === 'ready' || @@ -170,8 +146,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { if (!keepVisible) { setState({ kind: 'loading' }) } - // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with - // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves. + // Phase 1: PR + checks (fast); linkedPR read runs in parallel with forBranch so a closed/merged linked PR still resolves. const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha }) if ( !shouldApplyResult(seq, loadSeqRef.current) || @@ -181,9 +156,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { } stateIdentityRef.current = loadIdentity - // Keep prior comments/body visible across phase 1 when the same PR is still open. - // loadPrSidebarData always returns details:null; without this, soft refresh and - // PR-tab refresh blank the comment tree until phase 2 finishes. + // Preserve prior details across phase 1 (loadPrSidebarData returns details:null) so soft/PR-tab refresh doesn't blank the comment tree. const priorDetails = next.kind === 'ready' && stateRef.current.kind === 'ready' && @@ -213,10 +186,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { if (detailsInFlightRef.current?.seq === detailsSeq) { detailsInFlightRef.current = null } - // Phase-2 ownership is encoded by detailsSeq + identity + PR number alone — - // deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without - // bumping detailsSeq, and must not discard the in-flight details it preserved - // (ensure dedupes against this claim, so nothing would re-fetch them). + // Ownership keyed on detailsSeq (not loadSeq): a chip-only soft refresh bumps loadSeq without detailsSeq and must not discard these details. if ( detailsSeq !== detailsSeqRef.current || stateIdentityRef.current !== loadIdentity || @@ -236,11 +206,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { [buildDeps, branch, headSha, identity, worktreeId] ) - // Phase-2 only — used when the hub opens the PR segment after a chip-only load. - // Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1. - // Retries synthetic placeholders too: a failed phase-2 installs non-null empty - // details so Description/Comments leave the spinner, and without this ensure - // would never re-fetch on tab re-open. + // Phase-2-only fill-in; uses detailsSeqRef so it can't cancel a concurrent phase-1, and re-fetches non-null placeholders too. const ensurePrSidebarDetails = useCallback(async () => { const current = stateRef.current if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) { @@ -252,8 +218,7 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { return } const prNumber = current.data.pr.number - // A live phase-2 fetch for this PR is already in flight (its claim still owns - // the latest details seq) — do not start a duplicate. + // Skip if a live phase-2 fetch for this PR already owns the latest details seq (dedupe). const inFlight = detailsInFlightRef.current if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) { return @@ -284,18 +249,13 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { }) }, [buildDeps, identity, worktreeId]) - // Soft-refresh checks when HEAD advances on the same branch (post-commit). - // Also restarts an in-flight phase-1 load so a mid-flight head advance is not - // applied with a stale SHA (headShaRef would otherwise advance with no reload). + // Soft-refresh on same-branch HEAD advance; restart in-flight load so the advance isn't applied with a stale SHA. useEffect(() => { if (headShaRef.current === headSha) { return } headShaRef.current = headSha - // Identity was just wiped (branch/worktree switch): stateRef still holds the - // pre-wipe state in this effect flush, which would start a load flavored for - // the OLD surface (e.g. heavy details on the Changes tab). Let the owning - // surface's hidden-state effects drive the first load for the new identity. + // Identity just wiped: stateRef still holds stale pre-wipe state, so let the surface's hidden-state effects drive the new load. if (stateIdentityRef.current === null) { return } diff --git a/mobile/src/session/use-quick-commands.test.ts b/mobile/src/session/use-quick-commands.test.ts new file mode 100644 index 00000000000..6ab43487475 --- /dev/null +++ b/mobile/src/session/use-quick-commands.test.ts @@ -0,0 +1,391 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import type { TerminalQuickCommand } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { useQuickCommands } from './use-quick-commands' + +const FIRST: TerminalQuickCommand = { + id: 'first', + label: 'First', + action: 'terminal-command', + command: 'echo first', + appendEnter: true, + scope: { type: 'global' } +} +const SECOND: TerminalQuickCommand = { + id: 'second', + label: 'Second', + action: 'terminal-command', + command: 'echo second', + appendEnter: true, + scope: { type: 'global' } +} + +function success(commands: TerminalQuickCommand[]): RpcResponse { + return { + ok: true, + result: { terminalQuickCommands: commands } + } as RpcResponse +} + +function failure(message: string): RpcResponse { + return { + ok: false, + error: { code: 'internal_error', message } + } as RpcResponse +} + +function deferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('useQuickCommands', () => { + let renderer: ReactTestRenderer | null = null + let state: ReturnType | null = null + let consoleSpy: MockInstance + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const original = console.error + consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + state = null + consoleSpy.mockRestore() + }) + + async function mount(client: RpcClient, enabled = true): Promise { + function Harness(): null { + state = useQuickCommands({ client, enabled }) + return null + } + await act(async () => { + renderer = create(createElement(Harness)) + await Promise.resolve() + }) + } + + it('does not fetch settings while the sheet is closed', async () => { + const client = { sendRequest: vi.fn() } as unknown as RpcClient + + await mount(client, false) + + expect(client.sendRequest).not.toHaveBeenCalled() + expect(state?.ready).toBe(false) + }) + + it('keeps mutations disabled when the remote list could not be loaded', async () => { + const client = { + sendRequest: vi.fn().mockResolvedValue(failure('load failed')) + } as unknown as RpcClient + await mount(client) + + await act(async () => { + const persisted = await state!.persist({ type: 'delete', id: FIRST.id }) + expect(persisted).toBe(false) + }) + + expect(state?.ready).toBe(false) + expect(state?.error).toBe('load failed') + expect(client.sendRequest).toHaveBeenCalledTimes(1) + }) + + it('keeps mutations disabled when a successful load has a malformed list', async () => { + const client = { + sendRequest: vi.fn().mockResolvedValue({ ok: true, result: {} } as RpcResponse) + } as unknown as RpcClient + await mount(client) + + await act(async () => { + const persisted = await state!.persist({ type: 'delete', id: FIRST.id }) + expect(persisted).toBe(false) + }) + + expect(state?.ready).toBe(false) + expect(state?.error).toBe('Failed to load quick commands') + expect(client.sendRequest).toHaveBeenCalledTimes(1) + }) + + it('keeps a later queued mutation after an earlier mutation fails', async () => { + const firstUpdate = deferred() + const secondUpdate = deferred() + const updateParams: unknown[] = [] + const client = { + sendRequest: vi.fn((method: string, params?: unknown) => { + if (method === 'settings.getTerminalQuickCommands') { + return Promise.resolve(success([FIRST, SECOND])) + } + updateParams.push(params) + return updateParams.length === 1 ? firstUpdate.promise : secondUpdate.promise + }) + } as unknown as RpcClient + await mount(client) + expect(state?.commands).toEqual([FIRST, SECOND]) + + let firstPersist: Promise = Promise.resolve(false) + let secondPersist: Promise = Promise.resolve(false) + await act(async () => { + firstPersist = state!.persist({ type: 'delete', id: FIRST.id }) + secondPersist = state!.persist({ type: 'delete', id: SECOND.id }) + await Promise.resolve() + }) + + expect(state?.commands).toEqual([]) + expect(updateParams).toHaveLength(1) + expect(updateParams[0]).toEqual({ mutation: { type: 'delete', id: FIRST.id } }) + + await act(async () => { + firstUpdate.resolve(failure('first failed')) + await Promise.resolve() + await Promise.resolve() + }) + expect(updateParams).toEqual([ + { mutation: { type: 'delete', id: FIRST.id } }, + { mutation: { type: 'delete', id: SECOND.id } } + ]) + expect(state?.commands).toEqual([FIRST]) + + await act(async () => { + secondUpdate.resolve(success([FIRST])) + await Promise.all([firstPersist, secondPersist]) + }) + expect(await firstPersist).toBe(false) + expect(await secondPersist).toBe(true) + expect(state?.commands).toEqual([FIRST]) + expect(state?.error).toBeNull() + }) + + it('adopts the latest server-normalized list between queued mutations', async () => { + const normalizedFirst = { ...FIRST, label: 'Server normalized' } + const firstUpdate = deferred() + const secondUpdate = deferred() + const updateParams: unknown[] = [] + const client = { + sendRequest: vi.fn((method: string, params?: unknown) => { + if (method === 'settings.getTerminalQuickCommands') { + return Promise.resolve(success([FIRST, SECOND])) + } + updateParams.push(params) + return updateParams.length === 1 ? firstUpdate.promise : secondUpdate.promise + }) + } as unknown as RpcClient + await mount(client) + + let firstPersist: Promise = Promise.resolve(false) + let secondPersist: Promise = Promise.resolve(false) + await act(async () => { + firstPersist = state!.persist({ + type: 'upsert', + command: { ...FIRST, label: 'Local label' } + }) + secondPersist = state!.persist({ type: 'delete', id: SECOND.id }) + await Promise.resolve() + }) + + expect(updateParams[0]).toEqual({ + mutation: { type: 'upsert', command: { ...FIRST, label: 'Local label' } } + }) + await act(async () => { + firstUpdate.resolve(success([normalizedFirst, SECOND])) + await Promise.resolve() + await Promise.resolve() + }) + expect(updateParams[1]).toEqual({ mutation: { type: 'delete', id: SECOND.id } }) + + await act(async () => { + secondUpdate.resolve(success([normalizedFirst])) + await Promise.all([firstPersist, secondPersist]) + }) + expect(state?.commands).toEqual([normalizedFirst]) + }) + + it('preserves unrelated commands added by another client while the sheet is open', async () => { + const edited = { ...FIRST, label: 'Edited on mobile' } + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce(success([FIRST])) + // The host applies the targeted upsert to a list that desktop changed. + .mockResolvedValueOnce(success([edited, SECOND])) + } as unknown as RpcClient + await mount(client) + + await act(async () => { + await state!.persist({ type: 'upsert', command: edited }) + }) + + expect(client.sendRequest).toHaveBeenLastCalledWith('settings.updateTerminalQuickCommands', { + mutation: { type: 'upsert', command: edited } + }) + expect(state?.commands).toEqual([edited, SECOND]) + }) + + it('isolates an old client mutation from a replacement client', async () => { + const oldUpdate = deferred() + const oldClient = { + sendRequest: vi + .fn() + .mockResolvedValueOnce(success([FIRST])) + .mockReturnValueOnce(oldUpdate.promise) + } as unknown as RpcClient + const newClient = { + sendRequest: vi.fn().mockResolvedValue(success([SECOND])) + } as unknown as RpcClient + + function Harness({ client }: { client: RpcClient }): null { + state = useQuickCommands({ client, enabled: true }) + return null + } + await act(async () => { + renderer = create(createElement(Harness, { client: oldClient })) + await Promise.resolve() + }) + let persisted: Promise = Promise.resolve(false) + await act(async () => { + persisted = state!.persist({ type: 'delete', id: FIRST.id }) + await Promise.resolve() + renderer!.update(createElement(Harness, { client: newClient })) + await Promise.resolve() + }) + + expect(newClient.sendRequest).toHaveBeenCalledWith('settings.getTerminalQuickCommands') + expect(state?.commands).toEqual([SECOND]) + + await act(async () => { + oldUpdate.resolve(success([])) + await persisted + }) + expect(state?.commands).toEqual([SECOND]) + }) + + it('rolls back the latest optimistic mutation when persistence fails', async () => { + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce(success([FIRST])) + .mockResolvedValueOnce(failure('save failed')) + } as unknown as RpcClient + await mount(client) + + await act(async () => { + await state!.persist({ type: 'delete', id: FIRST.id }) + }) + + expect(state?.commands).toEqual([FIRST]) + expect(state?.error).toBe('save failed') + }) + + it('rolls back when a successful update has a malformed authoritative list', async () => { + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce(success([FIRST])) + .mockResolvedValueOnce({ ok: true, result: {} } as RpcResponse) + } as unknown as RpcClient + await mount(client) + + let persisted = true + await act(async () => { + persisted = await state!.persist({ type: 'delete', id: FIRST.id }) + }) + + expect(persisted).toBe(false) + expect(state?.commands).toEqual([FIRST]) + expect(state?.error).toBe('Failed to save quick command') + }) + + it('waits for an in-flight save before reloading after reopen', async () => { + const update = deferred() + let loadCount = 0 + const client = { + sendRequest: vi.fn((method: string) => { + if (method === 'settings.updateTerminalQuickCommands') { + return update.promise + } + loadCount += 1 + return Promise.resolve(success(loadCount === 1 ? [FIRST] : [])) + }) + } as unknown as RpcClient + + function Harness({ enabled }: { enabled: boolean }): null { + state = useQuickCommands({ client, enabled }) + return null + } + await act(async () => { + renderer = create(createElement(Harness, { enabled: true })) + await Promise.resolve() + }) + + let persisted: Promise = Promise.resolve(false) + await act(async () => { + persisted = state!.persist({ type: 'delete', id: FIRST.id }) + await Promise.resolve() + }) + await act(async () => { + renderer!.update(createElement(Harness, { enabled: false })) + await Promise.resolve() + }) + await act(async () => { + renderer!.update(createElement(Harness, { enabled: true })) + await Promise.resolve() + }) + expect(loadCount).toBe(1) + + await act(async () => { + update.resolve(success([])) + await persisted + await Promise.resolve() + await Promise.resolve() + }) + + expect(loadCount).toBe(2) + expect(state?.commands).toEqual([]) + expect(state?.ready).toBe(true) + }) + + it('rolls back to the confirmed list when consecutive queued mutations fail', async () => { + const firstUpdate = deferred() + const secondUpdate = deferred() + const client = { + sendRequest: vi + .fn() + .mockResolvedValueOnce(success([FIRST, SECOND])) + .mockReturnValueOnce(firstUpdate.promise) + .mockReturnValueOnce(secondUpdate.promise) + } as unknown as RpcClient + await mount(client) + + let firstPersist: Promise = Promise.resolve(false) + let secondPersist: Promise = Promise.resolve(false) + await act(async () => { + firstPersist = state!.persist({ type: 'delete', id: FIRST.id }) + secondPersist = state!.persist({ type: 'delete', id: SECOND.id }) + await Promise.resolve() + }) + + await act(async () => { + firstUpdate.resolve(failure('first failed')) + await Promise.resolve() + await Promise.resolve() + secondUpdate.resolve(failure('second failed')) + await Promise.all([firstPersist, secondPersist]) + }) + + expect(state?.commands).toEqual([FIRST, SECOND]) + expect(state?.error).toBe('second failed') + }) +}) diff --git a/mobile/src/session/use-quick-commands.ts b/mobile/src/session/use-quick-commands.ts new file mode 100644 index 00000000000..5ce5a994cd0 --- /dev/null +++ b/mobile/src/session/use-quick-commands.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcSuccess } from '../transport/types' +import type { TerminalQuickCommand } from '../../../src/shared/types' +import { + applyTerminalQuickCommandMutation, + parseNormalizedTerminalQuickCommands, + type TerminalQuickCommandMutation +} from '../terminal/quick-commands' + +type Args = { + client: RpcClient | null + // Fetch only while the sheet is open — quick commands are settings data we + // don't need to keep hydrated for every session screen. + enabled: boolean +} + +type QuickCommandsState = { + commands: TerminalQuickCommand[] + loading: boolean + ready: boolean + error: string | null + // Optimistically apply against the latest local list, then serialize writes. + // The server re-normalizes and returns the canonical list, which we adopt. + persist: (mutation: TerminalQuickCommandMutation) => Promise +} + +type PendingMutation = { + id: number + mutation: TerminalQuickCommandMutation +} + +type MutationContext = { + client: RpcClient + confirmed: TerminalQuickCommand[] + pending: PendingMutation[] + queue: Promise + nextMutationId: number +} + +function readQuickCommands(result: unknown): TerminalQuickCommand[] | null { + const list = (result as { terminalQuickCommands?: unknown } | null)?.terminalQuickCommands + return parseNormalizedTerminalQuickCommands(list) +} + +export function useQuickCommands({ client, enabled }: Args): QuickCommandsState { + const [commands, setCommands] = useState([]) + const [loading, setLoading] = useState(false) + const [ready, setReady] = useState(false) + const [error, setError] = useState(null) + const commandsRef = useRef([]) + const operationIdRef = useRef(0) + const mutationContextRef = useRef(null) + + useEffect(() => { + if (!enabled || !client) { + setReady(false) + return + } + let mutationContext = mutationContextRef.current + if (mutationContext?.client !== client) { + // A request for an old host must not delay or update mutations on a new one. + mutationContext = { + client, + confirmed: [], + pending: [], + queue: Promise.resolve(), + nextMutationId: 0 + } + mutationContextRef.current = mutationContext + commandsRef.current = [] + setCommands([]) + } + let stale = false + const operationId = operationIdRef.current + 1 + operationIdRef.current = operationId + setLoading(true) + setReady(false) + setError(null) + + void (async () => { + try { + // A close/reopen can overlap an in-flight save. Read only after that + // save settles so an older snapshot cannot replace its canonical result. + await mutationContext.queue + if ( + stale || + operationId !== operationIdRef.current || + mutationContextRef.current !== mutationContext + ) { + return + } + const response = await client.sendRequest('settings.getTerminalQuickCommands') + if ( + stale || + operationId !== operationIdRef.current || + mutationContextRef.current !== mutationContext + ) { + return + } + if (!response.ok) { + setError((response as RpcFailure).error.message || 'Failed to load quick commands') + return + } + const next = readQuickCommands((response as RpcSuccess).result) + if (!next) { + setError('Failed to load quick commands') + return + } + mutationContext.confirmed = next + commandsRef.current = next + setCommands(next) + setReady(true) + } catch (err) { + if ( + !stale && + operationId === operationIdRef.current && + mutationContextRef.current === mutationContext + ) { + setError(err instanceof Error ? err.message : 'Failed to load quick commands') + } + } finally { + if ( + !stale && + operationId === operationIdRef.current && + mutationContextRef.current === mutationContext + ) { + setLoading(false) + } + } + })() + + return () => { + stale = true + } + }, [client, enabled]) + + const persist = useCallback( + async (commandMutation: TerminalQuickCommandMutation) => { + // Why: the loaded list is the optimistic/rollback baseline; mutating + // before it arrives would make failure recovery show invented state. + const mutationContext = mutationContextRef.current + if (!client || loading || !ready || mutationContext?.client !== client) { + return false + } + const mutation: PendingMutation = { + id: mutationContext.nextMutationId + 1, + mutation: commandMutation + } + mutationContext.nextMutationId = mutation.id + mutationContext.pending.push(mutation) + const optimistic = applyTerminalQuickCommandMutation(commandsRef.current, commandMutation) + commandsRef.current = optimistic + setCommands(optimistic) + setError(null) + + const send = async (): Promise => { + let succeeded = false + let failureMessage: string | null = null + try { + const response = await client.sendRequest('settings.updateTerminalQuickCommands', { + mutation: commandMutation + }) + if (!response.ok) { + throw new Error( + (response as RpcFailure).error.message || 'Failed to save quick command' + ) + } + const confirmed = readQuickCommands((response as RpcSuccess).result) + if (!confirmed) { + // Why: treating an invalid success payload as [] would let the next + // full-list mutation erase commands that still exist on the host. + throw new Error('Failed to save quick command') + } + mutationContext.confirmed = confirmed + succeeded = true + return true + } catch (err) { + failureMessage = err instanceof Error ? err.message : 'Failed to save quick command' + return false + } finally { + mutationContext.pending = mutationContext.pending.filter( + (pending) => pending.id !== mutation.id + ) + if (mutationContextRef.current === mutationContext) { + const next = mutationContext.pending.reduce( + (current, pending) => applyTerminalQuickCommandMutation(current, pending.mutation), + mutationContext.confirmed + ) + commandsRef.current = next + setCommands(next) + const hasNewerMutation = mutationContext.pending.some( + (pending) => pending.id > mutation.id + ) + if (!hasNewerMutation) { + setError(succeeded ? null : failureMessage) + } + } + } + } + const request = mutationContext.queue.then(send, send) + mutationContext.queue = request.then( + () => undefined, + () => undefined + ) + return await request + }, + [client, loading, ready] + ) + + return { commands, loading, ready, error, persist } +} diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 25656aae2bd..9c3ebb27062 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -46,16 +46,13 @@ export function MobileSourceControlPanel({ onOpenedFileDiff }: MobileSourceControlPanelProps) { const [activeTab, setActiveTab] = useState(initialTab) - // Track first visit so Changes/History keep scroll state across segment switches - // without paying the mount cost until the user actually opens them. PR unmounts - // when inactive (WebViews / comment tree) but its controller state stays for the chip. + // Track first visit so Changes/History stay mounted (keep scroll) after first open; PR still unmounts when inactive. const [visitedTabs, setVisitedTabs] = useState>( () => new Set([initialTab]) ) const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0) - // Deep-link / push with a different `tab` param should adopt the new segment - // (expo-router can reuse the screen instance when only query params change). + // expo-router reuses the screen instance when only query params change, so adopt the new tab. useEffect(() => { setActiveTab(initialTab) setVisitedTabs((prev) => { @@ -119,13 +116,7 @@ export function MobileSourceControlPanel({ const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null const ready = screenState.kind === 'ready' - // One PR controller feeds both the branch-card chip and the Pull Request - // segment, so the chip's rollup can never disagree with the checks list it - // links to. Branch + head come from the already-loaded git.status — no second - // status read. The chip loads independently, so it never blocks the file list. - // Keep last-known identity across a transient status unload (disconnect / - // failed refresh) so the controller does not wipe ready → hidden → cold start. - // Head SHA matches the review path: status.head ?? branchCompare headOid. + // Keep last-known branch/head across a transient status unload so the PR controller isn't wiped ready → hidden → cold start. const lastPrBranchRef = useRef(null) const lastPrHeadRef = useRef(null) useEffect(() => { @@ -134,8 +125,7 @@ export function MobileSourceControlPanel({ }, [worktreeId]) const statusBranch = status?.branch ?? null const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null - // Write last-known identity in an effect, not the render body: a discarded - // concurrent render must not leave the fallback holding a never-committed value. + // Write last-known identity in an effect, not render: a discarded concurrent render must not leave the fallback stale. useEffect(() => { if (statusBranch) { lastPrBranchRef.current = statusBranch @@ -163,8 +153,7 @@ export function MobileSourceControlPanel({ const ensurePrDetailsRef = useRef(ensurePrDetails) ensurePrDetailsRef.current = ensurePrDetails - // Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until - // the Pull Request segment is open — opening SC to stage must not pull details. + // Chip bootstrap: phase-1 only (PR + checks); full payload waits for the PR segment so opening SC to stage doesn't pull details. useEffect(() => { if (activeTab === 'pr') { return @@ -174,22 +163,14 @@ export function MobileSourceControlPanel({ } }, [activeTab, prBranch, isHostedRepo, prSidebarKind]) - // Number of the ready PR whose phase-2 details are still missing (null when - // none). Keyed by PR number — not a boolean — so a same-branch PR swap during a - // chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its - // comments on a forever-spinner (a stale ensure bails on the number mismatch). - // Placeholder details (failed phase 2) also count as missing so reopening the - // PR tab retries instead of leaving empty Description/Comments forever. + // Keyed by PR number, not a boolean, so a same-branch PR swap re-arms phase 2 for the new PR. const prDetailsMissingFor = prController.prSidebarState.kind === 'ready' && prSidebarDetailsNeedFetch(prController.prSidebarState.data.details) ? prController.prSidebarState.data.pr.number : null - // PR segment: full load or phase-2 fill-in. Body only mounts while active. - // Guarded (and keyed) on prBranch so a branch that arrives while the segment is - // already open — e.g. mounted on a detached HEAD, then checkout — still loads; - // kind stays 'hidden' through that transition, so kind alone can't re-fire this. + // PR segment full load / phase-2 fill-in; key on prBranch so a branch arriving while open still loads (kind stays 'hidden'). useEffect(() => { if (activeTab !== 'pr' || !isHostedRepo || !prBranch) { return @@ -204,9 +185,7 @@ export function MobileSourceControlPanel({ }, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor]) const prChip = useMemo(() => { - // No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared - // state stays 'hidden' — which the chip would render as a forever spinner. - // Hide the chip instead; the Pull Request segment shows "branch unavailable". + // No branch (detached HEAD / mid-rebase) never loads a PR, so state stays 'hidden'; hide the chip or it spins forever. if (!isHostedRepo || !prBranch) { return null } @@ -217,9 +196,7 @@ export function MobileSourceControlPanel({ return buildMobilePrChipSummary(prController.prSidebarState, commentCount) }, [isHostedRepo, prBranch, prController.prSidebarState]) - // Design: refresh the active segment's body work, plus git.status for the shared - // branch card (counts/sync stay honest even while on History). Preserve ready - // status on a failed refresh so PR chip identity is not wiped to hidden. + // Refresh the active segment plus git.status (branch card stays honest on History); preserve ready on failure so the PR chip isn't wiped. const onRefresh = useCallback(() => { void loadStatus({ preserveReadyOnFailure: true }) if (activeTab === 'history') { @@ -237,11 +214,9 @@ export function MobileSourceControlPanel({ void refetchPr({ includeDetails: false }) }, [activeTab, isHostedRepo, loadStatus, refetchPr]) - // Embedded mode docks beside the terminal: close the dock instead of popping - // a route, and skip the full-screen safe-area chrome (the dock column owns it). + // Embedded mode docks beside the terminal: close the dock instead of popping a route; skip safe-area chrome (the dock column owns it). const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back() - // Chromeless PR body has no panel header — surface open-on-web on the hub chrome - // while the Pull Request segment is active (same affordance as the old /pr route). + // Chromeless PR body has no header, so surface open-on-web on the hub chrome while the PR segment is active. const prWebUrl = activeTab === 'pr' && prController.prSidebarState.kind === 'ready' && @@ -277,10 +252,7 @@ export function MobileSourceControlPanel({ { - // Why: retrying the request is useless while the transport's - // reconnect loop is parked at its give-up cap — revive the - // connection instead (issue #5049). loadStatus re-runs via - // its connState effect once the new client connects. + // Why: a parked reconnect loop makes retry useless — revive the connection instead (issue #5049); loadStatus re-runs on reconnect. if (connState !== 'connected' && hostId) { void forceReconnect(hostId) return @@ -294,13 +266,10 @@ export function MobileSourceControlPanel({ ) : null - // History only needs the RPC client — do not block it behind git.status. - // Changes/PR need status (branch, file list, head SHA), so they stay gated. + // History only needs the RPC client, so it isn't gated on git.status; Changes/PR need status (branch, files, head). const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes')) const showHistory = activeTab === 'history' || visitedTabs.has('history') - // Why: only mount the PR body while its segment is active. Keep-mounting retained - // Mermaid WebViews and re-rendered the comment tree on every commit keystroke. - // Controller + chip state still live for instant re-open without a full cold start. + // Only mount PR body while active: keep-mounting retained Mermaid WebViews and re-rendered the comment tree per keystroke. const showPrBody = ready && activeTab === 'pr' const conflictOperation = status?.conflictOperation ?? null // Git status always reports a conflictOperation enum; 'unknown' means none. @@ -319,10 +288,7 @@ export function MobileSourceControlPanel({ - {/* Branch card + PR chip are the Changes/Commits glance layer. On the PR - tab they duplicate the ready PR body (#, state, checks rollup, branch - trajectory), so hide the whole card there and let the PR panel own it — - unless a merge/rebase conflict is active, which only this card can abort. */} + {/* On the PR tab the branch card duplicates the ready PR body, so hide it there — unless a conflict is active, which only this card can abort. */} {ready && (activeTab !== 'pr' || hasActiveConflict) ? ( diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts index 13e43d45b43..b636ea12d3e 100644 --- a/mobile/src/storage/preferences.test.ts +++ b/mobile/src/storage/preferences.test.ts @@ -24,6 +24,7 @@ import { import { loadDefaultSessionView, loadSessionViewOverrides, + readDefaultSessionViewPreference, readSessionViewOverridesPreference, saveDefaultSessionView, updateSessionViewOverride @@ -62,6 +63,39 @@ describe('session view preference', () => { await expect(loadDefaultSessionView()).resolves.toBe('terminal') }) + it('reports an absent default as an undecided (null) preference', async () => { + vi.mocked(AsyncStorage.getItem).mockResolvedValue(null) + await expect(readDefaultSessionViewPreference()).resolves.toEqual({ + value: null, + loaded: true, + hasStoredValue: false + }) + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('chat') + await expect(readDefaultSessionViewPreference()).resolves.toEqual({ + value: 'chat', + loaded: true, + hasStoredValue: true + }) + + vi.mocked(AsyncStorage.getItem).mockResolvedValue('bogus') + await expect(readDefaultSessionViewPreference()).resolves.toEqual({ + value: null, + loaded: true, + hasStoredValue: true + }) + }) + + it('marks an unreadable default as not loaded so the opt-in gate can bail', async () => { + vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable')) + await expect(readDefaultSessionViewPreference()).resolves.toEqual({ + value: null, + loaded: false, + hasStoredValue: false + }) + await expect(loadDefaultSessionView()).resolves.toBe('terminal') + }) + it('loads and updates per-tab overrides under a host-and-worktree scoped key', async () => { vi.mocked(AsyncStorage.getItem).mockResolvedValue( JSON.stringify({ 'tab-1': 'chat', 'tab-2': 'terminal', 'tab-3': 'bogus' }) diff --git a/mobile/src/storage/session-view-preferences.ts b/mobile/src/storage/session-view-preferences.ts index 750167e3d53..cd9597d6f77 100644 --- a/mobile/src/storage/session-view-preferences.ts +++ b/mobile/src/storage/session-view-preferences.ts @@ -22,17 +22,32 @@ function clearDefaultViewWriteBarrier(barrier: Promise): void { } } -/** Global (per-device) default for how supported agent sessions open. */ -export async function loadDefaultSessionView(): Promise { +export type DefaultSessionViewPreference = { + readonly value: MobileSessionView | null + readonly loaded: boolean + readonly hasStoredValue: boolean +} + +/** Reads the raw per-device default and whether its storage key exists. */ +export async function readDefaultSessionViewPreference(): Promise { await defaultViewWriteBarrier try { const raw = await AsyncStorage.getItem(DEFAULT_SESSION_VIEW_KEY) - return raw === 'chat' || raw === 'terminal' ? raw : DEFAULT_SESSION_VIEW + return { + value: raw === 'chat' || raw === 'terminal' ? raw : null, + loaded: true, + hasStoredValue: raw !== null + } } catch { - return DEFAULT_SESSION_VIEW + return { value: null, loaded: false, hasStoredValue: false } } } +/** Global (per-device) default for how supported agent sessions open. */ +export async function loadDefaultSessionView(): Promise { + return (await readDefaultSessionViewPreference()).value ?? DEFAULT_SESSION_VIEW +} + export function saveDefaultSessionView(view: MobileSessionView): Promise { // Why: callers can outlive their route; a shared barrier keeps remounted // Settings screens from letting an older write land after a newer choice. diff --git a/mobile/src/terminal/quick-commands.test.ts b/mobile/src/terminal/quick-commands.test.ts new file mode 100644 index 00000000000..964fbc9b4d2 --- /dev/null +++ b/mobile/src/terminal/quick-commands.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalQuickCommand } from '../../../src/shared/types' +import { + buildMobileQuickCommandLaunch, + getQuickCommandDisplayPreview, + getQuickCommandPreview, + supportsMobileQuickCommands +} from './quick-commands' + +function command(overrides: Partial = {}): TerminalQuickCommand { + return { + id: 'command', + label: 'Command', + action: 'terminal-command', + command: 'pnpm test', + appendEnter: true, + scope: { type: 'global' }, + ...overrides + } as TerminalQuickCommand +} + +describe('mobile quick-command launch', () => { + it('only exposes quick commands when the host advertises the complete contract', () => { + expect(supportsMobileQuickCommands(undefined)).toBe(false) + expect(supportsMobileQuickCommands([])).toBe(false) + expect(supportsMobileQuickCommands(['terminal.binary-stream.v1'])).toBe(false) + expect(supportsMobileQuickCommands(['terminal.quick-commands.v1'])).toBe(true) + }) + + it('joins multiline runnable commands with "; " to match desktop', () => { + // Parity with desktop flattenTerminalQuickCommand: the same saved command + // must run identically on desktop and mobile. + expect( + buildMobileQuickCommandLaunch(command({ command: 'cd app\nnpm install\nnpm test' })) + ).toEqual({ + options: { + startupCommand: 'cd app; npm install; npm test', + startupCommandDelivery: 'shell-ready' + } + }) + }) + + it('forces shell-ready delivery for commands that resemble bare agent launches', () => { + expect(buildMobileQuickCommandLaunch(command({ command: 'codex exec --full-auto' }))).toEqual({ + options: { + startupCommand: 'codex exec --full-auto', + startupCommandDelivery: 'shell-ready' + } + }) + }) + + it('keeps append-enter-off commands as unsubmitted terminal input', () => { + const multiline = 'printf "first\\nsecond"\n# leave this unsubmitted' + expect( + buildMobileQuickCommandLaunch(command({ command: multiline, appendEnter: false })) + ).toEqual({ + options: { initialPrompt: multiline, enter: false, successToast: 'Command inserted' } + }) + }) + + it('injects supported agent prompts into the host-built launch command', () => { + expect( + buildMobileQuickCommandLaunch( + command({ + action: 'agent-prompt', + agent: 'codex', + prompt: 'Review this diff' + }) + ) + ).toEqual({ agent: 'codex', options: { agentPrompt: 'Review this diff' } }) + }) + + it('bounds native row text without truncating searchable or executable content', () => { + const longPrompt = `Review ${'x'.repeat(5993)}` + const agentCommand = command({ + action: 'agent-prompt', + agent: 'codex', + prompt: longPrompt + }) + + expect(getQuickCommandDisplayPreview(agentCommand)).toHaveLength(240) + expect(getQuickCommandDisplayPreview(agentCommand)).toMatch(/^Codex: Review .*…$/) + expect(getQuickCommandPreview(agentCommand)).toBe(`Codex: ${longPrompt}`) + expect(buildMobileQuickCommandLaunch(agentCommand)).toEqual({ + agent: 'codex', + options: { agentPrompt: longPrompt } + }) + }) +}) diff --git a/mobile/src/terminal/quick-commands.ts b/mobile/src/terminal/quick-commands.ts new file mode 100644 index 00000000000..40f92a55439 --- /dev/null +++ b/mobile/src/terminal/quick-commands.ts @@ -0,0 +1,105 @@ +import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types' +import { + applyTerminalQuickCommandMutation, + flattenTerminalQuickCommand, + isTerminalAgentQuickCommand, + MAX_QUICK_COMMANDS, + MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, + MAX_QUICK_COMMAND_LABEL_LENGTH, + MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH, + parseNormalizedTerminalQuickCommands, + supportsTerminalAgentQuickCommand, + terminalQuickCommandMatchesRepo, + type TerminalQuickCommandMutation +} from '../../../src/shared/terminal-quick-commands' +import { TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { MOBILE_TUI_AGENT_LABELS } from '../tasks/mobile-tui-agents' + +// Reuse the canonical desktop quick-command logic (pure, no heavy deps) so +// mobile behaves identically to desktop. Only genuinely mobile-specific pieces +// — agent-branded labels, native row truncation, and the launch plan — live here. +export { + isTerminalAgentQuickCommand as isAgentQuickCommand, + terminalQuickCommandMatchesRepo as quickCommandMatchesRepo, + supportsTerminalAgentQuickCommand, + MAX_QUICK_COMMANDS, + MAX_QUICK_COMMAND_AGENT_PROMPT_LENGTH, + MAX_QUICK_COMMAND_LABEL_LENGTH, + MAX_QUICK_COMMAND_TERMINAL_TEXT_LENGTH, + parseNormalizedTerminalQuickCommands, + applyTerminalQuickCommandMutation, + type TerminalQuickCommandMutation +} + +const MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH = 240 + +export function supportsMobileQuickCommands(capabilities: readonly string[] | undefined): boolean { + return capabilities?.includes(TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY) === true +} + +export type MobileQuickCommandLaunch = { + agent?: TuiAgent + options: { + agentPrompt?: string + startupCommand?: string + startupCommandDelivery?: 'shell-ready' + initialPrompt?: string + enter?: boolean + successToast?: string + } +} + +export function buildMobileQuickCommandLaunch( + command: TerminalQuickCommand +): MobileQuickCommandLaunch | null { + if (isTerminalAgentQuickCommand(command)) { + if (!command.prompt.trim() || !supportsTerminalAgentQuickCommand(command.agent)) { + return null + } + return { agent: command.agent, options: { agentPrompt: command.prompt } } + } + if (!command.command.trim()) { + return null + } + return command.appendEnter === false + ? { + options: { + initialPrompt: command.command, + enter: false, + successToast: `${command.label.trim() || 'Quick command'} inserted` + } + } + : { + // Why: raw commands that resemble bare agent launches can otherwise + // select the fast path and race slow native, WSL, or SSH shell startup. + // flattenTerminalQuickCommand joins multiline bodies exactly as desktop + // does so the same saved command runs identically on both. + options: { + startupCommand: flattenTerminalQuickCommand(command).command, + startupCommandDelivery: 'shell-ready' + } + } +} + +export function getQuickCommandAgentLabel(agent: TuiAgent): string { + return MOBILE_TUI_AGENT_LABELS[agent] ?? agent +} + +// The subtitle desktop shows under each quick command: agent prompts read +// "Codex: ", terminal commands show the raw command text. +export function getQuickCommandPreview(command: TerminalQuickCommand): string { + if (isTerminalAgentQuickCommand(command)) { + return `${getQuickCommandAgentLabel(command.agent)}: ${command.prompt}` + } + return command.command +} + +export function getQuickCommandDisplayPreview(command: TerminalQuickCommand): string { + const preview = getQuickCommandPreview(command) + if (preview.length <= MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH) { + return preview + } + // Why: one-line rows should not send up to 6 KB each through native text + // layout; full command bodies remain available to search, edit, and launch. + return `${preview.slice(0, MAX_QUICK_COMMAND_DISPLAY_PREVIEW_LENGTH - 1)}…` +} diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index e54c7c47737..6194fabc675 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -28,14 +28,9 @@ type TerminalViewportRefitOptions = { initializedHandlesRef: RefObject> connState: ConnectionState tabStripVisible: boolean - // Why: terminal text size (font scale) — changing it changes the cell size, so - // the PTY must be re-fitted to a new column count and reflowed. + // Why: text size (font scale); changing it changes cell size, so the PTY must be re-fitted to a new column count. textScale: number - // Why: the terminal's measured frame width changes when a side panel docks/undocks - // or EITHER sidebar is drag-resized (the left worktree sidebar shrinks the detail - // pane; the right dock takes a slice of the row) — all without any window-dim or - // tab-strip change. Carries that measured width so those resizes re-fit the PTY; - // the 150ms debounce coalesces the stream of drag widths into one settle-time refit. + // Why: measured frame width; panel dock/undock or sidebar resize changes it with no window/tab change, so it re-fits the PTY. terminalFrameWidth: number unsubscribeTerminal: (handle: string) => void subscribeToTerminal: (handle: string) => void @@ -46,12 +41,7 @@ type TerminalViewportRefitNotifications = { notifyKeyboardVisibility: (visible: boolean) => void } -// Why: re-measure the phone viewport when layout-affecting state changes -// outside the subscribe path — the tab strip toggling visibility, and the -// window itself resizing (fold/unfold on foldables, orientation rotation, -// split-screen). Without the resize trigger, a PTY fitted on the folded -// cover screen stays at cover-screen cols after unfolding and the terminal -// renders in only part of the display (#4579's "cut in half" symptom). +// Why: re-measure on layout changes outside the subscribe path (tab strip, fold/rotate/resize), or a PTY renders "cut in half" (#4579). export function useTerminalViewportRefit( options: TerminalViewportRefitOptions ): TerminalViewportRefitNotifications { @@ -82,9 +72,7 @@ export function useTerminalViewportRefit( keyboardVisible: false, pending: false }) - // Why: marks the currently-armed timer as a height refit so its callback can - // re-check the keyboard at fire time. Non-height refits (width/rotation and - // the forced reconnect/foreground re-asserts) stay unguarded so they always run. + // Why: marks the armed timer as a height refit so its callback re-checks the keyboard; other refits always run unguarded. const heightOriginatedRefitRef = useRef(false) const scheduleViewportRefit = useCallback( (options?: { heightOriginated?: boolean }) => { @@ -94,9 +82,7 @@ export function useTerminalViewportRefit( heightOriginatedRefitRef.current = options?.heightOriginated ?? false refitTimerRef.current = setTimeout(() => { refitTimerRef.current = null - // Why: a height refit deferred at keyboard-close can fire after the keyboard - // reopened within the 150ms debounce; re-check and re-defer so we never - // reflow the PTY mid-keystroke. Scoped via the height-originated flag. + // Why: a height refit can fire after the keyboard reopened within the debounce; re-check so we never reflow the PTY mid-keystroke. if (heightOriginatedRefitRef.current) { heightOriginatedRefitRef.current = false const decision = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, { @@ -143,11 +129,7 @@ export function useTerminalViewportRefit( } viewportRef.current = dims viewportMeasuredRef.current = true - // Why: prefer the in-place viewport update RPC over the legacy - // unsubscribe → subscribe cycle. This keeps the server-side - // mobile subscriber record alive (no driver=idle blip on the - // desktop banner; no false phone-fit baseline capture on the - // re-subscribe). See docs/mobile-presence-lock.md. + // Why: prefer in-place updateViewport over resubscribe to keep the mobile subscriber record alive. See docs/mobile-presence-lock.md. const rpc = clientRef.current const deviceToken = deviceTokenRef.current if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') { @@ -165,11 +147,7 @@ export function useTerminalViewportRefit( if (isTerminalUpdateViewportUpdated(response)) { rpc.updateTerminalSubscriptionViewport(handle, dims) if (isTerminalUpdateViewportApplied(response)) { - // Why: updateViewport reflows the server PTY and re-streams only - // the visible screen, so the WebView's local xterm scrollback - // stays wrapped at the old width. Reflow it locally only when - // the server actually applied phone-fit; desktop mode records - // the viewport but leaves the PTY at desktop dims. + // Why: updateViewport re-streams only the visible screen, so local scrollback stays wrapped at the old width — reflow it locally. ref.reflow(dims.cols, dims.rows) } return @@ -205,14 +183,7 @@ export function useTerminalViewportRefit( scheduleViewportRefit() }, [scheduleViewportRefit]) - // Why: the tab strip is hidden when only one terminal exists and shown - // once a second is created. Crossing the 1↔2 boundary changes the - // visible terminal area by ~40px, so the cached viewport dims in - // viewportRef become stale. Mark the viewport as un-measured so the - // next subscribe path's self-correcting loop (init → measure → - // resubscribe-with-fresh-viewport) re-runs against the new layout. - // Also schedule an explicit refit to cover the case where no new - // subscribe is happening. + // Why: the tab strip toggles at the 1↔2 terminal boundary (~40px area change), so the cached viewport goes stale. const prevTabStripVisibleRef = useRef(tabStripVisible) useEffect(() => { if (prevTabStripVisibleRef.current === tabStripVisible) { @@ -223,10 +194,7 @@ export function useTerminalViewportRefit( scheduleViewportRefit() }, [tabStripVisible, viewportMeasuredRef, scheduleViewportRefit]) - // Why: fold/unfold and rotation change the window dimensions without any - // subscribe or tab-strip transition. The PTY must be re-fitted to the new - // viewport or the terminal keeps the old grid (fit scale is capped at 1, - // so a grown window leaves the surface pinned to a fraction of the screen). + // Why: fold/unfold and rotation change window dims with no subscribe/tab change; refit or the grid stays stale (fit capped at 1). const { width: windowWidth, height: windowHeight } = useWindowDimensions() const prevWindowDimsRef = useRef({ width: windowWidth, height: windowHeight }) useEffect(() => { @@ -235,8 +203,7 @@ export function useTerminalViewportRefit( return } prevWindowDimsRef.current = { width: windowWidth, height: windowHeight } - // Why: adjustResize can change only window height while the IME is open; - // the frame-height notifier schedules one correction after it closes. + // Why: adjustResize can change only window height while the IME is open; the frame-height notifier corrects once it closes. if (prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible) { return } @@ -244,10 +211,7 @@ export function useTerminalViewportRefit( scheduleViewportRefit() }, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit]) - // Why: the text size changed, so the WebView is re-rendering at a new font/cell - // size. Re-measure and resize the PTY so the server reflows to the new column - // count. The refit's own 150ms debounce gives the WebView a frame to apply the - // new fontSize before we measure the resulting cell metrics. + // Why: on text-size change the refit's 150ms debounce lets the WebView apply the new fontSize before we re-measure cell metrics. const prevTextScaleRef = useRef(textScale) useEffect(() => { if (prevTextScaleRef.current === textScale) { @@ -258,10 +222,7 @@ export function useTerminalViewportRefit( scheduleViewportRefit() }, [textScale, viewportMeasuredRef, scheduleViewportRefit]) - // Why: the terminal's measured frame width changes when a panel docks/undocks or - // either sidebar is drag-resized — none of which touch the window dims or tab - // strip — so the cached viewport goes stale and the PTY keeps the pre-resize - // width. Mark un-measured and refit when the measured width changes. + // Why: panel dock/undock or sidebar resize changes frame width with no window/tab change, so the cached viewport goes stale. const prevFrameWidthRef = useRef(terminalFrameWidth) useEffect(() => { if (prevFrameWidthRef.current === terminalFrameWidth) { @@ -284,8 +245,7 @@ export function useTerminalViewportRefit( }, [viewportMeasuredRef, scheduleViewportRefit] ) - // Why: notify imperatively so layout churn does not rerender the full session; - // a height change during typing is coalesced into one refit after keyboard close. + // Why: notify imperatively so layout churn doesn't rerender the full session. const notifyTerminalFrameHeight = useCallback( (height: number) => notifyFrameHeightRefitEvent({ type: 'frame-height', height }), [notifyFrameHeightRefitEvent] @@ -310,8 +270,7 @@ export function useTerminalViewportRefit( if (!shouldRefit) { return } - // Why: the cached grid can match while the host PTY changed in background; - // reasserting equal dimensions is the convergence signal after iOS resume. + // Why: cached grid can match while the host PTY changed in background; reassert equal dims to converge after iOS resume. viewportMeasuredRef.current = false scheduleForcedViewportRefit() }) @@ -325,11 +284,9 @@ export function useTerminalViewportRefit( if (previous === 'connected' || connState !== 'connected') { return } - // Why: an in-place desktop upgrade may add updateViewport; reconnect is the - // narrow boundary where an old-host method_not_found cache becomes stale. + // Why: an in-place desktop upgrade may add updateViewport; reconnect is where the cached method_not_found goes stale. updateViewportCapabilityRef.current = 'unknown' - // Why: reconnect can restore a PTY whose host-side size changed while the - // socket was down, so equal cached dimensions still need reassertion. + // Why: reconnect can restore a PTY resized while the socket was down, so equal cached dims still need reassertion. viewportMeasuredRef.current = false scheduleForcedViewportRefit() }, [connState, viewportMeasuredRef, scheduleForcedViewportRefit]) diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index e9d188b74ef..a4d9c350c47 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,5 +1,4 @@ -// xterm.js WebView document + default Tokyonight theme. Extracted from -// TerminalWebView.tsx to keep that file within the max-lines budget. +// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget. import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import { colors } from '../theme/mobile-theme' import { TERMINAL_TEXT_SCALES } from '../storage/preferences' @@ -38,15 +37,7 @@ const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { brightWhite: '#c0caf5' } -// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor -// positioning designed for the desktop's terminal dimensions (~150+ cols). -// We initialize xterm at the desktop's exact cols/rows so those escape codes -// render correctly, then use a measured CSS transform: scale() to fit the -// canvas into the phone viewport. The scale is computed after xterm opens -// by measuring the rendered surface width, not hardcoded, so it adapts to -// any terminal column count (80, 150, 200+). All touch gestures (scroll, -// pinch-to-zoom, pan) are handled by custom JS rather than native WebView -// behavior, so they work correctly with the CSS scale transform. +// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing. export const XTERM_HTML = ` @@ -1892,6 +1883,5 @@ ${TERMINAL_WEBGL_RECOVERY_JS} ` -// Why: WebView treats source identity as page identity on some platforms; keep -// parent/session re-renders from reloading xterm and forcing fresh snapshots. +// Why: some WebViews treat source identity as page identity; keep this stable so re-renders don't reload xterm. export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index d9e07be4033..80ec7f11c1f 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -1,14 +1,5 @@ -// Why: collapses the per-screen WebSocket connection model into a single -// shared RpcClient per host. Implements the design in -// docs/mobile-shared-client-per-host.md. -// -// Lifecycle rules: -// - First request for a host opens its client lazily. -// - Refcount tracks active subscribers; when it drops to zero we schedule -// a 30-second idle close timer. If a new subscriber arrives within that -// window we cancel and reuse the same client. -// - removeHost() forces an immediate close so re-pairing gets a fresh -// transport. +// Single shared RpcClient per host, collapsing the old per-screen WebSocket connections. +// Design: docs/mobile-shared-client-per-host.md. import { createContext, useCallback, @@ -42,40 +33,28 @@ export type RpcClientContextValue = { closeHost: (hostId: string) => void getState: (hostId: string) => ConnectionState getReconnectAttempt: (hostId: string) => number - // Why: timestamp (ms epoch) of the last successful 'connected' state - // transition for this host, or null if never connected this session. - // Used by the UI to escalate "Reconnecting…" into a "host appears - // unreachable, re-pair?" prompt. + // Why: ms-epoch of the last 'connected' (null if never this session); UI escalates "Reconnecting…" into a re-pair prompt. getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void getAllClients: () => Array<{ hostId: string; client: RpcClient }> subscribeAllHosts: (listener: () => void) => () => void - // Why: lets the home screen feed already-loaded HostProfiles in so we - // don't pay loadHosts() latency twice (once in the focus-effect, again - // inside openEntry). + // Why: lets the home screen feed already-loaded HostProfiles so we don't pay loadHosts() latency twice. primeHosts: (hosts: HostProfile[]) => void } const Ctx = createContext(null) export function RpcClientProvider({ children }: { children: ReactNode }) { - // Why: entries live in a ref so updates don't force re-renders of the - // entire tree on every connection state change. State propagation goes - // through per-host listener Sets instead. + // Why: entries in a ref so state changes don't re-render the whole tree; propagation goes through per-host listener Sets. const storeRef = useRef>(new Map()) const stateListenersRef = useRef void>>>(new Map()) const allHostsListenersRef = useRef void>>(new Set()) - // Pending opens (avoid two acquire() callers in the same render racing the - // host lookup). Keyed by hostId, value is a sentinel resolved when the - // entry materialises. + // Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup. const pendingOpensRef = useRef(new HostClientOpenRegistry()) - // Why: a fast-path cache of already-loaded HostProfiles. Screens that - // have run loadHosts() can call primeHosts() to populate this and skip - // the second loadHosts() inside openEntry. Without this we'd serialize - // two Keychain passes on cold start. + // Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start. const primedHostsRef = useRef>(new Map()) function notifyHostState(hostId: string, state: ConnectionState) { @@ -118,28 +97,20 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const pendingOpen = pendingOpensRef.current.register(hostId, promise) try { - // Why: prefer the primed cache (populated by primeHosts when the - // screen already ran loadHosts) so we don't serialize a second - // Keychain pass behind the first one on cold start. + // Why: prefer the primed cache so we don't serialize a second Keychain pass on cold start. let host = primedHostsRef.current.get(hostId) if (!host) { try { const hosts = await loadHosts() host = hosts.find((h) => h.id === hostId) } catch { - // Why: a Keychain failure on cold start (rare but observed — - // happens when iOS Keychain is mid-unlock or Android Keystore - // races the JS bridge). Surface it as 'disconnected' so the - // home card flips off the perma-spinner and the user can hit - // Reconnect from the action sheet to retry. + // Why: cold-start Keychain failure (iOS mid-unlock / Android Keystore race); surface 'disconnected' so the user can Reconnect. notifyHostState(hostId, 'disconnected') notifyAllHosts() return null } if (!host) { - // Why: returning silently leaves mounted screens on a permanent - // spinner (STA-1511) — surface 'disconnected' so they can render - // their waiting/retry affordance instead. + // Why: silent return leaves screens on a permanent spinner (STA-1511); surface 'disconnected' so they show a retry affordance. notifyHostState(hostId, 'disconnected') notifyAllHosts() return null @@ -160,9 +131,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { try { client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry)) } catch { - // Why: connect() can throw synchronously if the public key is - // malformed or the endpoint URL is invalid. Notify so the UI - // doesn't sit on a stale 'connecting' label forever. + // Why: openHostLogicalClient can throw synchronously (bad public key / invalid URL); notify so the UI leaves 'connecting'. notifyHostState(hostId, 'disconnected') notifyAllHosts() return null @@ -191,12 +160,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { } }, []) - // Why: `acquire` is the synchronous get-or-open. If the entry already - // exists, return its client immediately and bump the refcount. If not, - // kick off an async open (the consumer will subscribe via - // `subscribeHostState` and re-read once 'connecting' fires). Optionally - // accepts the HostProfile so the caller can avoid an extra loadHosts() - // pass inside openEntry. + // Synchronous get-or-open: returns an existing client immediately, else kicks off an async open and returns null this tick. const acquire = useCallback( (hostId: string, host?: HostProfile): RpcClient | null => { if (host) { @@ -207,9 +171,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { existing.refCount += 1 return existing.client } - // Trigger async open. The acquire-side will return null this tick and - // try again once the state listener fires; consumers are expected to - // call acquire() inside an effect that re-runs on state changes. + // Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes. void openEntry(hostId).then((entry) => { if (!entry) { return @@ -227,14 +189,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { } }, []) - // Why: refcount dropping to 0 no longer triggers an idle-close. The - // app deliberately keeps live WebSockets open while the app itself is - // foregrounded — closing on transient navigation gaps was producing - // false 'disconnected' flashes when the user navigated home → host → - // back to home faster than React could re-acquire on the home side. - // Connections still close on: explicit user Disconnect, host removal, - // app backgrounding (OS-level socket suspension), and provider - // unmount (app shutdown). + // Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded. const release = useCallback((hostId: string) => { const entry = storeRef.current.get(hostId) if (!entry) { @@ -246,10 +201,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const forceReconnect = useCallback( async (hostId: string) => { const entry = storeRef.current.get(hostId) - // Why: preserve refcount across the swap. If the user reaches - // forceReconnect via the Disconnect → Reconnect path, the entry - // was already closed and refCount=0; fall back to active listener - // count as a proxy for "screens still watching this host." + // Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count. const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) if (entry) { @@ -318,16 +270,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { } }, []) - // Close all clients on provider unmount (app shutdown). - // Why: deps must be empty so this cleanup ONLY runs on real unmount. - // Hot-reload re-evaluates this module, which makes closeEntry a new - // function identity. With [closeEntry] as deps, every Fast Refresh - // would tear down all open WebSockets, leaving screens holding closed - // clients and the user staring at a 'Reconnecting…' card. Reading - // storeRef.current and the locally-scoped closeEntry inside the - // cleanup is safe — the ref is stable across renders, and the - // function captured here will be the one defined in the same - // closure as this effect. + // Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const store = storeRef.current @@ -339,9 +282,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { } }, []) - // Why: nudge every live client when the OS signals the link may be back - // (foreground, network restored/switched) so sessions recover without an - // app restart (issue #5049). + // Why: nudge live clients when the OS signals the link may be back so sessions recover without a restart (issue #5049). useEffect(() => { return subscribeConnectionRevivalTriggers(() => { for (const entry of storeRef.current.values()) { @@ -392,9 +333,7 @@ export function useRpcClientContext(): RpcClientContextValue { return ctx } -// Why: the primary hook for screens. Acquires the shared client for a -// hostId on mount and releases on unmount. Re-renders when the host's -// connection state changes. +// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change. export function useHostClient(hostId: string | undefined): { client: RpcClient | null state: ConnectionState @@ -419,17 +358,13 @@ export function useHostClient(hostId: string | undefined): { return } setState(next) - // Why: the client materialises after an async open, and forceReconnect - // swaps in a fresh client object. Re-read on every state change so a - // mounted screen never keeps driving a stale (closed) client. + // Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one. const found = ctx.getAllClients().find((entry) => entry.hostId === hostId) if (found && found.client !== clientRef.current) { clientRef.current = found.client force((n) => n + 1) } else if (!found && clientRef.current) { - // Why: closeHost deletes the entry without a replacement; holding the - // closed client would let screens keep issuing requests that can never - // resolve (STA-1511). Null it so they render disconnected states. + // Why: closeHost deletes the entry with no replacement; null it so screens don't drive a dead client (STA-1511). clientRef.current = null force((n) => n + 1) } @@ -450,9 +385,7 @@ export function useHostClient(hostId: string | undefined): { return { client: clientRef.current, state } } -// Why: home screen renders all paired hosts at once. Acquires each on -// mount, releases on unmount. The provider's refcounting ensures we -// don't double-open if a host-detail screen is also open. +// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. export function useAllHostClients(hostIds: string[]): Array<{ hostId: string client: RpcClient @@ -510,9 +443,7 @@ export function useAllHostClients(hostIds: string[]): Array<{ }, [key, tick]) } -// Why: removeHost() in host-store.ts must close the live client, but -// host-store has no React-side handle. Expose a hook that lets callers -// close a host after removal. +// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it. export function useCloseHost(): (hostId: string) => void { const ctx = useRpcClientContext() return ctx.closeHost @@ -524,9 +455,7 @@ export function useForceReconnect(): (hostId: string) => Promise { return ctx.forceReconnect } -// Why: lets the home screen feed already-loaded HostProfiles in so the -// provider can skip its own loadHosts() pass when it eventually opens -// each host — collapses two serial Keychain reads on cold-start into one. +// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start. export function usePrimeHosts(): (hosts: HostProfile[]) => void { const ctx = useRpcClientContext() return ctx.primeHosts diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index a404379e557..61ed6742d12 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -23,16 +23,12 @@ import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upg import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' const STORAGE_KEY = 'orca:hosts' -// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected. -// Use dots as the separator so the key shape stays readable while -// satisfying the validator. +// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. const TOKEN_KEY_PREFIX = 'orca.host-token.' const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' -// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off -// iCloud Keychain and out of iCloud/iTunes backup restores onto a -// different physical device. Reads/writes are silent (no biometric -// prompt) since we don't request access control flags. +// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device. +// Reads/writes stay silent (no biometric prompt) because we don't request access control flags. const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY } @@ -46,8 +42,7 @@ function webTokenKey(hostId: string): string { } async function readDeviceToken(hostId: string): Promise { - // Why: Expo SecureStore has no working web backend; keep this fallback - // web-only so native builds still keep pairing tokens in the keychain. + // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. if (Platform.OS === 'web') { return AsyncStorage.getItem(webTokenKey(hostId)) } @@ -76,18 +71,10 @@ async function deleteHostCredentials(hostId: string): Promise { await deleteMobileRelayDirectUpgradeJournal(hostId) } -// Why: SecureStore reads on Android Keystore can take 50-200ms each, and -// loadHosts() is called from every screen mount + every useFocusEffect. -// Stack with N hosts and you get N*200ms blocking every navigation, which -// triggers connection-churn cycles in the home-screen useEffect. Cache -// per-hostId in memory; invalidate only on save/remove. The cache lives -// for the JS-runtime lifetime, which matches AsyncStorage semantics -// (cleared on app uninstall, persisted across foreground/background). +// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove. const tokenCache = new Map() let inflightLoad: Promise | null = null -// Why: rename / lastConnected / remove / save all RMW the same hosts JSON. -// Without a queue, concurrent writers re-read a stale snapshot and the last -// setItem wins — resurrecting a removed host or dropping a rename. +// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename). let hostListMutation: Promise = Promise.resolve() function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { @@ -100,9 +87,7 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { return null } return parsed.flatMap((item) => { - // Why: pre-v0.0.3 records carry the deviceToken in AsyncStorage. - // Drop them silently — the three pre-launch users will re-pair on - // first run rather than carry a migration shim through the auth path. + // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim. if (item && typeof item === 'object' && 'deviceToken' in item) { return [] } @@ -115,11 +100,9 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { } export async function loadHosts(): Promise { - // Why: writers hold the mutation chain across their full RMW; wait so a - // load right after rename/remove does not race a half-written list. + // Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list. await hostListMutation - // Why: deduplicate concurrent loadHosts() calls so multiple screens - // mounting simultaneously share one Keychain read pass. + // Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass. if (inflightLoad) { return inflightLoad } @@ -152,16 +135,11 @@ async function doLoadHosts(): Promise { try { fetched = await readDeviceToken(stored.id) } catch { - // Why: a transient Keychain failure for one entry (e.g. - // errSecInteractionNotAllowed while the device is briefly locked, - // or a single corrupt record) must not blank the entire host list. - // Skip just this host — it'll reappear on the next load. + // Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it. continue } if (!fetched) { - // Why: orphaned metadata with no matching keychain entry — most - // likely a stale record from a development install. Skip it - // rather than surface a half-broken host. + // Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host. continue } token = fetched @@ -187,8 +165,7 @@ export async function resolvePairingHostIdentity( publicKeyB64: string, newHostId: string ): Promise<{ id: string; name: string }> { - // Why: one durable read both preserves an existing identity and names a new host, - // avoiding duplicate cards and a second serial storage read before connecting. + // Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards. await hostListMutation const hosts = await readStoredHostsForMutation() const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64) @@ -201,8 +178,7 @@ async function readStoredHostsForMutation(): Promise { try { const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) if (!parsed) { - // Why: refuse to RMW over unreadable payload — treating it as [] would - // wipe the durable host list on the next rename/remove/save. + // Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write. throw new Error('host list storage unreadable') } return parsed @@ -260,8 +236,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } if (index >= 0) { updatedExistingHost = true - // Why: affected installs may already contain duplicate rows; an authoritative - // save is the safe point to collapse them to the preserved host id. + // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. return hosts .filter(({ id }) => !duplicateHostIds.has(id)) .map((candidate) => (candidate.id === stored.id ? stored : candidate)) @@ -272,11 +247,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] }) - // Why: write metadata BEFORE the keychain token so a crash between the two - // leaves orphaned metadata (which loadHosts skips and removeHost can clean - // up) rather than an orphaned keychain token with no metadata pointer — - // the latter would persist forever since removeHost only deletes by hostId - // from current metadata. + // Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever. await writeDeviceToken(stored.id, validated.deviceToken) tokenCache.set(stored.id, validated.deviceToken) if (validated.endpoints) { @@ -293,8 +264,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise overlayRemovalIds.push(stored.id) } if (overlayRemovalIds.length > 0) { - // Why: reusing an id for direct-only re-pairing must not retain routing - // metadata from the host's previous transport state. + // Why: reusing an id for direct-only re-pairing must not retain routing metadata from the previous transport state. await removeMobileRelayHostOverlays(overlayRemovalIds) } for (const duplicateHostId of duplicateHostIds) { @@ -313,11 +283,9 @@ export async function removeHost(hostId: string): Promise { try { await removeMobileRelayHostOverlay(hostId) } catch { - // The missing legacy base is authoritative, so a retained overlay cannot - // resurrect this host and can be cleaned on a later explicit retry. + // Base removal is authoritative; a retained overlay can't resurrect the host and is cleaned on a later retry. } - // Why: await only the durable cleanup intent (AsyncStorage). Native keychain - // delete can reject or stall and must not freeze removeHost / the UI. + // Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI. try { await scheduleHostCredentialCleanup(hostId, deleteHostCredentials) } catch { @@ -333,10 +301,7 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ return retryPendingHostCredentialCleanups(deleteHostCredentials) } -// Why: Edit host can change name and endpoint together; a single -// mutateStoredHosts pass keeps both fields committed atomically so a -// mid-save failure can never persist one change without the other, and a -// host removed mid-edit throws consistently instead of silently no-oping. +// Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other. export async function updateHostNameAndEndpoint( hostId: string, updates: { name?: string; endpoint?: string } @@ -368,9 +333,7 @@ export async function updateLastConnected(hostId: string): Promise { return next }) } catch { - // Why: last-connected is a best-effort timestamp and callers fire it with - // `void`. Swallow unreadable-storage failures so they don't surface as an - // unhandled promise rejection. + // Why: best-effort timestamp fired with void; swallow so unreadable storage doesn't reject. } } diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 5420ba679a6..578e1a1ba0b 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -79,74 +79,36 @@ export type RpcClient = { viewport: { cols: number; rows: number } ) => void getState: () => ConnectionState - // Why: UI escalates "Reconnecting…" to "Can't connect" once attempts cross - // a threshold. 0 means never failed; counter is reset on successful open. + // 0 means never failed (reset on successful open); the UI escalates "Reconnecting…" to "Can't connect" past a threshold. getReconnectAttempt: () => number - // Why: timestamp (ms epoch) of the last time we reached 'connected'. - // null = never connected since the client was created. Used by the UI - // to distinguish "host moved/never reachable" from "transient blip". + // Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip". getLastConnectedAt: () => number | null onStateChange: (listener: (state: ConnectionState) => void) => () => void - // Why: app-resume hook. Android/iOS can kill the TCP path or park the - // reconnect loop while the app is backgrounded; callers invoke this on - // AppState 'active' so the session recovers without an app restart. + // Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover. notifyForeground: () => void close: () => void } -// Why: tiered backoff. The first four entries (500ms→4s) keep -// auto-recovery snappy for the common case — a brief Wi-Fi blip, -// laptop wake, or AP-isolation cycle. Beyond that we slow down -// (8s→60s) so a phone whose desktop is genuinely unreachable doesn't -// burn a TCP SYN every 4s indefinitely while still healing on its -// own when the network recovers. With 12 total attempts, the last -// four reuse the 60s cap (Math.min(idx, length-1)), so total elapsed -// time across all 12 attempts is ≈ 6 minutes before the give-up cap -// fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s). +// Why: tiered backoff — fast early entries recover blips; the slow tail avoids burning a SYN every 4s on an unreachable desktop. const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] -// Why: cap fast auto-retry once we're clearly unreachable for a long time. -// With the tiered backoff above this is ≈ 6 minutes of continuous -// failure before the UI surfaces the re-pair banner. The longer -// runway tolerates flaky AP-isolation routers and laptop sleep cycles -// that briefly drop the LAN path. MUST stay aligned with -// connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable" -// verdict matches the moment the loop slows to the trickle cadence. +// Why: ≈6 min of failure before the re-pair banner; MUST stay aligned with connection-health.ts UNREACHABLE_ATTEMPTS. const GIVE_UP_AFTER_ATTEMPTS = 12 -// Why: past the cap the loop must never park permanently. A wedged -// Tailscale/VPN tunnel produces no AppState or network-type transition -// (still Wi-Fi, still "online"), so no revival nudge ever fires — users -// had to toggle Tailscale off/on just to force one. A slow trickle dial -// self-heals once the tunnel recovers while staying cheap: one TCP -// attempt per 90s, foreground-only (iOS/Android suspend JS timers in -// the background). +// Why: never park past the cap — a wedged VPN fires no AppState/network nudge to revive it, so trickle-dial every 90s to self-heal. const TRICKLE_RECONNECT_DELAY_MS = 90_000 -// Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead. -// Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair -// while the desktop still listed it as paired with a valid token — a transient -// rejection (mid-session resume race, a stale frame after background) latched -// the terminal auth-failed state permanently. Retry the full handshake this -// many times with a clean reconnect before declaring auth dead. A genuinely -// revoked token is rejected on every attempt and converges to auth-failed in -// seconds; a one-off glitch self-heals without the user re-pairing. +// Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed. const AUTH_RETRY_BUDGET = 3 const REQUEST_TIMEOUT_MS = 30_000 const CONNECT_TIMEOUT_MS = 12_000 const HANDSHAKE_TIMEOUT_MS = 5_000 -// Why: RN's WebSocket implementation may not expose static readyState -// constants, but the protocol value for CONNECTING is stable across runtimes. +// Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes. const WEBSOCKET_CONNECTING_STATE = 0 -// Why: RN auto-pongs WebSocket pings natively, so JS needs an app-level -// liveness probe to detect half-open sockets. Any inbound app traffic after -// a probe starts proves the link is alive; otherwise an unanswered probe -// force-closes the socket so the reconnect path can recover. +// Why: RN auto-pongs pings natively, so JS needs an app-level probe to detect half-open sockets. const ACTIVITY_PROBE_INTERVAL_MS = 20_000 export type ConnectOptions = { onStateChange?: (state: ConnectionState) => void - // Fires for every observable lifecycle event so the UI can render a - // detailed connection log. Useful when 'Connecting…' hangs forever - // (e.g. broken Tailscale route) and you need to see *where* it's stuck. + // Fires for every lifecycle event so the UI can show where 'Connecting…' is stuck (e.g. broken Tailscale route). onLog?: ConnectionLogSink } @@ -185,14 +147,10 @@ export function connect( let handshakeTimer: ReturnType | null = null let activityProbeTimer: ReturnType | null = null let intentionallyClosed = false - // Why: consecutive auth rejections since the last successful connect. We - // tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching auth-failed - // so a transient rejection doesn't force a needless re-pair. Reset to 0 on - // every 'connected'. + // Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair. let authRejectionCount = 0 let lastConnectedAt: number | null = null - // Why: cheap diagnostics for RN/OkHttp process-state poisoning: do retry - // attempts differ, is anything inbound, and are closes instant or slow? + // Why: cheap diagnostics for RN/OkHttp process-state poisoning (retry cadence, inbound traffic, close timing). let lastInboundAt: number | null = null let inboundSequence = 0 let lastWsClosedAt: number | null = null @@ -200,7 +158,6 @@ export function connect( let currentWsOpenedAt: number | null = null // Why: fresh ephemeral keypair per connection provides forward secrecy. - // The shared key is derived from our ephemeral secret + server's static public key. let sharedKey: Uint8Array | null = null const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64) @@ -218,9 +175,7 @@ export function connect( stateListeners.add(onStateChange) } - // Diagnostic: tracks how long we've been in the current state. Useful - // for spotting "stuck in connecting" or "stuck in reconnecting" cases - // in the logs. + // Diagnostic: dwell time in the current state, for spotting "stuck in connecting/reconnecting". let stateEnteredAt = Date.now() function rejectConnectWaiters(reason: string) { @@ -250,8 +205,7 @@ export function connect( }) if (next === 'connected') { lastConnectedAt = Date.now() - // Why: a clean handshake proves the token is valid — clear the auth - // retry budget so a future isolated rejection gets the full budget again. + // Why: a clean handshake proves the token is valid — reset the auth retry budget. authRejectionCount = 0 for (const waiter of connectWaiters.splice(0)) { if (waiter.timeout) { @@ -269,8 +223,7 @@ export function connect( } } - // Why: don't dump device tokens / full URLs into log scrolls; truncate to - // the host:port so reconnect lifecycles are still readable. + // Why: keep device tokens / full URLs out of log scrolls — truncate to host:port. function redactedEndpoint(ep: string): string { try { const m = ep.match(/^wss?:\/\/([^/]+)/i) @@ -288,17 +241,13 @@ export function connect( return Promise.reject(new Error('Client closed')) } if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { - // Why: past the retry cap the loop only trickles every 90s — callers - // must fail fast rather than hang on a host that's been unreachable - // for minutes. A trickle dial that succeeds flips state to 'connected' - // and later requests go through normally. + // Why: past the cap the loop only trickles every 90s — fail fast instead of hanging on a long-unreachable host. return Promise.reject(new Error('Connection retry limit reached')) } return new Promise((resolve, reject) => { const waiter: ConnectWaiter = { resolve, reject, timeout: null } if (timeoutMs !== undefined) { - // Why: explicit per-request timeouts must include offline/reconnect - // waiting, not only the RPC after the socket becomes connected. + // Why: per-request timeouts must cover offline/reconnect waiting, not just the RPC after connect. waiter.timeout = setTimeout( () => { const index = connectWaiters.indexOf(waiter) @@ -328,11 +277,7 @@ export function connect( console.log('[net] openConnection', { attempt: reconnectAttempt, endpoint: redactedEndpoint(endpoint), - // Why: process-poisoning diagnostic. If wsCount is high (e.g. >50) - // and every recent open fails with 1006, suspect RN/OkHttp internal - // pool corruption that only force-quit clears. Compare msSinceLast* - // values to the failure cadence: instant repeated fails with no - // inbound traffic between them = process-state stuck. + // Why: diagnostic for RN/OkHttp pool corruption — high wsCount + repeated 1006 closes means process-state stuck. wsCount: wsConstructionCounter, msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null, msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null, @@ -354,8 +299,7 @@ export function connect( if (ws === openingWs) { return false } - // Why: React Native can deliver callbacks from a timed-out socket after - // reconnect has swapped in a replacement; stale events must not mutate it. + // Why: RN can deliver callbacks from a timed-out socket after reconnect swapped in a replacement — ignore them. console.log('[net] stale ws event ignored', { eventName, state, @@ -364,9 +308,7 @@ export function connect( return true } - // Why: React Native can leave TCP/WebSocket opens pending indefinitely on - // flaky network handoffs. Force the existing onclose reconnect path if - // onopen never arrives, instead of leaving the UI stuck at "Connecting...". + // Why: RN can leave opens pending forever on flaky handoffs — force reconnect if onopen never arrives. connectTimer = setTimeout(() => { connectTimer = null if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) { @@ -396,9 +338,7 @@ export function connect( setState('handshaking') emitLog('success', 'WebSocket open', 'Starting E2EE handshake') - // Why: generate a fresh ephemeral keypair for each connection. - // This provides forward secrecy — compromising one session's key - // doesn't compromise past or future sessions. + // Why: fresh ephemeral keypair per connection provides forward secrecy. const ephemeral = generateKeyPair() const hello = JSON.stringify({ type: 'e2ee_hello', @@ -437,8 +377,7 @@ export function connect( lastInboundAt = Date.now() const raw = typeof rawData === 'string' ? rawData : null - // Why: during handshaking, e2ee_ready is plaintext because it precedes - // encrypted auth; e2ee_authenticated/e2ee_error are encrypted. + // Why: e2ee_ready is plaintext (precedes encrypted auth); e2ee_authenticated/e2ee_error are encrypted. if (state === 'handshaking') { if (raw === null) { return @@ -481,9 +420,7 @@ export function connect( removeStreamListener(id) continue } - // Why: setState('connected') notifies UI listeners synchronously; - // a listener may subscribe and send immediately before this - // reconnect replay loop resumes. + // Why: a UI listener notified synchronously by setState('connected') may already have sent this stream — skip it. if (stream.sent) { continue } @@ -515,8 +452,7 @@ export function connect( return } - // Why: guard against decrypt with an invalid key — sharedKey can be null - // after destroy() or if a message arrives during a reconnect race. + // Why: sharedKey can be null after destroy() or a reconnect race — don't decrypt with an invalid key. if (!sharedKey || sharedKey.length !== 32) { return } @@ -553,10 +489,7 @@ export function connect( } recordValidatedInboundTraffic() - // Why: a mid-session unauthorized may be a transient glitch, not a dead - // pairing (issue #5200). handleAuthRejection retries the handshake a few - // times before latching auth-failed, while still bounding churn via the - // budget so a genuinely revoked token doesn't reconnect forever. + // Why: a mid-session unauthorized may be transient (issue #5200) — handleAuthRejection retries before latching auth-failed. if (!response.ok && response.error.code === 'unauthorized') { handleAuthRejection('Unauthorized — pairing may be revoked') return @@ -646,18 +579,12 @@ export function connect( ws.onclose = (event) => { const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined const closeAt = Date.now() - // Why: time-since-construct distinguishes failure modes. Instant - // close (<300ms) = TCP RST / port closed / route unreachable / RN - // synchronous reject. Mid (300ms–3s) = DNS/connect attempt + reset. - // Slow (>3s) = TCP SYN timeout / packet loss / NAT wedge. If an - // entire reconnect burst is all instant, the problem is local - // process state or routing, not packet loss. + // Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/packet loss. const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null const aliveMs = currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null - // Why: statically imported (not closure-built) — an earlier hot-reload - // bug came from a stale closure capturing a half-loaded module. + // Why: statically imported — a hot-reload bug came from a stale closure capturing a half-loaded module. const closeEvent = describeSocketEvent(event) console.log('[net] ws.onclose', { code: e?.code, @@ -682,9 +609,7 @@ export function connect( if (ignoreStaleSocketEvent('error')) { return } - // Why: RN surfaces network errors here (DNS failure, TCP RST, etc). - // onclose fires right after, but logging the error message gives us - // the original cause that the close code alone can hide. + // Why: RN surfaces the original network error here — onclose follows but its close code alone hides the cause. const e = event as { message?: string } | undefined const errEvent = describeSocketEvent(event) console.log('[net] ws.onerror', { @@ -734,11 +659,7 @@ export function connect( scheduleReconnect() } - // Why: a token rejection (handshake e2ee_error/unauthorized or a mid-session - // unauthorized RPC) may be transient — issue #5200. Retry the full handshake - // up to AUTH_RETRY_BUDGET times before declaring auth dead, so a one-off - // glitch self-heals instead of forcing the user to re-pair. A genuinely - // revoked token fails every retry and latches auth-failed within seconds. + // Why: an auth rejection may be transient (issue #5200) — retry up to AUTH_RETRY_BUDGET times before latching auth-failed. function handleAuthRejection(reason: string): void { activeBrowserScreencastRequestId = null pendingBrowserScreencastRequestId = null @@ -754,9 +675,7 @@ export function connect( 'Authentication rejected', `Retrying (${authRejectionCount}/${AUTH_RETRY_BUDGET})` ) - // Why: close the current socket but DON'T set intentionallyClosed — - // we want handleSocketClosed to route into the reconnect path so the - // token gets a fresh handshake. rejectAllPending unblocks in-flight RPCs. + // Why: close without setting intentionallyClosed so handleSocketClosed routes to reconnect and retries the handshake. const closing = ws ws = null sharedKey = null @@ -782,19 +701,11 @@ export function connect( } function scheduleReconnect() { - // Why: spinning fast reconnects forever drains battery and floods logs - // when the host is genuinely unreachable (wrong IP, port closed, - // host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a - // "Can't reach desktop, re-pair?" banner and the loop drops to the - // 90s trickle cadence instead of parking — a permanently parked loop - // could only be revived by an AppState/network transition, which a - // wedged VPN tunnel never produces. + // Why: past the cap, trickle (never park) — a parked loop only revives on a network transition a wedged VPN never produces. const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS let delay: number if (pastGiveUpCap) { - // Why: the counter holds at the cap — connection-health thresholds and - // the "Can't reach desktop" verdict key off attempts >= 12, and a - // successful open resets it to 0 anyway. + // Why: hold the counter at the cap — connection-health's "Can't reach desktop" verdict keys off attempts >= 12. delay = TRICKLE_RECONNECT_DELAY_MS rejectConnectWaiters('Connection retry limit reached') } else { @@ -824,10 +735,7 @@ export function connect( } } - // Why: app-level liveness probe — see ACTIVITY_PROBE_INTERVAL_MS comment - // at the top of the file. Fires while the channel is in 'connected' - // state, sends a tiny status.get, and force-closes the WS if the probe - // fails (which the existing onclose path then turns into a reconnect). + // Why: app-level liveness probe (see ACTIVITY_PROBE_INTERVAL_MS) — force-closes the WS on failure so onclose reconnects. function runActivityProbe() { if (state !== 'connected' || !ws) { return @@ -966,8 +874,7 @@ export function connect( removeStreamListener(id) return } - // Why: sent streams may still reply with `ready`; keep a tombstone so we - // can immediately unsubscribe. Queued streams never reached desktop. + // Why: a sent stream may still reply `ready`; keep the tombstone to unsubscribe it (queued streams never reached the desktop). if (!stream.sent) { removeStreamListener(id) } @@ -1013,11 +920,7 @@ export function connect( hasKey: !!sharedKey, state }) - // Why: if the state machine still thinks we're connected but the - // underlying WebSocket has flipped to CLOSING/CLOSED without onclose - // having fired (RN's WebSocket sometimes drops the event, or the - // server half-closed the stream), force a reconnect. Without this - // every send silently fails forever and the user sees a frozen UI. + // Why: RN can drop onclose, leaving state 'connected' over a dead socket; force reconnect or every send silently fails forever. if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) { console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { readyState: ws.readyState @@ -1125,9 +1028,7 @@ export function connect( if (pendingBrowserScreencastRequestId && pendingBrowserScreencastRequestId !== id) { disposeBrowserScreencastStream(pendingBrowserScreencastRequestId) } - // Why: browser screencast frames are connection-scoped and carry no - // stream id. Wait for the replacement stream's ready response before - // routing frames, so in-flight old-page pixels are dropped. + // Why: screencast frames carry no stream id, so route only after the new stream's ready to drop stale old-page pixels. pendingBrowserScreencastRequestId = id activeBrowserScreencastRequestId = null } @@ -1140,9 +1041,7 @@ export function connect( removeStreamListener(id) } } else { - // Stream is registered but the actual outbound subscribe will be - // sent (or re-sent) when the channel reaches 'connected'. Useful - // when terminals don't load — confirms the request is queued. + // Registered now; the outbound subscribe is (re-)sent once the channel reaches 'connected'. console.log('[net] subscribe queued — waiting for connected', { method, state }) } @@ -1157,12 +1056,7 @@ export function connect( return } if (stream?.method === 'terminal.subscribe') { - // Why: the runtime registers cleanup under the composite key - // `${terminal}:${clientId}` so two phones subscribing to the same - // terminal handle don't evict each other. Echo that composite key - // back on unsubscribe; also include `client.id` so the server can - // reconstruct it if a stale build emits a bare-handle id. See - // docs/mobile-presence-lock.md. + // Why: server keys cleanup by composite `${terminal}:${clientId}` so two phones don't evict each other. See docs/mobile-presence-lock.md. const unsubscribeParams = buildTerminalUnsubscribeParams(stream.params) if (unsubscribeParams) { sendEncrypted({ @@ -1211,20 +1105,14 @@ export function connect( return } if (state === 'connected') { - // Why: the OS can kill the TCP path while the app is backgrounded - // without delivering onclose, leaving a half-open socket that - // blackholes input. Probe now so death is detected in ≤8s instead - // of waiting out the 20s interval (issue #5049). + // Why: OS can kill the TCP path while backgrounded without onclose; probe now to detect the half-open socket in ≤8s (issue #5049). console.log('[net] foreground — probing live connection') startActivityProbe() runActivityProbe() return } if (state === 'reconnecting') { - // Why: while backgrounded the retry loop may be sitting on a 60s - // backoff or 90s trickle timer. Returning to the foreground is a - // strong user signal — restart with a fresh attempt budget - // immediately instead of waiting out the timer. + // Why: foreground is a strong user signal — restart immediately instead of waiting out a 60s/90s backoff timer. console.log('[net] foreground — restarting reconnect loop', { attempt: reconnectAttempt, hadTimer: !!reconnectTimer diff --git a/package.json b/package.json index e5734083688..77b11ed0f1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.146-rc.0", + "version": "1.4.147-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index fc285c56440..cdafcd66743 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -10,19 +10,13 @@ import { RuntimeClientError } from '../runtime-client' import { getTerminalHandle } from '../selectors' import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary' -// Why: 15 s is well under Claude Code's empirical ~2 min Bash-tool silence -// budget and generates only ~40 lines per 10 min wait — enough to assure the -// parent process the subprocess is alive without flooding logs. See design -// doc §3.4. +// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4. const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.` } -// Why: test-only escape hatch so subprocess tests can verify the feature in -// under 10 s rather than needing a full 15 s silence window. Production users -// should never set this — there is no surface documentation. A bogus value -// falls back to the default rather than disabling the keepalive. +// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default. function resolveKeepaliveIntervalMs(): number { const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS if (!raw) { @@ -40,16 +34,12 @@ function startCheckKeepalive(deadlineMs: number | undefined): () => void { const interval = setInterval(() => { const payload = { _keepalive: true, - // Why: retain the old marker for scripts filtering merged stderr while - // callers migrate to the unambiguous _keepalive field. + // Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive. _heartbeat: true, elapsedMs: Date.now() - startedAt, deadlineMs: deadlineMs ?? null } - // Why: `process.stderr.write` is line-flushed per-call in Node, whereas a - // fully-buffered writer would hold all keepalive lines until exit and - // silently defeat the whole point of the ping. Subprocess test asserts - // this by reading stderr incrementally. See §3.4. + // Why: process.stderr.write is line-flushed per-call in Node; a buffered writer would hold keepalives until exit. See §3.4. process.stderr.write(`${JSON.stringify(payload)}\n`) }, resolveKeepaliveIntervalMs()) if (typeof interval.unref === 'function') { @@ -58,8 +48,7 @@ function startCheckKeepalive(deadlineMs: number | undefined): () => void { return () => clearInterval(interval) } -// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI can surface a -// clear enum-aware error before the generic RPC Zod "Missing --status" message. +// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI surfaces an enum-aware error before the generic RPC message. const TASK_STATUS_VALUES = [ 'pending', 'ready', @@ -114,8 +103,7 @@ function getOptionalStructuredMessagePayload( 'Use either --payload or structured payload flags, not both.' ) } - // Why: raw JSON arguments are fragile in Windows PowerShell; these flags let - // workers send parseable orchestration payloads without shell-specific quoting. + // Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting. const payload: Record = {} if (taskId) { payload.taskId = taskId @@ -152,9 +140,7 @@ async function resolveOrchestrationTerminalHandle( const envHandle = process.env.ORCA_TERMINAL_HANDLE if (envHandle && envHandle.length > 0) { if (flagName === 'from' && options.validateEnvHandle) { - // Why: long-lived shells can retain an ORCA_TERMINAL_HANDLE after the - // runtime remints the pane handle; do not bake that stale id into - // coordinator preambles. + // Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles. const live = await isLiveTerminalHandle(envHandle, client) if (!live) { const reminted = await resolveOrchestrationPaneTerminalHandle(client) @@ -184,8 +170,7 @@ async function resolveTaskCreatorTerminalHandle( live = await isLiveTerminalHandle(envHandle, client) } catch (err) { if (isOptionalTaskCreatorHandleError(err)) { - // Why: creator handles are best-effort lineage metadata; graph - // unavailability should not block task creation itself. + // Why: creator handles are best-effort lineage metadata; graph unavailability must not block task creation. return undefined } throw err @@ -242,8 +227,7 @@ async function resolveOrchestrationPaneTerminalHandle( return undefined } try { - // Why: pane key reminting preserves the caller identity; focus-based - // active-terminal fallback can point at a different pane. + // Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane. const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { paneKey }) @@ -362,17 +346,11 @@ export const ORCHESTRATION_HANDLERS: Record = { !getOptionalStringFlag(flags, 'from') && !process.env.ORCA_TERMINAL_HANDLE ) { - // Why: focus is not lifecycle authority; injected dispatches carry an - // explicit worker handle so an identity-less subprocess must fail closed. + // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. throwNoActiveSenderTerminal() } - // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness - // probe (terminal.show throws runtime_unavailable in the exact mid-restart - // window worker_done must survive) and no pane remint (pre-payload- - // authority runtimes require from === the equally stale assignee_handle, - // and coordinator replies route to the sender row while the worker's own - // `check` reads its env-handle inbox). + // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness probe (worker_done must survive the mid-restart window) and no remint (older runtimes require from === the stale assignee_handle). const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') const result = await client.call('orchestration.send', { from, @@ -383,14 +361,12 @@ export const ORCHESTRATION_HANDLERS: Record = { priority: getOptionalStringFlag(flags, 'priority'), threadId: getOptionalStringFlag(flags, 'thread-id'), payload: getOptionalStructuredMessagePayload(flags), - // Why: the pane key is the remint-stable sender identity the runtime - // verifies lifecycle ownership against; older runtimes strip it. + // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. senderPaneKey: process.env.ORCA_PANE_KEY || undefined, devMode: isDevCliInvocation() }) if ('message' in result.result && result.result.lifecycle?.action === 'rejected') { - // Why: a persisted diagnostic is not a successful lifecycle signal; - // the non-zero status prevents workers from treating it as completion. + // Why: a rejected lifecycle signal isn't completion; non-zero exit stops workers from treating it as such. process.exitCode = 1 } printResult(result, json, (r) => { @@ -407,9 +383,7 @@ export const ORCHESTRATION_HANDLERS: Record = { 'orchestration check': async ({ flags, client, cwd, json }) => { const wait = flags.has('wait') const peek = flags.has('peek') - // Why: enforce mode exclusivity client-side too — an older runtime strips - // the unknown `peek` param and would otherwise execute --unread --peek as - // a destructive mark-read. + // Why: enforce mode exclusivity client-side — older runtimes strip unknown peek and run --unread --peek as destructive mark-read. if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) { throw new RuntimeClientError( 'invalid_argument', @@ -419,13 +393,7 @@ export const ORCHESTRATION_HANDLERS: Record = { const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') - // Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce - // no output for ~2 min (shorter on the non-interactive path). Emit a - // keepalive line to stderr every KEEPALIVE_INTERVAL_MS while the wait is - // active so the parent process can see the subprocess is still alive. - // Stderr rather than stdout so stdout stays a single final JSON payload, - // and JSON-shaped rather than `# …` so `2>&1 | jq` pipelines still work - // (jq refuses `#`-prefixed lines). See design doc §3.4. + // Why: Claude Code auto-backgrounds subprocesses silent ~2 min; emit JSON keepalives to stderr (stdout stays one payload). See §3.4. const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null type CheckResult = { messages: MessageSummary[] @@ -436,10 +404,7 @@ export const ORCHESTRATION_HANDLERS: Record = { try { result = await client.call('orchestration.check', { terminal, - // Why: --peek also sends unread:false so runtimes that predate the - // peek param (which their non-strict schema strips) degrade to the - // non-consuming all-messages mode instead of the destructive - // mark-read default; the read filter below restores peek semantics. + // Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read. unread: flags.has('unread') ? true : peek ? false : undefined, peek: peek ? true : undefined, all: flags.has('all') ? true : undefined, @@ -455,19 +420,14 @@ export const ORCHESTRATION_HANDLERS: Record = { const rawRowCount = result.result.messages.length const unreadOnly = result.result.messages.filter((m) => m.read !== 1) const removedReadRows = unreadOnly.length !== rawRowCount - // Why: read rows in a peek response are the pre-peek-runtime signature - // (its schema stripped `peek` and it ran the all mode). Such a runtime - // returned instead of blocking, so honoring --wait is impossible — - // failing beats silently returning empty before the deadline. + // Why: read rows mean a pre-peek runtime ran the all mode and returned instead of blocking; can't honor --wait, so fail loudly. if (wait && removedReadRows && unreadOnly.length === 0) { throw new RuntimeClientError( 'peek_wait_unsupported', 'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.' ) } - // Why: pre-peek runtimes cap the all mode at the newest 100 rows, so a - // full page means older unread messages may have been cut off. Warn on - // stderr so stdout stays a single JSON payload. + // Why: pre-peek runtimes cap the all mode at 100 rows; a full page may hide older unread — warn on stderr. if (removedReadRows && rawRowCount >= 100) { console.error( 'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.' @@ -477,8 +437,7 @@ export const ORCHESTRATION_HANDLERS: Record = { ...result, result: { ...result.result, - // Why: a pre-peek runtime builds `formatted` from all rows; drop it - // when the read filter removed any so output matches the peek set. + // Why: a pre-peek runtime builds `formatted` from all rows; drop it so output matches the filtered peek set. ...(removedReadRows ? { formatted: undefined } : {}), messages: unreadOnly, count: unreadOnly.length @@ -521,8 +480,7 @@ export const ORCHESTRATION_HANDLERS: Record = { if (r.count === 0) { return 'No messages.' } - // Why: default output omits body/payload for at-a-glance sweeps; --full - // prints them verbatim so callers can audit without parsing --json. + // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. return r.messages .map((m) => { const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` @@ -577,9 +535,7 @@ export const ORCHESTRATION_HANDLERS: Record = { ready: flags.has('ready') ? true : undefined, brief: brief ? true : undefined }) - // Why: current runtimes abbreviate server-side (rows carry - // spec_truncated) so full specs never cross the wire; older runtimes - // strip the brief param and need the client-side fallback. + // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback. const needsClientAbbreviation = brief && result.result.tasks.some((task) => task.spec_truncated === undefined) const output = needsClientAbbreviation @@ -671,18 +627,10 @@ export const ORCHESTRATION_HANDLERS: Record = { timeoutMs: parsedTimeoutMs, from }, - // Why: the runtime's `waitForMessage` can block up to `timeoutMs`, but - // the RPC transport has its own 60s default timeout that would fire - // first. Extend the per-call timeout by a small grace window so the - // RPC doesn't abort before the runtime's internal timeout resolves. + // Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves. { timeoutMs: timeoutMs + 5_000 } ) - // Why: deliberate bypass of `printResult`. `--json` on `ask` emits a - // single-line bare JSON object (no RPC envelope, no multi-line pretty- - // print) so workers can pipe `orca orchestration ask … --json | jq -r - // .answer` without reaching into a `result` envelope. This diverges from - // every other orchestration verb; called out in the commit message and - // guarded by a unit test in orchestration.test.ts. + // Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs. if (json) { console.log(JSON.stringify(result.result)) } else if (result.result.answer !== null) { @@ -698,8 +646,7 @@ export const ORCHESTRATION_HANDLERS: Record = { 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { const showPreamble = flags.has('preamble') ? true : undefined - // Why: resolve --from when previewing so the preamble embeds a real - // coordinator handle, matching what an actual dispatch would produce. + // Why: resolve --from so the previewed preamble embeds a real coordinator handle like an actual dispatch. const from = showPreamble ? await resolveCoordinatorTerminalHandle(flags, cwd, client) : undefined diff --git a/src/main/active-view-persistence-boundary.test.ts b/src/main/active-view-persistence-boundary.test.ts new file mode 100644 index 00000000000..26bb25ac70e --- /dev/null +++ b/src/main/active-view-persistence-boundary.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getActiveViewPreferenceFile } from './active-view-preference' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').replace('encrypted:', '') + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +describe('active-view persistence boundary', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-active-view-boundary-')) + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('persists a view switch without changing the global durable snapshot', async () => { + vi.useFakeTimers() + vi.resetModules() + const { Store } = await import('./persistence') + const dataFile = join(testState.dir, 'orca-data.json') + const store = new Store({ dataFile }) + store.flush() + const durableBefore = readFileSync(dataFile, 'utf-8') + + store.updateUI({ activeView: 'settings' }) + vi.advanceTimersByTime(1_000) + await store.waitForPendingWrite() + + const preferenceFile = getActiveViewPreferenceFile(dataFile) + const preferencePayload = readFileSync(preferenceFile, 'utf-8') + expect(Buffer.byteLength(preferencePayload)).toBeLessThan(64) + expect(JSON.parse(preferencePayload)).toEqual({ activeView: 'settings' }) + expect(readFileSync(dataFile, 'utf-8')).toBe(durableBefore) + expect(store.getUI().activeView).toBe('settings') + + const reloaded = new Store({ dataFile }) + expect(reloaded.getUI().activeView).toBe('settings') + }) +}) diff --git a/src/main/active-view-preference.test.ts b/src/main/active-view-preference.test.ts new file mode 100644 index 00000000000..972009f722c --- /dev/null +++ b/src/main/active-view-preference.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ActiveViewPreference, getActiveViewPreferenceFile } from './active-view-preference' + +describe('ActiveViewPreference', () => { + let dir: string + let dataFile: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-active-view-')) + dataFile = join(dir, 'orca-data.json') + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(dir, { recursive: true, force: true }) + }) + + it('migrates the legacy durable-state value into the profile sidecar', async () => { + vi.useFakeTimers() + const preference = new ActiveViewPreference(dataFile, 'tasks') + + expect(preference.get()).toBe('tasks') + expect(existsSync(getActiveViewPreferenceFile(dataFile))).toBe(false) + + // The renderer reasserts its hydrated value once persistence is ready. + expect(preference.set('tasks')).toBe(false) + vi.advanceTimersByTime(100) + await preference.waitForPendingWrite() + + expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({ + activeView: 'tasks' + }) + }) + + it('coalesces rapid switches into one tiny preference write', async () => { + vi.useFakeTimers() + const preference = new ActiveViewPreference(dataFile, 'terminal') + + preference.set('settings') + vi.advanceTimersByTime(50) + preference.set('automations') + vi.advanceTimersByTime(50) + expect(existsSync(getActiveViewPreferenceFile(dataFile))).toBe(false) + + vi.advanceTimersByTime(50) + await preference.waitForPendingWrite() + + expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({ + activeView: 'automations' + }) + }) + + it('flushes synchronously for an immediate graceful exit', () => { + vi.useFakeTimers() + const preference = new ActiveViewPreference(dataFile, 'terminal') + + preference.set('settings') + preference.flushOrThrow() + + expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({ + activeView: 'settings' + }) + vi.advanceTimersByTime(100) + expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({ + activeView: 'settings' + }) + }) + + it('ignores an invalid sidecar and invalid updates', () => { + writeFileSync(getActiveViewPreferenceFile(dataFile), '{"activeView":"unknown"}', 'utf-8') + const preference = new ActiveViewPreference(dataFile, 'not-a-view') + + expect(preference.get()).toBe('terminal') + expect(preference.set('also-not-a-view')).toBe(false) + expect(preference.get()).toBe('terminal') + }) + + it('rejects inherited object keys from a corrupt sidecar', () => { + // Why: `constructor`/`__proto__` are truthy under `in`; the sidecar must not + // treat them as a valid view and leave the main surface blank. + writeFileSync(getActiveViewPreferenceFile(dataFile), '{"activeView":"constructor"}', 'utf-8') + const preference = new ActiveViewPreference(dataFile, 'tasks') + + expect(preference.get()).toBe('tasks') + expect(preference.set('__proto__')).toBe(false) + expect(preference.get()).toBe('tasks') + }) +}) diff --git a/src/main/active-view-preference.ts b/src/main/active-view-preference.ts new file mode 100644 index 00000000000..beed983aef4 --- /dev/null +++ b/src/main/active-view-preference.ts @@ -0,0 +1,136 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { TopLevelView } from '../shared/types' +import { isTopLevelView } from '../shared/top-level-view' + +const ACTIVE_VIEW_FILE_NAME = 'active-view.json' +const SAVE_DEBOUNCE_MS = 100 + +type ActiveViewFile = { + activeView: TopLevelView +} + +export function getActiveViewPreferenceFile(dataFile: string): string { + return join(dirname(dataFile), ACTIVE_VIEW_FILE_NAME) +} + +function readActiveView(file: string): TopLevelView | null { + try { + const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial + return isTopLevelView(parsed.activeView) ? parsed.activeView : null + } catch { + return null + } +} + +function serializeActiveView(activeView: TopLevelView): string { + return `${JSON.stringify({ activeView } satisfies ActiveViewFile)}\n` +} + +export class ActiveViewPreference { + private readonly file: string + private activeView: TopLevelView + private persistedActiveView: TopLevelView | null + private writeTimer: ReturnType | null = null + private pendingWrite: Promise | null = null + private writeGeneration = 0 + + constructor(dataFile: string, legacyActiveView: unknown) { + this.file = getActiveViewPreferenceFile(dataFile) + const storedActiveView = readActiveView(this.file) + const fallbackActiveView = isTopLevelView(legacyActiveView) ? legacyActiveView : 'terminal' + this.activeView = storedActiveView ?? fallbackActiveView + this.persistedActiveView = storedActiveView + } + + get(): TopLevelView { + return this.activeView + } + + set(value: unknown): boolean { + if (!isTopLevelView(value)) { + return false + } + const changed = value !== this.activeView + this.activeView = value + if ( + value !== this.persistedActiveView || + this.writeTimer !== null || + this.pendingWrite !== null + ) { + this.scheduleSave() + } + return changed + } + + private scheduleSave(): void { + this.writeGeneration += 1 + if (this.writeTimer) { + clearTimeout(this.writeTimer) + } + this.writeTimer = setTimeout(() => { + this.writeTimer = null + const generation = this.writeGeneration + const activeView = this.activeView + const previousWrite = this.pendingWrite ?? Promise.resolve() + const nextWrite = previousWrite + .then(() => this.writeAsync(activeView, generation)) + .catch((error) => { + console.error('[active-view] Failed to persist preference:', error) + }) + .finally(() => { + if (this.pendingWrite === nextWrite) { + this.pendingWrite = null + } + }) + this.pendingWrite = nextWrite + }, SAVE_DEBOUNCE_MS) + } + + private async writeAsync(activeView: TopLevelView, generation: number): Promise { + const tmpFile = `${this.file}.${process.pid}.${generation}.tmp` + let renamed = false + try { + await mkdir(dirname(this.file), { recursive: true }) + await writeFile(tmpFile, serializeActiveView(activeView), 'utf-8') + // Why: keep the generation guard and the swap synchronous (no await between), + // or a concurrent flushOrThrow could rename a newer view that this stale write + // then clobbers, restoring the prior view on next launch. + if (generation !== this.writeGeneration) { + return + } + renameSync(tmpFile, this.file) + renamed = true + this.persistedActiveView = activeView + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + } + + flushOrThrow(): void { + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + const asyncWriteWasInFlight = this.pendingWrite !== null + this.writeGeneration += 1 + this.pendingWrite = null + if (!asyncWriteWasInFlight && this.activeView === this.persistedActiveView) { + return + } + mkdirSync(dirname(this.file), { recursive: true }) + const tmpFile = `${this.file}.${process.pid}.${this.writeGeneration}.tmp` + writeFileSync(tmpFile, serializeActiveView(this.activeView), 'utf-8') + renameSync(tmpFile, this.file) + this.persistedActiveView = this.activeView + } + + async waitForPendingWrite(): Promise { + if (this.pendingWrite) { + await this.pendingWrite + } + } +} diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index d669192907f..02ff623f52f 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -1,8 +1,4 @@ -// Why: when first-message auto-name is on, the first time an agent starts -// working in a freshly-created workspace we replace the auto-generated creature -// branch (e.g. `you/Nautilus`) with a short, work-derived name. This module -// owns the orchestration: gate on the signal, enforce the safety guardrails, -// summarize the prompt via the configured agent, and rename. +// On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name. import type { GlobalSettings, Repo } from '../../shared/types' import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -59,10 +55,7 @@ export type FirstWorkBranchRenameDeps = { setDisplayName: (worktreeId: string, displayName: string) => void /** Align the on-disk folder with the new branch leaf (best-effort, local-only). */ renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise - /** Record (or clear with null) a user-facing auto-rename generation failure - * so the sidebar can show a "rename failed" badge instead of silent retries. - * `failureOutput` carries the bounded full CLI output for local on-demand - * display; omitted/null replaces any stale capture. */ + /** Record (or clear with null) an auto-rename failure for the sidebar "rename failed" badge; `failureOutput` carries bounded CLI output for on-demand display. */ setRenameError: ( worktreeId: string, error: string | null, @@ -74,12 +67,7 @@ export type FirstWorkBranchRenameDeps = { onRenamed: (repoId: string) => void } -// Why: `inFlight` guards against concurrent runs while generation (seconds) is -// pending; `settled` short-circuits worktrees we've reached a definitive verdict -// on (renamed, or permanently ineligible) so we never re-probe git on later -// `working` events — important because the hook fires every turn for every pane. -// A *transient* failure (agent not reachable yet, brief detached HEAD) is NOT -// settled, so the real first prompt can still succeed on a later event. +// inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later). const inFlightWorktreeIds = new Set() const settledWorktreeIds = new Set() export const FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT = 500 @@ -91,8 +79,7 @@ export function resetFirstWorkBranchRenameState(): void { } function rememberSettledWorktreeId(worktreeId: string): void { - // Why: the app can see unbounded worktree ids over a long session; evicting - // oldest entries trades a rare re-probe for bounded process memory. + // Why: worktree ids are unbounded over a long session; evict oldest to bound memory (costs a rare re-probe). settledWorktreeIds.delete(worktreeId) settledWorktreeIds.add(worktreeId) while (settledWorktreeIds.size > FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT) { @@ -115,8 +102,7 @@ export async function maybeAutoRenameBranchOnFirstWork( if (!deps.getSettings().autoRenameBranchFromWork) { return } - // Resolve the worktree from the tab (authoritative) rather than the - // agent-echoed worktreeId, which is not present on every hook event. + // Resolve from the tab (authoritative); the agent-echoed worktreeId isn't present on every event. const tabId = parsePaneKey(event.paneKey)?.tabId ?? event.tabId const worktreeId = (tabId ? deps.resolveWorktreeIdForTab(tabId) : undefined) ?? event.worktreeId if (!worktreeId) { @@ -132,37 +118,27 @@ export async function maybeAutoRenameBranchOnFirstWork( } inFlightWorktreeIds.add(worktreeId) try { - // `settled` true means a definitive verdict (renamed or permanently - // ineligible); false means a transient bail that should retry later. + // settled = definitive verdict (renamed/ineligible); false = transient bail to retry later. const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps) if (settled) { rememberSettledWorktreeId(worktreeId) } } catch (error) { - // Why: best-effort, opt-in convenience. A failure must never disrupt the - // agent the user actually launched, so swallow after logging. + // Why: best-effort convenience; a failure must never disrupt the user's agent, so swallow after logging. console.warn('[auto-branch-rename] rename attempt failed:', error) } finally { inFlightWorktreeIds.delete(worktreeId) } } -/** - * Returns true when a definitive verdict was reached (renamed, or permanently - * ineligible — so stop re-probing), false for a transient bail worth retrying. - */ +/** Returns true for a definitive verdict (renamed or permanently ineligible → stop re-probing), false for a transient bail worth retrying. */ async function runAutoRename( worktreeId: string, prompt: string, assistantMessage: string | undefined, deps: FirstWorkBranchRenameDeps ): Promise { - // Why: when the feature appears not to fire, the question is always "which - // gate bailed?" — this names the gate so a single log line answers it. - // `stop` = permanent (don't retry); `retry` = transient (try again later). - // `clearError` clears a stale "rename failed" badge when the worktree reaches - // a benign final state (ineligible, user-renamed, or pushed) after an earlier - // transient generation failure raised one — otherwise it would stick forever. + // `stop` = permanent skip (logs which gate bailed); `retry` = transient; `clearError` drops a stale "rename failed" badge on a benign final state. const stop = (reason: string, clearError = false): true => { if (clearError) { deps.setRenameError(worktreeId, null) @@ -188,9 +164,7 @@ async function runAutoRename( } const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - // Why: worktreePath is a Git subprocess cwd. Folder-workspace instance IDs - // carry a synthetic `::workspace:` suffix that is not a real directory, - // so resolve to the backing folder or Git spawns against a nonexistent cwd. + // Why: folder-workspace ids carry a synthetic `::workspace:` suffix that's not a real dir; resolve to the backing folder for Git's cwd. const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return stop('unresolved repo or worktree id') @@ -223,8 +197,7 @@ async function runAutoRename( return stop(`branch "${currentBranch}" already has an upstream`, true) } if (upstreamProbe.outcome === 'probe-failed') { - // Why: settling an unreadable probe as "has upstream" made this failure - // silent and permanent via the settled cache (issue #7808). + // Why: settling an unreadable probe as "has upstream" made this silent + permanent via the settled cache (issue #7808). const probeError = upstreamProbe.message.replace(/\s+/g, ' ').trim() deps.setRenameError( worktreeId, @@ -237,8 +210,7 @@ async function runAutoRename( const hostKey = getCommitMessageModelDiscoveryHostKey(repo.connectionId ?? null) const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo) if (!resolvedParams.ok) { - // Why: a generation-step failure (vs a benign skip) is user-actionable, so - // surface it on the card rather than leaving a silent "rename pending". + // Why: a generation-step failure (vs a benign skip) is user-actionable, so surface it on the card. deps.setRenameError(worktreeId, resolvedParams.error) return stop(`no generation agent: ${resolvedParams.error}`) } @@ -256,17 +228,14 @@ async function runAutoRename( target ) if (!generated.success) { - // Transient: the agent may be momentarily busy/unreachable — let a later - // event retry rather than permanently leaving the creature name. - // A user-canceled generation isn't a failure to surface, so skip the badge. + // Transient failure: retry on a later event; skip the badge for a user-canceled generation. if (!generated.canceled) { deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null) } return retry(`generation failed: ${generated.error}`) } - // Re-validate after generation (it can take seconds): the branch must be the - // same unpublished creature branch we started from, or we leave it alone. + // Re-validate after generation (takes seconds): bail if the branch changed or was published meanwhile. const branchNow = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() if (branchNow !== currentBranch) { return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`) @@ -283,14 +252,12 @@ async function runAutoRename( const username = provider ? (await getSshGitUsername(provider, repo.path)) || null : (await resolveLocalGitUsername(repo.path)) || null - // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip - // it so it doesn't double-prefix the branch or leak into the display name. + // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip it to avoid double-prefixing. const slug = stripConfiguredBranchPrefix( generated.slug, getConfiguredBranchPrefix(settings, username) ) - // Prefix-only model output strips to empty; renaming with it would just - // re-add the prefix (`tmchow/tmchow`), so treat it as a benign skip. + // Prefix-only output strips to empty; renaming would just re-add the prefix (`tmchow/tmchow`), so skip. if (!slug) { return stop('model produced only the configured prefix', true) } @@ -301,8 +268,7 @@ async function runAutoRename( currentBranch ) if (!newBranch || newBranch === currentBranch) { - // Generation succeeded but yielded no distinct name — terminal and benign, - // so clear any stale failure badge a prior transient attempt left behind. + // No distinct name is terminal and benign, so clear any stale failure badge from a prior transient attempt. return stop(`no distinct unique branch name for slug "${slug}"`, true) } @@ -310,12 +276,9 @@ async function runAutoRename( ? provider.renameCurrentBranch(worktreePath, newBranch) : renameCurrentBranch(exec, newBranch)) - // resolveUniqueBranchName may have appended a collision suffix (`-2`, …), so - // derive the sidebar name and on-disk folder from the *resolved* branch leaf, - // not the pre-suffix slug, to keep branch, display, and folder aligned. + // resolveUniqueBranchName may append a collision suffix (`-2`, …), so derive names from the resolved leaf, not the slug. const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1) - // Keep the sidebar name in sync with the branch — but only when it is still - // the auto-generated creature name, so a name the user typed is left alone. + // Only sync the sidebar name when it's still the auto-generated creature name, so a user-typed name is left alone. const currentDisplayName = deps.getCurrentDisplayName(worktreeId) const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf }) const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName) @@ -326,10 +289,7 @@ async function runAutoRename( // A successful rename clears any stale generation-failure surfaced earlier. deps.setRenameError(worktreeId, null) - // Align the on-disk folder with the new branch leaf. Best-effort and local-only: - // a skip or failure (remote, Windows lock, dest taken) leaves the folder as-is - // and must never undo the branch/display rename that already landed. Runs after - // setDisplayName so the new display name rides along into the migrated identity. + // Best-effort local-only folder align, after setDisplayName so the new name rides into the migrated identity; a failure must never undo the landed rename. let folderRenamed = false if (deps.renameWorktreeFolder) { try { @@ -339,9 +299,7 @@ async function runAutoRename( } } - // A successful folder rename already invalidated caches and pushed a - // worktrees:changed carrying the id mapping; a second onRenamed would only - // trigger a redundant renderer re-list. Otherwise notify for the branch rename. + // A folder rename already notified the renderer, so a second onRenamed would just re-list redundantly. if (!folderRenamed) { deps.onRenamed(repo.id) } diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index c73e1536e4f..e0688b44345 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -40,16 +40,11 @@ export type HooksConfig = { [key: string]: unknown } -// Why: host-level backstop (seconds) for Orca-managed status hooks. The shell -// wrapper's curl `--max-time 1.5` is the normal dead-endpoint bound; this caps a -// hook the agent host itself runs in case that transport budget is bypassed. -// Intentionally independent of Copilot's `timeoutSec: 5` — both managed budgets -// coexist by design (#4633). +// Why: host-level backstop timeout for status hooks, independent of the curl --max-time and Copilot's timeoutSec (#4633). export const MANAGED_HOOK_TIMEOUT_SECONDS = 10 export const MANAGED_HOOK_TIMEOUT_MILLISECONDS = MANAGED_HOOK_TIMEOUT_SECONDS * 1000 -// Nested command hook used by the Claude-shaped `hooks: [...]` schema (Claude, -// Codex, Gemini, Droid, Grok, Command Code, Devin). +// Nested command hook for the Claude-shaped `hooks: [...]` schema (Claude, Codex, Gemini, Droid, Grok, Command Code, Devin). export function buildManagedCommandHook( command: string, timeout = MANAGED_HOOK_TIMEOUT_SECONDS @@ -57,8 +52,7 @@ export function buildManagedCommandHook( return { type: 'command', command, timeout } } -// Direct command definition used by schemas that put `command` on the -// definition itself (Cursor's documented top-level shape). +// Direct command definition for schemas that put `command` on the definition itself (Cursor's top-level shape). export function buildManagedCommandDefinition(command: string): HookDefinition { return { command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } @@ -70,18 +64,12 @@ export { type HooksJsonSnapshot } from './hooks-json-read' -// Why: callers in install/remove need to match not just the exact current -// managed command, but also stale entries pointing at old script paths — e.g. -// from a previous dev build with a different Electron userData dir, or a -// parallel dev/prod install. Matching by the managed script's file name -// (under any `agent-hooks/` directory) lets a fresh install sweep those -// without touching unrelated user-authored hooks. +// Why: match by script file name, not exact command, so a fresh install sweeps stale entries from old/parallel installs. export function createManagedCommandMatcher( scriptFileName: string ): (command: string | undefined) => boolean { const scriptStem = scriptFileName.replace(/\.(?:cmd|ps1|sh)$/, '') - // Why: local Windows installs use .cmd or Copilot's .ps1, while SSH/POSIX - // installs use .sh. A platform switch must still sweep stale Orca hooks. + // Why: installs use .cmd/.ps1 (Windows) or .sh (SSH/POSIX); match all so a platform switch still sweeps stale hooks. const needles = [ `agent-hooks/${scriptFileName}`, `agent-hooks/${scriptStem}.cmd`, @@ -111,8 +99,7 @@ function decodePowerShellEncodedCommand(command: string): string | null { } } -// Why: prod, dev, and parallel Orca instances must write the same managed -// settings entry instead of racing between per-userData script paths. +// Why: prod/dev/parallel Orca instances must write the same managed entry, not race between per-userData script paths. export function getSharedManagedScriptPath(scriptFileName: string): string { return join(homedir(), '.orca', 'agent-hooks', scriptFileName) } @@ -121,16 +108,9 @@ function quotePosixShellString(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } -// Why: a stale managed hook entry (left over after the user wiped userData, -// switched dev↔prod installs, or had a partial install fail) used to fire -// `/bin/sh ""` on every tool call, which exits 127 and surfaces -// as `PreToolUse hook (failed) error: hook exited with code 127` in the agent -// transcript. Guarding for a regular readable executable file makes a broken -// install a silent no-op without hiding failures from a script that starts. +// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call. export function wrapPosixHookCommand(scriptPath: string, env: Record = {}): string { - // Why: POSIX single-quote escape so $, `, ", and \ in scriptPath are taken - // literally — avoids a shell-injection footgun if a future caller passes an - // arbitrary path. + // Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path. const quoted = quotePosixShellString(scriptPath) const envPrefix = Object.entries(env) .map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`) @@ -154,8 +134,7 @@ export function wrapWindowsHookCommand( scriptPath: string, env: Record = {} ): string { - // Why: the encoded launcher protects paths across Windows hook shells and - // owns stdin when a stale config points at a missing managed script. + // Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script. const quoted = quotePowerShellString(scriptPath) const envPrefix = Object.entries(env) .map(([key, value]) => `$env:${key} = ${quotePowerShellString(value)}; `) @@ -168,14 +147,7 @@ export function wrapWindowsHookCommand( export const WINDOWS_CMD_SAFE_PATH = /^[A-Za-z0-9_.:\\~-]+$/ export function wrapWindowsCmdHookCommand(scriptPath: string): string { - // Why: Codex/Antigravity/Devin launch the hook command as a program (argv[0]), - // NOT through cmd.exe, so the launcher must be a single directly-spawnable - // token. The bare .cmd path is exactly that. A cmd-builtin `if exist …` - // launcher is unspawnable — its argv[0] is `if` — so it fails every hook with - // exit 1 (#8430 regression). A stale/missing script therefore surfaces a normal - // launch failure here; the missing-script stdin drain lives on the encoded - // fallback below, which needs a real interpreter for spaces/metacharacters - // anyway and cannot be reached by a cmd-builtin drain without breaking spawn. + // Why: Codex/Antigravity/Devin spawn the hook as argv[0], not via cmd.exe, so it must be one spawnable token; a cmd `if exist` launcher isn't (#8430). return WINDOWS_CMD_SAFE_PATH.test(scriptPath) ? scriptPath : wrapWindowsHookCommand(scriptPath) } @@ -183,17 +155,15 @@ export const WINDOWS_GIT_BASH_SAFE_PATH = /^[A-Za-z0-9_.:/~-]+$/ export function wrapWindowsGitBashHookCommand(scriptPath: string): string { const bashPath = scriptPath.replaceAll('\\', '/') - // Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; - // unsafe paths stay encoded and the fast path gains a missing-file drain. + // Why: Claude's Git Bash runner can execute a forward-slash .cmd directly; unsafe paths stay encoded. return WINDOWS_GIT_BASH_SAFE_PATH.test(bashPath) ? `if [ -f ${quotePosixShellString(bashPath)} ]; then ${quotePosixShellString(bashPath)}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` : wrapWindowsHookCommand(scriptPath) } export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { - // Why: Codex runs these hooks inline on every turn. PowerShell startup alone - // makes trusted Windows hooks visibly slow, so mirror the POSIX curl path. - // Qualify curl so a repo-local curl.exe cannot hijack hook payloads. + // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path. + // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads. return [ `"%SystemRoot%\\System32\\curl.exe" -sS -X POST "http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}" ^`, ' --connect-timeout 0.5 --max-time 1.5 ^', @@ -209,12 +179,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin ].join('\r\n') } -// Why: status hooks fire up to 6× per turn; spawning PowerShell per post adds -// ~300ms of interpreter startup each, which Codex 0.140's synchronous "Running -// hook" rows make visible. curl.exe (Windows 10 1803+) posts the same -// form fields as the POSIX hook and reads the raw payload from stdin via -// `--data-urlencode payload@-`, so UTF-8 (e.g. CJK prompts) survives byte-for- -// byte without the code-page translation that previously forced PowerShell. +// Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both. export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string { return [ '"%SystemRoot%\\System32\\curl.exe" -sS -X POST', @@ -286,8 +251,7 @@ export function hookDefinitionHasManagedCommand( ) } -// Why: temp+rename so concurrent Orca instances writing this shared path can't -// produce a torn script that an in-flight `/bin/sh ` would source. +// Why: temp+rename so concurrent writers can't leave a torn script for an in-flight /bin/sh to source. export function writeManagedScript(scriptPath: string, content: string): void { const dir = dirname(scriptPath) mkdirSync(dir, { recursive: true }) @@ -308,9 +272,7 @@ export function writeManagedScript(scriptPath: string, content: string): void { const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) try { writeScriptWithAclRetry(tmpPath, content) - // Why: chmod before rename so the canonical path is never visible in a - // unreadable/non-executable state; wrapPosixHookCommand's guards would - // silently skip the hook in that window. + // Why: chmod before rename so the canonical path is never visible non-executable, else the POSIX guard skips the hook. if (process.platform !== 'win32') { chmodSync(tmpPath, 0o755) } @@ -326,8 +288,7 @@ export function writeManagedScript(scriptPath: string, content: string): void { } } -// Why: on Windows, write may fail with EPERM if the target directory has a -// restrictive DACL. Grant an explicit ACL on EPERM and retry once. +// Why: a restrictive directory DACL makes writes fail with EPERM on Windows; grant an ACL and retry once. function writeScriptWithAclRetry(scriptPath: string, content: string): void { try { writeFileSync(scriptPath, content, 'utf-8') @@ -354,15 +315,8 @@ export function writeHooksJson( const dir = dirname(writePath) mkdirSync(dir, { recursive: true }) - // Why: write to a temp file then rename so a crash or disk-full mid-write - // leaves the original untouched. This is the only safe way to update a - // config file the user may have hand-edited. - // - // Why randomUUID: Date.now() alone collides when two install() calls fire in - // the same millisecond targeting the same dir (e.g. a future caller that - // installs multiple agents sharing a config dir, or rapid reinstalls from - // the settings UI). A collision would corrupt one of the two writes. The - // UUID suffix makes the tmp path unique per call. + // Why: temp+rename leaves the original untouched on a crash/disk-full mid-write. + // Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond. const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) const serialized = `${JSON.stringify(config, null, 2)}\n` const existingMode = @@ -378,9 +332,7 @@ export function writeHooksJson( return } } catch { - // Fall through to the normal write path — a read error here is not - // worth failing the install for; the atomic write below will either - // succeed or throw loudly. + // Fall through to the normal write path; a read error isn't worth failing the install for. } } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index f1aba1cb09f..0a75205623c 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -157,9 +157,7 @@ describe('AgentHookServer listener replay', () => { state: 'working', prompt: 'review loop', agentType: 'claude', - // Why: a working pane can be child-driven (lead already idle). - // Ctrl+C does not stop background children, so no terminal done - // may be inferred while one is still running. + // Why: a working pane can be child-driven while the lead is idle; Ctrl+C doesn't stop children, so no terminal-done may be inferred here. subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] } }, @@ -2571,9 +2569,7 @@ describe('AgentHookServer listener replay', () => { } }) - // Why: agent-status-over-SSH §3 — ingestRemote must run the same warn-once - // cross-build diagnostics the local HTTP path runs, so a remote source of - // genuinely stale hooks emits the same signal locally. + // Why (agent-status-over-SSH §3): ingestRemote must run the same warn-once diagnostics as the local HTTP path so stale remote hooks signal locally. it('runs warn-once env/version diagnostics on relay-forwarded events', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -2631,9 +2627,7 @@ describe('AgentHookServer listener replay', () => { 'conn-1' ) expect(warn.mock.calls.length).toBe(warnsAfterFirst) - // Why: pin both invariants — warn-once dedupe AND fanout still fires for - // the second event. Without the second assertion, a future refactor that - // drops the second event silently would still leave warn-count unchanged. + // Why: assert fanout still fires on the second event too, else a refactor that drops it would pass on warn-count alone. expect(listener).toHaveBeenCalledTimes(2) } finally { server.stop() @@ -3843,9 +3837,7 @@ describe('Claude hook normalization', () => { }) it('PostToolUse for an unknown tool surfaces the name without input', () => { - // Why: we use a per-tool allowlist to decide which field to preview. - // Tools we do not recognize render as name-only rather than guessing at - // a field, which avoids noisy/misleading previews (e.g. an opaque ID). + // Why: a per-tool allowlist picks the preview field; unrecognized tools render name-only to avoid guessing a misleading field (e.g. an opaque ID). const result = _internals.normalizeHookPayload( 'claude', buildBody({ @@ -3860,9 +3852,7 @@ describe('Claude hook normalization', () => { }) it('PostToolUse for TaskUpdate does not produce a misleading input preview', () => { - // Why: TaskUpdate's tool_input (e.g. { task_id: "3", status: "in_progress" }) - // has no meaningful preview — rendering "3" is actively confusing. The - // allowlist approach leaves toolInput undefined for unlisted tools. + // Why: TaskUpdate's tool_input has no meaningful preview (rendering "3" is confusing), so the allowlist leaves toolInput undefined. const result = _internals.normalizeHookPayload( 'claude', buildBody({ @@ -4144,9 +4134,7 @@ describe('Claude hook normalization', () => { }) it('finds an assistant reply that sits past the first chunk boundary', () => { - // Why: a turn with many large tool_result entries pushes the final text - // reply well past the first 64 KB chunk; the chunked scan should keep - // reading backward until it finds it. + // Why: large tool_result entries push the final reply past the first 64 KB chunk; the scan must keep reading backward to find it. const filler = 'x'.repeat(70_000) const lines = [ { role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } }, @@ -4208,8 +4196,7 @@ describe('Claude hook normalization', () => { }), 'production' ) - // Stop event has no tool fields of its own — merged snapshot should still - // carry the earlier PreToolUse values. + // Stop event has no tool fields, so the merged snapshot must keep the earlier PreToolUse values. const stop = _internals.normalizeHookPayload( 'claude', buildBody({ hook_event_name: 'Stop' }), @@ -4236,10 +4223,7 @@ describe('Codex hook normalization', () => { }) it('PreToolUse surfaces tool name + input preview and stays in working state', () => { - // Why: Codex's PreToolUse is NOT an approval prompt — it fires for every - // tool call. We map it to `working` (never `waiting`) and use it only to - // give the dashboard a live readout during the gap between prompt and - // Stop. Real approval signals flow through PermissionRequest. + // Why: Codex's PreToolUse fires for every tool call (not an approval), so map it to `working` not `waiting`; approvals flow through PermissionRequest. const result = _internals.normalizeHookPayload( 'codex', buildBody({ @@ -4255,10 +4239,7 @@ describe('Codex hook normalization', () => { }) it('PermissionRequest maps to waiting and surfaces the pending tool input', () => { - // Why: Codex asks for user attention through PermissionRequest. Orca's - // sidebar red dot depends on this becoming `waiting`; treating it like - // PreToolUse would leave the pane looking busy while it is blocked on the - // user. + // Why: PermissionRequest must map to `waiting` (sidebar red dot); treating it like PreToolUse would leave the pane looking busy while blocked on the user. const result = _internals.normalizeHookPayload( 'codex', buildBody({ @@ -4275,9 +4256,7 @@ describe('Codex hook normalization', () => { }) it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => { - // Why: UserPromptSubmit is a turn-boundary event; any tool_name on it - // would be leftover noise and should not leak into the working-state - // preview. Tool extraction is gated to PreToolUse/PostToolUse. + // Why: UserPromptSubmit is a turn boundary; tool extraction is gated to Pre/PostToolUse so stray tool_name can't leak into the preview. const result = _internals.normalizeHookPayload( 'codex', buildBody({ @@ -4419,11 +4398,7 @@ describe('OpenCode hook normalization', () => { }) it('SessionBusy does NOT clear the cached user prompt', () => { - // Why: OpenCode emits the user's MessagePart (message.updated) *before* - // SessionBusy fires — the session goes idle→busy only after OpenCode begins - // processing the prompt. So the cached prompt at SessionBusy is the current - // turn's prompt, not the previous turn's. Clearing on SessionBusy would - // clobber the data the dashboard needs to render for this turn. + // Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard. _internals.normalizeHookPayload( 'opencode', buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }), @@ -4458,12 +4433,7 @@ describe('OpenCode hook normalization', () => { }) it('AskUserQuestion maps to waiting', () => { - // Why: OpenCode emits `question.asked` when the agent uses an ask-the-user - // tool (distinct from `permission.asked`, which blocks on tool approval). - // Both leave the agent idle-but-waiting on a human, so both must render - // the same red "needs attention" indicator. Without this mapping the pane - // silently stays in `working` and the user has no visual cue that the - // agent is waiting on them. + // Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`. const result = _internals.normalizeHookPayload( 'opencode', buildBody({ hook_event_name: 'AskUserQuestion' }), @@ -4514,10 +4484,7 @@ describe('OpenCode hook normalization', () => { }) it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => { - // Why: plugin builds installed before the throttle/cap fix re-post the - // full accumulated reply on every streamed part update. The listener must - // bound the text so each event's status compare, IPC fanout, and renderer - // store update stay O(cap) instead of O(reply length). + // Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap). const assistant = _internals.normalizeHookPayload( 'opencode', buildBody({ @@ -4529,9 +4496,7 @@ describe('OpenCode hook normalization', () => { ) expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000) - // Why: prompt has always been single-line-capped at 200 by - // normalizeAgentStatusObject; this asserts the oversized input still - // flows through without blowing past that bound. + // Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound. const user = _internals.normalizeHookPayload( 'opencode', buildBody({ @@ -4656,8 +4621,7 @@ describe('Cursor hook normalization', () => { }), 'production' ) - // Why: keeping toolName set would let the compact sidebar show the tool - // instead of the failure text, hiding the error from the user. + // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error. expect(failed?.payload).toMatchObject({ state: 'working', lastAssistantMessage: 'file not found' @@ -5193,8 +5157,7 @@ describe('Pi hook normalization', () => { buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }), 'production' ) - // Why: pi captures the user prompt via before_agent_start, not via - // message_end. A user-role message_end should not flip lastAssistantMessage. + // Why: pi captures the user prompt via before_agent_start, so a user-role message_end must not flip lastAssistantMessage. expect(result?.payload.lastAssistantMessage).toBeUndefined() }) @@ -5214,8 +5177,7 @@ describe('Pi hook normalization', () => { buildBody({ hook_event_name: 'session_shutdown' }), 'production' ) - // Why: Pi also emits shutdown when reloading or replacing its in-process - // session while the PTY stays alive; only agent_end proves turn completion. + // Why: Pi emits shutdown on reload/replace while the PTY stays alive; only agent_end proves turn completion. expect(result).toBeNull() }) @@ -5324,8 +5286,7 @@ describe('Copilot hook normalization', () => { }), 'production' ) - // Why: keeping toolName set would let the compact sidebar show the tool - // instead of the failure text, hiding the error from the user. + // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error. expect(failed?.payload).toMatchObject({ state: 'working', lastAssistantMessage: 'command not found' @@ -5715,9 +5676,7 @@ describe('Endpoint file lifecycle', () => { await server.start({ env: 'production', userDataPath }) try { const filePath = server.endpointFilePath! - // Why: mask off type/setuid bits so we assert only the rwx octet that - // writeFileSync(mode:0o600) sets. A leaky umask at dir-create time can - // leave group/other bits on the *parent* dir but not on the file itself. + // Why: mask to the rwx octet so we assert only the file's mode:0o600, not umask-leaked bits on the parent dir. const mode = statSync(filePath).mode & 0o777 expect(mode).toBe(0o600) } finally { @@ -5739,19 +5698,11 @@ describe('Endpoint file lifecycle', () => { const secondToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN // Path is stable (so PTYs stamped before restart can still find the file) expect(secondPath).toBe(firstPath) - // But contents are refreshed with the new token (and port) — that is the - // whole point of the design: survivors reading a stale-env file reach the - // live server. Why token-first: the token is randomUUID()-minted per - // start(), so it is guaranteed to differ across restarts. The port comes - // from listen(0) and the kernel can legitimately reassign the same - // ephemeral port, so asserting port-inequality would be a latent flake. + // Contents refresh with a new token so stale-env survivors reach the live server. expect(secondToken).toBeTruthy() expect(secondToken).not.toBe(firstToken) const contents = readFileSync(secondPath!, 'utf8') - // Why: token-based content check is the rewrite signal. A strict - // "contents does NOT contain firstPort" assertion would flake on the - // (rare but legitimate) case where listen(0) reuses the same ephemeral - // port across restarts. The token is randomUUID() and cannot collide. + // Why: assert on token (randomUUID, can't collide), not port — listen(0) may legitimately reuse the ephemeral port and flake a port check. expect(contents).toContain(`ORCA_AGENT_HOOK_PORT=${secondPort}`) expect(contents).toContain(`ORCA_AGENT_HOOK_TOKEN=${secondToken}`) expect(contents).not.toContain(`ORCA_AGENT_HOOK_TOKEN=${firstToken}`) @@ -5761,13 +5712,7 @@ describe('Endpoint file lifecycle', () => { }) it('leaves the endpoint file in place on stop()', async () => { - // Why: stop() deliberately does NOT unlink the endpoint file. A stale file - // points at a dead port — the fail-open path (hook POSTs silently fail, - // same as pre-endpoint-file). Unlinking would introduce a TOCTOU race with a - // concurrent Orca instance sharing userData that could rewrite the file - // between our token check and unlink. The next successful start() - // overwrites the file atomically; tmp-file orphan hygiene is handled by - // the sweep inside writeEndpointFile(). + // Why: stop() leaves the file (stale = fail-open); unlinking would race a concurrent Orca instance rewriting it between token-check and unlink (TOCTOU). const server = new AgentHookServer() await server.start({ env: 'production', userDataPath }) const filePath = server.endpointFilePath! @@ -5825,9 +5770,7 @@ describe('Endpoint file lifecycle', () => { }) it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => { - // Why: the endpoint file is opt-in via start({ userDataPath }). In tests - // and in the packaged main-process path where userData is unset for any - // reason, hooks should fall back to the v1 behavior (no ENDPOINT key). + // Why: the endpoint file is opt-in via userDataPath; without it, hooks fall back to v1 behavior (no ENDPOINT key). const server = new AgentHookServer() await server.start({ env: 'production' }) try { @@ -5846,10 +5789,7 @@ describe('Endpoint file lifecycle', () => { }) it('sweeps stale .endpoint-*.tmp orphans older than 5 minutes on start', async () => { - // Why: writeEndpointFile() writes to a unique tmp path then renames. A crash - // between write and rename leaves an orphan tmp; the sweep inside - // writeEndpointFile() must drop ones older than 5 min without touching - // fresh ones (a concurrent writer's in-flight tmp). + // Why: a crash between tmp-write and rename orphans a tmp; sweep must drop stale ones (>5min) but spare a concurrent writer's fresh in-flight tmp. const dir = join(userDataPath, 'agent-hooks') mkdirSync(dir, { recursive: true }) const staleTmp = join(dir, '.endpoint-999-stale.tmp') @@ -5870,12 +5810,7 @@ describe('Endpoint file lifecycle', () => { }) it('refuses to write the endpoint file when a value contains shell metacharacters', async () => { - // Why: every value written is sourced as shell. The isShellSafeEndpointValue - // allowlist must reject a metacharacter-bearing value so a future caller - // cannot command-inject via the sourced file. `env` is the only caller- - // provided field we can easily poison from a test — feed it a semicolon - // and assert the file is not written and buildPtyEnv() omits the ENDPOINT - // key (gated on endpointFileWritten). + // Why: written values are sourced as shell, so isShellSafeEndpointValue must reject metacharacters to prevent command injection. const server = new AgentHookServer() await server.start({ env: 'bad;value', userDataPath }) try { @@ -5960,10 +5895,7 @@ describe('Endpoint file lifecycle', () => { try { const filePath = server.endpointFilePath! const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT - // Why: sources the file in a subshell and echoes the resulting env var, - // exactly as the managed hook script does at runtime. If the file shape - // ever drifts from `KEY=VALUE` (e.g. someone adds shell metacharacters - // without quoting), this test catches it before users do. + // Why: source the file exactly as the managed hook script does, catching drift from the KEY=VALUE shape before users do. const out = execFileSync('/bin/sh', ['-c', `. "${filePath}" && echo "$ORCA_AGENT_HOOK_PORT"`]) .toString() .trim() @@ -5989,9 +5921,7 @@ describe('Last-status persistence', () => { return join(userDataPath, 'agent-hooks', 'last-status.json') } - // Why: hydrate now drops entries older than 7d (HYDRATE_MAX_AGE_MS). Use - // a recent-but-not-Date.now() timestamp in fixtures so the tests assert - // hydration behavior rather than racing the wall clock. + // Why: use a recent-but-not-now timestamp so fixtures survive hydrate's 7d drop (HYDRATE_MAX_AGE_MS) without racing the wall clock. function recentTs(offsetMs = 0): number { return Date.now() - 60 * 60 * 1000 + offsetMs } @@ -6305,8 +6235,7 @@ describe('Last-status persistence', () => { subagents: undefined }) ]) - // Why: make the migration one-time; otherwise every launch reparses and - // re-prunes the same persisted idle rows. + // Why: migration must be one-time, else every launch re-prunes the same persisted idle rows. const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) expect(persisted.entries[PANE].payload.subagents).toBeUndefined() } finally { @@ -6614,9 +6543,7 @@ describe('Last-status persistence', () => { entries: { [TAB_A_PANE]: { paneKey: TAB_A_PANE, - // Why: deliberately divergent — paneKey says tab-A, the entry - // claims tab-B. Sanitizer must drop rather than hydrate this - // inconsistent row. + // Why: paneKey says tab-A but entry claims tab-B; sanitizer must drop the inconsistent row, not hydrate it. tabId: 'tab-B', receivedAt: recentTs(), stateStartedAt: recentTs(-1000), @@ -6676,14 +6603,10 @@ describe('Last-status persistence', () => { server.flushStatusPersistSync() const firstMtime = statSync(lastStatusPath()).mtimeMs - // Why: a no-op clearPaneState on a paneKey not in the cache is a - // mutation site that should NOT trigger a redundant write. (clear was - // designed to bail when nothing was evicted.) + // Why: clearPaneState on a paneKey not in the cache must not trigger a redundant write (clear bails when nothing was evicted). server.clearPaneState(makePaneKey('non-existent', LEAF_5)) server.flushStatusPersistSync() - // Touch back to the same mtime would let the test pass spuriously, so - // assert no rewrite happened by checking that mtime is unchanged after - // a forced sync flush. + // Assert no rewrite happened: mtime unchanged after a forced sync flush. const secondMtime = statSync(lastStatusPath()).mtimeMs expect(secondMtime).toBe(firstMtime) } finally { @@ -6706,8 +6629,7 @@ describe('Last-status persistence', () => { } finally { server.stop() } - // Why: file written even though we never explicitly flushed before stop — - // stop() must synchronously drain the pending trailing-debounced timer. + // Why: stop() must synchronously drain the pending trailing-debounced timer even though we never explicitly flushed. expect(existsSync(lastStatusPath())).toBe(true) const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me') @@ -7114,9 +7036,7 @@ describe('AgentHookServer ingestRemote', () => { const server = new AgentHookServer() const listener = vi.fn() server.setListener(listener) - // Why: bypass parseAgentStatusPayload (which itself rejects bad states) by - // constructing an obviously-invalid payload — `ingestRemote` is the trust - // boundary we're testing, not the parser. + // Why: bypass parseAgentStatusPayload with an invalid payload — ingestRemote is the trust boundary under test, not the parser. server.ingestRemote( { paneKey: PANE, @@ -7281,10 +7201,7 @@ describe('AgentHookServer ingestRemote', () => { }) it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => { - // Why: the relay normally normalizes the payload on the wire, but a buggy - // or malicious relay could forward an over-cap field. ingestRemote must - // re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH - // cap (200 chars) is enforced at the trust boundary. + // Why: a buggy/malicious relay could forward an over-cap field, so ingestRemote re-runs the normalizer to enforce the AGENT_STATUS_MAX_FIELD_LENGTH cap at the trust boundary. const server = new AgentHookServer() const listener = vi.fn() server.setListener(listener) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 4c5d4c80cc1..d02642b1111 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,15 +1,5 @@ /* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ -// Why: this module is the Orca-main-process adapter for the shared -// agent-hook listener pipeline (`src/shared/agent-hook-listener.ts`). The -// listener internals (request parsing, payload normalization, endpoint-file -// writing, validation) live in `shared/` so the relay can host the same -// pipeline on the remote without dragging Electron in. This file owns: -// - the loopback HTTP socket + bearer-token auth -// - the IPC fanout (setListener / lastStatusByPaneKey replay) -// - the `ingestRemote` entry point that bypasses HTTP for relay-forwarded -// events (see docs/design/agent-status-over-ssh.md §5) -// - the on-disk last-status cache (`last-status.json`) that survives -// Orca restart so retained dashboard rows reappear on relaunch +// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron. See docs/design/agent-status-over-ssh.md §5. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { createHash, randomBytes, randomUUID } from 'node:crypto' import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' @@ -74,14 +64,7 @@ import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn export type { AgentHookSource } -// Why: server-process-only enrichment of the shared event payload. The shared -// listener emits `AgentHookEventPayload` (the bare event shape). For -// persistence and the dashboard's "did the agent transition since I last -// looked?" comparison, we attach `receivedAt` (when the latest event arrived -// for this pane) and `stateStartedAt` (when the current state first appeared). -// Stored in `state.lastStatusByPaneKey` via assignability — `AgentHookEventPayload` -// is the declared map value, and the extra fields ride along untouched because -// the shared module only writes/clears, never reads. +// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). type EnrichedAgentHookEventPayload = AgentHookEventPayload & { receivedAt: number stateStartedAt: number @@ -103,42 +86,24 @@ type PaneKeyAliasEntry = { authorityVerified: boolean } -// Why: name of the on-disk cache that survives Orca restart. Lives next to -// the endpoint file in userData/agent-hooks/ so all hook-server-owned cross- -// restart artifacts stay co-located. +// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. const LAST_STATUS_FILE_NAME = 'last-status.json' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 -// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch -// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a -// developer who upgrades from an in-flight branch sees an empty hydration -// once instead of partially-typed legacy entries. New file format; never -// shipped to users at v1. A mismatched version is treated as a corrupt file -// (silent empty hydration). +// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). const LAST_STATUS_FILE_VERSION = 2 -// Why: trailing-edge debounce so a burst of hook events from a multi-agent -// run produces one disk write instead of N. The latency budget matches other -// hook-server batching; quit-time uses flushStatusPersistSync() for the -// guaranteed final flush. +// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. const STATUS_PERSIST_DEBOUNCE_MS = 250 const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) -// Why: bound the on-disk file's growth across many sessions. PTY-teardown -// eviction handles closed panes, but daemon-restored PTYs that never re-attach -// and crash-recovery paths where teardown never fires can leave entries -// pinned forever. 7 days matches the user-visible "still relevant?" horizon — -// older entries have almost certainly been resolved or abandoned and should -// not resurrect on hydrate. +// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 -// Why: closed-tab suppression only needs to cover recently closed tabs — a -// status event for a long-closed tab cannot arrive once its process/hooks are -// gone. Bound the set so it can't grow one entry per tab close for the whole -// session (it is otherwise only cleared at app quit). +// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 export const PANE_KEY_ALIASES_MAX = 1024 @@ -176,8 +141,7 @@ function equivalentInterruptAgentType( return normalizedActual === normalizedBaseline } -// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix -// at write/hydrate time so legacy numeric rows fail closed. +// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. export function isValidPaneKey(value: unknown): value is string { return ( typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null @@ -194,16 +158,14 @@ function dropHydratedIdleClaudeSubagents( return payload } const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working') - // Why: older builds persisted finished Claude children as idle rows. Prune - // them from the replay payload itself so restart cannot resurrect the pile. + // Why: older builds persisted finished Claude children as idle rows; prune them so restart can't resurrect the pile. return { ...payload, subagents: workingSubagents.length > 0 ? workingSubagents : undefined } } -// Why: the sole gate deciding whether a providerSessionOnly row is trustworthy -// enough to keep. Shared so the hydrate and relay-ingest paths can't drift. +// Why: the sole gate for keeping a providerSessionOnly row; shared so hydrate and relay-ingest can't drift. function isValidPiProviderSessionOnly( providerSession: AgentProviderSessionMetadata | undefined, agentType: AgentType | undefined @@ -230,9 +192,7 @@ function sanitizeHydratedEntry( if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { return null } - // Why: paneKey is `${tabId}:${leafUuid}`; a stored entry whose tabId field - // diverges from the key's tab segment is corruption (renamer bug, manual - // edit, future shape drift). Drop instead of hydrating an inconsistent row. + // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { return null } @@ -252,8 +212,7 @@ function sanitizeHydratedEntry( ) { return null } - // Why: connectionId is allowed to be null (local) or string (relay). Any - // other shape is rejected so the post-merge typed surface stays honest. + // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. const connectionIdRaw = record.connectionId let connectionId: string | null if (connectionIdRaw === null || connectionIdRaw === undefined) { @@ -307,10 +266,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta } } -// Why: OSC-only dedupe (ingestTerminalStatus). Deliberately omits `subagents`: -// OSC payloads never carry them, and including the field would make every -// hook-cached entry with child rows non-equivalent — the OSC ping would then -// apply and wipe the roster. Do not reuse this for hook-path comparisons. +// Why: OSC-only dedupe; omits `subagents` (OSC never carries them) so an OSC ping can't wipe the hook-cached roster. Don't reuse for hook comparisons. function equivalentParsedAgentStatusPayload( a: ParsedAgentStatusPayload, b: ParsedAgentStatusPayload @@ -345,8 +301,7 @@ function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boole if (next.payload.agentType !== 'claude') { return false } - // Why: a same-prompt retry is another UserPromptSubmit, while late Claude - // progress after Ctrl+C arrives as tool lifecycle work for the old turn. + // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) } @@ -378,10 +333,7 @@ function shouldKeepClaudePermissionVisible( if (isClaudePermissionResumingApprovedTool(previous, next)) { return false } - // Why: only real permission requests stay sticky across concurrent subagent - // activity; interactive questions clear on the next working hook. Newer - // Claude reports the AskUserQuestion wait AS a PermissionRequest, so the - // tool name — not the event name — decides which rule applies. + // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. if (isAskUserQuestionTool(previous.payload.toolName)) { return false } @@ -427,10 +379,7 @@ function isClaudePermissionResumingApprovedTool( (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && nextToolUseId !== undefined && !hasConflictingToolUseId && - // Why: subagents can share `agent_type`; a concrete agent id is the - // strongest available signal that the permission owner resumed execution. - // Claude's approval path omits identity but preserves the original - // tool_use_id on PostToolUse, so that exact id is also a safe clear signal. + // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && sameToolName && (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) @@ -484,8 +433,7 @@ function attachClaudePermissionToolUseId( } return { ...next, - // Why: Claude emits PermissionRequest without tool_use_id, then reports the - // approved command as PostToolUse with the original PreToolUse id. + // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. toolUseId: inheritedToolUseId } } @@ -494,35 +442,24 @@ export class AgentHookServer { private server: ReturnType | null = null private port = 0 private token = '' - // Why: identifies this Orca instance so hook scripts can stamp requests and - // the server can detect dev vs. prod cross-talk. Set at start() from the - // caller's knowledge of whether this is a packaged build. + // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. private env = 'production' private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null private onPaneStatusCleared: PaneStatusClearListener | null = null private statusChangeListeners = new Set() - // Why: directory that holds the on-disk endpoint file. Set via start()'s - // `userDataPath` option so the class has no direct Electron dependency - // (keeps it mockable in the vitest node environment). + // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). private endpointDir: string | null = null private endpointFilePathCache: string | null = null private endpointFileWritten = false - // Why: per-instance caches (warn-once Sets, lastPrompt/lastTool/lastStatus - // by paneKey). Held on the instance instead of as module-level Maps so - // tests can spin up multiple servers without state cross-contamination. + // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. private state: HookListenerState = createHookListenerState() - // Why: hydrated last-status rows are useful UI continuity, but they are not - // evidence of live agent work in this main-process runtime. + // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. private runtimeObservedStatusPaneKeys = new Set() private legacyPaneKeyAliases = new Map() private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null - // Why: full path to the on-disk last-status cache. Set in start() from - // userDataPath. Null when the server runs without a userDataPath (e.g. - // tests that skip the userDataPath option) — in that case, persistence is - // a no-op and only in-memory replay applies. + // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. private lastStatusFilePath: string | null = null - // Why: trailing-edge debounce timer. Captured per-instance so multiple - // server instances in the same process (tests) don't share state. + // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. private statusPersistTimer: ReturnType | null = null private assistantMessageRetryTimers = new Map>() private promptSentDedupeByPaneKey = new Map() @@ -530,9 +467,7 @@ export class AgentHookServer { private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() private connectionTimestampWatermarkById = new Map() - // Why: identity check — skip writes when the JSON-stringified contents - // exactly match the last successful disk write. Cheap protection against - // re-firing trailing timers when nothing changed. + // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. private lastWrittenJson: string | null = null setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { @@ -540,15 +475,10 @@ export class AgentHookServer { if (!listener) { return } - // Why: replay is best-effort per pane so one throwing listener call can't - // starve subsequent panes from being replayed. + // Why: replay is best-effort per pane so one throwing listener can't starve the rest. for (const payload of this.state.lastStatusByPaneKey.values()) { try { - // Why: cache values are stored as enriched payloads (with receivedAt / - // stateStartedAt). The map's declared element type from the shared - // listener is the bare AgentHookEventPayload because the shared module - // never reads from this map; only this class does, and only enriched - // values are ever inserted. + // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) } catch (err) { console.error('[agent-hooks] replay listener threw', err) @@ -567,10 +497,8 @@ export class AgentHookServer { this.onPaneStatusCleared = listener } - /** Snapshot of the current cached statuses, in the IPC-shaped form the - * renderer consumes. Used by the `agentStatus:getSnapshot` IPC after - * workspace tabs have hydrated, so the dashboard catches up on any - * hook events that fired during startup. */ + /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the + * dashboard catches up on hook events that fired during startup. */ getStatusSnapshot(): AgentStatusIpcPayload[] { return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) @@ -595,14 +523,11 @@ export class AgentHookServer { } const payload = existing.payload const agentType: AgentType | undefined = payload.agentType - // Why: Droid's Ctrl+C does not interrupt the current turn; repeated Ctrl+C - // exits the CLI, which is handled by process/PTY lifecycle cleanup. + // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. if (agentType === 'droid' && request.intent === 'ctrl-c') { return false } - // Why: these agents use the first Escape as a TUI/editor cancel. A single - // Escape can leave the turn running, so only a deliberate double Escape - // may infer an interrupted turn. + // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. if ( (agentType === 'opencode' || agentType === 'copilot') && request.intent === 'plain-escape' && @@ -610,9 +535,7 @@ export class AgentHookServer { ) { return false } - // Why: input-intent inference is a fallback for a missing final hook. A strict - // baseline match keeps a delayed timer from overwriting any newer hook, - // including same-millisecond prompt or agent identity changes. + // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. if ( payload.state !== 'working' || !equivalentInterruptAgentType(agentType, request.baselineAgentType) || @@ -623,17 +546,12 @@ export class AgentHookServer { ) { return false } - // Why: a 'working' pane can be child-driven (lead already idle, background - // subagent running). Ctrl+C at the TUI does not stop background children, - // so inferring a terminal done here would wrongly retire live child rows; - // their own hook events keep the row truthful instead. + // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. if (payload.subagents?.some((subagent) => subagent.state === 'working')) { return false } - // Why: keep the listener's Claude lead-turn record in sync — a later - // child lifecycle event would otherwise re-emit the stale pre-interrupt - // 'working' lead state and resurrect the cancelled pane. + // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. if (agentType === 'claude') { markClaudeLeadTurnInterrupted(this.state, existing.paneKey) } @@ -648,8 +566,7 @@ export class AgentHookServer { prompt: payload.prompt, agentType, interrupted: true, - // Why: idle children are display state; dropping them on an inferred - // interrupt would blank the child rows a later hook would restore. + // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) @@ -661,11 +578,8 @@ export class AgentHookServer { return true } - /** Guarded fallback for a hook Claude never sends: answering AskUserQuestion - * produces no event, so the amber wait would otherwise linger until the - * agent's next tool or turn end. The renderer reports the submit keystroke; - * this re-validates its baseline against the cached status (a racing real - * hook wins) and synthesizes the post-answer state. */ + /** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the + * renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */ inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false @@ -677,12 +591,7 @@ export class AgentHookServer { return false } const payload = existing.payload - // Why: only Claude's interactive question may clear on typed input. The - // tool name is the discriminator, not the hook event — Claude versions - // differ on whether the AskUserQuestion wait arrives as PreToolUse or - // PermissionRequest. Real permission waits (other tools) stay sticky until - // the approved tool resumes — a denied or ignored permission must keep - // demanding attention even though approving is also a keystroke. + // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. if ( payload.agentType !== 'claude' || payload.state !== 'waiting' || @@ -699,9 +608,7 @@ export class AgentHookServer { ) { return false } - // Why: sync the listener's lead-turn record too — a later child lifecycle - // event would otherwise re-emit the stale waiting state and resurrect the - // dismissed question card. + // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, @@ -754,8 +661,7 @@ export class AgentHookServer { } private markTabClosedForAgentStatus(tabId: string): void { - // Delete-then-add keeps recently closed tabs most-recent so eviction only - // sheds the oldest ids, which can no longer receive status events. + // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. this.closedAgentStatusTabIds.delete(tabId) this.closedAgentStatusTabIds.add(tabId) while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { @@ -881,8 +787,7 @@ export class AgentHookServer { promptInteractionKey }) try { - // Why: hooks prove the user submitted a turn, but do not know which UI - // launched the terminal; keep attribution low-cardinality and conservative. + // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. track('agent_prompt_sent', { agent_kind: agentKind, launch_source: 'unknown', @@ -901,15 +806,13 @@ export class AgentHookServer { const connectionClearWatermark = payload.connectionId ? this.connectionTimestampWatermarkById.get(payload.connectionId) : undefined - // Why: Date.now() can repeat across disconnect and reconnect. A remote - // replay must sort strictly after its connection's transient clear. + // Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear. const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) if (payload.connectionId) { this.connectionTimestampWatermarkById.set(payload.connectionId, now) } if (payload.providerSessionOnly) { - // Why: Pi session_start must replace stale turn state and survive snapshot - // replay, but it must not emit prompt telemetry or a fabricated status. + // Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status. const enriched = this.attachStatusTiming(payload, now) this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) @@ -953,8 +856,7 @@ export class AgentHookServer { if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { return previous } - // Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already - // stopped the turn. Do not let that stale same-turn event resurrect the row. + // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. if ( previous?.payload.state === 'done' && previous.payload.interrupted === true && @@ -1023,8 +925,7 @@ export class AgentHookServer { if (!discoveryReady) { const discovery = preparePendingGrokResultDiscovery(source, body) if (discovery) { - // Why: slug-group discovery can outlive the bounded transcript-flush - // timers. Its completion must drive the first retry deterministically. + // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. void discovery .then(() => { if (this.server) { @@ -1075,8 +976,7 @@ export class AgentHookServer { this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) return } - // Why: some agents POST Stop before their transcript/chat-history line is - // flushed. Discovery is event-driven; subsequent content retries stay timed. + // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. this.applyNormalizedStatus(normalized) } @@ -1105,8 +1005,7 @@ export class AgentHookServer { private boundPaneKeyAliases(): void { while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { - // Why: renderer-originated aliases are untrusted process-lifetime state; - // insertion-order eviction bounds both memory and per-message cleanup. + // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. const oldestKey = this.legacyPaneKeyAliases.keys().next().value if (!oldestKey) { break @@ -1150,8 +1049,7 @@ export class AgentHookServer { (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) ) } - // Why: hook status is renderer-originated evidence, not PTY ownership. - // ID-less moves are safe only after a prior verified transfer minted an alias. + // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. return alias?.authorityVerified === true } @@ -1233,8 +1131,7 @@ export class AgentHookServer { this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) } this.clearAssistantMessageRetry(previousOwnerPaneKey) - // Why: the live process keeps posting the physical source key after detach; - // persist one chain-safe mapping to whichever surface currently owns it. + // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. this.legacyPaneKeyAliases.set(physicalPaneKey, { stablePaneKey: toPaneKey, ptyId: normalizedPtyId, @@ -1298,9 +1195,7 @@ export class AgentHookServer { clearedStatusPaneKeys.add(entry.stablePaneKey) } if (shouldClearStablePaneKey) { - // Why: after hydrate, legacy rows are stored under the stable key. If - // this PTY is later proven dead before ptyPaneKey is rebuilt, alias - // cleanup is the only path that can evict that retained status. + // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. clearPaneCacheState(this.state, entry.stablePaneKey) this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) @@ -1334,8 +1229,7 @@ export class AgentHookServer { if (!stablePaneKey) { return body } - // Why: migrated and detached shells keep posting an immutable physical - // pane key; normalize both pane and tab identity to the current owner. + // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } } @@ -1388,8 +1282,7 @@ export class AgentHookServer { ) { return } - // Why: OSC terminal status is a runtime/model observation, not a hook - // prompt boundary. Keep prompt-sent telemetry tied to native hooks. + // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. this.applyNormalizedStatus({ paneKey, tabId, @@ -1399,15 +1292,7 @@ export class AgentHookServer { }) } - /** Ingest a payload that arrived over the relay JSON-RPC channel rather - * than the local HTTP server. `connectionId` is the SshChannelMultiplexer - * identity Orca holds (the wire envelope carries connectionId: null and - * Orca stamps the real value here). The relay has already normalized the - * payload via the shared listener module, but main is still the SSH trust - * boundary: re-run the canonical status normalizer before caching or - * persisting anything. The `env`/`version` fields are forwarded verbatim - * from the agent CLI's POST body on the remote and validated here so the - * warn-once diagnostics fire for real cross-build mismatches. */ + /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ ingestRemote( envelope: { paneKey: string @@ -1429,9 +1314,7 @@ export class AgentHookServer { }, connectionId: string ): void { - // Why: signature says non-empty, but the wire crosses a trust boundary — - // re-check at runtime (and trim) so a whitespace-only or empty - // connectionId can't poison caches. + // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. if (typeof connectionId !== 'string') { return } @@ -1442,9 +1325,7 @@ export class AgentHookServer { if (!envelope || typeof envelope.paneKey !== 'string') { return } - // Why: match the listener's HTTP path — `normalizeHookPayload` trims and - // length-caps paneKey before caching, so the cache key here must follow - // the same rule or remote-vs-local events for the same pane would diverge. + // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. const physicalPaneKey = envelope.paneKey.trim() const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) const parsedPaneKey = parsePaneKey(paneKey) @@ -1464,9 +1345,7 @@ export class AgentHookServer { if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { return } - // Why: mirror the HTTP path's `readStringField` behavior — trim and treat - // empty-after-trim as undefined rather than letting a literal "" leak - // into the event. + // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. const reportedTabId = envelope.tabId !== undefined && envelope.tabId.trim().length > 0 ? envelope.tabId.trim() @@ -1508,12 +1387,7 @@ export class AgentHookServer { ? envelope.toolAgentType.trim() : undefined const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined - // Why: the relay is across a trust boundary; re-run the canonical - // normalizer on the inner payload so prompt/agentType/toolName/toolInput - // length caps, embedded-newline collapse, and the `interrupted`-only-on- - // done invariant are enforced here too. Returns null on malformed input - // (including invalid state), which subsumes the prior explicit state - // check. + // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). const normalizedPayload = normalizeAgentStatusPayload(envelope.payload) if (!normalizedPayload) { return @@ -1524,9 +1398,7 @@ export class AgentHookServer { ) { return } - // Why: run the same warn-once diagnostics the HTTP path runs (cross-build - // version mismatch, dev-vs-prod env mismatch). Use `this.env` as the - // expected env so the messages match what the local server produces. + // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. warnOnHookEnvOrVersionMismatch(this.state, { version: envelope.version, env: envelope.env, @@ -1565,9 +1437,7 @@ export class AgentHookServer { this.env = options.env } if (options?.userDataPath) { - // Why: dev builds share one userData path, so callers can namespace the - // endpoint file by dev instance while packaged builds keep the stable path - // that lets long-lived PTYs reconnect after app restart. + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. this.endpointDir = options.endpointNamespace ? join(options.userDataPath, 'agent-hooks', options.endpointNamespace) : join(options.userDataPath, 'agent-hooks') @@ -1577,10 +1447,7 @@ export class AgentHookServer { this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - // Why: hydrate before binding the HTTP listener so any new hook POST - // (which goes through state.lastStatusByPaneKey.set) runs against an - // already-populated map. The renderer later pulls this map as a snapshot - // after workspace tabs are hydrated. + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. if (this.lastStatusFilePath) { this.hydrateLastStatusFromDisk() } @@ -1597,9 +1464,7 @@ export class AgentHookServer { return } - // Why: bound request time so a slow/stalled client cannot hold a socket - // open indefinitely (slowloris-style). The hook endpoints are local and - // should complete in well under a second. + // Why: bound request time so a stalled client can't hold a socket open (slowloris). req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { req.destroy() }) @@ -1625,17 +1490,14 @@ export class AgentHookServer { res.writeHead(204) res.end() } catch { - // Why: agent hooks must fail open. The receiver returns success for - // malformed payloads so a newer or broken hook never blocks the agent. + // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. res.writeHead(204) res.end() } }) await new Promise((resolve, reject) => { - // Why: swap the startup error handler on success so a later runtime - // error (e.g. EADDRINUSE during rebind, socket errors) doesn't reject - // an already-settled promise or crash the main process as unhandled. + // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. const onStartupError = (err: Error): void => { this.server?.off('listening', onListening) reject(err) @@ -1658,11 +1520,7 @@ export class AgentHookServer { } stop(): void { - // Why: flush any pending debounced write to disk BEFORE we clear the - // in-memory map. Quit-time state must be captured even if the trailing - // timer was scheduled but had not yet fired; otherwise a multi-agent - // run that ended its last hook event <250 ms before quit would lose - // that final delta on relaunch. + // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. this.flushStatusPersistSync() this.server?.close() this.server = null @@ -1675,9 +1533,7 @@ export class AgentHookServer { clearTimeout(timer) } this.assistantMessageRetryTimers.clear() - // Why: intentionally do NOT delete the endpoint file on stop(). A stale - // file points at a dead port, which matches the fail-open policy. Unlink - // would introduce a TOCTOU race vs. a concurrent Orca instance. + // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. this.endpointDir = null this.endpointFilePathCache = null this.endpointFileWritten = false @@ -1693,13 +1549,7 @@ export class AgentHookServer { this.notifyStatusChangeListeners() } - /** Why: invoked from the renderer-driven agentStatus:drop IPC when a user - * dismisses a still-active pane's status row. We must NOT wipe - * lastPromptByPaneKey or lastToolByPaneKey here — the pane's agent may - * still be alive, and the next hook event would otherwise arrive with an - * empty prompt and missing tool snapshot until a fresh UserPromptSubmit - * lands. clearPaneState (which wipes all three caches) is the right shape - * only for PTY-teardown. */ + /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ dropStatusEntry(paneKey: string): void { if (!this.deleteStatusEntry(paneKey)) { return @@ -1722,8 +1572,7 @@ export class AgentHookServer { let statusChanged = false for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { const entry = rawEntry as EnrichedAgentHookEventPayload - // Why: legacy/unstamped rows cannot be safely attributed to one host. - // Leave them for normal pane teardown instead of risking cross-host loss. + // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. if (entry.connectionId !== normalizedConnectionId) { continue } @@ -1732,14 +1581,11 @@ export class AgentHookServer { } } if (statusChanged) { - // Why: one disconnect can own many panes; persist and notify subscribers - // once so cleanup cost does not fan out with the pane count. + // Why: persist/notify once — one disconnect can own many panes. this.scheduleStatusPersist() this.notifyStatusChangeListeners() } - // Why: another host can overwrite the same pane key in main's cache while - // renderer still shows this connection's older row. Always send the - // connection cutoff, even when no current main entry matched. + // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. this.onPaneStatusCleared?.({ transient: true, connectionId: normalizedConnectionId, @@ -1837,10 +1683,7 @@ export class AgentHookServer { clearPaneState(paneKey: string): void { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - // Why: only schedule a write when we actually evicted a status entry — - // dropping prompt/tool caches for a pane that never produced a hook - // event does not change the on-disk file, and skipping the write avoids - // re-stat'ing on every dead-pane teardown. + // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) this.clearAssistantMessageRetry(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) @@ -1876,9 +1719,7 @@ export class AgentHookServer { ORCA_AGENT_HOOK_ENV: this.env, ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION } - // Why: managed hooks source this file at invocation time. Packaged builds - // use a stable file for restart handoff; dev callers pass a per-instance - // namespace so parallel `pnpm dev` runs do not steal each other's hooks. + // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. if (this.endpointFileWritten && this.endpointFilePathCache) { env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache } @@ -1912,17 +1753,13 @@ export class AgentHookServer { if (!this.lastStatusFilePath) { return } - // Why: defensive — keeps hydrate idempotent against repeated start() - // calls; production callers always have an empty map here, but a future - // re-start path must not silently merge prior-session state. + // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. this.state.lastStatusByPaneKey.clear() let raw: string try { raw = readFileSync(this.lastStatusFilePath, 'utf8') } catch (err) { - // Why: missing file is the common case (first launch). - // Other errors (EACCES, etc.) degrade to empty hydration with a single - // warn so the dashboard renders normally. + // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.warn('[agent-hooks] failed to read last-status file:', err) } @@ -1956,10 +1793,7 @@ export class AgentHookServer { let hydrated = 0 let dropped = 0 let prunedLegacyClaudeSubagents = 0 - // Why: bound disk growth — drop anything older than HYDRATE_MAX_AGE_MS so - // entries from worktrees archived weeks ago do not pile up forever. Use - // Date.now() once to keep the cutoff consistent across all entries this - // tick. + // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS for (const [paneKey, rawEntry] of Object.entries(entries)) { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) @@ -1977,16 +1811,14 @@ export class AgentHookServer { } this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) if (entry.connectionId) { - // Why: a restarted process can observe an earlier wall clock; seed - // transport ordering so new events and clears stay after disk state. + // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) this.connectionTimestampWatermarkById.set( entry.connectionId, Math.max(previousWatermark ?? -1, entry.receivedAt) ) } - // Why: preserve only working children across restart. Live activity - // confirms them; a later complete inventory may reap stale seeds. + // Why: seed only working children across restart; a later full inventory reaps stale ones. if (entry.payload.subagents) { seedClaudeSubagentRosterFromSnapshots( this.state, @@ -2005,14 +1837,10 @@ export class AgentHookServer { ) } if (dropped > 0 || prunedLegacyClaudeSubagents > 0) { - // Why: persist load-time pruning once so legacy idle rows do not consume - // parse/filter work again on every launch. + // Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch. this.runStatusPersist() } else if (hydrated > 0) { - // Why: prime from the raw on-disk bytes (not a re-serialization) so the - // dedup is robust against future shape drift in serializeStatusFile. - // Only prime when hydration was lossless — if entries were dropped - // during sanitize, the in-memory map diverges from the on-disk bytes. + // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. this.lastWrittenJson = raw } } @@ -2020,9 +1848,7 @@ export class AgentHookServer { private serializeStatusFile(): string { const entries: Record = {} for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { - // Why: defensive — never persist invalid keys even if they slipped - // into the in-memory map somehow. Same invariant the hydrate path - // enforces. + // Why: never persist invalid keys (matches the hydrate-path invariant). if (!isValidPaneKey(paneKey)) { continue } @@ -2037,8 +1863,7 @@ export class AgentHookServer { if (!this.lastStatusFilePath) { return } - // Why: each call resets the timer; the disk write fires - // STATUS_PERSIST_DEBOUNCE_MS after the LAST event in the burst. + // Why: reset the timer each call so the write fires only after the last event in a burst. if (this.statusPersistTimer) { clearTimeout(this.statusPersistTimer) } @@ -2046,9 +1871,7 @@ export class AgentHookServer { this.statusPersistTimer = null this.runStatusPersist() }, STATUS_PERSIST_DEBOUNCE_MS) - // Why: don't keep the event loop alive just for a status flush — quit - // already triggers flushStatusPersistSync(). On Node 12+ unref() is a - // no-op when called on an already-unref'd timer. + // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. if (typeof this.statusPersistTimer.unref === 'function') { this.statusPersistTimer.unref() } @@ -2100,10 +1923,7 @@ export class AgentHookServer { } } - /** Test-only accessor for the per-instance listener state. The `_internals` - * shim needs to reach this without exposing `state` on the public surface - * to renderer/main callers. AGENTS.md disallows `as unknown as X` escapes, - * so we expose a narrow getter rather than casting the private field. */ + /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ _getStateForTests(): HookListenerState { return this.state } @@ -2121,8 +1941,7 @@ export const agentHookServer = new AgentHookServer() // Why: exported for test coverage of the per-agent field extractors. export const _internals = { - // Why: bind the test-helper to the singleton's state so existing tests keep - // exercising the same caches the live server uses. + // Why: bind the test-helper to the singleton's state so tests exercise the live caches. normalizeHookPayload: ( source: AgentHookSource, body: unknown, diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 0bec7bf68fa..0ce3b7f1342 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -50,9 +50,7 @@ import type { import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' -// Why: must exceed agent-browser's internal per-command timeouts (goto defaults to 30s, -// wait can be up to 60s). Using 90s ensures the bridge never kills a command before -// agent-browser's own timeout fires and returns a proper error. +// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. const EXEC_TIMEOUT_MS = 90_000 const CONSECUTIVE_TIMEOUT_LIMIT = 3 const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 @@ -68,8 +66,7 @@ type SessionState = { // Why: track active interception patterns so they can be re-enabled after session restart activeInterceptPatterns: string[] activeCapture: boolean - // Why: store the webContentsId so we can verify the tab is still alive at execution time, - // not just at enqueue time. The queue delay can allow the tab to be destroyed in between. + // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. webContentsId: number activeProcess: ChildProcess | null } @@ -118,8 +115,7 @@ function focusedValueSetExpression( ].join('') } -// Why: rich editors reconcile only browser editing transactions; direct DOM -// fallback can look correct while leaving their model stale. +// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. function focusedRichTextEditExpression( valueExpression: string, options?: { selectAll?: boolean } @@ -166,8 +162,7 @@ type AgentBrowserExecOptions = { type EnqueueTargetedCommandOptions = { ensureSession?: boolean ensureVisible?: boolean - // Why: text-mutating commands must never fall back to the global active tab, - // which can point at a different worktree the user is currently viewing. + // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). requireScopedTarget?: boolean } @@ -181,10 +176,7 @@ function agentBrowserNativeName(): string { } function resolveAgentBrowserBinary(): string { - // Why: production builds copy the platform-specific binary into resources/ - // via electron-builder extraResources. Use Electron's resolved resourcesPath - // instead of hand-rolling ../resources so packaged macOS builds keep working - // on case-sensitive filesystems where Contents/Resources casing matters. + // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. const bundledResourcesPath = process.resourcesPath ?? (process.platform === 'darwin' @@ -195,9 +187,7 @@ function resolveAgentBrowserBinary(): string { return bundled } - // Why: in dev mode, resolve directly to the native binary inside node_modules. - // Use app.getAppPath() for a stable project root — __dirname is unreliable after - // electron-vite bundles main process code into out/main/index.js. + // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. const nmBin = join( app.getAppPath(), 'node_modules', @@ -220,9 +210,7 @@ function resolveAgentBrowserBinary(): string { return 'agent-browser' } -// Why: exec commands arrive as a single string (e.g. 'keyboard inserttext "hello world"'). -// Naive split on whitespace breaks quoted arguments. This parser respects double and -// single quotes so the value arrives as a single argument without surrounding quotes. +// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. function parseShellArgs(input: string): string[] { const args: string[] = [] let current = '' @@ -266,8 +254,7 @@ function stripAgentBrowserTargetArgs(args: string[]): string[] { return stripped } -// Why: agent-browser returns generic error messages for stale/unknown refs. -// Map them to a specific code so agents can reliably detect and re-snapshot. +// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. function classifyErrorCode(message: string): string { if (/unknown ref|ref not found|element not found: @e/i.test(message)) { return 'browser_stale_ref' @@ -511,29 +498,20 @@ function translateResult( } export class AgentBrowserBridge { - // Why: per-worktree active tab prevents one worktree's tab switch from - // affecting another worktree's command targeting. + // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. private readonly activeWebContentsPerWorktree = new Map() private activeWebContentsId: number | null = null private readonly sessions = new Map() private readonly commandQueues = new Map() private readonly processingQueues = new Set() - // Why: screenshot prep temporarily changes shared renderer paintability state. - // Per-session queues only serialize commands within one browser tab, so - // concurrent screenshots on different tabs can otherwise interleave hidden - // surface leases and blank each other's capture. + // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. private screenshotTurn: Promise = Promise.resolve() private readonly agentBrowserBin: string - // Why: when a process swap destroys a session that had active intercept patterns, - // store them here keyed by sessionName so the next ensureSession + first successful - // command can restore them automatically. + // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. private readonly pendingInterceptRestore = new Map() - // Why: two concurrent CLI calls can both enter ensureSession before either creates - // the session entry. This promise-based lock ensures only one creation proceeds. + // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. private readonly pendingSessionCreation = new Map>() - // Why: session destruction shells out to `agent-browser close`, which is async - // and keyed by session name. Recreating the same session before that close - // finishes can let the old teardown close the new daemon session. + // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. private readonly pendingSessionDestruction = new Map>() private readonly cancelledProcesses = new WeakSet() @@ -634,14 +612,12 @@ export class AgentBrowserBridge { newWebContentsId: number, previousWebContentsId?: number ): Promise { - // Why: Electron process swaps give same browserPageId but new webContentsId. - // Old proxy's webContents is destroyed, so destroy session and let next command recreate. + // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. const sessionName = `orca-tab-${browserPageId}` const session = this.sessions.get(sessionName) const oldWebContentsId = previousWebContentsId ?? session?.webContentsId const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) - // Why: save active intercept patterns before destroying so they can be restored - // on the new session after the next successful init command. + // Why: save intercept patterns before destroy so the new session can restore them after init. if (session && session.activeInterceptPatterns.length > 0) { this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) } @@ -680,10 +656,7 @@ export class AgentBrowserBridge { tabList(worktreeId?: string): BrowserTabListResult { const tabs = this.getRegisteredTabs(worktreeId) - // Why: use per-worktree active tab for the "active" flag so tab-list is - // consistent with what resolveActiveTab would pick for command routing. - // Keep this read-only though: discovery commands must not mutate the - // active-tab state that later bare commands rely on. + // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. let activeWcId = (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId const result: BrowserTabInfo[] = [] @@ -703,8 +676,7 @@ export class AgentBrowserBridge { result.push({ browserPageId: tabId, index: index++, - // Why: failed WebContents report chrome-error://, which is neither - // actionable nor the address the user asked to load. + // Why: failed WebContents report chrome-error://, not the address the user asked to load. url: loadError?.validatedUrl ?? wc.getURL() ?? '', title: wc.getTitle() ?? '', active: wcId === activeWcId, @@ -712,10 +684,7 @@ export class AgentBrowserBridge { certificateFailure }) } - // Why: if no tab has been explicitly activated yet, surface the first live - // tab as active in the listing without mutating bridge state. That keeps - // `tab list` side-effect free while still showing users which tab a bare - // command would select next. + // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. if (activeWcId == null && firstLiveWcId !== null) { activeWcId = firstLiveWcId if (result.length > 0) { @@ -725,8 +694,7 @@ export class AgentBrowserBridge { return { tabs: result } } - // Why: tab switch must go through the command queue to prevent race conditions - // with in-flight commands that target the previously active tab. + // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. async tabSwitch( index: number | undefined, worktreeId?: string, @@ -734,9 +702,7 @@ export class AgentBrowserBridge { ): Promise { return this.enqueueCommand(worktreeId, async () => { const tabs = this.getRegisteredTabs(worktreeId) - // Why: queue delay means the tab list can change between RPC arrival and - // execution time. Recompute against live webContents here so we never - // activate a tab index that disappeared while earlier commands were running. + // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) let switchedIndex = index ?? -1 let resolvedPageId = browserPageId @@ -753,14 +719,9 @@ export class AgentBrowserBridge { } const [tabId, wcId] = liveEntries[switchedIndex] this.activeWebContentsId = wcId - // Why: resolveActiveTab prefers the per-worktree map over the global when - // worktreeId is provided. Without this update, subsequent commands would - // still route to the previous tab despite tabSwitch reporting success. + // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) - // Why: `tab switch --page ` may omit --worktree because the page id is - // already a stable target. We still need to update the owning worktree's - // active-tab slot so later worktree-scoped commands follow the tab that was - // just activated instead of the previously active one. + // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. if (owningWorktreeId) { this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) } @@ -817,8 +778,7 @@ export class AgentBrowserBridge { browserPageId?: string ): Promise { await assertClipboardTextWriteWithinLimitWithYield(value) - // Why: agent-browser's CDP text insertion loses focus in Electron guests. - // Resolve the ref first, then edit through the browser's input pipeline. + // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. return this.enqueueTargetedCommand( worktreeId, browserPageId, @@ -1016,15 +976,11 @@ export class AgentBrowserBridge { wc.focus() const point = cdpButton === 'left' - ? // Why: DOM activation cannot carry Cmd/Ctrl/Alt/Shift, so modifier - // clicks use only the adjusted point and let CDP dispatch the event. + ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) : { x, y, adjusted: false, handled: false } - // Why: mobile taps should land as one atomic input operation. Sending - // move/down/up through separate CLI calls visibly hovers targets and can - // miss small controls before the click lands. - // Runtime may already activate DOM controls because mobile-emulated - // BrowserViews can ignore CDP mouse clicks for regular page taps. + // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. + // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. if (!point.handled) { await wc.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', @@ -1292,10 +1248,7 @@ export class AgentBrowserBridge { } async reload(worktreeId?: string, browserPageId?: string): Promise { - // Why: reload can trigger a process swap in Electron (site-isolation), which - // destroys the session mid-command. Use the webContents directly for reload - // instead of going through agent-browser to avoid the session lifecycle issue. - // Routed through enqueueCommand so it serializes with other in-flight commands. + // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { const wc = this.getWebContents(target.webContentsId) if (!wc) { @@ -1324,8 +1277,7 @@ export class AgentBrowserBridge { wc.on('did-finish-load', onFinish) wc.on('did-fail-load', onFail) - // Why: successful reloads must clear the fallback timer; otherwise each - // reload retains the webContents and listeners until the 10s timeout fires. + // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. fallbackTimer = setTimeout(finish, 10_000) if (typeof fallbackTimer.unref === 'function') { fallbackTimer.unref() @@ -1340,8 +1292,7 @@ export class AgentBrowserBridge { worktreeId?: string, browserPageId?: string ): Promise { - // Why: agent-browser writes the screenshot to a temp file and returns - // { "path": "/tmp/screenshot-xxx.png" }. We read the file and return base64. + // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. return this.enqueueTargetedCommand( worktreeId, browserPageId, @@ -1396,10 +1347,7 @@ export class AgentBrowserBridge { ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) : () => {} try { - // Why: after acquiring the hidden paintability lease, the compositor - // needs a short settle period to produce a painted frame. Waiting inside - // the global screenshot lock prevents another tab from changing lease - // state before the current capture actually hits CDP. + // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. await new Promise((r) => setTimeout(r, settleMs)) const raw = await this.execAgentBrowser(sessionName, commandArgs) return this.readScreenshotFromResult(raw, format) @@ -1421,9 +1369,7 @@ export class AgentBrowserBridge { ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) : () => {} try { - // Why: full-page capture still depends on the guest compositor producing - // a fresh frame. Wait after the target webview is paintable so the direct - // CDP capture sees the live page instead of a stale surface. + // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. await new Promise((r) => setTimeout(r, settleMs)) const wc = this.getWebContents(webContentsId) if (!wc) { @@ -1536,11 +1482,7 @@ export class AgentBrowserBridge { if (normalizedState) { args.push('--state', normalizedState) } - // Why: agent-browser's selector wait surface does not support `--state visible` - // or a documented per-command `--timeout`. Orca normalizes "visible" back - // to the default selector wait semantics and enforces the requested timeout - // at the bridge layer so missing selectors fail as browser_timeout instead - // of hanging until the generic runtime RPC timeout fires. + // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. return (await this.execAgentBrowser(sessionName, args, { timeoutMs: options?.timeout != null && hasCondition @@ -1589,8 +1531,7 @@ export class AgentBrowserBridge { browserPageId, async (sessionName) => { if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - // Why: agent-browser resolves this ref directly, preserving iframe, - // shadow-root, and unfocusable-target semantics for ordinary fields. + // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. await this.execAgentBrowser(sessionName, ['fill', element, '']) return { cleared: element } } @@ -1628,9 +1569,7 @@ export class AgentBrowserBridge { } async pdf(worktreeId?: string, browserPageId?: string): Promise { - // Why: agent-browser's pdf command via CDP Page.printToPDF hangs in Electron - // webviews. Use Electron's native webContents.printToPDF() which is reliable. - // Routed through enqueueCommand so it serializes with other in-flight commands. + // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { const wc = this.getWebContents(target.webContentsId) if (!wc) { @@ -1727,17 +1666,14 @@ export class AgentBrowserBridge { throw new BrowserError('browser_error', 'Debugger not attached') } - // Why: agent-browser only supports width/height/scale for `set viewport`; - // it has no `mobile` flag. Orca's CLI exposes `--mobile`, so apply the - // emulation directly through CDP to keep the public CLI contract honest. + // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: scale, mobile }) - // Why: BrowserView's compositor surface can keep the previous host size - // after metrics-only resize, which crops remote screencast clients. + // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( () => {} ) @@ -1821,9 +1757,7 @@ export class AgentBrowserBridge { }) } - // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request - // interception decisions. Currently agent-browser only operates on URL pattern-level - // routing, not individual request IDs, so the RPC/CLI interface doesn't map cleanly. + // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. // ── Capture commands ── @@ -1890,8 +1824,7 @@ export class AgentBrowserBridge { async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: strip target/session flags from raw passthrough commands so a - // caller cannot override Orca's selected browser page or CDP proxy. + // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) return await this.execAgentBrowser(sessionName, args) }) @@ -1968,8 +1901,7 @@ export class AgentBrowserBridge { return execute(sessionName, target) } - // Why: inactive browser panes are display:none in the renderer; the - // automation lease makes only this target paintable without selecting it. + // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) try { const visibleTarget = await this.refreshTargetAfterAutomationVisibility( @@ -2002,10 +1934,7 @@ export class AgentBrowserBridge { this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) } - // Why: making a parked webview paintable can re-register the same browser - // page with a new guest webContents. Tear down any stale named session now; - // DOM commands recreate immediately, direct-CDP commands let the next DOM - // command recreate against the live guest. + // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. await this.restartSessionForTarget( sessionName, visibleTarget.browserPageId, @@ -2086,8 +2015,7 @@ export class AgentBrowserBridge { throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') } - // Why: prefer per-worktree active tab to prevent cross-worktree interference. - // Fall back to global activeWebContentsId for callers that don't pass worktreeId. + // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. const preferredWcId = (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId @@ -2108,10 +2036,7 @@ export class AgentBrowserBridge { } } - // Why: persisted store state can leave ghost tabs whose webContents no longer exist. - // Skip those and pick the first live tab. Also activate it so tabList and - // subsequent resolveActiveTab calls are consistent without requiring an - // explicit tab switch after app startup. + // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. for (const [tabId, wcId] of tabs) { if (this.getWebContents(wcId)) { this.activeWebContentsId = wcId @@ -2129,14 +2054,7 @@ export class AgentBrowserBridge { ) } - // Why: text-mutating commands (inserttext/type/fill) must not silently fall - // back to the global active tab when no worktree was resolved — that tab can - // belong to a worktree the user is currently viewing, so a goal-loop agent in - // another worktree would inject text into the user's foreground webview and - // steal OS focus. A scoped (worktreeId-bearing) call is already safe because - // the candidate set is pre-filtered to that worktree, so defer to the lenient - // resolver. An unscoped call instead requires an unambiguous target: scope to - // the lone worktree with live tabs, or refuse rather than guess. + // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. private resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { if (worktreeId) { return this.resolveActiveTab(worktreeId) @@ -2177,9 +2095,7 @@ export class AgentBrowserBridge { return } - // Why: two concurrent CLI calls can both reach here before either finishes - // creating the session. Without this lock, both would create proxies and the - // second would overwrite the first, leaking the first proxy's server/debugger. + // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. const pending = this.pendingSessionCreation.get(sessionName) if (pending) { await pending @@ -2189,19 +2105,14 @@ export class AgentBrowserBridge { const createSession = async (): Promise => { const wc = this.getWebContents(webContentsId) if (!wc) { - // Why: the renderer can unregister/destroy a webview between target - // resolution and session creation. Preserve the explicit page identity - // so callers get the same error shape as a settled closed tab. + // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. throw new BrowserError( 'browser_tab_not_found', `Browser page ${browserPageId} is no longer available` ) } - // Why: agent-browser's daemon persists session state (including the CDP port) - // across Orca restarts. A stale session ignores --cdp (already initialized) and - // connects to the dead port. Must await close so the daemon forgets the session - // before we pass --cdp with the new port. + // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. await this.closeStaleAgentBrowserSession(sessionName) const proxy = new CdpWsProxy(wc) @@ -2286,13 +2197,11 @@ export class AgentBrowserBridge { const pendingCreation = this.pendingSessionCreation.get(sessionName) if (pendingCreation) { - // Why: tab close can race with stale-session cleanup before sessions.set(). - // Wait for creation to settle so a late proxy cannot survive the close. + // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. try { await pendingCreation } catch { - // Creation failures are handled by the original caller; teardown still - // needs to reject queued work and clear any partial state below. + // Creation failures are handled by the original caller; teardown still rejects queued work below. } } @@ -2305,14 +2214,11 @@ export class AgentBrowserBridge { this.sessions.delete(sessionName) this.pendingSessionCreation.delete(sessionName) - // Why: queued commands would hang forever if we just delete the queue — - // their promises would never resolve or reject. Drain and reject them. + // Why: queued commands would hang forever if we just delete the queue — drain and reject them. this.rejectQueuedCommandsForClosedSession(sessionName) if (session.activeProcess) { - // Why: queued command rejection is not enough when a daemon command is - // already running. Kill the active process so callers do not wait for the - // generic exec timeout after the session/tab has already been destroyed. + // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. this.cancelledProcesses.add(session.activeProcess) try { session.activeProcess.kill() @@ -2324,9 +2230,7 @@ export class AgentBrowserBridge { const destroy = (async (): Promise => { try { - // Why: each browser tab uses its own named agent-browser session. Closing - // without --session only tears down the default session and leaves the tab - // session's daemon process running. + // Why: each tab has its own named session — close without --session leaves this tab's daemon running. await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close']) } catch { // Session may already be dead @@ -2365,15 +2269,11 @@ export class AgentBrowserBridge { ): Promise { const session = this.sessions.get(sessionName) if (!session) { - // Why: queued commands can reach execution after a concurrent tab close - // deletes the session. Surface this as a tab lifecycle error, not an - // opaque internal bridge failure. + // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. throw this.createPageUnavailableError(sessionName) } - // Why: between enqueue time and execution time (queue delay), the webContents - // could be destroyed. Check here to give a clear error instead of letting the - // proxy fail with cryptic Electron debugger errors. + // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. if (!this.getWebContents(session.webContentsId)) { await this.destroySession(sessionName) throw this.createPageUnavailableError(sessionName) @@ -2383,18 +2283,14 @@ export class AgentBrowserBridge { const managesInterceptRoutes = commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') - // Why: --cdp is session-initialization only — first command needs it, subsequent don't. - // Pass as port number (not ws:// URL) so agent-browser hits the proxy's HTTP /json - // endpoint for target discovery. The proxy only exposes the webview, preventing - // agent-browser from picking the host renderer page. + // Why: --cdp is init-only; pass the port (not a ws:// URL) so agent-browser's /json discovery sees only the proxy's webview, not the host renderer page. const needsInit = !session.initialized if (needsInit) { const port = session.proxy.getPort() args.push('--cdp', String(port)) } - // Why: exec passthrough can produce a large argv array; spreading it into - // push risks V8 argument limits before execFile receives the command. + // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. for (const commandArg of commandArgs) { args.push(commandArg) } @@ -2412,14 +2308,11 @@ export class AgentBrowserBridge { ) } - // Why: only mark initialized after a successful command — if the first --cdp - // connection fails, the next attempt should retry with --cdp. + // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. if (needsInit) { session.initialized = true - // Why: after a process swap, intercept patterns are lost because the session - // was destroyed and recreated. Restore them now that the new session is live, - // unless the caller's first command explicitly reconfigured routing. + // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. const pendingPatterns = managesInterceptRoutes ? undefined : this.pendingInterceptRestore.get(sessionName) @@ -2437,8 +2330,7 @@ export class AgentBrowserBridge { ]) session.activeInterceptPatterns = pendingPatterns } catch { - // Why: intercept restore is best-effort — don't fail the user's command - // if the new page doesn't support the same interception setup. + // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. } } } @@ -2465,8 +2357,7 @@ export class AgentBrowserBridge { value: string ): Promise { await this.execAgentBrowser(sessionName, ['focus', element]) - // Why: stdin avoids argv limits while keeping replacement atomic; chunked - // editor transactions can move focus and split one fill across controls. + // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) }) @@ -2490,8 +2381,7 @@ export class AgentBrowserBridge { resolve() } - // Why: this is best-effort daemon cleanup before creating a fresh session; - // a wedged close command must not block the real browser action. + // Why: best-effort daemon cleanup — a wedged close must not block the real browser action. const timeout = setTimeout(() => { child?.kill() finish() @@ -2516,8 +2406,7 @@ export class AgentBrowserBridge { fallbackCode: string, webContentsId?: number ): BrowserError { - // Why: CDP "connection refused" can also mean a real proxy failure. Only - // convert it to a closed-page error when bridge state confirms the target is gone. + // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. if ( fallbackCode === 'browser_error' && isTabClosedTransportError(message) && @@ -2548,8 +2437,7 @@ export class AgentBrowserBridge { child = execFile( this.agentBrowserBin, args, - // Why: screenshots return large base64 strings that exceed Node's default - // 1MB maxBuffer, causing ENOBUFS and a timeout-like failure. + // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). { timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, maxBuffer: 50 * 1024 * 1024, @@ -2592,9 +2480,7 @@ export class AgentBrowserBridge { } if (error) { - // Why: agent-browser exits non-zero for command failures (e.g. clipboard - // NotAllowedError) but still writes structured JSON to stdout. Parse it - // so callers get the real error message instead of generic "Command failed". + // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. if (stdout) { try { const parsed = JSON.parse(stdout) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 3191509d30c..7bcfd4ea247 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,5 +1,4 @@ -/* eslint-disable max-lines -- Why: cookie import is a single pipeline (detect → decrypt → stage → swap) - that must stay together so the encryption, schema, and staging steps remain in sync. */ +/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ import { app, type BrowserWindow, dialog, session } from 'electron' import { execFileSync } from 'node:child_process' import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' @@ -19,8 +18,7 @@ import { DatabaseSync } from 'node:sqlite' import { tmpdir } from 'node:os' import { join } from 'node:path' -// Why: writing to userData instead of tmpdir() so the diag log is only -// readable by the current user, not world-readable in /tmp. +// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. let _diagLog: string | null = null function getDiagLogPath(): string { if (!_diagLog) { @@ -38,8 +36,7 @@ function reasonWithDiagLog(reason: string): string { const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 -// Why: imported cookie errors can include pasted or file-derived payloads; -// diagnostics only need a short preview, not a full-string whitespace pass. +// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. export function summarizeCookieImportError(err: unknown): string { const raw = err instanceof Error && err.message ? err.message : String(err) let summary = '' @@ -108,8 +105,7 @@ type ChromiumBrowserDef = { label: string keychainService: string keychainAccount: string - // Why: each platform stores browser data in a different location. The per-platform - // root paths are resolved at detection time via browserRootPath(). + // Per-platform data-dir roots, resolved at detection time via browserRootPath(). macRoot?: string winRoot?: string linuxRoot?: string @@ -161,8 +157,7 @@ const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ }, { family: 'helium', - // Why: Helium deviates from the ' Safe Storage'/'' convention — - // its Keychain entry is literally service 'Helium Storage Key', account 'Helium'. + // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. label: 'Helium', keychainService: 'Helium Storage Key', keychainAccount: 'Helium', @@ -208,9 +203,7 @@ function isSafeBrowserProfileDirectory(directory: string): boolean { ) } -// Why: Chrome's Local State JSON contains profile.info_cache which maps profile -// directory names (e.g. "Default", "Profile 1") to metadata including the -// user-visible display name. This lets us show human-readable names in the picker. +// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. function discoverProfiles(browserRoot: string): BrowserProfile[] { try { const localStatePath = join(browserRoot, 'Local State') @@ -267,8 +260,7 @@ function discoverFirefoxProfiles(): BrowserProfile[] { const entries = readdirSync(profilesRoot, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) - // Why: Firefox profile dirs are named . (e.g. "abc123.default-release"). - // Prefer 'default-release' as it's the primary user profile on most installs. + // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. const sorted = entries.sort((a, b) => { if (a.includes('default-release')) { return -1 @@ -360,8 +352,7 @@ export function detectInstalledBrowsers(): DetectedBrowser[] { continue } const profiles = discoverProfiles(root) - // Why: a browser is "detected" if at least one profile has a cookies DB. - // Use the first profile with a valid cookies path as the default selection. + // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. for (const profile of profiles) { const profileDir = join(root, profile.directory) const cookiesPath = resolveChromiumCookiesPath(profileDir) @@ -393,10 +384,6 @@ export function detectInstalledBrowsers(): DetectedBrowser[] { return detected } -// Why: when the user selects a different profile from the picker, we need to -// resolve the cookies path for that profile. Returns a new DetectedBrowser -// with the updated cookiesPath and selectedProfile, or null if the profile -// has no cookies DB. export function selectBrowserProfile( browser: DetectedBrowser, profileDirectory: string @@ -463,9 +450,7 @@ type ValidatedCookie = { expirationDate: number | undefined } -// Why: Chromium's SQLite schema uses CookieSameSiteForStorage enum: -// 0=UNSPECIFIED, 1=NO_RESTRICTION(None), 2=LAX, 3=STRICT. -// This differs from Firefox (0=None, 1=Lax, 2=Strict). +// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { switch (raw) { case 1: @@ -512,9 +497,7 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la return 'unspecified' } -// Why: Electron's cookies.set() requires a url field to determine the cookie's -// scope. Derive it from the domain + secure flag so the caller doesn't need -// to supply it. +// Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag. function deriveUrl(domain: string, secure: boolean): string | null { const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain if (!cleanDomain || cleanDomain.includes(' ')) { @@ -578,8 +561,7 @@ async function importValidatedCookies( let skipped = totalInput - cookies.length const domainSet = new Set() - // Why: Electron's cookies.set() rejects any non-printable-ASCII byte. - // Strip from all string fields as a safety net. + // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net. const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') for (const cookie of cookies) { @@ -596,8 +578,7 @@ async function importValidatedCookies( expirationDate: cookie.expirationDate }) importedCount++ - // Why: surface only the domain — never name, value, or path — so the - // renderer can show a useful summary without leaking secret cookie data. + // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data. const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain domainSet.add(cleanDomain) } catch (err) { @@ -638,8 +619,7 @@ async function importValidatedCookies( // Import from JSON file // --------------------------------------------------------------------------- -// Why: source selection must be main-owned via a native open dialog so a -// compromised renderer cannot turn cookie import into arbitrary file reads. +// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { const opts = { title: 'Import Cookies', @@ -714,14 +694,11 @@ export async function importCookiesFromFile( // Direct import from installed Chromium browser // --------------------------------------------------------------------------- -// Why: Google and other services bind auth cookies to the User-Agent that -// created them. We read the source browser's real version from its plist -// and construct a matching UA string so imported sessions aren't invalidated. +// Why: services bind auth cookies to the creating User-Agent, so build a UA matching the source browser's real version. export function getUserAgentForBrowser( family: BrowserSessionProfileSource['browserFamily'] ): string | null { - // Why: UA spoofing uses macOS-specific plist reading. On other platforms, - // skip UA override — the default Electron UA is acceptable. + // Why: UA version comes from macOS-only plist reading; elsewhere the default Electron UA is acceptable. if (process.platform !== 'darwin') { return null } @@ -763,14 +740,12 @@ export function getUserAgentForBrowser( return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null } case 'comet': { - // Why: Comet is Chromium-based and ships a Chrome-shaped version in its plist. - // Use the same UA shape as Chrome itself so Google-bound auth cookies survive import. + // Why: Comet is Chromium-based; use Chrome's UA shape so Google-bound auth cookies survive import. const v = readBrowserVersion('/Applications/Comet.app') return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null } case 'helium': { - // Why: Helium is Chromium-based and ships a Chrome-shaped version in its plist. - // Use the same UA shape as Chrome itself so Google-bound auth cookies survive import. + // Why: Helium is Chromium-based; use Chrome's UA shape so Google-bound auth cookies survive import. const v = readBrowserVersion('/Applications/Helium.app') return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null } @@ -805,16 +780,12 @@ function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { } } -// Why: each platform uses a different mechanism to protect the Chromium cookie encryption key. -// macOS: PBKDF2(keychain password, "saltysalt", 1003 iterations) → AES-128-CBC -// Linux: PBKDF2(keyring password or "peanuts", "saltysalt", 1 iteration) → AES-128-CBC -// Windows: DPAPI-encrypted master key from Local State → AES-256-GCM +// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. type EncryptionKeyResult = { key: Buffer mode: 'aes-128-cbc' | 'aes-256-gcm' - // Why: Linux v10 cookies use a hardcoded "peanuts" password while v11 uses the - // keyring password. We need both keys to decrypt the full cookie set. + // Why: Linux v10 cookies use "peanuts" and v11 the keyring password; both keys are needed to decrypt the full set. fallbackKey?: Buffer } @@ -926,8 +897,7 @@ export function buildChromiumCookieInsertParams( return null } - // Why: Chromium cookie DB columns drift across Chrome/Electron versions. - // Missing NOT NULL target columns must get safe Chromium defaults, not NULL. + // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. return fallbackChromiumCookieColumnValue(column, sourceRow) }) } @@ -972,9 +942,7 @@ function getLinuxEncryptionKey( keychainService: string, keychainAccount: string ): EncryptionKeyResult | null { - // Why: Linux v10 cookies use the hardcoded password "peanuts" with 1 PBKDF2 - // iteration. v11 cookies use the actual keyring password. We derive both keys - // so the decrypt function can try each based on the version prefix. + // Why: v10 cookies use hardcoded "peanuts", v11 the keyring password; derive both so decrypt can pick by version prefix. const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') let keyringPassword = '' @@ -1031,8 +999,7 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult return null } - // Why: PowerShell DPAPI decrypt is the only way to access the master key - // without native addons. The key is passed via stdin to prevent injection. + // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') const script = [ 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', @@ -1056,12 +1023,7 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult } } -// Why: Chromium 127+ prepends a 32-byte per-host HMAC to the cookie value -// before encrypting. After AES-CBC decryption, the raw output is: -// [32-byte HMAC] [actual cookie value] -// Detection: the HMAC is a hash, so roughly half its bytes are non-printable -// ASCII. Real cookie values are overwhelmingly printable. If ≥8 of the first -// 32 bytes are non-printable, it's an HMAC prefix. +// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. const CHROMIUM_COOKIE_HMAC_LEN = 32 function hasHmacPrefix(buf: Buffer): boolean { @@ -1103,8 +1065,7 @@ function decryptCookieValueRaw( return Buffer.alloc(0) } - // Why: Linux v10 uses "peanuts" key, v11 uses keyring key. Try the primary - // key first, then fallback. macOS uses the same key for both versions. + // Why: Linux v10 uses the "peanuts" key, v11 the keyring key; try primary then fallback (macOS uses one key). const keysToTry = version === 'v10' && keyResult.fallbackKey ? [keyResult.fallbackKey, keyResult.key] @@ -1175,8 +1136,7 @@ function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { } function appendSafariCookies(target: ValidatedCookie[], cookies: readonly ValidatedCookie[]): void { - // Why: Safari binary cookie pages can contain generated-size cookie lists; - // spreading a decoded page into push can exceed JavaScript's argument limit. + // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. for (const cookie of cookies) { target.push(cookie) } @@ -1215,8 +1175,7 @@ function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { if (buf.length < 48) { return null } - // Why: size is read from the binary file and could be attacker-controlled. - // Clamp to buf.length so readCString cannot escape the cookie's subarray. + // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. const size = Math.min(buf.readUInt32LE(0), buf.length) if (size < 48) { return null @@ -1402,8 +1361,7 @@ async function importCookiesFromSafari( data = readFileSync(browser.cookiesPath) } catch (err) { diag(` Safari read failed: ${err}`) - // Why: Safari's Cookies.binarycookies lives inside a macOS sandbox container. - // Reading it requires Full Disk Access in System Settings → Privacy & Security. + // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. const isPermError = err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' if (isPermError) { @@ -1459,19 +1417,9 @@ export async function importCookiesFromBrowser( return importCookiesFromSafari(browser, targetPartition) } - // Why: Electron's cookies.set() API rejects many valid cookie values (binary - // bytes > 0x7F etc). Instead, decrypt from the source browser and write - // plaintext directly to the SQLite `value` column. CookieMonster reads - // `value` as a raw byte string when `encrypted_value` is empty, bypassing - // all API-level validation. This works because Electron's CookieMonster in - // dev mode does not use os_crypt encryption — it stores cookies as plaintext. - // In packaged builds where os_crypt IS active, CookieMonster will re-encrypt - // plaintext cookies on its next flush, so this approach is safe in both modes. + // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds. - // Why: CookieMonster holds the live DB's data in memory and overwrites it - // on flush/shutdown. Writing directly to the live DB is futile. Instead, - // copy the live DB to a staging location, populate it there, and let the - // next cold start swap it in before CookieMonster initializes. + // Why: CookieMonster overwrites the live DB on flush, so stage a populated copy and swap it in at next cold start. const targetSession = session.fromPartition(targetPartition) await targetSession.cookies.flushStore() @@ -1479,10 +1427,7 @@ export async function importCookiesFromBrowser( const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName) let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - // Why: Electron only creates the partition's Cookies SQLite file after the - // session has actually stored a cookie. For newly created profiles that have - // never been used by a webview, the file won't exist yet. Setting and - // removing a throwaway cookie forces Electron to initialize the database. + // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. if (!liveCookiesPath) { try { await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) @@ -1508,8 +1453,7 @@ export async function importCookiesFromBrowser( mkdirSync(stagingDir, { recursive: true }) copyFileSync(liveCookiesPath, stagingCookiesPath) } catch { - // Why: copyFile is not atomic and can leave a partial database after an - // I/O failure, so failed imports must not retain sensitive cookie data. + // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. try { unlinkSync(stagingCookiesPath) } catch { @@ -1520,8 +1464,7 @@ export async function importCookiesFromBrowser( let sourceSnapshot: ChromiumCookieSnapshot try { - // Why: the browser can commit cookies only to WAL while it remains open; - // snapshot retries prevent pairing the main DB with a racing WAL generation. + // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) } catch (err) { try { @@ -1540,8 +1483,7 @@ export async function importCookiesFromBrowser( let stagingDb: InstanceType | null = null try { - // Why: Chromium stores timestamps as microseconds since 1601, which can exceed - // Number.MAX_SAFE_INTEGER (~9e15). readBigInts ensures no precision loss. + // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true @@ -1586,8 +1528,7 @@ export async function importCookiesFromBrowser( if (needsSourceKey && !sourceKey) { stagingDb.close() stagingDb = null - // Why: key denial happens after staging, so failed retries must not leave - // one full target database copy behind each time. + // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. try { unlinkSync(stagingCookiesPath) } catch { @@ -1599,11 +1540,7 @@ export async function importCookiesFromBrowser( } } - // Why: Google's integrity cookies (SIDCC, __Secure-*PSIDCC, __Secure-STRP) - // are cryptographically bound to the source browser's TLS fingerprint and - // environment. Importing them into a different browser causes - // accounts.google.com to reject the session with CookieMismatch. Skipping - // them lets Google regenerate fresh integrity cookies on the first request. + // Why: Google integrity cookies are bound to the source browser's TLS/env; importing them triggers CookieMismatch, so skip and let Google reissue. const INTEGRITY_COOKIE_NAMES = new Set([ 'SIDCC', '__Secure-1PSIDCC', @@ -1649,9 +1586,7 @@ export async function importCookiesFromBrowser( for (const sourceRow of sourceRows) { const encRaw = sourceRow.encrypted_value - // Why: node:sqlite returns BLOB columns as Uint8Array. Any other truthy type - // means the schema is unexpected — treat it as missing rather than creating - // an empty buffer that would silently produce a blank cookie value. + // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null const plainRaw = sourceRow.value @@ -1687,9 +1622,7 @@ export async function importCookiesFromBrowser( const httpOnly = sourceRow.is_httponly === 1n const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) - // Why: cookie values are raw byte strings, not UTF-8 text. Using latin1 - // (ISO-8859-1) preserves all byte values 0x00–0xFF without replacement - // characters that UTF-8 decoding would insert for invalid sequences. + // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. const value = decryptedValue.toString('latin1') decryptedCookies.push({ @@ -1716,18 +1649,13 @@ export async function importCookiesFromBrowser( diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) - // Why: clearing the session's in-memory cookie store before loading imported - // cookies prevents stale cookies from a previous Orca browsing session from - // mixing with the imported set. Mixed state (some old, some imported) causes - // sites like Google to detect inconsistent session cookies and reject them. + // Why: clear stale cookies first; mixing them with the imported set makes sites like Google reject the session. await targetSession.clearStorageData({ storages: ['cookies'] }) diag( ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` ) - // Why: loading cookies into memory via cookies.set() makes them available - // immediately without requiring a restart. The staging DB is kept as a - // fallback for any cookies that fail the cookies.set() validation. + // Why: load into memory via cookies.set() so imported cookies work without a restart. for (const cookie of decryptedCookies) { const url = deriveUrl(cookie.domain, cookie.secure) if (!url) { @@ -1735,8 +1663,7 @@ export async function importCookiesFromBrowser( continue } try { - // Why: __Host- prefixed cookies must not have a domain attribute and - // must have path=/. Chromium rejects them otherwise. + // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/. const isHostPrefixed = cookie.name.startsWith('__Host-') await targetSession.cookies.set({ url, @@ -1758,9 +1685,7 @@ export async function importCookiesFromBrowser( diag(` memory load: ${memoryLoaded} OK, ${memoryFailed} failed`) if (memoryFailed > 0) { - // Why: some cookies couldn't be loaded via cookies.set() (non-ASCII values - // or other validation failures). Keep the staging DB so the next cold start - // picks them up from SQLite where CookieMonster reads them without validation. + // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) } else { @@ -1799,8 +1724,7 @@ export async function importCookiesFromBrowser( } catch { /* may already be closed */ } - // Why: if the import fails after the staging DB was created, clean it up - // to avoid a stale staged import being applied on the next cold start. + // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. try { unlinkSync(stagingCookiesPath) } catch { diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 1185e48d48e..997b81f4fe6 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -1,7 +1,4 @@ -/* eslint-disable max-lines -- Why: browser guest UI policy is the single -privileged bridge for context menus, grab-mode shortcuts, and app-shortcut -forwarding from webContents guests. Splitting this rebase-only integration -would make the security boundary harder to audit. */ +/* eslint-disable max-lines -- Why: single privileged bridge for guest context menus, grab-mode, and app-shortcut forwarding; splitting would blur the security boundary. */ import { screen, webContents } from 'electron' import { normalizeBrowserNavigationUrl, @@ -101,23 +98,15 @@ export function setupGuestContextMenu(args: { if (!renderer) { return } - // Why: redact Kagi session tokens before the URL leaves main; the renderer - // pipes pageUrl into clipboard writes and shell.openExternal, both of which - // would otherwise expose the bearer token outside Orca. + // Why: redact the Kagi session token before pageUrl leaves main — the renderer pipes it into clipboard and shell.openExternal. const pageUrl = redactKagiSessionToken(guest.getURL()) - // Why: params.linkURL is empty when the user right-clicks non-link - // content. Normalizing an empty string through normalizeBrowserNavigationUrl - // produces the blank-page constant (a truthy string), which would trick the - // renderer into showing "Open Link…" items for every right-click. + // Why: empty linkURL normalized would yield the truthy blank-page constant, showing "Open Link…" on every non-link right-click. const rawLinkUrl = params.linkURL || '' const linkUrl = rawLinkUrl.length > 0 ? (normalizeExternalBrowserUrl(rawLinkUrl) ?? normalizeBrowserNavigationUrl(rawLinkUrl)) : null - // Why: send BOTH the guest viewport coordinates AND the OS screen cursor - // position. The renderer will try the screen cursor approach (which is - // immune to guest/renderer coordinate space mismatches) and fall back to - // guest coords if the screen API is unavailable. + // Why: send both viewport and screen-cursor coords; screen cursor avoids coordinate-space mismatch, guest coords are the fallback. const cursor = screen.getCursorScreenPoint() const navigationState = readGuestNavigationState(guest) renderer.send('browser:context-menu-requested', { @@ -128,18 +117,13 @@ export function setupGuestContextMenu(args: { screenY: cursor.y, pageUrl, linkUrl, - // Why: forward the native selection so the renderer can offer a Copy that - // writes it to the clipboard directly, bypassing pages that suppress copy - // via oncopy handlers (the reported bug — the selection is never re-read - // through a page-visible copy event). + // Why: forward the native selection so the renderer can Copy it directly, bypassing pages that suppress copy via oncopy handlers. selectionText: params.selectionText ?? '', ...navigationState }) } - // Why: `before-mouse-event` fires for every mouse event (move, down, up, - // scroll) on the guest. Installing the dismiss listener only while a context - // menu is open avoids an IPC dispatch per mouse event on idle guests. + // Why: before-mouse-event fires on every move/scroll; install the dismiss listener only while a menu is open to avoid per-event IPC. let dismissHandler: ((_event: Electron.Event, mouse: Electron.MouseInputEvent) => void) | null = null @@ -162,10 +146,7 @@ export function setupGuestContextMenu(args: { if (mouse.type !== 'mouseDown') { return } - // Why: a right-click mouseDown will be followed by a new context-menu - // event with updated coordinates. Sending a dismiss here would cause - // the renderer to briefly close the menu (trigger snaps to 0,0) then - // reopen it, producing a visible flash at the top-left corner. + // Why: a right-click mouseDown precedes a new context-menu event; dismissing here flashes the menu closed then reopens it at 0,0. if (mouse.button === 'right') { return } @@ -185,20 +166,12 @@ export function setupGuestContextMenu(args: { guest.off('context-menu', contextMenuHandler) removeDismissListener() } catch { - // Why: browser tabs can outlive the guest webContents briefly during - // teardown. Cleanup should be best-effort instead of throwing while the - // IDE is closing a tab. + // Why: browser tabs can briefly outlive the guest webContents during teardown, so cleanup is best-effort. } } } -// Why: browser grab mode intentionally uses Cmd/Ctrl+C as its entry -// gesture, but a focused webview guest is a separate Chromium process so -// the renderer's window-level keydown handler never sees that shortcut. -// Only forward the chord when Chromium would not perform a normal copy: -// no editable element is focused and there is no selected text. That keeps -// native page copy working while still making the grab shortcut reachable -// from focused web content. +// Why: a focused guest never surfaces Cmd/Ctrl+C to the renderer; forward only when it wouldn't do a normal copy (no editable focus, no selection). export function setupGrabShortcutForwarding(args: { browserTabId: string guest: Electron.WebContents @@ -224,9 +197,7 @@ export function setupGrabShortcutForwarding(args: { if (!renderer) { return } - // Why: a focused guest swallows bare keys before the renderer sees them. - // While grab mode is actively awaiting a pick, plain C/S belong to Orca's - // copy/screenshot shortcuts rather than the page's typing behavior. + // Why: a focused guest swallows bare keys; during an active grab pick, plain C/S are Orca's copy/screenshot, not page typing. event.preventDefault() renderer.send('browser:grabActionShortcut', { browserPageId: browserTabId, key: bareKey }) return @@ -268,8 +239,7 @@ export function setupGrabShortcutForwarding(args: { renderer.send('browser:grabModeToggle', browserTabId) }) .catch(() => { - // Why: shortcut forwarding is best-effort. Guest teardown or a - // transient executeJavaScript failure should not break normal copy. + // Why: shortcut forwarding is best-effort — guest teardown or a transient executeJavaScript failure must not break normal copy. }) } @@ -278,17 +248,12 @@ export function setupGrabShortcutForwarding(args: { try { guest.off('before-input-event', handler) } catch { - // Why: browser tabs can outlive the guest webContents briefly during - // teardown. Cleanup should be best-effort. + // Why: browser tabs can briefly outlive the guest webContents during teardown, so cleanup is best-effort. } } } -// Why: a focused webview guest is a separate Chromium process — keyboard -// events go to the guest's own webContents and never fire the renderer's -// window-level keydown handler or the main window's before-input-event. -// Intercept common app shortcuts on the guest and forward them to the -// renderer so they work consistently regardless of which surface has focus. +// Why: a focused webview guest is its own Chromium process whose key events never reach the renderer; forward shortcuts from here. export function setupGuestShortcutForwarding(args: { browserTabId: string guest: Electron.WebContents @@ -326,8 +291,7 @@ export function setupGuestShortcutForwarding(args: { ): boolean => { const keybindings = getKeybindings?.() if (action?.type === 'zoom') { - // Why: focused browser guests own page zoom, but their key events never - // reach the renderer-owned webview ref that can apply Orca's page zoom. + // Why: focused guest key events never reach the renderer-owned webview ref that applies Orca's page zoom. forwardBrowserPageZoom(event, action.direction) return true } @@ -339,12 +303,7 @@ export function setupGuestShortcutForwarding(args: { return false } if (action?.type === 'worktreeHistoryNavigate') { - // Why: preventDefault unconditionally — if we cannot resolve the - // renderer (torn-down tab or teardown race), dropping the keystroke - // into the guest's webContents would let Chromium / the guest page - // handle Cmd+Alt+Arrow as their own chord (e.g. guest-side text - // navigation). Consistency with the main-window path is preserved - // only by suppressing the event here too. + // Why: preventDefault unconditionally so the guest never handles Cmd+Alt+Arrow itself, even when the renderer can't be resolved. event.preventDefault() const renderer = resolveRenderer(browserTabId) renderer?.send('ui:worktreeHistoryNavigate', action.direction) @@ -358,9 +317,7 @@ export function setupGuestShortcutForwarding(args: { return true } - // Why: match this action outside the main-process shortcut allowlist so - // both the fresh Shift binding and upgrading users' seeded Alt binding - // reach the renderer instead of the focused guest page. + // Why: match outside the allowlist so both the new Shift binding and upgraders' seeded Alt binding reach the renderer. const switchAllTypesDirection = keybindingMatchesAction( 'tab.nextAllTypes', input, @@ -385,8 +342,7 @@ export function setupGuestShortcutForwarding(args: { return true } - // Why: terminal-only tab switching defaults to Ctrl+PageUp/PageDown on every - // platform, but still goes through the registry so disable/rebind is real. + // Why: terminal-tab switching defaults to Ctrl+PageUp/PageDown but goes through the registry so disable/rebind still works. const terminalTabDirection = keybindingMatchesAction( 'tab.nextTerminal', input, @@ -419,45 +375,29 @@ export function setupGuestShortcutForwarding(args: { } else if (keybindingMatchesAction('tab.newMarkdown', input, process.platform, keybindings)) { renderer.send('ui:newMarkdownTab') } else if (keybindingMatchesAction('tab.newTerminal', input, process.platform, keybindings)) { - // Why: Cmd/Ctrl+T opens a terminal in the user's active terminal surface - // even when focus is inside a browser guest. Cmd/Ctrl+Shift+B is the - // dedicated shortcut for new browser tabs. + // Why: Cmd/Ctrl+T opens a terminal even when a browser guest is focused (Shift+B is the new-browser-tab shortcut). renderer.send('ui:newTerminalTab') } else if ( keybindingMatchesAction('browser.focusAddressBar', input, process.platform, keybindings) ) { - // Why: the address bar lives in the renderer chrome, not the guest - // page. Forward Cmd/Ctrl+L out of the guest so the active BrowserPane - // can focus its own input just like a standalone browser would. + // Why: the address bar lives in renderer chrome, not the guest page; forward so the active BrowserPane can focus its input. renderer.send('ui:focusBrowserAddressBar') } else if ( keybindingMatchesAction('browser.hardReload', input, process.platform, keybindings) ) { - // Why: Cmd/Ctrl+Shift+R is the browser convention for hard reload - // (bypass cache). The guest would handle it natively, but Orca's webview - // reloadIgnoringCache() call must come from the renderer side so it goes - // through the same parked-webview ref that owns the guest surface. + // Why: forward hard reload so reloadIgnoringCache() runs on the renderer's parked-webview ref that owns the guest surface. renderer.send('ui:hardReloadBrowserPage') } else if (keybindingMatchesAction('browser.reload', input, process.platform, keybindings)) { - // Why: same as above for soft reload — Cmd/Ctrl+R must be forwarded so - // the renderer can call reload() on its own webview ref rather than - // relying on the guest's built-in shortcut, which may not reach the - // parked-webview eviction logic. + // Why: forward soft reload so the renderer's reload() hits the parked-webview eviction the guest's built-in shortcut skips. renderer.send('ui:reloadBrowserPage') } else if (keybindingMatchesAction('browser.find', input, process.platform, keybindings)) { - // Why: Cmd/Ctrl+F must be forwarded out of the guest so the renderer can - // open its own find-in-page bar and call webview.findInPage(). Letting the - // guest handle it natively would open Chromium's built-in find UI inside - // the guest frame, which is invisible behind Orca's chrome. + // Why: guest-native find UI is invisible behind Orca's chrome; forward so the renderer opens its own find-in-page bar. renderer.send('ui:findInBrowserPage') } else if (keybindingMatchesAction('browser.back', input, process.platform, keybindings)) { - // Why: macOS Logitech side-button remaps arrive as browser history - // keystrokes, not mouse/app-command events. Forward out of the guest so - // the renderer-owned webview ref can call goBack(). + // Why: macOS Logitech side-button remaps arrive as history keystrokes, not mouse events; forward so the renderer can goBack(). renderer.send('ui:browserHistoryNavigate', 'back') } else if (keybindingMatchesAction('browser.forward', input, process.platform, keybindings)) { - // Why: same as browser.back; the focused guest cannot call the - // renderer-owned parked webview's goForward() path directly. + // Why: same as browser.back; the focused guest cannot call the renderer-owned webview's goForward() directly. renderer.send('ui:browserHistoryNavigate', 'forward') } else if (keybindingMatchesAction('tab.close', input, process.platform, keybindings)) { renderer.send('ui:closeActiveTab') @@ -495,8 +435,7 @@ export function setupGuestShortcutForwarding(args: { } else { return false } - // Why: preventDefault stops the guest page from also processing the chord - // (e.g. Cmd+T opening a browser-internal new-tab page). + // Why: preventDefault stops the guest page from also processing the chord (e.g. Cmd+T opening a browser-internal new-tab page). event.preventDefault() return true } @@ -552,11 +491,7 @@ export function setupGuestShortcutForwarding(args: { if (input.type !== 'keyDown') { return } - // Why: resolve the policy action once per keystroke. The history-navigate - // chord (Cmd/Ctrl+Alt+Arrow) is the only allowlisted chord that carries - // Alt and must be handled before the generic modifier-chord gate below, - // which rejects Alt. Every other chord handled further down can reuse - // the same `action` rather than re-running the full predicate chain. + // Why: Cmd/Ctrl+Alt+Arrow is the only allowlisted chord carrying Alt, so resolve it before the Alt-rejecting chord gate below. const action = resolveWindowShortcutAction(input, process.platform, keybindings) forwardShortcutInput(event, input, action) } @@ -568,8 +503,7 @@ export function setupGuestShortcutForwarding(args: { if (zoomDirection !== 'in' && zoomDirection !== 'out') { return } - // Why: some keyboard layouts/platforms turn Ctrl/Cmd +/- into Electron's - // native zoom command before `before-input-event` reaches the guest. + // Why: some layouts/platforms turn Ctrl/Cmd +/- into Electron's native zoom before before-input-event reaches the guest. if (consumeRecentGuestWheelZoom(guest, zoomDirection)) { event.preventDefault() return @@ -605,8 +539,7 @@ export function setupGuestMouseWheelZoomForwarding(args: { if (!direction) { return } - // Why: wheel input over a focused webview does not reach renderer DOM - // handlers, so consume it here and forward to the existing page-zoom path. + // Why: wheel input over a focused webview never reaches renderer DOM handlers, so consume and forward here. event.preventDefault() markGuestWheelZoom(guest, direction) resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 33104ad9f7d..bd0e5698cd8 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1,7 +1,4 @@ -/* eslint-disable max-lines -- Why: BrowserManager intentionally remains the -single privileged facade for guest registration, authorization, and lifecycle -cleanup even after extracting the grab/session helpers. Keeping that ownership -in one file avoids scattering the browser security boundary across modules. */ +/* eslint-disable max-lines -- Why: single privileged facade for guest registration, authorization, and lifecycle cleanup; keeps the browser security boundary in one file. */ import { randomUUID } from 'node:crypto' import { shell, webContents } from 'electron' @@ -108,9 +105,7 @@ function cleanupLateAutomationVisibilityToken( if (typeof lateToken !== 'string' || lateToken.length === 0) { return } - // Why: the renderer creates the lease before waiting for paint; if main's - // acquire timeout wins, release the eventual token so hidden webviews do - // not stay paintable indefinitely. + // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. releaseAutomationVisibilityToken(renderer, lateToken) }) .catch(() => {}) @@ -131,10 +126,7 @@ function isAutomationVisibilityToken(token: unknown): token is string { return typeof token === 'string' && token.length > 0 } -// Why: mobile presets need a touch-capable UA or responsive sites serve the -// desktop variant based on UA sniffing. This is the Chrome DevTools default -// iPhone UA template; we splice in the guest session's real Chrome major so -// sec-ch-ua headers (see setupClientHintsOverride) stay consistent. +// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride). function buildMobileUserAgent(chromeMajor: string): string { return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` } @@ -176,9 +168,7 @@ const SAFE_POPUP_WINDOW_OPTIONS = { skipTaskbar: false, titleBarStyle: 'default', transparent: false, - // Why: applied by Electron when it creates the popup's WebContents, before - // createWindow runs. Feature strings and opener inheritance must not be able - // to relax the child's process isolation. + // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. webPreferences: { allowRunningInsecureContent: false, contextIsolation: true, @@ -226,20 +216,14 @@ export class BrowserManager { }) | null = null private readonly webContentsIdByTabId = new Map() - // Why: reverse map enables O(1) guest→tab lookups instead of O(N) linear - // scans on every mouse event, load failure, permission, and popup event. + // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. private readonly tabIdByWebContentsId = new Map() private readonly popupOwnerContextByGuestId = new Map() - // Why: guest registration is keyed by browser page id, but renderer - // visibility/focus state is keyed by browser workspace id. Screenshot prep - // has to bridge that mismatch to activate the right tab before capture. + // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. private readonly workspaceIdByPageId = new Map() private readonly sessionProfileIdByPageId = new Map() private readonly rendererWebContentsIdByTabId = new Map() - // Why: chain setViewportOverride calls per tab so rapid toggles don't - // interleave CDP commands. Without serialization, two concurrent calls can - // race (e.g. clearDeviceMetricsOverride landing after a later mobile - // setDeviceMetricsOverride), leaving emulation in an unexpected state. + // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. private readonly viewportOpsByTabId = new Map>() private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() @@ -252,9 +236,7 @@ export class BrowserManager { private readonly policyCleanupByGuestId = new Map void>() private readonly clickedLinkFrameNameByGuestId = new Map() private readonly loadErrorsByGuestId = new Map() - // Why: did-start-navigation optimistically hides the overlay, but an aborted - // nav never commits — stash the cleared error so did-fail-load(-3) can restore - // it instead of stranding the user on a blank surface. + // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. private readonly clearedLoadErrorsByGuestId = new Map() private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null private certificateTrustController: BrowserCertificateTrustController | null = null @@ -298,14 +280,7 @@ export class BrowserManager { this.settingsResolver = resolver } - // Why: Page.addScriptToEvaluateOnNewDocument (via the CDP debugger) is the - // only reliable way to run JS before page scripts on every navigation. - // The previous approach — executeJavaScript on did-start-navigation — ran - // on the OLD page context during navigation, so overrides were never - // present when the new page's Turnstile script executed. - // - // Returns a cleanup function that removes the detach listener and prevents - // further re-attach attempts. + // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. private injectAntiDetection(guest: Electron.WebContents): () => void { let disposed = false let reattachTimer: ReturnType | null = null @@ -331,11 +306,7 @@ export class BrowserManager { } } - // Why: the CDP proxy and bridge detach the debugger when they stop, - // which removes addScriptToEvaluateOnNewDocument injections. Re-attach - // so manual browsing retains anti-detection overrides after agent - // sessions end. The 500ms delay avoids racing with the proxy/bridge if - // it is mid-restart (detach → re-attach). + // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. const onDetach = (): void => { if (!disposed && !guest.isDestroyed() && reattachTimer === null) { reattachTimer = setTimeout(() => { @@ -398,10 +369,7 @@ export class BrowserManager { return renderer } - // Why: screenshot sessions target guest page ids, but Orca's visible browser - // chrome is keyed by workspace ids. If we activate the page id directly, the - // webview stays hidden under the terminal pane and Page.captureScreenshot - // times out even though the guest still exists. + // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) if (!browserPageId) { @@ -622,8 +590,7 @@ export class BrowserManager { return () => {} } - // Why: agent browser commands need a paintable webview for lazy-loading - // sites, but must not steal the user's visible Orca tab/worktree. + // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. const acquirePromise = renderer .executeJavaScript( `(async function() { @@ -659,8 +626,7 @@ export class BrowserManager { if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } - // Why: OAuth child windows must retain normal link/window relationships; - // only the primary embedded browser converts new-tab clicks to Orca tabs. + // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. const clickedLinkFrameName = inheritedOwnerContext ? null : `__orca_clicked_link_foreground_${randomUUID()}` @@ -669,30 +635,22 @@ export class BrowserManager { } let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) - // Why: Cloudflare Turnstile and similar bot detectors probe browser APIs - // (navigator.webdriver, plugins, window.chrome) that differ in Electron - // webviews vs real Chrome. Inject overrides on every page load so manual - // browsing passes challenges even without the CDP debugger attached. + // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. const disposeAntiDetection = this.injectAntiDetection(guest) - // Why: background throttling must be disabled so agent-driven screenshots - // (Page.captureScreenshot via CDP proxy) can capture frames even when the - // Orca window is not the focused foreground app. With throttling enabled, - // the compositor stops producing frames and capturePage() returns empty. + // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. guest.setBackgroundThrottling(false) const installClickedLinkRouting = (): void => { if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { return } - // Why: an isolated-world click listener can label real anchor clicks - // without exposing the private frame name to untrusted page scripts. + // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. void guest .executeJavaScriptInIsolatedWorld( BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, [ { - // Why: mobile emulation spoofs the guest UA as iOS, so modifier - // routing must use the actual desktop host platform from main. + // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. code: buildBrowserClickedLinkRoutingScript( clickedLinkFrameName, process.platform === 'darwin' @@ -725,8 +683,7 @@ export class BrowserManager { const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` iframeFrameNameByFrame.set(frame, name) iframeFrameByFrameName.set(name, frame) - // Why: child-frame tokens live in the page world, so they are consumed - // after one trusted click and replaced before another can be routed. + // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. void frame .executeJavaScript( buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), @@ -761,8 +718,7 @@ export class BrowserManager { guest.on('frame-created', handleFrameCreated) } const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { - // Why: popup descendants inherit the opener's owner context for routing, - // but must not replace its primary guest registration. + // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) } guest.on('did-create-window', handleDidCreateWindow) @@ -788,39 +744,30 @@ export class BrowserManager { action: 'opened-in-orca' }) } - // Why: a recognized user gesture must never fall through to a native - // popup merely because its renderer disappeared during the click. + // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. return { action: 'deny' } } - // Why: file URLs are valid for user-opened in-pane previews, but remote - // content must not create native child windows targeting local paths. + // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) if (browserTabId && canOpenAsChild) { - // Why: OAuth may request ordinary size/position features, but browser - // content must not create deceptive or inescapable native chrome. + // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. return { action: 'allow', overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, - // Why: a default child window has no address bar, so users cannot - // verify a popup's destination. Host it in an Orca window with an - // origin bar while keeping the shared session + window.opener. + // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. createWindow: (options: PopupChildWindowOptions) => this.createPopupChildWindowWithOriginBar(guest, url, options) } } else if (externalUrl) { - // Why: a target=_blank click on a Kagi search result page produces a - // popup URL that still contains the bearer token; redact before - // handing the URL to the OS default browser. + // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. void shell.openExternal(redactKagiSessionToken(externalUrl)) this.forwardOrQueuePopupEvent(guest.id, { origin: safeOrigin(externalUrl), action: 'opened-external' }) } else { - // Why: popup attempts can carry auth redirects and one-time tokens. - // Surface only sanitized origin metadata so the renderer can explain - // the blocked action without persisting sensitive URL details. + // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. this.forwardOrQueuePopupEvent(guest.id, { origin: safeOrigin(url), action: 'blocked' @@ -830,26 +777,17 @@ export class BrowserManager { }) const navigationGuard = (event: Electron.Event, url: string): void => { - // Why: blob: URLs are same-origin (inherit the creator's origin) and are - // used by Cloudflare Turnstile to load challenge resources inside iframes. - // Blocking them triggers error 600010 ("bot behavior detected"). Only - // allow blobs whose embedded origin is http(s) to maintain defense-in-depth - // against blob:null or other opaque-origin blobs. + // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { return } - // Why: file:// is permitted at `will-attach-webview` so the preview pane - // can render local HTML the user explicitly opened. After that initial - // load, a page must not be able to redirect the guest to file:// — that - // would let a remote page probe the local filesystem. Keep the in-guest - // navigation guard strict even though initial attach is permissive. + // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. if (url.startsWith('file:')) { event.preventDefault() return } if (!normalizeBrowserNavigationUrl(url)) { - // Why: `will-attach-webview` only validates the initial src. Main must - // keep enforcing the same allowlist for later guest navigations too. + // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. event.preventDefault() } } @@ -873,14 +811,11 @@ export class BrowserManager { toSecureCertificateEndpoint(validatedURL || guest.getURL()) === toSecureCertificateEndpoint(certificateFailure.origin) ) { - // Why: a request-guard cancellation is the transport for the existing - // certificate warning; do not replace it with ERR_ABORTED/blocked copy. + // Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. return } if (errorCode === -3) { - // Why: an aborted main-frame nav never committed, so restore the error - // did-start-navigation optimistically cleared — otherwise a retry that - // aborts leaves the failed page with no overlay. + // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) if (clearedError !== undefined) { this.clearedLoadErrorsByGuestId.delete(guest.id) @@ -911,14 +846,11 @@ export class BrowserManager { return } this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) - // Why: a failure queued before renderer registration belongs only to the - // navigation that produced it. A replacement navigation must not replay - // that stale failure when its later dom-ready registers the guest. + // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. this.pendingLoadFailuresByGuestId.delete(guest.id) const activeError = this.loadErrorsByGuestId.get(guest.id) if (activeError === undefined) { - // Why: no error to hide; drop any stale stash so a later abort cannot - // resurrect an error from a navigation that already succeeded. + // Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure. this.clearedLoadErrorsByGuestId.delete(guest.id) return } @@ -928,9 +860,7 @@ export class BrowserManager { } const didNavigateHandler = (_event: Electron.Event, url: string): void => { - // Why: a committed navigation means the optimistic stash from - // did-start-navigation is obsolete — drop it so a later ERR_ABORTED - // cannot restore a failure over the already-committed page. + // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. this.clearedLoadErrorsByGuestId.delete(guest.id) this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) } @@ -941,15 +871,12 @@ export class BrowserManager { guest.on('did-navigate', didNavigateHandler) guest.on('did-fail-load', didFailLoadHandler) const handleDestroyed = (): void => { - // Why: guests can be destroyed before renderer registration. Without - // this, attach-time policy closures remain retained until app shutdown. + // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. this.cleanupGuestPolicyAttachment(guest.id) } guest.on('destroyed', handleDestroyed) - // Why: store cleanup so unregisterGuest can remove these listeners when the - // guest surface is torn down, preventing the callbacks from preventing GC of - // the underlying WebContents wrapper. + // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. this.policyCleanupByGuestId.set(guest.id, () => { disposeAntiDetection() try { @@ -987,8 +914,7 @@ export class BrowserManager { options: PopupChildWindowOptions ): Electron.WebContents { const popup = openPopupWithOriginBar(options, targetUrl) - // Why: Electron does not emit did-create-window for createWindow-created - // children, so the opener's policies and routing context attach here. + // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. this.attachGuestPolicies( popup.contentWebContents, this.resolvePopupOwnerContext(openerGuest.id) @@ -997,8 +923,7 @@ export class BrowserManager { origin: safeOrigin(targetUrl), action: 'opened-in-orca' }) - // Why: parity with Electron's default child-window lifecycle — closing the - // owning browser tab must not leave orphaned session-bearing popups. + // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. const closePopupWithOpener = (): void => popup.close() openerGuest.once('destroyed', closePopupWithOpener) popup.onClosed(() => { @@ -1010,10 +935,7 @@ export class BrowserManager { } private retireStaleGuestWebContents(previousWebContentsId: number): void { - // Why: a browser page can re-register with a new guest id after Chromium - // swaps renderer processes. Late events from the dead guest must stop - // resolving to the live page, or stale download/popup/permission callbacks - // can be delivered to the wrong session after the swap. + // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. this.cleanupGuestPolicyAttachment(previousWebContentsId) this.tabIdByWebContentsId.delete(previousWebContentsId) } @@ -1030,8 +952,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) - // Why: a popup must stop inheriting authorization as soon as its primary - // owner is retired, even if Chromium has not destroyed the child yet. + // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. if (isPrimaryGuest) { for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { if (owner.rootGuestWebContentsId === guestWebContentsId) { @@ -1060,10 +981,7 @@ export class BrowserManager { if (!browserTabId) { return false } - // Why: re-registering the same browser tab can happen when Chromium swaps - // or recreates the underlying guest surface. Any active grab is bound to - // the old guest's listeners and teardown path, so keeping it alive would - // leave the session attached to a stale webContents until timeout. + // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. this.cancelGrabOp(browserTabId, 'evicted') const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) @@ -1077,18 +995,12 @@ export class BrowserManager { return false } - // Why: the renderer sends webContentsId, which we must not blindly trust. - // A compromised renderer could send the main window's own webContentsId, - // causing us to overwrite its setWindowOpenHandler or attach unintended - // context menus. Only accept genuine webview guest surfaces. + // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. if (guest.getType() !== 'webview') { return false } if (!this.policyAttachedGuestIds.has(webContentsId)) { - // Why: renderer registration is only the second half of the guest setup. - // Main must only trust guests that already passed attach-time policy - // installation; otherwise a trusted renderer could point us at some other - // arbitrary webview and bypass the intended host-window attach boundary. + // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. return false } @@ -1120,14 +1032,10 @@ export class BrowserManager { } unregisterGuest(browserTabId: string): void { - // Why: unregistering a guest while a grab is active means the guest is - // being torn down. Cancel the grab so the renderer gets a clean signal - // instead of a dangling Promise. + // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. this.cancelGrabOp(browserTabId, 'evicted') - // Why: remove the policy listeners attached in attachGuestPolicies so the - // callbacks (which close over the guest WebContents) do not prevent GC of - // the underlying Chromium surface after the guest is destroyed. + // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (guestWebContentsId !== undefined) { this.cleanupGuestPolicyAttachment(guestWebContentsId) @@ -1153,8 +1061,7 @@ export class BrowserManager { mouseWheelZoomCleanup() this.mouseWheelZoomCleanupByTabId.delete(browserTabId) } - // Why: browser downloads are transient per-tab chrome. Closing the owning - // tab must cancel active writes instead of hiding them behind no UI. + // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. for (const [downloadId, download] of this.downloadsById.entries()) { if (download.browserTabId === browserTabId && !download.terminalEvent) { this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') @@ -1169,17 +1076,12 @@ export class BrowserManager { this.workspaceIdByPageId.delete(browserTabId) this.sessionProfileIdByPageId.delete(browserTabId) this.worktreeIdByTabId.delete(browserTabId) - // Why: drop any pending viewport-op chain for this tab so the Map doesn't - // retain a resolved promise keyed to a destroyed guest. + // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. this.viewportOpsByTabId.delete(browserTabId) this.annotationViewportBridgeOpsByTabId.delete(browserTabId) } - // Why: headless orca serve has no renderer window to mount a , so its - // browser pages are backed by main-process offscreen WebContents instead. This - // registers such a page into the same resolution maps the bridge/screencast/ - // input handlers read, but skips the webview-only guards and the renderer setup - // (context menu, grab shortcut, etc.) that assume a renderer-hosted guest. + // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. registerOffscreenGuest({ browserPageId, worktreeId, @@ -1195,8 +1097,7 @@ export class BrowserManager { if (!guest || guest.isDestroyed()) { return } - // Why: offscreen pages have no renderer webview listeners, so main owns - // their load-failure lifecycle for remote browser chrome. + // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. this.offscreenGuestIds.add(webContentsId) this.attachGuestPolicies(guest) const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) @@ -1224,10 +1125,7 @@ export class BrowserManager { } this.policyAttachedGuestIds.clear() this.offscreenGuestIds.clear() - // Why: unregisterGuest only cleans up guests that were registered (have an - // entry in webContentsIdByTabId). Guests that went through - // attachGuestPolicies but were never registered still have cleanup closures - // here — invoke them so their event listeners are removed before clearing. + // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. for (const cleanup of this.policyCleanupByGuestId.values()) { cleanup() } @@ -1299,9 +1197,7 @@ export class BrowserManager { } } - // Why: both the did-fail-load path and the synthesized certificate failure - // build a load error from an untrusted URL; centralize the redaction so a - // future third site cannot forget to strip Kagi session tokens. + // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. private buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { return { code, @@ -1339,9 +1235,7 @@ export class BrowserManager { const browserPageId = this.tabIdByWebContentsId.get(webContentsId) const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null if (worktreeId) { - // Why: this runs inside an Electron guest event dispatch; the listener - // synchronously reconciles mobile-session tabs, and an escaping throw would - // become a fatal uncaught exception (no catch-all main-process guard). + // Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. try { this.browserGuestStateChangedListener?.(worktreeId) } catch (error) { @@ -1440,8 +1334,7 @@ export class BrowserManager { try { item.cancel() } catch { - // Why: without a destination, Chromium must not keep writing invisibly; - // cancellation remains best-effort after surfacing the failure. + // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. } return } @@ -1454,8 +1347,7 @@ export class BrowserManager { try { item.cancel() } catch { - // Why: failing setSavePath can leave Electron in a partially finalized - // state; cancellation is best-effort after Orca has made the UI terminal. + // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. } return } @@ -1489,8 +1381,7 @@ export class BrowserManager { download.item.off('updated', updatedHandler) download.item.off('done', doneHandler) } catch { - // Why: completed DownloadItems can already be finalized when cleanup - // runs. Cleanup must stay best-effort so UI teardown never crashes main. + // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. } } item.on('updated', updatedHandler) @@ -1510,9 +1401,7 @@ export class BrowserManager { return true } - // Why: guest browser surfaces are intentionally isolated from Orca's preload - // bridge, so renderer code cannot directly call Electron WebContents APIs on - // them. Main owns the devtools escape hatch and only after tab→guest lookup. + // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. async openDevTools(browserTabId: string): Promise { const webContentsId = this.webContentsIdByTabId.get(browserTabId) if (!webContentsId) { @@ -1520,8 +1409,7 @@ export class BrowserManager { } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - // Why: stale guest discovery must clear every per-tab registry entry, - // not just the forward/reverse WebContents maps. + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. this.unregisterGuest(browserTabId) return false } @@ -1529,20 +1417,12 @@ export class BrowserManager { return true } - // Why: Electron guests do not expose Chrome DevTools' device - // toolbar (Cmd+Shift+M) to the embedding app, so viewport emulation must be - // driven through CDP directly. We reuse the debugger attachment that - // injectAntiDetection already established and never detach it here — doing - // so would clear Page.addScriptToEvaluateOnNewDocument and other per-guest - // overrides. Passing override=null clears emulation. + // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. async setViewportOverride( browserTabId: string, override: BrowserViewportOverride | null ): Promise { - // Why: chain per-tab so rapid toggles (e.g. user clicking presets quickly) - // don't interleave CDP commands. Each call waits for the previous one to - // settle, guaranteeing the last-requested override wins rather than whichever - // sendCommand sequence happens to finish last. + // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() const next = prev .catch(() => {}) @@ -1551,9 +1431,7 @@ export class BrowserManager { try { return await next } finally { - // Why: only clear if this call's promise is still the tail. A concurrent - // later call may have already replaced the entry; deleting would drop the - // chain and break serialization for the next invocation. + // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. if (this.viewportOpsByTabId.get(browserTabId) === next) { this.viewportOpsByTabId.delete(browserTabId) } @@ -1588,15 +1466,13 @@ export class BrowserManager { } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - // Why: stale guest discovery must clear every per-tab registry entry, - // not just the forward/reverse WebContents maps. + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. this.unregisterGuest(browserTabId) return false } try { - // Why: the scroll bridge runs outside the page world so page monkey - // patches cannot read the per-tab token or tamper with bridge state. + // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. await guest.executeJavaScriptInIsolatedWorld( BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], @@ -1618,8 +1494,7 @@ export class BrowserManager { } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - // Why: stale guest discovery must clear every per-tab registry entry, - // not just the forward/reverse WebContents maps. + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. this.unregisterGuest(browserTabId) return false } @@ -1629,10 +1504,7 @@ export class BrowserManager { guest.debugger.attach('1.3') } } catch (err) { - // Why: DevTools being open on the guest causes attach to throw with - // "Another debugger is already attached". Silently returning false made - // this failure mode undiagnosable — surface it via the logger with enough - // context (tab + webContents ids) to correlate with user reports. + // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { browserTabId, webContentsId, @@ -1656,10 +1528,7 @@ export class BrowserManager { }) if (override.mobile) { const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent())) - // Why: pass userAgentMetadata alongside the mobile UA string so - // sec-ch-ua-mobile / sec-ch-ua-platform client hints match. Without - // it, session-level desktop client-hints leak through and create a - // UA/CH mismatch that bot-detection (Cloudflare, Turnstile) flags. + // Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak. await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: buildMobileUserAgent(chromeMajor), userAgentMetadata: { @@ -1682,9 +1551,7 @@ export class BrowserManager { } }) } else { - // Why: desktop presets still need the clean (non-Electron) UA so - // Cloudflare/Turnstile don't flag the session. Passing the cleaned - // real UA keeps sec-ch-ua consistent with the override. + // Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session. await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: cleanElectronUserAgent(guest.getUserAgent()) }) @@ -1704,14 +1571,9 @@ export class BrowserManager { } } - // --------------------------------------------------------------------------- - // Browser Context Grab — main-owned operations - // --------------------------------------------------------------------------- + // --- Browser Context Grab — main-owned operations --- - /** - * Validates that a caller (identified by sender webContentsId) owns the - * given browserTabId. Returns the guest WebContents or null. - */ + /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ getAuthorizedGuest( browserTabId: string, senderWebContentsId: number @@ -1726,8 +1588,7 @@ export class BrowserManager { } const guest = webContents.fromId(guestId) if (!guest || guest.isDestroyed()) { - // Why: stale guest discovery must clear every per-tab registry entry, - // not just the forward/reverse WebContents maps. + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. this.unregisterGuest(browserTabId) return null } @@ -1739,10 +1600,7 @@ export class BrowserManager { return this.grabSessionController.hasActiveGrabOp(browserTabId) } - /** - * Enable or disable grab mode for a browser tab. When enabled, injects the - * overlay runtime into the guest. When disabled, cancels any active grab op. - */ + /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ async setGrabMode( browserTabId: string, enabled: boolean, @@ -1752,10 +1610,7 @@ export class BrowserManager { this.cancelGrabOp(browserTabId, 'user') return true } - // Why: injecting the overlay runtime eagerly on arm lets the hover UI - // appear instantly when the user starts moving the pointer, rather than - // adding a visible delay between "click Grab" and "overlay appears". - // The runtime is idempotent — re-injection on the same page is safe. + // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. try { await guest.executeJavaScript(buildGuestOverlayScript('arm')) return true @@ -1765,18 +1620,9 @@ export class BrowserManager { } /** - * Await a single grab selection on the given tab. Returns a Promise that - * resolves exactly once when the user clicks, cancels, or an error occurs. + * Await a single grab selection on the given tab; resolves once on click, cancel, or error. * - * Why the click is handled in-guest rather than via main-side interception: - * Electron's `before-input-event` only fires for keyboard events, not mouse - * events on guest webContents. The design doc anticipated a main-owned - * interceptor, but the spike showed this API gap. The fallback (documented - * in the design doc) is to let the guest overlay's full-viewport hit-catcher - * consume the click. The overlay calls `stopPropagation()` and - * `preventDefault()` so the page underneath does not receive the event. - * This is not a perfect guarantee (capture-phase listeners on window may - * still fire), but it covers the vast majority of sites. + * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. */ awaitGrabSelection( browserTabId: string, @@ -1786,17 +1632,12 @@ export class BrowserManager { return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) } - /** - * Cancel an active grab operation for the given tab. - */ + /** Cancel an active grab operation for the given tab. */ cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { this.grabSessionController.cancelGrabOp(browserTabId, reason) } - /** - * Capture a screenshot of the guest surface and optionally crop it to - * the given CSS-pixel rect. - */ + /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ async captureSelectionScreenshot( _browserTabId: string, rect: BrowserGrabRect, @@ -1805,11 +1646,7 @@ export class BrowserManager { return captureGrabSelectionScreenshot(rect, guest) } - /** - * Extract the payload for the currently hovered element without disrupting - * the active grab overlay or awaitClick listener. Used by keyboard shortcuts - * that let the user copy content while hovering, before clicking. - */ + /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ async extractHoverPayload( _browserTabId: string, guest: Electron.WebContents @@ -1836,13 +1673,7 @@ export class BrowserManager { ) } - // Why: browser grab mode intentionally uses Cmd/Ctrl+C as its entry - // gesture, but a focused webview guest is a separate Chromium process so - // the renderer's window-level keydown handler never sees that shortcut. - // Only forward the chord when Chromium would not perform a normal copy: - // no editable element is focused and there is no selected text. That keeps - // native page copy working while still making the grab shortcut reachable - // from focused web content. + // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. private setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) if (previousCleanup) { @@ -1863,11 +1694,7 @@ export class BrowserManager { ) } - // Why: a focused webview guest is a separate Chromium process — keyboard - // events go to the guest's own webContents and never fire the renderer's - // window-level keydown handler or the main window's before-input-event. - // Intercept common app shortcuts on the guest and forward them to the - // renderer so they work consistently regardless of which surface has focus. + // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. private setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) if (previousCleanup) { @@ -1913,9 +1740,7 @@ export class BrowserManager { ): void { const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) if (!browserTabId) { - // Why: some localhost failures happen before the renderer finishes - // registering which tab owns this guest. Queue the failure by guest ID so - // registerGuest can replay it instead of silently losing the error state. + // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) return } @@ -2110,9 +1935,7 @@ export class BrowserManager { try { download.item.cancel() } catch { - // Why: DownloadItem.cancel can throw after the item has already - // finalized. Cleanup here is best-effort because the UI state is the - // source of truth for whether Orca still considers the request active. + // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. } if (shouldSendCancel) { @@ -2203,9 +2026,7 @@ export class BrowserManager { return } - // Why: redact Kagi session tokens before the validated URL is persisted - // by the renderer into BrowserPage.loadError (saved to disk via the - // workspace session writer). + // Why: redact Kagi session tokens before the renderer persists validatedUrl to disk. renderer.send('browser:guest-load-failed', { browserPageId: browserTabId, loadError: { @@ -2224,8 +2045,7 @@ export class BrowserManager { if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { return false } - // Why: only the renderer owns Orca's worktree/tab model. Main forwards a - // validated URL instead of letting arbitrary guest content mutate it. + // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. renderer.send('browser:open-link-in-orca-tab', { browserPageId: browserTabId, url: normalizedUrl diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index a70aff4e6b2..72a888edb62 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -1,7 +1,4 @@ -/* eslint-disable max-lines -- Why: the registry is the single source of truth for - browser session profiles, partition allowlisting, cookie import staging, and - per-partition permission/download policies. Splitting further would scatter the - security boundary across modules. */ +/* eslint-disable max-lines -- Why: single source of truth for browser session profiles, partition allowlisting, cookie staging, and per-partition policies; splitting scatters the security boundary. */ import { app, session } from 'electron' import type { Session } from 'electron' import { randomUUID } from 'node:crypto' @@ -52,10 +49,7 @@ const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' const LEGACY_BROWSER_SESSION_PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ -// Why: the registry is the single source of truth for which Electron partitions -// are valid. will-attach-webview consults it to decide whether a guest's -// requested partition is allowed. This prevents a compromised renderer from -// smuggling an arbitrary partition string into a guest surface. +// Why: source of truth for valid partitions; will-attach-webview consults it so a compromised renderer can't smuggle in an arbitrary partition. class BrowserSessionRegistry { private readonly profiles = new Map() @@ -86,10 +80,7 @@ class BrowserSessionRegistry { }) } - // Why: the default profile's source metadata (what browser was imported, - // when) must survive app restarts so the Settings UI can show the import - // status. Cookies themselves persist in the Electron partition's SQLite DB, - // but the registry is in-memory only. + // Why: source metadata must persist across restarts (for the Settings import status) since the registry is in-memory only. private get metadataPath(): string { return ( this.metadataPathOverride ?? join(app.getPath('userData'), BROWSER_SESSION_META_FILE_NAME) @@ -103,13 +94,11 @@ class BrowserSessionRegistry { private static partitionCookiesPath(partition: string): string { const partitionName = partition.replace('persist:', '') const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName) - // Why: replay must overwrite the same modern or legacy database that the - // importing Electron partition already uses. + // Why: replay must overwrite the same (modern or legacy) DB the importing partition already uses. return resolveChromiumCookiesPath(partitionDir) ?? join(partitionDir, 'Cookies') } - // Why: write-to-temp-then-rename is atomic on all supported platforms. - // A crash mid-write would only lose the temp file, not corrupt the live one. + // Why: write-temp-then-rename is atomic, so a crash mid-write can't corrupt the live file. private persistMeta(updates: Partial): void { try { const existing = this.loadPersistedMeta() @@ -129,8 +118,7 @@ class BrowserSessionRegistry { }) } - // Why: non-default profiles are in-memory only unless explicitly persisted. - // Without this, created profiles vanish on app restart. + // Why: non-default profiles are in-memory only; without this they vanish on restart. private persistProfiles(): void { const nonDefault = [...this.profiles.values()].filter((p) => p.id !== 'default') this.persistMeta({ profiles: nonDefault }) @@ -178,16 +166,8 @@ class BrowserSessionRegistry { } } - // Why: browser sessions must be initialized BEFORE any webview loads. - // Permission/download policies must exist before sites request capabilities, - // and the User-Agent must be set before the first request so imported session - // cookies are not invalidated by Electron's default UA. - // - // Why this also refreshes defaultSource: the singleton constructor runs at - // module-import time, which may be before app.isReady(). app.getPath('userData') - // is not guaranteed before ready, so the constructor's loadPersistedSource() - // silently returns null. Re-reading here after app readiness ensures the - // default profile's source is populated. + // Why: run before any webview loads, and set the UA before the first request or Electron's default UA invalidates imported cookies. + // Why re-read defaultSource: the constructor may run before app.isReady() (userData path unavailable), so loadPersistedSource() returned null. initializeBrowserSessionsFromPersistedState(): void { const meta = this.loadPersistedMeta() if (meta.defaultSource) { @@ -200,11 +180,7 @@ class BrowserSessionRegistry { this.hydrateFromPersisted(meta.profiles) } - // Why: the default partition is created in the constructor but never gets - // session policies (permission handlers, download handlers, etc.) because - // hydrateFromPersisted skips the default partition and createProfile never - // targets it. Without this, clipboard permissions and other guest policies - // are denied by default in the default browser partition. + // Why: nothing else installs policies on the default partition (hydrate skips it), so without this its guest permissions would be denied. this.setupSessionPolicies(this.defaultPartition) const partitions = new Set([ @@ -221,8 +197,7 @@ class BrowserSessionRegistry { continue } - // Why: even without an imported session, the default Electron UA contains - // "Electron/X.X.X" and the app name which trip Cloudflare Turnstile. + // Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile. const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) setupClientHintsOverride(sess, cleanUA) @@ -232,10 +207,7 @@ class BrowserSessionRegistry { } } - // Why: the import writes cookies to a staging DB because CookieMonster holds - // the live DB's data in memory and would overwrite our changes on its next - // flush. This method MUST run before any session.fromPartition() call so - // CookieMonster reads the staged cookies instead of the stale live DB. + // Why: must run before any session.fromPartition() so CookieMonster reads the staged cookies instead of overwriting them from its in-memory DB. applyPendingCookieImport(): void { try { const meta = this.loadPersistedMeta() @@ -243,8 +215,7 @@ class BrowserSessionRegistry { if (pendingEntries.length === 0) { return } - // Why: replay writes to partition-derived file paths, so corrupted - // metadata must pass the same validation as the webview allowlist. + // Why: replay writes to partition-derived paths, so corrupted metadata must pass the same validation as the webview allowlist. const knownPartitions = new Set([this.defaultPartition]) for (const profile of meta.profiles) { if (this.isValidPersistedProfile(profile)) { @@ -267,9 +238,7 @@ class BrowserSessionRegistry { try { mkdirSync(join(liveCookiesPath, '..'), { recursive: true }) copyFileSync(stagedPath, liveCookiesPath) - // Why: SQLite WAL mode stores uncommitted data in sidecar files. - // Stale WAL/SHM from a previous session could corrupt CookieMonster's - // read of the freshly swapped DB. + // Why: stale WAL/SHM sidecars would corrupt CookieMonster's read of the freshly swapped DB. let sidecarCopyFailed = false for (const suffix of ['-wal', '-shm']) { try { @@ -288,8 +257,7 @@ class BrowserSessionRegistry { } } if (sidecarCopyFailed) { - // Why: sidecar copy failures can leave an inconsistent replay state. - // Keep this entry for retry and preserve unrelated entries. + // Why: sidecar copy failed → inconsistent replay; keep this entry for retry. continue } for (const ext of ['', '-wal', '-shm']) { @@ -301,8 +269,7 @@ class BrowserSessionRegistry { } delete remainingEntries[partition] } catch { - // Why: failed replay for one partition should not drop unrelated entries. - // Keep this entry for retry next launch. + // Why: keep this entry for retry — one partition's failed replay shouldn't drop unrelated entries. } } this.persistMeta({ @@ -365,26 +332,19 @@ class BrowserSessionRegistry { resolveKnownPartition(profileId: string | null | undefined): string | null { if (!profileId) { - // Why: must track the active Orca profile's default partition, not the - // legacy constant, or non-default profiles would resolve local-default's - // cookie jar. + // Why: use the active Orca profile's default partition, not the legacy constant, or profiles resolve local-default's cookie jar. return this.defaultPartition } return this.profiles.get(profileId)?.partition ?? null } createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null { - // Why: only the constructor may create the default profile. Allowing the - // renderer to pass scope:'default' would create a second profile sharing - // the active default partition, causing confusion on delete (clearing storage - // for the shared partition). + // Why: block scope:'default' here — only the constructor makes the default profile; a second one sharing the partition breaks delete. if (scope === 'default') { return null } const id = randomUUID() - // Why: partition names are deterministic from the profile id so main can - // reconstruct the allowlist on restart from persisted profile metadata - // without needing a separate partition→profile mapping. + // Why: deterministic partition-from-id lets main rebuild the allowlist on restart without a separate partition→profile map. const partition = getOrcaProfileBrowserSessionPartition(this.activeOrcaProfileId, id) const profile: BrowserSessionProfile = { id, @@ -436,27 +396,22 @@ class BrowserSessionRegistry { userAgent: userAgentByPartition[this.defaultPartition] ?? null }) - // Why: clearing the partition's storage prevents orphaned cookies/cache from - // lingering after the user deletes an imported or isolated session profile. + // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind. try { const sess = session.fromPartition(profile.partition) this.clearSessionPolicies(profile.partition, sess) await sess.clearStorageData() await sess.clearCache() } catch { - // Why: partition cleanup is best-effort. The profile is already removed - // from the registry so it won't be allowed by will-attach-webview. + // Why: cleanup is best-effort — the profile is already out of the registry, so will-attach-webview blocks it regardless. } return true } - // Why: clearing cookies from the default partition lets users undo a cookie - // import without deleting the default profile itself. + // Why: lets users undo a cookie import without deleting the default profile itself. async clearDefaultSessionCookies(): Promise { try { - // Why: persist metadata BEFORE clearing storage so that if the app quits - // mid-clear, the next launch won't show a stale "imported from X" badge - // for cookies that were partially or fully removed. + // Why: persist metadata before clearing storage so a mid-clear quit doesn't leave a stale "imported from X" badge. const defaultProfile = this.profiles.get('default') if (defaultProfile) { this.profiles.set('default', { ...defaultProfile, source: null }) @@ -482,13 +437,7 @@ class BrowserSessionRegistry { } } - // Why: on startup, main must reconstruct the set of valid partitions from - // persisted session profiles so restored webviews are not denied by - // will-attach-webview before the renderer mounts them. - // Why: profiles are deserialized from a JSON file on disk. A corrupted or - // tampered file could inject an arbitrary partition into the allowlist that - // will-attach-webview trusts, so we validate the expected shape before - // registering anything. + // Why: validate on-disk profile shape so a tampered JSON file can't inject an arbitrary partition into the will-attach-webview allowlist. private isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile { if (!profile || typeof profile !== 'object') { return false @@ -533,9 +482,7 @@ class BrowserSessionRegistry { } } - // Why: every browser partition needs the same deny-by-default permission - // and download policies. Keeping the installer here prevents the default - // partition and imported/isolated partitions from drifting apart. + // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. private readonly configuredPartitions = new Set() private readonly handleWillDownload = ( _event: Electron.Event, @@ -558,12 +505,7 @@ class BrowserSessionRegistry { setupClientHintsOverride(sess, cleanUA) } sess.setPermissionRequestHandler((webContents, permission, callback, details) => { - // Why: `media` (camera/mic) must defer to macOS TCC instead of being - // denied outright. Denying at the session layer would make pages inside - // isolated browser profiles throw NotAllowedError even after the user - // granted Camera/Microphone to Orca — the same bug we fixed for the - // default partition. macOS TCC still gates the actual stream, so - // granting here only forwards what the OS has already authorized. + // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. if (permission === 'media') { void requestSystemMediaAccess( details as Electron.MediaAccessPermissionRequest | undefined @@ -619,9 +561,7 @@ class BrowserSessionRegistry { } private clearSessionPolicies(partition: string, sess: Session): void { - // Why: isolated/imported browser partitions can be deleted while the - // Electron Session object survives; clear policy callbacks and listener - // bookkeeping so removed profiles do not leave retained closures behind. + // Why: the Electron Session survives partition deletion; clear callbacks/listeners so removed profiles don't retain closures. this.configuredPartitions.delete(partition) browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', this.handleWillDownload) diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index bda4831a790..47d263ac371 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -67,18 +67,15 @@ type TabState = { debuggerDetachListener: (() => void) | null debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null iframeSessions: Map - // Why: capture state is per-tab so console/network events from one tab - // don't pollute another's capture buffer. + // Why: capture state is per-tab so one tab's console/network events don't pollute another's buffer. capturing: boolean consoleLog: BrowserConsoleEntry[] networkLog: BrowserNetworkEntry[] - // Why: interception state tracks patterns and paused requests so the - // agent can selectively continue or block individual requests. + // Why: interception state lets the agent selectively continue or block individual requests. intercepting: boolean interceptPatterns: string[] pausedRequests: Map - // Why: maps CDP requestId to the networkLog entry so loadingFinished - // can attribute size to the correct response when requests overlap. + // Why: maps CDP requestId → networkLog entry so loadingFinished attributes size to the right overlapping response. networkRequestMap: Map } @@ -123,9 +120,7 @@ export class CdpBridge { _worktreeId?: string, browserPageId?: string ): { browserPageId: string; url: string; title: string } | null { - // Why: OrcaRuntimeService pushes navigation/title updates after commands - // using a bridge-agnostic contract. The CDP bridge only routes one active - // tab at a time, but it still needs to expose the same metadata lookup. + // Why: expose the same metadata lookup as other bridges, though the CDP bridge routes only one active tab. const resolvedPageId = browserPageId ?? this.getActivePageId() if (!resolvedPageId) { return null @@ -185,10 +180,7 @@ export class CdpBridge { const localCenter = await this.getElementCenter(refSender, node.backendDOMNodeId) const { cx, cy } = await this.getPageCoordinates(guest, node, localCenter.cx, localCenter.cy) - // Why: mouseMoved fires mouseenter/mouseover which some sites need to - // reveal hover-dependent menus or clickable areas before the click lands. - // Input events go to the parent session — Chrome routes them to the - // correct frame based on coordinates. + // Why: mouseMoved fires mouseenter/mouseover so sites reveal hover-dependent menus/targets before the click lands. await sender('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy }) await sender('Input.dispatchMouseEvent', { type: 'mousePressed', @@ -244,9 +236,7 @@ export class CdpBridge { const toLocal = await this.getElementCenter(toSender, toNode.backendDOMNodeId) const to = await this.getPageCoordinates(guest, toNode, toLocal.cx, toLocal.cy) - // Why: 10-step interpolation with delays simulates human-like drag and - // triggers dragenter/dragover events on intermediate elements, which many - // drag-and-drop libraries rely on. + // Why: interpolate the drag so intermediate elements fire dragenter/dragover, which many drag-and-drop libs require. await sender('Input.dispatchMouseEvent', { type: 'mouseMoved', x: from.cx, y: from.cy }) await sender('Input.dispatchMouseEvent', { type: 'mousePressed', @@ -323,8 +313,7 @@ export class CdpBridge { await refSender('DOM.focus', { backendNodeId: node.backendDOMNodeId }) - // Why: select-all then delete clears any existing value before typing, - // matching the behavior of Playwright's fill() and agent-browser's fill. + // Why: select-all + delete clears the existing value before typing, matching Playwright/agent-browser fill(). await sender('Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', @@ -340,11 +329,8 @@ export class CdpBridge { await insertTextThroughCdp(sender, value) - // Why: React and other frameworks use synthetic event listeners that may not - // detect native keyboard events. Explicitly dispatching input/change ensures - // controlled components update their state. Uses refSender so that in iframe - // contexts, document.activeElement resolves to the iframe's focused element - // rather than the