mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/linear-cli-v2
This commit is contained in:
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 & 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 & 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub & 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>
|
||||
<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>
|
||||
<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>
|
||||
<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 & 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>
|
||||
<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 & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
- **Show Support:** Star this repo to follow along with our daily ships.
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
- **Show Support:** [Star](https://github.com/stablyai/orca) this repo to follow along with our daily ships.
|
||||
|
||||
---
|
||||
|
||||
## Developing
|
||||
|
||||
Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
|
||||
|
||||
<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
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 & 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 & 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 & 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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<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>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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)。
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
localAccountRuntime: 'host',
|
||||
localAccountWslDistro: null,
|
||||
openLinksInApp: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
showTitlebarAppName: true,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
+158
@@ -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'])
|
||||
})
|
||||
})
|
||||
+51
-1
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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+'
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user