Merge remote-tracking branch 'origin/main' into brennanb2025/linear-cli-v2

This commit is contained in:
brennanb2025
2026-06-12 11:46:28 -07:00
87 changed files with 5497 additions and 1292 deletions
+197 -108
View File
@@ -4,149 +4,230 @@
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub stars" /></a>
<img src="https://badgen.net/github/release/stablyai/orca/stable" alt="Latest stable release" />
<a href="https://github.com/stablyai/orca/releases"><img src="https://img.shields.io/github/downloads/stablyai/orca/total?label=downloads" alt="Total downloads across all releases" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
</p>
<p align="center">
<a href="README.md">English</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a>
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>The AI Orchestrator for 100x builders.</strong><br/>
Run Claude Code, OpenClaude, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Available for <strong>macOS, Windows, and Linux</strong>.
Run Claude Code, OpenClaude, Codex, or OpenCode side-by-side — each in its own worktree, tracked in one place.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Download Orca</ins></a></h3>
<p align="center">
<a href="#install"><strong>Download Orca</strong></a>
<img src="docs/assets/readme-hero.jpg" alt="Orca desktop app running agents in parallel worktrees, with the Orca mobile companion app in the corner" width="960" />
</p>
<p align="center">
<img src="docs/assets/readme-feature-showcase.gif" alt="Orca feature showcase cycling through parallel worktrees, terminal splits, design mode, GitHub and Linear workflows, CLI agents, and SSH worktrees" width="960" />
</p>
## Supported Agents
Orca supports any CLI agent (_not just this list_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## Features
**Run agents in parallel**
<table>
<tr>
<td width="50%" valign="middle">
- **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**
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="docs/assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="docs/assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop with the mobile companion app" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
- **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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="docs/assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/parallel-worktrees.jpg" alt="Parallel worktree orchestration" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Terminal Splits
Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback that survives restarts.
[Docs →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="docs/assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="docs/assets/feature-wall/terminal-splits.jpg" alt="Terminal splits" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="docs/assets/feature-wall/design-mode.gif" type="image/gif"><img src="docs/assets/feature-wall/design-mode.jpg" alt="Embedded browser and Design Mode" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="docs/assets/feature-wall/github-linear.gif" type="image/gif"><img src="docs/assets/feature-wall/github-linear.jpg" alt="GitHub and Linear task workflows in Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="docs/assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/ssh-worktrees.jpg" alt="Remote worktrees over SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="docs/assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="docs/assets/feature-wall/annotate-diff.jpg" alt="Annotate AI-generated diffs" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="docs/assets/feature-wall/file-drag.gif" type="image/gif"><img src="docs/assets/feature-wall/file-drag.jpg" alt="Drag files and images into an agent prompt" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**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 &amp; 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.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## 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.
<p align="center">
<picture><source srcset="docs/assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="docs/assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop with the mobile companion app" width="720" /></picture>
</p>
- **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.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Parallel Worktrees</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/parallel-worktrees.jpg" alt="Parallel worktree orchestration" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminal Splits</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="docs/assets/feature-wall/terminal-splits.jpg" alt="Ghostty-class terminal splits" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Design Mode</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/design-mode.gif" type="image/gif"><img src="docs/assets/feature-wall/design-mode.jpg" alt="Embedded browser and Design Mode" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub &amp; Linear, Native</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/github-linear.gif" type="image/gif"><img src="docs/assets/feature-wall/github-linear.jpg" alt="GitHub and Linear task workflows in Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Every CLI Agent</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/cli-agents.gif" type="image/gif"><img src="docs/assets/feature-wall/cli-agents.jpg" alt="Works with every CLI agent" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktrees</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/ssh-worktrees.jpg" alt="Remote worktrees over SSH" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Drag Files to Agents</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/file-drag.gif" type="image/gif"><img src="docs/assets/feature-wall/file-drag.jpg" alt="Drag files and images into an agent prompt" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Annotate AI Diffs</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="docs/assets/feature-wall/annotate-diff.jpg" alt="Annotate AI-generated diffs" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Native Search</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="docs/assets/feature-wall/keyboard-native.jpg" alt="Native search across Orca workflows" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Account Switcher &amp; Usage Tracking</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="docs/assets/feature-wall/codex-accounts.jpg" alt="Account switching and usage tracking" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Rich Repo Previews</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="docs/assets/feature-wall/markdown-editor.jpg" alt="Markdown, images, PDFs, and repo document previews" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Split Anything</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/split-screen.gif" type="image/gif"><img src="docs/assets/feature-wall/split-screen.jpg" alt="Split panes for agents, terminals, browsers, and files" width="390" /></picture><br/></kbd></a>
</p>
- **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 &amp; 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 &amp; 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.
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
</a>
## License
Orca is free and open source under the [MIT License](LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

+205 -102
View File
@@ -3,137 +3,231 @@
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="Plataformas compatibles" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Seguir en X" /></a>
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrellas en GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="https://img.shields.io/github/downloads/stablyai/orca/total?label=downloads" alt="Descargas totales en todas las versiones" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licencia" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Únete al Discord de Orca" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatibles: macOS, Windows y Linux" />
</p>
<p align="center">
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
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.<br/>
Disponible para <strong>macOS, Windows y Linux</strong>.
Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Descargar Orca</ins></a></h3>
<p align="center">
<a href="#instalación"><strong>Descargar 🐋</strong></a>
<img src="../assets/readme-hero.jpg" alt="La app de escritorio de Orca ejecutando agentes en worktrees paralelos, con la app companion móvil de Orca en la esquina" width="960" />
</p>
<p align="center">
<img src="../assets/file-drag.gif" alt="Captura de Orca" width="800" />
</p>
## Agentes compatibles
Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## 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.
<table>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Flujos de trabajo de GitHub y Linear en Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**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.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## 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.
<p align="center">
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="720" /></picture>
</p>
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.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Worktrees en paralelo</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminales divididas</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas de nivel Ghostty" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Modo diseño</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub y Linear nativos</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Flujos de GitHub y Linear en Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Cualquier agente CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="Compatible con cualquier agente CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>Worktrees por SSH</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Archivos a agentes</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Anotar diffs de IA</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Búsqueda nativa</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Búsqueda nativa en los flujos de Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Cambio de cuenta y seguimiento de uso</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="Cambio de cuenta y seguimiento de uso" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Previews ricos del repo</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Previsualización de Markdown, imágenes, PDFs y documentos del repo" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Divide cualquier cosa</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="Paneles divididos para agentes, terminales, navegadores y archivos" width="390" /></picture><br/></kbd></a>
</p>
- **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).
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Contribuidores de Orca" />
</a>
## Licencia
Orca es libre y de código abierto bajo la [Licencia MIT](../../LICENSE).
+205 -102
View File
@@ -3,137 +3,231 @@
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="対応プラットフォーム" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="X でフォロー" /></a>
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub スター数" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="https://img.shields.io/github/downloads/stablyai/orca/total?label=downloads" alt="全リリースの合計ダウンロード数" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="ライセンス" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca の Discord に参加" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="対応プラットフォーム: macOS、Windows、Linux" />
</p>
<p align="center">
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
Claude Code、OpenClaude、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。<br/>
<strong>macOS、Windows、Linux</strong> で利用できます。
Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca をダウンロード</ins></a></h3>
<p align="center">
<a href="#インストール"><strong>ダウンロード 🐋</strong></a>
<img src="../assets/readme-hero.jpg" alt="並列ワークツリーでエージェントを実行する Orca デスクトップアプリと、隅に表示された Orca モバイル companion アプリ" width="960" />
</p>
<p align="center">
<img src="../assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 対応するエージェント
Orca は任意の CLI エージェントに対応しています(_このリストに限定されません_)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## 機能
- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
- **GitHub 連携** — PR、Issue、Actions チェックが各ワークツリーに自動で紐づきます。
- **SSH サポート** — リモートマシンに接続し、Orca から直接エージェントを実行できます。
- **通知** — エージェントが完了したときや注意が必要なときに通知します。スレッドを未読にして後で戻ることもできます。
<table>
<tr>
<td width="50%" valign="middle">
### モバイル 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 並列ワークツリー
1 つのプロンプトを 5 つのエージェントに展開し、それぞれを独立した git ワークツリーで実行 — 結果を比較して、最良のものをマージできます。
[ドキュメント →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### ターミナル分割
WebGL レンダリング、無制限の分割、再起動後も残るスクロールバックを備えた Ghostty クラスのターミナル。
[ドキュメント →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="ターミナル分割" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### デザインモード
実際の Chromium ウィンドウで任意の UI 要素をクリックすると、その HTML、CSS、切り抜いたスクリーンショットがそのままエージェントのプロンプトに送られます。
[ドキュメント →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; Linear をネイティブに
PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意のタスクからワークツリーを開き、コンテキストスイッチなしでレビューできます。
[ドキュメント →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear タスクワークフロー" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH ワークツリー
強力なリモートマシン上でエージェントを実行 — ファイル編集、git、ターミナルをフルに使え、自動再接続とポートフォワーディングも付属します。
[ドキュメント →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### AI Diff に注釈
任意の Diff 行にコメントを付けてエージェントへ送り返せます — Orca から離れずにレビュー、編集、コミットまで完結します。
[ドキュメント →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### ファイルをエージェントへドラッグ
オートセーブが全面的に効く VS Code のエディタ — ファイルや画像をそのままエージェントのプロンプトへドラッグできます。
[ドキュメント →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
エージェント自身も Orca を操作できます — `orca worktree create`、`snapshot`、`click`、`fill` であらゆるワークフローをスクリプト化できます。
[ドキュメント →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="100%" /></picture></a>
</td>
</tr>
</table>
**さらに同梱:**
- **[クイックオープン](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 でも動きます。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## インストール
### 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 アプリ
スマートフォンからエージェントを操作できます。
<p align="center">
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="720" /></picture>
</p>
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **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)
---
## 機能ショーケース
各タイルをクリックすると、そのワークフローを確認できます。
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>並列ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>ターミナル分割</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty クラスのターミナル分割" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>デザインモード</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub と Linear をネイティブに</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear ワークフロー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意の CLI エージェント</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="任意の CLI エージェントに対応" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>ファイルをエージェントへ</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 注釈</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>ネイティブ検索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca ワークフロー全体のネイティブ検索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>アカウント切り替えと使用量トラッキング</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="アカウント切り替えと使用量トラッキング" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>リッチなリポジトリプレビュー</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、画像、PDF、リポジトリ文書のプレビュー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>何でも分割表示</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="エージェント、ターミナル、ブラウザ、ファイルの分割表示" width="390" /></picture><br/></kbd></a>
</p>
- **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) ガイドをご覧ください。
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca のコントリビューター" />
</a>
## ライセンス
Orca は [MIT License](../../LICENSE) の下で無料かつオープンソースです。
+205 -102
View File
@@ -3,137 +3,231 @@
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="지원 플랫폼" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="X에서 팔로우" /></a>
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub 스타" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="https://img.shields.io/github/downloads/stablyai/orca/total?label=downloads" alt="전체 릴리스 누적 다운로드 수" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="라이선스" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca Discord 참여" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="지원 플랫폼: macOS, Windows, Linux" />
</p>
<p align="center">
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
</p>
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
<strong>macOS, Windows, Linux</strong>에서 사용할 수 있습니다.
Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca 다운로드</ins></a></h3>
<p align="center">
<a href="#설치"><strong>다운로드 🐋</strong></a>
<img src="../assets/readme-hero.jpg" alt="병렬 worktree에서 에이전트를 실행 중인 Orca 데스크톱 앱과 한쪽 모서리에 보이는 Orca 모바일 companion 앱" width="960" />
</p>
<p align="center">
<img src="../assets/file-drag.gif" alt="Orca 스크린샷" width="800" />
</p>
## 지원 에이전트
Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정되지 않습니다_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## 기능
- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다.
- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다.
- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다.
- **GitHub 통합** — PR, issue, Actions 체크가 각 worktree에 자동으로 연결됩니다.
- **SSH 지원** — 원격 머신에 연결하고 Orca에서 직접 에이전트를 실행할 수 있습니다.
- **알림** — 에이전트가 완료되거나 주의가 필요할 때 알려줍니다. 스레드를 읽지 않음으로 표시해 나중에 다시 볼 수 있습니다.
<table>
<tr>
<td width="50%" valign="middle">
### 모바일 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 병렬 Worktree
하나의 프롬프트를 다섯 에이전트에 동시에 보내세요. 각 에이전트는 격리된 자체 git worktree에서 실행됩니다 — 결과를 비교하고 가장 좋은 것을 머지하세요.
[문서 →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 터미널 분할
WebGL 렌더링, 무한 분할, 재시작 후에도 유지되는 스크롤백을 갖춘 Ghostty급 터미널.
[문서 →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="터미널 분할" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 디자인 모드
실제 Chromium 창에서 UI 요소를 클릭하면 해당 HTML, CSS, 잘라낸 스크린샷이 에이전트 프롬프트로 바로 전송됩니다.
[문서 →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; Linear 네이티브
PR, issue, 프로젝트 보드를 앱 안에서 탐색하세요 — 어떤 작업에서든 worktree를 열고 컨텍스트 전환 없이 리뷰할 수 있습니다.
[문서 →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 작업 워크플로" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH Worktree
강력한 원격 머신에서 에이전트를 실행하세요. 파일 편집, git, 터미널을 모두 지원하며 자동 재연결과 포트 포워딩도 포함됩니다.
[문서 →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### AI Diff 주석
diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내세요 — Orca를 떠나지 않고 리뷰하고 수정하고 커밋할 수 있습니다.
[문서 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 에이전트로 파일 드래그
어디서나 자동 저장되는 VS Code 에디터 — 파일이나 이미지를 에이전트 프롬프트로 바로 드래그하세요.
[문서 →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
에이전트도 Orca를 조작할 수 있습니다 — `orca worktree create`, `snapshot`, `click`, `fill`로 모든 워크플로를 스크립팅하세요.
[문서 →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="100%" /></picture></a>
</td>
</tr>
</table>
**그 밖에 기본으로 제공되는 기능:**
- **[빠른 열기](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에서도 실행됩니다.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## 설치
### 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 앱
휴대폰에서 에이전트를 제어하세요.
<p align="center">
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="720" /></picture>
</p>
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **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)
---
## 기능 쇼케이스
타일을 클릭해 각 워크플로를 살펴보세요.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>병렬 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>터미널 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty급 터미널 분할" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>디자인 모드</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 및 Linear 네이티브</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 워크플로" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>모든 CLI 에이전트</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="모든 CLI 에이전트 지원" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>에이전트로 파일 드래그</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 주석</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>네이티브 검색</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca 워크플로 전반의 네이티브 검색" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>계정 전환 및 사용량 추적</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="계정 전환 및 사용량 추적" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>풍부한 리포지토리 미리보기</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown, 이미지, PDF, 리포지토리 문서 미리보기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>무엇이든 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="에이전트, 터미널, 브라우저, 파일을 위한 분할 패널" width="390" /></picture><br/></kbd></a>
</p>
- **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) 가이드를 확인하세요.
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 기여자" />
</a>
## 라이선스
Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
+205 -102
View File
@@ -3,149 +3,252 @@
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="支持的平台" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="在 X 上关注" /></a>
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub Star 数" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="https://img.shields.io/github/downloads/stablyai/orca/total?label=downloads" alt="所有版本的总下载量" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="许可证" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="加入 Orca Discord" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="支持的平台:macOS、Windows 和 Linux" />
</p>
<p align="center">
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
跨仓库并排运行 Claude Code、OpenClaude、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。<br/>
支持 <strong>macOS、Windows 和 Linux</strong>。
并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>下载 Orca</ins></a></h3>
<p align="center">
<a href="#安装"><strong>下载 🐋</strong></a>
<img src="../assets/readme-hero.jpg" alt="Orca 桌面应用在并行 worktree 中运行智能体,角落里是 Orca 移动 companion 应用" width="960" />
</p>
<p align="center">
<img src="../assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 支持的智能体
Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## 特性
- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。
- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
- **GitHub 集成** — PR、issue 和 Actions 检查会自动链接到对应的 worktree。
- **SSH 支持** — 连接远程机器,并直接从 Orca 在远程机器上运行智能体。
- **通知** — 智能体完成任务或需要关注时及时通知你。可将会话标记为未读,方便稍后返回处理。
<table>
<tr>
<td width="50%" valign="middle">
### 移动 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)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 并行 Worktree
把一个提示同时分发给五个智能体,每个都在自己隔离的 git worktree 中运行 — 比较结果,合并最佳方案。
[文档 →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 终端分屏
Ghostty 级终端,支持 WebGL 渲染、无限分屏,以及重启后依然保留的滚动历史。
[文档 →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="终端分屏" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 设计模式
在真实的 Chromium 窗口中点击任意 UI 元素,把它的 HTML、CSS 和裁剪好的截图直接发送到智能体的提示中。
[文档 →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; Linear 原生集成
在应用内浏览 PR、issue 和项目看板 — 从任意任务打开 worktree,无需切换上下文即可完成评审。
[文档 →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 任务工作流" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH Worktree
在高性能远程机器上运行智能体,完整支持文件编辑、git 和终端 — 自动重连与端口转发一应俱全。
[文档 →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 标注 AI Diff
在任意 diff 行上添加评论并发回给智能体 — 评审、编辑、提交,全程无需离开 Orca。
[文档 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 拖文件给智能体
VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智能体提示。
[文档 →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
智能体也能驱动 Orca — 用 `orca worktree create`、`snapshot`、`click` 和 `fill` 把每个工作流脚本化。
[文档 →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="100%" /></picture></a>
</td>
</tr>
</table>
**开箱即用的还有:**
- **[快速打开](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 里运行。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ 任何 CLI 智能体</kbd>
</p>
---
## 安装
### 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 应用
用手机控制你的智能体。
<p align="center">
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="720" /></picture>
</p>
与桌面应用配对,用手机监控并指挥你的智能体。
- **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)
---
## 功能展示
点击任意卡片了解对应工作流。
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>并行 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>终端分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty 级终端分屏" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>设计模式</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 与 Linear 原生集成</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 工作流" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意 CLI 智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="支持任意 CLI 智能体" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>拖文件给智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>标注 AI Diff</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>原生搜索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="贯穿 Orca 工作流的原生搜索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>账号切换与用量追踪</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="账号切换与用量追踪" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>丰富仓库预览</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、图片、PDF 和仓库文档预览" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>任意分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="为智能体、终端、浏览器和文件分屏" width="390" /></picture><br/></kbd></a>
</p>
- **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) 指南。
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 贡献者" />
</a>
## 许可证
Orca 是自由且开源的软件,遵循 [MIT 许可证](../../LICENSE)。
+62 -1
View File
@@ -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 (
<View style={styles.markdownState}>
@@ -469,6 +474,7 @@ function MarkdownReader({
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
@@ -479,7 +485,7 @@ function MarkdownReader({
// Save/Discard controls lifted instead of resizing that surface.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift,
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
@@ -948,6 +954,13 @@ export default function SessionScreen() {
const [activeHandle, setActiveHandle] = useState<string | null>(null)
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
const activeSessionTabIdRef = useRef<string | null>(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<ScrollView>(null)
const tabStripOffsetRef = useRef(0)
const tabStripViewportWidthRef = useRef(0)
const tabStripContentWidthRef = useRef(0)
const tabLayoutsRef = useRef<Map<string, { x: number; width: number }>>(new Map())
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(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. */}
<ScrollView
ref={tabStripRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabScroll}
contentContainerStyle={styles.tabContent}
keyboardShouldPersistTaps="handled"
scrollEventThrottle={16}
onScroll={(e) => {
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) => (
<Pressable
key={t.id}
style={[styles.tab, t.id === activeSessionTabId && styles.tabActive]}
onLayout={(e) => {
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()
@@ -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<WebView>(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 }) => {
@@ -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' });
})();
</script>
@@ -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()
})
})
@@ -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();
}`
@@ -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)
})
})
+40
View File
@@ -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)
}
+1 -1
View File
@@ -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",
@@ -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<AiVaultSession | null> {
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<void> {
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(/<user_query>\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(' '))
}
@@ -13,6 +13,7 @@ export type AiVaultScanOptions = {
copilotSessionsDir?: string
cursorProjectsDir?: string
opencodeStorageDir?: string
grokSessionsDir?: string
hermesSessionsDir?: string
rovoSessionsDir?: string
openclawStateDir?: string
+38
View File
@@ -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: '<user_info>context</user_info><user_query>Grok title</user_query>'
}
]
},
{ 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'"
)
+17 -4
View File
@@ -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':
@@ -82,6 +82,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
showTitlebarAppName: true,
+1
View File
@@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
localAccountRuntime: 'host',
localAccountWslDistro: null,
openLinksInApp: false,
openLinksInAppPreferencePrompted: false,
rightSidebarOpenByDefault: true,
sourceControlViewMode: 'list',
showTitlebarAppName: true,
+8
View File
@@ -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',
+1
View File
@@ -55,6 +55,7 @@ let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
'app_starred_orca',
'star_nag_outcome',
'feature_interaction_usage_bucket_reached'
])
+15
View File
@@ -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
+145
View File
@@ -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({
+57 -2
View File
@@ -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:')
)
}
+265 -7
View File
@@ -13,7 +13,15 @@ type TestWindow = {
webContents: { send: ReturnType<typeof vi.fn> }
}
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<boolean>()
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<boolean>()
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)
})
})
+126 -9
View File
@@ -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<EventProps<'star_nag_outcome'>, 'outcome' | 'next_threshold'>
type StarNagPromptSession = {
source: StarNagPromptSource
type StarNagPromptSession = StarNagPromptContext & {
starAttemptPromise?: Promise<boolean>
}
/**
@@ -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<boolean> {
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<boolean> {
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 })
+37
View File
@@ -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)
+2
View File
@@ -1627,6 +1627,8 @@ export type PreloadApi = {
dismiss: () => Promise<void>
complete: () => Promise<void>
disable: () => Promise<void>
openWeb: () => Promise<void>
starOrca: () => Promise<boolean>
forceShow: () => Promise<void>
}
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose —
+2
View File
@@ -1475,6 +1475,8 @@ const api = {
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'),
openWeb: (): Promise<void> => ipcRenderer.invoke('star-nag:openWeb'),
starOrca: (): Promise<boolean> => ipcRenderer.invoke('star-nag:starOrca'),
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
},
+4 -1
View File
@@ -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 {
>
<TooltipProvider delayDuration={400}>
<ConfirmationDialogProvider>
<WorkspacePortScanner enabled={workspaceSessionReady} />
<LinkRoutingPreferenceDialogProvider>
<WorkspacePortScanner enabled={workspaceSessionReady} />
{/* Why: leaf-mounted retention sync keeps agent-status retention
subscriptions from re-rendering the App tree. */}
<RetainedAgentsSyncGate />
@@ -2287,6 +2289,7 @@ function App(): React.JSX.Element {
>
<RecentTabSwitcher />
</RecoverableRenderErrorBoundary>
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
@@ -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({
<WorkItemStateBadge item={actionItem} />
</div>
<div className="grid gap-2">
<div className="grid gap-2 justify-items-start">
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
@@ -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({
) : (
<GitMerge className="size-3.5" />
)}
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: mergePresentation.label)}
<ChevronDown className="size-3 opacity-60" />
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: mergePresentation.label)}
</span>
<ChevronDown className="size-3 shrink-0 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -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({
) : (
<CircleDot className="size-3.5" />
)}
{nextState === 'closed'
? translate('auto.components.GitHubItemDialog.21860b58d0', 'Close pull request')
: translate('auto.components.GitHubItemDialog.ec5c4b3ab2', 'Reopen PR')}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{nextState === 'closed'
? translate('auto.components.GitHubItemDialog.21860b58d0', 'Close pull request')
: translate('auto.components.GitHubItemDialog.ec5c4b3ab2', 'Reopen PR')}
</span>
</Button>
</div>
</aside>
@@ -4896,12 +4907,12 @@ function GHEditSection({
) : null}
{hasAttachedWorkspace ? (
<DropdownMenu modal={false}>
<ButtonGroup className="w-full">
<ButtonGroup className="inline-flex w-auto max-w-full">
<Button
type="button"
size="sm"
onClick={handleOpenOrUseWorkspace}
className="flex-1 gap-1.5"
className="gap-1.5"
aria-label={translate(
'auto.components.GitHubItemDialog.84855fedd0',
'Open workspace attached to issue'
+22 -11
View File
@@ -84,6 +84,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 { SourceControlAgentActionDialog } from '@/components/right-sidebar/SourceControlAgentActionDialog'
import {
createGitHubChecksTabState,
@@ -3483,7 +3488,7 @@ function PRActionsPanel({
<WorkItemStateBadge item={actionItem} />
</div>
<div className="grid gap-2">
<div className="grid gap-2 justify-items-start">
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
@@ -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({
) : (
<GitMerge className="size-3.5" />
)}
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: mergePresentation.label)}
<ChevronDown className="size-3 opacity-60" />
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: mergePresentation.label)}
</span>
<ChevronDown className="size-3 shrink-0 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -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({
) : (
<CircleDot className="size-3.5" />
)}
{nextState === 'closed'
? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
: translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{nextState === 'closed'
? translate('auto.components.PullRequestPage.96d013ed28', 'Close pull request')
: translate('auto.components.PullRequestPage.9d5425918e', 'Reopen PR')}
</span>
</Button>
</div>
</aside>
+29 -13
View File
@@ -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')}
>
<X className="size-3.5" />
@@ -156,10 +160,22 @@ export function StarNagCard(): React.JSX.Element | null {
: translate('auto.components.StarNagCard.2d67b6c849', 'Star on GitHub')}
</Button>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleClose}>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleClose}
disabled={busy}
>
{translate('auto.components.StarNagCard.8c967b4d15', 'Not now')}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={handleDisable}>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleDisable}
disabled={busy}
>
{translate('auto.components.StarNagCard.73dfd4eb8d', "Don't ask again")}
</Button>
</div>
File diff suppressed because one or more lines are too long
@@ -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<boolean>
const PREVIEW_STORAGE_KEY = 'orca.previewLinkRoutingPreferenceDialog'
const PREVIEW_DEFAULT_STORAGE_KEY = `${PREVIEW_STORAGE_KEY}.default`
const LinkRoutingPreferenceDialogContext =
createContext<LinkRoutingPreferenceDialogContextValue | null>(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<LinkRoutingPreferenceDialogRequest[]>([])
const activeRequest = queue[0] ?? null
const activeRequestRef = useRef<LinkRoutingPreferenceDialogRequest | null>(activeRequest)
const setContextualToursBlockingSurfaceVisible = useAppStore(
(s) => s.setContextualToursBlockingSurfaceVisible
)
const lastDisplayedRequestRef = useRef<LinkRoutingPreferenceDialogRequest | null>(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<LinkRoutingPreferenceDialogContextValue>((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 (
<LinkRoutingPreferenceDialogContext.Provider value={requestPreference}>
{children}
<Dialog
open={activeRequest !== null}
onOpenChange={(open) => !open && settleActiveRequest(false)}
>
<DialogContent
showCloseButton={false}
overlayClassName="!z-[140]"
className="!z-[150] gap-4 p-0 sm:max-w-[520px]"
>
<div className="rounded-t-lg border-b border-border bg-muted/30 px-6 pt-5 pb-4">
<DialogHeader className="gap-3">
<div className="flex items-center justify-between gap-3">
<Badge variant="outline" className="bg-background/70 text-muted-foreground">
{translate(
'auto.components.link.routing.preference.dialog.badge',
'Terminal link'
)}
</Badge>
{displayedRequest?.options.preview ? (
<Badge variant="secondary">
{translate('auto.components.link.routing.preference.dialog.preview', 'Preview')}
</Badge>
) : null}
</div>
<div className="space-y-2">
<DialogTitle className="text-xl leading-tight">
{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?"
)}
</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
{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."
)}
</DialogDescription>
</div>
</DialogHeader>
</div>
<div className="space-y-3 px-6">
{displayHost ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{translate('auto.components.link.routing.preference.dialog.link.label', 'Link')}
</span>
<span className="rounded-md border border-border bg-muted/30 px-2 py-1 font-mono">
{displayHost}
</span>
</div>
) : null}
<div className="flex gap-2 rounded-lg border border-border bg-muted/20 p-3 text-xs leading-relaxed text-muted-foreground">
<Settings className="mt-0.5 size-3.5 shrink-0" />
<div className="space-y-1">
<p>
{translate(
'auto.components.link.routing.preference.dialog.orca.note',
'Orca can use imported cookies for logged-in sites.'
)}
</p>
<p>
{translate(
'auto.components.link.routing.preference.dialog.settings.note',
'Change this later in Settings → Browser.'
)}
</p>
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span>
{translate(
'auto.components.link.routing.preference.dialog.shortcut.note.prefix',
'When links open in Orca,'
)}
</span>
<ShortcutKeyCombo
keys={systemBrowserShortcutKeys}
keyCapClassName="min-w-0 px-1 py-0 text-[10px] shadow-none"
separatorClassName="text-[10px] text-muted-foreground"
/>
<span>
{translate(
'auto.components.link.routing.preference.dialog.shortcut.note.suffix',
'click opens system browser once.'
)}
</span>
</p>
</div>
</div>
</div>
<DialogFooter className="border-t border-border bg-muted/20 px-6 py-4 sm:justify-between">
<Button variant="outline" onClick={() => settleActiveRequest(false)}>
<ExternalLink className="size-4" />
{translate(
'auto.components.link.routing.preference.dialog.system.button',
'Use system browser'
)}
</Button>
<Button autoFocus onClick={() => settleActiveRequest(true)}>
{openLinksInAppDefault
? translate(
'auto.components.link.routing.preference.dialog.keep.orca.button',
'Keep Orca'
)
: translate(
'auto.components.link.routing.preference.dialog.orca.button',
'Open in Orca'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</LinkRoutingPreferenceDialogContext.Provider>
)
}
export function useLinkRoutingPreferenceDialog(): LinkRoutingPreferenceDialogContextValue {
const requestPreference = useContext(LinkRoutingPreferenceDialogContext)
if (!requestPreference) {
throw new Error(
'useLinkRoutingPreferenceDialog must be used inside LinkRoutingPreferenceDialogProvider'
)
}
return requestPreference
}
@@ -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<HTMLInputElement>(null)
const titleInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string>('')
@@ -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) && (
<ChecksList
@@ -25,6 +25,11 @@ import type { HostedReviewProvider } from '../../../../shared/hosted-review'
import { stripBaseRef } from './useCreatePullRequestDialogFields'
import type { DropdownActionKind, DropdownEntry } from './source-control-dropdown-items'
import { CreateHostedReviewComposerFields } from './CreateHostedReviewComposerFields'
import {
RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS,
RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS,
RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS
} from './right-sidebar-primary-action-layout'
const EMPTY_DROPDOWN_ITEMS: DropdownEntry[] = []
@@ -245,13 +250,17 @@ export function CreateHostedReviewComposer({
baseSameAsBranch={baseSameAsBranch}
/>
<div className="flex items-stretch pt-0.5">
<div className={cn(RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS, 'pt-0.5')}>
<Button
type="button"
size="xs"
disabled={createDisabled}
onClick={() => onPrimaryAction()}
className={cn('h-7 flex-1 px-3 text-xs', showDropdown && 'rounded-r-none')}
className={cn(
'h-7 px-3 text-xs',
showDropdown && 'rounded-r-none',
RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS
)}
title={createDisabledReason ?? primaryAction.title}
>
{isCreating ? (
@@ -259,12 +268,14 @@ export function CreateHostedReviewComposer({
) : (
<ReviewIcon className="size-3.5" />
)}
{getCreateButtonLabel({
isCreating,
pushBeforeCreate,
draft,
shortLabel: copy.shortLabel
})}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{getCreateButtonLabel({
isCreating,
pushBeforeCreate,
draft,
shortLabel: copy.shortLabel
})}
</span>
</Button>
{showDropdown ? (
<DropdownMenu>
@@ -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 (
<div className="space-y-1.5">
<TooltipProvider delayDuration={300}>
<div className="flex items-stretch">
<div className={RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS}>
<Tooltip>
<TooltipTrigger asChild>
{/* Why: wrapping in a <span> so the tooltip trigger receives pointer
events even when the merge button inside is disabled. */}
<span className={cn('flex flex-1', primaryMergeDisabled && 'cursor-not-allowed')}>
<span
className={cn(
'inline-flex min-w-0 max-w-full shrink',
primaryMergeDisabled && 'cursor-not-allowed'
)}
>
<Button
type="button"
size="xs"
className={cn(
'w-full rounded-r-none px-3 text-[11px]',
'rounded-r-none px-3 text-[11px]',
RIGHT_SIDEBAR_MERGE_PRIMARY_BUTTON_CLASS,
'bg-green-600 text-white hover:bg-green-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
@@ -126,14 +137,16 @@ export default function HostedReviewActions({
) : (
<GitMerge className="size-3.5" />
)}
{merging
? translate(
'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d',
'Working...'
)
: mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{merging
? translate(
'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d',
'Working...'
)
: mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
: (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)}
</span>
</Button>
</span>
</TooltipTrigger>
@@ -22,12 +22,12 @@ export function ClosedReviewActions({
onReopenReview: () => void
}): React.JSX.Element {
return (
<div className="space-y-1.5">
<div className="flex flex-col items-start gap-1.5">
<Button
type="button"
variant="outline"
size="xs"
className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={onReopenReview}
disabled={stateUpdating !== null}
>
@@ -64,7 +64,7 @@ export function MergedReviewActions({
type="button"
variant="secondary"
size="xs"
className="w-full cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="cursor-pointer text-[11px] hover:cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={onDeleteWorktree}
disabled={isDeletingWorktree}
>
@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest'
import { create } from 'zustand'
import {
createCommitMessageGenerationSlice,
createRunningCommitMessageGenerationRecord,
getCommitMessageGenerationRecordKey,
markCommitMessageGenerationHydrated,
resolveCommitMessageGenerationCancel,
resolveCommitMessageGenerationFailure,
resolveCommitMessageGenerationSuccess,
type CommitMessageGenerationRecord,
type CommitMessageGenerationSlice
} from '@/store/slices/commit-message-generation'
function runningRecord(overrides: Partial<CommitMessageGenerationRecord> = {}) {
return {
context: {
worktreeId: 'wt-a',
worktreePath: '/repo/a',
connectionId: 'conn-a',
requestId: 3
},
status: 'running' as const,
message: null,
error: null,
hydrated: false,
...overrides
}
}
function createCommitMessageGenerationTestStore() {
return create<CommitMessageGenerationSlice>()((...args) =>
createCommitMessageGenerationSlice(
...(args as unknown as Parameters<typeof createCommitMessageGenerationSlice>)
)
)
}
describe('SourceControl commit message generation records', () => {
it('keys commit-message generation by worktree id and falls back to path', () => {
expect(getCommitMessageGenerationRecordKey('wt-a', '/repo/a')).toBe('wt-a')
expect(getCommitMessageGenerationRecordKey(null, '/repo/a')).toBe('/repo/a')
expect(getCommitMessageGenerationRecordKey(null, '')).toBeNull()
})
it('applies generated messages only to the original running request', () => {
expect(
resolveCommitMessageGenerationSuccess({
record: runningRecord(),
requestId: 3,
message: 'feat: generated'
})
).toMatchObject({
status: 'succeeded',
message: 'feat: generated',
hydrated: false
})
expect(
resolveCommitMessageGenerationSuccess({
record: runningRecord(),
requestId: 4,
message: 'feat: stale'
})
).toBeNull()
expect(
resolveCommitMessageGenerationSuccess({
record: runningRecord({ status: 'canceled' }),
requestId: 3,
message: 'feat: stale'
})
).toBeNull()
})
it('preserves cancellation over later generator resolution', () => {
const canceled = resolveCommitMessageGenerationCancel(runningRecord())
expect(canceled).toMatchObject({
status: 'canceled',
error: null
})
expect(
resolveCommitMessageGenerationFailure({
record: canceled,
requestId: 3,
canceled: true,
error: null
})
).toBeNull()
})
it('marks completed messages as hydrated once the UI consumes them', () => {
const hydrated = markCommitMessageGenerationHydrated(
runningRecord({
status: 'succeeded',
message: 'docs: generated'
})
)
expect(hydrated).toMatchObject({
status: 'succeeded',
message: 'docs: generated',
hydrated: true
})
})
it('stores running records in the generation slice', () => {
const store = createCommitMessageGenerationTestStore()
const key = getCommitMessageGenerationRecordKey('wt-a', '/repo/a')!
const requestId = store.getState().allocateCommitMessageGenerationRequestId()
store.getState().setCommitMessageGenerationRecord(
key,
createRunningCommitMessageGenerationRecord({
worktreeId: 'wt-a',
worktreePath: '/repo/a',
connectionId: 'conn-a',
requestId,
runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
})
)
expect(store.getState().commitMessageGenerationRecords[key]).toMatchObject({
context: {
requestId,
worktreeId: 'wt-a',
worktreePath: '/repo/a',
runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
},
status: 'running'
})
})
it('prunes commit generation records for removed worktrees', () => {
const store = createCommitMessageGenerationTestStore()
store.getState().setCommitMessageGenerationRecord(
'wt-a',
createRunningCommitMessageGenerationRecord({
worktreeId: 'wt-a',
worktreePath: '/repo/a',
requestId: 1
})
)
store.getState().setCommitMessageGenerationRecord(
'wt-b',
createRunningCommitMessageGenerationRecord({
worktreeId: 'wt-b',
worktreePath: '/repo/b',
requestId: 2
})
)
store.getState().pruneCommitMessageGenerationRecords(new Set(['wt-a']))
expect(Object.keys(store.getState().commitMessageGenerationRecords)).toEqual(['wt-a'])
})
})
@@ -186,7 +186,8 @@ describe('SourceControl pull request generation records', () => {
connectionId: 'conn-a',
requestId: 1,
repoId: 'repo-1',
branch: 'feature-a'
branch: 'feature-a',
runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
},
seed,
fieldRevisions
@@ -208,12 +209,61 @@ describe('SourceControl pull request generation records', () => {
)
expect(store.getState().pullRequestGenerationRecords[key!]).toMatchObject({
context: { runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } },
status: 'succeeded',
result: generated,
hydrated: false
})
})
it('prunes PR generation records for removed worktrees', () => {
const store = createPullRequestGenerationTestStore()
const keyA = getPullRequestGenerationRecordKey({
worktreeId: 'wt-a',
worktreePath: '/repo/a',
repoId: 'repo-1',
branch: 'feature-a'
})!
const keyB = getPullRequestGenerationRecordKey({
worktreeId: 'wt-b',
worktreePath: '/repo/b',
repoId: 'repo-1',
branch: 'feature-b'
})!
store.getState().setPullRequestGenerationRecord(
keyA,
createRunningPullRequestGenerationRecord(
{
worktreeId: 'wt-a',
worktreePath: '/repo/a',
requestId: 1,
repoId: 'repo-1',
branch: 'feature-a'
},
seed,
fieldRevisions
)
)
store.getState().setPullRequestGenerationRecord(
keyB,
createRunningPullRequestGenerationRecord(
{
worktreeId: 'wt-b',
worktreePath: '/repo/b',
requestId: 2,
repoId: 'repo-1',
branch: 'feature-b'
},
seed,
fieldRevisions
)
)
store.getState().prunePullRequestGenerationRecords(new Set(['wt-a']))
expect(Object.keys(store.getState().pullRequestGenerationRecords)).toEqual([keyA])
})
it('does not reuse PR generation request ids across composer remounts', () => {
const store = createPullRequestGenerationTestStore()
const key = getPullRequestGenerationRecordKey({
@@ -145,6 +145,11 @@ import {
import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client'
import { PullRequestIcon } from './checks-panel-content'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
import {
RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS,
RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS,
RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS
} from './right-sidebar-primary-action-layout'
import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel'
import type { GitHistoryItem } from '../../../../shared/git-history'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
@@ -155,6 +160,8 @@ import type {
GitBranchCompareSummary,
GitConflictOperation,
GitStatusEntry,
GlobalSettings,
Repo,
SourceControlViewMode,
TuiAgent
} from '../../../../shared/types'
@@ -207,6 +214,14 @@ import {
type PullRequestGenerationContext,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
import {
createRunningCommitMessageGenerationRecord,
getCommitMessageGenerationRecordKey,
markCommitMessageGenerationHydrated,
resolveCommitMessageGenerationCancel,
resolveCommitMessageGenerationFailure,
resolveCommitMessageGenerationSuccess
} from '@/store/slices/commit-message-generation'
export {
appendCommitFailureCustomInstruction,
@@ -223,6 +238,30 @@ export {
export type SourceControlScope = 'all' | 'uncommitted'
type AbortConflictOperation = Extract<GitConflictOperation, 'merge' | 'rebase'>
type AbortActionErrorKind = 'abort_merge' | 'abort_rebase'
type CommitMessageGenerationTargetSnapshot = {
worktreeId: string
worktreePath: string
connectionId?: string
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
settings: GlobalSettings | null
discoveryHostKey: string
}
type CommitMessageGenerationOptions = RuntimeGenerateCommitMessageOverrides & {
target?: CommitMessageGenerationTargetSnapshot
}
type PullRequestGenerationTargetSnapshot = {
worktreeId: string | null
worktreePath: string
connectionId?: string
repo: Pick<Repo, 'id' | 'sourceControlAi'> | null
repoId: string
branch: string
settings: GlobalSettings | null
discoveryHostKey: string
fields: PullRequestGenerationFields
fieldRevisions: PullRequestFieldRevisions
}
export type SourceControlActionError = {
kind: RemoteOpKind | AbortActionErrorKind
message: string
@@ -844,15 +883,6 @@ function SourceControlInner(): React.JSX.Element {
const isAbortingOperation = abortOperationInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const confirmAction = useConfirmationDialog()
const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false
// Why: parallel state to commit. Same per-worktree shape so navigating between
// worktrees mid-generation never silently cancels the in-flight request.
const generateInFlightRef = useRef<Record<string, boolean>>({})
const [generateInFlightByWorktree, setGenerateInFlightByWorktree] = useState<
Record<string, boolean>
>({})
const [generateErrors, setGenerateErrors] = useState<Record<string, string | null>>({})
const isGenerating = generateInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const generateError = generateErrors[activeWorktreeId ?? ''] ?? null
const [hostedReviewCreationState, setHostedReviewCreationState] =
useState<HostedReviewCreationState | null>(null)
const createPrInFlightRef = useRef<Record<string, boolean>>({})
@@ -862,12 +892,24 @@ function SourceControlInner(): React.JSX.Element {
const [createPrErrors, setCreatePrErrors] = useState<Record<string, string | null>>({})
const isCreatingPr = createPrInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const createPrError = createPrErrors[activeWorktreeId ?? ''] ?? null
const commitGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords)
const allocateCommitMessageGenerationRequestId = useAppStore(
(s) => s.allocateCommitMessageGenerationRequestId
)
const setCommitMessageGenerationRecord = useAppStore((s) => s.setCommitMessageGenerationRecord)
const updateCommitMessageGenerationRecord = useAppStore(
(s) => s.updateCommitMessageGenerationRecord
)
const pruneCommitMessageGenerationRecords = useAppStore(
(s) => s.pruneCommitMessageGenerationRecords
)
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const allocatePullRequestGenerationRequestId = useAppStore(
(s) => s.allocatePullRequestGenerationRequestId
)
const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord)
const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord)
const prunePullRequestGenerationRecords = useAppStore((s) => s.prunePullRequestGenerationRecords)
const filterInputRef = useRef<HTMLInputElement>(null)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
const commitError = commitErrors[activeWorktreeId ?? ''] ?? null
@@ -894,6 +936,16 @@ function SourceControlInner(): React.JSX.Element {
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
const activeCommitGenerationKey = getCommitMessageGenerationRecordKey(
activeWorktreeId,
worktreePath
)
const activeCommitGenerationRecord = activeCommitGenerationKey
? (commitGenerationRecords[activeCommitGenerationKey] ?? null)
: null
const isGenerating = activeCommitGenerationRecord?.status === 'running'
const generateError =
activeCommitGenerationRecord?.status === 'failed' ? activeCommitGenerationRecord.error : null
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
worktreeId: activeWorktreeId,
worktreePath,
@@ -1287,17 +1339,70 @@ function SourceControlInner(): React.JSX.Element {
openSettingsPage
})
const [commitGenerationDialogTarget, setCommitGenerationDialogTarget] =
useState<CommitMessageGenerationTargetSnapshot | null>(null)
const [pullRequestGenerationDialogTarget, setPullRequestGenerationDialogTarget] =
useState<PullRequestGenerationTargetSnapshot | null>(null)
const createCommitMessageGenerationTarget = useCallback(() => {
if (!activeWorktreeId || !worktreePath) {
return null
}
return {
worktreeId: activeWorktreeId,
worktreePath,
connectionId: activeConnectionId ?? undefined,
repo: activeRepo ?? null,
settings,
discoveryHostKey: sourceControlAiDiscoveryHostKey
}
}, [
activeConnectionId,
activeRepo,
activeWorktreeId,
settings,
sourceControlAiDiscoveryHostKey,
worktreePath
])
const handleCommitGenerationDialogOpenChange = useCallback(
(open: boolean): void => {
setCommitGenerationDialogOpen(open)
if (!open) {
setCommitGenerationDialogTarget(null)
}
},
[setCommitGenerationDialogOpen]
)
const handlePullRequestGenerationDialogOpenChange = useCallback(
(open: boolean): void => {
setPullRequestGenerationDialogOpen(open)
if (!open) {
setPullRequestGenerationDialogTarget(null)
}
},
[setPullRequestGenerationDialogOpen]
)
// Why: orphaned draft/error/in-flight entries accumulate when worktrees are
// removed from the store (long sessions with many create/destroy cycles).
// Prune them so a deleted-then-reused worktree ID doesn't inherit stale
// state — especially commitInFlightRef, which would permanently disable
// Commit for that ID if left stuck at `true`.
useEffect(() => {
const liveWorktreeKeys = new Set<string>()
for (const worktree of worktreeMap.values()) {
liveWorktreeKeys.add(worktree.id)
if (worktree.path.trim()) {
liveWorktreeKeys.add(worktree.path)
}
}
const pruneRecord = <T,>(prev: Record<string, T>): Record<string, T> => {
let changed = false
const next: Record<string, T> = {}
for (const key of Object.keys(prev)) {
if (worktreeMap.has(key)) {
if (liveWorktreeKeys.has(key)) {
next[key] = prev[key]
} else {
changed = true
@@ -1310,26 +1415,21 @@ function SourceControlInner(): React.JSX.Element {
setRemoteActionErrors((prev) => pruneRecord(prev))
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev))
setGenerateInFlightByWorktree((prev) => pruneRecord(prev))
setGenerateErrors((prev) => pruneRecord(prev))
setGitHistoryByWorktree((prev) => pruneRecord(prev))
pruneCommitMessageGenerationRecords(liveWorktreeKeys)
prunePullRequestGenerationRecords(liveWorktreeKeys)
// Refs don't need setState — mutate in place to drop stale keys.
for (const key of Object.keys(commitInFlightRef.current)) {
if (!worktreeMap.has(key)) {
if (!liveWorktreeKeys.has(key)) {
delete commitInFlightRef.current[key]
}
}
for (const key of Object.keys(generateInFlightRef.current)) {
if (!worktreeMap.has(key)) {
delete generateInFlightRef.current[key]
}
}
for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) {
if (!worktreeMap.has(key)) {
if (!liveWorktreeKeys.has(key)) {
delete gitHistoryRequestByWorktreeRef.current[key]
}
}
}, [worktreeMap])
}, [pruneCommitMessageGenerationRecords, prunePullRequestGenerationRecords, worktreeMap])
useEffect(() => {
// Why: users often finish merge/rebase conflicts in a terminal. Once git
@@ -1474,117 +1574,223 @@ function SourceControlInner(): React.JSX.Element {
])
const handleGenerate = useCallback(
async (overrides?: RuntimeGenerateCommitMessageOverrides): Promise<void> => {
if (!activeWorktreeId || !worktreePath) {
async (options?: CommitMessageGenerationOptions): Promise<void> => {
const target = options?.target ?? createCommitMessageGenerationTarget()
if (!target) {
return
}
if (generateInFlightRef.current[activeWorktreeId]) {
const overrides: RuntimeGenerateCommitMessageOverrides = {
...(options?.sourceControlAiResolvedParams
? { sourceControlAiResolvedParams: options.sourceControlAiResolvedParams }
: {}),
...(options?.sourceControlAi ? { sourceControlAi: options.sourceControlAi } : {}),
...(options?.agentCmdOverrides ? { agentCmdOverrides: options.agentCmdOverrides } : {})
}
const { worktreeId, worktreePath: generationWorktreePath, connectionId } = target
const generationKey = getCommitMessageGenerationRecordKey(worktreeId, generationWorktreePath)
if (!generationKey) {
return
}
if (!overrides?.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
if (
useAppStore.getState().commitMessageGenerationRecords[generationKey]?.status === 'running'
) {
return
}
if (!overrides.sourceControlAiResolvedParams && resolvedCommitMessageAi?.ok !== true) {
return
}
if (
!overrides?.sourceControlAiResolvedParams &&
!overrides.sourceControlAiResolvedParams &&
resolvedCommitMessageAi?.ok === true &&
isCustomAgentId(resolvedCommitMessageAi.value.params.agentId)
) {
const command = resolvedCommitMessageAi.value.params.customAgentCommand?.trim() ?? ''
if (!command) {
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]:
const requestId = allocateCommitMessageGenerationRequestId()
const failedRecord = resolveCommitMessageGenerationFailure({
record: createRunningCommitMessageGenerationRecord({
worktreeId,
worktreePath: generationWorktreePath,
connectionId,
requestId,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
}),
requestId,
error: translate(
'auto.components.right.sidebar.SourceControl.e9e238b260',
'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
}))
)
})
if (failedRecord) {
setCommitMessageGenerationRecord(generationKey, failedRecord)
}
return
}
}
generateInFlightRef.current[activeWorktreeId] = true
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
const requestId = allocateCommitMessageGenerationRequestId()
// Why: Stop must route to the runtime selected at generation start, even
// if the user changes settings before cancellation.
setCommitMessageGenerationRecord(
generationKey,
createRunningCommitMessageGenerationRecord({
worktreeId,
worktreePath: generationWorktreePath,
connectionId,
requestId,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
})
)
try {
const result = await generateRuntimeCommitMessage(
{
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
settings: target.settings ?? useAppStore.getState().settings,
worktreeId,
worktreePath: generationWorktreePath,
connectionId
},
overrides
)
if (!result.success) {
// Why: cancellation is a deliberate user action, not a failure to
// surface. Clear any prior error and stay quiet.
if (result.canceled) {
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
return
}
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]: result.error
}))
updateCommitMessageGenerationRecord(generationKey, (record) =>
resolveCommitMessageGenerationFailure({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
})
)
return
}
// Why: race protection — the user may have started typing into the
// textarea while the agent was running. In that case we silently drop
// the generated message rather than overwrite their in-progress edits.
setCommitDrafts((prev) => {
const current = prev[activeWorktreeId]
if (current && current.length > 0) {
return prev
}
return writeCommitDraftForWorktree(prev, activeWorktreeId, result.message)
})
useAppStore.getState().recordFeatureInteraction('ai-commit-generation')
setGenerateErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
updateCommitMessageGenerationRecord(generationKey, (record) =>
resolveCommitMessageGenerationSuccess({
record,
requestId,
message: result.message
})
)
} catch (error) {
setGenerateErrors((prev) => ({
...prev,
[activeWorktreeId]:
error instanceof Error ? error.message : 'Failed to generate commit message'
}))
} finally {
setGenerateInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
generateInFlightRef.current[activeWorktreeId] = false
updateCommitMessageGenerationRecord(generationKey, (record) =>
resolveCommitMessageGenerationFailure({
record,
requestId,
error: error instanceof Error ? error.message : 'Failed to generate commit message'
})
)
}
},
[activeWorktreeId, resolvedCommitMessageAi, worktreePath]
[
allocateCommitMessageGenerationRequestId,
createCommitMessageGenerationTarget,
resolvedCommitMessageAi,
setCommitMessageGenerationRecord,
updateCommitMessageGenerationRecord
]
)
useEffect(() => {
if (
!activeCommitGenerationKey ||
!activeCommitGenerationRecord ||
activeCommitGenerationRecord.status !== 'succeeded' ||
!activeCommitGenerationRecord.message ||
activeCommitGenerationRecord.hydrated
) {
return
}
const {
context: { worktreeId },
message
} = activeCommitGenerationRecord
// Why: generation can finish while Source Control is showing a different
// worktree or is temporarily unmounted; hydrate the saved result when its
// originating worktree is visible again without overwriting user edits.
setCommitDrafts((prev) => {
const current = prev[worktreeId]
if (current && current.length > 0) {
return prev
}
return writeCommitDraftForWorktree(prev, worktreeId, message)
})
updateCommitMessageGenerationRecord(
activeCommitGenerationKey,
markCommitMessageGenerationHydrated
)
}, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord])
const handleGenerateCommitMessageClick = useCallback((): void => {
const target = createCommitMessageGenerationTarget()
if (!target) {
return
}
if (
hasConfiguredCommitMessageGenerationDefaults({ settings, repo: activeRepo ?? null }) &&
resolvedCommitMessageAi?.ok
) {
void handleGenerate({ sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params })
void handleGenerate({
sourceControlAiResolvedParams: resolvedCommitMessageAi.value.params,
target
})
return
}
// Why: the dialog remains open while users can switch worktrees; keep it
// bound to the worktree that requested generation.
setCommitGenerationDialogTarget(target)
openCommitGenerationDialog()
}, [activeRepo, handleGenerate, openCommitGenerationDialog, resolvedCommitMessageAi, settings])
}, [
activeRepo,
createCommitMessageGenerationTarget,
handleGenerate,
openCommitGenerationDialog,
resolvedCommitMessageAi,
settings
])
const handleCancelGenerate = useCallback((): void => {
if (!activeWorktreeId || !worktreePath) {
if (
!activeCommitGenerationKey ||
!activeCommitGenerationRecord ||
activeCommitGenerationRecord.status !== 'running'
) {
return
}
if (!generateInFlightRef.current[activeWorktreeId]) {
return
}
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
const { context } = activeCommitGenerationRecord
updateCommitMessageGenerationRecord(
activeCommitGenerationKey,
resolveCommitMessageGenerationCancel
)
// Why: fire-and-forget — the in-flight generateCommitMessage promise
// resolves with `{canceled: true}` once the kill propagates, which is
// where the spinner is cleared. Awaiting here would just delay UI feedback.
// resolves with `{canceled: true}` once the kill propagates, while the
// durable record lets the visible spinner clear immediately.
void cancelRuntimeGenerateCommitMessage({
settings: useAppStore.getState().settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
}).catch((error) => {
updateCommitMessageGenerationRecord(activeCommitGenerationKey, (record) => {
if (!record || record.context.requestId !== context.requestId) {
return null
}
return {
...record,
status: 'failed',
error:
error instanceof Error ? error.message : 'Failed to stop commit message generation',
hydrated: false
}
})
})
}, [activeWorktreeId, worktreePath])
}, [activeCommitGenerationKey, activeCommitGenerationRecord, updateCommitMessageGenerationRecord])
// Why: a single dispatcher for every remote-only action the split button or
// chevron dropdown can trigger. Keeps the error-swallow pattern in one
@@ -1912,42 +2118,90 @@ function SourceControlInner(): React.JSX.Element {
await refreshActiveGitStatusAfterMutation()
}, [refreshActiveGitStatusAfterMutation])
const createPullRequestGenerationTarget = useCallback(
(
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions
): PullRequestGenerationTargetSnapshot | null => {
if (!activeRepo || !worktreePath || !branchName) {
return null
}
return {
worktreeId: activeWorktreeId,
worktreePath,
connectionId: activeConnectionId ?? undefined,
repo: activeRepo,
repoId: activeRepo.id,
branch: branchName,
settings,
discoveryHostKey: sourceControlAiDiscoveryHostKey,
fields: { ...fields },
fieldRevisions: { ...fieldRevisions }
}
},
[
activeConnectionId,
activeRepo,
activeWorktreeId,
branchName,
settings,
sourceControlAiDiscoveryHostKey,
worktreePath
]
)
const handleGeneratePullRequestFieldsForActive = useCallback(
async (
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
overrides?: RuntimeGeneratePullRequestFieldsOverrides,
targetOverride?: PullRequestGenerationTargetSnapshot
): Promise<void> => {
if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) {
const target = targetOverride ?? createPullRequestGenerationTarget(fields, fieldRevisions)
if (!target) {
return
}
const generationKey = getPullRequestGenerationRecordKey({
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
repoId: target.repoId,
branch: target.branch
})
if (!generationKey) {
return
}
const generationKey = activePullRequestGenerationKey
if (
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
) {
return
}
const requestId = allocatePullRequestGenerationRequestId()
// Why: Stop must route to the runtime selected at generation start, even
// if the user changes settings before cancellation.
const context: PullRequestGenerationContext = {
worktreeId: activeWorktreeId,
worktreePath,
connectionId: getConnectionId(activeWorktreeId) ?? undefined,
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
connectionId: target.connectionId,
requestId,
repoId: activeRepo.id,
branch: branchName
repoId: target.repoId,
branch: target.branch,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
}
const seed = { ...fields }
// Why: SourceControl can unmount on tab switches; persisting the running
// record lets the embedded PR composer resume when the user returns.
const seed = { ...target.fields }
// Why: SourceControl can unmount on tab/worktree switches; the record is
// keyed to the originating repo/worktree/branch so stale UI cannot retarget
// or orphan the generated hosted-review fields.
setPullRequestGenerationRecord(
generationKey,
createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
createRunningPullRequestGenerationRecord(context, seed, target.fieldRevisions)
)
try {
const result = await generateRuntimePullRequestFields(
{
settings: useAppStore.getState().settings,
settings: target.settings ?? useAppStore.getState().settings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
@@ -2001,15 +2255,11 @@ function SourceControlInner(): React.JSX.Element {
}
},
[
activePullRequestGenerationKey,
activeRepo,
activeWorktreeId,
allocatePullRequestGenerationRequestId,
branchName,
createPullRequestGenerationTarget,
refreshGitStatusAfterPullRequestGeneration,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord,
worktreePath
updatePullRequestGenerationRecord
]
)
@@ -2029,7 +2279,7 @@ function SourceControlInner(): React.JSX.Element {
return resolvePullRequestGenerationCancel(current)
})
void cancelRuntimeGeneratePullRequestFields({
settings: useAppStore.getState().settings,
settings: record.context.runtimeTargetSettings,
worktreeId: record.context.worktreeId,
worktreePath: record.context.worktreePath,
connectionId: record.context.connectionId
@@ -2058,6 +2308,7 @@ function SourceControlInner(): React.JSX.Element {
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
fieldRevisions: prFieldRevisions,
baseQuery: prBaseQuery,
setBaseQuery: setPrBaseQuery,
baseResults: prBaseResults,
@@ -2103,10 +2354,36 @@ function SourceControlInner(): React.JSX.Element {
void handleGeneratePullRequestFields()
return
}
const target = createPullRequestGenerationTarget(
{ base: prBase, title: prTitle, body: prBody, draft: prDraft },
prFieldRevisions
)
if (!target) {
return
}
// Why: the agent-picker dialog can stay open while the user switches
// worktrees; keep Generate bound to the PR draft that opened it.
setPullRequestGenerationDialogTarget(target)
openPullRequestGenerationDialog()
}, [activeRepo, handleGeneratePullRequestFields, openPullRequestGenerationDialog, settings])
}, [
activeRepo,
createPullRequestGenerationTarget,
handleGeneratePullRequestFields,
openPullRequestGenerationDialog,
prBase,
prBody,
prDraft,
prFieldRevisions,
prTitle,
settings
])
useEffect(() => {
// Why: after a SourceControl remount, the PR composer first reseeds from
// eligibility; hydrate generated fields only after that seed exists.
if (hostedReviewCreation?.canCreate !== true) {
return
}
if (
!activePullRequestGenerationKey ||
!activePullRequestGenerationRecord ||
@@ -2141,6 +2418,7 @@ function SourceControlInner(): React.JSX.Element {
activePullRequestGenerationKey,
activePullRequestGenerationRecord,
applyGeneratedPullRequestFields,
hostedReviewCreation?.canCreate,
updatePullRequestGenerationRecord
])
@@ -4476,7 +4754,7 @@ function SourceControlInner(): React.JSX.Element {
/>
<SourceControlTextGenerationDialog
open={commitGenerationDialogOpen}
onOpenChange={setCommitGenerationDialogOpen}
onOpenChange={handleCommitGenerationDialogOpenChange}
actionId="commitMessage"
title={translate(
'auto.components.right.sidebar.SourceControl.6b122529d4',
@@ -4487,17 +4765,23 @@ function SourceControlInner(): React.JSX.Element {
'Choose the agent and command template for this run.'
)}
generateLabel="Generate"
settings={settings}
repo={activeRepo ?? null}
discoveryHostKey={sourceControlAiDiscoveryHostKey}
settings={commitGenerationDialogTarget?.settings ?? settings}
repo={commitGenerationDialogTarget?.repo ?? activeRepo ?? null}
discoveryHostKey={
commitGenerationDialogTarget?.discoveryHostKey ?? sourceControlAiDiscoveryHostKey
}
onGenerate={(params) => {
void handleGenerate({ sourceControlAiResolvedParams: params })
const target = commitGenerationDialogTarget ?? createCommitMessageGenerationTarget()
if (!target) {
return
}
void handleGenerate({ sourceControlAiResolvedParams: params, target })
}}
onSaveDefaults={handleSaveCommitMessageGenerationDefaults}
/>
<SourceControlTextGenerationDialog
open={pullRequestGenerationDialogOpen}
onOpenChange={setPullRequestGenerationDialogOpen}
onOpenChange={handlePullRequestGenerationDialogOpenChange}
actionId="pullRequest"
title={translate(
'auto.components.right.sidebar.SourceControl.1a6a6e0bc5',
@@ -4508,11 +4792,27 @@ function SourceControlInner(): React.JSX.Element {
'Choose the agent and command template for this run.'
)}
generateLabel="Generate"
settings={settings}
repo={activeRepo ?? null}
discoveryHostKey={sourceControlAiDiscoveryHostKey}
settings={pullRequestGenerationDialogTarget?.settings ?? settings}
repo={pullRequestGenerationDialogTarget?.repo ?? activeRepo ?? null}
discoveryHostKey={
pullRequestGenerationDialogTarget?.discoveryHostKey ?? sourceControlAiDiscoveryHostKey
}
onGenerate={(params) => {
void handleGeneratePullRequestFields({ sourceControlAiResolvedParams: params })
const target =
pullRequestGenerationDialogTarget ??
createPullRequestGenerationTarget(
{ base: prBase, title: prTitle, body: prBody, draft: prDraft },
prFieldRevisions
)
if (!target) {
return
}
void handleGeneratePullRequestFieldsForActive(
target.fields,
target.fieldRevisions,
{ sourceControlAiResolvedParams: params },
target
)
}}
onSaveDefaults={handleSavePullRequestGenerationDefaults}
/>
@@ -4579,7 +4879,7 @@ function CommitFailureFixSplitButton({
return (
<>
<DropdownMenu>
<div className="flex shrink-0 items-stretch">
<div className="inline-flex shrink-0 max-w-full items-stretch">
<Button
type="button"
variant={variant}
@@ -4972,7 +5272,7 @@ export function CommitArea({
chevron exposes the full action surface (fetch, pull, sync,
publish, compound commits) without forcing morphing labels to
carry every possible intent. */}
<div className={cn(showComposer ? 'mt-1 flex items-stretch' : 'flex items-stretch')}>
<div className={cn(RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS, showComposer && 'mt-1')}>
{/* Why: match the hosted-review action buttons in Checks
(size="xs", px-3 text-[11px]) so the sidebar has a consistent
action-button shape across Source Control and Checks. The primary
@@ -4981,13 +5281,16 @@ export function CommitArea({
pair read as one split button instead of two detached buttons. */}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex flex-1">
<span className="inline-flex min-w-0 max-w-full shrink">
<Button
type="button"
size="xs"
disabled={primaryAction.disabled}
onClick={() => onPrimaryAction()}
className="w-full rounded-r-none px-3 text-[11px]"
className={cn(
'rounded-r-none px-3 text-[11px]',
RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS
)}
title={primaryAction.title}
>
{showSpinner ? (
@@ -4995,7 +5298,9 @@ export function CommitArea({
) : PrimaryIcon ? (
<PrimaryIcon className="size-3.5" aria-hidden="true" />
) : null}
{primaryAction.label}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{primaryAction.label}
</span>
</Button>
</span>
</TooltipTrigger>
@@ -5733,12 +6038,12 @@ export function ConflictSummaryCard({
</div>
</div>
</div>
<div className="mt-2">
<div className="mt-2 flex flex-col items-start">
<Button
type="button"
variant="default"
size="sm"
className="h-7 w-full text-xs"
className="h-7 text-xs"
disabled={isResolvingWithAI}
onClick={onResolveWithAI}
>
@@ -5753,7 +6058,7 @@ export function ConflictSummaryCard({
type="button"
variant="outline"
size="sm"
className="mt-1.5 h-7 w-full text-xs"
className="mt-1.5 h-7 text-xs"
onClick={onReview}
>
<GitMerge className="size-3.5" />
@@ -5766,7 +6071,7 @@ export function ConflictSummaryCard({
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="mt-1.5 h-7 w-full text-xs"
className="mt-1.5 h-7 text-xs"
disabled={isResolvingWithAI || isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
@@ -5808,26 +6113,28 @@ export function OperationBanner({
return (
<div className="rounded-md border border-amber-500/25 bg-amber-500/5 px-3 py-2">
<div className="flex items-center justify-center gap-2">
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
{(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? (
<Button
type="button"
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="mt-2 h-7 w-full text-xs"
disabled={isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
{isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null}
{conflictOperation === 'rebase'
? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
: translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
</Button>
<div className="mt-2 flex flex-col items-start">
<Button
type="button"
// Why: abort is the escape hatch for this state, so match the quiet
// outline conflict-review action instead of reading as destructive.
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={isAbortingOperation}
onClick={() => onAbortOperation(conflictOperation)}
>
{isAbortingOperation ? <RefreshCw className="size-3.5 animate-spin" /> : null}
{conflictOperation === 'rebase'
? translate('auto.components.right.sidebar.SourceControl.425f138269', 'Abort rebase')
: translate('auto.components.right.sidebar.SourceControl.540ca8f78c', 'Abort merge')}
</Button>
</div>
) : null}
</div>
)
@@ -1232,7 +1232,7 @@ export function ChecksList({
<div className="flex items-center justify-center py-8 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.checks.panel.content.991f50c7e4',
'No checks configured'
'No checks reported yet'
)}
</div>
) : !checksExpanded ? null : (
@@ -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)
})
})
@@ -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
}
}
@@ -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)
})
})
@@ -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'
@@ -42,7 +42,12 @@ export function BrowserLinkRoutingSetting({
<button
role="switch"
aria-checked={settings.openLinksInApp}
onClick={() => updateSettings({ openLinksInApp: !settings.openLinksInApp })}
onClick={() =>
updateSettings({
openLinksInApp: !settings.openLinksInApp,
openLinksInAppPreferencePrompted: true
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.openLinksInApp ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
@@ -41,7 +41,7 @@ function ProjectOrderManualDefaultNotice(): React.JSX.Element | null {
<p className="min-w-0 text-sm font-semibold leading-snug text-worktree-sidebar-foreground">
{translate(
'auto.components.sidebar.ProjectOrderManualDefaultNotice.a1f4c2d8e0',
'Manual project order is now default'
'Manual project order is now the default'
)}
</p>
<Tooltip>
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
appRestart: vi.fn(),
updaterCheck: vi.fn(),
shellOpenUrl: vi.fn(),
useShortcutLabel: vi.fn(),
useShortcutKeys: vi.fn(),
setupProgress: {
ready: true,
coreDoneCount: 2,
@@ -32,7 +32,7 @@ vi.mock('@/store', () => ({
}))
vi.mock('@/hooks/useShortcutLabel', () => ({
useShortcutLabel: mocks.useShortcutLabel
useShortcutKeys: mocks.useShortcutKeys
}))
vi.mock('@/hooks/useMountedRef', () => ({
@@ -72,12 +72,14 @@ vi.mock('@/components/ui/tooltip', () => ({
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick
onClick,
'aria-label': ariaLabel
}: {
children: ReactNode
onClick?: (event: React.MouseEvent) => void
'aria-label'?: string
}) => (
<button data-testid="trigger-button" onClick={onClick}>
<button data-testid="trigger-button" aria-label={ariaLabel} onClick={onClick}>
{children}
</button>
)
@@ -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(<SidebarSettingsHelpMenu />)
expect(html).toContain('Settings')
expect(html).toContain('aria-label="Settings"')
})
it('renders the settings button before the help button', () => {
const html = renderToStaticMarkup(<SidebarSettingsHelpMenu />)
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(<SidebarSettingsHelpMenu />)
expect(html).toContain('⌘,')
expect(html).toContain('⌘')
expect(html).toContain('>,</span>')
})
})
@@ -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 (
<>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleMenuOpenChange}>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
type="button"
aria-label={translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c',
'Help'
)}
className="text-muted-foreground"
onPointerDown={(event) => revealAdminOptions(event.altKey)}
onClick={(event) => revealAdminOptions(event.altKey)}
>
<CircleHelp className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<Button
variant="ghost"
size="icon-xs"
type="button"
aria-label={translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998',
'Settings'
)}
className="text-muted-foreground"
onClick={openSettingsPage}
>
<Settings className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c', 'Help')}
<TooltipContent side="top" sideOffset={4} className="flex items-center gap-1.5">
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')}
{settingsShortcutKeys.length > 0 ? (
<ShortcutKeyCombo
keys={settingsShortcutKeys}
className="gap-0.5"
keyCapClassName="min-w-0 border-background/20 bg-background/10 px-1 py-0 text-[10px] text-background shadow-none"
separatorClassName="text-[10px] text-background/70"
/>
) : null}
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start" sideOffset={8} className="w-52">
<DropdownMenuItem onSelect={openSettingsPage}>
<Settings className="size-3.5" />
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.a428c25998', 'Settings')}
<span className="ml-auto text-xs tracking-wide opacity-60">{settingsShortcut}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={openShortcutsSettings}>
<Keyboard className="size-3.5" />
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.e565171a7c',
'Keyboard Shortcuts'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setFeedbackOpen(true)}>
<MessageSquareText className="size-3.5" />
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.4cf5b868d7',
'Send Feedback'
)}
</DropdownMenuItem>
{showMilestones ? (
<DropdownMenuItem onSelect={openMilestones}>
<img
src={logo}
alt=""
aria-hidden="true"
className="size-3.5 object-contain invert opacity-55 dark:invert-0"
/>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleMenuOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
type="button"
aria-label={translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c',
'Help'
)}
className="text-muted-foreground"
onPointerDown={(event) => revealAdminOptions(event.altKey)}
onClick={(event) => revealAdminOptions(event.altKey)}
>
<CircleHelp className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.2991a0106c', 'Help')}
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start" sideOffset={8} className="w-52">
<DropdownMenuItem onSelect={openShortcutsSettings}>
<Keyboard className="size-3.5" />
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.f8a2c91d4e',
'Milestones'
)}
<SetupGuideProgressRing
done={setupProgress.coreDoneCount}
total={setupProgress.coreTotal}
sizeClassName="size-4"
className="ml-auto"
/>
</DropdownMenuItem>
) : null}
{showAdminOptions ? (
<DropdownMenuItem
className="whitespace-nowrap"
onClick={handleShowOnboarding}
onSelect={handleShowOnboarding}
>
<School className="size-3.5" />
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c',
'Onboarding'
'auto.components.sidebar.SidebarSettingsHelpMenu.e565171a7c',
'Keyboard Shortcuts'
)}
</DropdownMenuItem>
) : null}
<ExternalMenuItem
label={translate('auto.components.sidebar.SidebarSettingsHelpMenu.cdc87f897e', 'Docs')}
url={DOCS_URL}
icon={<BookOpen className="size-3.5" />}
/>
<ExternalMenuItem
label={translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.5f83d86d92',
'Changelog'
)}
url={CHANGELOG_URL}
icon={<ScrollText className="size-3.5" />}
/>
<DropdownMenuSeparator />
<ExternalMenuItem
label={translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.5687ab246a',
'GitHub'
)}
url={GITHUB_URL}
icon={<Github className="size-3.5" />}
/>
<DropdownMenuItem onSelect={() => openExternalUrl(DISCORD_URL)}>
<DiscordIcon />
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b', 'Discord')}
<ExternalLink className="ml-auto size-3 text-muted-foreground" />
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openExternalUrl(X_URL)}>
<XIcon />
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.c4f8e1b72a', 'X')}
<ExternalLink className="ml-auto size-3 text-muted-foreground" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
onSelect={handleCheckForUpdates}
>
{updateStatus.state === 'checking' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.29c56f30ee',
'Check for Updates'
)}
</DropdownMenuItem>
{showAdminOptions ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleRestartOrca} disabled={isRestartingOrca}>
<RotateCw className="size-3.5" />
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setFeedbackOpen(true)}>
<MessageSquareText className="size-3.5" />
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.4cf5b868d7',
'Send Feedback'
)}
</DropdownMenuItem>
{showMilestones ? (
<DropdownMenuItem onSelect={openMilestones}>
<img
src={logo}
alt=""
aria-hidden="true"
className="size-3.5 object-contain invert opacity-55 dark:invert-0"
/>
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1',
'Restart Orca'
'auto.components.sidebar.SidebarSettingsHelpMenu.f8a2c91d4e',
'Milestones'
)}
<SetupGuideProgressRing
done={setupProgress.coreDoneCount}
total={setupProgress.coreTotal}
sizeClassName="size-4"
className="ml-auto"
/>
</DropdownMenuItem>
) : null}
{showAdminOptions ? (
<DropdownMenuItem
className="whitespace-nowrap"
onClick={handleShowOnboarding}
onSelect={handleShowOnboarding}
>
<School className="size-3.5" />
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.b7e4d2a19c',
'Onboarding'
)}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<ExternalMenuItem
label={translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.cdc87f897e',
'Docs'
)}
url={DOCS_URL}
icon={<BookOpen className="size-3.5" />}
/>
<ExternalMenuItem
label={translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.5f83d86d92',
'Changelog'
)}
url={CHANGELOG_URL}
icon={<ScrollText className="size-3.5" />}
/>
<DropdownMenuSeparator />
<ExternalMenuItem
label={translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.5687ab246a',
'GitHub'
)}
url={GITHUB_URL}
icon={<Github className="size-3.5" />}
/>
<DropdownMenuItem onSelect={() => openExternalUrl(DISCORD_URL)}>
<DiscordIcon />
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b', 'Discord')}
<ExternalLink className="ml-auto size-3 text-muted-foreground" />
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openExternalUrl(X_URL)}>
<XIcon />
{translate('auto.components.sidebar.SidebarSettingsHelpMenu.c4f8e1b72a', 'X')}
<ExternalLink className="ml-auto size-3 text-muted-foreground" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
onSelect={handleCheckForUpdates}
>
{updateStatus.state === 'checking' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.29c56f30ee',
'Check for Updates'
)}
</DropdownMenuItem>
{showAdminOptions ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleRestartOrca} disabled={isRestartingOrca}>
<RotateCw className="size-3.5" />
{translate(
'auto.components.sidebar.SidebarSettingsHelpMenu.ad3d3ed7f1',
'Restart Orca'
)}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
<SidebarFeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} />
</>
)
@@ -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<void> => {
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({
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setEditor({
mode: 'add',
command: createTerminalQuickCommandDraft({ type: 'repo', repoId })
})
}
onClick={addRepoCommand}
className="my-auto flex h-7 shrink-0 items-center gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.8f1e971966',
@@ -203,186 +179,17 @@ export function TabBarQuickCommandsButton({
)
}
const splitButtonClass =
'my-auto flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-border/60 text-muted-foreground'
const innerButtonBase =
'flex items-center bg-transparent leading-none text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
const renderItem = (command: TerminalQuickCommand): React.JSX.Element => (
<CommandItem
key={command.id}
value={command.id}
onSelect={() => handleRun(command)}
className="group/qc mx-1 my-0.5 items-center gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 data-[selected=true]:bg-black/8 dark:data-[selected=true]:bg-white/14"
>
{isTerminalAgentQuickCommand(command) ? (
<span className="shrink-0 text-muted-foreground">
<AgentIcon agent={command.agent} size={12} />
</span>
) : (
<Play
className="size-3 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{command.label}</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${command.prompt}`
: command.command}
</span>
</span>
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover/qc:opacity-100 group-data-[selected=true]/qc:opacity-100">
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setMenuOpen(false)
setEditor({ mode: 'edit', command })
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.15529ede69',
'Edit {{value0}}',
{ value0: command.label }
)}
>
<Pencil className="size-3" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
void handleDeleteCommand(command)
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.196593b6a9',
'Remove {{value0}}',
{ value0: command.label }
)}
>
<Trash2 className="size-3" />
</button>
</span>
</CommandItem>
)
return (
<>
<div className={splitButtonClass}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => mostRecent && handleRun(mostRecent)}
disabled={!mostRecent}
className={cn(innerButtonBase, 'gap-1.5 rounded-l-md rounded-r-none px-1.5')}
aria-label={
mostRecent
? translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.b775303755',
'Run quick command: {{value0}}',
{ value0: mostRecent.label }
)
: translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.85482c57bc',
'Run quick command'
)
}
>
<Play className="size-3 shrink-0" fill="currentColor" strokeWidth={0} />
<span className="max-w-[160px] truncate text-[12px] font-medium">
{mostRecent?.label ??
translate('auto.components.tab.bar.TabBarQuickCommandsButton.7b1c9d6ae1', 'Run')}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{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'
)}
</TooltipContent>
</Tooltip>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
innerButtonBase,
'justify-center rounded-l-none rounded-r-md border-l border-border/60 px-1'
)}
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.b82e237a4b',
'More quick commands'
)}
>
<ChevronDown className="size-3" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={6} className="w-72 p-0">
<Command
shouldFilter={false}
value={commandValue}
onValueChange={setCommandValue}
className="bg-transparent"
>
<CommandList className="max-h-72 py-1">
{totalVisible === 0 ? (
<CommandEmpty className="py-4 text-center text-[11px]">
{translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.20bbd75896',
'No commands'
)}
</CommandEmpty>
) : null}
{repoCommands.map(renderItem)}
{repoCommands.length > 0 && globalCommands.length > 0 ? (
<CommandSeparator className="my-1" />
) : null}
{globalCommands.map(renderItem)}
</CommandList>
<div className="border-t border-border/50 p-1">
<button
type="button"
onClick={() => {
setMenuOpen(false)
setEditor({
mode: 'add',
command: createTerminalQuickCommandDraft({ type: 'repo', repoId })
})
}}
className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Plus className="size-3.5" />
{translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831',
'Add command'
)}
</button>
</div>
</Command>
</DropdownMenuContent>
</DropdownMenu>
</div>
<TabBarQuickCommandsMenu
repoCommands={repoCommands}
globalCommands={globalCommands}
mostRecent={mostRecent}
onAddCommand={addRepoCommand}
onEditCommand={(command) => setEditor({ mode: 'edit', command })}
onDeleteCommand={(command) => void handleDeleteCommand(command)}
onRunCommand={handleRun}
/>
<TerminalQuickCommandDialog
open={editor !== null}
mode={editor?.mode ?? 'add'}
@@ -0,0 +1,388 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Pencil, Play, Plus, Trash2 } from 'lucide-react'
import {
Command,
CommandEmpty,
CommandInput,
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 {
getTerminalQuickCommandBody,
isTerminalAgentQuickCommand
} from '../../../../shared/terminal-quick-commands'
import type { TerminalQuickCommand } from '../../../../shared/types'
import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
getTerminalQuickCommandPickerValue,
searchTerminalQuickCommands
} from '@/lib/terminal-quick-command-search'
type TabBarQuickCommandsMenuProps = {
repoCommands: readonly TerminalQuickCommand[]
globalCommands: readonly TerminalQuickCommand[]
mostRecent: TerminalQuickCommand | null
onAddCommand: () => 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<string | null>(null)
const searchInputRef = useRef<HTMLInputElement | null>(null)
const commandListRef = useRef<HTMLDivElement | null>(null)
const focusFrameRef = useRef<number | null>(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<HTMLInputElement>) => {
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 => (
<CommandItem
key={command.id}
value={command.id}
onSelect={() => 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) ? (
<span className="shrink-0 text-muted-foreground">
<AgentIcon agent={command.agent} size={12} />
</span>
) : (
<Play
className="size-3 shrink-0 text-muted-foreground"
fill="currentColor"
strokeWidth={0}
/>
)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{command.label}</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{isTerminalAgentQuickCommand(command)
? `${getAgentLabel(command.agent)}: ${command.prompt}`
: command.command}
</span>
</span>
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover/qc:opacity-100 group-data-[selected=true]/qc:opacity-100">
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setMenuOpen(false)
onEditCommand(command)
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.15529ede69',
'Edit {{value0}}',
{ value0: command.label }
)}
>
<Pencil className="size-3" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
setMenuOpen(false)
onDeleteCommand(command)
}}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.196593b6a9',
'Remove {{value0}}',
{ value0: command.label }
)}
>
<Trash2 className="size-3" />
</button>
</span>
</CommandItem>
)
return (
<div className={splitButtonClass}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => mostRecent && runAndClose(mostRecent)}
disabled={!mostRecent}
className={cn(innerButtonBase, 'gap-1.5 rounded-l-md rounded-r-none px-1.5')}
aria-label={
mostRecent
? translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.b775303755',
'Run quick command: {{value0}}',
{ value0: mostRecent.label }
)
: translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.85482c57bc',
'Run quick command'
)
}
>
<Play className="size-3 shrink-0" fill="currentColor" strokeWidth={0} />
<span className="max-w-[160px] truncate text-[12px] font-medium">
{mostRecent?.label ??
translate('auto.components.tab.bar.TabBarQuickCommandsButton.7b1c9d6ae1', 'Run')}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{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'
)}
</TooltipContent>
</Tooltip>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
innerButtonBase,
'justify-center rounded-l-none rounded-r-md border-l border-border/60 px-1'
)}
aria-label={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.b82e237a4b',
'More quick commands'
)}
>
<ChevronDown className="size-3" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
sideOffset={6}
className="w-72 p-0"
onKeyDown={(event) => {
if (event.key !== 'Enter' || showSearch || filteredVisibleCommands.length !== 1) {
return
}
event.preventDefault()
runAndClose(filteredVisibleCommands[0])
}}
>
<Command
shouldFilter={false}
loop
value={commandValue}
onValueChange={setCommandValueOverride}
className="bg-transparent"
>
{showSearch ? (
<CommandInput
ref={searchInputRef}
autoFocus
placeholder={translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.f3a8c2d1e7',
'Search quick commands...'
)}
value={query}
onValueChange={(nextQuery) => {
// 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}
<CommandList ref={commandListRef} className="max-h-72 py-1">
{filteredVisibleCommands.length === 0 ? (
<CommandEmpty className="py-4 text-center text-[11px]">
{query.trim()
? translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.b4e7f9a2c1',
'No commands match'
)
: translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.20bbd75896',
'No commands'
)}
</CommandEmpty>
) : null}
{filteredRepoCommands.map(renderItem)}
{filteredRepoCommands.length > 0 && filteredGlobalCommands.length > 0 ? (
<CommandSeparator className="my-1" />
) : null}
{filteredGlobalCommands.map(renderItem)}
</CommandList>
<div className="border-t border-border/50 p-1">
<button
type="button"
onClick={() => {
setMenuOpen(false)
onAddCommand()
}}
className="flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Plus className="size-3.5" />
{translate(
'auto.components.tab.bar.TabBarQuickCommandsButton.a2c7a33831',
'Add command'
)}
</button>
</div>
</Command>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -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<Promise<boolean> | null>(null)
const requestOpenLinksInAppPreference = useCallback(
(url: string): Promise<boolean> | 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,
@@ -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 }
@@ -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<MouseEvent, 'metaKey' | 'ctrlKey'> &
Partial<Pick<MouseEvent, 'shiftKey' | 'preventDefault' | 'stopPropagation'>>
@@ -13,7 +16,9 @@ export function handleOscLink(
rawText: string,
event: TerminalLinkEvent | undefined,
deps: Pick<LinkHandlerDeps, 'worktreeId' | 'worktreePath'> &
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>>
Partial<Pick<LinkHandlerDeps, 'runtimeEnvironmentId' | 'startupCwd' | 'terminalHomePath'>> & {
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
}
@@ -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<boolean> | 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 },
@@ -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<GlobalSettings | null | undefined>
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
+45 -4
View File
@@ -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": {
+54 -13
View File
@@ -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": {
+54 -13
View File
@@ -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": {
+53 -12
View File
@@ -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": {
+53 -12
View File
@@ -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": {
@@ -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', () => {
+1 -1
View File
@@ -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
@@ -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')
})
})
@@ -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, ' ')
}
@@ -39,7 +39,7 @@ function delay(ms: number): Promise<void> {
export function shouldOpenWorkspacePortInOrcaBrowser(
settings: { openLinksInApp?: boolean } | null | undefined
): boolean {
return settings?.openLinksInApp !== false
return settings?.openLinksInApp === true
}
export function workspacePortOwnerWorktreeId(port: WorkspacePort): string | null {
+3 -1
View File
@@ -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<AppState>()((...a) => ({
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a),
...createPullRequestGenerationSlice(...a)
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a)
}))
registerHttpLinkStoreAccessor(() => useAppStore.getState())
@@ -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<string, CommitMessageGenerationRecord>
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<string>) => 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 } : {}
})
})
@@ -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<AppState>()((...a) => ({
@@ -169,7 +170,8 @@ function createTestStore() {
...createWorktreeNavHistorySlice(...a),
...createDictationSlice(...a),
...createWorkspaceCleanupSlice(...a),
...createPullRequestGenerationSlice(...a)
...createPullRequestGenerationSlice(...a),
...createCommitMessageGenerationSlice(...a)
}))
}
@@ -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<T> = {
promise: Promise<T>
resolve: (value: T) => void
}
function deferred<T>(): Deferred<T> {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve
})
return { promise, resolve }
}
function createTestStore() {
return create<AppState>()(
(...a) =>
({
...createGitHubSlice(...a),
...createHostedReviewSlice(...a)
}) as AppState
)
}
function makePR(overrides: Partial<PRInfo> = {}): 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<PRCheckDetail[]>()
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<PRCheckDetail[]>()
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<PRCheckDetail[]>()
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<AppState>)
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<AppState>)
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<AppState>)
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<AppState>)
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
})
})
})
@@ -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<AppState>)
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<AppState>)
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', () => {
+57 -15
View File
@@ -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<PRInfo | null>; force: boolean; generation: number; lookupHintKey: string }
>()
const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>()
const inflightChecksRequests = new Map<string, Promise<PRCheckDetail[]>>()
type InflightChecksRequest = {
promise: Promise<PRCheckDetail[]>
noCache: boolean
}
const inflightChecksRequests = new Map<string, InflightChecksRequest>()
const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>()
type InflightWorkItems = {
promise: Promise<GitHubWorkItem[]>
@@ -654,6 +659,10 @@ function isFresh<T>(entry: CacheEntry<T> | undefined, ttl = CACHE_TTL): entry is
return entry !== undefined && Date.now() - entry.fetchedAt < ttl
}
function checksCacheTtl(entry: CacheEntry<PRCheckDetail[]> | 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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (s
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: options?.force
noCache: requestNoCache
},
{ timeoutMs: 30_000 }
)
@@ -2463,7 +2482,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: options?.force
noCache: requestNoCache
})) as PRCheckDetail[])
set((s) => {
const nextState: Partial<AppState> = {
@@ -2505,7 +2524,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
})()
inflightChecksRequests.set(inflightKey, request)
inflightChecksRequests.set(inflightKey, { promise: request, noCache: requestNoCache })
return request
},
@@ -2924,6 +2943,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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
@@ -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<string>) => 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 } : {}
})
})
@@ -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)
}))
}
+3 -1
View File
@@ -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
@@ -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()
+14
View File
@@ -170,6 +170,7 @@ function invalidateRuntimeWorktreeCaches(): void {
type WebSettingsApi = NonNullable<PreloadApi['settings']>
type WebKeybindingsApi = NonNullable<PreloadApi['keybindings']>
type WebGitHubApi = NonNullable<PreloadApi['gh']>
type WebStarNagApi = NonNullable<PreloadApi['starNag']>
type WebGitHubResult<K extends keyof WebGitHubApi> = Awaited<ReturnType<WebGitHubApi[K]>>
type WebGitHubRouteKey =
| 'repoSlug'
@@ -555,6 +556,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
browser: createBrowserApi(),
emulator: createEmulatorApi(),
gh: createGitHubApi(),
starNag: createStarNagApi(),
gl: createGitLabApi(),
hostedReview: createRuntimeNamespaceApi('hostedReview'),
linear: createRuntimeNamespaceApi('linear'),
@@ -1573,6 +1575,18 @@ function createEmulatorApi(): NonNullable<Partial<PreloadApi>['emulator']> {
} as unknown as NonNullable<Partial<PreloadApi>['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 = <Result>(method: WebGitHubRuntimeMethod, args?: unknown): Promise<Result> =>
callRuntimeResult<Result>(method, mapRepoPathArg(args))
+3
View File
@@ -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<AiVaultAgent, string>
@@ -140,6 +142,7 @@ function buildAgentResumeInvocation(
case 'claude':
case 'cursor':
case 'gemini':
case 'grok':
case 'hermes':
case 'openclaw':
case 'droid':
+2 -1
View File
@@ -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,
+42
View File
@@ -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<typeof starNagOutcomeSchema>
export type StarNagPromptSource = z.infer<typeof starNagPromptSourceSchema>
export type StarNagPromptMode = z.infer<typeof starNagPromptModeSchema>
export type StarNagAgentBucket = z.infer<typeof starNagAgentBucketSchema>
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+'
}
+77
View File
@@ -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({
+25
View File
@@ -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'
+3
View File
@@ -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. */
@@ -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) => {