diff --git a/README.md b/README.md
index 4f164b24ad6..fc2b643ac77 100644
--- a/README.md
+++ b/README.md
@@ -4,149 +4,230 @@
-
+
+
- English · Español · 中文 · 日本語 · 한국어
+ Español · 中文 · 日本語 · 한국어
The AI Orchestrator for 100x builders.
- Run Claude Code, OpenClaude, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
- Available for macOS, Windows, and Linux .
+ Run Claude Code, OpenClaude, Codex, or OpenCode side-by-side — each in its own worktree, tracked in one place.
+
+
- Download Orca
+
-
-
-
-
-## Supported Agents
-
-Orca supports any CLI agent (_not just this list_).
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## Features
-**Run agents in parallel**
+
+
+
-- **Bring your own subscription** — Use Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode, or any other CLI agent without an Orca login.
-- **Worktree-native tasks** — Give each task its own worktree so agents can work side-by-side without stashing or branch juggling.
-- **Multi-agent terminals** — Run agents in tabs and split panes, then see active, waiting, and finished sessions at a glance.
+### Mobile Companion
-**Review and ship**
+Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
-- **Agent-ready browser** — Open local apps, inspect pages, annotate UI, and pass precise browser context back to agents.
-- **Design Mode** — Iterate on frontend changes with an embedded browser built for visual review and quick fixes.
-- **Source control built in** — Review AI-generated diffs, make quick edits, and commit without leaving Orca.
-- **GitHub workflow links** — Keep PRs, issues, and Actions checks connected to the worktree doing the work.
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
-**Work anywhere**
+
+
+
+
+
+
+
-- **SSH support** — Connect to remote machines and run agents there directly from Orca.
-- **Mobile companion** — Monitor and steer agents from your phone, with emulator-friendly mobile workflows.
-- **Computer Use** — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
-- **Notifications and unread state** — Know when an agent finishes or needs attention, then mark threads unread to come back later.
+### Parallel Worktrees
+
+Fan one prompt across five agents, each in its own isolated git worktree — compare the results and merge the winner.
+
+[Docs →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### Terminal Splits
+
+Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback that survives restarts.
+
+[Docs →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### Design Mode
+
+Click any UI element in a real Chromium window to send its HTML, CSS, and a cropped screenshot straight into your agent's prompt.
+
+[Docs →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub & Linear, Native
+
+Browse PRs, issues, and project boards in-app — open a worktree from any task and review without a context switch.
+
+[Docs →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### SSH Worktrees
+
+Run agents on a beefy remote box with full file editing, git, and terminals — auto-reconnect and port forwarding included.
+
+[Docs →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### Annotate AI Diffs
+
+Drop comments on any diff line and ship them back to the agent — review, edit, and commit without leaving Orca.
+
+[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### Drag Files to Agents
+
+VS Code's editor with autosave everywhere — drag files or images straight into an agent prompt.
+
+[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+Agents drive Orca too — script every workflow with `orca worktree create`, `snapshot`, `click`, and `fill`.
+
+[Docs →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**Also in the box:**
+
+- **[Quick open](https://www.onorca.dev/docs/model/quick-open)** — Search across worktrees, files, agents, commands, and repo context without leaving your flow.
+- **[Account switcher & usage tracking](https://www.onorca.dev/docs/agents/usage-tracking)** — See Claude and Codex usage and rate-limit resets, and hot-swap accounts without re-logging in.
+- **[Rich repo previews](https://www.onorca.dev/docs/editing/markdown)** — Preview Markdown, images, PDFs, and repo docs in the workspace.
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
+- **[Notifications and unread state](https://www.onorca.dev/docs/notifications)** — Know when an agent finishes or needs attention, then mark threads unread to come back later.
+- **And many, many more** — we ship daily, so this list is perpetually behind. The [changelog](https://github.com/stablyai/orca/releases) is the real feature list.
+
+---
+
+## Supported Agents
+
+Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + any CLI agent
+
---
## Install
-### Mac, Linux, Windows
+### Desktop — macOS, Windows, Linux
-- **[Download from onOrca.dev](https://onOrca.dev)**
-- Or via **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**
+- **[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)
-_Alternatively, install from a package manager:_
-
-### macOS (Homebrew)
+_Or via a package manager:_
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# Precompiled binary
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# Build from GitHub source
-yay -S stably-orca-git
```
----
+### Mobile Companion — iOS, Android
-## Mobile Companion App
+Pair with your desktop app to monitor and steer your agents from your phone.
-Control your agents from your phone.
-
-
-
-
-
-- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Download APK from GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## Feature Showcase
-
-Click any tile to explore the workflow.
-
-
- Parallel Worktrees
- Terminal Splits
- Design Mode
- GitHub & Linear, Native
- Every CLI Agent
- SSH Worktrees
- Drag Files to Agents
- Annotate AI Diffs
- Orca CLI
- Native Search
- Account Switcher & Usage Tracking
- Rich Repo Previews
- Split Anything
-
+- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
+- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
@@ -155,11 +236,19 @@ Click any tile to explore the workflow.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
-- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
-- **Show Support:** Star this repo to follow along with our daily ships.
+- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
+- **Show Support:** [Star](https://github.com/stablyai/orca) this repo to follow along with our daily ships.
---
## Developing
Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
+
+
+
+
+
+## License
+
+Orca is free and open source under the [MIT License](LICENSE).
diff --git a/docs/assets/readme-hero.jpg b/docs/assets/readme-hero.jpg
new file mode 100644
index 00000000000..83d2c73cabc
Binary files /dev/null and b/docs/assets/readme-hero.jpg differ
diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md
index fedafc84f5f..414400b9569 100644
--- a/docs/readme/README.es.md
+++ b/docs/readme/README.es.md
@@ -3,137 +3,231 @@
-
-
-
+
+
+
+
+
- English · 中文 · 日本語 · 한국어 · Español
+ English · 中文 · 日本語 · 한국어
El orquestador de IA para desarrolladores 100x.
- Ejecuta Claude Code, OpenClaude, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
- Disponible para macOS, Windows y Linux .
+ Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
+
+
- Descargar 🐋
+
-
-
-
-
-## Agentes compatibles
-
-Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## Características
-- **Sin login** — Usa tu propia suscripción de Claude Code, OpenClaude, Codex, Grok o Antigravity.
-- **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante.
-- **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
-- **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca.
-- **Integración con GitHub** — PRs, issues y checks de Actions vinculados automáticamente a cada worktree.
-- **Soporte SSH** — Conéctate a máquinas remotas y ejecuta agentes en ellas directamente desde Orca.
-- **Notificaciones** — Entérate cuando un agente termine o necesite tu atención. Marca hilos como no leídos para retomarlos después.
+
+
+
+
+### App companion móvil
+
+Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
+
+[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### Worktrees en paralelo
+
+Lanza un mismo prompt a cinco agentes, cada uno en su propio worktree de git aislado — compara los resultados y haz merge del ganador.
+
+[Docs →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### Terminales divididas
+
+Terminales de nivel Ghostty con renderizado WebGL, divisiones infinitas y un scrollback que sobrevive a los reinicios.
+
+[Docs →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### Modo diseño
+
+Haz clic en cualquier elemento de UI en una ventana real de Chromium para enviar su HTML, su CSS y una captura recortada directo al prompt de tu agente.
+
+[Docs →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub y Linear, nativos
+
+Explora PRs, issues y tableros de proyecto dentro de la app — abre un worktree desde cualquier tarea y revisa sin cambiar de contexto.
+
+[Docs →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### Worktrees por SSH
+
+Ejecuta agentes en una máquina remota potente con edición completa de archivos, git y terminales — con reconexión automática y reenvío de puertos incluidos.
+
+[Docs →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### Anotar diffs de IA
+
+Deja comentarios en cualquier línea de un diff y envíalos de vuelta al agente — revisa, edita y haz commit sin salir de Orca.
+
+[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### Arrastra archivos a los agentes
+
+El editor de VS Code con autoguardado en todas partes — arrastra archivos o imágenes directo al prompt de un agente.
+
+[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+Los agentes también manejan Orca — automatiza cualquier flujo de trabajo con `orca worktree create`, `snapshot`, `click` y `fill`.
+
+[Docs →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**También incluye:**
+
+- **[Apertura rápida](https://www.onorca.dev/docs/model/quick-open)** — Busca entre worktrees, archivos, agentes, comandos y contexto del repo sin salir de tu flujo.
+- **[Cambio de cuenta y seguimiento de uso](https://www.onorca.dev/docs/agents/usage-tracking)** — Consulta el uso de Claude y Codex y los reinicios de límites de uso, y cambia de cuenta al instante sin volver a iniciar sesión.
+- **[Previews ricos del repo](https://www.onorca.dev/docs/editing/markdown)** — Previsualiza Markdown, imágenes, PDFs y documentos del repo en el workspace.
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Deja que los agentes manejen apps de escritorio y UI visible cuando un flujo de trabajo necesita interacción real.
+- **[Notificaciones y estado de no leído](https://www.onorca.dev/docs/notifications)** — Entérate cuando un agente termine o necesite tu atención, y marca hilos como no leídos para retomarlos después.
+- **Y muchas, muchas más** — lanzamos a diario, así que esta lista siempre va atrasada. El [changelog](https://github.com/stablyai/orca/releases) es la verdadera lista de funciones.
+
+---
+
+## Agentes compatibles
+
+Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orca.
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + any CLI agent
+
---
## Instalación
-### Mac, Linux, Windows
+### Escritorio — macOS, Windows, Linux
-- **[Descarga desde onOrca.dev](https://onOrca.dev)**
-- O desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**
+- **[Descarga desde onOrca.dev](https://onorca.dev/download)**
+- O descarga un build directamente: [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) · [Todos los builds](https://github.com/stablyai/orca/releases/latest)
-_También puedes instalar desde un gestor de paquetes:_
-
-### macOS (Homebrew)
+_O mediante un gestor de paquetes:_
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# Binario precompilado
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# Compilar desde el código de GitHub
-yay -S stably-orca-git
```
----
+### App companion móvil — iOS, Android
-## App companion móvil
-
-Controla tus agentes desde el teléfono.
-
-
-
-
+Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [Descargar APK desde GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## Showcase de funciones
-
-Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
-
-
- Worktrees en paralelo
- Terminales divididas
- Modo diseño
- GitHub y Linear nativos
- Cualquier agente CLI
- Worktrees por SSH
- Archivos a agentes
- Anotar diffs de IA
- Orca CLI
- Búsqueda nativa
- Cambio de cuenta y seguimiento de uso
- Previews ricos del repo
- Divide cualquier cosa
-
+- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
@@ -142,10 +236,19 @@ Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
- **Discord:** Únete a la comunidad en **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Sigue a **[@orca_build](https://x.com/orca_build)** para novedades y anuncios.
- **Feedback e ideas:** Lanzamos rápido. ¿Te falta algo? [Pide una nueva feature](https://github.com/stablyai/orca/issues).
-- **Muéstranos tu apoyo:** Dale una estrella al repo para seguir nuestros lanzamientos diarios.
+- **Privacidad:** Consulta la [documentación de privacidad y telemetría](https://www.onorca.dev/docs/telemetry) para saber qué datos anónimos de uso recopila Orca y cómo desactivar su envío.
+- **Muéstranos tu apoyo:** Dale una [estrella](https://github.com/stablyai/orca) a este repo para seguir nuestros lanzamientos diarios.
---
## Desarrollo
-¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](../.github/CONTRIBUTING.md).
+¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
+
+
+
+
+
+## Licencia
+
+Orca es libre y de código abierto bajo la [Licencia MIT](../../LICENSE).
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index 07ad4e312b4..fbb3d010d3e 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -3,137 +3,231 @@
-
-
-
+
+
+
+
+
- English · 中文 · 日本語 · 한국어 · Español
+ English · Español · 中文 · 한국어
100x ビルダーのための AI オーケストレーター。
- Claude Code、OpenClaude、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
- macOS、Windows、Linux で利用できます。
+ Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
+
+
- ダウンロード 🐋
+
-
-
-
-
-## 対応するエージェント
-
-Orca は任意の CLI エージェントに対応しています(_このリストに限定されません_)。
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## 機能
-- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
-- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
-- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
-- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
-- **GitHub 連携** — PR、Issue、Actions チェックが各ワークツリーに自動で紐づきます。
-- **SSH サポート** — リモートマシンに接続し、Orca から直接エージェントを実行できます。
-- **通知** — エージェントが完了したときや注意が必要なときに通知します。スレッドを未読にして後で戻ることもできます。
+
+
+
+
+### モバイル Companion
+
+スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
+
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### 並列ワークツリー
+
+1 つのプロンプトを 5 つのエージェントに展開し、それぞれを独立した git ワークツリーで実行 — 結果を比較して、最良のものをマージできます。
+
+[ドキュメント →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### ターミナル分割
+
+WebGL レンダリング、無制限の分割、再起動後も残るスクロールバックを備えた Ghostty クラスのターミナル。
+
+[ドキュメント →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### デザインモード
+
+実際の Chromium ウィンドウで任意の UI 要素をクリックすると、その HTML、CSS、切り抜いたスクリーンショットがそのままエージェントのプロンプトに送られます。
+
+[ドキュメント →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub & Linear をネイティブに
+
+PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意のタスクからワークツリーを開き、コンテキストスイッチなしでレビューできます。
+
+[ドキュメント →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### SSH ワークツリー
+
+強力なリモートマシン上でエージェントを実行 — ファイル編集、git、ターミナルをフルに使え、自動再接続とポートフォワーディングも付属します。
+
+[ドキュメント →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### AI Diff に注釈
+
+任意の Diff 行にコメントを付けてエージェントへ送り返せます — Orca から離れずにレビュー、編集、コミットまで完結します。
+
+[ドキュメント →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### ファイルをエージェントへドラッグ
+
+オートセーブが全面的に効く VS Code のエディタ — ファイルや画像をそのままエージェントのプロンプトへドラッグできます。
+
+[ドキュメント →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+エージェント自身も Orca を操作できます — `orca worktree create`、`snapshot`、`click`、`fill` であらゆるワークフローをスクリプト化できます。
+
+[ドキュメント →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**さらに同梱:**
+
+- **[クイックオープン](https://www.onorca.dev/docs/model/quick-open)** — フローを離れずに、ワークツリー、ファイル、エージェント、コマンド、リポジトリコンテキストを横断検索できます。
+- **[アカウント切り替えと使用量トラッキング](https://www.onorca.dev/docs/agents/usage-tracking)** — Claude と Codex の使用量やレート制限のリセットを確認し、再ログインなしでアカウントを切り替えられます。
+- **[リッチなリポジトリプレビュー](https://www.onorca.dev/docs/editing/markdown)** — Markdown、画像、PDF、リポジトリ文書をワークスペース内でプレビューできます。
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 実際の操作が必要なワークフローでは、エージェントにデスクトップアプリや画面上の UI を操作させられます。
+- **[通知と未読ステータス](https://www.onorca.dev/docs/notifications)** — エージェントの完了や要対応をすぐに把握し、スレッドを未読に戻して後で確認できます。
+- **その他、まだまだたくさん** — 毎日リリースしているので、このリストは常に追いついていません。本当の機能一覧は[チェンジログ](https://github.com/stablyai/orca/releases)です。
+
+---
+
+## 対応するエージェント
+
+**あらゆる CLI エージェント**で動作します — ターミナルで動くものなら、Orca でも動きます。
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + any CLI agent
+
---
## インストール
-### Mac, Linux, Windows
+### デスクトップ — macOS, Windows, Linux
-- **[onOrca.dev からダウンロード](https://onOrca.dev)**
-- または **[GitHub Releases ページ](https://github.com/stablyai/orca/releases/latest)** から入手
+- **[onOrca.dev からダウンロード](https://onorca.dev/download)**
+- またはビルドを直接入手: [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) · [すべてのビルド](https://github.com/stablyai/orca/releases/latest)
_パッケージマネージャーからもインストールできます:_
-### macOS (Homebrew)
-
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# ビルド済みバイナリ
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# GitHub ソースからビルド
-yay -S stably-orca-git
```
----
+### モバイル Companion — iOS, Android
-## モバイル Companion アプリ
-
-スマートフォンからエージェントを操作できます。
-
-
-
-
+デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [GitHub Releases から APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## 機能ショーケース
-
-各タイルをクリックすると、そのワークフローを確認できます。
-
-
- 並列ワークツリー
- ターミナル分割
- デザインモード
- GitHub と Linear をネイティブに
- 任意の CLI エージェント
- SSH ワークツリー
- ファイルをエージェントへ
- AI Diff 注釈
- Orca CLI
- ネイティブ検索
- アカウント切り替えと使用量トラッキング
- リッチなリポジトリプレビュー
- 何でも分割表示
-
+- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
@@ -141,11 +235,20 @@ yay -S stably-orca-git
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** のコミュニティに参加してください。
- **Twitter / X:** アップデートやお知らせは **[@orca_build](https://x.com/orca_build)** をフォローしてください。
-- **フィードバックとアイデア:** 私たちは高速にリリースしています。足りない機能がありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues) してください。
-- **応援する:** 毎日のリリースを追うために、このリポジトリにスターを付けてください。
+- **フィードバックとアイデア:** 私たちは高速にリリースしています。足りない機能がありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues)してください。
+- **プライバシー:** Orca が収集する匿名の利用データとオプトアウトの方法については、[プライバシーとテレメトリーのドキュメント](https://www.onorca.dev/docs/telemetry)をご覧ください。
+- **応援する:** 毎日のリリースを追うために、このリポジトリに[スター](https://github.com/stablyai/orca)を付けてください。
---
## 開発について
-貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) ガイドをご覧ください。
+貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) ガイドをご覧ください。
+
+
+
+
+
+## ライセンス
+
+Orca は [MIT License](../../LICENSE) の下で無料かつオープンソースです。
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 6568f629885..bcd972e3da5 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -3,137 +3,231 @@
-
-
-
+
+
+
+
+
- English · 中文 · 日本語 · 한국어 · Español
+ English · Español · 中文 · 日本語
100x 빌더를 위한 AI 오케스트레이터.
- Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
- macOS, Windows, Linux 에서 사용할 수 있습니다.
+ Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
+
+
- 다운로드 🐋
+
-
-
-
-
-## 지원 에이전트
-
-Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정되지 않습니다_).
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## 기능
-- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
-- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다.
-- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다.
-- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다.
-- **GitHub 통합** — PR, issue, Actions 체크가 각 worktree에 자동으로 연결됩니다.
-- **SSH 지원** — 원격 머신에 연결하고 Orca에서 직접 에이전트를 실행할 수 있습니다.
-- **알림** — 에이전트가 완료되거나 주의가 필요할 때 알려줍니다. 스레드를 읽지 않음으로 표시해 나중에 다시 볼 수 있습니다.
+
+
+
+
+### 모바일 Companion
+
+휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
+
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### 병렬 Worktree
+
+하나의 프롬프트를 다섯 에이전트에 동시에 보내세요. 각 에이전트는 격리된 자체 git worktree에서 실행됩니다 — 결과를 비교하고 가장 좋은 것을 머지하세요.
+
+[문서 →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### 터미널 분할
+
+WebGL 렌더링, 무한 분할, 재시작 후에도 유지되는 스크롤백을 갖춘 Ghostty급 터미널.
+
+[문서 →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### 디자인 모드
+
+실제 Chromium 창에서 UI 요소를 클릭하면 해당 HTML, CSS, 잘라낸 스크린샷이 에이전트 프롬프트로 바로 전송됩니다.
+
+[문서 →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub & Linear 네이티브
+
+PR, issue, 프로젝트 보드를 앱 안에서 탐색하세요 — 어떤 작업에서든 worktree를 열고 컨텍스트 전환 없이 리뷰할 수 있습니다.
+
+[문서 →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### SSH Worktree
+
+강력한 원격 머신에서 에이전트를 실행하세요. 파일 편집, git, 터미널을 모두 지원하며 자동 재연결과 포트 포워딩도 포함됩니다.
+
+[문서 →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### AI Diff 주석
+
+diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내세요 — Orca를 떠나지 않고 리뷰하고 수정하고 커밋할 수 있습니다.
+
+[문서 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### 에이전트로 파일 드래그
+
+어디서나 자동 저장되는 VS Code 에디터 — 파일이나 이미지를 에이전트 프롬프트로 바로 드래그하세요.
+
+[문서 →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+에이전트도 Orca를 조작할 수 있습니다 — `orca worktree create`, `snapshot`, `click`, `fill`로 모든 워크플로를 스크립팅하세요.
+
+[문서 →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**그 밖에 기본으로 제공되는 기능:**
+
+- **[빠른 열기](https://www.onorca.dev/docs/model/quick-open)** — 작업 흐름을 벗어나지 않고 worktree, 파일, 에이전트, 커맨드, 리포지토리 컨텍스트를 검색하세요.
+- **[계정 전환 및 사용량 추적](https://www.onorca.dev/docs/agents/usage-tracking)** — Claude와 Codex의 사용량과 rate limit 초기화 시점을 확인하고, 다시 로그인하지 않고 계정을 바로 전환하세요.
+- **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다.
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요.
+- **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요.
+- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다.
+
+---
+
+## 지원 에이전트
+
+**모든 CLI 에이전트**와 함께 작동합니다 — 터미널에서 실행되는 에이전트라면 Orca에서도 실행됩니다.
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + any CLI agent
+
---
## 설치
-### Mac, Linux, Windows
+### 데스크톱 — macOS, Windows, Linux
-- **[onOrca.dev에서 다운로드](https://onOrca.dev)**
-- 또는 **[GitHub Releases 페이지](https://github.com/stablyai/orca/releases/latest)** 에서 받기
+- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
+- 또는 빌드를 직접 받기: [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) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
-_패키지 매니저로도 설치할 수 있습니다:_
-
-### macOS (Homebrew)
+_또는 패키지 매니저로 설치:_
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# 사전 컴파일된 바이너리
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# GitHub 소스에서 빌드
-yay -S stably-orca-git
```
----
+### 모바일 Companion — iOS, Android
-## 모바일 Companion 앱
-
-휴대폰에서 에이전트를 제어하세요.
-
-
-
-
+데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [GitHub Releases에서 APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## 기능 쇼케이스
-
-타일을 클릭해 각 워크플로를 살펴보세요.
-
-
- 병렬 Worktree
- 터미널 분할
- 디자인 모드
- GitHub 및 Linear 네이티브
- 모든 CLI 에이전트
- SSH Worktree
- 에이전트로 파일 드래그
- AI Diff 주석
- Orca CLI
- 네이티브 검색
- 계정 전환 및 사용량 추적
- 풍부한 리포지토리 미리보기
- 무엇이든 분할
-
+- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
@@ -142,10 +236,19 @@ yay -S stably-orca-git
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
-- **응원하기:** 이 리포지토리에 star를 눌러 일일 릴리스를 따라와 주세요.
+- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
+- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일의 릴리스를 따라와 주세요.
---
## 개발
-기여하거나 로컬에서 실행하고 싶으신가요? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 가이드를 확인하세요.
+기여하거나 로컬에서 실행하고 싶으신가요? [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) 가이드를 확인하세요.
+
+
+
+
+
+## 라이선스
+
+Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 5615bf81cd0..5e8fac5b266 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -3,149 +3,252 @@
-
-
-
+
+
+
+
+
- English · 中文 · 日本語 · 한국어 · Español
+ English · Español · 日本語 · 한국어
面向 100x 构建者的 AI 编排器。
- 跨仓库并排运行 Claude Code、OpenClaude、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
- 支持 macOS、Windows 和 Linux 。
+ 并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
+
+
- 下载 🐋
+
-
-
-
-
-## 支持的智能体
-
-Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
-
-
- Claude Code
- OpenClaude
- Codex
- Grok
- Gemini
- Antigravity
- Pi
- oh-my-pi
- Hermes Agent
- OpenCode
- Goose
- Amp
- Auggie
- Autohand Code
- Charm
- Cline
- Codebuff
- Command Code
- Continue
- Cursor
- Droid
- GitHub Copilot
- Kilocode
- Kimi
- Kiro
- Mistral Vibe
- Qwen Code
- Rovo Dev
-
-
----
-
## 特性
-- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。
-- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
-- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
-- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
-- **GitHub 集成** — PR、issue 和 Actions 检查会自动链接到对应的 worktree。
-- **SSH 支持** — 连接远程机器,并直接从 Orca 在远程机器上运行智能体。
-- **通知** — 智能体完成任务或需要关注时及时通知你。可将会话标记为未读,方便稍后返回处理。
+
+
+
+
+### 移动 Companion 应用
+
+用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
+
+[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
+
+
+
+
+
+
+
+
+
+### 并行 Worktree
+
+把一个提示同时分发给五个智能体,每个都在自己隔离的 git worktree 中运行 — 比较结果,合并最佳方案。
+
+[文档 →](https://www.onorca.dev/docs/model/worktrees)
+
+
+
+
+
+
+
+
+
+### 终端分屏
+
+Ghostty 级终端,支持 WebGL 渲染、无限分屏,以及重启后依然保留的滚动历史。
+
+[文档 →](https://www.onorca.dev/docs/terminal)
+
+
+
+
+
+
+
+
+
+### 设计模式
+
+在真实的 Chromium 窗口中点击任意 UI 元素,把它的 HTML、CSS 和裁剪好的截图直接发送到智能体的提示中。
+
+[文档 →](https://www.onorca.dev/docs/browser/design-mode)
+
+
+
+
+
+
+
+
+
+### GitHub & Linear 原生集成
+
+在应用内浏览 PR、issue 和项目看板 — 从任意任务打开 worktree,无需切换上下文即可完成评审。
+
+[文档 →](https://www.onorca.dev/docs/review/linear)
+
+
+
+
+
+
+
+
+
+### SSH Worktree
+
+在高性能远程机器上运行智能体,完整支持文件编辑、git 和终端 — 自动重连与端口转发一应俱全。
+
+[文档 →](https://www.onorca.dev/docs/ssh)
+
+
+
+
+
+
+
+
+
+### 标注 AI Diff
+
+在任意 diff 行上添加评论并发回给智能体 — 评审、编辑、提交,全程无需离开 Orca。
+
+[文档 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
+
+
+
+
+
+
+
+
+
+### 拖文件给智能体
+
+VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智能体提示。
+
+[文档 →](https://www.onorca.dev/docs/editing/file-explorer)
+
+
+
+
+
+
+
+
+
+### Orca CLI
+
+智能体也能驱动 Orca — 用 `orca worktree create`、`snapshot`、`click` 和 `fill` 把每个工作流脚本化。
+
+[文档 →](https://www.onorca.dev/docs/cli/overview)
+
+
+
+
+
+
+
+
+**开箱即用的还有:**
+
+- **[快速打开](https://www.onorca.dev/docs/model/quick-open)** — 在 worktree、文件、智能体、命令和仓库上下文之间搜索,不打断你的心流。
+- **[账号切换与用量追踪](https://www.onorca.dev/docs/agents/usage-tracking)** — 查看 Claude 和 Codex 的用量与限额重置时间,并且无需重新登录即可热切换账号。
+- **[丰富仓库预览](https://www.onorca.dev/docs/editing/markdown)** — 在工作区中预览 Markdown、图片、PDF 和仓库文档。
+- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 当工作流需要真实交互时,让智能体操作桌面应用和可见 UI。
+- **[通知与未读状态](https://www.onorca.dev/docs/notifications)** — 第一时间知道智能体何时完成或需要关注,并可将会话标记为未读,稍后再回来处理。
+- **还有很多很多** — 我们每天发布新功能,这个列表永远跟不上。[更新日志](https://github.com/stablyai/orca/releases)才是真正的功能列表。
+
+---
+
+## 支持的智能体
+
+适配**任何 CLI 智能体** — 只要能在终端里运行,就能在 Orca 里运行。
+
+
+ Claude Code
+ Codex
+ Grok
+ Gemini
+ Cursor
+ GitHub Copilot
+ OpenCode
+ Amp
+ OpenClaude
+ Antigravity
+ Pi
+ oh-my-pi
+ Hermes Agent
+ Goose
+ Auggie
+ Autohand Code
+ Charm
+ Cline
+ Codebuff
+ Command Code
+ Continue
+ Droid
+ Kilocode
+ Kimi
+ Kiro
+ Mistral Vibe
+ Qwen Code
+ Rovo Dev
+ + 任何 CLI 智能体
+
---
## 安装
-### Mac, Linux, Windows
+### 桌面端 — macOS、Windows、Linux
-- **[从 onOrca.dev 下载](https://onOrca.dev)**
-- 或通过 **[GitHub Releases 页面](https://github.com/stablyai/orca/releases/latest)** 获取
+- **[从 onOrca.dev 下载](https://onorca.dev/download)**
+- 或直接获取安装包:[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) · [全部构建](https://github.com/stablyai/orca/releases/latest)
_也可以通过包管理器安装:_
-### macOS (Homebrew)
-
```bash
+# macOS (Homebrew)
brew install --cask stablyai/orca/orca
-```
-### Arch Linux (AUR)
-
-```bash
-# 预编译二进制
+# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
-
-# 从 GitHub 源码构建
-yay -S stably-orca-git
```
----
+### 移动 Companion 应用 — iOS、Android
-## 移动 Companion 应用
-
-用手机控制你的智能体。
-
-
-
-
+与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
-- **Android:** [从 GitHub Releases 下载 APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
-
----
-
-## 功能展示
-
-点击任意卡片了解对应工作流。
-
-
- 并行 Worktree
- 终端分屏
- 设计模式
- GitHub 与 Linear 原生集成
- 任意 CLI 智能体
- SSH Worktree
- 拖文件给智能体
- 标注 AI Diff
- Orca CLI
- 原生搜索
- 账号切换与用量追踪
- 丰富仓库预览
- 任意分屏
-
+- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
## 社区与支持
-- **Discord:** 加入我们的 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
+- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
-- **支持我们:** 给这个仓库点 Star,关注我们的日常发布。
+- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
+- **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。
---
## 开发
-想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 指南。
+想要贡献代码或在本地运行?请参阅我们的 [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) 指南。
+
+
+
+
+
+## 许可证
+
+Orca 是自由且开源的软件,遵循 [MIT 许可证](../../LICENSE)。
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 9046fcbf0c3..e4b2a03ccc3 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -131,6 +131,7 @@ import {
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
+import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import {
createMobileSessionCreateWarningState,
dismissMobileSessionCreateWarningState,
@@ -431,6 +432,10 @@ 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.
+ const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
+ const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
if (!doc || doc.status === 'loading') {
return (
@@ -469,6 +474,7 @@ function MarkdownReader({
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
+ onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
(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.
+ const tabStripRef = useRef(null)
+ const tabStripOffsetRef = useRef(0)
+ const tabStripViewportWidthRef = useRef(0)
+ const tabStripContentWidthRef = useRef(0)
+ const tabLayoutsRef = useRef>(new Map())
const [markdownDocs, setMarkdownDocs] = useState>(new Map())
const markdownDocsRef = useRef>(new Map())
const [fileDocs, setFileDocs] = useState>(new Map())
@@ -2295,6 +2308,34 @@ export default function SessionScreen() {
}
}, [])
+ const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
+ if (!tabId) {
+ return
+ }
+ const layout = tabLayoutsRef.current.get(tabId)
+ if (!layout) {
+ return
+ }
+ const nextOffset = resolveTabStripScrollOffset({
+ tabX: layout.x,
+ tabWidth: layout.width,
+ viewportWidth: tabStripViewportWidthRef.current,
+ contentWidth: tabStripContentWidthRef.current,
+ currentOffset: tabStripOffsetRef.current
+ })
+ if (nextOffset !== tabStripOffsetRef.current) {
+ tabStripOffsetRef.current = nextOffset
+ tabStripRef.current?.scrollTo({ x: nextOffset, animated })
+ }
+ }, [])
+
+ // 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.
+ useEffect(() => {
+ const id = requestAnimationFrame(() => scrollActiveTabIntoView(activeSessionTabId, true))
+ return () => cancelAnimationFrame(id)
+ }, [activeSessionTabId, scrollActiveTabIntoView])
+
useEffect(() => {
if (hostId && worktreeId) {
void AsyncStorage.setItem(
@@ -3867,16 +3908,36 @@ export default function SessionScreen() {
(#5106); leaving a non-live tab still closes the keyboard
because the live input unmounts. */}
{
+ tabStripOffsetRef.current = e.nativeEvent.contentOffset.x
+ }}
+ onLayout={(e) => {
+ tabStripViewportWidthRef.current = e.nativeEvent.layout.width
+ scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
+ }}
+ onContentSizeChange={(width) => {
+ tabStripContentWidthRef.current = width
+ scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
+ }}
>
{visibleTabs.map((t) => (
{
+ const { x, width } = e.nativeEvent.layout
+ tabLayoutsRef.current.set(t.id, { x, width })
+ if (t.id === activeSessionTabIdRef.current) {
+ scrollActiveTabIntoView(t.id, false)
+ }
+ }}
onPress={() => switchSessionTab(t)}
onLongPress={() => {
triggerMediumImpact()
diff --git a/mobile/src/components/MobileRichMarkdownEditor.tsx b/mobile/src/components/MobileRichMarkdownEditor.tsx
index e50bc2e19ca..c9e0b44e082 100644
--- a/mobile/src/components/MobileRichMarkdownEditor.tsx
+++ b/mobile/src/components/MobileRichMarkdownEditor.tsx
@@ -19,6 +19,7 @@ import {
} from 'lucide-react-native'
import WebView, { type WebViewMessageEvent } from 'react-native-webview'
import { colors, radii, spacing } from '../theme/mobile-theme'
+import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script'
import {
buildMobileRichMarkdownEditorHtml,
escapeInjectedJavaScriptString
@@ -73,12 +74,14 @@ type Props = {
content: string
editable: boolean
onChange: (content: string) => void
+ onKeyboardInsetChange?: (bottom: number) => void
}
type EditorWebViewMessage =
| { type: 'ready' }
| { type: 'change'; markdown: string; generation: number }
| { type: 'openLink'; url: string }
+ | { type: 'keyboardInset'; bottom: number }
type ToolbarItem = {
command: RichMarkdownCommand
@@ -104,7 +107,12 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
]
-function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
+function MobileRichMarkdownEditorInner({
+ content,
+ editable,
+ onChange,
+ onKeyboardInsetChange
+}: Props) {
const webViewRef = useRef(null)
const readyRef = useRef(false)
const documentGenerationRef = useRef(0)
@@ -150,6 +158,12 @@ function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
}
}, [applyEditable, editable])
+ // Clear any reported keyboard inset when the editor unmounts so a lifted
+ // Save/Discard bar settles back once the tab closes.
+ useEffect(() => {
+ return () => onKeyboardInsetChange?.(0)
+ }, [onKeyboardInsetChange])
+
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: unknown
@@ -182,9 +196,16 @@ function MobileRichMarkdownEditorInner({ content, editable, onChange }: Props) {
if (url) {
void Linking.openURL(url).catch(() => {})
}
+ return
+ }
+ if (editorMessage.type === 'keyboardInset' && typeof editorMessage.bottom === 'number') {
+ const bottom = normalizeMobileRichMarkdownKeyboardInset(editorMessage.bottom)
+ if (bottom !== null) {
+ onKeyboardInsetChange?.(bottom)
+ }
}
},
- [applyContent, applyEditable, content, editable, onChange]
+ [applyContent, applyEditable, content, editable, onChange, onKeyboardInsetChange]
)
const handleShouldStartLoadWithRequest = useCallback((request: { url?: string }) => {
diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts
index 826bd1693d0..32fc12b59b2 100644
--- a/mobile/src/components/mobile-rich-markdown-editor-html.ts
+++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts
@@ -1,4 +1,5 @@
import { colors } from '../theme/mobile-theme'
+import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
@@ -669,13 +670,8 @@ export function buildMobileRichMarkdownEditorHtml(): string {
}
});
- window.__orcaRichMarkdown = {
- setMarkdown: setMarkdown,
- setEditable: setEditable,
- runCommand: runCommand,
- currentMarkdown: currentMarkdown
- };
-
+ window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown };
+${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
post({ type: 'ready' });
})();
diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts
new file mode 100644
index 00000000000..c29b6ab5ba9
--- /dev/null
+++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeMobileRichMarkdownKeyboardInset } from './mobile-rich-markdown-editor-keyboard-inset-script'
+
+describe('normalizeMobileRichMarkdownKeyboardInset', () => {
+ it('rounds finite inset measurements for native layout', () => {
+ expect(normalizeMobileRichMarkdownKeyboardInset(42.6)).toBe(43)
+ })
+
+ it('clamps negative inset measurements to zero', () => {
+ expect(normalizeMobileRichMarkdownKeyboardInset(-8)).toBe(0)
+ })
+
+ it('rejects non-finite inset measurements', () => {
+ expect(normalizeMobileRichMarkdownKeyboardInset(Number.NaN)).toBeNull()
+ expect(normalizeMobileRichMarkdownKeyboardInset(Number.POSITIVE_INFINITY)).toBeNull()
+ })
+})
diff --git a/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts
new file mode 100644
index 00000000000..4182d133f00
--- /dev/null
+++ b/mobile/src/components/mobile-rich-markdown-editor-keyboard-inset-script.ts
@@ -0,0 +1,28 @@
+// In-page script that reports the height covered by the on-screen keyboard.
+// Native Keyboard events are unreliable while focus lives in the editor
+// WebView, so measure the covered region directly from visualViewport and let
+// RN lift its native Save/Discard bar above it.
+export function normalizeMobileRichMarkdownKeyboardInset(value: number): number | null {
+ if (!Number.isFinite(value)) {
+ return null
+ }
+ return Math.max(0, Math.round(value))
+}
+
+export const MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT = `
+ var lastInset = -1;
+ function reportKeyboardInset() {
+ var viewport = window.visualViewport;
+ var bottom = viewport
+ ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)
+ : 0;
+ var rounded = Math.round(bottom);
+ if (rounded === lastInset) return;
+ lastInset = rounded;
+ post({ type: 'keyboardInset', bottom: rounded });
+ }
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener('resize', reportKeyboardInset);
+ window.visualViewport.addEventListener('scroll', reportKeyboardInset);
+ reportKeyboardInset();
+ }`
diff --git a/mobile/src/session/tab-strip-scroll.test.ts b/mobile/src/session/tab-strip-scroll.test.ts
new file mode 100644
index 00000000000..fab517a3155
--- /dev/null
+++ b/mobile/src/session/tab-strip-scroll.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+import { resolveTabStripScrollOffset } from './tab-strip-scroll'
+
+describe('resolveTabStripScrollOffset', () => {
+ it('keeps the offset when the active tab is already fully visible', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 140,
+ tabWidth: 128,
+ viewportWidth: 360,
+ contentWidth: 800,
+ currentOffset: 100
+ })
+ ).toBe(100)
+ })
+
+ it('scrolls left to reveal a tab off the left edge', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 50,
+ tabWidth: 128,
+ viewportWidth: 360,
+ contentWidth: 800,
+ currentOffset: 200,
+ margin: 12
+ })
+ ).toBe(38)
+ })
+
+ it('scrolls right to reveal a tab off the right edge', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 640,
+ tabWidth: 128,
+ viewportWidth: 360,
+ contentWidth: 900,
+ currentOffset: 0,
+ margin: 12
+ })
+ ).toBe(420)
+ })
+
+ it('clamps the offset to the content bounds', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 880,
+ tabWidth: 128,
+ viewportWidth: 360,
+ contentWidth: 900,
+ currentOffset: 0
+ })
+ ).toBe(540)
+ })
+
+ it('returns the current offset when the viewport has not been measured', () => {
+ expect(
+ resolveTabStripScrollOffset({
+ tabX: 100,
+ tabWidth: 128,
+ viewportWidth: 0,
+ contentWidth: 0,
+ currentOffset: 0
+ })
+ ).toBe(0)
+ })
+})
diff --git a/mobile/src/session/tab-strip-scroll.ts b/mobile/src/session/tab-strip-scroll.ts
new file mode 100644
index 00000000000..275b9ed5f57
--- /dev/null
+++ b/mobile/src/session/tab-strip-scroll.ts
@@ -0,0 +1,40 @@
+export type TabStripScrollInput = {
+ tabX: number
+ tabWidth: number
+ viewportWidth: number
+ contentWidth: number
+ currentOffset: number
+ margin?: number
+}
+
+/**
+ * Keep active-tab reveal deterministic across async RN layout events without
+ * nudging the strip when the tab is already visible.
+ */
+export function resolveTabStripScrollOffset({
+ tabX,
+ tabWidth,
+ viewportWidth,
+ contentWidth,
+ currentOffset,
+ margin = 12
+}: TabStripScrollInput): number {
+ const maxOffset = Math.max(0, contentWidth - viewportWidth)
+ if (viewportWidth <= 0) {
+ return currentOffset
+ }
+
+ const visibleStart = currentOffset
+ const visibleEnd = currentOffset + viewportWidth
+ const tabStart = tabX
+ const tabEnd = tabX + tabWidth
+
+ let nextOffset = currentOffset
+ if (tabStart < visibleStart + margin) {
+ nextOffset = tabStart - margin
+ } else if (tabEnd > visibleEnd - margin) {
+ nextOffset = tabEnd + margin - viewportWidth
+ }
+
+ return Math.min(Math.max(0, nextOffset), maxOffset)
+}
diff --git a/package.json b/package.json
index 931cb6b01a1..d9ee8a2f415 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.64-rc.2",
+ "version": "1.4.65-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
diff --git a/src/main/ai-vault/session-scanner-grok-parser.ts b/src/main/ai-vault/session-scanner-grok-parser.ts
new file mode 100644
index 00000000000..53dca524860
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-grok-parser.ts
@@ -0,0 +1,110 @@
+import { createReadStream } from 'fs'
+import { readFile } from 'fs/promises'
+import { dirname, join } from 'path'
+import { createInterface } from 'readline'
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
+import {
+ addPreviewMessage,
+ createAccumulator,
+ finalizeSession,
+ sessionIdFromFileName,
+ updateTimeline
+} from './session-scanner-accumulator'
+import {
+ asRecord,
+ extractString,
+ normalizeTitleText,
+ numberValue,
+ parseJsonObject
+} from './session-scanner-values'
+
+export async function parseGrokSessionFile(
+ file: FileWithMtime,
+ platform: NodeJS.Platform = process.platform
+): Promise {
+ const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
+ if (!record) {
+ return null
+ }
+ const info = asRecord(record.info)
+ const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path))
+ const accumulator = createAccumulator({ agent: 'grok', file, sessionId })
+ accumulator.cwd = extractString(info?.cwd)
+ accumulator.title =
+ normalizeTitleText(extractString(record.generated_title) ?? '') ??
+ normalizeTitleText(extractString(record.session_summary) ?? '')
+ accumulator.model = extractString(record.current_model_id)
+ accumulator.branch = extractString(record.head_branch)
+ accumulator.messageCount =
+ numberValue(record.num_chat_messages) || numberValue(record.num_messages)
+ updateTimeline(accumulator, extractString(record.created_at))
+ updateTimeline(accumulator, extractString(record.updated_at))
+ updateTimeline(accumulator, extractString(record.last_active_at))
+ await consumeGrokChatHistory(accumulator, dirname(file.path))
+ return finalizeSession(accumulator, platform)
+}
+
+async function consumeGrokChatHistory(
+ accumulator: SessionAccumulator,
+ sessionDir: string
+): Promise {
+ try {
+ const lines = createInterface({
+ input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }),
+ crlfDelay: Infinity
+ })
+
+ for await (const line of lines) {
+ const record = parseJsonObject(line)
+ if (!record) {
+ continue
+ }
+ const role = extractString(record.type)
+ if (role !== 'user' && role !== 'assistant') {
+ continue
+ }
+ const text = extractGrokContentText(record.content)
+ if (role === 'user') {
+ accumulator.title ??= normalizeTitleText(text ?? '')
+ }
+ addPreviewMessage(accumulator, {
+ role,
+ text,
+ timestamp: extractString(record.timestamp)
+ })
+ }
+ } catch {
+ // Summary-only sessions still provide enough metadata for the Vault list.
+ }
+}
+
+function extractGrokContentText(value: unknown): string | null {
+ const text = extractGrokRawContentText(value)
+ if (!text) {
+ return null
+ }
+ return text.match(/\s*([\s\S]*?)\s*<\/user_query>/i)?.[1]?.trim() || text
+}
+
+function extractGrokRawContentText(value: unknown): string | null {
+ if (typeof value === 'string') {
+ return extractString(value)
+ }
+ if (!Array.isArray(value)) {
+ return null
+ }
+ const parts: string[] = []
+ for (const item of value) {
+ if (typeof item === 'string') {
+ parts.push(item)
+ continue
+ }
+ const record = asRecord(item)
+ const text = extractString(record?.text) || extractString(record?.content)
+ if (text) {
+ parts.push(text)
+ }
+ }
+ return extractString(parts.join(' '))
+}
diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts
index 47beebcfbc4..da494568adb 100644
--- a/src/main/ai-vault/session-scanner-types.ts
+++ b/src/main/ai-vault/session-scanner-types.ts
@@ -13,6 +13,7 @@ export type AiVaultScanOptions = {
copilotSessionsDir?: string
cursorProjectsDir?: string
opencodeStorageDir?: string
+ grokSessionsDir?: string
hermesSessionsDir?: string
rovoSessionsDir?: string
openclawStateDir?: string
diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts
index 7c7bd43b508..1efaacdacdb 100644
--- a/src/main/ai-vault/session-scanner.test.ts
+++ b/src/main/ai-vault/session-scanner.test.ts
@@ -20,6 +20,7 @@ function isolatedScanRoots(root: string) {
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
+ grokSessionsDir: join(root, 'grok-sessions'),
hermesSessionsDir: join(root, 'hermes-sessions'),
rovoSessionsDir: join(root, 'rovo-sessions'),
openclawStateDir: join(root, 'openclaw-state'),
@@ -422,6 +423,42 @@ describe('scanAiVaultSessions', () => {
})
)
+ await mkdir(join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session'), {
+ recursive: true
+ })
+ await writeFile(
+ join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session', 'summary.json'),
+ JSON.stringify({
+ info: { id: 'grok-session', cwd: '/tmp/grok' },
+ session_summary: '',
+ created_at: '2026-05-01T10:04:00.000Z',
+ updated_at: '2026-05-01T10:04:01.000Z',
+ num_chat_messages: 2,
+ current_model_id: 'grok-build',
+ head_branch: 'feature/grok-vault'
+ })
+ )
+ await writeFile(
+ join(
+ roots.grokSessionsDir,
+ encodeURIComponent('/tmp/grok'),
+ 'grok-session',
+ 'chat_history.jsonl'
+ ),
+ jsonLines([
+ {
+ type: 'user',
+ content: [
+ {
+ type: 'text',
+ text: 'context Grok title '
+ }
+ ]
+ },
+ { type: 'assistant', content: 'Done' }
+ ])
+ )
+
await mkdir(roots.hermesSessionsDir, { recursive: true })
await writeFile(
join(roots.hermesSessionsDir, 'session_hermes-session.json'),
@@ -544,6 +581,7 @@ describe('scanAiVaultSessions', () => {
expect(commandByAgent.get('opencode')).toBe(
"cd '/tmp/opencode' && opencode --session 'opencode-session'"
)
+ expect(commandByAgent.get('grok')).toBe("cd '/tmp/grok' && grok --resume 'grok-session'")
expect(commandByAgent.get('hermes')).toBe(
"cd '/tmp/hermes' && hermes --resume 'hermes-session'"
)
diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts
index 5e31ff966c2..b3f612cea70 100644
--- a/src/main/ai-vault/session-scanner.ts
+++ b/src/main/ai-vault/session-scanner.ts
@@ -8,6 +8,7 @@ import type {
import { sessionSortTime } from './session-scanner-accumulator'
import { codexHomeForSessionsDir, uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
+import { parseGrokSessionFile } from './session-scanner-grok-parser'
import {
parseDroidSessionFile,
parseMessageGraphSessionFile,
@@ -53,15 +54,17 @@ const OPENCODE_STORAGE_DIR = join(
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
'storage'
)
+const GROK_SESSIONS_DIR = join(
+ process.env.GROK_HOME?.trim() || join(homedir(), '.grok'),
+ 'sessions'
+)
const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions')
const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions')
const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw')
-const OPENCLAW_LEGACY_STATE_DIR = join(homedir(), '.clawdbot')
const PI_SESSIONS_DIR = normalizePiSessionsDir(
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions')
)
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
-const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
export async function scanAiVaultSessions(
options: AiVaultScanOptions = {}
@@ -121,6 +124,14 @@ export async function scanAiVaultSessions(
issues,
extensions: ['.json']
}),
+ discoverFiles({
+ rootDir: options.grokSessionsDir ?? GROK_SESSIONS_DIR,
+ limit: limitPerAgent,
+ agent: 'grok',
+ issues,
+ extensions: ['.json'],
+ filePredicate: (path) => basename(path) === 'summary.json'
+ }),
discoverFiles({
rootDir: options.hermesSessionsDir ?? HERMES_SESSIONS_DIR,
limit: limitPerAgent,
@@ -140,7 +151,7 @@ export async function scanAiVaultSessions(
discoverOpenClawFiles({
rootDirs: [
options.openclawStateDir ?? OPENCLAW_STATE_DIR,
- options.openclawLegacyStateDir ?? OPENCLAW_LEGACY_STATE_DIR
+ options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot')
],
limit: limitPerAgent,
issues
@@ -160,7 +171,7 @@ export async function scanAiVaultSessions(
extensions: ['.jsonl']
}),
discoverFiles({
- rootDir: options.droidProjectsDir ?? DROID_PROJECTS_DIR,
+ rootDir: options.droidProjectsDir ?? join(homedir(), '.factory', 'projects'),
limit: limitPerAgent,
agent: 'droid',
issues,
@@ -274,6 +285,8 @@ async function parseAgentSessionFile(
return parseCursorSessionFile(candidate.file, platform)
case 'opencode':
return parseOpenCodeSessionFile(candidate.file, platform)
+ case 'grok':
+ return parseGrokSessionFile(candidate.file, platform)
case 'hermes':
return parseHermesSessionFile(candidate.file, platform)
case 'rovo':
diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts
index 1096210f842..1630ada4d8a 100644
--- a/src/main/codex-accounts/runtime-home-service.test.ts
+++ b/src/main/codex-accounts/runtime-home-service.test.ts
@@ -82,6 +82,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
+ openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
showTitlebarAppName: true,
diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts
index 21f1335010f..2a00e2a4856 100644
--- a/src/main/codex-accounts/service.test.ts
+++ b/src/main/codex-accounts/service.test.ts
@@ -86,6 +86,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
+ openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
showTitlebarAppName: true,
diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts
index 162ee86ba7a..27c7d0cd15d 100644
--- a/src/main/ipc/telemetry.test.ts
+++ b/src/main/ipc/telemetry.test.ts
@@ -160,6 +160,14 @@ describe('telemetry IPC handlers', () => {
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
const handler = handlers.get('telemetry:track')!
handler({}, 'app_starred_orca', { source: 'settings' })
+ handler({}, 'star_nag_outcome', {
+ outcome: 'shown',
+ source: 'threshold',
+ mode: 'gh',
+ threshold: 35,
+ agents_since_baseline: 35,
+ agents_since_baseline_bucket: '35-69'
+ })
handler({}, 'feature_interaction_usage_bucket_reached', {
feature_id: 'tasks',
feature_category: 'task_management',
diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts
index a4faa9d7927..03cc50e7026 100644
--- a/src/main/ipc/telemetry.ts
+++ b/src/main/ipc/telemetry.ts
@@ -55,6 +55,7 @@ let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set([
'app_starred_orca',
+ 'star_nag_outcome',
'feature_interaction_usage_bucket_reached'
])
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index a1e7ea44acf..c2a044024e5 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -2229,6 +2229,14 @@ export class Store {
parsed.settings?.disabledTuiAgents
)
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
+ const openLinksInAppWasPersisted = Object.prototype.hasOwnProperty.call(
+ parsed.settings ?? {},
+ 'openLinksInApp'
+ )
+ const migratedOpenLinksInAppPreferencePrompted =
+ typeof parsed.settings?.openLinksInAppPreferencePrompted === 'boolean'
+ ? parsed.settings.openLinksInAppPreferencePrompted
+ : openLinksInAppWasPersisted
if (
parsed.settings?.agentYoloDefaultsMigrated !== true ||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
@@ -2245,6 +2253,12 @@ export class Store {
if (!autoRenameBranchFromWorkDefaultedOn) {
this.loadNeedsSave = true
}
+ if (
+ parsed.settings?.openLinksInAppPreferencePrompted !==
+ migratedOpenLinksInAppPreferencePrompted
+ ) {
+ this.loadNeedsSave = true
+ }
const normalizedOnboarding = normalizeLoadedOnboardingState(
parsed.onboarding,
defaults.onboarding
@@ -2322,6 +2336,7 @@ export class Store {
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
seedDefaults: true
}),
+ openLinksInAppPreferencePrompted: migratedOpenLinksInAppPreferencePrompted,
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
sourceControlAi: migratedSourceControlAi,
// Why: new builds read sourceControlAi, but rollback builds still
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index d78f56d2bd0..5460a02efe9 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -9443,6 +9443,151 @@ describe('OrcaRuntimeService', () => {
expect(spawn.mock.calls[1]?.[0]).not.toHaveProperty('sessionId')
})
+ it('keeps the activated headless tab active across PTY republishes (serve focus-jump regression)', async () => {
+ // Why: in `orca serve`, focusTerminal has no renderer to persist the remote
+ // client's tab choice before PTY republishes.
+ let nextPty = 0
+ const spawn = vi.fn().mockImplementation(async () => ({ id: `headless-pty-${++nextPty}` }))
+ const runtime = new OrcaRuntimeService(store)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ const FIRST_LEAF = '22222222-2222-4222-8222-222222222222'
+ const SECOND_LEAF = '33333333-3333-4333-8333-333333333333'
+ // The first-created headless terminal is the one the snapshot marks active.
+ await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
+ tabId: 'tab-first',
+ leafId: FIRST_LEAF
+ })
+ await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
+ tabId: 'tab-other',
+ leafId: SECOND_LEAF
+ })
+
+ const events: RuntimeMobileSessionTabsResult[] = []
+ runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
+
+ // The remote client switches to the other (non-active) tab.
+ await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-other')
+
+ const afterActivate = events.at(-1)
+ expect(afterActivate?.activeTabId).toBe(`tab-other::${SECOND_LEAF}`)
+ expect(afterActivate?.activeTabType).toBe('terminal')
+ expect(afterActivate?.tabGroups?.[0]?.activeTabId).toBe('tab-other')
+ expect(
+ afterActivate?.tabs.find((tab) => tab.id === `tab-other::${SECOND_LEAF}`)?.isActive
+ ).toBe(true)
+ expect(afterActivate?.tabs.find((tab) => tab.id === `tab-first::${FIRST_LEAF}`)?.isActive).toBe(
+ false
+ )
+
+ // PTY title updates republish snapshots, so the client's chosen tab must
+ // survive after activation.
+ events.length = 0
+ runtime.onPtyData('headless-pty-2', '\x1b]0;tab-other running\x07', 200)
+
+ const afterPtyData = events.at(-1)
+ expect(afterPtyData?.activeTabId).toBe(`tab-other::${SECOND_LEAF}`)
+ expect(afterPtyData?.activeTabType).toBe('terminal')
+ expect(afterPtyData?.tabGroups?.[0]?.activeTabId).toBe('tab-other')
+ })
+
+ it('does not bump the snapshot version when re-activating the already-active headless tab', async () => {
+ // Why: redundant activations of the current tab must not force a remote re-render.
+ const spawn = vi.fn().mockResolvedValue({ id: 'headless-pty-solo' })
+ const runtime = new OrcaRuntimeService(store)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ const LEAF = '44444444-4444-4444-8444-444444444444'
+ await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-solo', leafId: LEAF })
+
+ const events: RuntimeMobileSessionTabsResult[] = []
+ runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
+
+ await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-solo')
+
+ expect(events).toHaveLength(0)
+ })
+
+ it('does not persist active server-side when an authoritative renderer is attached', async () => {
+ // Why: when a renderer window is authoritative it re-syncs the snapshot itself,
+ // so the headless persist must NOT fire — the renderer stays the source of truth.
+ let nextPty = 0
+ const spawn = vi.fn().mockImplementation(async () => ({ id: `attached-pty-${++nextPty}` }))
+ const runtime = new OrcaRuntimeService(store)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ const LEAF_A = '55555555-5555-4555-8555-555555555555'
+ const LEAF_B = '66666666-6666-4666-8666-666666666666'
+ await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-a', leafId: LEAF_A })
+ await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-b', leafId: LEAF_B })
+
+ // Make an authoritative renderer window present.
+ runtime.attachWindow(1)
+ runtime.markGraphReady(1)
+ electronMocks.BrowserWindow.fromId.mockReturnValue({
+ isDestroyed: () => false,
+ webContents: { send: vi.fn() }
+ })
+
+ const events: RuntimeMobileSessionTabsResult[] = []
+ runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
+
+ await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-b')
+
+ // The headless persist is gated off by the authoritative window — nothing emitted.
+ expect(events).toHaveLength(0)
+ })
+
+ it('does not persist active server-side for a `:headless-merge:` snapshot after renderer detach', async () => {
+ // Why: after renderer detach, merged snapshots have no authoritative window
+ // but still carry renderer-owned group state.
+ let nextPty = 0
+ const spawn = vi.fn().mockImplementation(async () => ({ id: `merge-pty-${++nextPty}` }))
+ const runtime = new OrcaRuntimeService(store)
+ runtime.setPtyController({
+ spawn,
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null
+ })
+
+ const LEAF_A = '77777777-7777-4777-8777-777777777777'
+ const LEAF_B = '88888888-8888-4888-8888-888888888888'
+ // tab-a (first-created) is the snapshot's active tab.
+ await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-a', leafId: LEAF_A })
+ await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-b', leafId: LEAF_B })
+
+ // Simulate a post-detach merged snapshot with no authoritative window.
+ const current = runtime['mobileSessionTabsByWorktree'].get(TEST_WORKTREE_ID)!
+ runtime['mobileSessionTabsByWorktree'].set(TEST_WORKTREE_ID, {
+ ...current,
+ publicationEpoch: `renderer:headless-merge:${current.publicationEpoch}`
+ })
+
+ const events: RuntimeMobileSessionTabsResult[] = []
+ runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
+
+ // The merge exclusion must suppress server-side active rewrites.
+ await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-b')
+
+ expect(events).toHaveLength(0)
+ })
+
it('spawns fresh SSH terminals when hydrated persistence has no relay identity', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 373607da76d..37d3679b9b0 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -2799,6 +2799,8 @@ export class OrcaRuntimeService {
throw new Error('tab_not_found')
}
+ let activatedTab: RuntimeMobileSessionSnapshotTab = tab
+
if (tab.type === 'terminal') {
const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find(
(candidate) => candidate.type === 'terminal' && candidate.id === tab.id
@@ -2839,6 +2841,7 @@ export class OrcaRuntimeService {
)
const targetTab = activeSibling ?? tab
this.notifier?.focusTerminal(targetTab.parentTabId, worktreeId, targetTab.leafId)
+ activatedTab = targetTab
} else if (tab.type === 'browser') {
// Why: browser mobile tabs are renderer-owned unified tabs; focusing the
// session tab keeps desktop tab order/group state authoritative.
@@ -2846,9 +2849,56 @@ export class OrcaRuntimeService {
} else {
this.notifier?.focusEditorTab?.(tab.id, worktreeId)
}
+
+ // Why: serve/headless snapshots have no renderer to re-publish focus, but
+ // merged epochs can still contain renderer-owned group state.
+ if (
+ !this.getAvailableAuthoritativeWindow() &&
+ this.isPureHeadlessMobileSessionPublication(snapshot!.publicationEpoch)
+ ) {
+ this.persistHeadlessMobileSessionActiveTab(worktreeId, snapshot!, activatedTab)
+ }
return this.getMobileSessionTabsForWorktree(worktreeId)
}
+ private persistHeadlessMobileSessionActiveTab(
+ worktreeId: string,
+ snapshot: RuntimeMobileSessionTabsSnapshot,
+ activeTab: RuntimeMobileSessionSnapshotTab
+ ): void {
+ const alreadyActive =
+ snapshot.activeTabId === activeTab.id &&
+ snapshot.activeTabType === activeTab.type &&
+ snapshot.tabs.every((candidate) => candidate.isActive === (candidate.id === activeTab.id))
+ if (alreadyActive) {
+ // Why: re-activating the already-active tab must not bump snapshotVersion,
+ // or every redundant activation would force a remote re-render.
+ return
+ }
+ const tabs = snapshot.tabs.map((candidate) => ({
+ ...candidate,
+ isActive: candidate.id === activeTab.id
+ }))
+ const terminalTabs = tabs.filter(
+ (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal'
+ )
+ const next: RuntimeMobileSessionTabsSnapshot = {
+ ...snapshot,
+ snapshotVersion: snapshot.snapshotVersion + 1,
+ activeTabId: activeTab.id,
+ activeTabType: activeTab.type,
+ tabGroups: this.buildHeadlessMobileSessionTabGroups(
+ worktreeId,
+ terminalTabs,
+ activeTab.type === 'terminal' ? activeTab : null,
+ snapshot.tabGroups
+ ),
+ tabs
+ }
+ this.mobileSessionTabsByWorktree.set(worktreeId, next)
+ this.notifyMobileSessionTabsChanged(worktreeId)
+ }
+
private shouldMaterializeHeadlessMobileSessionTab(
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab
@@ -13342,10 +13392,15 @@ export class OrcaRuntimeService {
)
}
+ private isPureHeadlessMobileSessionPublication(publicationEpoch: string): boolean {
+ return (
+ publicationEpoch.startsWith('headless:') || publicationEpoch.startsWith('headless-hydrated:')
+ )
+ }
+
private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean {
return (
- publicationEpoch.startsWith('headless:') ||
- publicationEpoch.startsWith('headless-hydrated:') ||
+ this.isPureHeadlessMobileSessionPublication(publicationEpoch) ||
publicationEpoch.includes(':headless-merge:')
)
}
diff --git a/src/main/star-nag/service.test.ts b/src/main/star-nag/service.test.ts
index 5028667d25c..9dacb3d9dca 100644
--- a/src/main/star-nag/service.test.ts
+++ b/src/main/star-nag/service.test.ts
@@ -13,7 +13,15 @@ type TestWindow = {
webContents: { send: ReturnType }
}
-const { appMock, browserWindowMock, checkOrcaStarredMock, ipcMainHandleMock } = vi.hoisted(() => ({
+const {
+ appMock,
+ browserWindowMock,
+ checkOrcaStarredMock,
+ starOrcaMock,
+ trackMock,
+ getCohortAtEmitMock,
+ ipcMainHandleMock
+} = vi.hoisted(() => ({
appMock: {
getVersion: vi.fn(() => '1.2.3')
},
@@ -21,6 +29,9 @@ const { appMock, browserWindowMock, checkOrcaStarredMock, ipcMainHandleMock } =
getAllWindows: vi.fn<() => TestWindow[]>(() => [])
},
checkOrcaStarredMock: vi.fn(),
+ starOrcaMock: vi.fn(),
+ trackMock: vi.fn(),
+ getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 3 })),
ipcMainHandleMock: vi.fn()
}))
@@ -33,7 +44,16 @@ vi.mock('electron', () => ({
}))
vi.mock('../github/client', () => ({
- checkOrcaStarred: checkOrcaStarredMock
+ checkOrcaStarred: checkOrcaStarredMock,
+ starOrca: starOrcaMock
+}))
+
+vi.mock('../telemetry/client', () => ({
+ track: trackMock
+}))
+
+vi.mock('../telemetry/cohort-classifier', () => ({
+ getCohortAtEmit: getCohortAtEmitMock
}))
type AgentStartedListener = (totalAgentsSpawned: number) => void
@@ -131,6 +151,11 @@ describe('StarNagService', () => {
browserWindowMock.getAllWindows.mockReturnValue([])
checkOrcaStarredMock.mockReset()
checkOrcaStarredMock.mockResolvedValue(false)
+ starOrcaMock.mockReset()
+ starOrcaMock.mockResolvedValue(true)
+ trackMock.mockReset()
+ getCohortAtEmitMock.mockReset()
+ getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 })
ipcMainHandleMock.mockReset()
consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => undefined)
})
@@ -150,7 +175,9 @@ describe('StarNagService', () => {
emitAgentStarted(46)
expect(window.webContents.send).toHaveBeenCalledTimes(1)
- expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
+ expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
+ mode: 'gh'
+ })
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
@@ -171,7 +198,18 @@ describe('StarNagService', () => {
emitAgentStarted(45)
await flushAsyncWork()
- expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' })
+ expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
+ mode: 'web'
+ })
+ expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
+ outcome: 'shown',
+ source: 'threshold',
+ mode: 'web',
+ threshold: STAR_NAG_INITIAL_THRESHOLD,
+ agents_since_baseline: 35,
+ agents_since_baseline_bucket: '35-69',
+ nth_repo_added: 3
+ })
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
app_version: '1.2.3',
@@ -203,13 +241,16 @@ describe('StarNagService', () => {
await flushAsyncWork()
expect(consoleInfoMock).not.toHaveBeenCalled()
+ expect(trackMock).not.toHaveBeenCalled()
const window = createWindow()
browserWindowMock.getAllWindows.mockReturnValue([window])
emitAgentStarted(46)
await flushAsyncWork()
- expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
+ expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
+ mode: 'gh'
+ })
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
app_version: '1.2.3',
@@ -282,7 +323,9 @@ describe('StarNagService', () => {
browserWindowMock.getAllWindows.mockReturnValue([window])
forceShow()
- expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
+ expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
+ mode: 'gh'
+ })
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
app_version: '1.2.3',
@@ -405,7 +448,9 @@ describe('StarNagService', () => {
expect(window.webContents.send).toHaveBeenCalledTimes(1)
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
- expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' })
+ expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
+ mode: 'web'
+ })
expect(consoleInfoMock).toHaveBeenCalledWith({
event: 'star_nag_shown',
app_version: '1.2.3',
@@ -458,4 +503,217 @@ describe('StarNagService', () => {
source: 'force_show'
})
})
+
+ it('emits shown and already_starred_suppressed outcomes with cohort context', async () => {
+ const window = createWindow()
+ browserWindowMock.getAllWindows.mockReturnValue([window])
+ const { service, emitAgentStarted } = createHarness()
+
+ service.start()
+ emitAgentStarted(45)
+ await flushAsyncWork()
+
+ expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
+ outcome: 'shown',
+ source: 'threshold',
+ mode: 'gh',
+ threshold: STAR_NAG_INITIAL_THRESHOLD,
+ agents_since_baseline: 35,
+ agents_since_baseline_bucket: '35-69',
+ nth_repo_added: 3
+ })
+
+ trackMock.mockClear()
+ checkOrcaStarredMock.mockResolvedValue(true)
+ const next = createHarness()
+ next.service.start()
+ next.emitAgentStarted(45)
+ await flushAsyncWork()
+
+ expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
+ outcome: 'already_starred_suppressed',
+ source: 'threshold',
+ mode: 'gh',
+ threshold: STAR_NAG_INITIAL_THRESHOLD,
+ agents_since_baseline: 35,
+ agents_since_baseline_bucket: '35-69',
+ nth_repo_added: 3
+ })
+ })
+
+ it('emits dismissed, disabled, and opened_web as distinct main-owned outcomes', () => {
+ const window = createWindow()
+ browserWindowMock.getAllWindows.mockReturnValue([window])
+ const dismissed = createHarness()
+
+ dismissed.service.registerIpcHandlers()
+ getIpcHandler('star-nag:forceShow')()
+ getIpcHandler('star-nag:dismiss')()
+
+ expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
+ outcome: 'dismissed',
+ source: 'force_show',
+ mode: 'gh',
+ threshold: STAR_NAG_INITIAL_THRESHOLD,
+ agents_since_baseline: 35,
+ agents_since_baseline_bucket: '35-69',
+ nth_repo_added: 3,
+ next_threshold: STAR_NAG_INITIAL_THRESHOLD * 2
+ })
+
+ trackMock.mockClear()
+ ipcMainHandleMock.mockClear()
+ const disabled = createHarness()
+ disabled.service.registerIpcHandlers()
+ getIpcHandler('star-nag:forceShow')()
+ getIpcHandler('star-nag:disable')()
+
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'disabled', mode: 'gh' })
+ )
+
+ trackMock.mockClear()
+ ipcMainHandleMock.mockClear()
+ const opened = createHarness()
+ opened.service.registerIpcHandlers()
+ getIpcHandler('star-nag:forceShow')()
+ getIpcHandler('star-nag:openWeb')()
+
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'opened_web', mode: 'web' })
+ )
+ })
+
+ it('emits direct-star attempted and succeeded outcomes plus app_starred_orca', async () => {
+ const window = createWindow()
+ browserWindowMock.getAllWindows.mockReturnValue([window])
+ const { service, ui } = createHarness()
+
+ service.registerIpcHandlers()
+ getIpcHandler('star-nag:forceShow')()
+ const ok = await getIpcHandler('star-nag:starOrca')()
+
+ expect(ok).toBe(true)
+ expect(ui.starNagCompleted).toBe(true)
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'star_attempted', mode: 'gh' })
+ )
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' })
+ )
+ expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
+ source: 'star_nag',
+ nth_repo_added: 3
+ })
+ })
+
+ it('uses fresh cohort context for canonical app_starred_orca success telemetry', async () => {
+ const window = createWindow()
+ browserWindowMock.getAllWindows.mockReturnValue([window])
+ getCohortAtEmitMock
+ .mockReturnValueOnce({ nth_repo_added: 2 })
+ .mockReturnValueOnce({ nth_repo_added: 4 })
+ const { service } = createHarness()
+
+ service.registerIpcHandlers()
+ getIpcHandler('star-nag:forceShow')()
+ await getIpcHandler('star-nag:starOrca')()
+
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'shown', nth_repo_added: 2 })
+ )
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'star_succeeded', nth_repo_added: 2 })
+ )
+ expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
+ source: 'star_nag',
+ nth_repo_added: 4
+ })
+ })
+
+ it('records success and completion when direct star resolves after dismissal cleared the visible session', async () => {
+ const window = createWindow()
+ browserWindowMock.getAllWindows.mockReturnValue([window])
+ const deferredStar = createDeferred()
+ starOrcaMock.mockReturnValue(deferredStar.promise)
+ const { service, ui } = createHarness()
+
+ service.registerIpcHandlers()
+ getIpcHandler('star-nag:forceShow')()
+ const starPromise = getIpcHandler('star-nag:starOrca')()
+ getIpcHandler('star-nag:dismiss')()
+
+ deferredStar.resolve(true)
+ await expect(starPromise).resolves.toBe(true)
+
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' })
+ )
+ expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
+ source: 'star_nag',
+ nth_repo_added: 3
+ })
+ expect(ui.starNagCompleted).toBe(true)
+ })
+
+ it('clears the in-flight direct-star guard after thrown attempts so the user can retry', async () => {
+ const window = createWindow()
+ browserWindowMock.getAllWindows.mockReturnValue([window])
+ starOrcaMock.mockRejectedValueOnce(new Error('gh failed')).mockResolvedValueOnce(true)
+ const { service, ui } = createHarness()
+
+ service.registerIpcHandlers()
+ getIpcHandler('star-nag:forceShow')()
+ const starFromNag = getIpcHandler('star-nag:starOrca')
+
+ await expect(starFromNag()).rejects.toThrow('gh failed')
+ await expect(starFromNag()).resolves.toBe(true)
+
+ expect(starOrcaMock).toHaveBeenCalledTimes(2)
+ expect(ui.starNagCompleted).toBe(true)
+ })
+
+ it('records failed direct star before web fallback and guards duplicate in-flight attempts', async () => {
+ const window = createWindow()
+ browserWindowMock.getAllWindows.mockReturnValue([window])
+ const deferredStar = createDeferred()
+ starOrcaMock.mockReturnValue(deferredStar.promise)
+ const { service, ui } = createHarness()
+
+ service.registerIpcHandlers()
+ getIpcHandler('star-nag:forceShow')()
+ const starFromNag = getIpcHandler('star-nag:starOrca')
+ const first = starFromNag()
+ const second = starFromNag()
+
+ deferredStar.resolve(false)
+ await expect(first).resolves.toBe(false)
+ await expect(second).resolves.toBe(false)
+
+ const starAttempts = trackMock.mock.calls.filter(
+ ([name, payload]) =>
+ name === 'star_nag_outcome' &&
+ (payload as { outcome?: string }).outcome === 'star_attempted'
+ )
+ expect(starAttempts).toHaveLength(1)
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'star_failed', mode: 'gh' })
+ )
+
+ getIpcHandler('star-nag:openWeb')()
+
+ expect(trackMock).toHaveBeenCalledWith(
+ 'star_nag_outcome',
+ expect.objectContaining({ outcome: 'opened_web', mode: 'web' })
+ )
+ expect(ui.starNagCompleted).toBe(true)
+ })
})
diff --git a/src/main/star-nag/service.ts b/src/main/star-nag/service.ts
index 76afffd8d83..fd8b211a1a0 100644
--- a/src/main/star-nag/service.ts
+++ b/src/main/star-nag/service.ts
@@ -1,14 +1,22 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
-import { checkOrcaStarred } from '../github/client'
+import { checkOrcaStarred, starOrca } from '../github/client'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
+import { track } from '../telemetry/client'
+import { getCohortAtEmit } from '../telemetry/cohort-classifier'
+import {
+ bucketStarNagAgentsSinceBaseline,
+ type StarNagOutcome,
+ type StarNagPromptMode,
+ type StarNagPromptSource
+} from '../../shared/star-nag-telemetry'
+import type { EventProps } from '../../shared/telemetry-events'
-type StarNagPromptSource = 'threshold' | 'force_show'
-type StarNagPromptMode = 'gh' | 'web'
+type StarNagPromptContext = Omit, 'outcome' | 'next_threshold'>
-type StarNagPromptSession = {
- source: StarNagPromptSource
+type StarNagPromptSession = StarNagPromptContext & {
+ starAttemptPromise?: Promise
}
/**
@@ -36,8 +44,9 @@ export class StarNagService {
// resolving.
private evaluating = false
private pendingForceShow = false
- // Why: dismissal backoff should only apply to a prompt that was actually
- // delivered, and the dismissal payload needs the delivered prompt source.
+ // Why: dismissal backoff and action telemetry must use the prompt context
+ // that was delivered, not whatever threshold/source happens to be current
+ // when the renderer later reports a user action.
private promptSession: StarNagPromptSession | null = null
constructor(store: Store, stats: StatsCollector) {
@@ -65,7 +74,9 @@ export class StarNagService {
registerIpcHandlers(): void {
ipcMain.handle('star-nag:dismiss', () => this.dismiss())
ipcMain.handle('star-nag:complete', () => this.markCompleted())
- ipcMain.handle('star-nag:disable', () => this.markCompleted())
+ ipcMain.handle('star-nag:disable', () => this.disable())
+ ipcMain.handle('star-nag:openWeb', () => this.openWeb())
+ ipcMain.handle('star-nag:starOrca', () => this.starOrcaFromNag())
ipcMain.handle('star-nag:forceShow', () => this.forceShow())
}
@@ -134,6 +145,7 @@ export class StarNagService {
return
}
if (starred) {
+ this.trackAlreadyStarredSuppressed(source)
// Already starred somewhere — lock in the permanent suppression so we
// stop recomputing thresholds on every spawn.
this.markCompleted()
@@ -167,13 +179,67 @@ export class StarNagService {
this.promptSession = null
return false
}
+ const context = this.createPromptContext(source, mode)
win.webContents.send('star-nag:show', { mode })
this.promptVisible = true
- this.promptSession = { source }
+ this.promptSession = context
+ this.trackOutcome('shown')
this.logConsoleEvent('star_nag_shown', source)
return true
}
+ private createPromptContext(
+ source: StarNagPromptSource,
+ mode: StarNagPromptMode
+ ): StarNagPromptContext {
+ const ui = this.store.getUI()
+ const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
+ const agentsSinceBaseline = Math.max(
+ 0,
+ this.stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
+ )
+ return {
+ source,
+ mode,
+ threshold,
+ agents_since_baseline: agentsSinceBaseline,
+ agents_since_baseline_bucket: bucketStarNagAgentsSinceBaseline(agentsSinceBaseline),
+ ...getCohortAtEmit()
+ }
+ }
+
+ private trackOutcome(
+ outcome: StarNagOutcome,
+ options: { mode?: StarNagPromptMode; nextThreshold?: number } = {}
+ ): void {
+ const session = this.promptSession
+ if (!session) {
+ return
+ }
+ this.trackSessionOutcome(session, outcome, options)
+ }
+
+ private trackSessionOutcome(
+ session: StarNagPromptSession,
+ outcome: StarNagOutcome,
+ options: { mode?: StarNagPromptMode; nextThreshold?: number } = {}
+ ): void {
+ const { starAttemptPromise: _starAttemptPromise, ...context } = session
+ track('star_nag_outcome', {
+ ...context,
+ outcome,
+ ...(options.mode === undefined ? {} : { mode: options.mode }),
+ ...(options.nextThreshold === undefined ? {} : { next_threshold: options.nextThreshold })
+ })
+ }
+
+ private trackAlreadyStarredSuppressed(source: StarNagPromptSource): void {
+ track('star_nag_outcome', {
+ ...this.createPromptContext(source, 'gh'),
+ outcome: 'already_starred_suppressed'
+ })
+ }
+
private logConsoleEvent(
event: 'star_nag_shown' | 'star_nag_dismissed',
source: StarNagPromptSource,
@@ -211,6 +277,7 @@ export class StarNagService {
const ui = this.store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const nextThreshold = threshold * 2
+ this.trackOutcome('dismissed', { nextThreshold })
this.logConsoleEvent('star_nag_dismissed', session.source, nextThreshold)
this.store.updateUI({
starNagNextThreshold: nextThreshold,
@@ -220,6 +287,56 @@ export class StarNagService {
this.promptSession = null
}
+ private disable(): void {
+ this.trackOutcome('disabled')
+ this.markCompleted()
+ }
+
+ private openWeb(): void {
+ this.trackOutcome('opened_web', { mode: 'web' })
+ this.markCompleted()
+ }
+
+ private async starOrcaFromNag(): Promise {
+ const session = this.promptSession
+ if (!session) {
+ return false
+ }
+ if (session.starAttemptPromise) {
+ return session.starAttemptPromise
+ }
+ const attempt = this.runStarOrcaAttempt(session)
+ session.starAttemptPromise = attempt
+ try {
+ return await attempt
+ } finally {
+ if (this.promptSession === session) {
+ delete session.starAttemptPromise
+ }
+ }
+ }
+
+ private async runStarOrcaAttempt(session: StarNagPromptSession): Promise {
+ this.trackSessionOutcome(session, 'star_attempted', { mode: 'gh' })
+ const starred = await starOrca()
+ if (!starred) {
+ if (this.promptSession === session) {
+ this.trackSessionOutcome(session, 'star_failed', { mode: 'gh' })
+ session.mode = 'web'
+ }
+ return false
+ }
+ this.trackSessionOutcome(session, 'star_succeeded', { mode: 'gh' })
+ // Why: app_starred_orca remains the canonical cross-surface success event;
+ // star_nag_outcome is only the nag-funnel companion.
+ track('app_starred_orca', {
+ source: 'star_nag',
+ ...getCohortAtEmit()
+ })
+ this.markCompleted()
+ return true
+ }
+
/** User successfully starred or opted out → never nag again. */
private markCompleted(): void {
this.store.updateUI({ starNagCompleted: true })
diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts
index bb93bdad1b1..573b6907b32 100644
--- a/src/main/telemetry/validator.test.ts
+++ b/src/main/telemetry/validator.test.ts
@@ -41,6 +41,43 @@ describe('validate', () => {
expect(result.ok).toBe(true)
})
+ it('accepts a well-formed star_nag_outcome payload with cohort context', () => {
+ const result = validate('star_nag_outcome', {
+ outcome: 'opened_web',
+ source: 'force_show',
+ mode: 'web',
+ threshold: 35,
+ agents_since_baseline: 42,
+ agents_since_baseline_bucket: '35-69',
+ nth_repo_added: 4
+ })
+ expect(result.ok).toBe(true)
+ })
+
+ it('rejects malformed star_nag_outcome payloads', () => {
+ expect(
+ validate('star_nag_outcome', {
+ outcome: 'opened_web',
+ source: 'force_show',
+ mode: 'web',
+ threshold: 35,
+ agents_since_baseline: 42,
+ agents_since_baseline_bucket: '35-69',
+ raw_error: 'nope'
+ } as never).ok
+ ).toBe(false)
+ expect(
+ validate('star_nag_outcome', {
+ outcome: 'opened_web',
+ source: 'force_show',
+ mode: 'web',
+ threshold: 0,
+ agents_since_baseline: 42,
+ agents_since_baseline_bucket: '35-69'
+ } as never).ok
+ ).toBe(false)
+ })
+
it('drops unknown event names', () => {
const result = validate('not_a_real_event' as never, {})
expect(result.ok).toBe(false)
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 4dc0465be9d..c0816720ce0 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -1627,6 +1627,8 @@ export type PreloadApi = {
dismiss: () => Promise
complete: () => Promise
disable: () => Promise
+ openWeb: () => Promise
+ starOrca: () => Promise
forceShow: () => Promise
}
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose —
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 239ce243715..3c181eed8d0 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1475,6 +1475,8 @@ const api = {
dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'),
complete: (): Promise => ipcRenderer.invoke('star-nag:complete'),
disable: (): Promise => ipcRenderer.invoke('star-nag:disable'),
+ openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'),
+ starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'),
forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow')
},
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index d4a2d1320a4..f797cf4fc64 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -61,6 +61,7 @@ import { WorkspacePortScanner } from './components/ports/WorkspacePortScanner'
import { CrashReportDialog } from './components/crash-report/CrashReportDialog'
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
+import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
import { useEditorExternalWatch } from './hooks/useEditorExternalWatch'
@@ -1773,7 +1774,8 @@ function App(): React.JSX.Element {
>
-
+
+
{/* Why: leaf-mounted retention sync keeps agent-status retention
subscriptions from re-rendering the App tree. */}
@@ -2287,6 +2289,7 @@ function App(): React.JSX.Element {
>
+
diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx
index 041ef30765f..2b12de6ee28 100644
--- a/src/renderer/src/components/GitHubItemDialog.tsx
+++ b/src/renderer/src/components/GitHubItemDialog.tsx
@@ -83,6 +83,11 @@ import {
import type { DiffSection } from '@/components/editor/diff-section-types'
import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model'
import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content'
+import {
+ REVIEW_ACTION_MERGE_BUTTON_CLASS,
+ REVIEW_ACTION_STATE_BUTTON_CLASS,
+ RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS
+} from '@/components/right-sidebar/right-sidebar-primary-action-layout'
import {
createGitHubChecksTabState,
resolveGitHubChecksTabState,
@@ -3241,7 +3246,7 @@ function PRActionsPanel({
-
+
@@ -3250,7 +3255,8 @@ function PRActionsPanel({
type="button"
size="sm"
className={cn(
- 'w-full justify-center gap-2 bg-green-600 text-white hover:bg-green-700',
+ REVIEW_ACTION_MERGE_BUTTON_CLASS,
+ 'gap-2 bg-green-600 text-white hover:bg-green-700',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
@@ -3259,11 +3265,13 @@ function PRActionsPanel({
) : (
)}
- {mergePresentation.autoMergeAction?.label ??
- (mergePresentation.directMergeAvailable
- ? mergeMethods.defaultLabel
- : mergePresentation.label)}
-
+
+ {mergePresentation.autoMergeAction?.label ??
+ (mergePresentation.directMergeAvailable
+ ? mergeMethods.defaultLabel
+ : mergePresentation.label)}
+
+
@@ -3309,7 +3317,8 @@ function PRActionsPanel({
variant={nextState === 'closed' ? 'outline' : 'secondary'}
size="sm"
className={cn(
- 'w-full justify-center gap-2',
+ REVIEW_ACTION_STATE_BUTTON_CLASS,
+ 'gap-2',
nextState === 'closed' &&
'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50'
)}
@@ -3323,9 +3332,11 @@ function PRActionsPanel({
) : (
)}
- {nextState === 'closed'
- ? translate('auto.components.GitHubItemDialog.21860b58d0', 'Close pull request')
- : translate('auto.components.GitHubItemDialog.ec5c4b3ab2', 'Reopen PR')}
+
+ {nextState === 'closed'
+ ? translate('auto.components.GitHubItemDialog.21860b58d0', 'Close pull request')
+ : translate('auto.components.GitHubItemDialog.ec5c4b3ab2', 'Reopen PR')}
+
@@ -4896,12 +4907,12 @@ function GHEditSection({
) : null}
{hasAttachedWorkspace ? (
-
+
-
+
@@ -3492,7 +3497,8 @@ function PRActionsPanel({
type="button"
size="sm"
className={cn(
- 'w-full justify-center gap-2 bg-green-600 text-white hover:bg-green-700',
+ REVIEW_ACTION_MERGE_BUTTON_CLASS,
+ 'gap-2 bg-green-600 text-white hover:bg-green-700',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
@@ -3501,11 +3507,13 @@ function PRActionsPanel({
) : (
)}
- {mergePresentation.autoMergeAction?.label ??
- (mergePresentation.directMergeAvailable
- ? mergeMethods.defaultLabel
- : mergePresentation.label)}
-
+
+ {mergePresentation.autoMergeAction?.label ??
+ (mergePresentation.directMergeAvailable
+ ? mergeMethods.defaultLabel
+ : mergePresentation.label)}
+
+
@@ -3551,7 +3559,8 @@ function PRActionsPanel({
variant={nextState === 'closed' ? 'outline' : 'secondary'}
size="sm"
className={cn(
- 'w-full justify-center gap-2',
+ REVIEW_ACTION_STATE_BUTTON_CLASS,
+ 'gap-2',
nextState === 'closed' &&
'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50'
)}
@@ -3565,9 +3574,11 @@ function PRActionsPanel({
) : (
)}
- {nextState === 'closed'
- ? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
- : translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
+
+ {nextState === 'closed'
+ ? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
+ : translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
+
diff --git a/src/renderer/src/components/StarNagCard.tsx b/src/renderer/src/components/StarNagCard.tsx
index 4f6a4f9eaf4..f127caf344d 100644
--- a/src/renderer/src/components/StarNagCard.tsx
+++ b/src/renderer/src/components/StarNagCard.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, Star, X } from 'lucide-react'
import { Card } from './ui/card'
import { Button } from './ui/button'
@@ -39,18 +39,24 @@ export function StarNagCard(): React.JSX.Element | null {
})
}, [])
- const handleClose = (): void => {
+ const handleClose = useCallback((): void => {
+ if (busy) {
+ return
+ }
setVisible(false)
// Why: fire-and-forget. If persisting the dismissal fails the worst case
// is we re-fire the same threshold on next launch — not worth blocking
// the close animation on.
void window.api.starNag.dismiss()
- }
+ }, [busy])
- const handleDisable = (): void => {
+ const handleDisable = useCallback((): void => {
+ if (busy) {
+ return
+ }
setVisible(false)
void window.api.starNag.disable()
- }
+ }, [busy])
useEffect(() => {
if (!visible) {
@@ -63,9 +69,7 @@ export function StarNagCard(): React.JSX.Element | null {
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
- // eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes
- // over stable refs; re-binding on each render is unnecessary.
- }, [visible])
+ }, [handleClose, visible])
if (!visible) {
return null
@@ -78,7 +82,7 @@ export function StarNagCard(): React.JSX.Element | null {
if (mode === 'web') {
setBusy(true)
await window.api.shell.openUrl(ORCA_STARGAZERS_URL)
- await window.api.starNag.disable()
+ await window.api.starNag.openWeb()
if (mountedRef.current) {
setBusy(false)
setVisible(false)
@@ -86,7 +90,7 @@ export function StarNagCard(): React.JSX.Element | null {
return
}
setBusy(true)
- const ok = await window.api.gh.starOrca('star_nag')
+ const ok = await window.api.starNag.starOrca()
if (mountedRef.current) {
setBusy(false)
}
@@ -96,7 +100,6 @@ export function StarNagCard(): React.JSX.Element | null {
}
return
}
- await window.api.starNag.complete()
if (mountedRef.current) {
setVisible(false)
}
@@ -126,6 +129,7 @@ export function StarNagCard(): React.JSX.Element | null {
size="icon"
className="size-7 shrink-0"
onClick={handleClose}
+ disabled={busy}
aria-label={translate('auto.components.StarNagCard.b5e685e4d9', 'Dismiss')}
>
@@ -156,10 +160,22 @@ export function StarNagCard(): React.JSX.Element | null {
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
-
+
{translate('auto.components.StarNagCard.8c967b4d15', 'Not now')}
-
+
{translate('auto.components.StarNagCard.73dfd4eb8d', "Don't ask again")}
diff --git a/src/renderer/src/components/feature-wall/review-animated-visual-ship-styles.tsx b/src/renderer/src/components/feature-wall/review-animated-visual-ship-styles.tsx
index 00ed0a14c33..8284f0091af 100644
--- a/src/renderer/src/components/feature-wall/review-animated-visual-ship-styles.tsx
+++ b/src/renderer/src/components/feature-wall/review-animated-visual-ship-styles.tsx
@@ -14,7 +14,7 @@ export function ReviewShipVisualStyles(): JSX.Element {
)
diff --git a/src/renderer/src/components/link-routing-preference-dialog.tsx b/src/renderer/src/components/link-routing-preference-dialog.tsx
new file mode 100644
index 00000000000..cdf07d62962
--- /dev/null
+++ b/src/renderer/src/components/link-routing-preference-dialog.tsx
@@ -0,0 +1,260 @@
+import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
+import { ExternalLink, Settings } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
+import { useAppStore } from '@/store'
+import { translate } from '@/i18n/i18n'
+
+type LinkRoutingPreferenceDialogOptions = {
+ url?: string
+ preview?: boolean
+ openLinksInAppDefault?: boolean
+}
+
+type LinkRoutingPreferenceDialogRequest = {
+ id: number
+ options: LinkRoutingPreferenceDialogOptions
+ resolve: (openInOrca: boolean) => void
+}
+
+type LinkRoutingPreferenceDialogContextValue = (
+ options?: LinkRoutingPreferenceDialogOptions
+) => Promise
+
+const PREVIEW_STORAGE_KEY = 'orca.previewLinkRoutingPreferenceDialog'
+const PREVIEW_DEFAULT_STORAGE_KEY = `${PREVIEW_STORAGE_KEY}.default`
+const LinkRoutingPreferenceDialogContext =
+ createContext(null)
+
+function displayHostForUrl(url: string | undefined): string | null {
+ if (!url) {
+ return null
+ }
+ try {
+ return new URL(url).host
+ } catch {
+ return null
+ }
+}
+
+export function LinkRoutingPreferenceDialogProvider({
+ children
+}: {
+ children: React.ReactNode
+}): React.JSX.Element {
+ const nextIdRef = useRef(0)
+ const [queue, setQueue] = useState([])
+ const activeRequest = queue[0] ?? null
+ const activeRequestRef = useRef(activeRequest)
+ const setContextualToursBlockingSurfaceVisible = useAppStore(
+ (s) => s.setContextualToursBlockingSurfaceVisible
+ )
+ const lastDisplayedRequestRef = useRef(activeRequest)
+ activeRequestRef.current = activeRequest
+ if (activeRequest) {
+ lastDisplayedRequestRef.current = activeRequest
+ }
+ // Why: Radix keeps dialog content mounted while closing; keep copy stable during exit animation.
+ const displayedRequest = activeRequest ?? lastDisplayedRequestRef.current
+ const displayHost = displayHostForUrl(displayedRequest?.options.url)
+ const openLinksInAppDefault = displayedRequest?.options.openLinksInAppDefault === true
+ const isMac = navigator.userAgent.includes('Mac')
+ const systemBrowserShortcutKeys = isMac ? ['⇧', '⌘'] : ['Shift', 'Ctrl']
+
+ useEffect(() => {
+ setContextualToursBlockingSurfaceVisible(activeRequest !== null)
+ return () => setContextualToursBlockingSurfaceVisible(false)
+ }, [activeRequest, setContextualToursBlockingSurfaceVisible])
+
+ const requestPreference = useCallback((options = {}) => {
+ return new Promise((resolve) => {
+ const request: LinkRoutingPreferenceDialogRequest = {
+ id: nextIdRef.current,
+ options,
+ resolve
+ }
+ nextIdRef.current += 1
+ setQueue((currentQueue) => [...currentQueue, request])
+ })
+ }, [])
+
+ useEffect(() => {
+ if (!import.meta.env.DEV || typeof window === 'undefined') {
+ return
+ }
+ if (window.sessionStorage.getItem(PREVIEW_STORAGE_KEY) !== '1') {
+ return
+ }
+ const previewDefault = window.sessionStorage.getItem(PREVIEW_DEFAULT_STORAGE_KEY)
+ window.sessionStorage.removeItem(PREVIEW_STORAGE_KEY)
+ window.sessionStorage.removeItem(PREVIEW_DEFAULT_STORAGE_KEY)
+ void requestPreference({
+ openLinksInAppDefault: previewDefault === 'orca',
+ preview: true,
+ url: 'https://github.com/stablyai/orca/pull/1234'
+ })
+ }, [requestPreference])
+
+ const settleActiveRequest = useCallback((openInOrca: boolean) => {
+ const request = activeRequestRef.current
+ if (!request) {
+ return
+ }
+ request.resolve(openInOrca)
+ setQueue((currentQueue) => {
+ if (currentQueue[0]?.id === request.id) {
+ return currentQueue.slice(1)
+ }
+ return currentQueue.filter((queuedRequest) => queuedRequest.id !== request.id)
+ })
+ }, [])
+
+ return (
+
+ {children}
+ !open && settleActiveRequest(false)}
+ >
+
+
+
+
+
+ {translate(
+ 'auto.components.link.routing.preference.dialog.badge',
+ 'Terminal link'
+ )}
+
+ {displayedRequest?.options.preview ? (
+
+ {translate('auto.components.link.routing.preference.dialog.preview', 'Preview')}
+
+ ) : null}
+
+
+
+ {openLinksInAppDefault
+ ? translate(
+ 'auto.components.link.routing.preference.dialog.keep.title',
+ "Keep terminal links in Orca's browser?"
+ )
+ : translate(
+ 'auto.components.link.routing.preference.dialog.title',
+ "Open terminal links in Orca's browser?"
+ )}
+
+
+ {openLinksInAppDefault
+ ? translate(
+ 'auto.components.link.routing.preference.dialog.keep.description',
+ 'Or use your system browser by default.'
+ )
+ : translate(
+ 'auto.components.link.routing.preference.dialog.description',
+ "Use Orca's browser for terminal links, or keep your system browser."
+ )}
+
+
+
+
+
+
+ {displayHost ? (
+
+
+ {translate('auto.components.link.routing.preference.dialog.link.label', 'Link')}
+
+
+ {displayHost}
+
+
+ ) : null}
+
+
+
+
+
+ {translate(
+ 'auto.components.link.routing.preference.dialog.orca.note',
+ 'Orca can use imported cookies for logged-in sites.'
+ )}
+
+
+ {translate(
+ 'auto.components.link.routing.preference.dialog.settings.note',
+ 'Change this later in Settings → Browser.'
+ )}
+
+
+
+ {translate(
+ 'auto.components.link.routing.preference.dialog.shortcut.note.prefix',
+ 'When links open in Orca,'
+ )}
+
+
+
+ {translate(
+ 'auto.components.link.routing.preference.dialog.shortcut.note.suffix',
+ 'click opens system browser once.'
+ )}
+
+
+
+
+
+
+
+ settleActiveRequest(false)}>
+
+ {translate(
+ 'auto.components.link.routing.preference.dialog.system.button',
+ 'Use system browser'
+ )}
+
+ settleActiveRequest(true)}>
+ {openLinksInAppDefault
+ ? translate(
+ 'auto.components.link.routing.preference.dialog.keep.orca.button',
+ 'Keep Orca'
+ )
+ : translate(
+ 'auto.components.link.routing.preference.dialog.orca.button',
+ 'Open in Orca'
+ )}
+
+
+
+
+
+ )
+}
+
+export function useLinkRoutingPreferenceDialog(): LinkRoutingPreferenceDialogContextValue {
+ const requestPreference = useContext(LinkRoutingPreferenceDialogContext)
+ if (!requestPreference) {
+ throw new Error(
+ 'useLinkRoutingPreferenceDialog must be used inside LinkRoutingPreferenceDialogProvider'
+ )
+ }
+ return requestPreference
+}
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
index 38c89f37d40..ce6d0acea24 100644
--- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
@@ -89,6 +89,10 @@ import {
restorePRCommentThreadSnapshot
} from './pr-comment-thread-resolution'
import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller'
+import {
+ CHECKS_PANEL_BASE_POLL_INTERVAL_MS,
+ nextChecksPanelPollInterval
+} from './checks-panel-polling'
import {
getChecksPanelEmptyStateCopy,
shouldShowChecksPanelPublishBranchAction
@@ -449,7 +453,7 @@ export default function ChecksPanel(): React.JSX.Element {
const [titleSaving, setTitleSaving] = useState(false)
const titleInputRef = useRef(null)
const titleInputFocusTimerRef = useRef | null>(null)
- const pollIntervalRef = useRef(30_000) // start at 30s, backs off to 120s
+ const pollIntervalRef = useRef(CHECKS_PANEL_BASE_POLL_INTERVAL_MS)
const mountedRef = useMountedRef()
const confirm = useConfirmationDialog()
const prevChecksRef = useRef('')
@@ -584,7 +588,7 @@ export default function ChecksPanel(): React.JSX.Element {
setHostedReviewCreationSnapshot(null)
setGitStatusSnapshot(null)
setGitStatusRefreshNonce((value) => value + 1)
- pollIntervalRef.current = 30_000
+ pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
prevChecksRef.current = ''
conflictSummaryRefreshKeyRef.current = null
refreshRequestKeyRef.current = null
@@ -1362,14 +1366,13 @@ export default function ChecksPanel(): React.JSX.Element {
}
setChecks(result)
- // Exponential backoff: if checks haven't changed, double the interval (cap 120s).
- // If they changed, reset to 30s.
- const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
- pollIntervalRef.current =
- signature === prevChecksRef.current
- ? Math.min(pollIntervalRef.current * 2, 120_000)
- : 30_000
- prevChecksRef.current = signature
+ const poll = nextChecksPanelPollInterval({
+ checks: result,
+ previousSignature: prevChecksRef.current,
+ currentIntervalMs: pollIntervalRef.current
+ })
+ pollIntervalRef.current = poll.intervalMs
+ prevChecksRef.current = poll.signature
} catch (err) {
if (
!isCurrentAsyncResult(
@@ -1442,12 +1445,13 @@ export default function ChecksPanel(): React.JSX.Element {
const result = gitLabPipelineJobsToPRChecks(details?.pipelineJobs ?? [])
setChecks(result)
setComments(gitLabMRCommentsToPRComments(details?.comments))
- const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
- pollIntervalRef.current =
- signature === prevChecksRef.current
- ? Math.min(pollIntervalRef.current * 2, 120_000)
- : 30_000
- prevChecksRef.current = signature
+ const poll = nextChecksPanelPollInterval({
+ checks: result,
+ previousSignature: prevChecksRef.current,
+ currentIntervalMs: pollIntervalRef.current
+ })
+ pollIntervalRef.current = poll.intervalMs
+ prevChecksRef.current = poll.signature
} catch (err) {
if (!isCurrentAsyncResult(requestKey)) {
return
@@ -1484,7 +1488,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
// Reset backoff state on PR change
- pollIntervalRef.current = 30_000
+ pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
prevChecksRef.current = ''
// Why: PR check status is user-visible when the panel is open. Keep visible
// unfocused windows fresh, but stop timers and API work while hidden.
@@ -1499,7 +1503,7 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
- pollIntervalRef.current = 30_000
+ pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
prevChecksRef.current = ''
return installWindowVisibilityTimeoutPoller({
run: () => fetchGitLabDetails(),
@@ -1751,14 +1755,13 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
setChecks(result)
- const signature = JSON.stringify(
- result.map((c) => `${c.name}:${c.status}:${c.conclusion}`)
- )
- pollIntervalRef.current =
- signature === prevChecksRef.current
- ? Math.min(pollIntervalRef.current * 2, 120_000)
- : 30_000
- prevChecksRef.current = signature
+ const poll = nextChecksPanelPollInterval({
+ checks: result,
+ previousSignature: prevChecksRef.current,
+ currentIntervalMs: pollIntervalRef.current
+ })
+ pollIntervalRef.current = poll.intervalMs
+ prevChecksRef.current = poll.signature
},
(err) => {
if (!isCurrentRequest() || !isCurrentAsyncResult(prRequestKey)) {
@@ -1836,9 +1839,8 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
// Why: entering the Checks tab is automatic UI behavior, not an explicit
- // user refresh. Route PR refresh through the coordinator so rate-limit
- // guards still apply; only force detail panes that the entry freshness rule
- // already proved stale, so tab entry stays fresh without broad fan-out.
+ // user refresh. Keep check loads cacheable so the GitHub CLI cache can
+ // absorb visible polling; only the manual refresh button bypasses it.
if (isGitLabReviewContext) {
void fetchHostedReviewForBranch(repo.path, branch, {
force: true,
@@ -1854,7 +1856,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
enqueueGitHubPRRefresh(activeWorktreeId, 'active', 80)
if (options.refreshChecks) {
- void fetchChecks({ force: true })
+ void fetchChecks()
}
if (options.refreshComments) {
void fetchComments({ force: true })
@@ -1918,9 +1920,9 @@ export default function ChecksPanel(): React.JSX.Element {
const refreshComments =
prNumber !== null && (commentsFetchedAt === undefined || commentsFetchedAt < cutoff)
- // Reset polling attention state so the forced fetch's signature establishes
- // a fresh baseline rather than colliding with the previous PR's backoff.
- pollIntervalRef.current = 30_000
+ // Reset polling attention state so this entry refresh establishes a fresh
+ // baseline rather than colliding with the previous PR's backoff.
+ pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
prevChecksRef.current = ''
handleEntryRefresh({ refreshChecks, refreshComments })
}, [entryKey, prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, handleEntryRefresh])
@@ -3300,7 +3302,7 @@ export default function ChecksPanel(): React.JSX.Element {
>
)}
{/* Why: when the hosted review has merge conflicts and no checks have been fetched,
- showing "No checks configured" is misleading — checks may exist but
+ showing an empty checks state is misleading — checks may exist but
simply cannot run until conflicts are resolved. Hide the empty state. */}
{!(activeConflictReview && checks.length === 0 && !checksLoading) && (
-
+
onPrimaryAction()}
- className={cn('h-7 flex-1 px-3 text-xs', showDropdown && 'rounded-r-none')}
+ className={cn(
+ 'h-7 px-3 text-xs',
+ showDropdown && 'rounded-r-none',
+ RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS
+ )}
title={createDisabledReason ?? primaryAction.title}
>
{isCreating ? (
@@ -259,12 +268,14 @@ export function CreateHostedReviewComposer({
) : (
)}
- {getCreateButtonLabel({
- isCreating,
- pushBeforeCreate,
- draft,
- shortLabel: copy.shortLabel
- })}
+
+ {getCreateButtonLabel({
+ isCreating,
+ pushBeforeCreate,
+ draft,
+ shortLabel: copy.shortLabel
+ })}
+
{showDropdown ? (
diff --git a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx
index b82419fb61b..fd438a3ba85 100644
--- a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx
+++ b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx
@@ -22,6 +22,11 @@ import {
MergedReviewActions
} from './HostedReviewStateActions'
import { useHostedReviewActions, type HostedReviewActionInfo } from './use-hosted-review-actions'
+import {
+ RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS,
+ RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS,
+ RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS
+} from './right-sidebar-primary-action-layout'
import { translate } from '@/i18n/i18n'
export default function HostedReviewActions({
@@ -100,17 +105,23 @@ export default function HostedReviewActions({
return (
-
+
{/* Why: wrapping in a so the tooltip trigger receives pointer
events even when the merge button inside is disabled. */}
-
+
)}
- {merging
- ? translate(
- 'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d',
- 'Working...'
- )
- : mergePresentation.directMergeAvailable
- ? mergeMethods.defaultLabel
- : (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)}
+
+ {merging
+ ? translate(
+ 'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d',
+ 'Working...'
+ )
+ : mergePresentation.directMergeAvailable
+ ? mergeMethods.defaultLabel
+ : (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)}
+
diff --git a/src/renderer/src/components/right-sidebar/HostedReviewStateActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewStateActions.tsx
index 5ac25e3d4bc..b1ad87dd1a5 100644
--- a/src/renderer/src/components/right-sidebar/HostedReviewStateActions.tsx
+++ b/src/renderer/src/components/right-sidebar/HostedReviewStateActions.tsx
@@ -22,12 +22,12 @@ export function ClosedReviewActions({
onReopenReview: () => void
}): React.JSX.Element {
return (
-
+
@@ -64,7 +64,7 @@ export function MergedReviewActions({
type="button"
variant="secondary"
size="xs"
- className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
+ className="cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={onDeleteWorktree}
disabled={isDeletingWorktree}
>
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts
new file mode 100644
index 00000000000..7581d393ade
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/SourceControl.commit-generation-records.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from 'vitest'
+import { create } from 'zustand'
+import {
+ createCommitMessageGenerationSlice,
+ createRunningCommitMessageGenerationRecord,
+ getCommitMessageGenerationRecordKey,
+ markCommitMessageGenerationHydrated,
+ resolveCommitMessageGenerationCancel,
+ resolveCommitMessageGenerationFailure,
+ resolveCommitMessageGenerationSuccess,
+ type CommitMessageGenerationRecord,
+ type CommitMessageGenerationSlice
+} from '@/store/slices/commit-message-generation'
+
+function runningRecord(overrides: Partial = {}) {
+ return {
+ context: {
+ worktreeId: 'wt-a',
+ worktreePath: '/repo/a',
+ connectionId: 'conn-a',
+ requestId: 3
+ },
+ status: 'running' as const,
+ message: null,
+ error: null,
+ hydrated: false,
+ ...overrides
+ }
+}
+
+function createCommitMessageGenerationTestStore() {
+ return create()((...args) =>
+ createCommitMessageGenerationSlice(
+ ...(args as unknown as Parameters)
+ )
+ )
+}
+
+describe('SourceControl commit message generation records', () => {
+ it('keys commit-message generation by worktree id and falls back to path', () => {
+ expect(getCommitMessageGenerationRecordKey('wt-a', '/repo/a')).toBe('wt-a')
+ expect(getCommitMessageGenerationRecordKey(null, '/repo/a')).toBe('/repo/a')
+ expect(getCommitMessageGenerationRecordKey(null, '')).toBeNull()
+ })
+
+ it('applies generated messages only to the original running request', () => {
+ expect(
+ resolveCommitMessageGenerationSuccess({
+ record: runningRecord(),
+ requestId: 3,
+ message: 'feat: generated'
+ })
+ ).toMatchObject({
+ status: 'succeeded',
+ message: 'feat: generated',
+ hydrated: false
+ })
+
+ expect(
+ resolveCommitMessageGenerationSuccess({
+ record: runningRecord(),
+ requestId: 4,
+ message: 'feat: stale'
+ })
+ ).toBeNull()
+
+ expect(
+ resolveCommitMessageGenerationSuccess({
+ record: runningRecord({ status: 'canceled' }),
+ requestId: 3,
+ message: 'feat: stale'
+ })
+ ).toBeNull()
+ })
+
+ it('preserves cancellation over later generator resolution', () => {
+ const canceled = resolveCommitMessageGenerationCancel(runningRecord())
+
+ expect(canceled).toMatchObject({
+ status: 'canceled',
+ error: null
+ })
+ expect(
+ resolveCommitMessageGenerationFailure({
+ record: canceled,
+ requestId: 3,
+ canceled: true,
+ error: null
+ })
+ ).toBeNull()
+ })
+
+ it('marks completed messages as hydrated once the UI consumes them', () => {
+ const hydrated = markCommitMessageGenerationHydrated(
+ runningRecord({
+ status: 'succeeded',
+ message: 'docs: generated'
+ })
+ )
+
+ expect(hydrated).toMatchObject({
+ status: 'succeeded',
+ message: 'docs: generated',
+ hydrated: true
+ })
+ })
+
+ it('stores running records in the generation slice', () => {
+ const store = createCommitMessageGenerationTestStore()
+ const key = getCommitMessageGenerationRecordKey('wt-a', '/repo/a')!
+ const requestId = store.getState().allocateCommitMessageGenerationRequestId()
+
+ store.getState().setCommitMessageGenerationRecord(
+ key,
+ createRunningCommitMessageGenerationRecord({
+ worktreeId: 'wt-a',
+ worktreePath: '/repo/a',
+ connectionId: 'conn-a',
+ requestId,
+ runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
+ })
+ )
+
+ expect(store.getState().commitMessageGenerationRecords[key]).toMatchObject({
+ context: {
+ requestId,
+ worktreeId: 'wt-a',
+ worktreePath: '/repo/a',
+ runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
+ },
+ status: 'running'
+ })
+ })
+
+ it('prunes commit generation records for removed worktrees', () => {
+ const store = createCommitMessageGenerationTestStore()
+ store.getState().setCommitMessageGenerationRecord(
+ 'wt-a',
+ createRunningCommitMessageGenerationRecord({
+ worktreeId: 'wt-a',
+ worktreePath: '/repo/a',
+ requestId: 1
+ })
+ )
+ store.getState().setCommitMessageGenerationRecord(
+ 'wt-b',
+ createRunningCommitMessageGenerationRecord({
+ worktreeId: 'wt-b',
+ worktreePath: '/repo/b',
+ requestId: 2
+ })
+ )
+
+ store.getState().pruneCommitMessageGenerationRecords(new Set(['wt-a']))
+
+ expect(Object.keys(store.getState().commitMessageGenerationRecords)).toEqual(['wt-a'])
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts
index fe1916328a1..aabaf5e5e8b 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts
+++ b/src/renderer/src/components/right-sidebar/SourceControl.pr-generation-records.test.ts
@@ -186,7 +186,8 @@ describe('SourceControl pull request generation records', () => {
connectionId: 'conn-a',
requestId: 1,
repoId: 'repo-1',
- branch: 'feature-a'
+ branch: 'feature-a',
+ runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
},
seed,
fieldRevisions
@@ -208,12 +209,61 @@ describe('SourceControl pull request generation records', () => {
)
expect(store.getState().pullRequestGenerationRecords[key!]).toMatchObject({
+ context: { runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } },
status: 'succeeded',
result: generated,
hydrated: false
})
})
+ it('prunes PR generation records for removed worktrees', () => {
+ const store = createPullRequestGenerationTestStore()
+ const keyA = getPullRequestGenerationRecordKey({
+ worktreeId: 'wt-a',
+ worktreePath: '/repo/a',
+ repoId: 'repo-1',
+ branch: 'feature-a'
+ })!
+ const keyB = getPullRequestGenerationRecordKey({
+ worktreeId: 'wt-b',
+ worktreePath: '/repo/b',
+ repoId: 'repo-1',
+ branch: 'feature-b'
+ })!
+ store.getState().setPullRequestGenerationRecord(
+ keyA,
+ createRunningPullRequestGenerationRecord(
+ {
+ worktreeId: 'wt-a',
+ worktreePath: '/repo/a',
+ requestId: 1,
+ repoId: 'repo-1',
+ branch: 'feature-a'
+ },
+ seed,
+ fieldRevisions
+ )
+ )
+ store.getState().setPullRequestGenerationRecord(
+ keyB,
+ createRunningPullRequestGenerationRecord(
+ {
+ worktreeId: 'wt-b',
+ worktreePath: '/repo/b',
+ requestId: 2,
+ repoId: 'repo-1',
+ branch: 'feature-b'
+ },
+ seed,
+ fieldRevisions
+ )
+ )
+
+ store.getState().prunePullRequestGenerationRecords(new Set(['wt-a']))
+
+ expect(Object.keys(store.getState().pullRequestGenerationRecords)).toEqual([keyA])
+ })
+
it('does not reuse PR generation request ids across composer remounts', () => {
const store = createPullRequestGenerationTestStore()
const key = getPullRequestGenerationRecordKey({
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index 83be74d11ca..25ef6c1468c 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -145,6 +145,11 @@ import {
import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
import { PullRequestIcon } from './checks-panel-content'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
+import {
+ RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS,
+ RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS,
+ RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS
+} from './right-sidebar-primary-action-layout'
import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel'
import type { GitHistoryItem } from '../../../../shared/git-history'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
@@ -155,6 +160,8 @@ import type {
GitBranchCompareSummary,
GitConflictOperation,
GitStatusEntry,
+ GlobalSettings,
+ Repo,
SourceControlViewMode,
TuiAgent
} from '../../../../shared/types'
@@ -207,6 +214,14 @@ import {
type PullRequestGenerationContext,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
+import {
+ createRunningCommitMessageGenerationRecord,
+ getCommitMessageGenerationRecordKey,
+ markCommitMessageGenerationHydrated,
+ resolveCommitMessageGenerationCancel,
+ resolveCommitMessageGenerationFailure,
+ resolveCommitMessageGenerationSuccess
+} from '@/store/slices/commit-message-generation'
export {
appendCommitFailureCustomInstruction,
@@ -223,6 +238,30 @@ export {
export type SourceControlScope = 'all' | 'uncommitted'
type AbortConflictOperation = Extract
type AbortActionErrorKind = 'abort_merge' | 'abort_rebase'
+type CommitMessageGenerationTargetSnapshot = {
+ worktreeId: string
+ worktreePath: string
+ connectionId?: string
+ repo: Pick | null
+ settings: GlobalSettings | null
+ discoveryHostKey: string
+}
+type CommitMessageGenerationOptions = RuntimeGenerateCommitMessageOverrides & {
+ target?: CommitMessageGenerationTargetSnapshot
+}
+type PullRequestGenerationTargetSnapshot = {
+ worktreeId: string | null
+ worktreePath: string
+ connectionId?: string
+ repo: Pick | null
+ repoId: string
+ branch: string
+ settings: GlobalSettings | null
+ discoveryHostKey: string
+ fields: PullRequestGenerationFields
+ fieldRevisions: PullRequestFieldRevisions
+}
+
export type SourceControlActionError = {
kind: RemoteOpKind | AbortActionErrorKind
message: string
@@ -844,15 +883,6 @@ function SourceControlInner(): React.JSX.Element {
const isAbortingOperation = abortOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const confirmAction = useConfirmationDialog()
const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false
- // Why: parallel state to commit. Same per-worktree shape so navigating between
- // worktrees mid-generation never silently cancels the in-flight request.
- const generateInFlightRef = useRef>({})
- const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState<
- Record
- >({})
- const [generateErrors, setGenerateErrors] = useState>({})
- const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false
- const generateError = generateErrors[activeWorktreeId ?? ''] ?? null
const [hostedReviewCreationState, setHostedReviewCreationState] =
useState(null)
const createPrInFlightRef = useRef>({})
@@ -862,12 +892,24 @@ function SourceControlInner(): React.JSX.Element {
const [createPrErrors, setCreatePrErrors] = useState>({})
const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const createPrError = createPrErrors[activeWorktreeId ?? ''] ?? null
+ const commitGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords)
+ const allocateCommitMessageGenerationRequestId = useAppStore(
+ (s) => s.allocateCommitMessageGenerationRequestId
+ )
+ const setCommitMessageGenerationRecord = useAppStore((s) => s.setCommitMessageGenerationRecord)
+ const updateCommitMessageGenerationRecord = useAppStore(
+ (s) => s.updateCommitMessageGenerationRecord
+ )
+ const pruneCommitMessageGenerationRecords = useAppStore(
+ (s) => s.pruneCommitMessageGenerationRecords
+ )
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const allocatePullRequestGenerationRequestId = useAppStore(
(s) => s.allocatePullRequestGenerationRequestId
)
const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord)
const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord)
+ const prunePullRequestGenerationRecords = useAppStore((s) => s.prunePullRequestGenerationRecords)
const filterInputRef = useRef(null)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
const commitError = commitErrors[activeWorktreeId ?? ''] ?? null
@@ -894,6 +936,16 @@ function SourceControlInner(): React.JSX.Element {
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
+ const activeCommitGenerationKey = getCommitMessageGenerationRecordKey(
+ activeWorktreeId,
+ worktreePath
+ )
+ const activeCommitGenerationRecord = activeCommitGenerationKey
+ ? (commitGenerationRecords[activeCommitGenerationKey] ?? null)
+ : null
+ const isGenerating = activeCommitGenerationRecord?.status === 'running'
+ const generateError =
+ activeCommitGenerationRecord?.status === 'failed' ? activeCommitGenerationRecord.error : null
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
worktreeId: activeWorktreeId,
worktreePath,
@@ -1287,17 +1339,70 @@ function SourceControlInner(): React.JSX.Element {
openSettingsPage
})
+ const [commitGenerationDialogTarget, setCommitGenerationDialogTarget] =
+ useState(null)
+ const [pullRequestGenerationDialogTarget, setPullRequestGenerationDialogTarget] =
+ useState(null)
+
+ const createCommitMessageGenerationTarget = useCallback(() => {
+ if (!activeWorktreeId || !worktreePath) {
+ return null
+ }
+ return {
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId: activeConnectionId ?? undefined,
+ repo: activeRepo ?? null,
+ settings,
+ discoveryHostKey: sourceControlAiDiscoveryHostKey
+ }
+ }, [
+ activeConnectionId,
+ activeRepo,
+ activeWorktreeId,
+ settings,
+ sourceControlAiDiscoveryHostKey,
+ worktreePath
+ ])
+
+ const handleCommitGenerationDialogOpenChange = useCallback(
+ (open: boolean): void => {
+ setCommitGenerationDialogOpen(open)
+ if (!open) {
+ setCommitGenerationDialogTarget(null)
+ }
+ },
+ [setCommitGenerationDialogOpen]
+ )
+
+ const handlePullRequestGenerationDialogOpenChange = useCallback(
+ (open: boolean): void => {
+ setPullRequestGenerationDialogOpen(open)
+ if (!open) {
+ setPullRequestGenerationDialogTarget(null)
+ }
+ },
+ [setPullRequestGenerationDialogOpen]
+ )
+
// Why: orphaned draft/error/in-flight entries accumulate when worktrees are
// removed from the store (long sessions with many create/destroy cycles).
// Prune them so a deleted-then-reused worktree ID doesn't inherit stale
// state — especially commitInFlightRef, which would permanently disable
// Commit for that ID if left stuck at `true`.
useEffect(() => {
+ const liveWorktreeKeys = new Set()
+ for (const worktree of worktreeMap.values()) {
+ liveWorktreeKeys.add(worktree.id)
+ if (worktree.path.trim()) {
+ liveWorktreeKeys.add(worktree.path)
+ }
+ }
const pruneRecord = (prev: Record): Record => {
let changed = false
const next: Record = {}
for (const key of Object.keys(prev)) {
- if (worktreeMap.has(key)) {
+ if (liveWorktreeKeys.has(key)) {
next[key] = prev[key]
} else {
changed = true
@@ -1310,26 +1415,21 @@ function SourceControlInner(): React.JSX.Element {
setRemoteActionErrors((prev) => pruneRecord(prev))
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev))
- setGenerateInFlightByWorktree((prev) => pruneRecord(prev))
- setGenerateErrors((prev) => pruneRecord(prev))
setGitHistoryByWorktree((prev) => pruneRecord(prev))
+ pruneCommitMessageGenerationRecords(liveWorktreeKeys)
+ prunePullRequestGenerationRecords(liveWorktreeKeys)
// Refs don't need setState — mutate in place to drop stale keys.
for (const key of Object.keys(commitInFlightRef.current)) {
- if (!worktreeMap.has(key)) {
+ if (!liveWorktreeKeys.has(key)) {
delete commitInFlightRef.current[key]
}
}
- for (const key of Object.keys(generateInFlightRef.current)) {
- if (!worktreeMap.has(key)) {
- delete generateInFlightRef.current[key]
- }
- }
for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) {
- if (!worktreeMap.has(key)) {
+ if (!liveWorktreeKeys.has(key)) {
delete gitHistoryRequestByWorktreeRef.current[key]
}
}
- }, [worktreeMap])
+ }, [pruneCommitMessageGenerationRecords, prunePullRequestGenerationRecords, worktreeMap])
useEffect(() => {
// Why: users often finish merge/rebase conflicts in a terminal. Once git
@@ -1474,117 +1574,223 @@ function SourceControlInner(): React.JSX.Element {
])
const handleGenerate = useCallback(
- async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise => {
- if (!activeWorktreeId || !worktreePath) {
+ async (options?: CommitMessageGenerationOptions): Promise => {
+ const target = options?.target ?? createCommitMessageGenerationTarget()
+ if (!target) {
return
}
- if (generateInFlightRef.current[activeWorktreeId]) {
+ const overrides: RuntimeGenerateCommitMessageOverrides = {
+ ...(options?.sourceControlAiResolvedParams
+ ? { sourceControlAiResolvedParams: options.sourceControlAiResolvedParams }
+ : {}),
+ ...(options?.sourceControlAi ? { sourceControlAi: options.sourceControlAi } : {}),
+ ...(options?.agentCmdOverrides ? { agentCmdOverrides: options.agentCmdOverrides } : {})
+ }
+ const { worktreeId, worktreePath: generationWorktreePath, connectionId } = target
+ const generationKey = getCommitMessageGenerationRecordKey(worktreeId, generationWorktreePath)
+ if (!generationKey) {
return
}
- if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
+ if (
+ useAppStore.getState().commitMessageGenerationRecords[generationKey]?.status === 'running'
+ ) {
+ return
+ }
+ if (!overrides.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
return
}
if (
- !overrides?.sourceControlAiResolvedParams &&
+ !overrides.sourceControlAiResolvedParams &&
resolvedCommitMessageAi?.ok === true &&
isCustomAgentId(resolvedCommitMessageAi.value.params.agentId)
) {
const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? ''
if (!command) {
- setGenerateErrors((prev) => ({
- ...prev,
- [activeWorktreeId]:
+ const requestId = allocateCommitMessageGenerationRequestId()
+ const failedRecord = resolveCommitMessageGenerationFailure({
+ record: createRunningCommitMessageGenerationRecord({
+ worktreeId,
+ worktreePath: generationWorktreePath,
+ connectionId,
+ requestId,
+ runtimeTargetSettings: {
+ activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
+ }
+ }),
+ requestId,
+ error: translate(
+ 'auto.components.right.sidebar.SourceControl.e9e238b260',
'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
- }))
+ )
+ })
+ if (failedRecord) {
+ setCommitMessageGenerationRecord(generationKey, failedRecord)
+ }
return
}
}
- generateInFlightRef.current[activeWorktreeId] = true
- const connectionId = getConnectionId(activeWorktreeId) ?? undefined
- setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
- setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
+ const requestId = allocateCommitMessageGenerationRequestId()
+ // Why: Stop must route to the runtime selected at generation start, even
+ // if the user changes settings before cancellation.
+ setCommitMessageGenerationRecord(
+ generationKey,
+ createRunningCommitMessageGenerationRecord({
+ worktreeId,
+ worktreePath: generationWorktreePath,
+ connectionId,
+ requestId,
+ runtimeTargetSettings: {
+ activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
+ }
+ })
+ )
try {
const result = await generateRuntimeCommitMessage(
{
- settings: useAppStore.getState().settings,
- worktreeId: activeWorktreeId,
- worktreePath,
+ settings: target.settings ?? useAppStore.getState().settings,
+ worktreeId,
+ worktreePath: generationWorktreePath,
connectionId
},
overrides
)
if (!result.success) {
- // Why: cancellation is a deliberate user action, not a failure to
- // surface. Clear any prior error and stay quiet.
- if (result.canceled) {
- setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
- return
- }
- setGenerateErrors((prev) => ({
- ...prev,
- [activeWorktreeId]: result.error
- }))
+ updateCommitMessageGenerationRecord(generationKey, (record) =>
+ resolveCommitMessageGenerationFailure({
+ record,
+ requestId,
+ canceled: result.canceled,
+ error: result.canceled ? null : result.error
+ })
+ )
return
}
- // Why: race protection — the user may have started typing into the
- // textarea while the agent was running. In that case we silently drop
- // the generated message rather than overwrite their in-progress edits.
- setCommitDrafts((prev) => {
- const current = prev[activeWorktreeId]
- if (current && current.length > 0) {
- return prev
- }
- return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message)
- })
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
- setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
+ updateCommitMessageGenerationRecord(generationKey, (record) =>
+ resolveCommitMessageGenerationSuccess({
+ record,
+ requestId,
+ message: result.message
+ })
+ )
} catch (error) {
- setGenerateErrors((prev) => ({
- ...prev,
- [activeWorktreeId]:
- error instanceof Error ? error.message : 'Failed to generate commit message'
- }))
- } finally {
- setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
- generateInFlightRef.current[activeWorktreeId] = false
+ updateCommitMessageGenerationRecord(generationKey, (record) =>
+ resolveCommitMessageGenerationFailure({
+ record,
+ requestId,
+ error: error instanceof Error ? error.message : 'Failed to generate commit message'
+ })
+ )
}
},
- [activeWorktreeId, resolvedCommitMessageAi, worktreePath]
+ [
+ allocateCommitMessageGenerationRequestId,
+ createCommitMessageGenerationTarget,
+ resolvedCommitMessageAi,
+ setCommitMessageGenerationRecord,
+ updateCommitMessageGenerationRecord
+ ]
)
+ useEffect(() => {
+ if (
+ !activeCommitGenerationKey ||
+ !activeCommitGenerationRecord ||
+ activeCommitGenerationRecord.status !== 'succeeded' ||
+ !activeCommitGenerationRecord.message ||
+ activeCommitGenerationRecord.hydrated
+ ) {
+ return
+ }
+
+ const {
+ context: { worktreeId },
+ message
+ } = activeCommitGenerationRecord
+ // Why: generation can finish while Source Control is showing a different
+ // worktree or is temporarily unmounted; hydrate the saved result when its
+ // originating worktree is visible again without overwriting user edits.
+ setCommitDrafts((prev) => {
+ const current = prev[worktreeId]
+ if (current && current.length > 0) {
+ return prev
+ }
+ return writeCommitDraftForWorktree(prev, worktreeId, message)
+ })
+ updateCommitMessageGenerationRecord(
+ activeCommitGenerationKey,
+ markCommitMessageGenerationHydrated
+ )
+ }, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord])
+
const handleGenerateCommitMessageClick = useCallback((): void => {
+ const target = createCommitMessageGenerationTarget()
+ if (!target) {
+ return
+ }
if (
hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) &&
resolvedCommitMessageAi?.ok
) {
- void handleGenerate({ sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params })
+ void handleGenerate({
+ sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params,
+ target
+ })
return
}
+ // Why: the dialog remains open while users can switch worktrees; keep it
+ // bound to the worktree that requested generation.
+ setCommitGenerationDialogTarget(target)
openCommitGenerationDialog()
- }, [activeRepo, handleGenerate, openCommitGenerationDialog, resolvedCommitMessageAi, settings])
+ }, [
+ activeRepo,
+ createCommitMessageGenerationTarget,
+ handleGenerate,
+ openCommitGenerationDialog,
+ resolvedCommitMessageAi,
+ settings
+ ])
const handleCancelGenerate = useCallback((): void => {
- if (!activeWorktreeId || !worktreePath) {
+ if (
+ !activeCommitGenerationKey ||
+ !activeCommitGenerationRecord ||
+ activeCommitGenerationRecord.status !== 'running'
+ ) {
return
}
- if (!generateInFlightRef.current[activeWorktreeId]) {
- return
- }
- const connectionId = getConnectionId(activeWorktreeId) ?? undefined
+ const { context } = activeCommitGenerationRecord
+ updateCommitMessageGenerationRecord(
+ activeCommitGenerationKey,
+ resolveCommitMessageGenerationCancel
+ )
// Why: fire-and-forget — the in-flight generateCommitMessage promise
- // resolves with `{canceled: true}` once the kill propagates, which is
- // where the spinner is cleared. Awaiting here would just delay UI feedback.
+ // resolves with `{canceled: true}` once the kill propagates, while the
+ // durable record lets the visible spinner clear immediately.
void cancelRuntimeGenerateCommitMessage({
- settings: useAppStore.getState().settings,
- worktreeId: activeWorktreeId,
- worktreePath,
- connectionId
+ settings: context.runtimeTargetSettings,
+ worktreeId: context.worktreeId,
+ worktreePath: context.worktreePath,
+ connectionId: context.connectionId
+ }).catch((error) => {
+ updateCommitMessageGenerationRecord(activeCommitGenerationKey, (record) => {
+ if (!record || record.context.requestId !== context.requestId) {
+ return null
+ }
+ return {
+ ...record,
+ status: 'failed',
+ error:
+ error instanceof Error ? error.message : 'Failed to stop commit message generation',
+ hydrated: false
+ }
+ })
})
- }, [activeWorktreeId, worktreePath])
+ }, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord])
// Why: a single dispatcher for every remote-only action the split button or
// chevron dropdown can trigger. Keeps the error-swallow pattern in one
@@ -1912,42 +2118,90 @@ function SourceControlInner(): React.JSX.Element {
await refreshActiveGitStatusAfterMutation()
}, [refreshActiveGitStatusAfterMutation])
+ const createPullRequestGenerationTarget = useCallback(
+ (
+ fields: PullRequestGenerationFields,
+ fieldRevisions: PullRequestFieldRevisions
+ ): PullRequestGenerationTargetSnapshot | null => {
+ if (!activeRepo || !worktreePath || !branchName) {
+ return null
+ }
+ return {
+ worktreeId: activeWorktreeId,
+ worktreePath,
+ connectionId: activeConnectionId ?? undefined,
+ repo: activeRepo,
+ repoId: activeRepo.id,
+ branch: branchName,
+ settings,
+ discoveryHostKey: sourceControlAiDiscoveryHostKey,
+ fields: { ...fields },
+ fieldRevisions: { ...fieldRevisions }
+ }
+ },
+ [
+ activeConnectionId,
+ activeRepo,
+ activeWorktreeId,
+ branchName,
+ settings,
+ sourceControlAiDiscoveryHostKey,
+ worktreePath
+ ]
+ )
+
const handleGeneratePullRequestFieldsForActive = useCallback(
async (
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions,
- overrides?: RuntimeGeneratePullRequestFieldsOverrides
+ overrides?: RuntimeGeneratePullRequestFieldsOverrides,
+ targetOverride?: PullRequestGenerationTargetSnapshot
): Promise => {
- if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) {
+ const target = targetOverride ?? createPullRequestGenerationTarget(fields, fieldRevisions)
+ if (!target) {
+ return
+ }
+ const generationKey = getPullRequestGenerationRecordKey({
+ worktreeId: target.worktreeId,
+ worktreePath: target.worktreePath,
+ repoId: target.repoId,
+ branch: target.branch
+ })
+ if (!generationKey) {
return
}
- const generationKey = activePullRequestGenerationKey
if (
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
) {
return
}
const requestId = allocatePullRequestGenerationRequestId()
+ // Why: Stop must route to the runtime selected at generation start, even
+ // if the user changes settings before cancellation.
const context: PullRequestGenerationContext = {
- worktreeId: activeWorktreeId,
- worktreePath,
- connectionId: getConnectionId(activeWorktreeId) ?? undefined,
+ worktreeId: target.worktreeId,
+ worktreePath: target.worktreePath,
+ connectionId: target.connectionId,
requestId,
- repoId: activeRepo.id,
- branch: branchName
+ repoId: target.repoId,
+ branch: target.branch,
+ runtimeTargetSettings: {
+ activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
+ }
}
- const seed = { ...fields }
- // Why: SourceControl can unmount on tab switches; persisting the running
- // record lets the embedded PR composer resume when the user returns.
+ const seed = { ...target.fields }
+ // Why: SourceControl can unmount on tab/worktree switches; the record is
+ // keyed to the originating repo/worktree/branch so stale UI cannot retarget
+ // or orphan the generated hosted-review fields.
setPullRequestGenerationRecord(
generationKey,
- createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
+ createRunningPullRequestGenerationRecord(context, seed, target.fieldRevisions)
)
try {
const result = await generateRuntimePullRequestFields(
{
- settings: useAppStore.getState().settings,
+ settings: target.settings ?? useAppStore.getState().settings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
@@ -2001,15 +2255,11 @@ function SourceControlInner(): React.JSX.Element {
}
},
[
- activePullRequestGenerationKey,
- activeRepo,
- activeWorktreeId,
allocatePullRequestGenerationRequestId,
- branchName,
+ createPullRequestGenerationTarget,
refreshGitStatusAfterPullRequestGeneration,
setPullRequestGenerationRecord,
- updatePullRequestGenerationRecord,
- worktreePath
+ updatePullRequestGenerationRecord
]
)
@@ -2029,7 +2279,7 @@ function SourceControlInner(): React.JSX.Element {
return resolvePullRequestGenerationCancel(current)
})
void cancelRuntimeGeneratePullRequestFields({
- settings: useAppStore.getState().settings,
+ settings: record.context.runtimeTargetSettings,
worktreeId: record.context.worktreeId,
worktreePath: record.context.worktreePath,
connectionId: record.context.connectionId
@@ -2058,6 +2308,7 @@ function SourceControlInner(): React.JSX.Element {
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
+ fieldRevisions: prFieldRevisions,
baseQuery: prBaseQuery,
setBaseQuery: setPrBaseQuery,
baseResults: prBaseResults,
@@ -2103,10 +2354,36 @@ function SourceControlInner(): React.JSX.Element {
void handleGeneratePullRequestFields()
return
}
+ const target = createPullRequestGenerationTarget(
+ { base: prBase, title: prTitle, body: prBody, draft: prDraft },
+ prFieldRevisions
+ )
+ if (!target) {
+ return
+ }
+ // Why: the agent-picker dialog can stay open while the user switches
+ // worktrees; keep Generate bound to the PR draft that opened it.
+ setPullRequestGenerationDialogTarget(target)
openPullRequestGenerationDialog()
- }, [activeRepo, handleGeneratePullRequestFields, openPullRequestGenerationDialog, settings])
+ }, [
+ activeRepo,
+ createPullRequestGenerationTarget,
+ handleGeneratePullRequestFields,
+ openPullRequestGenerationDialog,
+ prBase,
+ prBody,
+ prDraft,
+ prFieldRevisions,
+ prTitle,
+ settings
+ ])
useEffect(() => {
+ // Why: after a SourceControl remount, the PR composer first reseeds from
+ // eligibility; hydrate generated fields only after that seed exists.
+ if (hostedReviewCreation?.canCreate !== true) {
+ return
+ }
if (
!activePullRequestGenerationKey ||
!activePullRequestGenerationRecord ||
@@ -2141,6 +2418,7 @@ function SourceControlInner(): React.JSX.Element {
activePullRequestGenerationKey,
activePullRequestGenerationRecord,
applyGeneratedPullRequestFields,
+ hostedReviewCreation?.canCreate,
updatePullRequestGenerationRecord
])
@@ -4476,7 +4754,7 @@ function SourceControlInner(): React.JSX.Element {
/>
{
- void handleGenerate({ sourceControlAiResolvedParams: params })
+ const target = commitGenerationDialogTarget ?? createCommitMessageGenerationTarget()
+ if (!target) {
+ return
+ }
+ void handleGenerate({ sourceControlAiResolvedParams: params, target })
}}
onSaveDefaults={handleSaveCommitMessageGenerationDefaults}
/>
{
- void handleGeneratePullRequestFields({ sourceControlAiResolvedParams: params })
+ const target =
+ pullRequestGenerationDialogTarget ??
+ createPullRequestGenerationTarget(
+ { base: prBase, title: prTitle, body: prBody, draft: prDraft },
+ prFieldRevisions
+ )
+ if (!target) {
+ return
+ }
+ void handleGeneratePullRequestFieldsForActive(
+ target.fields,
+ target.fieldRevisions,
+ { sourceControlAiResolvedParams: params },
+ target
+ )
}}
onSaveDefaults={handleSavePullRequestGenerationDefaults}
/>
@@ -4579,7 +4879,7 @@ function CommitFailureFixSplitButton({
return (
<>
-
+
+
{/* Why: match the hosted-review action buttons in Checks
(size="xs", px-3 text-[11px]) so the sidebar has a consistent
action-button shape across Source Control and Checks. The primary
@@ -4981,13 +5281,16 @@ export function CommitArea({
pair read as one split button instead of two detached buttons. */}
-
+
onPrimaryAction()}
- className="w-full rounded-r-none px-3 text-[11px]"
+ className={cn(
+ 'rounded-r-none px-3 text-[11px]',
+ RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS
+ )}
title={primaryAction.title}
>
{showSpinner ? (
@@ -4995,7 +5298,9 @@ export function CommitArea({
) : PrimaryIcon ? (
) : null}
- {primaryAction.label}
+
+ {primaryAction.label}
+
@@ -5733,12 +6038,12 @@ export function ConflictSummaryCard({
-
+
@@ -5753,7 +6058,7 @@ export function ConflictSummaryCard({
type="button"
variant="outline"
size="sm"
- className="mt-1.5 h-7 w-full text-xs"
+ className="mt-1.5 h-7 text-xs"
onClick={onReview}
>
@@ -5766,7 +6071,7 @@ export function ConflictSummaryCard({
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
- className="mt-1.5 h-7 w-full text-xs"
+ className="mt-1.5 h-7 text-xs"
disabled={isResolvingWithAI || isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
@@ -5808,26 +6113,28 @@ export function OperationBanner({
return (
-
+
{label}
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
-
onAbortOperation(conflictOperation)}
- >
- {isAbortingOperation ? : null}
- {conflictOperation === 'rebase'
- ? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
- : translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
-
+
+ onAbortOperation(conflictOperation)}
+ >
+ {isAbortingOperation ? : null}
+ {conflictOperation === 'rebase'
+ ? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
+ : translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
+
+
) : null}
)
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
index 584401e21ca..b618b8eaf1f 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
@@ -1232,7 +1232,7 @@ export function ChecksList({
{translate(
'auto.components.right.sidebar.checks.panel.content.991f50c7e4',
- 'No checks configured'
+ 'No checks reported yet'
)}
) : !checksExpanded ? null : (
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-polling.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-polling.test.ts
new file mode 100644
index 00000000000..55e453ded19
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/checks-panel-polling.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+
+import type { PRCheckDetail } from '../../../../shared/types'
+import {
+ CHECKS_PANEL_BASE_POLL_INTERVAL_MS,
+ CHECKS_PANEL_MAX_POLL_INTERVAL_MS,
+ nextChecksPanelPollInterval
+} from './checks-panel-polling'
+
+describe('nextChecksPanelPollInterval', () => {
+ it('keeps repeated empty results at the baseline poll interval', () => {
+ expect(
+ nextChecksPanelPollInterval({
+ checks: [],
+ previousSignature: '[]',
+ currentIntervalMs: CHECKS_PANEL_MAX_POLL_INTERVAL_MS
+ })
+ ).toEqual({ intervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS, signature: '[]' })
+ })
+
+ it('backs off repeated non-empty results up to the maximum interval', () => {
+ const checks: PRCheckDetail[] = [
+ { name: 'build', status: 'completed', conclusion: 'success', url: null }
+ ]
+ const { signature } = nextChecksPanelPollInterval({
+ checks,
+ previousSignature: '',
+ currentIntervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS
+ })
+
+ expect(
+ nextChecksPanelPollInterval({
+ checks,
+ previousSignature: signature,
+ currentIntervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS
+ }).intervalMs
+ ).toBe(CHECKS_PANEL_BASE_POLL_INTERVAL_MS * 2)
+ expect(
+ nextChecksPanelPollInterval({
+ checks,
+ previousSignature: signature,
+ currentIntervalMs: CHECKS_PANEL_MAX_POLL_INTERVAL_MS
+ }).intervalMs
+ ).toBe(CHECKS_PANEL_MAX_POLL_INTERVAL_MS)
+ })
+
+ it('resets changed non-empty results to the baseline poll interval', () => {
+ const previous: PRCheckDetail[] = [
+ { name: 'build', status: 'queued', conclusion: null, url: null }
+ ]
+ const next: PRCheckDetail[] = [
+ { name: 'build', status: 'completed', conclusion: 'success', url: null }
+ ]
+ const { signature } = nextChecksPanelPollInterval({
+ checks: previous,
+ previousSignature: '',
+ currentIntervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS
+ })
+
+ expect(
+ nextChecksPanelPollInterval({
+ checks: next,
+ previousSignature: signature,
+ currentIntervalMs: CHECKS_PANEL_MAX_POLL_INTERVAL_MS
+ }).intervalMs
+ ).toBe(CHECKS_PANEL_BASE_POLL_INTERVAL_MS)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-polling.ts b/src/renderer/src/components/right-sidebar/checks-panel-polling.ts
new file mode 100644
index 00000000000..42573079584
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/checks-panel-polling.ts
@@ -0,0 +1,26 @@
+import type { PRCheckDetail } from '../../../../shared/types'
+
+export const CHECKS_PANEL_BASE_POLL_INTERVAL_MS = 30_000
+export const CHECKS_PANEL_MAX_POLL_INTERVAL_MS = 120_000
+
+export function nextChecksPanelPollInterval(input: {
+ checks: PRCheckDetail[]
+ previousSignature: string
+ currentIntervalMs: number
+}): { intervalMs: number; signature: string } {
+ const signature = JSON.stringify(
+ input.checks.map((check) => `${check.name}:${check.status}:${check.conclusion}`)
+ )
+
+ if (input.checks.length === 0) {
+ return { intervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS, signature }
+ }
+
+ return {
+ intervalMs:
+ signature === input.previousSignature
+ ? Math.min(input.currentIntervalMs * 2, CHECKS_PANEL_MAX_POLL_INTERVAL_MS)
+ : CHECKS_PANEL_BASE_POLL_INTERVAL_MS,
+ signature
+ }
+}
diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts b/src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts
new file mode 100644
index 00000000000..0e9921b3dc8
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest'
+import {
+ REVIEW_ACTION_MERGE_BUTTON_CLASS,
+ RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS,
+ RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS,
+ RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS
+} from './right-sidebar-primary-action-layout'
+
+describe('right sidebar primary action layout classes', () => {
+ it('lets split-button primaries shrink inside the minimum-width sidebar', () => {
+ expect(RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS).toContain('min-w-0')
+ expect(RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS).toContain('shrink')
+ expect(RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS).toContain('shrink')
+ })
+
+ it('uses preferred widths instead of hard minimums for morphing action labels', () => {
+ expect(RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS).toContain('w-[10.5rem]')
+ expect(RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS).not.toContain('min-w-[10.5rem]')
+ expect(RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS).toContain('w-[11.5rem]')
+ expect(RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS).not.toContain('min-w-[11.5rem]')
+ })
+
+ it('shares the shrinkable merge sizing with full-page review actions', () => {
+ expect(REVIEW_ACTION_MERGE_BUTTON_CLASS).toBe(RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS)
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.ts b/src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.ts
new file mode 100644
index 00000000000..4ea850c8ac3
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/right-sidebar-primary-action-layout.ts
@@ -0,0 +1,16 @@
+// Why: sidebar primaries rotate labels as git/review state changes. Content-width
+// reads better than full-bleed; preferred width reduces jump between states; the
+// primary half must shrink first so split-button chevrons never overflow.
+export const RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS = 'inline-flex min-w-0 max-w-full items-stretch'
+
+export const RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS = 'w-[10.5rem] min-w-0 max-w-full shrink'
+
+// Covers "Squash and merge" and "Disable auto-merge".
+export const RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS = 'w-[11.5rem] min-w-0 max-w-full shrink'
+
+export const RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS = 'block min-w-0 truncate'
+
+// PR full-page and item-dialog asides share the same merge/state labels.
+export const REVIEW_ACTION_MERGE_BUTTON_CLASS = RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS
+
+export const REVIEW_ACTION_STATE_BUTTON_CLASS = 'w-[11.5rem] min-w-0 max-w-full shrink'
diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx
index bc5fa79a76a..6b0885a86ef 100644
--- a/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx
+++ b/src/renderer/src/components/settings/BrowserLinkRoutingSetting.tsx
@@ -42,7 +42,12 @@ export function BrowserLinkRoutingSetting({
updateSettings({ openLinksInApp: !settings.openLinksInApp })}
+ onClick={() =>
+ updateSettings({
+ openLinksInApp: !settings.openLinksInApp,
+ openLinksInAppPreferencePrompted: true
+ })
+ }
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.openLinksInApp ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
diff --git a/src/renderer/src/components/sidebar/ProjectOrderManualDefaultNotice.tsx b/src/renderer/src/components/sidebar/ProjectOrderManualDefaultNotice.tsx
index 64abeff0c2d..4ccc2764d44 100644
--- a/src/renderer/src/components/sidebar/ProjectOrderManualDefaultNotice.tsx
+++ b/src/renderer/src/components/sidebar/ProjectOrderManualDefaultNotice.tsx
@@ -41,7 +41,7 @@ function ProjectOrderManualDefaultNotice(): React.JSX.Element | null {
{translate(
'auto.components.sidebar.ProjectOrderManualDefaultNotice.a1f4c2d8e0',
- 'Manual project order is now default'
+ 'Manual project order is now the default'
)}
diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx
index 97218602f21..d63a1555346 100644
--- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx
+++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.test.tsx
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
appRestart: vi.fn(),
updaterCheck: vi.fn(),
shellOpenUrl: vi.fn(),
- useShortcutLabel: vi.fn(),
+ useShortcutKeys: vi.fn(),
setupProgress: {
ready: true,
coreDoneCount: 2,
@@ -32,7 +32,7 @@ vi.mock('@/store', () => ({
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
- useShortcutLabel: mocks.useShortcutLabel
+ useShortcutKeys: mocks.useShortcutKeys
}))
vi.mock('@/hooks/useMountedRef', () => ({
@@ -72,12 +72,14 @@ vi.mock('@/components/ui/tooltip', () => ({
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
- onClick
+ onClick,
+ 'aria-label': ariaLabel
}: {
children: ReactNode
onClick?: (event: React.MouseEvent) => void
+ 'aria-label'?: string
}) => (
-
+
{children}
)
@@ -97,7 +99,7 @@ vi.mock('./SidebarFeedbackDialog', () => ({
describe('SidebarSettingsHelpMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
- mocks.useShortcutLabel.mockReturnValue('⌘,')
+ mocks.useShortcutKeys.mockReturnValue(['⌘', ','])
updateStatus = { state: 'idle' }
mocks.setupProgress = {
ready: true,
@@ -112,9 +114,17 @@ describe('SidebarSettingsHelpMenu', () => {
expect(html).toContain('Help')
})
- it('renders Settings menu item', () => {
+ it('renders the settings button with correct aria-label', () => {
const html = renderToStaticMarkup( )
- expect(html).toContain('Settings')
+ expect(html).toContain('aria-label="Settings"')
+ })
+
+ it('renders the settings button before the help button', () => {
+ const html = renderToStaticMarkup( )
+ const settingsIndex = html.indexOf('lucide-settings')
+ const helpIndex = html.indexOf('lucide-circle-question-mark')
+ expect(settingsIndex).toBeGreaterThanOrEqual(0)
+ expect(helpIndex).toBeGreaterThan(settingsIndex)
})
it('renders Send Feedback menu item', () => {
@@ -179,8 +189,9 @@ describe('SidebarSettingsHelpMenu', () => {
expect(html).toContain('Check for Updates')
})
- it('renders shortcut label next to Settings', () => {
+ it('renders shortcut keys in the settings tooltip', () => {
const html = renderToStaticMarkup( )
- expect(html).toContain('⌘,')
+ expect(html).toContain('⌘')
+ expect(html).toContain('>,')
})
})
diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx
index a56c8ba7092..0da82554dfd 100644
--- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx
+++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx
@@ -26,7 +26,8 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useMountedRef } from '@/hooks/useMountedRef'
-import { useShortcutLabel } from '@/hooks/useShortcutLabel'
+import { useShortcutKeys } from '@/hooks/useShortcutLabel'
+import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event'
import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing'
import { useSetupGuideProgress } from '../setup-guide/use-setup-guide-progress'
@@ -84,7 +85,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
const updateStatus = useAppStore((s) => s.updateStatus)
const setupProgress = useSetupGuideProgress(true, false, false)
- const settingsShortcut = useShortcutLabel('app.settings')
+ const settingsShortcutKeys = useShortcutKeys('app.settings')
const [menuOpen, setMenuOpen] = useState(false)
const [feedbackOpen, setFeedbackOpen] = useState(false)
const [showAdminOptions, setShowAdminOptions] = useState(false)
@@ -157,145 +158,173 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
return (
<>
-
+
-
- revealAdminOptions(event.altKey)}
- onClick={(event) => revealAdminOptions(event.altKey)}
- >
-
-
-
+
+
+
-
- {translate('auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c', 'Help')}
+
+ {translate('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')}
+ {settingsShortcutKeys.length > 0 ? (
+
+ ) : null}
-
-
-
- {translate('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')}
- {settingsShortcut}
-
-
-
- {translate(
- 'auto.components.sidebar.SidebarSettingsHelpMenu.e565171a7c',
- 'Keyboard Shortcuts'
- )}
-
-
- setFeedbackOpen(true)}>
-
- {translate(
- 'auto.components.sidebar.SidebarSettingsHelpMenu.4cf5b868d7',
- 'Send Feedback'
- )}
-
- {showMilestones ? (
-
-
+
+
+
+
+ revealAdminOptions(event.altKey)}
+ onClick={(event) => revealAdminOptions(event.altKey)}
+ >
+
+
+
+
+
+ {translate('auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c', 'Help')}
+
+
+
+
+
{translate(
- 'auto.components.sidebar.SidebarSettingsHelpMenu.f8a2c91d4e',
- 'Milestones'
- )}
-
-
- ) : null}
- {showAdminOptions ? (
-
-
- {translate(
- 'auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c',
- 'Onboarding'
+ 'auto.components.sidebar.SidebarSettingsHelpMenu.e565171a7c',
+ 'Keyboard Shortcuts'
)}
- ) : null}
- }
- />
- }
- />
-
- }
- />
- openExternalUrl(DISCORD_URL)}>
-
- {translate('auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b', 'Discord')}
-
-
- openExternalUrl(X_URL)}>
-
- {translate('auto.components.sidebar.SidebarSettingsHelpMenu.c4f8e1b72a', 'X')}
-
-
-
-
- {updateStatus.state === 'checking' ? (
-
- ) : (
-
- )}
- {translate(
- 'auto.components.sidebar.SidebarSettingsHelpMenu.29c56f30ee',
- 'Check for Updates'
- )}
-
- {showAdminOptions ? (
- <>
-
-
-
+
+ setFeedbackOpen(true)}>
+
+ {translate(
+ 'auto.components.sidebar.SidebarSettingsHelpMenu.4cf5b868d7',
+ 'Send Feedback'
+ )}
+
+ {showMilestones ? (
+
+
{translate(
- 'auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1',
- 'Restart Orca'
+ 'auto.components.sidebar.SidebarSettingsHelpMenu.f8a2c91d4e',
+ 'Milestones'
+ )}
+
+
+ ) : null}
+ {showAdminOptions ? (
+
+
+ {translate(
+ 'auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c',
+ 'Onboarding'
)}
- >
- ) : null}
-
-
+ ) : null}
+ }
+ />
+ }
+ />
+
+ }
+ />
+ openExternalUrl(DISCORD_URL)}>
+
+ {translate('auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b', 'Discord')}
+
+
+ openExternalUrl(X_URL)}>
+
+ {translate('auto.components.sidebar.SidebarSettingsHelpMenu.c4f8e1b72a', 'X')}
+
+
+
+
+ {updateStatus.state === 'checking' ? (
+
+ ) : (
+
+ )}
+ {translate(
+ 'auto.components.sidebar.SidebarSettingsHelpMenu.29c56f30ee',
+ 'Check for Updates'
+ )}
+
+ {showAdminOptions ? (
+ <>
+
+
+
+ {translate(
+ 'auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1',
+ 'Restart Orca'
+ )}
+
+ >
+ ) : null}
+
+
+
>
)
diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx
index 173170a4330..a033bc01407 100644
--- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx
+++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx
@@ -1,37 +1,22 @@
import { useMemo, useState } from 'react'
-import { ChevronDown, Pencil, Play, Plus, Trash2 } from 'lucide-react'
+import { Plus } from 'lucide-react'
import { useAppStore } from '@/store'
-import {
- Command,
- CommandEmpty,
- CommandItem,
- CommandList,
- CommandSeparator
-} from '@/components/ui/command'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuTrigger
-} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
createTerminalQuickCommandDraft,
TerminalQuickCommandDialog
} from '@/components/terminal-quick-commands/TerminalQuickCommandDialog'
import {
- getTerminalQuickCommandBody,
getTerminalQuickCommandScope,
- isTerminalAgentQuickCommand,
isTerminalQuickCommandComplete
} from '../../../../shared/terminal-quick-commands'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab'
import type { TerminalQuickCommand } from '../../../../shared/types'
-import { cn } from '@/lib/utils'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
-import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
+import { TabBarQuickCommandsMenu } from './TabBarQuickCommandsMenu'
type TabBarQuickCommandsButtonProps = {
worktreeId: string
@@ -92,8 +77,6 @@ export function TabBarQuickCommandsButton({
return repoCommands[0] ?? globalCommands[0] ?? null
}, [repoCommands, globalCommands, recentId])
- const [menuOpen, setMenuOpen] = useState(false)
- const [commandValue, setCommandValue] = useState('')
const [editor, setEditor] = useState<
| { mode: 'add'; command: TerminalQuickCommand }
| { mode: 'edit'; command: TerminalQuickCommand }
@@ -103,16 +86,11 @@ export function TabBarQuickCommandsButton({
const totalVisible = repoCommands.length + globalCommands.length
const hasAnyCommands = totalVisible > 0
- const handleOpenChange = (next: boolean): void => {
- setMenuOpen(next)
- if (!next) {
- setCommandValue('')
- }
- }
-
- const handleRun = (command: TerminalQuickCommand): void => {
- setMenuOpen(false)
- runQuickCommandInNewTab({ command, worktreeId, groupId })
+ const addRepoCommand = (): void => {
+ setEditor({
+ mode: 'add',
+ command: createTerminalQuickCommandDraft({ type: 'repo', repoId: repoId ?? '' })
+ })
}
const handleSaveCommand = (next: TerminalQuickCommand): void => {
@@ -123,7 +101,6 @@ export function TabBarQuickCommandsButton({
}
const handleDeleteCommand = async (command: TerminalQuickCommand): Promise => {
- setMenuOpen(false)
const confirmed = await confirm({
title: translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.e8e1a52edb',
@@ -147,6 +124,10 @@ export function TabBarQuickCommandsButton({
void updateSettings({ terminalQuickCommands: current.filter((c) => c.id !== command.id) })
}
+ const handleRun = (command: TerminalQuickCommand): void => {
+ runQuickCommandInNewTab({ command, worktreeId, groupId })
+ }
+
// Why: hidden in folder-mode worktrees (no repoId) and floating terminals.
// Without a repoId the button can't represent a repo-scoped run target, and
// global-only mode would be confusing in a context that doesn't belong to a
@@ -163,12 +144,7 @@ export function TabBarQuickCommandsButton({
- setEditor({
- mode: 'add',
- command: createTerminalQuickCommandDraft({ type: 'repo', repoId })
- })
- }
+ onClick={addRepoCommand}
className="my-auto flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.8f1e971966',
@@ -203,186 +179,17 @@ export function TabBarQuickCommandsButton({
)
}
- const splitButtonClass =
- 'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground'
- const innerButtonBase =
- 'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
-
- const renderItem = (command: TerminalQuickCommand): React.JSX.Element => (
- handleRun(command)}
- className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-black/8 dark:data-[selected=true]:bg-white/14"
- >
- {isTerminalAgentQuickCommand(command) ? (
-
-
-
- ) : (
-
- )}
-
- {command.label}
-
- {isTerminalAgentQuickCommand(command)
- ? `${getAgentLabel(command.agent)}: ${command.prompt}`
- : command.command}
-
-
-
- {
- event.stopPropagation()
- setMenuOpen(false)
- setEditor({ mode: 'edit', command })
- }}
- className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
- aria-label={translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.15529ede69',
- 'Edit {{value0}}',
- { value0: command.label }
- )}
- >
-
-
- {
- event.stopPropagation()
- void handleDeleteCommand(command)
- }}
- className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
- aria-label={translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.196593b6a9',
- 'Remove {{value0}}',
- { value0: command.label }
- )}
- >
-
-
-
-
- )
-
return (
<>
-
-
-
- mostRecent && handleRun(mostRecent)}
- disabled={!mostRecent}
- className={cn(innerButtonBase, 'gap-1.5 rounded-l-md rounded-r-none px-1.5')}
- aria-label={
- mostRecent
- ? translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.b775303755',
- 'Run quick command: {{value0}}',
- { value0: mostRecent.label }
- )
- : translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.85482c57bc',
- 'Run quick command'
- )
- }
- >
-
-
- {mostRecent?.label ??
- translate('auto.components.tab.bar.TabBarQuickCommandsButton.7b1c9d6ae1', 'Run')}
-
-
-
-
- {mostRecent
- ? isTerminalAgentQuickCommand(mostRecent)
- ? translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.77ac113df0',
- 'Start {{value0}}: {{value1}}',
- {
- value0: getAgentLabel(mostRecent.agent),
- value1: getTerminalQuickCommandBody(mostRecent)
- }
- )
- : translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.37e1bb90ce',
- 'Run: {{value0}}',
- { value0: getTerminalQuickCommandBody(mostRecent) }
- )
- : translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.85482c57bc',
- 'Run quick command'
- )}
-
-
-
-
-
-
-
-
-
-
-
- {totalVisible === 0 ? (
-
- {translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.20bbd75896',
- 'No commands'
- )}
-
- ) : null}
- {repoCommands.map(renderItem)}
- {repoCommands.length > 0 && globalCommands.length > 0 ? (
-
- ) : null}
- {globalCommands.map(renderItem)}
-
-
-
{
- setMenuOpen(false)
- setEditor({
- mode: 'add',
- command: createTerminalQuickCommandDraft({ type: 'repo', repoId })
- })
- }}
- className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
- >
-
- {translate(
- 'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831',
- 'Add command'
- )}
-
-
-
-
-
-
+ setEditor({ mode: 'edit', command })}
+ onDeleteCommand={(command) => void handleDeleteCommand(command)}
+ onRunCommand={handleRun}
+ />
void
+ onDeleteCommand: (command: TerminalQuickCommand) => void
+ onEditCommand: (command: TerminalQuickCommand) => void
+ onRunCommand: (command: TerminalQuickCommand) => void
+}
+export function TabBarQuickCommandsMenu({
+ repoCommands,
+ globalCommands,
+ mostRecent,
+ onAddCommand,
+ onDeleteCommand,
+ onEditCommand,
+ onRunCommand
+}: TabBarQuickCommandsMenuProps): React.JSX.Element {
+ const [menuOpen, setMenuOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [commandValueOverride, setCommandValueOverride] = useState(null)
+ const searchInputRef = useRef(null)
+ const commandListRef = useRef(null)
+ const focusFrameRef = useRef(null)
+ const totalVisible = repoCommands.length + globalCommands.length
+ const showSearch = totalVisible > 1
+ const filteredRepoCommands = useMemo(
+ () => searchTerminalQuickCommands(repoCommands, query),
+ [repoCommands, query]
+ )
+ const filteredGlobalCommands = useMemo(
+ () => searchTerminalQuickCommands(globalCommands, query),
+ [globalCommands, query]
+ )
+ const filteredVisibleCommands = useMemo(
+ () => [...filteredRepoCommands, ...filteredGlobalCommands],
+ [filteredRepoCommands, filteredGlobalCommands]
+ )
+ const commandValue = useMemo(() => {
+ const activeValue = getTerminalQuickCommandPickerValue({
+ preferredCommandId: mostRecent?.id ?? null,
+ filteredCommands: filteredVisibleCommands,
+ rawQuery: query
+ })
+ if (
+ commandValueOverride &&
+ filteredVisibleCommands.some((command) => command.id === commandValueOverride)
+ ) {
+ return commandValueOverride
+ }
+ return activeValue
+ }, [commandValueOverride, filteredVisibleCommands, mostRecent?.id, query])
+ const selectedCommand = useMemo(
+ () => filteredVisibleCommands.find((command) => command.id === commandValue) ?? null,
+ [commandValue, filteredVisibleCommands]
+ )
+ const cancelFocusFrame = useCallback((): void => {
+ if (focusFrameRef.current !== null) {
+ cancelAnimationFrame(focusFrameRef.current)
+ focusFrameRef.current = null
+ }
+ }, [])
+ const focusSearchInput = useCallback((): void => {
+ cancelFocusFrame()
+ focusFrameRef.current = requestAnimationFrame(() => {
+ focusFrameRef.current = null
+ const searchInput = searchInputRef.current
+ if (!searchInput) {
+ return
+ }
+ searchInput.focus()
+ const end = searchInput.value.length
+ searchInput.setSelectionRange(end, end)
+ })
+ }, [cancelFocusFrame])
+ const handleOpenChange = (next: boolean): void => {
+ setMenuOpen(next)
+ if (next) {
+ setCommandValueOverride(null)
+ return
+ }
+ cancelFocusFrame()
+ setQuery('')
+ setCommandValueOverride(null)
+ }
+ useEffect(() => {
+ if (!menuOpen || !showSearch) {
+ return
+ }
+ // Why: Radix focuses the menu surface by default; search-first UX needs
+ // the input ready so Enter can run the highlighted command.
+ focusSearchInput()
+ return cancelFocusFrame
+ }, [cancelFocusFrame, focusSearchInput, menuOpen, showSearch])
+ const runAndClose = useCallback(
+ (command: TerminalQuickCommand): void => {
+ setMenuOpen(false)
+ onRunCommand(command)
+ },
+ [onRunCommand]
+ )
+ const handleSearchKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' && selectedCommand) {
+ // Why: cmdk does not submit the highlighted item from CommandInput
+ // inside a DropdownMenu — mirror other searchable menus and run it here.
+ event.preventDefault()
+ event.stopPropagation()
+ runAndClose(selectedCommand)
+ return
+ }
+ if (
+ (event.key === 'ArrowDown' || event.key === 'ArrowUp') &&
+ filteredVisibleCommands.length > 0
+ ) {
+ event.preventDefault()
+ event.stopPropagation()
+ const currentIndex = filteredVisibleCommands.findIndex(
+ (command) => command.id === commandValue
+ )
+ const startIndex = Math.max(currentIndex, 0)
+ const direction = event.key === 'ArrowDown' ? 1 : -1
+ let nextIndex = startIndex + direction
+ if (nextIndex < 0) {
+ nextIndex = filteredVisibleCommands.length - 1
+ } else if (nextIndex >= filteredVisibleCommands.length) {
+ nextIndex = 0
+ }
+ setCommandValueOverride(filteredVisibleCommands[nextIndex].id)
+ requestAnimationFrame(() => {
+ commandListRef.current
+ ?.querySelector('[cmdk-item][data-selected="true"]')
+ ?.scrollIntoView({ block: 'nearest' })
+ })
+ return
+ }
+ if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
+ // Why: keep printable keys in the search field instead of Radix typeahead,
+ // while letting Escape/Tab and system shortcuts keep their menu semantics.
+ event.stopPropagation()
+ }
+ },
+ [commandValue, filteredVisibleCommands, runAndClose, selectedCommand]
+ )
+ const splitButtonClass =
+ 'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground'
+ const innerButtonBase =
+ 'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
+ const renderItem = (command: TerminalQuickCommand): React.JSX.Element => (
+ runAndClose(command)}
+ className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground"
+ >
+ {isTerminalAgentQuickCommand(command) ? (
+
+
+
+ ) : (
+
+ )}
+
+ {command.label}
+
+ {isTerminalAgentQuickCommand(command)
+ ? `${getAgentLabel(command.agent)}: ${command.prompt}`
+ : command.command}
+
+
+
+ {
+ event.stopPropagation()
+ setMenuOpen(false)
+ onEditCommand(command)
+ }}
+ className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
+ aria-label={translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.15529ede69',
+ 'Edit {{value0}}',
+ { value0: command.label }
+ )}
+ >
+
+
+ {
+ event.stopPropagation()
+ setMenuOpen(false)
+ onDeleteCommand(command)
+ }}
+ className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
+ aria-label={translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.196593b6a9',
+ 'Remove {{value0}}',
+ { value0: command.label }
+ )}
+ >
+
+
+
+
+ )
+ return (
+
+
+
+ mostRecent && runAndClose(mostRecent)}
+ disabled={!mostRecent}
+ className={cn(innerButtonBase, 'gap-1.5 rounded-l-md rounded-r-none px-1.5')}
+ aria-label={
+ mostRecent
+ ? translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.b775303755',
+ 'Run quick command: {{value0}}',
+ { value0: mostRecent.label }
+ )
+ : translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.85482c57bc',
+ 'Run quick command'
+ )
+ }
+ >
+
+
+ {mostRecent?.label ??
+ translate('auto.components.tab.bar.TabBarQuickCommandsButton.7b1c9d6ae1', 'Run')}
+
+
+
+
+ {mostRecent
+ ? isTerminalAgentQuickCommand(mostRecent)
+ ? translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.77ac113df0',
+ 'Start {{value0}}: {{value1}}',
+ {
+ value0: getAgentLabel(mostRecent.agent),
+ value1: getTerminalQuickCommandBody(mostRecent)
+ }
+ )
+ : translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.37e1bb90ce',
+ 'Run: {{value0}}',
+ { value0: getTerminalQuickCommandBody(mostRecent) }
+ )
+ : translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.85482c57bc',
+ 'Run quick command'
+ )}
+
+
+
+
+
+
+
+
+ {
+ if (event.key !== 'Enter' || showSearch || filteredVisibleCommands.length !== 1) {
+ return
+ }
+ event.preventDefault()
+ runAndClose(filteredVisibleCommands[0])
+ }}
+ >
+
+ {showSearch ? (
+ {
+ // Why: a new query changes the filtered list, so keyboard
+ // selection should jump to the best match immediately.
+ setCommandValueOverride(null)
+ setQuery(nextQuery)
+ }}
+ onKeyDown={handleSearchKeyDown}
+ className="h-9 py-2 text-[12px]"
+ wrapperClassName="border-b border-border/50 px-2"
+ iconClassName="h-3.5 w-3.5"
+ />
+ ) : null}
+
+ {filteredVisibleCommands.length === 0 ? (
+
+ {query.trim()
+ ? translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.b4e7f9a2c1',
+ 'No commands match'
+ )
+ : translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.20bbd75896',
+ 'No commands'
+ )}
+
+ ) : null}
+ {filteredRepoCommands.map(renderItem)}
+ {filteredRepoCommands.length > 0 && filteredGlobalCommands.length > 0 ? (
+
+ ) : null}
+ {filteredGlobalCommands.map(renderItem)}
+
+
+
{
+ setMenuOpen(false)
+ onAddCommand()
+ }}
+ className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
+ >
+
+ {translate(
+ 'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831',
+ 'Add command'
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
index 5a52b8baa9b..f92271b1b30 100644
--- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx
+++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx
@@ -7,6 +7,7 @@ import { X } from 'lucide-react'
import { useAppStore } from '../../store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog'
import { DaemonActionDialog, useDaemonActions } from '@/components/shared/useDaemonActions'
import {
DEFAULT_TERMINAL_DIVIDER_DARK,
@@ -400,6 +401,7 @@ export default function TerminalPane({
const refreshWorkspaceSpace = useAppStore((store) => store.refreshWorkspaceSpace)
const settings = useAppStore((store) => store.settings)
const updateSettings = useAppStore((store) => store.updateSettings)
+ const requestLinkRoutingPreference = useLinkRoutingPreferenceDialog()
const keybindings = useAppStore((store) => store.keybindings)
// Why: Windows is the only platform where bare right-click is repurposed as
// a paste gesture; on macOS/Linux the terminal still owns right-click for the
@@ -483,6 +485,38 @@ export default function TerminalPane({
const settingsRef = useRef(settings)
settingsRef.current = settings
+ const openLinksInAppPreferencePromiseRef = useRef | null>(null)
+
+ const requestOpenLinksInAppPreference = useCallback(
+ (url: string): Promise | null => {
+ if (settingsRef.current?.openLinksInAppPreferencePrompted === true) {
+ return null
+ }
+ if (!settingsRef.current) {
+ return null
+ }
+ if (openLinksInAppPreferencePromiseRef.current) {
+ return openLinksInAppPreferencePromiseRef.current
+ }
+ const preferencePromise = (async () => {
+ const openInOrca = await requestLinkRoutingPreference({
+ openLinksInAppDefault: settingsRef.current?.openLinksInApp === true,
+ url
+ })
+ await updateSettings({
+ openLinksInApp: openInOrca,
+ openLinksInAppPreferencePrompted: true
+ })
+ return openInOrca
+ })()
+ openLinksInAppPreferencePromiseRef.current = preferencePromise
+ void preferencePromise.finally(() => {
+ openLinksInAppPreferencePromiseRef.current = null
+ })
+ return preferencePromise
+ },
+ [requestLinkRoutingPreference, updateSettings]
+ )
// Why: the persisted setting can be 'auto' (default) or one of the four
// explicit modes. useEffectiveMacOptionAsAlt resolves 'auto' into
// 'true' | 'false' based on the probe's current layout category (US → 'true',
@@ -781,6 +815,7 @@ export default function TerminalPane({
systemPrefersDark,
settings,
settingsRef,
+ requestOpenLinksInAppPreference,
effectiveMacOptionAsAlt,
effectiveMacOptionAsAltRef: macOptionAsAltRef,
initialLayoutRef,
diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
index 1914d29878e..97f1c61638b 100644
--- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
@@ -39,7 +39,11 @@ const setPendingEditorRevealMock = vi.fn()
const deps = { worktreeId: 'wt-1', worktreePath: '/tmp' }
const storeState = {
settings: undefined as
- | { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null }
+ | {
+ openLinksInApp?: boolean
+ openLinksInAppPreferencePrompted?: boolean
+ activeRuntimeEnvironmentId?: string | null
+ }
| undefined,
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock,
@@ -178,16 +182,40 @@ describe('handleOscLink', () => {
expect(stopPropagation).not.toHaveBeenCalled()
})
- it('defaults to Orca when settings have not hydrated yet', () => {
+ it('defaults to the system browser when settings have not hydrated yet', () => {
setPlatform('Macintosh')
storeState.settings = undefined
handleOscLink('https://example.com', { metaKey: true, ctrlKey: false, shiftKey: false }, deps)
+ expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+ expect(setActiveWorktreeMock).not.toHaveBeenCalled()
+ })
+
+ it('waits for the first-use preference before routing terminal http links', async () => {
+ setPlatform('Macintosh')
+ storeState.settings = { openLinksInApp: false, openLinksInAppPreferencePrompted: false }
+ const requestOpenLinksInAppPreference = vi.fn(async () => {
+ storeState.settings = { openLinksInApp: true, openLinksInAppPreferencePrompted: true }
+ return true
+ })
+
+ handleOscLink(
+ 'https://example.com',
+ { metaKey: true, ctrlKey: false, shiftKey: false },
+ { ...deps, requestOpenLinksInAppPreference }
+ )
+
+ expect(requestOpenLinksInAppPreference).toHaveBeenCalledWith('https://example.com/')
+ expect(openUrlMock).not.toHaveBeenCalled()
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+
+ await flushAsyncWork()
+
expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', {
activate: true
})
- expect(setActiveWorktreeMock).toHaveBeenCalledWith('wt-1')
expect(openUrlMock).not.toHaveBeenCalled()
})
@@ -1367,6 +1395,55 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(element.removeEventListener).toHaveBeenCalledWith('mouseup', mouseUp)
})
+ it('asks for the first-use preference from the direct URL click fallback', async () => {
+ setPlatform('Macintosh')
+ storeState.settings = { openLinksInApp: false, openLinksInAppPreferencePrompted: false }
+ const rows = [
+ makeBufferLine('PR opened: https://github.com/stablyai/orca-marketing-website/pull/82')
+ ]
+ const requestOpenLinksInAppPreference = vi.fn(async () => {
+ storeState.settings = { openLinksInApp: true, openLinksInAppPreferencePrompted: true }
+ return true
+ })
+ const { terminal, element } = makeFallbackTerminal(rows)
+ const disposable = installHttpLinkClickFallback(terminal, {
+ worktreeId: 'wt-1',
+ requestOpenLinksInAppPreference
+ })
+ const mouseUp = getRegisteredBubbleMouseUpHandler(element)
+ const preventDefault = vi.fn()
+
+ mouseUp({
+ button: 0,
+ metaKey: true,
+ ctrlKey: false,
+ shiftKey: false,
+ defaultPrevented: false,
+ clientX: 230,
+ clientY: 25,
+ preventDefault,
+ stopPropagation: vi.fn()
+ } as unknown as MouseEvent)
+
+ expect(requestOpenLinksInAppPreference).toHaveBeenCalledWith(
+ 'https://github.com/stablyai/orca-marketing-website/pull/82'
+ )
+ expect(openUrlMock).not.toHaveBeenCalled()
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
+
+ await flushAsyncWork()
+
+ expect(createBrowserTabMock).toHaveBeenCalledWith(
+ 'wt-1',
+ 'https://github.com/stablyai/orca-marketing-website/pull/82',
+ { activate: true }
+ )
+ expect(preventDefault).toHaveBeenCalled()
+ expect(terminal.clearSelection).toHaveBeenCalled()
+
+ disposable.dispose()
+ })
+
it('does not double-open URLs when xterm already handled the mouseup', () => {
setPlatform('Macintosh')
storeState.settings = { openLinksInApp: false }
diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts
index 71c4739a709..4c1d0582faf 100644
--- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts
@@ -1,10 +1,13 @@
import { resolveTerminalFileLinkText } from '@/lib/terminal-links'
-import { openHttpLink } from '@/lib/http-link-routing'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { isTerminalLinkActivation } from './terminal-link-handlers'
import { resolveTerminalFileUrlTarget } from './terminal-file-url-target'
import { openDetectedFilePath } from './terminal-file-open-routing'
+import {
+ openTerminalHttpLink,
+ type TerminalLinkRoutingPreferenceRequester
+} from './terminal-url-link-hit-testing'
type TerminalLinkEvent = Pick &
Partial>
@@ -13,7 +16,9 @@ export function handleOscLink(
rawText: string,
event: TerminalLinkEvent | undefined,
deps: Pick &
- Partial>
+ Partial> & {
+ requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
+ }
): void {
if (!isTerminalLinkActivation(event)) {
return
@@ -65,9 +70,10 @@ export function handleOscLink(
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
- openHttpLink(parsed.toString(), {
+ openTerminalHttpLink(parsed.toString(), {
worktreeId: deps.worktreeId,
- forceSystemBrowser: Boolean(event?.shiftKey)
+ forceSystemBrowser: Boolean(event?.shiftKey),
+ requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})
return
}
diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
index 52d3c935ce5..fc491aca662 100644
--- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts
@@ -6,12 +6,18 @@ import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
type UrlLinkHitTestDeps = {
worktreeId: string
forceSystemBrowser?: boolean
+ requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
type UrlLinkClickFallbackDeps = {
worktreeId: string
+ requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester
}
+export type TerminalLinkRoutingPreferenceRequester = (
+ url: string
+) => boolean | Promise | null | undefined
+
type ParsedTerminalHttpLink = {
url: string
startIndex: number
@@ -100,7 +106,8 @@ export function installHttpLinkClickFallback(
// that xterm already handled.
const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, {
worktreeId: deps.worktreeId,
- forceSystemBrowser: event.shiftKey
+ forceSystemBrowser: event.shiftKey,
+ requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference
})
if (opened) {
event.preventDefault()
@@ -134,10 +141,7 @@ export function openHttpLinkAtBufferPosition(
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
continue
}
- openHttpLink(parsed.url, {
- worktreeId: deps.worktreeId,
- forceSystemBrowser: deps.forceSystemBrowser
- })
+ openTerminalHttpLink(parsed.url, deps)
return true
}
}
@@ -145,6 +149,33 @@ export function openHttpLinkAtBufferPosition(
return false
}
+export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void {
+ if (deps.forceSystemBrowser) {
+ openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true })
+ return
+ }
+
+ const preferenceDecision = deps.requestOpenLinksInAppPreference?.(url)
+ if (preferenceDecision === null || preferenceDecision === undefined) {
+ openHttpLink(url, { worktreeId: deps.worktreeId })
+ return
+ }
+
+ // Why: the first terminal link click may need an async preference dialog.
+ // Suppress the browser's default link handling first, then route after the
+ // persisted choice is available.
+ void Promise.resolve(preferenceDecision)
+ .then((openInOrca) => {
+ openHttpLink(url, {
+ worktreeId: deps.worktreeId,
+ forceSystemBrowser: !openInOrca
+ })
+ })
+ .catch(() => {
+ openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true })
+ })
+}
+
function rangeContainsBufferPosition(
range: IBufferRange,
position: { x: number; y: number },
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index 7f721fc0d9f..425f56d8e46 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -16,7 +16,10 @@ import {
import { createTerminalHandleLinkProvider } from './terminal-handle-links'
import type { LinkHandlerDeps } from './terminal-link-handlers'
import { handleOscLink } from './terminal-osc-link-routing'
-import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing'
+import {
+ installHttpLinkClickFallback,
+ type TerminalLinkRoutingPreferenceRequester
+} from './terminal-url-link-hit-testing'
import type {
GlobalSettings,
SetupSplitDirection,
@@ -133,6 +136,7 @@ type UseTerminalPaneLifecycleDeps = {
systemPrefersDark: boolean
settings: GlobalSettings | null | undefined
settingsRef: React.RefObject
+ requestOpenLinksInAppPreference: TerminalLinkRoutingPreferenceRequester
/** Resolved Option-as-Alt value: `'auto'` has already been mapped to
* `'true' | 'false'` via the keyboard-layout probe. Passed separately
* from `settings` because the probe lives outside the settings store. */
@@ -322,6 +326,7 @@ export function useTerminalPaneLifecycle({
systemPrefersDark,
settings,
settingsRef,
+ requestOpenLinksInAppPreference,
effectiveMacOptionAsAlt,
effectiveMacOptionAsAltRef,
initialLayoutRef,
@@ -696,10 +701,10 @@ export function useTerminalPaneLifecycle({
linkDeps
)
fileLinkClickFallbackDisposablesRef.current.set(pane.id, fileLinkClickFallbackDisposable)
- const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(
- pane.terminal,
- linkDeps
- )
+ const httpLinkClickFallbackDisposable = installHttpLinkClickFallback(pane.terminal, {
+ ...linkDeps,
+ requestOpenLinksInAppPreference
+ })
httpLinkClickFallbackDisposables.set(pane.id, httpLinkClickFallbackDisposable)
// Why: skip empty selections so clicking to deselect doesn't clobber
// whatever the user last copied elsewhere.
@@ -766,7 +771,8 @@ export function useTerminalPaneLifecycle({
activate: (event, text) => {
handleOscLink(text, event as MouseEvent | undefined, {
...linkDeps,
- runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null
+ runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
+ requestOpenLinksInAppPreference
})
// Why: Cmd/Ctrl+clicking a link activates Orca handling (open file,
// new browser tab, system browser) which can steal focus from the
@@ -1033,7 +1039,8 @@ export function useTerminalPaneLifecycle({
...linkDeps,
runtimeEnvironmentId: activePane
? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null)
- : null
+ : null,
+ requestOpenLinksInAppPreference
})
// Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus
// away from the terminal before the click's mouseup reaches
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index ffc079773b4..2fc9fcd83ba 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -2443,7 +2443,9 @@
"77ac113df0": "Start {{value0}}: {{value1}}",
"7b1c9d6ae1": "Run",
"c781f992e4": "destructive",
- "be8f0ff166": "Delete"
+ "be8f0ff166": "Delete",
+ "f3a8c2d1e7": "Search quick commands...",
+ "b4e7f9a2c1": "No commands match"
},
"shell": {
"icons": {
@@ -3960,7 +3962,7 @@
"chooseSourceProject": "Choose task source"
},
"ProjectOrderManualDefaultNotice": {
- "a1f4c2d8e0": "Manual project order is now default",
+ "a1f4c2d8e0": "Manual project order is now the default",
"822ff300ad": "Dismiss",
"b7e3a91c4f": "Drag project headers to reorder, or switch to",
"e8c1f4a2b9": "in workspace options."
@@ -8017,7 +8019,8 @@
"31f6d46278": "Unresolved",
"2c417432b7": "Resolved locally",
"f3a8b2c1d0e5": "Enter a {{value0}} title.",
- "e2b7a1c0d9f4": "Failed to create {{value0}}"
+ "e2b7a1c0d9f4": "Failed to create {{value0}}",
+ "e9e238b260": "Custom command is empty. Add one in Settings -> Git -> Source Control AI."
},
"SourceControlAgentActionDialog": {
"8e856842d1": "Could not start the selected agent.",
@@ -8097,7 +8100,7 @@
"74c6885b8a": "More comment actions",
"cbcc4ab3db": "Showing first 100 checks",
"0dca6bfab5": "Open check details",
- "991f50c7e4": "No checks configured",
+ "991f50c7e4": "No checks reported yet",
"9ad98f2a17": "pending",
"5e52f4ef7f": "failing",
"02ca4f9074": "passing",
@@ -10545,6 +10548,44 @@
"8388bdea2b": "Connect Jira site"
}
}
+ },
+ "link": {
+ "routing": {
+ "preference": {
+ "dialog": {
+ "badge": "Terminal link",
+ "preview": "Preview",
+ "title": "Open terminal links in Orca's browser?",
+ "description": "Use Orca's browser for terminal links, or keep your system browser.",
+ "orca": {
+ "button": "Open in Orca",
+ "note": "Orca can use imported cookies for logged-in sites."
+ },
+ "settings": {
+ "note": "Change this later in Settings → Browser."
+ },
+ "system": {
+ "button": "Use system browser"
+ },
+ "link": {
+ "label": "Link"
+ },
+ "shortcut": {
+ "note": {
+ "prefix": "When links open in Orca,",
+ "suffix": "click opens system browser once."
+ }
+ },
+ "keep": {
+ "title": "Keep terminal links in Orca's browser?",
+ "description": "Or use your system browser by default.",
+ "orca": {
+ "button": "Keep Orca"
+ }
+ }
+ }
+ }
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 78ab2275c2c..3a8e6ae9003 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -2443,7 +2443,9 @@
"77ac113df0": "Inicio {{value0}}: {{value1}}",
"7b1c9d6ae1": "Correr",
"c781f992e4": "destructivo",
- "be8f0ff166": "Borrar"
+ "be8f0ff166": "Borrar",
+ "f3a8c2d1e7": "Search quick commands...",
+ "b4e7f9a2c1": "No commands match"
},
"shell": {
"icons": {
@@ -3952,18 +3954,18 @@
"wslLabel": "WSL default"
},
"FolderWorkspaceComposerDialog": {
- "connectFailed": "Failed to connect to project.",
- "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
- "title": "Create Folder Workspace",
- "create": "Create workspace",
- "sourceProject": "Task Source",
- "chooseSourceProject": "Choose task source"
+ "connectFailed": "No se pudo conectar al proyecto.",
+ "noRepos": "Agrega un proyecto Git en esta carpeta para adjuntar tareas de GitHub o GitLab.",
+ "title": "Crear espacio de trabajo de carpeta",
+ "create": "Crear espacio de trabajo",
+ "sourceProject": "Origen de tareas",
+ "chooseSourceProject": "Elige el origen de tareas"
},
"ProjectOrderManualDefaultNotice": {
- "a1f4c2d8e0": "Manual project order is now default",
- "822ff300ad": "Dismiss",
- "b7e3a91c4f": "Drag project headers to reorder, or switch to",
- "e8c1f4a2b9": "in workspace options."
+ "a1f4c2d8e0": "El orden manual de proyectos ahora es el predeterminado",
+ "822ff300ad": "Descartar",
+ "b7e3a91c4f": "Arrastra los encabezados de proyecto para reordenarlos, o cambia a",
+ "e8c1f4a2b9": "en las opciones del espacio de trabajo."
}
},
"shared": {
@@ -8017,7 +8019,8 @@
"31f6d46278": "Irresoluto",
"2c417432b7": "Resuelto localmente",
"f3a8b2c1d0e5": "Ingrese un título {{value0}}.",
- "e2b7a1c0d9f4": "No se pudo crear {{value0}}"
+ "e2b7a1c0d9f4": "No se pudo crear {{value0}}",
+ "e9e238b260": "Custom command is empty. Add one in Settings -> Git -> Source Control AI."
},
"SourceControlAgentActionDialog": {
"8e856842d1": "No se pudo iniciar el agente seleccionado.",
@@ -8097,7 +8100,7 @@
"74c6885b8a": "Más acciones de comentarios",
"cbcc4ab3db": "Mostrando los primeros 100 cheques",
"0dca6bfab5": "Abrir detalles del cheque",
- "991f50c7e4": "No hay controles configurados",
+ "991f50c7e4": "Aún no se han reportado controles",
"9ad98f2a17": "pendiente",
"5e52f4ef7f": "defecto",
"02ca4f9074": "paso",
@@ -10545,6 +10548,44 @@
"8388bdea2b": "Connect Jira site"
}
}
+ },
+ "link": {
+ "routing": {
+ "preference": {
+ "dialog": {
+ "badge": "Enlace del terminal",
+ "preview": "Vista previa",
+ "title": "¿Abrir enlaces del terminal en el navegador de Orca?",
+ "description": "Usa el navegador de Orca para los enlaces del terminal o conserva tu navegador del sistema.",
+ "orca": {
+ "button": "Abrir en Orca",
+ "note": "Orca puede usar cookies importadas para sitios con sesión iniciada."
+ },
+ "settings": {
+ "note": "Cámbialo después en Configuración → Navegador."
+ },
+ "system": {
+ "button": "Usar navegador del sistema"
+ },
+ "link": {
+ "label": "Enlace"
+ },
+ "shortcut": {
+ "note": {
+ "prefix": "Cuando los enlaces se abren en Orca,",
+ "suffix": "clic abre el navegador del sistema una vez."
+ }
+ },
+ "keep": {
+ "title": "¿Mantener los enlaces del terminal en el navegador de Orca?",
+ "description": "O usa tu navegador del sistema de forma predeterminada.",
+ "orca": {
+ "button": "Mantener Orca"
+ }
+ }
+ }
+ }
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 9c34e18ead3..bd777fd693b 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -2443,7 +2443,9 @@
"77ac113df0": "{{value0}} を開始: {{value1}}",
"7b1c9d6ae1": "走る",
"c781f992e4": "破壊的な",
- "be8f0ff166": "削除"
+ "be8f0ff166": "削除",
+ "f3a8c2d1e7": "Search quick commands...",
+ "b4e7f9a2c1": "No commands match"
},
"shell": {
"icons": {
@@ -3952,18 +3954,18 @@
"wslLabel": "WSL default"
},
"FolderWorkspaceComposerDialog": {
- "connectFailed": "Failed to connect to project.",
- "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
- "title": "Create Folder Workspace",
- "create": "Create workspace",
- "sourceProject": "Task Source",
- "chooseSourceProject": "Choose task source"
+ "connectFailed": "プロジェクトに接続できませんでした。",
+ "noRepos": "GitHub または GitLab のタスクを関連付けるには、このフォルダー内に Git プロジェクトを追加してください。",
+ "title": "フォルダーワークスペースを作成",
+ "create": "ワークスペースを作成",
+ "sourceProject": "タスクソース",
+ "chooseSourceProject": "タスクソースを選択"
},
"ProjectOrderManualDefaultNotice": {
- "a1f4c2d8e0": "Manual project order is now default",
- "822ff300ad": "Dismiss",
- "b7e3a91c4f": "Drag project headers to reorder, or switch to",
- "e8c1f4a2b9": "in workspace options."
+ "a1f4c2d8e0": "手動のプロジェクト順序がデフォルトになりました",
+ "822ff300ad": "閉じる",
+ "b7e3a91c4f": "プロジェクトヘッダーをドラッグして並べ替えるか、",
+ "e8c1f4a2b9": "をワークスペースオプションで選択してください。"
}
},
"shared": {
@@ -8017,7 +8019,8 @@
"2c417432b7": "ローカルで解決済み",
"d206117f90": "{{value0}} コンフリクト ({{value1}})",
"f3a8b2c1d0e5": "{{value0}} のタイトルを入力してください。",
- "e2b7a1c0d9f4": "{{value0}} の作成に失敗しました"
+ "e2b7a1c0d9f4": "{{value0}} の作成に失敗しました",
+ "e9e238b260": "Custom command is empty. Add one in Settings -> Git -> Source Control AI."
},
"SourceControlAgentActionDialog": {
"8e856842d1": "選択した agent を開始できませんでした。",
@@ -8097,7 +8100,7 @@
"74c6885b8a": "その他のコメント操作",
"cbcc4ab3db": "最初の 100 件のチェックを表示しています",
"0dca6bfab5": "オープンチェックの詳細",
- "991f50c7e4": "チェックが設定されていません",
+ "991f50c7e4": "まだチェックは報告されていません",
"9ad98f2a17": "保留中",
"5e52f4ef7f": "失敗した",
"02ca4f9074": "合格",
@@ -10545,6 +10548,44 @@
"8388bdea2b": "Connect Jira site"
}
}
+ },
+ "link": {
+ "routing": {
+ "preference": {
+ "dialog": {
+ "badge": "ターミナルリンク",
+ "preview": "プレビュー",
+ "title": "ターミナルのリンクを Orca のブラウザで開きますか?",
+ "description": "ターミナルのリンクを Orca のブラウザで開くか、システムブラウザを使い続けます。",
+ "orca": {
+ "button": "Orca で開く",
+ "note": "Orca はインポート済み Cookie を使ってログイン済みサイトを開けます。"
+ },
+ "settings": {
+ "note": "後で 設定 → ブラウザ から変更できます。"
+ },
+ "system": {
+ "button": "システムブラウザを使用"
+ },
+ "link": {
+ "label": "リンク"
+ },
+ "shortcut": {
+ "note": {
+ "prefix": "リンクを Orca で開く場合、",
+ "suffix": "クリックで今回だけシステムブラウザで開きます。"
+ }
+ },
+ "keep": {
+ "title": "ターミナルのリンクを Orca のブラウザで開き続けますか?",
+ "description": "またはシステムブラウザを既定で使用します。",
+ "orca": {
+ "button": "Orca のままにする"
+ }
+ }
+ }
+ }
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 68e02cdf503..d271d7cfa0c 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -2443,7 +2443,9 @@
"77ac113df0": "시작 {{value0}}: {{value1}}",
"7b1c9d6ae1": "달리다",
"c781f992e4": "파괴적인",
- "be8f0ff166": "삭제"
+ "be8f0ff166": "삭제",
+ "f3a8c2d1e7": "Search quick commands...",
+ "b4e7f9a2c1": "No commands match"
},
"shell": {
"icons": {
@@ -3952,18 +3954,18 @@
"wslLabel": "WSL default"
},
"FolderWorkspaceComposerDialog": {
- "connectFailed": "Failed to connect to project.",
- "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
- "title": "Create Folder Workspace",
+ "connectFailed": "프로젝트에 연결하지 못했습니다.",
+ "noRepos": "GitHub 또는 GitLab 작업을 연결하려면 이 폴더 아래에 Git 프로젝트를 추가하세요.",
+ "title": "폴더 워크스페이스 만들기",
"create": "워크스페이스 만들기",
- "sourceProject": "Task Source",
- "chooseSourceProject": "Choose task source"
+ "sourceProject": "작업 소스",
+ "chooseSourceProject": "작업 소스 선택"
},
"ProjectOrderManualDefaultNotice": {
- "a1f4c2d8e0": "Manual project order is now default",
- "822ff300ad": "Dismiss",
- "b7e3a91c4f": "Drag project headers to reorder, or switch to",
- "e8c1f4a2b9": "in workspace options."
+ "a1f4c2d8e0": "수동 프로젝트 순서가 이제 기본값입니다",
+ "822ff300ad": "닫기",
+ "b7e3a91c4f": "프로젝트 헤더를 드래그하여 순서를 바꾸거나",
+ "e8c1f4a2b9": "정렬을 워크스페이스 옵션에서 선택하세요."
}
},
"shared": {
@@ -8017,7 +8019,8 @@
"2c417432b7": "로컬에서 해결됨",
"d206117f90": "{{value0}} 충돌 ({{value1}})",
"f3a8b2c1d0e5": "{{value0}} 제목을 입력하세요.",
- "e2b7a1c0d9f4": "{{value0}} 생성에 실패했습니다"
+ "e2b7a1c0d9f4": "{{value0}} 생성에 실패했습니다",
+ "e9e238b260": "Custom command is empty. Add one in Settings -> Git -> Source Control AI."
},
"SourceControlAgentActionDialog": {
"8e856842d1": "선택한 agent를 시작할 수 없습니다.",
@@ -8097,7 +8100,7 @@
"74c6885b8a": "추가 댓글 작업",
"cbcc4ab3db": "처음 100개 검사 표시",
"0dca6bfab5": "검사 세부 정보 열기",
- "991f50c7e4": "구성된 검사가 없습니다.",
+ "991f50c7e4": "아직 보고된 체크가 없습니다.",
"9ad98f2a17": "보류 중",
"5e52f4ef7f": "실패",
"02ca4f9074": "통과",
@@ -10545,6 +10548,44 @@
"8388bdea2b": "Connect Jira site"
}
}
+ },
+ "link": {
+ "routing": {
+ "preference": {
+ "dialog": {
+ "badge": "터미널 링크",
+ "preview": "미리보기",
+ "title": "터미널 링크를 Orca 브라우저에서 열까요?",
+ "description": "터미널 링크를 Orca 브라우저에서 열거나 시스템 브라우저를 계속 사용합니다.",
+ "orca": {
+ "button": "Orca에서 열기",
+ "note": "Orca는 가져온 쿠키를 사용해 로그인된 사이트를 열 수 있습니다."
+ },
+ "settings": {
+ "note": "나중에 설정 → 브라우저에서 변경할 수 있습니다."
+ },
+ "system": {
+ "button": "시스템 브라우저 사용"
+ },
+ "link": {
+ "label": "링크"
+ },
+ "shortcut": {
+ "note": {
+ "prefix": "링크가 Orca에서 열릴 때,",
+ "suffix": "클릭하면 한 번만 시스템 브라우저에서 열립니다."
+ }
+ },
+ "keep": {
+ "title": "터미널 링크를 Orca 브라우저에서 계속 열까요?",
+ "description": "또는 시스템 브라우저를 기본값으로 사용합니다.",
+ "orca": {
+ "button": "Orca 유지"
+ }
+ }
+ }
+ }
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index be01f7ec6ff..471f73cfa84 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -2443,7 +2443,9 @@
"77ac113df0": "开始 {{value0}}: {{value1}}",
"7b1c9d6ae1": "跑步",
"c781f992e4": "destructive",
- "be8f0ff166": "删除"
+ "be8f0ff166": "删除",
+ "f3a8c2d1e7": "Search quick commands...",
+ "b4e7f9a2c1": "No commands match"
},
"shell": {
"icons": {
@@ -3952,18 +3954,18 @@
"wslLabel": "WSL default"
},
"FolderWorkspaceComposerDialog": {
- "connectFailed": "Failed to connect to project.",
- "noRepos": "Add a Git project under this folder to attach GitHub or GitLab tasks.",
- "title": "Create Folder Workspace",
+ "connectFailed": "无法连接到项目。",
+ "noRepos": "在此文件夹下添加 Git 项目,以关联 GitHub 或 GitLab 任务。",
+ "title": "创建文件夹工作区",
"create": "创建工作区",
- "sourceProject": "Task Source",
- "chooseSourceProject": "Choose task source"
+ "sourceProject": "任务来源",
+ "chooseSourceProject": "选择任务来源"
},
"ProjectOrderManualDefaultNotice": {
- "a1f4c2d8e0": "Manual project order is now default",
- "822ff300ad": "Dismiss",
- "b7e3a91c4f": "Drag project headers to reorder, or switch to",
- "e8c1f4a2b9": "in workspace options."
+ "a1f4c2d8e0": "手动项目排序现在是默认设置",
+ "822ff300ad": "关闭",
+ "b7e3a91c4f": "拖动项目标题即可重新排序,或在工作区选项中切换到",
+ "e8c1f4a2b9": "。"
}
},
"shared": {
@@ -8017,7 +8019,8 @@
"2c417432b7": "已在本地解决",
"d206117f90": "{{value0}} 冲突 ({{value1}})",
"f3a8b2c1d0e5": "请输入 {{value0}} 标题。",
- "e2b7a1c0d9f4": "创建 {{value0}} 失败"
+ "e2b7a1c0d9f4": "创建 {{value0}} 失败",
+ "e9e238b260": "Custom command is empty. Add one in Settings -> Git -> Source Control AI."
},
"SourceControlAgentActionDialog": {
"8e856842d1": "无法启动选定的 Agent。",
@@ -8097,7 +8100,7 @@
"74c6885b8a": "更多评论动作",
"cbcc4ab3db": "显示前 100 项检查",
"0dca6bfab5": "打开检查详细信息",
- "991f50c7e4": "未配置检查",
+ "991f50c7e4": "尚未报告任何检查",
"9ad98f2a17": "待办的",
"5e52f4ef7f": "失败",
"02ca4f9074": "通过",
@@ -10545,6 +10548,44 @@
"8388bdea2b": "Connect Jira site"
}
}
+ },
+ "link": {
+ "routing": {
+ "preference": {
+ "dialog": {
+ "badge": "终端链接",
+ "preview": "预览",
+ "title": "在 Orca 浏览器中打开终端链接?",
+ "description": "用 Orca 浏览器打开终端链接,或继续使用系统浏览器。",
+ "orca": {
+ "button": "在 Orca 中打开",
+ "note": "Orca 可以使用已导入的 Cookie 打开已登录的网站。"
+ },
+ "settings": {
+ "note": "之后可在设置 → 浏览器中更改。"
+ },
+ "system": {
+ "button": "使用系统浏览器"
+ },
+ "link": {
+ "label": "链接"
+ },
+ "shortcut": {
+ "note": {
+ "prefix": "当链接在 Orca 中打开时,",
+ "suffix": "点击可临时改用系统浏览器。"
+ }
+ },
+ "keep": {
+ "title": "继续在 Orca 浏览器中打开终端链接?",
+ "description": "或默认使用系统浏览器。",
+ "orca": {
+ "button": "继续使用 Orca"
+ }
+ }
+ }
+ }
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts
index 8345e899b38..f1eeee1f09b 100644
--- a/src/renderer/src/lib/http-link-routing.test.ts
+++ b/src/renderer/src/lib/http-link-routing.test.ts
@@ -8,7 +8,11 @@ const createBrowserTabMock = vi.fn()
const storeState = {
settings: undefined as
- | { openLinksInApp?: boolean; activeRuntimeEnvironmentId?: string | null }
+ | {
+ openLinksInApp?: boolean
+ openLinksInAppPreferencePrompted?: boolean
+ activeRuntimeEnvironmentId?: string | null
+ }
| undefined,
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock
@@ -44,13 +48,13 @@ describe('openHttpLink', () => {
expect(openUrlMock).not.toHaveBeenCalled()
})
- it('defaults to Orca routing when settings have not hydrated', () => {
+ it('defaults to the system browser when settings have not hydrated', () => {
storeState.settings = undefined
openHttpLink('https://example.com/', { worktreeId: 'wt-1' })
- expect(createBrowserTabMock).toHaveBeenCalled()
- expect(openUrlMock).not.toHaveBeenCalled()
+ expect(openUrlMock).toHaveBeenCalledWith('https://example.com/')
+ expect(createBrowserTabMock).not.toHaveBeenCalled()
})
it('routes floating workspace links into Orca without changing the active repo worktree', () => {
diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts
index d67d96c8efd..1dd9b741262 100644
--- a/src/renderer/src/lib/http-link-routing.ts
+++ b/src/renderer/src/lib/http-link-routing.ts
@@ -35,7 +35,7 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void
!remoteRuntimeActive &&
!forceSystemBrowser &&
Boolean(worktreeId) &&
- state?.settings?.openLinksInApp !== false
+ state?.settings?.openLinksInApp === true
if (routeToOrca && worktreeId && state) {
// Why: http clicks from inside a worktree should not push a worktree-switch
diff --git a/src/renderer/src/lib/terminal-quick-command-search.test.ts b/src/renderer/src/lib/terminal-quick-command-search.test.ts
new file mode 100644
index 00000000000..c22133f6998
--- /dev/null
+++ b/src/renderer/src/lib/terminal-quick-command-search.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest'
+import {
+ getTerminalQuickCommandPickerValue,
+ searchTerminalQuickCommands
+} from './terminal-quick-command-search'
+import type { TerminalQuickCommand } from '../../../shared/types'
+
+const commands: TerminalQuickCommand[] = [
+ {
+ id: 'dev',
+ label: 'dev',
+ action: 'terminal-command',
+ command: 'pnpm dev',
+ appendEnter: true
+ },
+ {
+ id: 'review',
+ label: 'codex-code-review',
+ action: 'agent-prompt',
+ agent: 'codex',
+ prompt: 'Review all code changes'
+ },
+ {
+ id: 'simulate',
+ label: 'simulate new user',
+ action: 'terminal-command',
+ command: 'pnpm simulate-new-user',
+ appendEnter: true
+ }
+]
+
+describe('terminal quick command search', () => {
+ it('returns all commands for an empty query', () => {
+ expect(searchTerminalQuickCommands(commands, '')).toEqual(commands)
+ })
+
+ it('matches label, body, and agent text', () => {
+ expect(searchTerminalQuickCommands(commands, 'dev').map((command) => command.id)).toEqual([
+ 'dev'
+ ])
+ expect(searchTerminalQuickCommands(commands, 'codex').map((command) => command.id)).toEqual([
+ 'review'
+ ])
+ expect(
+ searchTerminalQuickCommands(commands, 'review all').map((command) => command.id)
+ ).toEqual(['review'])
+ expect(searchTerminalQuickCommands(commands, 'simulate').map((command) => command.id)).toEqual([
+ 'simulate'
+ ])
+ })
+
+ it('prefers the recent command when the query is empty', () => {
+ expect(
+ getTerminalQuickCommandPickerValue({
+ preferredCommandId: 'simulate',
+ filteredCommands: commands,
+ rawQuery: ''
+ })
+ ).toBe('simulate')
+ })
+
+ it('selects the first filtered match while searching', () => {
+ expect(
+ getTerminalQuickCommandPickerValue({
+ preferredCommandId: 'dev',
+ filteredCommands: searchTerminalQuickCommands(commands, 'codex'),
+ rawQuery: 'codex'
+ })
+ ).toBe('review')
+ })
+})
diff --git a/src/renderer/src/lib/terminal-quick-command-search.ts b/src/renderer/src/lib/terminal-quick-command-search.ts
new file mode 100644
index 00000000000..958847c78b4
--- /dev/null
+++ b/src/renderer/src/lib/terminal-quick-command-search.ts
@@ -0,0 +1,90 @@
+import {
+ getTerminalQuickCommandBody,
+ isTerminalAgentQuickCommand
+} from '../../../shared/terminal-quick-commands'
+import type { TerminalQuickCommand } from '../../../shared/types'
+
+type RankedCommand = {
+ command: TerminalQuickCommand
+ score: number
+ index: number
+}
+
+const NO_MATCH = Number.POSITIVE_INFINITY
+
+export function searchTerminalQuickCommands(
+ commands: readonly TerminalQuickCommand[],
+ rawQuery: string
+): TerminalQuickCommand[] {
+ const query = normalizeSearchText(rawQuery)
+ if (!query) {
+ return [...commands]
+ }
+
+ const matches: RankedCommand[] = []
+ commands.forEach((command, index) => {
+ const score = scoreQuickCommand(command, query)
+ if (score !== NO_MATCH) {
+ matches.push({ command, score, index })
+ }
+ })
+
+ matches.sort((a, b) => a.score - b.score || a.index - b.index)
+ return matches.map((match) => match.command)
+}
+
+export function getTerminalQuickCommandPickerValue({
+ preferredCommandId,
+ filteredCommands,
+ rawQuery
+}: {
+ preferredCommandId: string | null
+ filteredCommands: readonly TerminalQuickCommand[]
+ rawQuery: string
+}): string {
+ if (!normalizeSearchText(rawQuery)) {
+ if (
+ preferredCommandId &&
+ filteredCommands.some((command) => command.id === preferredCommandId)
+ ) {
+ return preferredCommandId
+ }
+ return filteredCommands[0]?.id ?? ''
+ }
+ return filteredCommands[0]?.id ?? ''
+}
+
+function scoreQuickCommand(command: TerminalQuickCommand, query: string): number {
+ const body = getTerminalQuickCommandBody(command)
+ const scores = [scoreCandidate(query, command.label, 0), scoreCandidate(query, body, 400)]
+ if (isTerminalAgentQuickCommand(command)) {
+ scores.push(scoreCandidate(query, command.agent, 200))
+ }
+ return Math.min(...scores)
+}
+
+function scoreCandidate(query: string, rawCandidate: string, baseScore: number): number {
+ const candidate = normalizeSearchText(rawCandidate)
+ if (!candidate) {
+ return NO_MATCH
+ }
+ if (candidate === query) {
+ return baseScore
+ }
+ if (candidate.startsWith(query)) {
+ return baseScore + 50
+ }
+ const wordIndex = candidate.indexOf(` ${query}`)
+ if (wordIndex >= 0) {
+ return baseScore + 100 + wordIndex
+ }
+ const index = candidate.indexOf(query)
+ if (index >= 0) {
+ return baseScore + 200 + index
+ }
+ return NO_MATCH
+}
+
+function normalizeSearchText(value: string): string {
+ return value.trim().toLowerCase().replace(/\s+/g, ' ')
+}
diff --git a/src/renderer/src/lib/workspace-port-actions.ts b/src/renderer/src/lib/workspace-port-actions.ts
index 82d3e0e598f..844fd4fe3b7 100644
--- a/src/renderer/src/lib/workspace-port-actions.ts
+++ b/src/renderer/src/lib/workspace-port-actions.ts
@@ -39,7 +39,7 @@ function delay(ms: number): Promise {
export function shouldOpenWorkspacePortInOrcaBrowser(
settings: { openLinksInApp?: boolean } | null | undefined
): boolean {
- return settings?.openLinksInApp !== false
+ return settings?.openLinksInApp === true
}
export function workspacePortOwnerWorktreeId(port: WorkspacePort): string | null {
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index 9668bf7d566..8e430d6e5d1 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -30,6 +30,7 @@ import { createWorktreeNavHistorySlice } from './slices/worktree-nav-history'
import { createDictationSlice } from './slices/dictation'
import { createWorkspaceCleanupSlice } from './slices/workspace-cleanup'
import { createPullRequestGenerationSlice } from './slices/pull-request-generation'
+import { createCommitMessageGenerationSlice } from './slices/commit-message-generation'
import { e2eConfig } from '@/lib/e2e-config'
import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
@@ -63,7 +64,8 @@ export const useAppStore = create()((...a) => ({
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a),
- ...createPullRequestGenerationSlice(...a)
+ ...createPullRequestGenerationSlice(...a),
+ ...createCommitMessageGenerationSlice(...a)
}))
registerHttpLinkStoreAccessor(() => useAppStore.getState())
diff --git a/src/renderer/src/store/slices/commit-message-generation.ts b/src/renderer/src/store/slices/commit-message-generation.ts
new file mode 100644
index 00000000000..77f271781cf
--- /dev/null
+++ b/src/renderer/src/store/slices/commit-message-generation.ts
@@ -0,0 +1,189 @@
+import type { StateCreator } from 'zustand'
+import type { GlobalSettings } from '../../../../shared/types'
+import type { AppState } from '../types'
+
+export type CommitMessageGenerationRuntimeTargetSettings = Pick<
+ GlobalSettings,
+ 'activeRuntimeEnvironmentId'
+>
+
+export type CommitMessageGenerationContext = {
+ worktreeId: string
+ worktreePath: string
+ connectionId?: string
+ requestId: number
+ runtimeTargetSettings?: CommitMessageGenerationRuntimeTargetSettings | null
+}
+
+export type CommitMessageGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded'
+
+export type CommitMessageGenerationRecord = {
+ context: CommitMessageGenerationContext
+ status: CommitMessageGenerationStatus
+ message: string | null
+ error: string | null
+ hydrated: boolean
+}
+
+export type CommitMessageGenerationRecords = Record
+
+export type CommitMessageGenerationSlice = {
+ commitMessageGenerationRequestSeq: number
+ commitMessageGenerationRecords: CommitMessageGenerationRecords
+ allocateCommitMessageGenerationRequestId: () => number
+ setCommitMessageGenerationRecord: (key: string, record: CommitMessageGenerationRecord) => void
+ updateCommitMessageGenerationRecord: (
+ key: string,
+ updater: (record: CommitMessageGenerationRecord | null) => CommitMessageGenerationRecord | null
+ ) => void
+ pruneCommitMessageGenerationRecords: (liveWorktreeKeys: ReadonlySet) => void
+}
+
+export function getCommitMessageGenerationRecordKey(
+ worktreeId: string | null | undefined,
+ worktreePath: string | null | undefined
+): string | null {
+ if (worktreeId) {
+ return worktreeId
+ }
+ return worktreePath?.trim() ? worktreePath : null
+}
+
+export function createRunningCommitMessageGenerationRecord(
+ context: CommitMessageGenerationContext
+): CommitMessageGenerationRecord {
+ return {
+ context,
+ status: 'running',
+ message: null,
+ error: null,
+ hydrated: false
+ }
+}
+
+export function resolveCommitMessageGenerationSuccess({
+ record,
+ requestId,
+ message
+}: {
+ record: CommitMessageGenerationRecord | null | undefined
+ requestId: number
+ message: string
+}): CommitMessageGenerationRecord | null {
+ if (!record || record.context.requestId !== requestId || record.status !== 'running') {
+ return null
+ }
+ return {
+ ...record,
+ status: 'succeeded',
+ message,
+ error: null,
+ hydrated: false
+ }
+}
+
+export function resolveCommitMessageGenerationFailure({
+ record,
+ requestId,
+ error,
+ canceled = false
+}: {
+ record: CommitMessageGenerationRecord | null | undefined
+ requestId: number
+ error: string | null
+ canceled?: boolean
+}): CommitMessageGenerationRecord | null {
+ if (!record || record.context.requestId !== requestId || record.status !== 'running') {
+ return null
+ }
+ return {
+ ...record,
+ status: canceled ? 'canceled' : 'failed',
+ message: null,
+ error: canceled ? null : error,
+ hydrated: false
+ }
+}
+
+export function resolveCommitMessageGenerationCancel(
+ record: CommitMessageGenerationRecord | null | undefined
+): CommitMessageGenerationRecord | null {
+ if (!record || record.status !== 'running') {
+ return null
+ }
+ return {
+ ...record,
+ status: 'canceled',
+ error: null,
+ hydrated: false
+ }
+}
+
+export function markCommitMessageGenerationHydrated(
+ record: CommitMessageGenerationRecord | null | undefined
+): CommitMessageGenerationRecord | null {
+ if (!record || record.status !== 'succeeded') {
+ return null
+ }
+ return {
+ ...record,
+ hydrated: true
+ }
+}
+
+export const createCommitMessageGenerationSlice: StateCreator<
+ AppState,
+ [],
+ [],
+ CommitMessageGenerationSlice
+> = (set) => ({
+ commitMessageGenerationRequestSeq: 0,
+ commitMessageGenerationRecords: {},
+ allocateCommitMessageGenerationRequestId: () => {
+ let nextRequestId = 0
+ set((state) => {
+ nextRequestId = state.commitMessageGenerationRequestSeq + 1
+ return {
+ commitMessageGenerationRequestSeq: nextRequestId
+ }
+ })
+ return nextRequestId
+ },
+ setCommitMessageGenerationRecord: (key, record) =>
+ set((state) => ({
+ commitMessageGenerationRecords: {
+ ...state.commitMessageGenerationRecords,
+ [key]: record
+ }
+ })),
+ updateCommitMessageGenerationRecord: (key, updater) =>
+ set((state) => {
+ const nextRecord = updater(state.commitMessageGenerationRecords[key] ?? null)
+ if (!nextRecord) {
+ return {}
+ }
+ return {
+ commitMessageGenerationRecords: {
+ ...state.commitMessageGenerationRecords,
+ [key]: nextRecord
+ }
+ }
+ }),
+ pruneCommitMessageGenerationRecords: (liveWorktreeKeys) =>
+ set((state) => {
+ let changed = false
+ const nextRecords: CommitMessageGenerationRecords = {}
+ for (const [key, record] of Object.entries(state.commitMessageGenerationRecords)) {
+ const worktreeKey = getCommitMessageGenerationRecordKey(
+ record.context.worktreeId,
+ record.context.worktreePath
+ )
+ if (worktreeKey && liveWorktreeKeys.has(worktreeKey)) {
+ nextRecords[key] = record
+ } else {
+ changed = true
+ }
+ }
+ return changed ? { commitMessageGenerationRecords: nextRecords } : {}
+ })
+})
diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts
index 96d6e8f097f..d5672d751cb 100644
--- a/src/renderer/src/store/slices/diffComments.test.ts
+++ b/src/renderer/src/store/slices/diffComments.test.ts
@@ -137,6 +137,7 @@ import { createWorktreeNavHistorySlice } from './worktree-nav-history'
import { createDictationSlice } from './dictation'
import { createWorkspaceCleanupSlice } from './workspace-cleanup'
import { createPullRequestGenerationSlice } from './pull-request-generation'
+import { createCommitMessageGenerationSlice } from './commit-message-generation'
function createTestStore() {
return create()((...a) => ({
@@ -169,7 +170,8 @@ function createTestStore() {
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a),
- ...createPullRequestGenerationSlice(...a)
+ ...createPullRequestGenerationSlice(...a),
+ ...createCommitMessageGenerationSlice(...a)
}))
}
diff --git a/src/renderer/src/store/slices/github-checks-cache.test.ts b/src/renderer/src/store/slices/github-checks-cache.test.ts
new file mode 100644
index 00000000000..2474ac521e3
--- /dev/null
+++ b/src/renderer/src/store/slices/github-checks-cache.test.ts
@@ -0,0 +1,390 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { create } from 'zustand'
+
+import { createGitHubSlice, prChecksCacheSuffix } from './github'
+import { createHostedReviewSlice } from './hosted-review'
+import { getHostedReviewCacheKey } from './hosted-review-cache-identity'
+import type { AppState } from '../types'
+import type { PRCheckDetail, PRInfo } from '../../../../shared/types'
+
+const mockApi = {
+ gh: {
+ prChecks: vi.fn()
+ },
+ cache: {
+ setGitHub: vi.fn()
+ }
+}
+
+// @ts-expect-error test window mock
+globalThis.window = { api: mockApi }
+
+type Deferred = {
+ promise: Promise
+ resolve: (value: T) => void
+}
+
+function deferred(): Deferred {
+ let resolve: (value: T) => void = () => {}
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve
+ })
+ return { promise, resolve }
+}
+
+function createTestStore() {
+ return create()(
+ (...a) =>
+ ({
+ ...createGitHubSlice(...a),
+ ...createHostedReviewSlice(...a)
+ }) as AppState
+ )
+}
+
+function makePR(overrides: Partial = {}): PRInfo {
+ return {
+ number: 12,
+ title: 'Test PR',
+ state: 'open',
+ url: 'https://example.com/pr/12',
+ checksStatus: 'pending',
+ updatedAt: '2026-03-28T00:00:00Z',
+ mergeable: 'UNKNOWN',
+ headSha: 'head-oid',
+ ...overrides
+ }
+}
+
+beforeEach(() => {
+ mockApi.gh.prChecks.mockReset()
+ mockApi.gh.prChecks.mockResolvedValue([])
+ mockApi.cache.setGitHub.mockReset()
+ mockApi.cache.setGitHub.mockResolvedValue(undefined)
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('createGitHubSlice.fetchPRChecks checks cache freshness', () => {
+ it('expires empty checks cache entries after the shorter empty TTL', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-id'
+ const branch = 'feature/test'
+
+ mockApi.gh.prChecks.mockResolvedValue([])
+
+ await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+ vi.setSystemTime(11_001)
+ await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+
+ expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(2)
+ })
+
+ it('keeps repeated automatic empty checks refreshes cacheable', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-id'
+ const branch = 'feature/test'
+
+ mockApi.gh.prChecks.mockResolvedValue([])
+
+ await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+ vi.setSystemTime(11_001)
+ await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+
+ expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(2)
+ expect(mockApi.gh.prChecks).toHaveBeenNthCalledWith(1, {
+ repoPath,
+ repoId,
+ prNumber: 12,
+ headSha: undefined,
+ prRepo: null,
+ noCache: false
+ })
+ expect(mockApi.gh.prChecks).toHaveBeenNthCalledWith(2, {
+ repoPath,
+ repoId,
+ prNumber: 12,
+ headSha: undefined,
+ prRepo: null,
+ noCache: false
+ })
+ })
+
+ it('keeps non-empty checks cache entries fresh for the normal checks TTL', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(1_000)
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-id'
+ const branch = 'feature/test'
+
+ mockApi.gh.prChecks.mockResolvedValue([
+ { name: 'build', status: 'completed', conclusion: 'success', url: null }
+ ])
+
+ await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+ vi.setSystemTime(11_001)
+ await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+
+ expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(1)
+ })
+
+ it('dedupes simultaneous cacheable empty checks requests', async () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-id'
+ const branch = 'feature/test'
+ const request = deferred()
+ mockApi.gh.prChecks.mockReturnValueOnce(request.promise)
+
+ const first = store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+ const second = store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+
+ expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(1)
+ request.resolve([])
+
+ await expect(first).resolves.toEqual([])
+ await expect(second).resolves.toEqual([])
+ })
+
+ it('does not dedupe forced checks onto an in-flight cacheable request', async () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-id'
+ const branch = 'feature/test'
+ const cacheableRequest = deferred()
+ const forcedChecks = [
+ { name: 'build', status: 'completed', conclusion: 'success', url: null } as const
+ ]
+ mockApi.gh.prChecks
+ .mockReturnValueOnce(cacheableRequest.promise)
+ .mockResolvedValueOnce(forcedChecks)
+
+ const cacheable = store
+ .getState()
+ .fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+ const forced = store
+ .getState()
+ .fetchPRChecks(repoPath, 12, branch, undefined, null, { force: true, repoId })
+
+ expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(1)
+ cacheableRequest.resolve([])
+
+ await expect(cacheable).resolves.toEqual([])
+ await expect(forced).resolves.toEqual(forcedChecks)
+ expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(2)
+ expect(mockApi.gh.prChecks).toHaveBeenNthCalledWith(2, {
+ repoPath,
+ repoId,
+ prNumber: 12,
+ headSha: undefined,
+ prRepo: null,
+ noCache: true
+ })
+ })
+
+ it('dedupes simultaneous forced checks requests', async () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-id'
+ const branch = 'feature/test'
+ const request = deferred()
+ const checks = [
+ { name: 'build', status: 'completed', conclusion: 'success', url: null } as const
+ ]
+ mockApi.gh.prChecks.mockReturnValueOnce(request.promise)
+
+ const first = store
+ .getState()
+ .fetchPRChecks(repoPath, 12, branch, undefined, null, { force: true, repoId })
+ const second = store
+ .getState()
+ .fetchPRChecks(repoPath, 12, branch, undefined, null, { force: true, repoId })
+
+ expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(1)
+ request.resolve(checks)
+
+ await expect(first).resolves.toEqual(checks)
+ await expect(second).resolves.toEqual(checks)
+ expect(mockApi.gh.prChecks).toHaveBeenCalledWith({
+ repoPath,
+ repoId,
+ prNumber: 12,
+ headSha: undefined,
+ prRepo: null,
+ noCache: true
+ })
+ })
+
+ it('treats explicit noCache checks requests as fresh requests', async () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-id'
+ const branch = 'feature/test'
+
+ mockApi.gh.prChecks.mockResolvedValue([
+ { name: 'build', status: 'completed', conclusion: 'success', url: null }
+ ])
+
+ await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
+ await store
+ .getState()
+ .fetchPRChecks(repoPath, 12, branch, undefined, null, { noCache: true, repoId })
+
+ expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(2)
+ expect(mockApi.gh.prChecks).toHaveBeenNthCalledWith(2, {
+ repoPath,
+ repoId,
+ prNumber: 12,
+ headSha: undefined,
+ prRepo: null,
+ noCache: true
+ })
+ })
+
+ it('preserves cached checks when the checks IPC fails', async () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const branch = 'feature/test'
+ const checksCacheKey = `${repoPath}::pr-checks::12`
+ const cachedChecks = [
+ { name: 'build', status: 'completed', conclusion: 'failure', url: null } as const
+ ]
+
+ store.setState({
+ checksCache: {
+ [checksCacheKey]: {
+ data: cachedChecks,
+ fetchedAt: 1,
+ headSha: 'abc123head'
+ }
+ }
+ } as unknown as Partial)
+ mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited'))
+
+ await expect(
+ store.getState().fetchPRChecks(repoPath, 12, branch, 'abc123head', null, { force: true })
+ ).resolves.toEqual(cachedChecks)
+
+ expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(cachedChecks)
+ expect(store.getState().checksCache[checksCacheKey]?.fetchedAt).toBe(1)
+ })
+
+ it('does not return cached checks for a different requested head SHA after IPC failure', async () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const branch = 'feature/test'
+ const checksCacheKey = `${repoPath}::pr-checks::12`
+ const oldHeadChecks = [
+ { name: 'build', status: 'completed', conclusion: 'success', url: null } as const
+ ]
+
+ store.setState({
+ checksCache: {
+ [checksCacheKey]: {
+ data: oldHeadChecks,
+ fetchedAt: 1,
+ headSha: 'old-head'
+ }
+ }
+ } as unknown as Partial)
+ mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited'))
+
+ await expect(
+ store.getState().fetchPRChecks(repoPath, 12, branch, 'new-head', null, { force: true })
+ ).resolves.toEqual([])
+
+ expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(oldHeadChecks)
+ expect(store.getState().checksCache[checksCacheKey]?.headSha).toBe('old-head')
+ })
+})
+
+describe('createGitHubSlice.applyGitHubPRRefreshEvent checks cache reuse', () => {
+ it('does not derive neutral PR status from stale empty checks during refresh events', () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-1'
+ const branch = 'feature/stale-empty-checks'
+ const cacheKey = `${repoId}::${branch}`
+ const hostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId)
+ const checksCacheKey = `${repoId}::${prChecksCacheSuffix(12, null, 'head-oid')}`
+
+ store.setState({
+ checksCache: {
+ [checksCacheKey]: {
+ data: [],
+ fetchedAt: 1_000,
+ headSha: 'head-oid'
+ }
+ }
+ } as unknown as Partial)
+
+ store.getState().applyGitHubPRRefreshEvent({
+ sequence: 1,
+ aliases: [{ cacheKey, repoId, repoPath, branch }],
+ reason: 'visible',
+ outcome: {
+ kind: 'found',
+ pr: makePR({ number: 12, checksStatus: 'pending', headSha: 'head-oid' }),
+ fetchedAt: 21_000
+ }
+ })
+
+ expect(store.getState().prCache[cacheKey]).toMatchObject({
+ data: expect.objectContaining({ checksStatus: 'pending' }),
+ fetchedAt: 21_000
+ })
+ expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({
+ data: expect.objectContaining({ provider: 'github', status: 'pending' }),
+ fetchedAt: 21_000
+ })
+ })
+
+ it('reuses fresh head-specific checks cache entries during refresh events', () => {
+ const store = createTestStore()
+ const repoPath = '/repo'
+ const repoId = 'repo-1'
+ const branch = 'feature/fresh-head-checks'
+ const cacheKey = `${repoId}::${branch}`
+ const hostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId)
+ const checksCacheKey = `${repoId}::${prChecksCacheSuffix(12, null, 'head-oid')}`
+
+ store.setState({
+ checksCache: {
+ [checksCacheKey]: {
+ data: [{ name: 'build', status: 'completed', conclusion: 'success', url: null }],
+ fetchedAt: 20_000,
+ headSha: 'head-oid'
+ }
+ }
+ } as unknown as Partial)
+
+ store.getState().applyGitHubPRRefreshEvent({
+ sequence: 1,
+ aliases: [{ cacheKey, repoId, repoPath, branch }],
+ reason: 'visible',
+ outcome: {
+ kind: 'found',
+ pr: makePR({ number: 12, checksStatus: 'pending', headSha: 'head-oid' }),
+ fetchedAt: 21_000
+ }
+ })
+
+ expect(store.getState().prCache[cacheKey]).toMatchObject({
+ data: expect.objectContaining({ checksStatus: 'success' }),
+ fetchedAt: 21_000
+ })
+ expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({
+ data: expect.objectContaining({ provider: 'github', status: 'success' }),
+ fetchedAt: 21_000
+ })
+ })
+})
diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts
index 10d9bf92fce..c45ecf55801 100644
--- a/src/renderer/src/store/slices/github.test.ts
+++ b/src/renderer/src/store/slices/github.test.ts
@@ -847,62 +847,6 @@ describe('createGitHubSlice.fetchPRComments', () => {
vi.useRealTimers()
}
})
-
- it('preserves cached checks when the checks IPC fails', async () => {
- const store = createTestStore()
- const repoPath = '/repo'
- const branch = 'feature/test'
- const checksCacheKey = `${repoPath}::pr-checks::12`
- const cachedChecks = [
- { name: 'build', status: 'completed', conclusion: 'failure', url: null } as const
- ]
-
- store.setState({
- checksCache: {
- [checksCacheKey]: {
- data: cachedChecks,
- fetchedAt: 1,
- headSha: 'abc123head'
- }
- }
- } as unknown as Partial)
- mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited'))
-
- await expect(
- store.getState().fetchPRChecks(repoPath, 12, branch, 'abc123head', null, { force: true })
- ).resolves.toEqual(cachedChecks)
-
- expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(cachedChecks)
- expect(store.getState().checksCache[checksCacheKey]?.fetchedAt).toBe(1)
- })
-
- it('does not return cached checks for a different requested head SHA after IPC failure', async () => {
- const store = createTestStore()
- const repoPath = '/repo'
- const branch = 'feature/test'
- const checksCacheKey = `${repoPath}::pr-checks::12`
- const oldHeadChecks = [
- { name: 'build', status: 'completed', conclusion: 'success', url: null } as const
- ]
-
- store.setState({
- checksCache: {
- [checksCacheKey]: {
- data: oldHeadChecks,
- fetchedAt: 1,
- headSha: 'old-head'
- }
- }
- } as unknown as Partial)
- mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited'))
-
- await expect(
- store.getState().fetchPRChecks(repoPath, 12, branch, 'new-head', null, { force: true })
- ).resolves.toEqual([])
-
- expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(oldHeadChecks)
- expect(store.getState().checksCache[checksCacheKey]?.headSha).toBe('old-head')
- })
})
describe('createGitHubSlice.fetchPRCheckDetails', () => {
diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts
index add36b6cbfc..7aaf6a21e07 100644
--- a/src/renderer/src/store/slices/github.ts
+++ b/src/renderer/src/store/slices/github.ts
@@ -427,6 +427,7 @@ function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolea
const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed)
const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently
+const EMPTY_CHECKS_CACHE_TTL = 10_000
// Why: the NewWorkspace page's work-item list is a browse surface, not a
// source of truth, so 60s staleness is fine — stale data renders instantly
// while a background refresh keeps it current.
@@ -441,7 +442,11 @@ const inflightPRRequests = new Map<
{ promise: Promise; force: boolean; generation: number; lookupHintKey: string }
>()
const inflightIssueRequests = new Map>()
-const inflightChecksRequests = new Map>()
+type InflightChecksRequest = {
+ promise: Promise
+ noCache: boolean
+}
+const inflightChecksRequests = new Map()
const inflightCommentsRequests = new Map>()
type InflightWorkItems = {
promise: Promise
@@ -654,6 +659,10 @@ function isFresh(entry: CacheEntry | undefined, ttl = CACHE_TTL): entry is
return entry !== undefined && Date.now() - entry.fetchedAt < ttl
}
+function checksCacheTtl(entry: CacheEntry | undefined): number {
+ return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_TTL
+}
+
function findWorktreeById(state: AppState, worktreeId: string): Worktree | null {
for (const worktrees of Object.values(state.worktreesByRepo)) {
const worktree = worktrees.find((w) => w.id === worktreeId)
@@ -2411,10 +2420,11 @@ export const createGitHubSlice: StateCreator = (s
)
: cacheKey
const inflightKey = cacheKey
+ const requestNoCache = options?.noCache === true || options?.force === true
const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey]
if (
- !options?.force &&
- isFresh(cached, CHECKS_CACHE_TTL) &&
+ !requestNoCache &&
+ isFresh(cached, checksCacheTtl(cached)) &&
(!headSha || cached.headSha === headSha)
) {
const cachedChecks = cached.data ?? []
@@ -2438,7 +2448,16 @@ export const createGitHubSlice: StateCreator = (s
const inflightRequest = inflightChecksRequests.get(inflightKey)
if (inflightRequest) {
- return inflightRequest
+ if (!requestNoCache || inflightRequest.noCache) {
+ return inflightRequest.promise
+ }
+ // Why: manual refreshes must not inherit an automatic request that may
+ // still be served from the GitHub CLI cache. Wait, then issue fresh.
+ await inflightRequest.promise.catch(() => {})
+ const latestInflightRequest = inflightChecksRequests.get(inflightKey)
+ if (latestInflightRequest?.noCache) {
+ return latestInflightRequest.promise
+ }
}
const request = (async () => {
@@ -2453,7 +2472,7 @@ export const createGitHubSlice: StateCreator = (s
prNumber,
headSha,
prRepo: prRepo ?? null,
- noCache: options?.force
+ noCache: requestNoCache
},
{ timeoutMs: 30_000 }
)
@@ -2463,7 +2482,7 @@ export const createGitHubSlice: StateCreator = (s
prNumber,
headSha,
prRepo: prRepo ?? null,
- noCache: options?.force
+ noCache: requestNoCache
})) as PRCheckDetail[])
set((s) => {
const nextState: Partial = {
@@ -2505,7 +2524,7 @@ export const createGitHubSlice: StateCreator = (s
}
})()
- inflightChecksRequests.set(inflightKey, request)
+ inflightChecksRequests.set(inflightKey, { promise: request, noCache: requestNoCache })
return request
},
@@ -2924,6 +2943,17 @@ export const createGitHubSlice: StateCreator = (s
const checksCacheKeys = [
...(alias.repoId
? [
+ ...(pr.headSha
+ ? [
+ runtimeScopedRepoCacheKey(
+ alias.repoPath,
+ alias.repoId,
+ prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
+ s.settings,
+ alias.connectionId
+ )
+ ]
+ : []),
runtimeScopedRepoCacheKey(
alias.repoPath,
alias.repoId,
@@ -2933,6 +2963,17 @@ export const createGitHubSlice: StateCreator = (s
)
]
: []),
+ ...(pr.headSha
+ ? [
+ runtimeScopedRepoCacheKey(
+ alias.repoPath,
+ undefined,
+ prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
+ s.settings,
+ alias.connectionId
+ )
+ ]
+ : []),
runtimeScopedRepoCacheKey(
alias.repoPath,
undefined,
@@ -2944,14 +2985,15 @@ export const createGitHubSlice: StateCreator = (s
]
const checksEntry = checksCacheKeys
.map((key) => s.checksCache[key])
- .find((entry) => entry?.data)
- if (
- checksEntry?.data &&
- checksEntry.headSha &&
- pr.headSha &&
- checksEntry.headSha === pr.headSha &&
- event.outcome.fetchedAt - checksEntry.fetchedAt < CHECKS_CACHE_TTL
- ) {
+ .find(
+ (entry) =>
+ entry?.data &&
+ entry.headSha &&
+ pr.headSha &&
+ entry.headSha === pr.headSha &&
+ event.outcome.fetchedAt - entry.fetchedAt < checksCacheTtl(entry)
+ )
+ if (checksEntry?.data) {
return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) }
}
return pr
diff --git a/src/renderer/src/store/slices/pull-request-generation.ts b/src/renderer/src/store/slices/pull-request-generation.ts
index 6775a6fbb98..ed77d9f5169 100644
--- a/src/renderer/src/store/slices/pull-request-generation.ts
+++ b/src/renderer/src/store/slices/pull-request-generation.ts
@@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
+import type { GlobalSettings } from '../../../../shared/types'
import type { AppState } from '../types'
export type PullRequestFieldName = 'base' | 'title' | 'body' | 'draft'
@@ -11,6 +12,11 @@ export type PullRequestGenerationFields = {
draft: boolean
}
+export type PullRequestGenerationRuntimeTargetSettings = Pick<
+ GlobalSettings,
+ 'activeRuntimeEnvironmentId'
+>
+
export type PullRequestGenerationContext = {
worktreeId: string | null
worktreePath: string
@@ -18,6 +24,7 @@ export type PullRequestGenerationContext = {
requestId: number
repoId: string
branch: string
+ runtimeTargetSettings?: PullRequestGenerationRuntimeTargetSettings | null
}
export type PullRequestGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded'
@@ -43,6 +50,7 @@ export type PullRequestGenerationSlice = {
key: string,
updater: (record: PullRequestGenerationRecord | null) => PullRequestGenerationRecord | null
) => void
+ prunePullRequestGenerationRecords: (liveWorktreeKeys: ReadonlySet) => void
}
export function getPullRequestGenerationWorktreeKey(
@@ -214,5 +222,22 @@ export const createPullRequestGenerationSlice: StateCreator<
[key]: nextRecord
}
}
+ }),
+ prunePullRequestGenerationRecords: (liveWorktreeKeys) =>
+ set((state) => {
+ let changed = false
+ const nextRecords: PullRequestGenerationRecords = {}
+ for (const [key, record] of Object.entries(state.pullRequestGenerationRecords)) {
+ const worktreeKey = getPullRequestGenerationWorktreeKey(
+ record.context.worktreeId,
+ record.context.worktreePath
+ )
+ if (worktreeKey && liveWorktreeKeys.has(worktreeKey)) {
+ nextRecords[key] = record
+ } else {
+ changed = true
+ }
+ }
+ return changed ? { pullRequestGenerationRecords: nextRecords } : {}
})
})
diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts
index ce70ee77236..a65cb122a2c 100644
--- a/src/renderer/src/store/slices/store-test-helpers.ts
+++ b/src/renderer/src/store/slices/store-test-helpers.ts
@@ -38,6 +38,7 @@ import { createWorktreeNavHistorySlice } from './worktree-nav-history'
import { createDictationSlice } from './dictation'
import { createWorkspaceCleanupSlice } from './workspace-cleanup'
import { createPullRequestGenerationSlice } from './pull-request-generation'
+import { createCommitMessageGenerationSlice } from './commit-message-generation'
import { translate } from '@/i18n/i18n'
export const TEST_REPO = {
@@ -79,7 +80,8 @@ export function createTestStore() {
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a),
- ...createPullRequestGenerationSlice(...a)
+ ...createPullRequestGenerationSlice(...a),
+ ...createCommitMessageGenerationSlice(...a)
}))
}
diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts
index 7ef6ecec84d..e6152999f36 100644
--- a/src/renderer/src/store/types.ts
+++ b/src/renderer/src/store/types.ts
@@ -28,6 +28,7 @@ import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history'
import type { DictationSlice } from './slices/dictation'
import type { WorkspaceCleanupSlice } from './slices/workspace-cleanup'
import type { PullRequestGenerationSlice } from './slices/pull-request-generation'
+import type { CommitMessageGenerationSlice } from './slices/commit-message-generation'
export type AppState = RepoSlice &
SparsePresetsSlice &
@@ -58,4 +59,5 @@ export type AppState = RepoSlice &
WorktreeNavHistorySlice &
DictationSlice &
WorkspaceCleanupSlice &
- PullRequestGenerationSlice
+ PullRequestGenerationSlice &
+ CommitMessageGenerationSlice
diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts
index 340dd3147b9..541393b26b4 100644
--- a/src/renderer/src/web/web-preload-api.test.ts
+++ b/src/renderer/src/web/web-preload-api.test.ts
@@ -1215,6 +1215,42 @@ describe('web file preload API', () => {
})
})
+describe('web star nag preload API', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('keeps the browser-paired star nag API safe and in parity with the preload contract', async () => {
+ const { api } = await installApi('Linux')
+
+ expect(Object.keys(api.starNag).sort()).toEqual([
+ 'complete',
+ 'disable',
+ 'dismiss',
+ 'forceShow',
+ 'onShow',
+ 'openWeb',
+ 'starOrca'
+ ])
+
+ const listener = vi.fn()
+ const unsubscribe = api.starNag.onShow(listener)
+ unsubscribe()
+
+ await expect(api.starNag.dismiss()).resolves.toBeUndefined()
+ await expect(api.starNag.complete()).resolves.toBeUndefined()
+ await expect(api.starNag.disable()).resolves.toBeUndefined()
+ await expect(api.starNag.openWeb()).resolves.toBeUndefined()
+ await expect(api.starNag.forceShow()).resolves.toBeUndefined()
+ await expect(api.starNag.starOrca()).resolves.toBe(false)
+ expect(listener).not.toHaveBeenCalled()
+ })
+})
+
describe('web GitHub preload API', () => {
beforeEach(() => {
vi.resetModules()
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index d9b23fdb064..d7d422f6b59 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -170,6 +170,7 @@ function invalidateRuntimeWorktreeCaches(): void {
type WebSettingsApi = NonNullable
type WebKeybindingsApi = NonNullable
type WebGitHubApi = NonNullable
+type WebStarNagApi = NonNullable
type WebGitHubResult = Awaited>
type WebGitHubRouteKey =
| 'repoSlug'
@@ -555,6 +556,7 @@ function createWebPreloadApi(): Partial {
browser: createBrowserApi(),
emulator: createEmulatorApi(),
gh: createGitHubApi(),
+ starNag: createStarNagApi(),
gl: createGitLabApi(),
hostedReview: createRuntimeNamespaceApi('hostedReview'),
linear: createRuntimeNamespaceApi('linear'),
@@ -1573,6 +1575,18 @@ function createEmulatorApi(): NonNullable['emulator']> {
} as unknown as NonNullable['emulator']>
}
+function createStarNagApi(): WebStarNagApi {
+ return {
+ onShow: () => noopUnsubscribe,
+ dismiss: () => Promise.resolve(),
+ complete: () => Promise.resolve(),
+ disable: () => Promise.resolve(),
+ openWeb: () => Promise.resolve(),
+ starOrca: () => Promise.resolve(false),
+ forceShow: () => Promise.resolve()
+ }
+}
+
function createGitHubApi(): WebGitHubApi {
const route = (method: WebGitHubRuntimeMethod, args?: unknown): Promise =>
callRuntimeResult(method, mapRepoPathArg(args))
diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts
index c8229dd3935..05d2a1740f1 100644
--- a/src/shared/ai-vault-types.ts
+++ b/src/shared/ai-vault-types.ts
@@ -11,6 +11,7 @@ export const AI_VAULT_AGENTS = [
'rovo',
'copilot',
'opencode',
+ 'grok',
'openclaw',
'droid'
] as const satisfies readonly TuiAgent[]
@@ -30,6 +31,7 @@ export const AI_VAULT_AGENT_LABELS = {
rovo: 'Rovo Dev',
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
+ grok: 'Grok',
openclaw: 'OpenClaw',
droid: 'Droid'
} as const satisfies Record
@@ -140,6 +142,7 @@ function buildAgentResumeInvocation(
case 'claude':
case 'cursor':
case 'gemini':
+ case 'grok':
case 'hermes':
case 'openclaw':
case 'droid':
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 300dc3a9295..3378bd76c75 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -245,7 +245,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
httpProxyUrl: '',
httpProxyBypassRules: '',
electronHttp1CompatibilityMode: false,
- openLinksInApp: true,
+ openLinksInApp: false,
+ openLinksInAppPreferencePrompted: false,
openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS],
rightSidebarOpenByDefault: true,
showGitIgnoredFiles: true,
diff --git a/src/shared/star-nag-telemetry.ts b/src/shared/star-nag-telemetry.ts
new file mode 100644
index 00000000000..7f995d0c6f9
--- /dev/null
+++ b/src/shared/star-nag-telemetry.ts
@@ -0,0 +1,42 @@
+import { z } from 'zod'
+
+export const STAR_NAG_OUTCOMES = [
+ 'shown',
+ 'dismissed',
+ 'disabled',
+ 'star_attempted',
+ 'star_succeeded',
+ 'star_failed',
+ 'opened_web',
+ 'already_starred_suppressed'
+] as const
+
+export const STAR_NAG_PROMPT_SOURCES = ['threshold', 'force_show'] as const
+export const STAR_NAG_PROMPT_MODES = ['gh', 'web'] as const
+export const STAR_NAG_AGENT_BUCKETS = ['0-34', '35-69', '70-139', '140-279', '280+'] as const
+
+export const starNagOutcomeSchema = z.enum(STAR_NAG_OUTCOMES)
+export const starNagPromptSourceSchema = z.enum(STAR_NAG_PROMPT_SOURCES)
+export const starNagPromptModeSchema = z.enum(STAR_NAG_PROMPT_MODES)
+export const starNagAgentBucketSchema = z.enum(STAR_NAG_AGENT_BUCKETS)
+
+export type StarNagOutcome = z.infer
+export type StarNagPromptSource = z.infer
+export type StarNagPromptMode = z.infer
+export type StarNagAgentBucket = z.infer
+
+export function bucketStarNagAgentsSinceBaseline(agentsSinceBaseline: number): StarNagAgentBucket {
+ if (agentsSinceBaseline < 35) {
+ return '0-34'
+ }
+ if (agentsSinceBaseline < 70) {
+ return '35-69'
+ }
+ if (agentsSinceBaseline < 140) {
+ return '70-139'
+ }
+ if (agentsSinceBaseline < 280) {
+ return '140-279'
+ }
+ return '280+'
+}
diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts
index 9d32a1bff30..f895ab957e3 100644
--- a/src/shared/telemetry-events.test.ts
+++ b/src/shared/telemetry-events.test.ts
@@ -158,6 +158,83 @@ describe('app_starred_orca schema', () => {
})
})
+describe('star_nag_outcome schema', () => {
+ const valid = {
+ outcome: 'shown',
+ source: 'threshold',
+ mode: 'gh',
+ threshold: 35,
+ agents_since_baseline: 35,
+ agents_since_baseline_bucket: '35-69',
+ nth_repo_added: 2
+ }
+
+ it('accepts a strict valid payload with cohort context', () => {
+ expect(eventSchemas.star_nag_outcome.safeParse(valid).success).toBe(true)
+ })
+
+ it('is in the runtime cohort-injection roster', () => {
+ expect(isCohortExtendedEvent('star_nag_outcome')).toBe(true)
+ })
+
+ it('accepts next_threshold only as a positive integer', () => {
+ expect(
+ eventSchemas.star_nag_outcome.safeParse({
+ ...valid,
+ outcome: 'dismissed',
+ next_threshold: 70
+ }).success
+ ).toBe(true)
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, next_threshold: 0 }).success).toBe(
+ false
+ )
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, next_threshold: 1.5 }).success).toBe(
+ false
+ )
+ expect(
+ eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome: 'shown', next_threshold: 70 })
+ .success
+ ).toBe(false)
+ })
+
+ it('rejects unknown outcome source mode and bucket values', () => {
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome: 'ignored' }).success).toBe(
+ false
+ )
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, source: 'renderer' }).success).toBe(
+ false
+ )
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, mode: 'desktop' }).success).toBe(
+ false
+ )
+ expect(
+ eventSchemas.star_nag_outcome.safeParse({
+ ...valid,
+ agents_since_baseline_bucket: '35+'
+ }).success
+ ).toBe(false)
+ })
+
+ it('rejects malformed numeric fields and raw extra fields', () => {
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, threshold: -1 }).success).toBe(false)
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, threshold: 1.5 }).success).toBe(
+ false
+ )
+ expect(
+ eventSchemas.star_nag_outcome.safeParse({ ...valid, agents_since_baseline: -1 }).success
+ ).toBe(false)
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, nth_repo_added: -1 }).success).toBe(
+ false
+ )
+ expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, error: 'gh failed' }).success).toBe(
+ false
+ )
+ expect(
+ eventSchemas.star_nag_outcome.safeParse({ ...valid, url: 'https://github.com' }).success
+ ).toBe(false)
+ })
+})
+
describe('agent_error schema', () => {
it('round-trips a minimal {error_class, agent_kind} payload', () => {
const parsed = eventSchemas.agent_error.safeParse({
diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts
index e2a6f75b5c0..7de381fd6ae 100644
--- a/src/shared/telemetry-events.ts
+++ b/src/shared/telemetry-events.ts
@@ -34,6 +34,12 @@ import {
import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers'
import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source'
import { appStarSourceSchema } from './gh-star-source'
+import {
+ starNagAgentBucketSchema,
+ starNagOutcomeSchema,
+ starNagPromptModeSchema,
+ starNagPromptSourceSchema
+} from './star-nag-telemetry'
import {
NESTED_REPO_COUNT_BUCKETS,
NESTED_REPO_IMPORT_ACTIONS,
@@ -349,6 +355,23 @@ const appStarredOrcaSchema = z
})
.strict()
+const starNagOutcomeEventSchema = z
+ .object({
+ outcome: starNagOutcomeSchema,
+ source: starNagPromptSourceSchema,
+ mode: starNagPromptModeSchema,
+ threshold: z.number().int().positive(),
+ agents_since_baseline: z.number().int().nonnegative(),
+ agents_since_baseline_bucket: starNagAgentBucketSchema,
+ nth_repo_added: nthRepoAddedSchema,
+ next_threshold: z.number().int().positive().optional()
+ })
+ .strict()
+ .refine((payload) => payload.next_threshold === undefined || payload.outcome === 'dismissed', {
+ message: 'next_threshold is only valid for dismissed outcomes',
+ path: ['next_threshold']
+ })
+
const workspaceCreatedSchema = z
.object({
source: workspaceSourceSchema,
@@ -1291,6 +1314,7 @@ const terminalPaneSplitSchema = z
export const eventSchemas = {
app_opened: appOpenedSchema,
app_starred_orca: appStarredOrcaSchema,
+ star_nag_outcome: starNagOutcomeEventSchema,
feature_interaction_usage_bucket_reached: featureInteractionUsageBucketReachedSchema,
repo_added: repoAddedSchema,
@@ -1417,6 +1441,7 @@ export const COHORT_EXTENDED: readonly EventName[] = Array.from(COHORT_EXTENDED_
type _CohortExtendedRoster =
| 'app_opened'
| 'app_starred_orca'
+ | 'star_nag_outcome'
| 'feature_interaction_usage_bucket_reached'
| 'repo_added'
| 'add_repo_setup_step_action'
diff --git a/src/shared/types.ts b/src/shared/types.ts
index edc2e995db9..b2ad8e9ff2a 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2185,6 +2185,9 @@ export type GlobalSettings = {
* The setting stays opt-in so existing workflows continue to use the system browser
* until the user explicitly wants worktree-scoped in-app browsing. */
openLinksInApp: boolean
+ /** Why: terminal link routing asks once at first use instead of silently
+ * changing where links open for new users. */
+ openLinksInAppPreferencePrompted: boolean
/** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */
openInApplications?: OpenInApplication[]
/** Deprecated: migration/backward-compat only. Use PersistedUIState.rightSidebarOpen. */
diff --git a/tests/e2e/source-control-pr-generation-switch.spec.ts b/tests/e2e/source-control-pr-generation-switch.spec.ts
index 189338d7d0d..a41a53bf0d5 100644
--- a/tests/e2e/source-control-pr-generation-switch.spec.ts
+++ b/tests/e2e/source-control-pr-generation-switch.spec.ts
@@ -140,6 +140,67 @@ test.describe('Source Control AI PR generation worktree switching', () => {
})
})
+ test('hydrates pending PR generation after Source Control remounts', async ({
+ orcaPage
+ }, testInfo) => {
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ const { prWorktreeId, prWorktreePath, primaryBranch } = await seedCreatePrComposer(orcaPage)
+ createBranchCommit(prWorktreePath)
+
+ const screenshotDir = path.join(
+ process.cwd(),
+ 'validation-screenshots',
+ `pr-generation-remount-${Date.now()}`
+ )
+ mkdirSync(screenshotDir, { recursive: true })
+ await testInfo.attach('validation-screenshot-dir', {
+ body: screenshotDir,
+ contentType: 'text/plain'
+ })
+ const generatorScriptPath = path.join(screenshotDir, 'delayed-pr-generator.cjs')
+ const callLogPath = path.join(screenshotDir, 'delayed-pr-generator.log')
+ await installDelayedPrGenerator(orcaPage, generatorScriptPath, callLogPath, primaryBranch)
+
+ await openSourceControl(orcaPage, prWorktreeId)
+ const generate = orcaPage.getByRole('button', {
+ name: 'Generate pull request details with AI'
+ })
+ await expect(generate).toBeVisible({ timeout: 10_000 })
+ await expect(generate).toBeEnabled()
+ await generate.click()
+ await expect(
+ orcaPage.getByRole('button', { name: 'Stop generating pull request details' })
+ ).toBeVisible()
+ await expect.poll(() => readLog(callLogPath)).toContain('start')
+
+ await orcaPage.evaluate(() => {
+ window.__store?.getState().setRightSidebarTab('explorer')
+ })
+ await expect(
+ orcaPage.getByRole('button', { name: 'Stop generating pull request details' })
+ ).toHaveCount(0)
+ await expect
+ .poll(() => readFileSync(callLogPath, 'utf8'), { timeout: 10_000 })
+ .toContain('finish')
+
+ await openSourceControl(orcaPage, prWorktreeId)
+ await expect(orcaPage.getByRole('textbox', { name: 'Pull request title' })).toHaveValue(
+ 'Generated PR title after switch',
+ { timeout: 10_000 }
+ )
+ await expect(orcaPage.getByRole('textbox', { name: 'Pull request description' })).toHaveValue(
+ 'Generated PR body after switch'
+ )
+ await orcaPage.screenshot({
+ path: path.join(screenshotDir, '01-remounted-source-control-hydrated-pr-fields.png')
+ })
+ await writeEvidence(testInfo, screenshotDir, 'pr-generation-remount-evidence.json', {
+ expectedOriginalWorktreeId: prWorktreeId,
+ generatorLog: readLog(callLogPath)
+ })
+ })
+
test('keeps pending commit message generation attached to its original worktree', async ({
orcaPage
}, testInfo) => {
@@ -242,6 +303,73 @@ test.describe('Source Control AI PR generation worktree switching', () => {
})
})
+ test('hydrates pending commit message generation after Source Control remounts', async ({
+ orcaPage
+ }, testInfo) => {
+ await waitForSessionReady(orcaPage)
+ await waitForActiveWorktree(orcaPage)
+ const { commitWorktreeId, commitWorktreePath } = await seedCommitMessageComposer(orcaPage)
+ createStagedCommitMessageChange(commitWorktreePath)
+
+ const screenshotDir = path.join(
+ process.cwd(),
+ 'validation-screenshots',
+ `commit-message-generation-remount-${Date.now()}`
+ )
+ mkdirSync(screenshotDir, { recursive: true })
+ await testInfo.attach('validation-screenshot-dir', {
+ body: screenshotDir,
+ contentType: 'text/plain'
+ })
+ const generatorScriptPath = path.join(screenshotDir, 'delayed-commit-generator.cjs')
+ const callLogPath = path.join(screenshotDir, 'delayed-commit-generator.log')
+ await installDelayedCommitMessageGenerator(orcaPage, generatorScriptPath, callLogPath)
+
+ await openSourceControl(orcaPage, commitWorktreeId)
+ const generate = orcaPage.getByRole('button', {
+ name: 'Generate commit message with AI'
+ })
+ await expect(generate).toBeVisible({ timeout: 10_000 })
+ await expect(generate).toBeEnabled()
+ await generate.click()
+ await expect(
+ orcaPage.getByRole('button', { name: 'Stop generating commit message' })
+ ).toBeVisible()
+ await expect.poll(() => readLog(callLogPath)).toContain('start')
+
+ await orcaPage.evaluate(() => {
+ window.__store?.getState().setRightSidebarTab('explorer')
+ })
+ await expect(
+ orcaPage.getByRole('button', { name: 'Stop generating commit message' })
+ ).toHaveCount(0)
+ await expect
+ .poll(() => readFileSync(callLogPath, 'utf8'), { timeout: 10_000 })
+ .toContain('finish')
+
+ await openSourceControl(orcaPage, commitWorktreeId)
+ await expect(orcaPage.getByRole('textbox', { name: 'Commit message' })).toHaveValue(
+ [
+ 'Generated commit message after switch',
+ '',
+ 'Generated from staged e2e-commit-message-generation.txt after switching worktrees'
+ ].join('\n'),
+ { timeout: 10_000 }
+ )
+ await orcaPage.screenshot({
+ path: path.join(screenshotDir, '01-remounted-source-control-hydrated-message.png')
+ })
+ await writeEvidence(
+ testInfo,
+ screenshotDir,
+ 'commit-message-generation-remount-evidence.json',
+ {
+ expectedOriginalWorktreeId: commitWorktreeId,
+ generatorLog: readLog(callLogPath)
+ }
+ )
+ })
+
test('hides the commit AI composer on a clean branch empty state', async ({
orcaPage
}, testInfo) => {