diff --git a/README.md b/README.md index 4f164b24ad6..fc2b643ac77 100644 --- a/README.md +++ b/README.md @@ -4,149 +4,230 @@

GitHub stars - Latest stable release + Total downloads across all releases License + Join the Orca Discord Supported platforms: macOS, Windows, and Linux

- 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

+

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

-

- Orca feature showcase cycling through parallel worktrees, terminal splits, design mode, GitHub and Linear workflows, CLI agents, and SSH worktrees -

- -## 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** + + Orca desktop with the mobile companion app +
-- **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) + + + Parallel worktree orchestration +
+ +### Terminal Splits + +Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback that survives restarts. + +[Docs →](https://www.onorca.dev/docs/terminal) + + + Terminal splits +
+ +### 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) + + + Embedded browser and 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) + + + GitHub and Linear task workflows in Orca +
+ +### 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) + + + Remote worktrees over 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) + + + Annotate AI-generated diffs +
+ +### 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) + + + Drag files and images into an agent prompt +
+ +### 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) + + + Script Orca from the CLI +
+ +**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 logo Claude Code   + Codex logo Codex   + Grok logo Grok   + Gemini logo Gemini   + Cursor logo Cursor   + GitHub Copilot logo GitHub Copilot   + OpenCode logo OpenCode   + Amp logo Amp   + OpenClaude logo OpenClaude   + Antigravity logo Antigravity   + Pi logo Pi   + oh-my-pi logo oh-my-pi   + Hermes Agent logo Hermes Agent   + Goose logo Goose   + Auggie logo Auggie   + Autohand Code logo Autohand Code   + Charm logo Charm   + Cline logo Cline   + Codebuff logo Codebuff   + Command Code logo Command Code   + Continue logo Continue   + Droid logo Droid   + Kilocode logo Kilocode   + Kimi logo Kimi   + Kiro logo Kiro   + Mistral Vibe logo Mistral Vibe   + Qwen Code logo Qwen Code   + Rovo Dev logo 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. - -

- Orca desktop with the mobile companion app -

- -- **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

Parallel worktree orchestration
   - Terminal Splits

Ghostty-class terminal splits


- Design Mode

Embedded browser and Design Mode
   - GitHub & Linear, Native

GitHub and Linear task workflows in Orca


- Every CLI Agent

Works with every CLI agent
   - SSH Worktrees

Remote worktrees over SSH


- Drag Files to Agents

Drag files and images into an agent prompt
   - Annotate AI Diffs

Annotate AI-generated diffs


- Orca CLI

Script Orca from the CLI
   - Native Search

Native search across Orca workflows


- Account Switcher & Usage Tracking

Account switching and usage tracking
   - Rich Repo Previews

Markdown, images, PDFs, and repo document previews


- Split Anything

Split panes for agents, terminals, browsers, and files
-

+- **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. + + + Orca contributors + + +## 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 @@

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

- 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 Orca

+

- Descargar 🐋 + La app de escritorio de Orca ejecutando agentes en worktrees paralelos, con la app companion móvil de Orca en la esquina

-

- Captura de Orca -

- -## 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) + + + Orca de escritorio con la app companion móvil +
+ +### 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) + + + Orquestación de worktrees en paralelo +
+ +### 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) + + + Terminales divididas +
+ +### 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) + + + Navegador integrado y modo diseño +
+ +### 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) + + + Flujos de trabajo de GitHub y Linear en Orca +
+ +### 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) + + + Worktrees remotos por 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) + + + Anotar diffs generados por IA +
+ +### 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) + + + Arrastra archivos e imágenes al prompt de un agente +
+ +### 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) + + + Automatiza Orca desde la CLI +
+ +**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 logo Claude Code   + Codex logo Codex   + Grok logo Grok   + Gemini logo Gemini   + Cursor logo Cursor   + GitHub Copilot logo GitHub Copilot   + OpenCode logo OpenCode   + Amp logo Amp   + OpenClaude logo OpenClaude   + Antigravity logo Antigravity   + Pi logo Pi   + oh-my-pi logo oh-my-pi   + Hermes Agent logo Hermes Agent   + Goose logo Goose   + Auggie logo Auggie   + Autohand Code logo Autohand Code   + Charm logo Charm   + Cline logo Cline   + Codebuff logo Codebuff   + Command Code logo Command Code   + Continue logo Continue   + Droid logo Droid   + Kilocode logo Kilocode   + Kimi logo Kimi   + Kiro logo Kiro   + Mistral Vibe logo Mistral Vibe   + Qwen Code logo Qwen Code   + Rovo Dev logo 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. - -

- Orca de escritorio con la app companion móvil -

+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

Orquestación de worktrees en paralelo
   - Terminales divididas

Terminales divididas de nivel Ghostty


- Modo diseño

Navegador integrado y modo diseño
   - GitHub y Linear nativos

Flujos de GitHub y Linear en Orca


- Cualquier agente CLI

Compatible con cualquier agente CLI
   - Worktrees por SSH

Worktrees remotos por SSH


- Archivos a agentes

Arrastra archivos e imágenes al prompt de un agente
   - Anotar diffs de IA

Anotar diffs generados por IA


- Orca CLI

Automatiza Orca desde la CLI
   - Búsqueda nativa

Búsqueda nativa en los flujos de Orca


- Cambio de cuenta y seguimiento de uso

Cambio de cuenta y seguimiento de uso
   - Previews ricos del repo

Previsualización de Markdown, imágenes, PDFs y documentos del repo


- Divide cualquier cosa

Paneles divididos para agentes, terminales, navegadores y archivos
-

+- **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). + + + Contribuidores de Orca + + +## 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 @@

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

- 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 をダウンロード

+

- ダウンロード 🐋 + 並列ワークツリーでエージェントを実行する Orca デスクトップアプリと、隅に表示された Orca モバイル companion アプリ

-

- Orca Screenshot -

- -## 対応するエージェント - -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) + + + Orca デスクトップとモバイル companion アプリ +
+ +### 並列ワークツリー + +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) + + + Orca の GitHub と Linear タスクワークフロー +
+ +### SSH ワークツリー + +強力なリモートマシン上でエージェントを実行 — ファイル編集、git、ターミナルをフルに使え、自動再接続とポートフォワーディングも付属します。 + +[ドキュメント →](https://www.onorca.dev/docs/ssh) + + + SSH 経由のリモートワークツリー +
+ +### AI Diff に注釈 + +任意の Diff 行にコメントを付けてエージェントへ送り返せます — Orca から離れずにレビュー、編集、コミットまで完結します。 + +[ドキュメント →](https://www.onorca.dev/docs/review/annotate-ai-diff) + + + 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) + + + CLI から Orca をスクリプト操作 +
+ +**さらに同梱:** + +- **[クイックオープン](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 logo Claude Code   + Codex logo Codex   + Grok logo Grok   + Gemini logo Gemini   + Cursor logo Cursor   + GitHub Copilot logo GitHub Copilot   + OpenCode logo OpenCode   + Amp logo Amp   + OpenClaude logo OpenClaude   + Antigravity logo Antigravity   + Pi logo Pi   + oh-my-pi logo oh-my-pi   + Hermes Agent logo Hermes Agent   + Goose logo Goose   + Auggie logo Auggie   + Autohand Code logo Autohand Code   + Charm logo Charm   + Cline logo Cline   + Codebuff logo Codebuff   + Command Code logo Command Code   + Continue logo Continue   + Droid logo Droid   + Kilocode logo Kilocode   + Kimi logo Kimi   + Kiro logo Kiro   + Mistral Vibe logo Mistral Vibe   + Qwen Code logo Qwen Code   + Rovo Dev logo 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 アプリ - -スマートフォンからエージェントを操作できます。 - -

- Orca デスクトップとモバイル 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) - ---- - -## 機能ショーケース - -各タイルをクリックすると、そのワークフローを確認できます。 - -

- 並列ワークツリー

並列ワークツリーのオーケストレーション
   - ターミナル分割

Ghostty クラスのターミナル分割


- デザインモード

組み込みブラウザとデザインモード
   - GitHub と Linear をネイティブに

Orca の GitHub と Linear ワークフロー


- 任意の CLI エージェント

任意の CLI エージェントに対応
   - SSH ワークツリー

SSH 経由のリモートワークツリー


- ファイルをエージェントへ

ファイルや画像をエージェントのプロンプトへドラッグ
   - AI Diff 注釈

AI が生成した Diff への注釈


- Orca CLI

CLI から Orca をスクリプト操作
   - ネイティブ検索

Orca ワークフロー全体のネイティブ検索


- アカウント切り替えと使用量トラッキング

アカウント切り替えと使用量トラッキング
   - リッチなリポジトリプレビュー

Markdown、画像、PDF、リポジトリ文書のプレビュー


- 何でも分割表示

エージェント、ターミナル、ブラウザ、ファイルの分割表示
-

+- **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 のコントリビューター + + +## ライセンス + +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 @@

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

- 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 다운로드

+

- 다운로드 🐋 + 병렬 worktree에서 에이전트를 실행 중인 Orca 데스크톱 앱과 한쪽 모서리에 보이는 Orca 모바일 companion 앱

-

- Orca 스크린샷 -

- -## 지원 에이전트 - -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) + + + Orca 데스크톱과 모바일 companion 앱 +
+ +### 병렬 Worktree + +하나의 프롬프트를 다섯 에이전트에 동시에 보내세요. 각 에이전트는 격리된 자체 git worktree에서 실행됩니다 — 결과를 비교하고 가장 좋은 것을 머지하세요. + +[문서 →](https://www.onorca.dev/docs/model/worktrees) + + + 병렬 worktree 오케스트레이션 +
+ +### 터미널 분할 + +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) + + + Orca의 GitHub 및 Linear 작업 워크플로 +
+ +### SSH Worktree + +강력한 원격 머신에서 에이전트를 실행하세요. 파일 편집, git, 터미널을 모두 지원하며 자동 재연결과 포트 포워딩도 포함됩니다. + +[문서 →](https://www.onorca.dev/docs/ssh) + + + SSH를 통한 원격 worktree +
+ +### AI Diff 주석 + +diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내세요 — Orca를 떠나지 않고 리뷰하고 수정하고 커밋할 수 있습니다. + +[문서 →](https://www.onorca.dev/docs/review/annotate-ai-diff) + + + 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) + + + CLI에서 Orca 스크립팅 +
+ +**그 밖에 기본으로 제공되는 기능:** + +- **[빠른 열기](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 logo Claude Code   + Codex logo Codex   + Grok logo Grok   + Gemini logo Gemini   + Cursor logo Cursor   + GitHub Copilot logo GitHub Copilot   + OpenCode logo OpenCode   + Amp logo Amp   + OpenClaude logo OpenClaude   + Antigravity logo Antigravity   + Pi logo Pi   + oh-my-pi logo oh-my-pi   + Hermes Agent logo Hermes Agent   + Goose logo Goose   + Auggie logo Auggie   + Autohand Code logo Autohand Code   + Charm logo Charm   + Cline logo Cline   + Codebuff logo Codebuff   + Command Code logo Command Code   + Continue logo Continue   + Droid logo Droid   + Kilocode logo Kilocode   + Kimi logo Kimi   + Kiro logo Kiro   + Mistral Vibe logo Mistral Vibe   + Qwen Code logo Qwen Code   + Rovo Dev logo 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 앱 - -휴대폰에서 에이전트를 제어하세요. - -

- Orca 데스크톱과 모바일 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

병렬 worktree 오케스트레이션
   - 터미널 분할

Ghostty급 터미널 분할


- 디자인 모드

내장 브라우저와 디자인 모드
   - GitHub 및 Linear 네이티브

Orca의 GitHub 및 Linear 워크플로


- 모든 CLI 에이전트

모든 CLI 에이전트 지원
   - SSH Worktree

SSH를 통한 원격 worktree


- 에이전트로 파일 드래그

파일과 이미지를 에이전트 프롬프트로 드래그
   - AI Diff 주석

AI가 생성한 diff에 주석 달기


- Orca CLI

CLI에서 Orca 스크립팅
   - 네이티브 검색

Orca 워크플로 전반의 네이티브 검색


- 계정 전환 및 사용량 추적

계정 전환 및 사용량 추적
   - 풍부한 리포지토리 미리보기

Markdown, 이미지, PDF, 리포지토리 문서 미리보기


- 무엇이든 분할

에이전트, 터미널, 브라우저, 파일을 위한 분할 패널
-

+- **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 기여자 + + +## 라이선스 + +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 @@

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

- 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

+

- 下载 🐋 + Orca 桌面应用在并行 worktree 中运行智能体,角落里是 Orca 移动 companion 应用

-

- Orca Screenshot -

- -## 支持的智能体 - -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) + + + Orca 桌面端与移动 companion 应用 +
+ +### 并行 Worktree + +把一个提示同时分发给五个智能体,每个都在自己隔离的 git worktree 中运行 — 比较结果,合并最佳方案。 + +[文档 →](https://www.onorca.dev/docs/model/worktrees) + + + 并行 worktree 编排 +
+ +### 终端分屏 + +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) + + + Orca 中的 GitHub 与 Linear 任务工作流 +
+ +### SSH Worktree + +在高性能远程机器上运行智能体,完整支持文件编辑、git 和终端 — 自动重连与端口转发一应俱全。 + +[文档 →](https://www.onorca.dev/docs/ssh) + + + 通过 SSH 使用远程 worktree +
+ +### 标注 AI Diff + +在任意 diff 行上添加评论并发回给智能体 — 评审、编辑、提交,全程无需离开 Orca。 + +[文档 →](https://www.onorca.dev/docs/review/annotate-ai-diff) + + + 标注 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) + + + 从 CLI 脚本化 Orca +
+ +**开箱即用的还有:** + +- **[快速打开](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 logo Claude Code   + Codex logo Codex   + Grok logo Grok   + Gemini logo Gemini   + Cursor logo Cursor   + GitHub Copilot logo GitHub Copilot   + OpenCode logo OpenCode   + Amp logo Amp   + OpenClaude logo OpenClaude   + Antigravity logo Antigravity   + Pi logo Pi   + oh-my-pi logo oh-my-pi   + Hermes Agent logo Hermes Agent   + Goose logo Goose   + Auggie logo Auggie   + Autohand Code logo Autohand Code   + Charm logo Charm   + Cline logo Cline   + Codebuff logo Codebuff   + Command Code logo Command Code   + Continue logo Continue   + Droid logo Droid   + Kilocode logo Kilocode   + Kimi logo Kimi   + Kiro logo Kiro   + Mistral Vibe logo Mistral Vibe   + Qwen Code logo Qwen Code   + Rovo Dev logo 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 应用 - -用手机控制你的智能体。 - -

- Orca 桌面端与移动 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

并行 worktree 编排
   - 终端分屏

Ghostty 级终端分屏


- 设计模式

内置浏览器与设计模式
   - GitHub 与 Linear 原生集成

Orca 中的 GitHub 与 Linear 工作流


- 任意 CLI 智能体

支持任意 CLI 智能体
   - SSH Worktree

通过 SSH 使用远程 worktree


- 拖文件给智能体

将文件和图片拖入智能体提示
   - 标注 AI Diff

标注 AI 生成的 diff


- Orca CLI

从 CLI 脚本化 Orca
   - 原生搜索

贯穿 Orca 工作流的原生搜索


- 账号切换与用量追踪

账号切换与用量追踪
   - 丰富仓库预览

Markdown、图片、PDF 和仓库文档预览


- 任意分屏

为智能体、终端、浏览器和文件分屏
-

+- **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 贡献者 + + +## 许可证 + +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: 'contextGrok 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')}
- -
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.' + )} + +

+
+
+
+ + + + + +
+
+
+ ) +} + +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) && ( -
+
{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. */} - + 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 ( -
+
@@ -5733,12 +6038,12 @@ export function ConflictSummaryCard({
-
+
+
+ +
) : 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({ ) @@ -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 ( <> - +
- - - + - - {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 ? ( - - + + + + + + + + + {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({ - - - - ) - return ( <> -
- - - - - - {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)} - -
- -
-
-
-
-
+ 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} + + + + + + + + ) + return ( +
+ + + + + + {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)} + +
+ +
+
+
+
+
+ ) +} 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) => {