Merge branch 'nwparker/term-speed-2-skip-grammar-removal' into nwparker/term-speed-2-architecture-docs

This commit is contained in:
Neil
2026-06-16 16:13:08 -07:00
2142 changed files with 227343 additions and 34111 deletions
-3
View File
@@ -1,3 +0,0 @@
# Why: enables window.__store in the renderer build so E2E tests can read
# Zustand state directly instead of fragile DOM scraping.
VITE_EXPOSE_STORE=true
@@ -0,0 +1,112 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
const repository = process.env.GITHUB_REPOSITORY ?? 'stablyai/orca'
const token = process.env.GITHUB_TOKEN
const outputPath = process.env.DOWNLOADS_BADGE_PATH ?? 'docs/assets/readme-downloads.svg'
const headers = {
Accept: 'application/vnd.github+json',
'User-Agent': 'orca-readme-downloads-badge',
'X-GitHub-Api-Version': '2022-11-28'
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
async function fetchJson(url) {
const response = await fetch(url, { headers })
if (!response.ok) {
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
async function getTotalReleaseDownloads() {
let page = 1
let total = 0
while (true) {
const releases = await fetchJson(
`https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`
)
if (releases.length === 0) {
return total
}
for (const release of releases) {
if (release.draft) {
continue
}
for (const asset of release.assets ?? []) {
total += asset.download_count ?? 0
}
}
page += 1
}
}
function formatDownloads(total) {
if (total < 1000) {
return String(total)
}
if (total < 1_000_000) {
return `${Math.round(total / 1000)}k`
}
const rounded = total / 1_000_000
return `${rounded >= 10 ? Math.round(rounded) : rounded.toFixed(1)}m`
}
function textWidth(label) {
return Math.ceil(label.length * 7.1 + 10)
}
function escapeXml(value) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
function renderBadge(value) {
const label = 'downloads'
const leftWidth = textWidth(label)
const rightWidth = textWidth(value)
const width = leftWidth + rightWidth
const labelX = leftWidth / 2
const valueX = leftWidth + rightWidth / 2
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${escapeXml(label)}: ${escapeXml(value)}">
<title>${escapeXml(label)}: ${escapeXml(value)}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="${width}" height="20" rx="3"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="${leftWidth}" height="20" fill="#555"/>
<rect x="${leftWidth}" width="${rightWidth}" height="20" fill="#4c1"/>
<rect width="${width}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(label)}</text>
<text x="${labelX}" y="14">${escapeXml(label)}</text>
<text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(value)}</text>
<text x="${valueX}" y="14">${escapeXml(value)}</text>
</g>
</svg>
`
}
const total = await getTotalReleaseDownloads()
const badge = renderBadge(formatDownloads(total))
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, badge)
console.log(`Rendered ${outputPath} from ${total} downloads.`)
@@ -1,9 +1,12 @@
name: Mobile Release
name: Mobile Android Release
# Why a separate workflow from iOS: iOS releases go through App Store review,
# which can take days. Decoupling the triggers lets an Android release ship
# immediately without waiting on iOS, and vice versa.
on:
push:
tags:
- 'mobile-v*'
- 'mobile-android-v*'
workflow_dispatch:
jobs:
@@ -57,14 +60,14 @@ jobs:
path: mobile/android/app/build/outputs/apk/release/*.apk
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/mobile-v')
if: startsWith(github.ref, 'refs/tags/mobile-android-v')
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--title "Orca Mobile $tag" \
--title "Orca Mobile Android $tag" \
--prerelease \
--latest=false \
--generate-notes \
+108
View File
@@ -0,0 +1,108 @@
name: Mobile iOS Release
# Why a separate workflow from Android: iOS releases go through App Store
# review, which can take days. Decoupling the triggers lets an Android release
# ship immediately without waiting on iOS, and vice versa.
on:
push:
tags:
- 'mobile-ios-v*'
workflow_dispatch:
jobs:
ios-build:
# GitHub-hosted macOS runner: required for Xcode. Expo SDK 55's
# expo-modules-core declares swift_version 6.0 and uses Swift 6 syntax
# (@MainActor isolation). Xcode 16.x (macos-15) can't even parse it
# ("unknown attribute 'MainActor'"), so we need Xcode 26.x → macos-26.
runs-on: macos-26
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
# Xcode 26.x ships the Swift 6.x toolchain Expo SDK 55 requires.
xcode-version: '26.5'
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: mobile
- name: Expo prebuild
run: npx expo prebuild --platform ios --no-install
- name: Install CocoaPods
run: npx pod-install ios
# Why: `-allowProvisioningUpdates` + the App Store Connect API key can
# create/refresh provisioning profiles, but it cannot recreate the
# distribution certificate's PRIVATE KEY across runs. So we import a
# pre-exported distribution .p12 (created once via Apple Developer) into a
# throwaway keychain. The keychain is ephemeral to the runner and torn
# down with the VM; nothing secret is written to the repo.
- name: Import distribution certificate
env:
IOS_DIST_CERT_P12: ${{ secrets.IOS_DIST_CERT_P12 }}
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/orca-signing.keychain-db"
# Random per-run keychain password; never persisted.
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
CERT_PATH="$RUNNER_TEMP/orca-dist-cert.p12"
echo "$IOS_DIST_CERT_P12" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$IOS_DIST_CERT_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
# Allow codesign/xcodebuild to use the key without an interactive prompt.
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
# Put our keychain in the search list so xcodebuild can find the identity.
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
rm -f "$CERT_PATH"
- name: Build and upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Keep fastlane non-interactive and quiet about analytics in CI.
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: bundle exec fastlane ios release
- name: Upload .ipa artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: orca-mobile-ipa
path: mobile/build/*.ipa
if-no-files-found: ignore
+12
View File
@@ -32,6 +32,18 @@ jobs:
with:
run_install: false
# Why: the mobile typecheck imports shared types from ../src/shared, and
# some of those files import runtime deps (tweetnacl, ws) resolved from
# the repo-root node_modules. Without a root install, tsc fails with
# "Cannot find module 'tweetnacl'/'ws'". Mobile is a separate pnpm project
# (not in the root workspace), so this is a distinct install.
# --ignore-scripts skips the root postinstall (Electron native-module
# rebuild) which is irrelevant to a type-only check and would only add
# time and failure surface on this ubuntu mobile runner.
- name: Install root dependencies
working-directory: .
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -0,0 +1,48 @@
name: README Downloads Badge
on:
workflow_dispatch:
schedule:
- cron: '17 */6 * * *'
release:
types:
- published
- edited
- deleted
permissions:
contents: write
concurrency:
group: readme-downloads-badge
cancel-in-progress: false
jobs:
update:
# Why: this workflow commits to main, so forks should not create divergent badge commits.
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
- name: Render downloads badge
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/render-readme-downloads-badge.mjs
- name: Commit badge update
run: |
set -euo pipefail
if git diff --quiet -- docs/assets/readme-downloads.svg; then
echo "Downloads badge is already current."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add docs/assets/readme-downloads.svg
git commit -m "Update README downloads badge"
git push origin main
+5
View File
@@ -93,6 +93,10 @@ docs/reference/react-performance-audit.md
validation-screenshots/
.stably-browser
# PR verification evidence screenshots are referenced from notes but should not
# be committed.
notes/artifacts/
# Playwright
test-results/
playwright-report/
@@ -110,3 +114,4 @@ validation-screenshots/
src/renderer/src/i18n/locales/.zh-catalog-cache.json
src/renderer/src/i18n/locales/.ko-catalog-cache.json
src/renderer/src/i18n/locales/.ja-catalog-cache.json
src/renderer/src/i18n/locales/.es-catalog-cache.json
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+202 -115
View File
@@ -4,152 +4,231 @@
<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>
</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>
</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>.
</p>
<p align="center">
<a href="#install"><strong>Download 🐋</strong></a>
</p>
<p align="center">
<img src="https://badgen.net/github/release/stablyai/orca/stable" alt="Latest stable release" />
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" 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">
<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" />
<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>
## Supported Agents
Orca supports any CLI agent (_not just this list_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
<p align="center">
<strong>The AI Orchestrator for 100x builders.</strong><br/>
Run Codex, ClaudeCode, OpenCode or Pi 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">
<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>
## 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.13/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
**Work anywhere**
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="docs/assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="docs/assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop with the mobile companion app" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
- **SSH support** — Connect to remote machines and run agents there directly from Orca.
- **Mobile companion** — Monitor and steer agents from your phone, with emulator-friendly mobile workflows.
- **Computer Use** — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
- **Notifications and unread state** — Know when an agent finishes or needs attention, then mark threads unread to come back later.
### Parallel Worktrees
Fan one prompt across five agents, each in its own isolated git worktree — compare the results and merge the winner.
[Docs →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="docs/assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/parallel-worktrees.jpg" alt="Parallel worktree orchestration" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Terminal Splits
Ghostty-class terminals with WebGL rendering, infinite splits, and scrollback that survives restarts.
[Docs →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="docs/assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="docs/assets/feature-wall/terminal-splits.jpg" alt="Terminal splits" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Design Mode
Click any UI element in a real Chromium window to send its HTML, CSS, and a cropped screenshot straight into your agent's prompt.
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="docs/assets/feature-wall/design-mode.gif" type="image/gif"><img src="docs/assets/feature-wall/design-mode.jpg" alt="Embedded browser and Design Mode" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; Linear, Native
Browse PRs, issues, and project boards in-app — open a worktree from any task and review without a context switch.
[Docs →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="docs/assets/feature-wall/github-linear.gif" type="image/gif"><img src="docs/assets/feature-wall/github-linear.jpg" alt="GitHub and Linear task workflows in Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH Worktrees
Run agents on a beefy remote box with full file editing, git, and terminals — auto-reconnect and port forwarding included.
[Docs →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="docs/assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/ssh-worktrees.jpg" alt="Remote worktrees over SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Annotate AI Diffs
Drop comments on any diff line and ship them back to the agent — review, edit, and commit without leaving Orca.
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="docs/assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="docs/assets/feature-wall/annotate-diff.jpg" alt="Annotate AI-generated diffs" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Drag Files to Agents
VS Code's editor with autosave everywhere — drag files or images straight into an agent prompt.
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="docs/assets/feature-wall/file-drag.gif" type="image/gif"><img src="docs/assets/feature-wall/file-drag.jpg" alt="Drag files and images into an agent prompt" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
Agents drive Orca too — script every workflow with `orca worktree create`, `snapshot`, `click`, and `fill`.
[Docs →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**Also in the box:**
- **[Quick open](https://www.onorca.dev/docs/model/quick-open)** — Search across worktrees, files, agents, commands, and repo context without leaving your flow.
- **[Account switcher &amp; usage tracking](https://www.onorca.dev/docs/agents/usage-tracking)** — See Claude and Codex usage and rate-limit resets, and hot-swap accounts without re-logging in.
- **[Rich repo previews](https://www.onorca.dev/docs/editing/markdown)** — Preview Markdown, images, PDFs, and repo docs in the workspace.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
- **[Notifications and unread state](https://www.onorca.dev/docs/notifications)** — Know when an agent finishes or needs attention, then mark threads unread to come back later.
- **And many, many more** — we ship daily, so this list is perpetually behind. The [changelog](https://github.com/stablyai/orca/releases) is the real feature list.
---
## Supported Agents
Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## Install
### Mac, Linux, Windows
### Desktop — macOS, Windows, Linux
- **[Download from onOrca.dev](https://onOrca.dev)**
- Or via **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**
- **[Download from onOrca.dev](https://onorca.dev/download)**
- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
_Alternatively, install from a package manager:_
### macOS (Homebrew)
_Or via a package manager:_
```bash
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
```
### Arch Linux (AUR)
```bash
# Precompiled binary
# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
# Build from GitHub source
yay -S stably-orca-git
```
---
### Mobile Companion — iOS, Android
## Mobile Companion App
Pair with your desktop app to monitor and steer your agents from your phone.
Control your agents from your phone.
<p align="center">
<picture><source srcset="docs/assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="docs/assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop with the mobile companion app" width="720" /></picture>
</p>
- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download APK from GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
## Feature Showcase
Click any tile to explore the workflow.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Parallel Worktrees</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/parallel-worktrees.jpg" alt="Parallel worktree orchestration" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminal Splits</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="docs/assets/feature-wall/terminal-splits.jpg" alt="Ghostty-class terminal splits" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Design Mode</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/design-mode.gif" type="image/gif"><img src="docs/assets/feature-wall/design-mode.jpg" alt="Embedded browser and Design Mode" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub &amp; Linear, Native</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/github-linear.gif" type="image/gif"><img src="docs/assets/feature-wall/github-linear.jpg" alt="GitHub and Linear task workflows in Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Every CLI Agent</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/cli-agents.gif" type="image/gif"><img src="docs/assets/feature-wall/cli-agents.jpg" alt="Works with every CLI agent" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktrees</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/ssh-worktrees.jpg" alt="Remote worktrees over SSH" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Drag Files to Agents</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/file-drag.gif" type="image/gif"><img src="docs/assets/feature-wall/file-drag.jpg" alt="Drag files and images into an agent prompt" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Annotate AI Diffs</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="docs/assets/feature-wall/annotate-diff.jpg" alt="Annotate AI-generated diffs" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Native Search</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="docs/assets/feature-wall/keyboard-native.jpg" alt="Native search across Orca workflows" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Account Switcher &amp; Usage Tracking</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="docs/assets/feature-wall/codex-accounts.jpg" alt="Account switching and usage tracking" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Rich Repo Previews</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="docs/assets/feature-wall/markdown-editor.jpg" alt="Markdown, images, PDFs, and repo document previews" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Split Anything</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/split-screen.gif" type="image/gif"><img src="docs/assets/feature-wall/split-screen.jpg" alt="Split panes for agents, terminals, browsers, and files" width="390" /></picture><br/></kbd></a>
</p>
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk)
---
@@ -158,11 +237,19 @@ Click any tile to explore the workflow.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
- **Show Support:** Star this repo to follow along with our daily ships.
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
- **Show Support:** [Star](https://github.com/stablyai/orca) this repo to follow along with our daily ships.
---
## Developing
Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
</a>
## License
Orca is free and open source under the [MIT License](LICENSE).
+8 -1
View File
@@ -238,12 +238,19 @@ module.exports = {
artifactName: 'orca-macos-${arch}.${ext}'
},
linux: {
// Why: Ubuntu 26 ships GNOME Orca as the `orca` package and /usr/bin/orca.
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
// Why: the icns source lets electron-builder emit standard hicolor PNG
// sizes; a single 1024px PNG is ignored by some Linux docks/launchers.
icon: 'resources/build/icon.icns',
desktop: {
entry: {
// Why: Electron reports WM_CLASS=orca for the visible Linux window;
// GNOME docks need an exact match to group it with orca-ide.desktop.
StartupWMClass: 'orca'
}
},
extraResources: [
...commonExtraResources,
linuxSpeechNativeResource,
+12
View File
@@ -46,6 +46,18 @@ Run the maintained coverage gate:
pnpm run verify:localization-coverage
```
Sync catalog keys after adding or removing `translate(...)` calls:
```sh
pnpm run sync:localization-catalog
```
The sync command adds missing `en.json` entries from each call's string fallback,
copies untranslated English placeholders into other locale catalogs to keep
parity, removes locale entries whose English key was deleted, and repairs
placeholder mismatches. Run the machine-translation bootstrap commands only when
refreshing real translations, not for ordinary UI copy changes.
The coverage gate compares current candidates against
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
new candidates fail the check and must be localized or added with a reviewed
@@ -29,6 +29,11 @@ const LOCALE_CONFIG = {
targetLanguage: 'ja',
displayName: 'Japanese',
cacheFile: '.ja-catalog-cache.json'
},
es: {
targetLanguage: 'es',
displayName: 'Spanish',
cacheFile: '.es-catalog-cache.json'
}
}
@@ -67,6 +67,10 @@ describe('electron-builder config', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
it('matches the Linux desktop entry to Electron window class', () => {
expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca')
})
it('builds RPMs without changing existing Linux artifact names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm'])
expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}')
@@ -0,0 +1,44 @@
export async function installSyntheticVisibleSpinners(page, count, animation, steps) {
if (count <= 0) {
return
}
const animationTiming =
animation === 'steps' ? `1s steps(${steps}, end) infinite` : '1s linear infinite'
await page.addStyleTag({
content: `
@keyframes orca-idle-bench-spin {
to { transform: rotate(360deg); }
}
.orca-idle-bench-spinner-host {
position: fixed;
top: 16px;
right: 16px;
z-index: 2147483647;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.orca-idle-bench-spinner {
width: 10px;
height: 10px;
border: 2px solid rgb(234 179 8);
border-top-color: transparent;
border-radius: 9999px;
animation: orca-idle-bench-spin ${animationTiming};
}
`
})
await page.evaluate((spinnerCount) => {
document.querySelector('[data-orca-idle-bench-spinners]')?.remove()
const host = document.createElement('div')
host.className = 'orca-idle-bench-spinner-host'
host.setAttribute('data-orca-idle-bench-spinners', String(spinnerCount))
for (let index = 0; index < spinnerCount; index += 1) {
const spinner = document.createElement('div')
spinner.className = 'orca-idle-bench-spinner'
host.appendChild(spinner)
}
document.body.appendChild(host)
}, count)
}
+73 -1
View File
@@ -134,6 +134,8 @@ export const JA_VALUE_OVERRIDES = {
'Add a project first': 'まずプロジェクトを追加',
'Pick a base branch below': '以下のベースブランチを選択',
'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択',
'Local project, Git repo, or folder with many repos':
'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー',
'Enter passphrase': 'パスフレーズを入力',
'Enter password': 'パスワードを入力',
'Enter the passphrase for': 'のパスフレーズを入力',
@@ -151,5 +153,75 @@ export const JA_VALUE_OVERRIDES = {
'Enter a commit message to commit': 'コミットメッセージを入力してコミット',
'Choose parent folder...': '親フォルダーを選択...',
'Check for stuck work, stale generated files, failing validation, and anything that needs human attention. Report only actionable issues.':
'スタックした作業、古い生成ファイル、検証の失敗、および人間の対応が必要なものがないか確認してください。対応が必要なイシューのみを報告してください。'
'スタックした作業、古い生成ファイル、検証の失敗、および人間の対応が必要なものがないか確認してください。対応が必要なイシューのみを報告してください。',
// Round 6: homograph/term mistranslations surfaced by a full ja.json audit.
// Why: machine translation rendered UI terms as unrelated everyday words.
Smart: 'スマート',
Jobs: 'ジョブ',
Bold: '太字',
Strike: '取り消し線',
thread: 'スレッド',
Lead: 'リード',
Assignees: '担当者',
'Assignees:': '担当者:',
Origin: 'オリジン',
Force: '強制',
Kind: '種類',
Address: 'アドレス',
Mouse: 'マウス',
Home: 'ホーム',
Move: '移動',
Change: '変更',
Conflicts: '競合',
conflict: '競合',
'unresolved conflict': '未解決の競合',
'Next match': '次の一致',
'Previous match': '前の一致',
'No matches': '一致なし',
'New Issue': '新規イシュー',
'Issue title': 'イシューのタイトル',
'Sub-issue title': 'サブイシューのタイトル',
Merged: 'マージ済み',
Behind: '遅れ',
'Head branch': 'ヘッドブランチ',
Import: 'インポート',
'Import…': 'インポート…',
'Import...': 'インポート...',
'Re-import': '再インポート',
Local: 'ローカル',
local: 'ローカル',
'Scanning...': 'スキャン中...',
Sparse: 'スパース',
sparse: 'スパース',
connection: '接続',
'Est. cost': '推定コスト',
'Est. API-equivalent cost': 'API 相当の推定コスト',
'Est. spend': '推定費用',
Spend: '費用',
'new markdown': '新規 markdown',
working: '実行中',
'Working…': '処理中…',
On: 'オン',
Hold: 'ホールド',
'Keep alive until reset': 'リセットされるまで維持',
Forward: '進む',
Fresh: '新規',
Gone: '削除済み',
Ding: 'ディン',
'Take back': '操作を取り戻す',
'Mobile driving': 'モバイルで操作中',
'Mobile is driving this browser': 'モバイルがこのブラウザを操作しています',
'Wants to run': '実行をリクエスト中',
'No states found': 'ステータスが見つかりません',
'Loading states': 'ステータスを読み込み中',
vibrancy: '透過効果',
ligature: '合字',
Zinc: 'ジンク',
Rose: 'ローズ',
import: 'インポート',
mouse: 'マウス',
'Open job in GitLab': 'GitLab でジョブを開く',
'Showing first 100 jobs': '最初の 100 件のジョブを表示しています',
'Imported from Ghostty.': 'Ghostty からインポートしました。',
'Claude Accounts': 'Claude アカウント'
}
+2
View File
@@ -590,5 +590,7 @@ export const LOCALE_KEY_OVERRIDES = {
zh: '已添加评审评论。',
ja: 'レビューコメントを追加しました。'
},
// Port forwarding "Forward" is 転送, not the browser-navigation 進む.
'auto.components.right.sidebar.PortsPanel.c9d106547a': { ja: '転送' },
...KO_KEY_OVERRIDES
}
@@ -204,6 +204,8 @@ export const KO_VALUE_OVERRIDES = {
'to submit.': '제출.',
'Install the Orca skill so agents know to use the Orca CLI.':
'에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.',
'Local project, Git repo, or folder with many repos':
'로컬 프로젝트, Git repo 또는 repos가 많은 폴더',
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.':
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.',
'changed since you last approved. Re-review before it runs':
+3
View File
@@ -197,6 +197,9 @@ export const LOCALE_PHRASE_FIXES = {
replacement: '拉取请求已合并',
whenEnIncludes: 'Pull request merged'
},
{ pattern: /PR已/g, replacement: '拉取请求已', whenEnIncludes: 'Pull request' },
{ pattern: /此PR/g, replacement: '此拉取请求', whenEnIncludes: 'pull request' },
{ pattern: /先生!/g, replacement: 'MR !', whenEnIncludes: 'MR' },
{ pattern: /USB设备/g, replacement: 'USB 设备', whenEnIncludes: 'USB Devices' },
{ pattern: /球队/g, replacement: '团队', whenEnIncludes: 'teams' },
{
@@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.',
locale: 'ko'
})
).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
expect(
repairTranslatedValue({
key: 'auto.components.editor.MarkdownPreview.322afab6ff',
@@ -83,4 +83,55 @@ describe('locale-translation-policy ko round 5', () => {
'작업 중단, 오래 생성된 파일, 유효성 검사 실패 및 사람의 주의가 필요한 모든 사항을 확인하세요. 실행 가능한 이슈만 보고하세요.'
)
})
it('keeps protected workflow terms in English', () => {
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
enValue: 'Terminal 1',
localeValue: '터미널 1',
locale: 'ko'
})
).toBe('Terminal 1')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
enValue: 'Agent Skills',
localeValue: '에이전트 스킬',
locale: 'ko'
})
).toBe('Agent 스킬')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
enValue: 'Markdown',
localeValue: '가격 인하',
locale: 'ko'
})
).toBe('Markdown')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d',
enValue: 'Unpushed commits',
localeValue: '푸시되지 않은 커밋',
locale: 'ko'
})
).toBe('푸시되지 않은 commits')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
enValue: 'Repo',
localeValue: '레포',
locale: 'ko'
})
).toBe('Repo')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
enValue: 'Local project, Git repo, or folder with many repos',
localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
locale: 'ko'
})
).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더')
})
})
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
describe('locale-translation-policy es round 5', () => {
it('keeps protected workflow terms in English', () => {
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
enValue: 'Markdown',
localeValue: 'Reducción',
locale: 'es'
})
).toBe('Markdown')
expect(
repairTranslatedValue({
key: 'auto.components.TaskPage.7f3f7b4c18',
enValue: 'Description (optional, markdown)',
localeValue: 'Descripción (opcional, rebaja)',
locale: 'es'
})
).toBe('Descripción (opcional, markdown)')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
enValue: 'Commit',
localeValue: 'Comprometerse',
locale: 'es'
})
).toBe('Commit')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.GitHistoryPanel.cf7cad58d2',
enValue: 'No commits yet',
localeValue: 'Aún no hay compromisos',
locale: 'es'
})
).toBe('Aún no hay commits')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.SourceControl.b94112eb9e',
enValue: 'Commit message',
localeValue: 'mensaje de confirmación',
locale: 'es'
})
).toBe('mensaje de Commit')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
enValue: 'Repo',
localeValue: 'repositorio',
locale: 'es'
})
).toBe('Repo')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
enValue: 'Local project, Git repo, or folder with many repos',
localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios',
locale: 'es'
})
).toBe('Proyecto local, repo de Git o carpeta con muchos repos')
})
})
@@ -101,4 +101,56 @@ describe('locale-translation-policy ja round 5', () => {
})
).toBe('まずプロジェクトを追加')
})
it('keeps protected workflow terms in English', () => {
expect(
repairTranslatedValue({
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
enValue: 'Terminals',
localeValue: '端子',
locale: 'ja'
})
).toBe('Terminals')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
enValue: 'Agent Skills',
localeValue: 'エージェントのスキル',
locale: 'ja'
})
).toBe('Agent のスキル')
expect(
repairTranslatedValue({
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
enValue: 'New Markdown',
localeValue: '新規マークダウン',
locale: 'ja'
})
).toBe('新規 Markdown')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.local.base.ref.suggestion.toast.commits',
enValue: 'commits',
localeValue: 'コミット',
locale: 'ja'
})
).toBe('commits')
expect(
repairTranslatedValue({
key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df',
enValue: 'Repo',
localeValue: 'リポ',
locale: 'ja'
})
).toBe('Repo')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
enValue: 'Local project, Git repo, or folder with many repos',
localeValue:
'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
locale: 'ja'
})
).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー')
})
})
+324 -16
View File
@@ -15,6 +15,8 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.'
export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX]
export const NEVER_TRANSLATE_VALUES = new Set([
'Agent',
'Agents',
'Aider',
'Amp',
'Antigravity',
@@ -30,11 +32,14 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'Continue',
'Cursor',
'Droid',
'Devin',
'Gemini',
'GitHub Copilot',
'GitLab',
'Goose',
'Grok',
'Hermes',
'Jira',
'Kilocode',
'Kimi',
'Kiro',
@@ -44,16 +49,34 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'OpenClaude',
'OpenClaw',
'OpenCode',
'OpenCode Go',
'Orca',
'Pi',
'PostHog',
'Qwen Code',
'Repo',
'Repos',
'Rovo Dev',
'Commit',
'Commits',
'Markdown',
'Terminal',
'Terminals',
'VS Code',
'Warp',
'Zed',
'agent',
'agents',
'codex',
'commit',
'commits',
'gemini',
'claude',
'markdown',
'repo',
'repos',
'terminal',
'terminals',
'gh',
'idle',
'anthropic',
@@ -63,7 +86,90 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'darwin',
'Nautilus',
'GitHub',
'Beta'
'no_proxy',
'Beta',
// Round 6: product/tool names, language names, and code tokens that machine
// translation wrongly localized (e.g. tailscale→尾鱗, Swift→迅速, yarn→糸).
'Tailscale',
'tailscale',
'Ghostty',
'ghostty',
'pwsh',
'yarn',
'Kagi',
'kagi',
'Bitbucket',
'bitbucket',
'GNOME',
'gnome',
'iCloud',
'icloud',
'ripgrep',
'PowerShell',
'powershell',
'TypeScript',
'typescript',
'Mermaid',
'mermaid',
'Swift',
'swift',
'Rust',
'rust',
'Java',
'java',
'Go',
'Python',
'python',
'Kotlin',
'kotlin',
'Ruby',
'ruby',
'Bash',
'bash',
'GraphQL',
'graphql',
'iOS',
'iPhone',
'iPad',
'ide',
'IDE',
'ui',
'UI',
'otlp',
'OTLP',
'calt',
'ai',
'AI',
'ci',
'CI',
'REST',
'rest',
'YAML',
'yaml',
'yml',
'XML',
'SQL',
'CSS',
'Token',
'token',
'HTTP/1.1',
'HTTP/2',
'true',
'false',
'/home/user',
'/home/user/project',
'/path/to/destination',
'.orca/issue-command',
'PLAN.md',
'feat/mobile-page',
'sk-...',
'main',
'master',
'HEAD',
'lint',
'MD',
'/home/user/projects',
'Claude Code'
])
export const BRAND_MISTRANSLATIONS = {
@@ -84,7 +190,25 @@ export const BRAND_MISTRANSLATIONS = {
Pi: ['파이'],
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
Discord: ['디스코드'],
Linear: ['선형']
Linear: ['선형'],
Agent: ['에이전트'],
Agents: ['에이전트'],
agent: ['에이전트'],
agents: ['에이전트'],
Commit: ['커밋'],
Commits: ['커밋'],
commit: ['커밋'],
commits: ['커밋'],
Markdown: ['마크다운', '가격 인하'],
markdown: ['마크다운', '가격 인하'],
Repo: ['저장소', '레포'],
Repos: ['저장소', '레포'],
repo: ['저장소', '레포'],
repos: ['저장소', '레포'],
Terminal: ['터미널'],
Terminals: ['터미널'],
terminal: ['터미널'],
terminals: ['터미널']
},
zh: {
Codex: ['法典'],
@@ -103,8 +227,36 @@ export const BRAND_MISTRANSLATIONS = {
Pi: ['圆周率'],
Droid: ['机器人'],
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
Bitbucket: ['位桶'],
Linear: ['线性', '线形'],
Jira: ['吉拉']
Jira: ['吉拉'],
Tailscale: ['尾鳞', '尾鱗'],
Agent: ['代理', '智能体'],
Agents: ['代理', '智能体'],
agent: ['代理', '智能体'],
agents: ['代理', '智能体'],
Commit: ['提交'],
Commits: ['提交'],
commit: ['提交'],
commits: ['提交'],
Markdown: ['降价'],
markdown: ['降价'],
Repo: ['存储库', '仓库', '回购协议', '回购'],
Repos: ['存储库', '仓库', '回购协议', '回购'],
repo: ['存储库', '仓库', '回购协议', '回购'],
repos: ['存储库', '仓库', '回购协议', '回购'],
Terminal: ['终端', '端子'],
Terminals: ['终端', '端子'],
terminal: ['终端', '端子'],
terminals: ['终端', '端子'],
Bash: ['重击'],
PowerShell: ['电源外壳'],
REST: ['休息'],
HEAD: ['头'],
Swift: ['迅速'],
Rust: ['锈'],
'Claude Code': ['Claude·科德'],
'Git AI Author': ['Git AI 作者']
},
ja: {
Codex: ['法典', 'コーデックス'],
@@ -124,16 +276,111 @@ export const BRAND_MISTRANSLATIONS = {
Droid: ['ロボット', 'ドロイド'],
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
Discord: ['不和'],
Linear: ['線形']
Linear: ['線形'],
Agent: ['エージェント'],
Agents: ['エージェント'],
agent: ['エージェント'],
agents: ['エージェント'],
Commit: ['コミット'],
Commits: ['コミット'],
commit: ['コミット'],
commits: ['コミット'],
Markdown: ['マークダウン'],
markdown: ['マークダウン'],
Repo: ['リポジトリ', 'リポ'],
Repos: ['リポジトリ', 'リポ'],
repo: ['リポジトリ', 'リポ'],
repos: ['リポジトリ', 'リポ'],
Terminal: ['ターミナル', '端子'],
Terminals: ['ターミナル', '端子'],
terminal: ['ターミナル', '端子'],
terminals: ['ターミナル', '端子']
},
es: {
Codex: ['códice', 'Códice'],
Gemini: ['Géminis'],
Claude: ['claudia', 'Claudia'],
Orca: ['orca', 'Orcas', 'orcas'],
OpenCode: ['código abierto', 'Código abierto'],
OpenClaude: ['Openclaude'],
Antigravity: ['antigravedad', 'Antigravedad'],
'GitHub Copilot': ['Copiloto de GitHub'],
Discord: ['discordia'],
Linear: ['lineal', 'Lineal'],
Jira: ['jira'],
Agent: ['Agente', 'agente'],
Agents: ['Agentes', 'agentes'],
agent: ['agente'],
agents: ['agentes'],
Commit: ['Confirmación', 'confirmación', 'Confirmar', 'Comprometerse'],
Commits: ['Confirmaciones', 'confirmaciones', 'Compromisos', 'compromisos', 'Se compromete'],
commit: ['confirmación', 'confirmar', 'comprometerse', 'compromiso'],
commits: ['confirmaciones', 'compromisos'],
Markdown: ['Reducción', 'reducción', 'Rebaja', 'rebaja', 'rebajas'],
markdown: ['reducción', 'rebaja', 'rebajas'],
Repo: ['Repositorio', 'repositorio'],
Repos: ['Repositorios', 'repositorios'],
repo: ['repositorio'],
repos: ['repositorios']
}
}
export const NATIVE_PICKER_LABELS = {
zh: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語' },
ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語' },
ja: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語' }
zh: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
ja: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
es: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' }
}
const CJK_LATIN_SPACED_TERMS = [
'Terminal',
'Terminals',
'terminal',
'terminals',
'Agents',
'Agent',
'agents',
'agent',
'Markdown',
'markdown',
'Repos',
'Repo',
'repos',
'repo',
'Commits',
'Commit',
'commits',
'commit',
'Linear',
'GitHub',
'GitLab',
'Jira',
'Claude',
'Claude Code',
'Codex',
'Gemini',
'Kimi',
'OpenCode',
'Orca',
'Cursor',
'Bitbucket',
'Tailscale',
'Kagi',
'SSH',
'WSL',
'PR',
'MR',
'REST',
'HEAD',
'Bash',
'PowerShell',
'Git AI Author',
'Token',
'token'
]
const CJK_LATIN_SPACED_TERM_PATTERN = CJK_LATIN_SPACED_TERMS.join('|')
export function isEnglishOnlyKey(key) {
return ENGLISH_ONLY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
}
@@ -151,15 +398,28 @@ export function shouldPreserveEnglishValue(enValue, key = '') {
return NEVER_TRANSLATE_VALUES.has(enValue)
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function includesPreservedLatinTerm(value, term) {
if (!/^[A-Za-z_]+$/.test(term)) {
return value.includes(term)
}
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
}
function applyBrandMistranslationFixes(enValue, localeValue, locale) {
let result = localeValue
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
for (const [brand, wrongForms] of Object.entries(mistranslations)) {
for (const [brand, wrongForms] of Object.entries(mistranslations).sort(
([left], [right]) => right.length - left.length
)) {
if (!enValue.includes(brand)) {
continue
}
if (result.includes(brand)) {
if (includesPreservedLatinTerm(result, brand)) {
continue
}
for (const wrong of wrongForms) {
@@ -177,6 +437,39 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale) {
return result
}
function applyCjkLatinTermSpacing(localeValue, locale) {
// Why: CJK UI copy should keep protected Latin workflow terms readable when MT glues them to native text.
let result = localeValue
.replace(
new RegExp(
`(${CJK_LATIN_SPACED_TERM_PATTERN})([\\u3040-\\u30ff\\u3400-\\u9fff\\uac00-\\ud7af])`,
'g'
),
'$1 $2'
)
.replace(
new RegExp(
`([\\u3040-\\u30ff\\u3400-\\u9fff\\uac00-\\ud7af])(${CJK_LATIN_SPACED_TERM_PATTERN})`,
'g'
),
'$1 $2'
)
.replace(
new RegExp(`(${CJK_LATIN_SPACED_TERM_PATTERN})(${CJK_LATIN_SPACED_TERM_PATTERN})`, 'g'),
'$1 $2'
)
if (locale === 'ko') {
result = result.replace(
new RegExp(
`(${CJK_LATIN_SPACED_TERM_PATTERN}) (가|이|은|는|을|를|와|과|의|로|으로|에서|에게|도|만|부터|까지)`,
'g'
),
'$1$2'
)
}
return result
}
function applyPhraseFixes(enValue, localeValue, locale) {
let result = localeValue
for (const fix of LOCALE_PHRASE_FIXES[locale] ?? []) {
@@ -191,28 +484,43 @@ function applyPhraseFixes(enValue, localeValue, locale) {
export function repairTranslatedValue({ key, enValue, localeValue, locale }) {
const keyOverride = LOCALE_KEY_OVERRIDES[key]?.[locale]
if (keyOverride) {
return keyOverride
// Why: exact key overrides can still carry stale MT output, so glossary repairs remain the final gate.
let result = applyBrandMistranslationFixes(enValue, keyOverride, locale)
result = applyPhraseFixes(enValue, result, locale)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
return result
}
const valueOverride = LOCALE_VALUE_OVERRIDES[locale]?.[enValue]
if (valueOverride) {
let result = applyBrandMistranslationFixes(enValue, valueOverride, locale)
result = applyPhraseFixes(enValue, result, locale)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
return result
}
if (shouldPreserveEnglishValue(enValue, key)) {
return enValue
}
const override = LOCALE_VALUE_OVERRIDES[locale]?.[enValue]
if (override) {
return override
}
let result = localeValue
if (key.includes('.search.')) {
const searchOverride = SEARCH_KEYWORD_OVERRIDES[locale]?.[enValue]
if (searchOverride) {
return searchOverride
result = searchOverride
}
}
let result = localeValue
result = applyBrandMistranslationFixes(enValue, result, locale)
result = applyPhraseFixes(enValue, result, locale)
if (['zh', 'ja', 'ko'].includes(locale)) {
result = applyCjkLatinTermSpacing(result, locale)
}
if (enValue.includes('orca://')) {
result = result.replace(/虎鲸:\/\//g, 'orca://')
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
locale: 'ja'
})
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
).toBe('失敗したチェックに対して AI agent を開始しました。')
expect(
repairTranslatedValue({
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
localeValue: '電話機からターミナルとエージェントを制御します。',
locale: 'ja'
})
).toBe('スマートフォンからターミナルとエージェントを操作')
).toBe('スマートフォンから terminals と agents を操作')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.934add88b6',
@@ -267,7 +267,7 @@ describe('locale-translation-policy', () => {
localeValue: '未已检测代理',
locale: 'zh'
})
).toBe('未检测到代理')
).toBe('未检测到 agents')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
@@ -275,7 +275,7 @@ describe('locale-translation-policy', () => {
localeValue: '代理技巧',
locale: 'zh'
})
).toBe('代理技能')
).toBe('Agent 技能')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.9ae151b26b',
@@ -358,7 +358,7 @@ describe('locale-translation-policy', () => {
localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。',
locale: 'ja'
})
).toBe('コミットするものはありません。PR はすでにマージされています。')
).toBe('commit するものはありません。PR はすでにマージされています。')
expect(
repairTranslatedValue({
key: 'auto.components.settings.integrations.search.581844769a',
@@ -85,4 +85,273 @@ describe('locale-translation-policy zh round 5', () => {
})
).toBe('集成')
})
it('keeps Terminal as a product surface term', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.Settings.3de4bbb841',
enValue: 'Terminal',
localeValue: '终端',
locale: 'zh'
})
).toBe('Terminal')
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
enValue: 'Terminal 1',
localeValue: '终端 1',
locale: 'zh'
})
).toBe('Terminal 1')
expect(
repairTranslatedValue({
key: 'auto.components.agent.AgentCombobox.986f946354',
enValue: 'Blank Terminal',
localeValue: '空白端子',
locale: 'zh'
})
).toBe('空白 Terminal')
expect(
repairTranslatedValue({
key: 'auto.components.terminal.pane.TerminalContextMenu.20e565d865',
enValue: 'Split Terminal Right',
localeValue: '分体式端子右',
locale: 'zh'
})
).toBe('向右拆分 Terminal')
expect(
repairTranslatedValue({
key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
enValue: 'Terminal Cursor',
localeValue: '终端Cursor',
locale: 'zh'
})
).toBe('Terminal Cursor')
expect(
repairTranslatedValue({
key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9',
enValue: 'New Terminal',
localeValue: '新Terminal',
locale: 'zh'
})
).toBe('新 Terminal')
})
it('keeps workflow terms in English', () => {
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.SidebarNav.9c95e1ce91',
enValue: 'Agents',
localeValue: '代理',
locale: 'zh'
})
).toBe('Agents')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.28986b3747',
enValue: 'Started an AI agent for the broken checks.',
localeValue: '已启动 AI 代理处理失败的检查。',
locale: 'zh'
})
).toBe('已启动 AI agent 处理失败的检查。')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
enValue: 'Markdown',
localeValue: '降价',
locale: 'zh'
})
).toBe('Markdown')
expect(
repairTranslatedValue({
key: 'auto.components.TaskPage.7f3f7b4c18',
enValue: 'Description (optional, markdown)',
localeValue: '描述(可选,降价)',
locale: 'zh'
})
).toBe('描述(可选,markdown)')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.local.base.ref.suggestion.toast.commits',
enValue: 'commits',
localeValue: '次提交',
locale: 'zh'
})
).toBe('commits')
expect(
repairTranslatedValue({
key: 'auto.store.slices.worktrees.d1d78a7baa',
enValue:
'Git could not safely delete branch "{{value0}}"{{value1}}, so Orca kept it to avoid losing local commits.',
localeValue:
'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。',
locale: 'zh'
})
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。')
})
it('does not confuse proxy copy with Agent terminology', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.GeneralNetworkSettingsSection.f6d76cc8f4',
enValue: 'Proxy Bypass Rules',
localeValue: 'Agent绕过规则',
locale: 'zh'
})
).toBe('代理绕过规则')
expect(
repairTranslatedValue({
key: 'auto.components.settings.GeneralNetworkSettingsSection.1e214e265a',
enValue:
'Leave empty to use system proxy settings and inherited proxy environment variables.',
localeValue: '留空以使用系统Agent设置和继承的Agent环境变量。',
locale: 'zh'
})
).toBe('留空以使用系统代理设置和继承的代理环境变量。')
expect(
repairTranslatedValue({
key: 'auto.components.settings.general.search.91a46caafc',
enValue: 'no_proxy',
localeValue: '无Agent',
locale: 'zh'
})
).toBe('no_proxy')
})
it('keeps repo terminology in English', () => {
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
enValue: 'Repo',
localeValue: '回购协议',
locale: 'zh'
})
).toBe('Repo')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
enValue: 'Local project, Git repo, or folder with many repos',
localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹',
locale: 'zh'
})
).toBe('本地项目、Git repo 或包含多个 repos 的文件夹')
})
it('keeps product, provider, code, and shell tokens untranslated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.IntegrationsPane.8489c0aa49',
enValue: 'Bitbucket',
localeValue: '位桶',
locale: 'zh'
})
).toBe('Bitbucket')
expect(
repairTranslatedValue({
key: 'auto.components.settings.integrations.search.50d20817f7',
enValue: 'bitbucket',
localeValue: '位桶',
locale: 'zh'
})
).toBe('Bitbucket')
expect(
repairTranslatedValue({
key: 'auto.components.settings.MobileNetworkInterfaceSection.1dc87a7fbc',
enValue: 'Tailscale',
localeValue: '尾鳞',
locale: 'zh'
})
).toBe('Tailscale')
expect(
repairTranslatedValue({
key: 'auto.components.tab.bar.TabBar.efb33546ff',
enValue: 'Git Bash',
localeValue: 'git 重击',
locale: 'zh'
})
).toBe('git bash')
expect(
repairTranslatedValue({
key: 'auto.components.tab.bar.TabBar.2148f65e04',
enValue: 'PowerShell',
localeValue: '电源外壳',
locale: 'zh'
})
).toBe('PowerShell')
expect(
repairTranslatedValue({
key: 'auto.components.github.github.rate.limit.display.bb227706a6',
enValue: 'REST',
localeValue: '休息',
locale: 'zh'
})
).toBe('REST')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.GitHistoryGraphSvg.47eff48230',
enValue: 'HEAD',
localeValue: '头',
locale: 'zh'
})
).toBe('HEAD')
expect(
repairTranslatedValue({
key: 'auto.components.editor.RichMarkdownCodeBlock.9e384d48dc',
enValue: 'Swift',
localeValue: '迅速',
locale: 'zh'
})
).toBe('Swift')
expect(
repairTranslatedValue({
key: 'auto.components.editor.RichMarkdownCodeBlock.e72e6b03f4',
enValue: 'Rust',
localeValue: '锈',
locale: 'zh'
})
).toBe('Rust')
})
it('normalizes zh product spacing and contextual review abbreviations', () => {
expect(
repairTranslatedValue({
key: 'settings.appearance.statusBar.claudeToggleDescription',
enValue: 'Show Claude token and cost usage for the active workspace.',
localeValue: '显示Claude Token 和成本使用情况。',
locale: 'zh'
})
).toBe('显示 Claude Token 和成本使用情况。')
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.ComputerUseAnimatedVisual.94787f01f8',
enValue: 'Claude Code session started',
localeValue: 'Claude·科德 会话已开始',
locale: 'zh'
})
).toBe('Claude Code 会话已开始')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.dbe5e2448e',
enValue: 'Pull request merged',
localeValue: 'PR已合并',
locale: 'zh'
})
).toBe('拉取请求已合并')
expect(
repairTranslatedValue({
key: 'auto.components.GitLabItemDialog.9b11cd233f',
enValue: 'Closed MR !{{value0}}',
localeValue: '已关闭先生!{{value0}}',
locale: 'zh'
})
).toBe('已关闭 MR !{{value0}}')
expect(
repairTranslatedValue({
key: 'auto.components.tab.bar.tab.create.menu.options.1baeb07c17',
enValue: 'ios simulator',
localeValue: 'ios simulator',
locale: 'zh'
})
).toBe('iOS 模拟器')
})
})
@@ -3,6 +3,13 @@ import { KO_VALUE_OVERRIDES } from './locale-ko-value-overrides.mjs'
import { ZH_VALUE_OVERRIDES } from './locale-zh-value-overrides.mjs'
export const LOCALE_VALUE_OVERRIDES = {
es: {
'Explore Orca': 'Explorar Orca',
'OpenCode Go': 'OpenCode Go',
'Open in Cursor': 'Abrir en Cursor',
'Local project, Git repo, or folder with many repos':
'Proyecto local, repo de Git o carpeta con muchos repos'
},
ko: {
Save: '저장',
Close: '닫기',
@@ -2,6 +2,14 @@
// Why: keep locale-value-overrides.mjs under max-lines while preserving exact-match repairs.
export const ZH_VALUE_OVERRIDES = {
phone: '手机',
bitbucket: 'Bitbucket',
ios: 'iOS',
iphone: 'iPhone',
ipad: 'iPad',
'ios simulator': 'iOS 模拟器',
'mobile emulator': '移动端模拟器',
web: 'Web',
'Git Bash': 'git bash',
'Switch to phone mode': '切换到手机模式',
'e.g. feature': '例如 feature',
'font features': '字体特性',
@@ -33,6 +41,10 @@ export const ZH_VALUE_OVERRIDES = {
'No reviewers.': '没有评审人',
'Open the PR details to view current reviewers.': '打开 PR 详情以查看当前评审人。',
'Review comment added.': '已添加评审评论。',
'Loading labels': '加载标签',
Approved: '已批准',
Strike: '删除线',
Bold: '粗体',
'Needs review': '待评审',
'need review': '待评审',
'In review': '评审中',
@@ -156,6 +168,17 @@ export const ZH_VALUE_OVERRIDES = {
'自您上次批准以来已发生变化。运行前请重新评审',
'Run the weekly dependency audit and summarize risky changes.':
'每周运行依赖项审计并总结有风险的更改。',
'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.':
'这将在重启后启用进程级 Electron 网络开关。适用于拒绝 HTTP/2 更新下载的企业 VPN 或代理。',
'Use only when a corporate VPN or proxy breaks update downloads with HTTP/2 protocol errors. It affects all Electron networking after restart.':
'仅当企业 VPN 或代理因 HTTP/2 协议错误而中断更新下载时使用。重启后会影响所有 Electron 网络。',
'Use HTTP/1.1 for Electron networking when HTTP/2 fails behind a proxy.':
'当 HTTP/2 在代理后面失败时,使用 HTTP/1.1 进行 Electron 网络。',
'Proxy Bypass Rules': '代理绕过规则',
'Hosts that should bypass the configured HTTP proxy.': '应绕过配置的 HTTP 代理的主机。',
'Leave empty to use system proxy settings and inherited proxy environment variables.':
'留空以使用系统代理设置和继承的代理环境变量。',
'Proxy Command': '代理命令',
"Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.":
'让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。',
'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”',
@@ -176,6 +199,17 @@ export const ZH_VALUE_OVERRIDES = {
'每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。',
'Show Linear in the Tasks source picker and sidebar shortcuts.':
'在任务源选择器和侧边栏快捷方式中显示 Linear。',
'Local project, Git repo, or folder with many repos':
'本地项目、Git repo 或包含多个 repos 的文件夹',
'Staged Changes': '已暂存的更改',
Changes: '更改',
'Untracked Files': '未跟踪文件',
'Split Up': '向上拆分',
'Split Down': '向下拆分',
'Split Left': '向左拆分',
'Split Right': '向右拆分',
'Split Terminal Down': '向下拆分 Terminal',
'Split Terminal Right': '向右拆分 Terminal',
'Optional account switching for Claude while preserving shared chat context.':
'Claude 的可选账户切换,同时保留共享聊天上下文。',
'Countdown timer showing time until prompt cache expires (Claude agents).':
+6 -2
View File
@@ -74,7 +74,9 @@ function editForCandidate(candidate, key, translation, sourceFile) {
}
function sourceKindForPath(filePath) {
return filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
return filePath.endsWith('.tsx') || filePath.endsWith('.jsx')
? ts.ScriptKind.TSX
: ts.ScriptKind.TS
}
function findNodeByRange(sourceFile, start, end) {
@@ -201,7 +203,9 @@ async function collectCandidateFiles(root) {
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!['.git', 'assets', 'dist', 'node_modules', 'out', '__snapshots__'].includes(entry.name)) {
if (
!['.git', 'assets', 'dist', 'node_modules', 'out', '__snapshots__'].includes(entry.name)
) {
stack.push(fullPath)
}
continue
+3 -2
View File
@@ -10,7 +10,8 @@ const LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const LOCALE_CACHE_FILES = {
ko: '.ko-catalog-cache.json',
zh: '.zh-catalog-cache.json',
ja: '.ja-catalog-cache.json'
ja: '.ja-catalog-cache.json',
es: '.es-catalog-cache.json'
}
function parseLocaleArg(argv) {
@@ -56,7 +57,7 @@ export async function repairLocale(root, locale) {
}
export async function main(root = process.cwd(), locale = parseLocaleArg(process.argv)) {
const locales = locale ? [locale] : ['ko', 'zh', 'ja']
const locales = locale ? [locale] : ['ko', 'zh', 'ja', 'es']
const unsupported = locales.filter((code) => !LOCALE_CACHE_FILES[code])
if (unsupported.length > 0) {
console.error(`Unsupported locale(s): ${unsupported.join(', ')}`)
+591
View File
@@ -0,0 +1,591 @@
#!/usr/bin/env node
import { _electron as electron } from '@stablyai/playwright-test'
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs'
const DEFAULT_WARMUP_MS = 15_000
const DEFAULT_SAMPLE_MS = 30_000
const DEFAULT_INTERVAL_MS = 1_000
const DEFAULT_WORKTREE_COUNT = 1
const ONBOARDING_FINAL_STEP = 3
const ONBOARDING_FLOW_VERSION = 2
function parseArgs(argv) {
const options = {
warmupMs: DEFAULT_WARMUP_MS,
sampleMs: DEFAULT_SAMPLE_MS,
intervalMs: DEFAULT_INTERVAL_MS,
worktrees: DEFAULT_WORKTREE_COUNT,
skipBuild: false,
headful: false,
output: null,
disableRendererAnimations: false,
syntheticVisibleSpinners: 0,
syntheticSpinnerAnimation: 'smooth',
syntheticSpinnerSteps: 12
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const readValue = () => {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${arg}`)
}
index += 1
return value
}
if (arg === '--') {
continue
} else if (arg === '--warmup-ms') {
options.warmupMs = Number(readValue())
} else if (arg === '--sample-ms') {
options.sampleMs = Number(readValue())
} else if (arg === '--interval-ms') {
options.intervalMs = Number(readValue())
} else if (arg === '--worktrees') {
options.worktrees = Number(readValue())
} else if (arg === '--output') {
options.output = readValue()
} else if (arg === '--skip-build') {
options.skipBuild = true
} else if (arg === '--headful') {
options.headful = true
} else if (arg === '--disable-renderer-animations') {
options.disableRendererAnimations = true
} else if (arg === '--synthetic-visible-spinners') {
options.syntheticVisibleSpinners = Number(readValue())
} else if (arg === '--synthetic-spinner-animation') {
options.syntheticSpinnerAnimation = readValue()
} else if (arg === '--synthetic-spinner-steps') {
options.syntheticSpinnerSteps = Number(readValue())
} else if (arg === '--help') {
printUsage()
process.exit(0)
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
for (const key of [
'warmupMs',
'sampleMs',
'intervalMs',
'worktrees',
'syntheticVisibleSpinners',
'syntheticSpinnerSteps'
]) {
if (!Number.isFinite(options[key]) || options[key] < 0) {
throw new Error(`Invalid --${key}: ${options[key]}`)
}
}
options.worktrees = Math.max(1, Math.floor(options.worktrees))
options.intervalMs = Math.max(250, Math.floor(options.intervalMs))
options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners))
options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps))
if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) {
throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`)
}
return options
}
function printUsage() {
console.log(
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
)
}
function run(command, args, options = {}) {
execFileSync(command, args, { stdio: options.stdio ?? 'pipe', encoding: 'utf8', ...options })
}
function buildAppIfNeeded(root, skipBuild) {
const mainPath = path.join(root, 'out', 'main', 'index.js')
if (skipBuild && existsSync(mainPath)) {
return mainPath
}
if (skipBuild) {
throw new Error(`--skip-build requested, but ${mainPath} does not exist`)
}
console.log('[idle-cpu] building Electron app with electron-vite --mode e2e')
run('npx', ['electron-vite', 'build', '--mode', 'e2e'], {
cwd: root,
stdio: 'inherit',
env: { ...process.env, VITE_EXPOSE_STORE: 'true' }
})
return mainPath
}
function makeCompletedOnboardingProfile() {
return {
settings: {
telemetry: {
optedIn: true,
installId: '00000000-0000-4000-8000-000000000000',
existedBeforeTelemetryRelease: false
}
},
onboarding: {
flowVersion: ONBOARDING_FLOW_VERSION,
closedAt: 1,
outcome: 'completed',
lastCompletedStep: ONBOARDING_FINAL_STEP
},
ui: {
contextualToursSeenIds: [
'workspace-board',
'browser',
'tasks',
'automations',
'workspace-creation'
],
contextualToursAutoEligible: false,
projectOrderManualDefaultNoticeDismissed: true
}
}
}
function createIdleRepo(worktreeCount) {
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'orca-idle-cpu-repo-'))
const cleanupDirs = [repoDir]
run('git', ['init'], { cwd: repoDir })
run('git', ['config', 'user.email', 'idle-cpu@test.local'], { cwd: repoDir })
run('git', ['config', 'user.name', 'Idle CPU Benchmark'], { cwd: repoDir })
writeFileSync(path.join(repoDir, 'README.md'), '# Orca idle CPU benchmark\n')
writeFileSync(
path.join(repoDir, 'package.json'),
`${JSON.stringify({ private: true }, null, 2)}\n`
)
mkdirSync(path.join(repoDir, 'src'), { recursive: true })
writeFileSync(path.join(repoDir, 'src', 'index.ts'), 'export const idleBenchmark = true\n')
run('git', ['add', '-A'], { cwd: repoDir })
run('git', ['commit', '-m', 'Initial idle CPU fixture'], { cwd: repoDir })
for (let i = 2; i <= worktreeCount; i += 1) {
const worktreeDir = path.join(
path.dirname(repoDir),
`orca-idle-cpu-worktree-${i}-${Date.now()}`
)
cleanupDirs.push(worktreeDir)
run('git', ['worktree', 'add', worktreeDir, '-b', `idle-cpu-${i}`], { cwd: repoDir })
}
return { repoDir, cleanupDirs }
}
function launchArgs(mainPath, headful) {
if (headful || process.platform !== 'linux') {
return [mainPath]
}
return [
'--disable-gpu',
'--disable-gpu-compositing',
'--disable-gpu-sandbox',
'--disable-dev-shm-usage',
'--in-process-gpu',
mainPath
]
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function parseCpuTimeSeconds(value) {
const trimmed = String(value || '').trim()
if (!trimmed) {
return null
}
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = maybeTime.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
return null
}
if (parts.length === 3) {
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
}
if (parts.length === 2) {
return days * 86400 + parts[0] * 60 + parts[1]
}
if (parts.length === 1) {
return days * 86400 + parts[0]
}
return null
}
function parseUnixProcesses(stdout) {
const rows = []
for (const raw of stdout.split('\n')) {
const line = raw.trim()
if (!line) {
continue
}
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
percentCpu: Number(match[3]),
rssBytes: Number(match[4]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
command: match[6]
})
}
return rows
}
function readUnixProcesses() {
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
encoding: 'utf8',
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxBuffer: 20 * 1024 * 1024
})
return parseUnixProcesses(stdout)
}
function readWindowsProcesses() {
const script =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(result.stderr || 'PowerShell process enumeration failed')
}
const parsed = JSON.parse(result.stdout || '[]')
const entries = Array.isArray(parsed) ? parsed : [parsed]
return entries.map((entry) => ({
pid: Number(entry.ProcessId),
ppid: Number(entry.ParentProcessId),
percentCpu: 0,
cpuTimeSeconds: null,
rssBytes: Number(entry.WorkingSetSize) || 0,
command: String(entry.CommandLine || '')
}))
}
function readProcessRows() {
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
}
function descendantsOf(rows, rootPid) {
const children = new Map()
for (const row of rows) {
const list = children.get(row.ppid) ?? []
list.push(row)
children.set(row.ppid, list)
}
const result = []
const stack = [rootPid]
const seen = new Set()
while (stack.length > 0) {
const pid = stack.pop()
if (seen.has(pid)) {
continue
}
seen.add(pid)
const row = rows.find((candidate) => candidate.pid === pid)
if (row) {
result.push(row)
}
for (const child of children.get(pid) ?? []) {
stack.push(child.pid)
}
}
return result
}
function classify(row, rootPid) {
const command = row.command.toLowerCase()
if (row.pid === rootPid) {
return 'main'
}
if (command.includes('daemon-entry')) {
return 'daemon'
}
if (command.includes('--type=gpu-process')) {
return 'gpu'
}
if (command.includes('--type=renderer')) {
return 'renderer'
}
if (command.includes('--type=utility')) {
return 'utility'
}
if (command.includes('--type=')) {
return 'electron-other'
}
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
return 'agent-or-node'
}
return 'other-descendant'
}
async function collectRendererIdleState(page) {
return page.evaluate(() => {
const describeElement = (element) => {
if (!(element instanceof Element)) {
return null
}
const classes = typeof element.className === 'string' ? element.className : ''
const testId = element.getAttribute('data-testid')
const label = element.getAttribute('aria-label')
return {
tag: element.tagName.toLowerCase(),
id: element.id || null,
testId,
label,
classes: classes.split(/\s+/).filter(Boolean).slice(0, 12),
text: (element.textContent || '').trim().slice(0, 80)
}
}
const animations = document.getAnimations({ subtree: true }).map((animation) => {
const effect = animation.effect
const target = effect instanceof KeyframeEffect ? effect.target : null
return {
playState: animation.playState,
currentTime: typeof animation.currentTime === 'number' ? animation.currentTime : null,
playbackRate: animation.playbackRate,
duration:
effect instanceof KeyframeEffect && typeof effect.getTiming().duration === 'number'
? effect.getTiming().duration
: null,
iterations: effect instanceof KeyframeEffect ? effect.getTiming().iterations : null,
target: describeElement(target)
}
})
return {
visibilityState: document.visibilityState,
runningAnimationCount: animations.filter((animation) => animation.playState === 'running')
.length,
animations: animations.slice(0, 80)
}
})
}
function summarizeSamples(samples) {
const byKind = new Map()
for (const sample of samples) {
for (const proc of sample.processes) {
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
bucket.cpuValues.push(proc.cpu)
bucket.rssValues.push(proc.rssBytes)
byKind.set(proc.kind, bucket)
}
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
}
for (const [kind, count] of counts) {
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
}
}
const summary = {}
for (const [kind, values] of byKind) {
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
const rssSumBySample = samples.map((sample) =>
sample.processes
.filter((proc) => proc.kind === kind)
.reduce((sum, proc) => sum + proc.rssBytes, 0)
)
summary[kind] = {
meanCpuPercent: mean(values.cpuValues),
p95CpuPercent: percentile(cpuSorted, 0.95),
maxCpuPercent: Math.max(0, ...values.cpuValues),
meanRssBytes: mean(rssSumBySample),
maxProcessCount: values.maxProcessCount
}
}
summary.total = {
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
p95CpuPercent: percentile(
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
0.95
),
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
}
return summary
}
function summarizeProcessInventory(samples) {
const inventory = {}
for (const sample of samples) {
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
const entry = inventory[proc.kind] ?? {
maxProcessCount: 0,
maxCpuPercent: 0,
commandSamples: []
}
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
entry.commandSamples.push(proc.command)
}
inventory[proc.kind] = entry
}
for (const [kind, count] of counts) {
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
}
}
return inventory
}
function mean(values) {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
}
function percentile(sorted, fraction) {
if (sorted.length === 0) {
return 0
}
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
return sorted[index]
}
function terminateProcesses(processes) {
for (const proc of processes) {
try {
process.kill(proc.pid)
} catch {}
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const mainPath = buildAppIfNeeded(root, options.skipBuild)
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-idle-cpu-userdata-'))
const { repoDir, cleanupDirs } = createIdleRepo(options.worktrees)
writeFileSync(
path.join(userDataDir, 'orca-data.json'),
`${JSON.stringify(makeCompletedOnboardingProfile(), null, 2)}\n`
)
const { ELECTRON_RUN_AS_NODE, ...cleanEnv } = process.env
void ELECTRON_RUN_AS_NODE
const app = await electron.launch({
args: launchArgs(mainPath, options.headful),
env: {
...cleanEnv,
NODE_ENV: 'development',
ORCA_E2E_USER_DATA_DIR: userDataDir,
...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
}
})
const rootPid = app.process().pid
try {
const page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
const measurementCss = []
if (options.disableRendererAnimations) {
measurementCss.push(
'*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}'
)
}
if (measurementCss.length > 0) {
await page.addStyleTag({ content: measurementCss.join('\n') })
}
await installSyntheticVisibleSpinners(
page,
options.syntheticVisibleSpinners,
options.syntheticSpinnerAnimation,
options.syntheticSpinnerSteps
)
await page.evaluate(async (repoPath) => {
await window.api.repos.add({ path: repoPath })
const store = window.__store
await store?.getState().fetchRepos()
const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath)
if (repo) {
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
await store.getState().fetchWorktrees(repo.id)
}
}, repoDir)
await page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
null,
{ timeout: 60_000 }
)
console.log(
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}`
)
await sleep(options.warmupMs)
const rendererIdleState = await collectRendererIdleState(page)
const deadline = Date.now() + options.sampleMs
const samples = []
let previousSnapshot = null
while (Date.now() <= deadline || samples.length === 0) {
const sampledAt = Date.now()
const processRows = descendantsOf(readProcessRows(), rootPid)
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
if (previousSnapshot) {
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
const processes = rawProcesses.map((row) => {
const previous = previousByPid.get(row.pid)
const canComputeDelta =
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
const cpu = canComputeDelta
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
: row.percentCpu
return { ...row, cpu }
})
samples.push({
at: sampledAt,
elapsedMs: sampledAt - previousSnapshot.at,
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
processes
})
}
previousSnapshot = { at: sampledAt, processes: rawProcesses }
await sleep(options.intervalMs)
}
const report = {
benchmark: 'orca-idle-cpu',
createdAt: new Date().toISOString(),
options,
rootPid,
platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length },
rendererIdleState,
sampleCount: samples.length,
summary: summarizeSamples(samples),
processInventory: summarizeProcessInventory(samples),
samples
}
if (options.output) {
mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`)
console.log(`[idle-cpu] wrote ${options.output}`)
}
console.log(
JSON.stringify(
{
summary: report.summary,
processInventory: report.processInventory,
sampleCount: report.sampleCount
},
null,
2
)
)
} finally {
const launchedProcesses = descendantsOf(readProcessRows(), rootPid).filter(
(proc) => proc.pid !== rootPid
)
await app.close().catch(() => undefined)
await sleep(250)
terminateProcesses(launchedProcesses)
rmSync(userDataDir, { recursive: true, force: true })
for (const dir of cleanupDirs) {
rmSync(dir, { recursive: true, force: true })
}
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
+236 -9
View File
@@ -9,6 +9,8 @@ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const SOURCE_RELATIVE_ROOTS = [path.join('src', 'renderer', 'src'), path.join('src', 'main')]
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
@@ -70,13 +72,14 @@ function expressionNameText(node) {
return undefined
}
function reportAt(root, filePath, sourceFile, node, key) {
function reportAt(root, filePath, sourceFile, node, key, fallback) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return {
filePath: normalizePath(root, filePath),
line: position.line + 1,
column: position.character + 1,
key
key,
fallback
}
}
@@ -103,7 +106,17 @@ export function collectLocalizationKeyReferences(filePath, sourceText, root = pr
firstArg &&
ts.isStringLiteralLike(firstArg)
) {
references.push(reportAt(root, filePath, sourceFile, firstArg, firstArg.text))
const secondArg = node.arguments[1]
references.push(
reportAt(
root,
filePath,
sourceFile,
firstArg,
firstArg.text,
secondArg && ts.isStringLiteralLike(secondArg) ? secondArg.text : undefined
)
)
}
}
@@ -126,6 +139,52 @@ function formatMissingKeys(label, keys) {
return keys.map((key) => `${label}: ${key}`).join('\n')
}
function normalizeInterpolationVariables(value) {
return collectInterpolationVariables(value)
.map((variable) => variable.slice(2, -2))
.join('|')
}
function formatInconsistentFallbackVariables(inconsistentFallbackVariables) {
return inconsistentFallbackVariables
.map(({ key, references }) => {
const locations = references
.map(
(reference) =>
` ${reference.filePath}:${reference.line}:${reference.column} ${JSON.stringify(reference.fallback)}`
)
.join('\n')
return `${key}\n${locations}`
})
.join('\n\n')
}
function collectInconsistentFallbackVariables(references) {
const byKey = new Map()
for (const reference of references) {
if (typeof reference.fallback !== 'string') {
continue
}
const existing = byKey.get(reference.key) ?? []
existing.push(reference)
byKey.set(reference.key, existing)
}
return [...byKey.entries()]
.map(([key, keyReferences]) => {
const uniqueFallbackVariables = new Set(
keyReferences.map((reference) => normalizeInterpolationVariables(reference.fallback))
)
return {
key,
references: keyReferences,
uniqueFallbackVariableCount: uniqueFallbackVariables.size
}
})
.filter(({ uniqueFallbackVariableCount }) => uniqueFallbackVariableCount > 1)
}
function collectInterpolationVariables(value) {
if (typeof value === 'string') {
const matches = value.match(PLACEHOLDER_RE) ?? []
@@ -151,7 +210,61 @@ function flattenCatalogEntries(value, prefix = '', entries = new Map()) {
return entries
}
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
function getCatalogEntry(catalog, key) {
return key.split('.').reduce((cursor, part) => cursor?.[part], catalog)
}
function setCatalogEntry(catalog, key, value) {
const parts = key.split('.')
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (typeof cursor[part] !== 'object' || cursor[part] === null || Array.isArray(cursor[part])) {
cursor[part] = {}
}
cursor = cursor[part]
}
cursor[parts.at(-1)] = value
}
function deleteCatalogEntry(catalog, key) {
const parts = key.split('.')
const stack = []
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (
typeof cursor?.[part] !== 'object' ||
cursor[part] === null ||
Array.isArray(cursor[part])
) {
return false
}
stack.push([cursor, part])
cursor = cursor[part]
}
const leafKey = parts.at(-1)
if (!Object.hasOwn(cursor, leafKey)) {
return false
}
delete cursor[leafKey]
for (let index = stack.length - 1; index >= 0; index -= 1) {
const [parent, part] = stack[index]
const child = parent[part]
if (
typeof child === 'object' &&
child !== null &&
!Array.isArray(child) &&
Object.keys(child).length === 0
) {
delete parent[part]
}
}
return true
}
function collectLocaleParityIssues(enCatalog, localeCatalog) {
const enEntries = flattenCatalogEntries(enCatalog)
const localeEntries = flattenCatalogEntries(localeCatalog)
const missingInLocale = [...enEntries.keys()].filter((key) => !localeEntries.has(key))
@@ -169,6 +282,71 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
}
}
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
}
function repairLocaleParity(enCatalog, localeCatalog) {
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
let changed = 0
for (const key of missingInLocale) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
for (const key of extraInLocale) {
if (deleteCatalogEntry(localeCatalog, key)) {
changed += 1
}
}
for (const key of interpolationMismatches) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
return changed
}
function referencesMissingFallbacks(missing) {
return missing.filter((reference) => typeof reference.fallback !== 'string')
}
function collectMissingCatalogEntries(missing) {
const entries = new Map()
for (const reference of missing) {
if (typeof reference.fallback !== 'string') {
continue
}
if (!entries.has(reference.key)) {
entries.set(reference.key, reference.fallback)
}
}
return entries
}
function applyMissingEnglishEntries(catalog, missing) {
const entries = collectMissingCatalogEntries(missing)
let changed = 0
for (const [key, fallback] of entries) {
if (getCatalogEntry(catalog, key) !== undefined) {
continue
}
setCatalogEntry(catalog, key, fallback)
changed += 1
}
return changed
}
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
if (
missingInLocale.length > 0 ||
extraInLocale.length > 0 ||
@@ -205,12 +383,18 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
return 0
}
export async function main(root = process.cwd()) {
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
function parseArgs(argv) {
return {
fix: argv.includes('--fix')
}
}
export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
const catalogPath = path.join(localesDir, 'en.json')
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
const catalogKeys = new Set(flattenCatalogKeys(catalog))
const sourceRoots = [path.join(root, 'src', 'renderer', 'src'), path.join(root, 'src', 'main')]
let catalogKeys = new Set(flattenCatalogKeys(catalog))
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
const references = []
for (const sourceRoot of sourceRoots) {
@@ -224,9 +408,41 @@ export async function main(root = process.cwd()) {
const missing = references.filter((reference) => !catalogKeys.has(reference.key))
if (missing.length > 0) {
const missingFallbacks = referencesMissingFallbacks(missing)
if (options.fix && missingFallbacks.length === 0) {
const added = applyMissingEnglishEntries(catalog, missing)
await fs.writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8')
catalogKeys = new Set(flattenCatalogKeys(catalog))
console.log(`Added ${added} missing localization key(s) to en.json.`)
} else {
if (options.fix && missingFallbacks.length > 0) {
console.error('Some missing localization keys do not have string fallbacks to bootstrap.')
console.error('')
console.error(formatMissingReferences(missingFallbacks))
return 1
}
console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
console.error('')
console.error(formatMissingReferences(missing))
console.error('')
console.error('Run `pnpm run sync:localization-catalog` to add keys with string fallbacks.')
return 1
}
}
const remainingMissing = references.filter((reference) => !catalogKeys.has(reference.key))
if (remainingMissing.length > 0) {
console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
console.error('')
console.error(formatMissingReferences(missing))
console.error(formatMissingReferences(remainingMissing))
return 1
}
const inconsistentFallbackVariables = collectInconsistentFallbackVariables(references)
if (inconsistentFallbackVariables.length > 0) {
console.error('Localization keys are used with inconsistent interpolation placeholders.')
console.error('')
console.error(formatInconsistentFallbackVariables(inconsistentFallbackVariables))
return 1
}
@@ -246,8 +462,19 @@ export async function main(root = process.cwd()) {
const localeName = fileName.replace(/\.json$/, '')
const localeCatalogPath = path.join(localesDir, fileName)
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
if (options.fix) {
const repaired = repairLocaleParity(catalog, localeCatalog)
if (repaired > 0) {
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
}
}
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
if (exitCode !== 0) {
if (!options.fix) {
console.error('')
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
}
return exitCode
}
}
@@ -0,0 +1,82 @@
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf8'))
}
function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-catalog-'))
const rendererDir = path.join(root, 'src', 'renderer', 'src', 'components')
const mainDir = path.join(root, 'src', 'main')
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
mkdirSync(rendererDir, { recursive: true })
mkdirSync(mainDir, { recursive: true })
mkdirSync(localesDir, { recursive: true })
writeFileSync(path.join(rendererDir, 'Example.tsx'), sourceText, 'utf8')
writeFileSync(path.join(mainDir, 'empty.ts'), 'export {}\n', 'utf8')
writeJson(path.join(localesDir, 'en.json'), enCatalog)
writeJson(path.join(localesDir, 'es.json'), esCatalog)
return { root, localesDir }
}
describe('verify-localization-catalog', () => {
it('bootstraps missing catalog entries from string fallbacks', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
})
it('repairs stale locale keys and interpolation mismatches', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
enCatalog: { auto: { example: { greeting: 'Hello {{name}}' } } },
esCatalog: {
auto: {
example: { greeting: 'Hola' },
stale: { removed: 'Viejo' }
}
}
})
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
})
it('does not invent values for keys without string fallbacks', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.noFallback')\n"
})
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
})
})
+2
View File
@@ -6,7 +6,9 @@
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/shared/**/*",
"../src/main/ipc/worktree-branch-name.ts",
"../src/main/ipc/worktree-logic.ts",
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
"../src/main/wsl.ts"
],
"compilerOptions": {
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.0m">
<title>downloads: 1.0m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="113" height="20" rx="3"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="74" height="20" fill="#555"/>
<rect x="74" width="39" height="20" fill="#4c1"/>
<rect width="113" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.0m</text>
<text x="93.5" y="14">1.0m</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

+205 -102
View File
@@ -3,137 +3,231 @@
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="Plataformas compatibles" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="Seguir en X" /></a>
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrellas en GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Descargas totales en todas las versiones" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licencia" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Únete al Discord de Orca" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatibles: macOS, Windows y Linux" />
</p>
<p align="center">
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
Ejecuta Claude Code, OpenClaude, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
Disponible para <strong>macOS, Windows y Linux</strong>.
Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Descargar Orca</ins></a></h3>
<p align="center">
<a href="#instalación"><strong>Descargar 🐋</strong></a>
<img src="../assets/readme-hero.jpg" alt="La app de escritorio de Orca ejecutando agentes en worktrees paralelos, con la app companion móvil de Orca en la esquina" width="960" />
</p>
<p align="center">
<img src="../assets/file-drag.gif" alt="Captura de Orca" width="800" />
</p>
## Agentes compatibles
Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## Características
- **Sin login** — Usa tu propia suscripción de Claude Code, OpenClaude, Codex, Grok o Antigravity.
- **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante.
- **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
- **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca.
- **Integración con GitHub** — PRs, issues y checks de Actions vinculados automáticamente a cada worktree.
- **Soporte SSH** — Conéctate a máquinas remotas y ejecuta agentes en ellas directamente desde Orca.
- **Notificaciones** — Entérate cuando un agente termine o necesite tu atención. Marca hilos como no leídos para retomarlos después.
<table>
<tr>
<td width="50%" valign="middle">
### App companion móvil
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Worktrees en paralelo
Lanza un mismo prompt a cinco agentes, cada uno en su propio worktree de git aislado — compara los resultados y haz merge del ganador.
[Docs →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Terminales divididas
Terminales de nivel Ghostty con renderizado WebGL, divisiones infinitas y un scrollback que sobrevive a los reinicios.
[Docs →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Modo diseño
Haz clic en cualquier elemento de UI en una ventana real de Chromium para enviar su HTML, su CSS y una captura recortada directo al prompt de tu agente.
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub y Linear, nativos
Explora PRs, issues y tableros de proyecto dentro de la app — abre un worktree desde cualquier tarea y revisa sin cambiar de contexto.
[Docs →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Flujos de trabajo de GitHub y Linear en Orca" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Worktrees por SSH
Ejecuta agentes en una máquina remota potente con edición completa de archivos, git y terminales — con reconexión automática y reenvío de puertos incluidos.
[Docs →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Anotar diffs de IA
Deja comentarios en cualquier línea de un diff y envíalos de vuelta al agente — revisa, edita y haz commit sin salir de Orca.
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Arrastra archivos a los agentes
El editor de VS Code con autoguardado en todas partes — arrastra archivos o imágenes directo al prompt de un agente.
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
Los agentes también manejan Orca — automatiza cualquier flujo de trabajo con `orca worktree create`, `snapshot`, `click` y `fill`.
[Docs →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="100%" /></picture></a>
</td>
</tr>
</table>
**También incluye:**
- **[Apertura rápida](https://www.onorca.dev/docs/model/quick-open)** — Busca entre worktrees, archivos, agentes, comandos y contexto del repo sin salir de tu flujo.
- **[Cambio de cuenta y seguimiento de uso](https://www.onorca.dev/docs/agents/usage-tracking)** — Consulta el uso de Claude y Codex y los reinicios de límites de uso, y cambia de cuenta al instante sin volver a iniciar sesión.
- **[Previews ricos del repo](https://www.onorca.dev/docs/editing/markdown)** — Previsualiza Markdown, imágenes, PDFs y documentos del repo en el workspace.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Deja que los agentes manejen apps de escritorio y UI visible cuando un flujo de trabajo necesita interacción real.
- **[Notificaciones y estado de no leído](https://www.onorca.dev/docs/notifications)** — Entérate cuando un agente termine o necesite tu atención, y marca hilos como no leídos para retomarlos después.
- **Y muchas, muchas más** — lanzamos a diario, así que esta lista siempre va atrasada. El [changelog](https://github.com/stablyai/orca/releases) es la verdadera lista de funciones.
---
## Agentes compatibles
Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orca.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## Instalación
### Mac, Linux, Windows
### Escritorio — macOS, Windows, Linux
- **[Descarga desde onOrca.dev](https://onOrca.dev)**
- O desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**
- **[Descarga desde onOrca.dev](https://onorca.dev/download)**
- O descarga un build directamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos los builds](https://github.com/stablyai/orca/releases/latest)
_También puedes instalar desde un gestor de paquetes:_
### macOS (Homebrew)
_O mediante un gestor de paquetes:_
```bash
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
```
### Arch Linux (AUR)
```bash
# Binario precompilado
# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
# Compilar desde el código de GitHub
yay -S stably-orca-git
```
---
### App companion móvil — iOS, Android
## App companion móvil
Controla tus agentes desde el teléfono.
<p align="center">
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="720" /></picture>
</p>
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar APK desde GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
## Showcase de funciones
Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Worktrees en paralelo</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminales divididas</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas de nivel Ghostty" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Modo diseño</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub y Linear nativos</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Flujos de GitHub y Linear en Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Cualquier agente CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="Compatible con cualquier agente CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>Worktrees por SSH</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Archivos a agentes</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Anotar diffs de IA</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Búsqueda nativa</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Búsqueda nativa en los flujos de Orca" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Cambio de cuenta y seguimiento de uso</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="Cambio de cuenta y seguimiento de uso" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Previews ricos del repo</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Previsualización de Markdown, imágenes, PDFs y documentos del repo" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Divide cualquier cosa</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="Paneles divididos para agentes, terminales, navegadores y archivos" width="390" /></picture><br/></kbd></a>
</p>
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk)
---
@@ -142,10 +236,19 @@ Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
- **Discord:** Únete a la comunidad en **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Sigue a **[@orca_build](https://x.com/orca_build)** para novedades y anuncios.
- **Feedback e ideas:** Lanzamos rápido. ¿Te falta algo? [Pide una nueva feature](https://github.com/stablyai/orca/issues).
- **Muéstranos tu apoyo:** Dale una estrella al repo para seguir nuestros lanzamientos diarios.
- **Privacidad:** Consulta la [documentación de privacidad y telemetría](https://www.onorca.dev/docs/telemetry) para saber qué datos anónimos de uso recopila Orca y cómo desactivar su envío.
- **Muéstranos tu apoyo:** Dale una [estrella](https://github.com/stablyai/orca) a este repo para seguir nuestros lanzamientos diarios.
---
## Desarrollo
¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](../.github/CONTRIBUTING.md).
¿Quieres contribuir o ejecutar Orca localmente? Consulta nuestra guía [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Contribuidores de Orca" />
</a>
## Licencia
Orca es libre y de código abierto bajo la [Licencia MIT](../../LICENSE).
+205 -102
View File
@@ -3,137 +3,231 @@
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="対応プラットフォーム" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="X でフォロー" /></a>
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub スター数" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="全リリースの合計ダウンロード数" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="ライセンス" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca の Discord に参加" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="対応プラットフォーム: macOS、Windows、Linux" />
</p>
<p align="center">
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
Claude Code、OpenClaude、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。<br/>
<strong>macOS、Windows、Linux</strong> で利用できます。
Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca をダウンロード</ins></a></h3>
<p align="center">
<a href="#インストール"><strong>ダウンロード 🐋</strong></a>
<img src="../assets/readme-hero.jpg" alt="並列ワークツリーでエージェントを実行する Orca デスクトップアプリと、隅に表示された Orca モバイル companion アプリ" width="960" />
</p>
<p align="center">
<img src="../assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 対応するエージェント
Orca は任意の CLI エージェントに対応しています(_このリストに限定されません_)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## 機能
- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
- **GitHub 連携** — PR、Issue、Actions チェックが各ワークツリーに自動で紐づきます。
- **SSH サポート** — リモートマシンに接続し、Orca から直接エージェントを実行できます。
- **通知** — エージェントが完了したときや注意が必要なときに通知します。スレッドを未読にして後で戻ることもできます。
<table>
<tr>
<td width="50%" valign="middle">
### モバイル Companion
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 並列ワークツリー
1 つのプロンプトを 5 つのエージェントに展開し、それぞれを独立した git ワークツリーで実行 — 結果を比較して、最良のものをマージできます。
[ドキュメント →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### ターミナル分割
WebGL レンダリング、無制限の分割、再起動後も残るスクロールバックを備えた Ghostty クラスのターミナル。
[ドキュメント →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="ターミナル分割" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### デザインモード
実際の Chromium ウィンドウで任意の UI 要素をクリックすると、その HTML、CSS、切り抜いたスクリーンショットがそのままエージェントのプロンプトに送られます。
[ドキュメント →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; Linear をネイティブに
PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意のタスクからワークツリーを開き、コンテキストスイッチなしでレビューできます。
[ドキュメント →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear タスクワークフロー" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH ワークツリー
強力なリモートマシン上でエージェントを実行 — ファイル編集、git、ターミナルをフルに使え、自動再接続とポートフォワーディングも付属します。
[ドキュメント →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### AI Diff に注釈
任意の Diff 行にコメントを付けてエージェントへ送り返せます — Orca から離れずにレビュー、編集、コミットまで完結します。
[ドキュメント →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### ファイルをエージェントへドラッグ
オートセーブが全面的に効く VS Code のエディタ — ファイルや画像をそのままエージェントのプロンプトへドラッグできます。
[ドキュメント →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
エージェント自身も Orca を操作できます — `orca worktree create`、`snapshot`、`click`、`fill` であらゆるワークフローをスクリプト化できます。
[ドキュメント →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="100%" /></picture></a>
</td>
</tr>
</table>
**さらに同梱:**
- **[クイックオープン](https://www.onorca.dev/docs/model/quick-open)** — フローを離れずに、ワークツリー、ファイル、エージェント、コマンド、リポジトリコンテキストを横断検索できます。
- **[アカウント切り替えと使用量トラッキング](https://www.onorca.dev/docs/agents/usage-tracking)** — Claude と Codex の使用量やレート制限のリセットを確認し、再ログインなしでアカウントを切り替えられます。
- **[リッチなリポジトリプレビュー](https://www.onorca.dev/docs/editing/markdown)** — Markdown、画像、PDF、リポジトリ文書をワークスペース内でプレビューできます。
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 実際の操作が必要なワークフローでは、エージェントにデスクトップアプリや画面上の UI を操作させられます。
- **[通知と未読ステータス](https://www.onorca.dev/docs/notifications)** — エージェントの完了や要対応をすぐに把握し、スレッドを未読に戻して後で確認できます。
- **その他、まだまだたくさん** — 毎日リリースしているので、このリストは常に追いついていません。本当の機能一覧は[チェンジログ](https://github.com/stablyai/orca/releases)です。
---
## 対応するエージェント
**あらゆる CLI エージェント**で動作します — ターミナルで動くものなら、Orca でも動きます。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## インストール
### Mac, Linux, Windows
### デスクトップ — macOS, Windows, Linux
- **[onOrca.dev からダウンロード](https://onOrca.dev)**
- または **[GitHub Releases ページ](https://github.com/stablyai/orca/releases/latest)** から入手
- **[onOrca.dev からダウンロード](https://onorca.dev/download)**
- またはビルドを直接入手: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [すべてのビルド](https://github.com/stablyai/orca/releases/latest)
_パッケージマネージャーからもインストールできます:_
### macOS (Homebrew)
```bash
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
```
### Arch Linux (AUR)
```bash
# ビルド済みバイナリ
# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
# GitHub ソースからビルド
yay -S stably-orca-git
```
---
### モバイル Companion — iOS, Android
## モバイル Companion アプリ
スマートフォンからエージェントを操作できます。
<p align="center">
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="720" /></picture>
</p>
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [GitHub Releases から APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
## 機能ショーケース
各タイルをクリックすると、そのワークフローを確認できます。
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>並列ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>ターミナル分割</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty クラスのターミナル分割" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>デザインモード</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub と Linear をネイティブに</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear ワークフロー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意の CLI エージェント</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="任意の CLI エージェントに対応" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>ファイルをエージェントへ</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 注釈</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>ネイティブ検索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca ワークフロー全体のネイティブ検索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>アカウント切り替えと使用量トラッキング</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="アカウント切り替えと使用量トラッキング" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>リッチなリポジトリプレビュー</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、画像、PDF、リポジトリ文書のプレビュー" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>何でも分割表示</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="エージェント、ターミナル、ブラウザ、ファイルの分割表示" width="390" /></picture><br/></kbd></a>
</p>
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk)
---
@@ -141,11 +235,20 @@ yay -S stably-orca-git
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** のコミュニティに参加してください。
- **Twitter / X:** アップデートやお知らせは **[@orca_build](https://x.com/orca_build)** をフォローしてください。
- **フィードバックとアイデア:** 私たちは高速にリリースしています。足りない機能がありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues) してください。
- **応援する:** 毎日のリリースを追うために、このリポジトリにスターを付けてください。
- **フィードバックとアイデア:** 私たちは高速にリリースしています。足りない機能がありますか?[機能リクエストを送信](https://github.com/stablyai/orca/issues)してください。
- **プライバシー:** Orca が収集する匿名の利用データとオプトアウトの方法については、[プライバシーとテレメトリーのドキュメント](https://www.onorca.dev/docs/telemetry)をご覧ください。
- **応援する:** 毎日のリリースを追うために、このリポジトリに[スター](https://github.com/stablyai/orca)を付けてください。
---
## 開発について
貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) ガイドをご覧ください。
貢献したい、またはローカルで実行したいですか? [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) ガイドをご覧ください。
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca のコントリビューター" />
</a>
## ライセンス
Orca は [MIT License](../../LICENSE) の下で無料かつオープンソースです。
+205 -102
View File
@@ -3,137 +3,231 @@
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="지원 플랫폼" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="X에서 팔로우" /></a>
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub 스타" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="전체 릴리스 누적 다운로드 수" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="라이선스" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca Discord 참여" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="지원 플랫폼: macOS, Windows, Linux" />
</p>
<p align="center">
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
</p>
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
<strong>macOS, Windows, Linux</strong>에서 사용할 수 있습니다.
Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca 다운로드</ins></a></h3>
<p align="center">
<a href="#설치"><strong>다운로드 🐋</strong></a>
<img src="../assets/readme-hero.jpg" alt="병렬 worktree에서 에이전트를 실행 중인 Orca 데스크톱 앱과 한쪽 모서리에 보이는 Orca 모바일 companion 앱" width="960" />
</p>
<p align="center">
<img src="../assets/file-drag.gif" alt="Orca 스크린샷" width="800" />
</p>
## 지원 에이전트
Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정되지 않습니다_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## 기능
- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다.
- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다.
- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다.
- **GitHub 통합** — PR, issue, Actions 체크가 각 worktree에 자동으로 연결됩니다.
- **SSH 지원** — 원격 머신에 연결하고 Orca에서 직접 에이전트를 실행할 수 있습니다.
- **알림** — 에이전트가 완료되거나 주의가 필요할 때 알려줍니다. 스레드를 읽지 않음으로 표시해 나중에 다시 볼 수 있습니다.
<table>
<tr>
<td width="50%" valign="middle">
### 모바일 Companion
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 병렬 Worktree
하나의 프롬프트를 다섯 에이전트에 동시에 보내세요. 각 에이전트는 격리된 자체 git worktree에서 실행됩니다 — 결과를 비교하고 가장 좋은 것을 머지하세요.
[문서 →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 터미널 분할
WebGL 렌더링, 무한 분할, 재시작 후에도 유지되는 스크롤백을 갖춘 Ghostty급 터미널.
[문서 →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="터미널 분할" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 디자인 모드
실제 Chromium 창에서 UI 요소를 클릭하면 해당 HTML, CSS, 잘라낸 스크린샷이 에이전트 프롬프트로 바로 전송됩니다.
[문서 →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; Linear 네이티브
PR, issue, 프로젝트 보드를 앱 안에서 탐색하세요 — 어떤 작업에서든 worktree를 열고 컨텍스트 전환 없이 리뷰할 수 있습니다.
[문서 →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 작업 워크플로" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH Worktree
강력한 원격 머신에서 에이전트를 실행하세요. 파일 편집, git, 터미널을 모두 지원하며 자동 재연결과 포트 포워딩도 포함됩니다.
[문서 →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### AI Diff 주석
diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내세요 — Orca를 떠나지 않고 리뷰하고 수정하고 커밋할 수 있습니다.
[문서 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 에이전트로 파일 드래그
어디서나 자동 저장되는 VS Code 에디터 — 파일이나 이미지를 에이전트 프롬프트로 바로 드래그하세요.
[문서 →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
에이전트도 Orca를 조작할 수 있습니다 — `orca worktree create`, `snapshot`, `click`, `fill`로 모든 워크플로를 스크립팅하세요.
[문서 →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="100%" /></picture></a>
</td>
</tr>
</table>
**그 밖에 기본으로 제공되는 기능:**
- **[빠른 열기](https://www.onorca.dev/docs/model/quick-open)** — 작업 흐름을 벗어나지 않고 worktree, 파일, 에이전트, 커맨드, 리포지토리 컨텍스트를 검색하세요.
- **[계정 전환 및 사용량 추적](https://www.onorca.dev/docs/agents/usage-tracking)** — Claude와 Codex의 사용량과 rate limit 초기화 시점을 확인하고, 다시 로그인하지 않고 계정을 바로 전환하세요.
- **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요.
- **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요.
- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다.
---
## 지원 에이전트
**모든 CLI 에이전트**와 함께 작동합니다 — 터미널에서 실행되는 에이전트라면 Orca에서도 실행됩니다.
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ any CLI agent</kbd>
</p>
---
## 설치
### Mac, Linux, Windows
### 데스크톱 — macOS, Windows, Linux
- **[onOrca.dev에서 다운로드](https://onOrca.dev)**
- 또는 **[GitHub Releases 페이지](https://github.com/stablyai/orca/releases/latest)** 에서 받기
- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
- 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
_패키지 매니저로도 설치할 수 있습니다:_
### macOS (Homebrew)
_또는 패키지 매니저로 설치:_
```bash
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
```
### Arch Linux (AUR)
```bash
# 사전 컴파일된 바이너리
# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
# GitHub 소스에서 빌드
yay -S stably-orca-git
```
---
### 모바일 Companion — iOS, Android
## 모바일 Companion 앱
휴대폰에서 에이전트를 제어하세요.
<p align="center">
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="720" /></picture>
</p>
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [GitHub Releases에서 APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
## 기능 쇼케이스
타일을 클릭해 각 워크플로를 살펴보세요.
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>병렬 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>터미널 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty급 터미널 분할" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>디자인 모드</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 및 Linear 네이티브</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 워크플로" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>모든 CLI 에이전트</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="모든 CLI 에이전트 지원" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>에이전트로 파일 드래그</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 주석</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>네이티브 검색</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca 워크플로 전반의 네이티브 검색" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>계정 전환 및 사용량 추적</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="계정 전환 및 사용량 추적" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>풍부한 리포지토리 미리보기</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown, 이미지, PDF, 리포지토리 문서 미리보기" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>무엇이든 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="에이전트, 터미널, 브라우저, 파일을 위한 분할 패널" width="390" /></picture><br/></kbd></a>
</p>
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk)
---
@@ -142,10 +236,19 @@ yay -S stably-orca-git
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **응원하기:** 이 리포지토리에 star를 눌러 일일 릴리스를 따라와 주세요.
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일의 릴리스를 따라와 주세요.
---
## 개발
기여하거나 로컬에서 실행하고 싶으신가요? [CONTRIBUTING.md](../.github/CONTRIBUTING.md) 가이드를 확인하세요.
기여하거나 로컬에서 실행하고 싶으신가요? [CONTRIBUTING.md](../../.github/CONTRIBUTING.md) 가이드를 확인하세요.
<a href="https://github.com/stablyai/orca/graphs/contributors">
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 기여자" />
</a>
## 라이선스
Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
+205 -102
View File
@@ -3,149 +3,252 @@
</h1>
<p align="center">
<img src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge" alt="支持的平台" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/%E2%80%8E-Follow_@orca__build-000000?style=for-the-badge&logo=x&logoColor=white" alt="在 X 上关注" /></a>
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub Star 数" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="所有版本的总下载量" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="许可证" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="加入 Orca Discord" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="支持的平台:macOS、Windows 和 Linux" />
</p>
<p align="center">
<a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a> · <a href="README.es.md">Español</a>
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
</p>
<p align="center">
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
跨仓库并排运行 Claude Code、OpenClaude、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。<br/>
支持 <strong>macOS、Windows 和 Linux</strong>。
并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>下载 Orca</ins></a></h3>
<p align="center">
<a href="#安装"><strong>下载 🐋</strong></a>
<img src="../assets/readme-hero.jpg" alt="Orca 桌面应用在并行 worktree 中运行智能体,角落里是 Orca 移动 companion 应用" width="960" />
</p>
<p align="center">
<img src="../assets/file-drag.gif" alt="Orca Screenshot" width="800" />
</p>
## 支持的智能体
Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
</p>
---
## 特性
- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。
- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
- **GitHub 集成** — PR、issue 和 Actions 检查会自动链接到对应的 worktree。
- **SSH 支持** — 连接远程机器,并直接从 Orca 在远程机器上运行智能体。
- **通知** — 智能体完成任务或需要关注时及时通知你。可将会话标记为未读,方便稍后返回处理。
<table>
<tr>
<td width="50%" valign="middle">
### 移动 Companion 应用
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 并行 Worktree
把一个提示同时分发给五个智能体,每个都在自己隔离的 git worktree 中运行 — 比较结果,合并最佳方案。
[文档 →](https://www.onorca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 终端分屏
Ghostty 级终端,支持 WebGL 渲染、无限分屏,以及重启后依然保留的滚动历史。
[文档 →](https://www.onorca.dev/docs/terminal)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="终端分屏" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 设计模式
在真实的 Chromium 窗口中点击任意 UI 元素,把它的 HTML、CSS 和裁剪好的截图直接发送到智能体的提示中。
[文档 →](https://www.onorca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### GitHub &amp; Linear 原生集成
在应用内浏览 PR、issue 和项目看板 — 从任意任务打开 worktree,无需切换上下文即可完成评审。
[文档 →](https://www.onorca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 任务工作流" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### SSH Worktree
在高性能远程机器上运行智能体,完整支持文件编辑、git 和终端 — 自动重连与端口转发一应俱全。
[文档 →](https://www.onorca.dev/docs/ssh)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 标注 AI Diff
在任意 diff 行上添加评论并发回给智能体 — 评审、编辑、提交,全程无需离开 Orca。
[文档 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### 拖文件给智能体
VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智能体提示。
[文档 →](https://www.onorca.dev/docs/editing/file-explorer)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="100%" /></picture></a>
</td>
</tr>
<tr>
<td width="50%" valign="middle">
### Orca CLI
智能体也能驱动 Orca — 用 `orca worktree create`、`snapshot`、`click` 和 `fill` 把每个工作流脚本化。
[文档 →](https://www.onorca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="100%" /></picture></a>
</td>
</tr>
</table>
**开箱即用的还有:**
- **[快速打开](https://www.onorca.dev/docs/model/quick-open)** — 在 worktree、文件、智能体、命令和仓库上下文之间搜索,不打断你的心流。
- **[账号切换与用量追踪](https://www.onorca.dev/docs/agents/usage-tracking)** — 查看 Claude 和 Codex 的用量与限额重置时间,并且无需重新登录即可热切换账号。
- **[丰富仓库预览](https://www.onorca.dev/docs/editing/markdown)** — 在工作区中预览 Markdown、图片、PDF 和仓库文档。
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 当工作流需要真实交互时,让智能体操作桌面应用和可见 UI。
- **[通知与未读状态](https://www.onorca.dev/docs/notifications)** — 第一时间知道智能体何时完成或需要关注,并可将会话标记为未读,稍后再回来处理。
- **还有很多很多** — 我们每天发布新功能,这个列表永远跟不上。[更新日志](https://github.com/stablyai/orca/releases)才是真正的功能列表。
---
## 支持的智能体
适配**任何 CLI 智能体** — 只要能在终端里运行,就能在 Orca 里运行。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a> &nbsp;
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a> &nbsp;
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a> &nbsp;
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a> &nbsp;
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a> &nbsp;
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a> &nbsp;
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a> &nbsp;
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a> &nbsp;
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a> &nbsp;
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a> &nbsp;
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a> &nbsp;
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a> &nbsp;
<kbd>+ 任何 CLI 智能体</kbd>
</p>
---
## 安装
### Mac, Linux, Windows
### 桌面端 — macOS、Windows、Linux
- **[从 onOrca.dev 下载](https://onOrca.dev)**
- 或通过 **[GitHub Releases 页面](https://github.com/stablyai/orca/releases/latest)** 获取
- **[从 onOrca.dev 下载](https://onorca.dev/download)**
- 或直接获取安装包:[macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [全部构建](https://github.com/stablyai/orca/releases/latest)
_也可以通过包管理器安装:_
### macOS (Homebrew)
```bash
# macOS (Homebrew)
brew install --cask stablyai/orca/orca
```
### Arch Linux (AUR)
```bash
# 预编译二进制
# Arch Linux (AUR) — or stably-orca-git to build from source
yay -S stably-orca-bin
# 从 GitHub 源码构建
yay -S stably-orca-git
```
---
### 移动 Companion 应用 — iOS、Android
## 移动 Companion 应用
用手机控制你的智能体。
<p align="center">
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="720" /></picture>
</p>
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [从 GitHub Releases 下载 APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
---
## 功能展示
点击任意卡片了解对应工作流。
<p align="center">
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>并行 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>终端分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty 级终端分屏" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>设计模式</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 与 Linear 原生集成</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 工作流" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意 CLI 智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="支持任意 CLI 智能体" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>拖文件给智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>标注 AI Diff</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>原生搜索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="贯穿 Orca 工作流的原生搜索" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>账号切换与用量追踪</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="账号切换与用量追踪" width="390" /></picture><br/></kbd></a> &nbsp;&nbsp;
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>丰富仓库预览</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、图片、PDF 和仓库文档预览" width="390" /></picture><br/></kbd></a><br/><br/>
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>任意分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="为智能体、终端、浏览器和文件分屏" width="390" /></picture><br/></kbd></a>
</p>
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.13/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)。
@@ -26,7 +26,7 @@ Do not upload this state as broad analytics. Product analytics should continue t
| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. |
| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. |
| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. |
| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. |
| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Keep the existing Floating Workspace tour, and suppress future education about the global terminal/browser/markdown workspace. |
| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. |
| Computer Use setup | `computer-use-setup` | Computer Use was selected in legacy onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. |
| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. |
@@ -62,6 +62,7 @@ Orca also records surface-level interactions for feature areas where opening the
- `browser`: non-blank browser page viewed
- `tasks`: Tasks page opened
- `automations`: Automations page opened
- `floating-workspace`: floating workspace opened
- `workspace-creation`: workspace creation flow opened
These remain intentionally separate from action-level IDs such as `workspace-board-actions`, `automation-created`, and `automation-run`. Surface-level IDs answer "has the user entered the feature area?" Action-level IDs answer "has the user performed the deeper workflow?"
+55 -2
View File
@@ -88,7 +88,7 @@ Dashboard caveats:
### 2026-05-09 - Onboarding Cohort Injection
Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_tour_outcome`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_task_sources_snapshot`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, onboarding import/setup events, `onboarding_feature_setup_toggled`, `onboarding_feature_setup_run`, `onboarding_feature_setup_terminal_opened`, and `onboarding_feature_setup_terminal_interacted`. See `src/shared/telemetry-events.ts` for the exact current roster.
Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_tour_outcome`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_task_sources_snapshot`, `onboarding_windows_terminal_snapshot`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, onboarding import/setup events, `onboarding_feature_setup_toggled`, `onboarding_feature_setup_run`, `onboarding_feature_setup_terminal_opened`, and `onboarding_feature_setup_terminal_interacted`. See `src/shared/telemetry-events.ts` for the exact current roster.
The original `#1608` rollout covered `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, and onboarding import events. Later onboarding events joined the roster by declaring `cohort` in their schemas.
@@ -300,7 +300,7 @@ This is a product-flow and telemetry-interpretation boundary, not a new event ro
Dashboard caveats:
- Treat `onboarding_step_*` rows for the removed final code/project picker step as historical first-run onboarding signals after this rollout.
- Segment numeric onboarding step analysis across this boundary. The active final step changed from the five-step active flow to `ONBOARDING_FINAL_STEP = 4`.
- Segment numeric onboarding step analysis across this boundary. At this boundary, the active final step changed from the five-step active flow to `ONBOARDING_FINAL_STEP = 4`; later onboarding step rollouts may supersede that final-step value.
- Do not use absence of new final code/project picker rows as a drop-off signal; that step no longer exists in active onboarding.
### 2026-06-03 - Add Project Default Checkout Handoff
@@ -327,6 +327,59 @@ Dashboard caveats:
- Use `add_repo_existing_workspaces_detected` to estimate how often added projects had non-main existing workspaces, but do not infer the user selected "use existing worktrees" because that choice no longer exists in the normal flow.
- Use `add_repo_default_checkout_handoff` for the current handoff outcome. `result = 'opened_default_checkout'` is the expected path; `result = 'revealed_project'` is the graceful fallback. Break down fallback rows by `source` and `reason`.
### 2026-06-10 - Repo Added Git-vs-Folder Signal
Scope: `repo_added.is_git_repo` replaces the retired `onboarding_completed.is_git_repo` split for git-vs-folder analysis. Project selection moved out of onboarding in the 1.4.46 flow, so `onboarding_completed` now fires before any repo is chosen. After that boundary, the old `onboarding_completed.is_git_repo` value is not a valid git-vs-folder signal.
`repo_added.is_git_repo` is sourced from git detection at the add point. It is optional so SSH/remote paths that genuinely cannot determine git-ness can omit the property instead of defaulting to `false`.
| Field | Value |
| ------------------------ | ------------------------------------------------------------------------------------------- |
| PR | `#5121` |
| Merge commit | `TBD` |
| `code_merged_at_utc` | `TBD` |
| First release | `TBD` |
| First release commit | `TBD` |
| `first_released_at_utc` | `TBD` |
| `first_seen_at_utc` | `TBD` on `repo_added.is_git_repo` |
| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and field coverage has been checked in PostHog. |
PostHog evidence checked at `2026-06-10T19:00:00Z`:
- Dashboard tile "Fresh-install onboarding completion over time" (`JlIt5J1N`, insight id `9076383`, project `406068`) showed the git-repo share collapse to about 4% while plain-folder completions spiked to about 88% on 2026-06-05.
- Raw `onboarding_completed.is_git_repo` counts by `app_version` showed a version cliff: versions through `1.4.45` were about 80% true, while `1.4.46`, `1.4.47`, and `1.4.48` had zero true rows in the sampled data.
Dashboard caveats:
- Treat `onboarding_completed.is_git_repo` as historical only after app version `1.4.45`.
- Do not stitch historical `onboarding_completed.is_git_repo` and new `repo_added.is_git_repo` series without an explicit version boundary and label change; they are emitted at different funnel moments.
- Repoint dashboard tile `JlIt5J1N` to use `repo_added.is_git_repo` once the new field is observed in release telemetry.
- Omitted `repo_added.is_git_repo` means unknown/degraded detection, not plain folder. Only explicit `false` means plain folder.
### 2026-06-16 - Windows Terminal Preferences Onboarding Step
Scope: Windows first-run onboarding adds a terminal preferences step before notifications. The step lets users choose the default Windows terminal shell and right-click paste/menu behavior before their first project handoff.
`onboarding_step_*` rows can now emit `value_kind = 'windows_terminal'` at step `4`. Notifications move to step `5`, so `ONBOARDING_FINAL_STEP = 5` for current active onboarding. Non-Windows clients skip the Windows terminal step but still persist through the skipped step so resumed onboarding lands on notifications. `onboarding_windows_terminal_snapshot` records the low-cardinality selected shell bucket, right-click behavior, exit action, duration, and advance method when the visible Windows terminal step exits.
| Field | Value |
| ------------------------ | -------------------------------------------------------------------------------------- |
| PR | `#5488` |
| Merge commit | `68abadba8198c627fb642c41e54937c04ccddfe8` |
| `code_merged_at_utc` | `2026-06-16T21:07:26Z` |
| First release | `TBD` |
| First release commit | `TBD` |
| `first_released_at_utc` | `TBD` |
| `first_seen_at_utc` | `TBD` on `onboarding_step_viewed { value_kind: 'windows_terminal' }` and `onboarding_windows_terminal_snapshot` |
| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and Windows/non-Windows split plus snapshot coverage are verified. |
Dashboard caveats:
- Segment numeric onboarding step analysis across this boundary. Step `4` is Windows terminal preferences in the current flow, but was notifications in the previous active flow.
- Use `value_kind` rather than numeric `step` when comparing notifications or Windows terminal setup across releases.
- Non-Windows users can have persisted `lastCompletedStep` values that include the skipped Windows step; do not treat that as evidence they viewed the Windows terminal page.
- `onboarding_windows_terminal_snapshot.default_shell = 'other'` means Orca could not bucket the persisted setting. It is not a raw shell path and should be monitored as telemetry quality, not a product choice.
## Updating This File
When adding or changing telemetry that dashboard authors will depend on:
+2
View File
@@ -180,6 +180,8 @@ export default defineConfig({
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
+10 -4
View File
@@ -28,13 +28,19 @@
{
"files": ["src/terminal/TerminalWebView.tsx"],
"rules": {
"max-lines": ["error", { "max": 2054, "skipBlankLines": true, "skipComments": true }]
"max-lines": ["error", { "max": 379, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/terminal/terminal-webview-html.ts"],
"rules": {
"max-lines": ["error", { "max": 1778, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/source-control/*.tsx"],
"rules": {
"max-lines": ["error", { "max": 2004, "skipBlankLines": true, "skipComments": true }]
"max-lines": ["error", { "max": 2152, "skipBlankLines": true, "skipComments": true }]
}
},
{
@@ -52,7 +58,7 @@
{
"files": ["app/index.tsx"],
"rules": {
"max-lines": ["error", { "max": 1419, "skipBlankLines": true, "skipComments": true }]
"max-lines": ["error", { "max": 1422, "skipBlankLines": true, "skipComments": true }]
}
},
{
@@ -64,7 +70,7 @@
{
"files": ["src/transport/rpc-client.ts"],
"rules": {
"max-lines": ["error", { "max": 1070, "skipBlankLines": true, "skipComments": true }]
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
{
+6
View File
@@ -0,0 +1,6 @@
# Pins fastlane for reproducible iOS releases in CI (see fastlane/Fastfile and
# .github/workflows/mobile-build.yml). macOS runners ship a fastlane, but
# pinning here keeps the release toolchain stable across runner image bumps.
source "https://rubygems.org"
gem "fastlane"
+9 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.12",
"version": "0.0.14",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -20,6 +20,7 @@
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
"NSPhotoLibraryUsageDescription": "Allow Orca to attach photos from your library to a terminal session on your paired desktop.",
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
@@ -78,6 +79,7 @@
},
"plugins": [
"expo-router",
"./plugins/android-respect-rotation-lock.js",
[
"expo-splash-screen",
{
@@ -93,6 +95,12 @@
"recordAudioAndroid": false
}
],
[
"expo-image-picker",
{
"photosPermission": "Allow Orca to attach photos from your library to a terminal session on your paired desktop."
}
],
[
"expo-build-properties",
{
+7 -2
View File
@@ -145,8 +145,12 @@ export default function RootLayout() {
headerTintColor: colors.textPrimary,
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
contentStyle: { backgroundColor: colors.bgBase },
headerShadowVisible: false,
orientation: 'all'
headerShadowVisible: false
// Why: deliberately no `orientation` screenOption. react-native-screens
// has no value that respects the device rotation lock — even 'default'
// calls setRequestedOrientation(UNSPECIFIED) at runtime, overriding the
// manifest. Leaving it unset lets the manifest's "fullUser" (set by the
// android-respect-rotation-lock config plugin) honor the auto-rotate lock.
}}
>
<Stack.Screen
@@ -161,6 +165,7 @@ export default function RootLayout() {
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
@@ -0,0 +1,137 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
export const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
gap: spacing.sm
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
titleWrap: {
flex: 1
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
subheading: {
fontSize: typography.metaSize,
color: colors.textSecondary,
marginTop: 1
},
scroll: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm
},
section: {
marginBottom: spacing.xl
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm
},
sectionHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5
},
card: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowMain: {
flex: 1,
gap: 4
},
// Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
// same width whether or not the row is currently selected (otherwise the
// checkmark on the active account squeezes the bars narrower than the
// inactive rows above/below it).
rowTrailing: {
width: 24,
alignItems: 'flex-end',
justifyContent: 'center',
marginLeft: spacing.sm
},
rowTitle: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSubtitle: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
usageRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: 4
},
errorText: {
fontSize: typography.metaSize,
color: colors.statusRed
},
placeholder: {
paddingVertical: spacing.xl * 2,
alignItems: 'center',
gap: spacing.sm
},
placeholderText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
footerHint: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingTop: spacing.sm
},
footerHintText: {
flex: 1,
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})
+34 -146
View File
@@ -2,7 +2,6 @@ import { useEffect, useState, useCallback } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
ScrollView,
ActivityIndicator,
@@ -15,13 +14,16 @@ import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { styles } from './accounts-screen-styles'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
hasActiveProviderUsage,
UsageBar
} from '../../../src/components/AccountUsage'
@@ -132,6 +134,8 @@ export default function AccountsScreen() {
}
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
const activeSessionBar = getUsageBarState(activeUsage, 'session')
const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly')
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
return (
<View style={styles.section}>
@@ -149,6 +153,25 @@ export default function AccountsScreen() {
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>System default</Text>
<Text style={styles.rowSubtitle}>Use the agent's own login</Text>
{/* Why: when system default is the active selection, activeUsage
holds the system-default login's rate limits — surface them
here so non-managed users still see their usage. */}
{state.activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
<View style={styles.usageRow}>
<UsageBar
label="5h"
usedPercent={activeSessionBar.usedPercent}
unavailable={activeSessionBar.unavailable}
loading={activeSessionBar.loading}
/>
<UsageBar
label="7d"
usedPercent={activeWeeklyBar.usedPercent}
unavailable={activeWeeklyBar.unavailable}
loading={activeWeeklyBar.loading}
/>
</View>
) : null}
</View>
<View style={styles.rowTrailing}>
{state.activeAccountId === null ? (
@@ -164,12 +187,12 @@ export default function AccountsScreen() {
const inactiveEntry = !isActive
? getInactiveProviderUsage(snapshot, provider, account.id)
: null
const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null)
const usage = isActive ? activeUsage : (inactiveEntry?.rateLimits ?? null)
const isFetching =
(isActive && usage?.status === 'fetching') ||
(!isActive && inactiveEntry?.isFetching === true)
const session = usage?.session
const weekly = usage?.weekly
const sessionBar = getUsageBarState(usage, 'session', isFetching)
const weeklyBar = getUsageBarState(usage, 'weekly', isFetching)
return (
<View key={account.id}>
<View style={styles.separator} />
@@ -185,15 +208,15 @@ export default function AccountsScreen() {
<View style={styles.usageRow}>
<UsageBar
label="5h"
usedPercent={session?.usedPercent ?? null}
unavailable={!session && !isFetching}
loading={isFetching && !session}
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
/>
<UsageBar
label="7d"
usedPercent={weekly?.usedPercent ?? null}
unavailable={!weekly && !isFetching}
loading={isFetching && !weekly}
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
/>
</View>
{usage?.error ? (
@@ -285,138 +308,3 @@ export default function AccountsScreen() {
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
gap: spacing.sm
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
titleWrap: {
flex: 1
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
subheading: {
fontSize: typography.metaSize,
color: colors.textSecondary,
marginTop: 1
},
scroll: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm
},
section: {
marginBottom: spacing.xl
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm
},
sectionHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5
},
card: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowMain: {
flex: 1,
gap: 4
},
// Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
// same width whether or not the row is currently selected (otherwise the
// checkmark on the active account squeezes the bars narrower than the
// inactive rows above/below it).
rowTrailing: {
width: 24,
alignItems: 'flex-end',
justifyContent: 'center',
marginLeft: spacing.sm
},
rowTitle: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSubtitle: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
usageRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: 4
},
errorText: {
fontSize: typography.metaSize,
color: colors.statusRed
},
placeholder: {
paddingVertical: spacing.xl * 2,
alignItems: 'center',
gap: spacing.sm
},
placeholderText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
footerHint: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingTop: spacing.sm
},
footerHintText: {
flex: 1,
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})
+42 -111
View File
@@ -10,111 +10,30 @@ import {
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronDown, ChevronLeft, ChevronRight, File, FileText, Folder } from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import {
ChevronDown,
ChevronLeft,
ChevronRight,
File,
FileText,
Folder,
Image as ImageIcon
} from 'lucide-react-native'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import { getWorktreeLabel } from '../../../../src/session/worktree-label'
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
import {
buildTree,
flattenTree,
isMarkdownPath,
type FilesListResult,
type MobileFileEntry,
type TreeNode
} from '../../../../src/files/file-tree'
import type { RpcSuccess } from '../../../../src/transport/types'
import { triggerError, triggerSelection } from '../../../../src/platform/haptics'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
type MobileFileEntry = {
relativePath: string
basename: string
kind: 'text' | 'binary'
}
type FilesListResult = {
files: MobileFileEntry[]
totalCount: number
truncated: boolean
}
type TreeNode = {
id: string
name: string
relativePath: string
depth: number
kind: 'directory' | 'text' | 'binary'
}
type DirectoryNode = {
name: string
relativePath: string
directories: Map<string, DirectoryNode>
files: MobileFileEntry[]
}
function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
return { name, relativePath, directories: new Map(), files: [] }
}
function buildTree(files: MobileFileEntry[]): DirectoryNode {
const root = createDirectoryNode('', '')
for (const file of files) {
const parts = file.relativePath.split('/').filter(Boolean)
let current = root
for (let index = 0; index < parts.length - 1; index += 1) {
const name = parts[index]!
const relativePath = parts.slice(0, index + 1).join('/')
let child = current.directories.get(name)
if (!child) {
child = createDirectoryNode(name, relativePath)
current.directories.set(name, child)
}
current = child
}
current.files.push(file)
}
return root
}
function flattenTree(root: DirectoryNode, expanded: ReadonlySet<string>): TreeNode[] {
const rows: TreeNode[] = []
const visit = (directory: DirectoryNode, depth: number): void => {
const dirs = Array.from(directory.directories.values()).sort((a, b) =>
a.name.localeCompare(b.name)
)
for (const child of dirs) {
rows.push({
id: `dir:${child.relativePath}`,
name: child.name,
relativePath: child.relativePath,
depth,
kind: 'directory'
})
if (expanded.has(child.relativePath)) {
visit(child, depth + 1)
}
}
const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename))
for (const file of files) {
rows.push({
id: `file:${file.relativePath}`,
name: file.basename,
relativePath: file.relativePath,
depth,
kind: file.kind
})
}
}
visit(root, 0)
return rows
}
function isMarkdownPath(relativePath: string): boolean {
return /\.(md|mdx|markdown)$/i.test(relativePath)
}
function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
if (name?.trim()) {
return name.trim()
}
const pathPart = worktreeId.includes('::')
? worktreeId.slice(worktreeId.indexOf('::') + 2)
: worktreeId
const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
}
export default function MobileFileExplorerScreen() {
const { hostId, worktreeId, name } = useLocalSearchParams<{
hostId: string
@@ -123,6 +42,7 @@ export default function MobileFileExplorerScreen() {
}>()
const router = useRouter()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [files, setFiles] = useState<MobileFileEntry[]>([])
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(true)
@@ -174,8 +94,8 @@ export default function MobileFileExplorerScreen() {
}, [])
const openFile = useCallback(
async (relativePath: string, kind: 'text' | 'binary') => {
if (!client || kind === 'binary') {
async (relativePath: string) => {
if (!client) {
return
}
setOpeningPath(relativePath)
@@ -196,13 +116,16 @@ export default function MobileFileExplorerScreen() {
setOpeningPath(null)
}
},
[client, hostId, name, router, worktreeId]
[client, router, worktreeId]
)
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
const isDirectory = item.kind === 'directory'
const isExpanded = expanded.has(item.relativePath)
const disabled = item.kind === 'binary'
// Images render in the mobile viewer (via files.readPreview), so a binary
// image is openable; only non-previewable binaries are unavailable.
const isImage = item.kind === 'binary' && classifyMobileArtifact(item.relativePath) === 'image'
const disabled = item.kind === 'binary' && !isImage
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
return (
<Pressable
@@ -216,8 +139,8 @@ export default function MobileFileExplorerScreen() {
onPress={() => {
if (isDirectory) {
toggleDirectory(item.relativePath)
} else if (item.kind === 'text' || item.kind === 'binary') {
void openFile(item.relativePath, item.kind)
} else if (!disabled) {
void openFile(item.relativePath)
}
}}
accessibilityLabel={
@@ -241,6 +164,8 @@ export default function MobileFileExplorerScreen() {
<Folder size={17} color={colors.textSecondary} />
) : markdown ? (
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
) : isImage ? (
<ImageIcon size={17} color={colors.textSecondary} />
) : (
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
)}
@@ -287,7 +212,15 @@ export default function MobileFileExplorerScreen() {
) : error ? (
<View style={styles.state}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={() => void loadFiles()}>
{/* Why: while disconnected, re-sending the request is useless — revive
the parked transport instead (issue #5049); loadFiles re-runs via
its effect once the new client connects. */}
<Pressable
style={styles.retryButton}
onPress={() =>
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles()
}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
@@ -349,9 +282,7 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
list: {
flex: 1
},
list: { flex: 1 },
listContent: {
paddingVertical: spacing.sm
},
@@ -0,0 +1,207 @@
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import type { RpcSuccess } from '../../../../src/transport/types'
import { colors, spacing, typography } from '../../../../src/theme/mobile-theme'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
type MobileCommitRow
} from '../../../../src/source-control/mobile-git-history'
import type { GitBranchChangeEntry } from '../../../../../src/shared/types'
function firstParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
}
export default function HistoryScreen() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
}>()
const hostId = firstParam(params.hostId)
const worktreeId = firstParam(params.worktreeId)
const router = useRouter()
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<string | null>(null)
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
useEffect(() => {
let active = true
if (!client || connState !== 'connected' || !worktreeId) {
return
}
// Reset prior error/rows so a successful retry doesn't stay stuck behind a
// stale error (error wins render precedence).
setError(null)
setRows(null)
void (async () => {
try {
const result = await fetchMobileGitHistory(client, worktreeId)
if (active) {
setRows(mapMobileCommitRows(result, Date.now()))
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : 'Failed to load history')
}
}
})()
return () => {
active = false
}
}, [client, connState, worktreeId])
const toggleCommit = useCallback(
(row: MobileCommitRow) => {
const next = expanded === row.id ? null : row.id
setExpanded(next)
if (next && client && !filesById[row.id]) {
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
void client
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
.then((response) => {
const entries = response.ok
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
: []
setFilesById((prev) => ({ ...prev, [row.id]: entries }))
})
.catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] })))
}
},
[client, expanded, filesById, worktreeId]
)
const renderCommit = useCallback(
({ item }: { item: MobileCommitRow }) => {
const files = filesById[item.id]
const isOpen = expanded === item.id
return (
<View style={styles.commit}>
<Pressable
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
onPress={() => toggleCommit(item)}
>
{isOpen ? (
<ChevronDown size={14} color={colors.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
)}
<View style={styles.commitMain}>
<Text style={styles.commitSubject} numberOfLines={1}>
{item.subject}
</Text>
<Text style={styles.commitMeta} numberOfLines={1}>
{item.shortId} · {item.author} · {item.relativeTime}
</Text>
</View>
</Pressable>
{isOpen ? (
<View style={styles.files}>
{files === 'loading' || files === undefined ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : files.length === 0 ? (
<Text style={styles.empty}>No file changes</Text>
) : (
files.map((file) => (
<View key={file.path} style={styles.fileRow}>
<Text style={styles.filePath} numberOfLines={1}>
{file.path}
</Text>
<Text style={styles.fileStat}>
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
</Text>
</View>
))
)}
</View>
) : null}
</View>
)
},
[expanded, filesById, toggleCommit]
)
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} accessibilityLabel="Back">
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<Text style={styles.title}>Commit History</Text>
</View>
{error ? (
<View style={styles.state}>
<Text style={styles.stateText}>{error}</Text>
</View>
) : rows === null ? (
<View style={styles.state}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : rows.length === 0 ? (
<View style={styles.state}>
<Text style={styles.stateText}>No commits.</Text>
</View>
) : (
<FlatList
data={rows}
renderItem={renderCommit}
keyExtractor={(row) => row.id}
contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
/>
)}
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgBase },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm
},
back: { padding: spacing.xs },
title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
commitHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2
},
commitHeaderPressed: { backgroundColor: colors.bgRaised },
commitMain: { flex: 1, minWidth: 0 },
commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize },
commitMeta: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
marginTop: 2
},
files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 },
fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
filePath: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily },
add: { color: colors.gitDecorationAdded },
del: { color: colors.gitDecorationDeleted },
empty: { color: colors.textMuted, fontSize: typography.metaSize }
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
import { useCallback } from 'react'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { MobileDiffReviewScreenView } from '../../../../src/components/MobileDiffReviewScreenView'
import {
firstReviewParam,
normalizeReviewFilterParam
} from '../../../../src/session/mobile-diff-review-screen-model'
import { useMobileDiffReviewController } from '../../../../src/session/use-mobile-diff-review-controller'
import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context'
export default function MobileDiffReviewScreen() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
scope?: string | string[]
}>()
const hostId = firstReviewParam(params.hostId)
const worktreeId = firstReviewParam(params.worktreeId)
const name = firstReviewParam(params.name)
const initialFilter = normalizeReviewFilterParam(firstReviewParam(params.scope))
const router = useRouter()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const openSession = useCallback(() => {
const query = name ? `?${new URLSearchParams({ name }).toString()}` : ''
router.replace(
`/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(worktreeId)}${query}`
)
}, [hostId, name, router, worktreeId])
const controller = useMobileDiffReviewController({
client,
connState,
hostId,
worktreeId,
name,
initialFilter,
onOpenSession: openSession,
onReconnect: forceReconnect
})
return <MobileDiffReviewScreenView controller={controller} onBack={() => router.back()} />
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,178 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
export const mobileSessionCommandInputStyles = StyleSheet.create({
createWarningBanner: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
createWarningText: {
flex: 1,
color: colors.textPrimary,
fontSize: 12,
lineHeight: 16
},
createWarningDismiss: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
marginTop: -4
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginBottom: spacing.lg
},
createError: {
color: colors.statusRed,
fontSize: 13,
marginBottom: spacing.sm
},
emptyActions: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
gap: spacing.sm
},
createButton: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button
},
createButtonDisabled: {
opacity: 0.5
},
createButtonText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
commandDock: {
zIndex: 20
},
accessoryBar: {
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
accessoryContent: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
gap: spacing.xs
},
accessoryKey: {
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs,
borderRadius: radii.button,
minWidth: 36,
alignItems: 'center'
},
accessoryKeyPressed: {
backgroundColor: colors.borderSubtle
},
accessoryKeyActive: {
backgroundColor: colors.textPrimary
},
customAccessoryKey: {
borderWidth: 1,
borderColor: colors.borderSubtle
},
accessoryKeyDisabled: {
opacity: 0.35
},
accessoryKeyText: {
color: colors.textSecondary,
fontSize: 12,
fontFamily: typography.monoFamily
},
accessoryKeyTextActive: {
color: colors.bgBase,
fontWeight: '700'
},
accessoryKeyTextDisabled: {
color: colors.textMuted
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',
minHeight: 46,
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
textInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: 0,
fontSize: 14,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
liveInputBar: {
gap: spacing.sm
},
liveInputHint: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
liveInputCapture: {
position: 'absolute',
opacity: 0,
width: 1,
height: 1,
color: colors.textPrimary
},
sendButton: {
backgroundColor: colors.bgRaised,
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center'
},
dictationButton: {
backgroundColor: colors.bgRaised,
width: 34,
height: 34,
borderRadius: 17,
borderWidth: 1,
borderColor: 'transparent',
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
dictationButtonActive: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
sendButtonDisabled: {
opacity: 0.35
}
})
@@ -0,0 +1,164 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
export const mobileSessionFrameStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
kavInner: {
flex: 1
},
sessionChrome: {
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
sessionTopBar: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.xs
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
filesButton: {
width: 36,
height: 36,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs
},
filesButtonPressed: {
backgroundColor: colors.bgRaised
},
sessionTitleBlock: {
flex: 1,
minWidth: 0
},
sessionTitle: {
color: colors.textPrimary,
fontSize: 14,
fontWeight: '600'
},
sessionMetaRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2
},
sessionMetaText: {
flexShrink: 1,
color: colors.textSecondary,
fontSize: typography.metaSize
},
tabBar: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: colors.borderSubtle
},
tabScroll: {
flex: 1,
maxHeight: 36
},
tabContent: {
paddingLeft: spacing.sm,
paddingRight: spacing.sm
},
tab: {
width: 128,
maxWidth: 128,
minHeight: 36,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
tabActive: {
// Neutral grey underline, matching the desktop terminal tab's active
// indicator (a muted foreground/card mix), not a blue accent.
borderBottomColor: colors.textSecondary
},
tabLabelRow: {
maxWidth: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
tabText: {
flexShrink: 1,
color: colors.textSecondary,
fontSize: 13
},
tabTextActive: {
color: colors.textPrimary
},
newTerminalButton: {
width: 40,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
newTerminalButtonPressed: {
backgroundColor: colors.bgRaised
},
newTerminalButtonDisabled: {
opacity: 0.45
},
terminalFrame: {
flex: 1,
minHeight: 0,
position: 'relative',
overflow: 'hidden'
},
terminalPane: {
...StyleSheet.absoluteFillObject
},
terminalPaneHidden: {
opacity: 0
},
terminalWebView: {
flex: 1
},
markdownFrame: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
},
browserFrame: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
},
markdownEditor: {
flex: 1,
position: 'relative'
},
markdownState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
gap: spacing.md
},
markdownError: {
color: colors.statusRed,
fontSize: typography.bodySize
}
})
@@ -0,0 +1,140 @@
import { Platform, StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
export const mobileSessionReaderStyles = StyleSheet.create({
markdownTextInput: {
flex: 1,
minHeight: 0,
color: colors.textPrimary,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.xl * 3,
fontSize: typography.bodySize,
lineHeight: 22,
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
filePreviewScroll: {
flex: 1,
minHeight: 0,
backgroundColor: colors.editorSurface
},
filePreviewContent: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.xl
},
filePreviewText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
lineHeight: 22,
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
imagePreviewContainer: {
flex: 1,
minHeight: 0,
backgroundColor: colors.editorSurface
},
imagePreviewScroll: {
flex: 1
},
imagePreviewContent: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.lg
},
imagePreview: {
width: '100%',
height: '100%',
minHeight: 200
},
diffNotesToolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
diffNotesTitleRow: {
minWidth: 0,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
diffNotesTitle: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
},
diffNotesActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
diffNotesActionButton: {
minHeight: 30,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
backgroundColor: colors.bgRaised
},
diffNotesActionText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
},
diffLineBlock: {
marginBottom: spacing.xs
},
diffLine: {
flexDirection: 'row',
alignItems: 'flex-start',
borderLeftWidth: 2,
borderLeftColor: colors.editorSurface,
paddingRight: spacing.sm
},
diffLineAdded: {
backgroundColor: colors.diffAddedBg,
borderLeftColor: colors.gitDecorationAdded
},
diffLineDeleted: {
backgroundColor: colors.diffDeletedBg,
borderLeftColor: colors.gitDecorationDeleted
},
diffGutter: {
width: 42,
paddingRight: spacing.sm,
textAlign: 'right',
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: 22,
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
diffText: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize,
lineHeight: 22,
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
},
diffPrefix: {
color: colors.textMuted
},
diffPrefixAdded: {
color: colors.gitDecorationAdded
},
diffPrefixDeleted: {
color: colors.gitDecorationDeleted
}
})
@@ -0,0 +1,189 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../../../../src/theme/mobile-theme'
export const mobileSessionReviewCommentStyles = StyleSheet.create({
diffCommentAddButton: {
width: 26,
height: 22,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
diffCommentAddButtonPressed: {
backgroundColor: colors.bgPanel
},
diffCommentButtonDisabled: {
opacity: 0.45
},
diffCommentList: {
gap: spacing.xs,
marginLeft: 44,
marginRight: spacing.sm,
marginTop: spacing.xs
},
diffCommentCard: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
diffCommentHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: 2
},
diffCommentMeta: {
flex: 1,
color: colors.textMuted,
fontSize: typography.metaSize,
fontWeight: '600'
},
diffCommentDeleteButton: {
width: 22,
height: 22,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 11
},
diffCommentBody: {
color: colors.textPrimary,
fontSize: typography.metaSize,
lineHeight: 17
},
diffCommentComposer: {
gap: spacing.xs,
marginLeft: 44,
marginRight: spacing.sm,
marginTop: spacing.xs,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
padding: spacing.sm
},
diffCommentInput: {
minHeight: 70,
height: 70,
marginRight: 0,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
diffCommentComposerActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.xs
},
diffCommentSecondaryAction: {
minHeight: 30,
justifyContent: 'center',
borderRadius: radii.button,
paddingHorizontal: spacing.md
},
diffCommentSecondaryText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
},
diffCommentPrimaryAction: {
minHeight: 30,
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.md
},
diffCommentPrimaryText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '700'
},
markdownRefreshButton: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
markdownButtonDisabled: {
opacity: 0.45
},
markdownRefreshText: {
color: colors.textPrimary,
fontSize: 13,
fontWeight: '600'
},
markdownFloatingBar: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
bottom: spacing.lg,
alignItems: 'flex-end',
gap: spacing.xs
},
markdownFloatingStatus: {
maxWidth: '100%',
alignSelf: 'flex-end',
overflow: 'hidden',
color: colors.textSecondary,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: typography.metaSize
},
markdownFloatingActions: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: spacing.xs
},
markdownFloatingButton: {
minHeight: 34,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
markdownSaveButton: {
backgroundColor: colors.bgRaised
},
markdownFloatingButtonText: {
color: colors.textPrimary,
fontSize: 13,
fontWeight: '600'
},
toast: {
position: 'absolute',
bottom: spacing.lg,
alignSelf: 'center',
left: 0,
right: 0,
alignItems: 'center'
},
toastText: {
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontSize: 13,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
overflow: 'hidden'
}
})
@@ -0,0 +1,149 @@
import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
import type { MobileTerminalTheme } from '../../../../src/terminal/TerminalWebView'
import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines'
import type {
MobileHighlightedDiffLine,
MobileSyntaxSegment
} from '../../../../src/session/mobile-file-syntax'
import type { TerminalRecord } from '../../../../src/session/mobile-terminal-records'
import type { DiffComment } from '../../../../../src/shared/types'
import type { AgentStatusEntry } from '../../../../../src/shared/agent-status-types'
export type Terminal = TerminalRecord
export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser'
export type MobileSessionTab =
| {
type: 'terminal'
id: string
title: string
parentTabId?: string
leafId?: string
status?: 'pending-handle' | 'ready'
terminal: string | null
agentStatus?: AgentStatusEntry | null
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
| {
type: 'markdown'
id: string
title: string
filePath: string
relativePath: string
isDirty: boolean
isActive: boolean
documentVersion: string
}
| {
type: 'file'
id: string
title: string
filePath: string
relativePath: string
language?: string
mode?: 'edit' | 'diff'
diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
isDirty: boolean
isActive: boolean
}
| MobileBrowserTab
export type SessionTabsResult = {
worktree: string
publicationEpoch?: string
snapshotVersion: number
tabs: MobileSessionTab[]
activeTabId: string | null
activeTabType: MobileSessionTabType | null
}
export type RuntimeStatusResult = {
capabilities?: string[]
}
export type MarkdownDocState =
| { status: 'loading' }
| {
status: 'ready'
content: string
localContent: string
baseVersion: string
isDirty: boolean
editable: boolean
stale?: boolean
saving?: boolean
saveError?: string
readOnlyReason?: string
}
| { status: 'error'; message: string }
export type FileDocState =
| { status: 'loading' }
| { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number }
| { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean }
| { status: 'ready'; kind: 'image'; dataUri: string }
| { status: 'ready'; kind: 'html'; content: string }
| { status: 'error'; message: string }
export type RenderableDiffLine = MobileHighlightedDiffLine<MobileDiffLine>
export type DiffCommentActions = {
comments: DiffComment[]
busy: boolean
onAdd: (filePath: string, lineNumber: number, body: string) => Promise<boolean>
onDelete: (commentId: string) => Promise<void>
onCopyAll: () => Promise<void>
onSendAll: () => void
}
export type DiffNotesDelivery = {
prompt: string
comments: DiffComment[]
}
export type ReadyFileDocState = Extract<FileDocState, { status: 'ready' }>
export type FileSyntaxState = {
doc: ReadyFileDocState
language: string
segments: MobileSyntaxSegment[]
}
export type DiffSyntaxState = {
doc: ReadyFileDocState
language: string
lines: RenderableDiffLine[]
}
export type DirtyMarkdownDraft = {
tabId: string
title: string
content: string
}
export type TerminalCreateResult = {
tab: Extract<MobileSessionTab, { type: 'terminal' }>
}
export type MobileNewTabAgentLoadState = 'idle' | 'loading' | 'loaded' | 'error'
export type RuntimeRepoSummary = {
id: string
connectionId?: string | null
}
export type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
export type TerminalGestureInputBucket = {
tokens: number
lastRefillMs: number
}
export type TerminalGestureInputQueue = {
bytes: string
sequenceCount: number
timer: ReturnType<typeof setTimeout> | null
lastUpdatedMs: number
}
@@ -0,0 +1,11 @@
import { mobileSessionCommandInputStyles } from './mobile-session-command-input-styles'
import { mobileSessionFrameStyles } from './mobile-session-frame-styles'
import { mobileSessionReaderStyles } from './mobile-session-reader-styles'
import { mobileSessionReviewCommentStyles } from './mobile-session-review-comment-styles'
export const styles = {
...mobileSessionFrameStyles,
...mobileSessionReaderStyles,
...mobileSessionReviewCommentStyles,
...mobileSessionCommandInputStyles
}
@@ -14,25 +14,48 @@ import {
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import {
ChevronLeft,
ArrowDown,
ArrowDownUp,
ArrowUp,
Check,
ChevronLeft,
CloudUpload,
FileText,
GitBranch,
GitPullRequest,
History,
Minus,
MoreHorizontal,
Plus,
RefreshCw,
Sparkles,
Trash2,
X
X,
type LucideIcon
} from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import type { MobileSourceControlActionIcon } from '../../../../src/source-control/mobile-source-control-actions'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import { getWorktreeLabel } from '../../../../src/session/worktree-label'
import type { RpcSuccess } from '../../../../src/transport/types'
import { MobileSourceControlReviewEntry } from '../../../../src/source-control/mobile-source-control-review-entry'
import { resolveMobileBranchCompareBaseRef } from '../../../../src/source-control/mobile-branch-base-ref'
import {
cancelMobileCommitMessage,
requestMobileCommitMessage
} from '../../../../src/source-control/mobile-commit-message-ai'
import { buildMobileSourceControlActions } from '../../../../src/source-control/mobile-source-control-actions'
import {
MobilePrComposeSheet,
openMobilePrUrl
} from '../../../../src/components/MobilePrComposeSheet'
import {
resolveMobilePrPrefill,
type MobilePrPrefill
} from '../../../../src/source-control/mobile-pr-create'
import { PickerModal } from '../../../../src/components/PickerModal'
import type { RuntimeGitLocalBranches } from '../../../../../src/shared/runtime-types'
type MobileGitLocalBranches = RuntimeGitLocalBranches
import {
ActionSheetModal,
type ActionSheetAction
@@ -133,16 +156,6 @@ type MobileBranchDiffPreviewState =
}
| { kind: 'error'; entry: MobileGitBranchChangeEntry; message: string }
type RuntimeRepoSummary = {
id: string
worktreeBaseRef?: string | null
}
type RepoBaseRefDefaultResult = {
defaultBaseRef: string | null
remoteCount: number
}
type GitDiffTextResult = {
kind: 'text'
originalContent: string
@@ -150,6 +163,19 @@ type GitDiffTextResult = {
}
const KEYBOARD_COMMIT_BAR_CLEARANCE = 10
const SOURCE_CONTROL_ACTION_ICONS: Record<MobileSourceControlActionIcon, LucideIcon> = {
commit: Check,
push: ArrowUp,
pull: ArrowDown,
sync: ArrowDownUp,
fetch: RefreshCw,
publish: CloudUpload,
rebase: GitBranch,
pr: GitPullRequest,
branch: GitBranch,
history: History
}
const SELECTOR_RETRY_COUNT = 3
const SELECTOR_RETRY_DELAY_MS = 250
@@ -161,56 +187,6 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getRepoIdFromMobileWorktreeId(id: string): string {
// Why: mobile cannot import desktop shared modules in its standalone tsc run,
// but the runtime worktree id wire format is still `${repoId}::${path}`.
const separatorIdx = id.indexOf('::')
return separatorIdx === -1 ? id : id.slice(0, separatorIdx)
}
async function resolveMobileBranchCompareBaseRef(
client: RpcClient,
worktreeId: string
): Promise<string | null> {
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
if (!repoId) {
return null
}
let repoBaseRef: string | null = null
const repoResponse = await client.sendRequest('repo.list')
if (repoResponse.ok) {
const repos = ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos
const repo = repos?.find((candidate) => candidate.id === repoId)
repoBaseRef = repo?.worktreeBaseRef?.trim() || null
}
if (repoBaseRef) {
return repoBaseRef
}
const defaultResponse = await client.sendRequest('repo.baseRefDefault', { repo: `id:${repoId}` })
if (!defaultResponse.ok) {
if (isMobileGitUnavailable(defaultResponse.error?.code, defaultResponse.error?.message)) {
return null
}
throw new Error(defaultResponse.error?.message || 'Unable to resolve branch base')
}
const result = (defaultResponse as RpcSuccess).result as RepoBaseRefDefaultResult
return result.defaultBaseRef?.trim() || null
}
function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
if (name?.trim()) {
return name.trim()
}
const pathPart = worktreeId.includes('::')
? worktreeId.slice(worktreeId.indexOf('::') + 2)
: worktreeId
const normalized = pathPart.replace(/\\/g, '/').replace(/\/+$/, '')
return normalized.slice(normalized.lastIndexOf('/') + 1) || 'Worktree'
}
function formatBranchLabel(branch: string | undefined, head: string | undefined): string {
if (branch?.startsWith('refs/heads/')) {
return branch.slice('refs/heads/'.length)
@@ -249,6 +225,7 @@ export default function MobileSourceControlScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [screenState, setScreenState] = useState<ScreenState>({ kind: 'loading' })
const [branchCompareState, setBranchCompareState] = useState<MobileBranchCompareState>({
kind: 'idle'
@@ -258,6 +235,12 @@ export default function MobileSourceControlScreen() {
)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [commitMessage, setCommitMessage] = useState('')
const [generatingMessage, setGeneratingMessage] = useState(false)
const [showPrSheet, setShowPrSheet] = useState(false)
const [showBranchPicker, setShowBranchPicker] = useState(false)
const [localBranches, setLocalBranches] = useState<MobileGitLocalBranches | null>(null)
const [createdPrUrl, setCreatedPrUrl] = useState<string | null>(null)
const [prPrefill, setPrPrefill] = useState<MobilePrPrefill | null>(null)
const [discardTarget, setDiscardTarget] = useState<MobileGitStatusEntry | null>(null)
const [showActionSheet, setShowActionSheet] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
@@ -516,6 +499,7 @@ export default function MobileSourceControlScreen() {
branchCompareState.kind === 'error' ||
(branchCompareResult !== null && branchCompareResult.summary.status !== 'ready')
const hasVisibleChanges = sections.length > 0 || shouldShowBranchCompareSection
const reviewableCount = entries.length + (branchCompareCanOpen ? branchEntries.length : 0)
const stageablePaths = useMemo(() => getStageablePaths(entries), [entries])
const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries])
const stagedCount = useMemo(() => countStagedEntries(entries), [entries])
@@ -693,6 +677,109 @@ export default function MobileSourceControlScreen() {
)
}, [commitMessage, runGitWorkflow, sendCommitRequest])
// AI-generate a commit message from the staged diff. Matches desktop: the
// button is always available; a missing model surfaces as a toast.
const generateCommitMessage = useCallback(async () => {
if (!client || generatingMessage || busyActionRef.current) {
return
}
setGeneratingMessage(true)
setActionError(null)
try {
const result = await requestMobileCommitMessage(client, worktreeId)
if (!mountedRef.current) {
return
}
if (result.success) {
setCommitMessage(result.message)
triggerSuccess()
} else if (!result.canceled) {
triggerError()
setActionError(result.error)
}
} finally {
if (mountedRef.current) {
setGeneratingMessage(false)
}
}
}, [client, generatingMessage, worktreeId])
const cancelGenerateCommitMessage = useCallback(() => {
if (client) {
void cancelMobileCommitMessage(client, worktreeId)
}
}, [client, worktreeId])
const openPrSheet = useCallback(
async (pushFirst: boolean) => {
setShowActionSheet(false)
if (pushFirst) {
const pushed = await runGitWorkflow('push-create-pr', async () => {
await sendGitRequest<unknown>('git.push')
})
if (!pushed || !mountedRef.current) {
return
}
}
const up = status?.upstreamStatus
const prefill: MobilePrPrefill = client
? await resolveMobilePrPrefill(client, worktreeId, {
branch: status?.branch,
title: branchLabel,
hasUncommittedChanges: (status?.entries?.length ?? 0) > 0,
hasUpstream: up?.hasUpstream === true,
ahead: up?.ahead ?? 0,
behind: up?.behind ?? 0
})
: { provider: 'github', base: 'main', title: branchLabel, body: '' }
if (!mountedRef.current) {
return
}
setPrPrefill(prefill)
setShowPrSheet(true)
},
[branchLabel, client, runGitWorkflow, sendGitRequest, status, worktreeId]
)
const openBranchPicker = useCallback(() => {
setShowActionSheet(false)
setLocalBranches(null)
setShowBranchPicker(true)
if (client) {
void sendGitRequest<MobileGitLocalBranches>('git.localBranches')
.then((result) => {
if (mountedRef.current) {
setLocalBranches(result)
}
})
.catch(() => {
if (mountedRef.current) {
setLocalBranches({ current: null, branches: [] })
}
})
}
}, [client, sendGitRequest])
const openHistory = useCallback(() => {
setShowActionSheet(false)
if (hostId && worktreeId) {
router.push(
`/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters<
typeof router.push
>[0]
)
}
}, [hostId, router, worktreeId])
// Switch to a local branch, then reload status.
const checkoutBranch = useCallback(
async (branch: string) => {
setShowBranchPicker(false)
await runGitAction('checkout', 'git.checkout', { branch })
},
[runGitAction]
)
const runCommitFollowUps = useCallback(
async (actionId: string, afterCommit: () => Promise<void>) => {
const message = commitMessage.trim()
@@ -800,6 +887,33 @@ export default function MobileSourceControlScreen() {
setShowActionSheet(false)
}, [runGitSync])
const runActionSheetRebase = useCallback(async () => {
await runGitWorkflow('rebase', async () => {
if (!client) {
throw new Error('Waiting for desktop...')
}
const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId)
if (!baseRef) {
throw new Error('No base branch to rebase onto')
}
await sendGitRequest<unknown>('git.rebaseFromBase', { baseRef })
})
setShowActionSheet(false)
}, [client, runGitWorkflow, sendGitRequest, worktreeId])
// Abort an in-progress merge/rebase from the conflict banner.
const abortConflictOperation = useCallback(
async (operation: string) => {
const method =
operation === 'merge' ? 'git.abortMerge' : operation === 'rebase' ? 'git.abortRebase' : null
if (!method) {
return
}
await runGitAction(`abort-${operation}`, method, {})
},
[runGitAction]
)
const openFile = useCallback(
async (entry: MobileGitStatusEntry) => {
if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') {
@@ -944,144 +1058,58 @@ export default function MobileSourceControlScreen() {
[branchCompareState, client, connState, worktreeId]
)
const actionSheetActions = useMemo<ActionSheetAction[]>(() => {
const hasMessage = commitMessage.trim().length > 0
const hasStaged = stagedCount > 0
const hasUpstream = upstream?.hasUpstream === true
const ahead = upstream?.ahead ?? 0
const behind = upstream?.behind ?? 0
const busy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const commitHint = !hasStaged
? 'Stage at least one file'
: !hasMessage
? 'Enter a commit message'
: undefined
const remoteHint = !upstreamKnown
? 'Checking branch status...'
: hasUpstream
? undefined
: 'Publish Branch first'
const createPrHint = 'Pull requests are not available on mobile yet'
return [
{
label: 'Commit',
icon: Check,
disabled: busy || !!commitHint,
hint: commitHint,
loading: busyAction === 'commit',
skipAutoClose: true,
onPress: () => void runActionSheetCommit()
},
{
label: 'Commit & Push',
icon: ArrowUp,
disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream,
hint: commitHint ?? remoteHint,
loading: busyAction === 'commit-push',
skipAutoClose: true,
onPress: () => void runActionSheetCommitSequence('commit-push', [{ method: 'git.push' }])
},
{
label: 'Commit & Sync',
icon: ArrowDownUp,
disabled: busy || !!commitHint || !upstreamKnown || !hasUpstream || behind === 0,
hint:
commitHint ??
(!upstreamKnown || !hasUpstream
? remoteHint
: behind === 0
? 'Nothing to pull'
: undefined),
loading: busyAction === 'commit-sync',
skipAutoClose: true,
onPress: () => void runActionSheetCommitSync()
},
{
label: ahead > 0 ? `Push (${ahead})` : 'Push',
icon: ArrowUp,
disabled: busy || !upstreamKnown || !hasUpstream || ahead === 0,
hint: !hasUpstream ? remoteHint : ahead === 0 ? 'Nothing to push' : undefined,
loading: busyAction === 'push',
skipAutoClose: true,
onPress: () => void runActionSheetGitSequence('push', [{ method: 'git.push' }])
},
{
label: 'Create PR',
icon: GitPullRequest,
disabled: true,
hint: createPrHint,
onPress: () => {}
},
{
label: 'Push & Create PR',
icon: GitPullRequest,
disabled: true,
hint: createPrHint,
onPress: () => {}
},
{
label: behind > 0 ? `Pull (${behind})` : 'Pull',
icon: ArrowDown,
disabled: busy || !upstreamKnown || !hasUpstream || behind === 0,
hint: !hasUpstream ? remoteHint : behind === 0 ? 'Nothing to pull' : undefined,
loading: busyAction === 'pull',
skipAutoClose: true,
onPress: () => void runActionSheetGitSequence('pull', [{ method: 'git.pull' }])
},
{
label: ahead > 0 || behind > 0 ? `Sync (↓${behind} ↑${ahead})` : 'Sync',
icon: ArrowDownUp,
disabled: busy || !upstreamKnown || !hasUpstream || (ahead === 0 && behind === 0),
hint:
!upstreamKnown || !hasUpstream
? remoteHint
: ahead === 0 && behind === 0
? 'Branch is up to date'
: undefined,
loading: busyAction === 'sync',
skipAutoClose: true,
onPress: () => void runActionSheetGitSync()
},
{
label: 'Fetch',
icon: RefreshCw,
disabled: busy,
loading: busyAction === 'fetch',
skipAutoClose: true,
onPress: () => void runActionSheetGitSequence('fetch', [{ method: 'git.fetch' }])
},
{
label: 'Publish Branch',
icon: CloudUpload,
disabled: busy || !upstreamKnown || hasUpstream,
hint: !upstreamKnown
? 'Checking branch status...'
: hasUpstream
? 'Branch is already published'
: undefined,
loading: busyAction === 'publish',
skipAutoClose: true,
onPress: () =>
void runActionSheetGitSequence('publish', [
{ method: 'git.push', params: { publish: true } }
])
}
const actionSheetActions = useMemo<ActionSheetAction[]>(
() =>
buildMobileSourceControlActions({
commitMessage,
stagedCount,
upstream: upstream ?? null,
upstreamKnown,
busyAction,
openingPath,
openingBranchPath,
prAvailable: upstreamKnown && upstream?.hasUpstream === true,
handlers: {
commit: () => void runActionSheetCommit(),
commitPush: () =>
void runActionSheetCommitSequence('commit-push', [{ method: 'git.push' }]),
commitSync: () => void runActionSheetCommitSync(),
push: () => void runActionSheetGitSequence('push', [{ method: 'git.push' }]),
pull: () => void runActionSheetGitSequence('pull', [{ method: 'git.pull' }]),
sync: () => void runActionSheetGitSync(),
fetch: () => void runActionSheetGitSequence('fetch', [{ method: 'git.fetch' }]),
publish: () =>
void runActionSheetGitSequence('publish', [
{ method: 'git.push', params: { publish: true } }
]),
fastForward: () =>
void runActionSheetGitSequence('fast-forward', [{ method: 'git.fastForward' }]),
rebase: () => void runActionSheetRebase(),
createPr: () => void openPrSheet(false),
pushAndCreatePr: () => void openPrSheet(true),
checkout: () => void openBranchPicker(),
history: () => void openHistory()
}
}).map((action) => ({ ...action, icon: SOURCE_CONTROL_ACTION_ICONS[action.iconKey] })),
[
busyAction,
commitMessage,
openBranchPicker,
openHistory,
openingBranchPath,
openingPath,
openPrSheet,
runActionSheetCommit,
runActionSheetCommitSequence,
runActionSheetCommitSync,
runActionSheetGitSequence,
runActionSheetGitSync,
runActionSheetRebase,
stagedCount,
upstream,
upstreamKnown
]
}, [
busyAction,
commitMessage,
openingBranchPath,
openingPath,
runActionSheetCommit,
runActionSheetCommitSequence,
runActionSheetCommitSync,
runActionSheetGitSequence,
runActionSheetGitSync,
stagedCount,
upstream,
upstreamKnown
])
)
const renderItem = useCallback<
SectionListRenderItem<
@@ -1431,7 +1459,20 @@ export default function MobileSourceControlScreen() {
</Text>
<Text style={styles.stateText}>{screenState.message}</Text>
{screenState.kind === 'error' ? (
<Pressable style={styles.retryButton} onPress={() => void loadStatus()}>
<Pressable
style={styles.retryButton}
onPress={() => {
// Why: retrying the request is useless while the transport's
// reconnect loop is parked at its give-up cap — revive the
// connection instead (issue #5049). loadStatus re-runs via
// its connState effect once the new client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
void loadStatus()
}}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}
@@ -1455,7 +1496,23 @@ export default function MobileSourceControlScreen() {
<Text style={styles.countText}>{branchEntries.length} on branch</Text>
) : null}
{status && status.conflictOperation !== 'unknown' ? (
<Text style={styles.conflictText}>{status.conflictOperation}</Text>
<View style={styles.conflictRow}>
<Text style={styles.conflictText}>{status.conflictOperation}</Text>
{(status.conflictOperation === 'merge' ||
status.conflictOperation === 'rebase') && (
<Pressable
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
disabled={busyAction !== null}
onPress={() => void abortConflictOperation(status.conflictOperation)}
>
<Text style={styles.abortText}>
{busyAction === `abort-${status.conflictOperation}`
? 'Aborting…'
: `Abort ${status.conflictOperation}`}
</Text>
</Pressable>
)}
</View>
) : null}
</View>
{actionError ? (
@@ -1465,6 +1522,19 @@ export default function MobileSourceControlScreen() {
</Text>
</View>
) : null}
<MobileSourceControlReviewEntry
count={reviewableCount}
disabled={
screenState.kind !== 'ready' ||
connState !== 'connected' ||
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null
}
hostId={hostId}
worktreeId={worktreeId}
worktreeName={name}
/>
<View style={styles.bulkRow}>
<Pressable
style={({ pressed }) => [
@@ -1584,6 +1654,30 @@ export default function MobileSourceControlScreen() {
onSubmitEditing={() => void commit()}
/>
)}
<Pressable
style={({ pressed }) => [
styles.generateButton,
(stagedCount === 0 || busyAction !== null) && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
// Why: stay tappable while generating so the press can cancel
// (disabling it here made the cancel branch below unreachable).
disabled={stagedCount === 0 || busyAction !== null}
onPress={() =>
generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
}
accessibilityLabel={
generatingMessage
? 'Cancel commit message generation'
: 'Generate commit message with AI'
}
>
{generatingMessage ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={16} color={colors.textSecondary} strokeWidth={2.1} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [
styles.commitButton,
@@ -1643,6 +1737,52 @@ export default function MobileSourceControlScreen() {
}}
onCancel={() => setDiscardTarget(null)}
/>
<MobilePrComposeSheet
visible={showPrSheet}
client={client}
worktreeId={worktreeId ?? ''}
prefill={prPrefill ?? { provider: 'github', base: 'main', title: branchLabel, body: '' }}
onClose={() => setShowPrSheet(false)}
onCreated={(url) => {
setShowPrSheet(false)
setCreatedPrUrl(url)
void loadStatus({ preserveReadyOnFailure: true, force: true })
}}
/>
<PickerModal
visible={showBranchPicker}
title="Switch Branch"
options={(localBranches?.branches ?? []).map((b) => ({
value: b,
label: b,
subtitle: b === localBranches?.current ? 'current' : undefined
}))}
selected={localBranches?.current ?? ''}
onSelect={(branch) => {
if (branch !== localBranches?.current) {
void checkoutBranch(branch)
} else {
setShowBranchPicker(false)
}
}}
onClose={() => setShowBranchPicker(false)}
/>
<ConfirmModal
visible={createdPrUrl !== null}
title="Pull Request Created"
message="Open it in your browser?"
confirmLabel="Open"
onConfirm={() => {
if (createdPrUrl) {
openMobilePrUrl(createdPrUrl)
}
setCreatedPrUrl(null)
}}
onCancel={() => setCreatedPrUrl(null)}
/>
</View>
)
}
@@ -1743,11 +1883,32 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
conflictRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
conflictText: {
color: colors.statusAmber,
fontSize: typography.metaSize,
textTransform: 'capitalize'
},
abortButton: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.statusAmber
},
abortPressed: {
backgroundColor: colors.bgRaised
},
abortText: {
color: colors.statusAmber,
fontSize: typography.metaSize,
fontWeight: '600',
textTransform: 'capitalize'
},
actionError: {
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
@@ -1953,6 +2114,14 @@ const styles = StyleSheet.create({
justifyContent: 'center',
paddingHorizontal: spacing.md
},
generateButton: {
width: 42,
minHeight: 42,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
commitButtonDisabled: {
opacity: 0.45
},
+1
View File
@@ -17,6 +17,7 @@ export default function HostGroupLayout() {
name="[hostId]/source-control/[worktreeId]"
options={{ title: 'Source Control' }}
/>
<Stack.Screen name="[hostId]/review/[worktreeId]" options={{ title: 'Review Changes' }} />
</Stack>
)
}
+31 -19
View File
@@ -19,10 +19,14 @@ import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
getUsageBarState,
hasActiveProviderUsage,
hasRenderableUsage,
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import type { RpcClient } from '../src/transport/rpc-client'
import {
useAllHostClients,
@@ -73,6 +77,10 @@ type WorktreeSummary = {
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
// The worktree the desktop currently has focused (exactly one is true).
isActive?: boolean
// Last terminal-output time (ms); breaks ties when nothing is focused.
lastOutputAt?: number
}
type HostWorktreeInfo = {
@@ -179,7 +187,9 @@ function fetchWorktreeInfo(
}
client
.sendRequest('worktree.ps')
// Why: worktree.ps defaults to 200 and silently truncates; request the full
// set so the host worktree count and active count are accurate.
.sendRequest('worktree.ps', { limit: 10000 })
.then((response) => {
if (disposed()) {
return
@@ -190,7 +200,8 @@ function fetchWorktreeInfo(
setCachedWorktrees(hostId, worktrees)
const activeStatuses = new Set(['working', 'active', 'permission'])
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
const lastActive = active.length > 0 ? active[0] : (worktrees[0] ?? null)
// Mirror the desktop's focused workspace (see pickResumeWorktree).
const lastActive = pickResumeWorktree(worktrees)
setInfo((prev) => ({
...prev,
[hostId]: {
@@ -601,9 +612,10 @@ export default function HomeScreen() {
if (!snap) {
continue
}
const hasClaude = snap.claude.accounts.length > 0
const hasCodex = snap.codex.accounts.length > 0
if (hasClaude || hasCodex) {
// Why: also show hosts whose only usage is the system-default login
// (no Orca-managed accounts but live rate-limit data for the active
// target), otherwise system-default users see no usage section at all.
if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) {
items.push({ host, snapshot: snap })
}
}
@@ -971,16 +983,16 @@ export default function HomeScreen() {
provider === 'claude'
? snapshot.claude.accounts
: snapshot.codex.accounts
if (accounts.length === 0) {
const limits = getActiveProviderRateLimits(snapshot, provider)
// Why: with no managed accounts, still render a
// "System default" row when the active target has
// live usage data; the row label already falls back
// to "System default" below.
if (accounts.length === 0 && !hasActiveProviderUsage(limits)) {
return null
}
const limits = getActiveProviderRateLimits(snapshot, provider)
const isFetching =
limits?.status === 'fetching' || limits?.status === 'idle'
const unavailable =
limits == null ||
limits.status === 'unavailable' ||
limits.status === 'error'
const sessionBar = getUsageBarState(limits, 'session')
const weeklyBar = getUsageBarState(limits, 'weekly')
return (
<View key={provider} style={styles.accountsRow}>
<View style={styles.accountsIcon}>
@@ -997,15 +1009,15 @@ export default function HomeScreen() {
<View style={styles.accountsBars}>
<UsageBar
label="5h"
usedPercent={limits?.session?.usedPercent ?? null}
unavailable={unavailable}
loading={isFetching && limits?.session == null}
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
/>
<UsageBar
label="7d"
usedPercent={limits?.weekly?.usedPercent ?? null}
unavailable={unavailable}
loading={isFetching && limits?.weekly == null}
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
/>
</View>
</View>
+10
View File
@@ -9,6 +9,7 @@ import {
Wrench,
Shield,
LifeBuoy,
Mic,
Terminal as TerminalIcon
} from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
@@ -36,6 +37,15 @@ export default function SettingsScreen() {
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/voice-settings')}
>
<Mic size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Voice</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/notifications')}
+150 -233
View File
@@ -1,44 +1,52 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AppState,
View,
Text,
StyleSheet,
Pressable,
ScrollView,
Switch,
type AppStateStatus
} from 'react-native'
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native'
import {
CustomKeyModal,
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../src/components/CustomKeyModal'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useAnimatedRef,
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import {
TERMINAL_ACCESSORY_KEYS,
type TerminalAccessoryKey
} from '../src/terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryBuiltInIds,
loadTerminalAccessoryLayout,
resetTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from '../src/terminal/terminal-accessory-layout'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
import {
loadTerminalAutocompleteEnabled,
loadTerminalTextScale,
saveTerminalAutocompleteEnabled,
saveTerminalTextScale
} from '../src/storage/preferences'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest'
// scale = baseline zoom the terminal WebView applies on top of fit-to-width.
// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values.
const TEXT_SIZE_OPTIONS: (PickerOption<TextSizeValue> & { scale: number })[] = [
{ value: 'smallest', label: 'Smallest (50%)', scale: 0.5 },
{ value: 'smaller', label: 'Smaller (75%)', scale: 0.75 },
{ value: 'default', label: 'Default (100%)', scale: 1 },
{ value: 'large', label: 'Large (125%)', scale: 1.25 },
{ value: 'larger', label: 'Larger (150%)', scale: 1.5 },
{ value: 'largest', label: 'Largest (200%)', scale: 2 }
]
function textSizeValueFromScale(scale: number): TextSizeValue {
return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default'
}
function textSizeSummary(scale: number): string {
return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label
}
const AUTO_RESTORE_FIT_OPTIONS: (PickerOption<RestoreValue> & { ms: number | null })[] = [
{ value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
{ value: '60s', label: 'After 1 minute', ms: 60_000 },
@@ -111,33 +119,6 @@ function HostFitRow({
)
}
function ShortcutBarRow({
shortcutKey,
visible,
onToggle
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
}): React.JSX.Element {
return (
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
export default function TerminalSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
@@ -152,9 +133,6 @@ export default function TerminalSettingsScreen() {
[hostClients]
)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
// Why: per-host current value, lazily fetched. We keep state at the
// screen level rather than per-row so the picker can render at root
// level — embedding PickerModal inside a row clipped its BottomDrawer
@@ -162,81 +140,41 @@ export default function TerminalSettingsScreen() {
// drawer appear cut-off.
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback((nextIds: string[]) => {
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => saveTerminalAccessoryLayout(nextIds))
.catch(() => {})
.finally(() => {
pendingLayoutWritesRef.current -= 1
})
}, [])
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void loadTerminalAccessoryLayout().then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
return
}
setVisibleBuiltInIds(layout.visibleBuiltInIds)
})
}, [])
const refreshCustomKeys = useCallback(() => {
void loadCustomKeys().then(setCustomKeys)
}, [])
const handleDeleteCustomKey = useCallback(
async (key: CustomKey) => {
const updated = customKeys.filter((k) => k.id !== key.id)
setCustomKeys(updated)
await saveCustomKeys(updated)
},
[customKeys]
)
useFocusEffect(
useCallback(() => {
refreshShortcutLayout()
refreshCustomKeys()
}, [refreshShortcutLayout, refreshCustomKeys])
)
const [textScale, setTextScale] = useState(1)
const [textSizePickerOpen, setTextSizePickerOpen] = useState(false)
useEffect(() => {
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refreshShortcutLayout()
refreshCustomKeys()
void loadTerminalTextScale().then(setTextScale)
}, [])
const selectTextSize = useCallback((value: TextSizeValue) => {
const opt = TEXT_SIZE_OPTIONS.find((o) => o.value === value)
if (!opt) {
return
}
setTextScale(opt.scale)
void saveTerminalTextScale(opt.scale)
}, [])
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
// Why: a fast toggle before the initial load resolves must win — otherwise the
// delayed read would clobber the user's choice with the stored (stale) value.
const userToggledAutocompleteRef = useRef(false)
useEffect(() => {
let stale = false
void loadTerminalAutocompleteEnabled().then((enabled) => {
if (!stale && !userToggledAutocompleteRef.current) {
setAutocompleteEnabled(enabled)
}
})
return () => sub.remove()
}, [refreshShortcutLayout, refreshCustomKeys])
const toggleBuiltInKey = useCallback(
(id: string, visible: boolean) => {
setVisibleBuiltInIds((current) => {
const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
persistLayout(next)
return next
})
},
[persistLayout]
)
const resetBuiltInKeys = useCallback(() => {
const next = resetTerminalAccessoryBuiltInIds()
setVisibleBuiltInIds(next)
persistLayout(next)
}, [persistLayout])
return () => {
stale = true
}
}, [])
const toggleAutocomplete = useCallback((next: boolean) => {
userToggledAutocompleteRef.current = true
setAutocompleteEnabled(next)
void saveTerminalAutocompleteEnabled(next)
}, [])
useEffect(() => {
let cancelled = false
@@ -295,10 +233,28 @@ export default function TerminalSettingsScreen() {
}
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds])
const scrollRef = useAnimatedRef<Animated.ScrollView>()
const scrollOffsetY = useSharedValue(0)
const scrollContentHeight = useSharedValue(0)
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = event.contentOffset.y
})
// Why: imperative toggle instead of state — a re-render while a drag gesture
// is active would rebuild the row gestures and could cancel the drag.
const setScrollEnabled = useCallback(
(enabled: boolean) => {
scrollRef.current?.setNativeProps({ scrollEnabled: enabled })
},
[scrollRef]
)
const handleDragActiveChange = useCallback(
(active: boolean) => setScrollEnabled(!active),
[setScrollEnabled]
)
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<GestureHandlerRootView style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
@@ -306,7 +262,16 @@ export default function TerminalSettingsScreen() {
<Text style={styles.heading}>Terminal</Text>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Animated.ScrollView
ref={scrollRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
onContentSizeChange={(_width, height) => {
scrollContentHeight.value = height
}}
>
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
<Text style={styles.groupDescription}>
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your screen. When
@@ -340,76 +305,56 @@ export default function TerminalSettingsScreen() {
</View>
)}
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<Text style={[styles.groupHeading, styles.inputGroupGap]}>TEXT SIZE</Text>
<Text style={styles.groupDescription}>
Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes
show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal
itself, which updates this setting. Per-device display only; doesn&apos;t change the
desktop terminal.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => (
<View key={shortcutKey.id}>
{idx > 0 && <View style={styles.separator} />}
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
</View>
))}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
onPress={() => setTextSizePickerOpen(true)}
>
<Type size={16} color={colors.textSecondary} />
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>Show every built-in shortcut key</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
) : (
customKeys.map((key, idx) => (
<View key={key.id}>
{idx > 0 && <View style={styles.separator} />}
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
</View>
))
)}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
<Text style={styles.rowLabel}>Text size</Text>
<Text style={styles.rowSublabel}>{textSizeSummary(textScale)}</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</ScrollView>
<Text style={[styles.groupHeading, styles.inputGroupGap]}>KEYBOARD INPUT</Text>
<Text style={styles.groupDescription}>
Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal
command bar. Off by default so the keyboard never rewrites commands, flags, or paths.
Direct keyboard input (when keys go straight to the terminal) always sends raw keystrokes,
so suggestions don&apos;t apply there.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Autocomplete &amp; autocorrect</Text>
<Text style={styles.rowSublabel}>{autocompleteEnabled ? 'On' : 'Off'}</Text>
</View>
<Switch
value={autocompleteEnabled}
onValueChange={toggleAutocomplete}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
<TerminalShortcutSettings
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={handleDragActiveChange}
/>
</Animated.ScrollView>
<PickerModal<RestoreValue>
visible={pickerHost != null}
@@ -424,14 +369,15 @@ export default function TerminalSettingsScreen() {
onClose={() => setPickerHostId(null)}
/>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
setCustomKeys(keys)
}}
<PickerModal<TextSizeValue>
visible={textSizePickerOpen}
title="Terminal text size"
options={TEXT_SIZE_OPTIONS}
selected={textSizeValueFromScale(textScale)}
onSelect={selectTextSize}
onClose={() => setTextSizePickerOpen(false)}
/>
</View>
</GestureHandlerRootView>
)
}
@@ -472,9 +418,6 @@ const styles = StyleSheet.create({
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
@@ -489,6 +432,9 @@ const styles = StyleSheet.create({
sectionTopGap: {
marginTop: spacing.sm
},
inputGroupGap: {
marginTop: spacing.xl
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
@@ -517,38 +463,9 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
})
+392
View File
@@ -0,0 +1,392 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
import {
downloadDictationModel,
fetchDictationSetup,
isModelInFlight,
setDictationConfig,
type MobileSpeechModel,
type MobileSpeechSetup
} from '../src/dictation/mobile-dictation-setup'
const POLL_INTERVAL_MS = 1500
const DICTATION_MODES = [
{ value: 'toggle', label: 'Toggle' },
{ value: 'hold', label: 'Hold' }
] as const
export default function VoiceSettingsScreen(): React.JSX.Element {
const router = useRouter()
const insets = useSafeAreaInsets()
const [hosts, setHosts] = useState<HostProfile[]>([])
useEffect(() => {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const hostClients = useAllHostClients(hostIds)
// Voice dictation runs on the paired desktop, so pick the first connected host.
const client: RpcClient | null = useMemo(
() => hostClients.find((entry) => entry.state === 'connected')?.client ?? null,
[hostClients]
)
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [busyModelId, setBusyModelId] = useState<string | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const refresh = useCallback(async () => {
if (!client) {
return
}
try {
setSetup(await fetchDictationSetup(client))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load voice settings')
}
}, [client])
// Initial load once a connected client is available.
useEffect(() => {
if (!client) {
return
}
setLoading(true)
setError(null)
void refresh().finally(() => setLoading(false))
}, [client, refresh])
// Poll only while a model is downloading/extracting; stop otherwise.
useEffect(() => {
const inFlight = setup?.models.some(isModelInFlight) ?? false
if (inFlight && client) {
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
}
return undefined
}, [setup, client, refresh])
const handleToggleEnabled = useCallback(
async (enabled: boolean) => {
if (!client) {
return
}
setError(null)
// Optimistic flip so the switch responds instantly; reconcile below.
setSetup((prev) => (prev ? { ...prev, enabled } : prev))
try {
setSetup(await setDictationConfig(client, { enabled }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
void refresh()
}
},
[client, refresh]
)
const handleSelectMode = useCallback(
async (dictationMode: 'toggle' | 'hold') => {
if (!client) {
return
}
setError(null)
setSetup((prev) => (prev ? { ...prev, dictationMode } : prev))
try {
setSetup(await setDictationConfig(client, { dictationMode }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
void refresh()
}
},
[client, refresh]
)
const handleUseModel = useCallback(
async (model: MobileSpeechModel) => {
if (!client) {
return
}
setBusyModelId(model.id)
setError(null)
try {
setSetup(await setDictationConfig(client, { enabled: true, modelId: model.id }))
setModelDrawerOpen(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not select model')
} finally {
setBusyModelId(null)
}
},
[client]
)
const handleDownload = useCallback(
async (model: MobileSpeechModel) => {
if (!client) {
return
}
setBusyModelId(model.id)
setError(null)
try {
await downloadDictationModel(client, model.id)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Download failed')
} finally {
setBusyModelId(null)
}
},
[client, refresh]
)
const enabled = setup?.enabled ?? false
const selectedModel = setup?.models.find((m) => m.id === setup.selectedModelId)
const selectedModelLabel = selectedModel?.label ?? 'None selected'
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Voice</Text>
</View>
{!client ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.emptyText}>Connect to a desktop to manage voice settings.</Text>
</View>
) : loading && setup === null ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : setup === null ? (
<View style={[styles.section, styles.sectionTopGap]}>
<Text style={styles.errorText}>{error ?? 'Failed to load voice settings.'}</Text>
</View>
) : (
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Text style={styles.groupHeading}>DICTATION</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Enable Voice Dictation</Text>
<Text style={styles.rowSublabel}>
Dictate text into any focused pane on your desktop.
</Text>
</View>
<Switch
value={enabled}
onValueChange={(v) => void handleToggleEnabled(v)}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<View style={styles.separator} />
<View
style={[styles.row, !enabled && styles.disabled]}
pointerEvents={enabled ? 'auto' : 'none'}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Dictation Mode</Text>
<Text style={styles.rowSublabel}>
Toggle: press once to start, again to stop. Hold: dictate while held.
</Text>
</View>
<View style={styles.segmented}>
{DICTATION_MODES.map((mode) => {
const active = setup.dictationMode === mode.value
return (
<Pressable
key={mode.value}
onPress={() => void handleSelectMode(mode.value)}
style={[styles.segment, active && styles.segmentActive]}
>
<Text style={[styles.segmentText, active && styles.segmentTextActive]}>
{mode.label}
</Text>
</Pressable>
)
})}
</View>
</View>
</View>
<Text style={[styles.groupHeading, styles.inputGroupGap]}>SPEECH MODEL</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<Pressable
style={({ pressed }) => [
styles.row,
!enabled && styles.disabled,
pressed && styles.rowPressed
]}
disabled={!enabled}
onPress={() => setModelDrawerOpen(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Speech Model</Text>
<Text style={styles.rowSublabel} numberOfLines={1}>
{selectedModelLabel}
</Text>
</View>
<ChevronRight size={18} color={colors.textMuted} />
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
)}
<BottomDrawer visible={modelDrawerOpen} onClose={() => setModelDrawerOpen(false)}>
<Text style={styles.drawerTitle}>Speech Model</Text>
{setup ? (
<VoiceModelList
setup={setup}
disabled={false}
busyModelId={busyModelId}
onUseModel={(m) => void handleUseModel(m)}
onDownload={(m) => void handleDownload(m)}
/>
) : null}
</BottomDrawer>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
paddingHorizontal: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.sm,
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
scrollContent: {
paddingBottom: spacing.xl
},
loading: { paddingVertical: spacing.xl, alignItems: 'center' },
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: { marginTop: spacing.sm },
inputGroupGap: { marginTop: spacing.xl },
disabled: { opacity: 0.5 },
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
errorText: {
fontSize: typography.bodySize,
color: colors.statusRed,
padding: spacing.md
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: { backgroundColor: colors.bgRaised },
rowContent: { flex: 1 },
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
drawerTitle: {
fontSize: typography.bodySize,
fontWeight: '700',
color: colors.textPrimary,
paddingHorizontal: spacing.md + 2,
paddingTop: spacing.sm,
paddingBottom: spacing.xs
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
segmented: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bgBase,
borderRadius: radii.button,
padding: 2
},
segment: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radii.button - 1
},
segmentActive: { backgroundColor: colors.bgRaised },
segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' },
segmentTextActive: { color: colors.textPrimary },
error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md }
})
+2
View File
@@ -0,0 +1,2 @@
app_identifier(ENV["IOS_BUNDLE_IDENTIFIER"] || "com.stably.orca.mobile")
team_id(ENV["APPLE_TEAM_ID"])
+81
View File
@@ -0,0 +1,81 @@
# Orca Mobile iOS release lane.
#
# Builds the prebuilt iOS workspace, signs it with the distribution identity
# imported into the CI keychain plus an explicit App Store provisioning profile
# fetched via the App Store Connect API key, then uploads the .ipa to
# TestFlight. All Apple credentials come from CI env vars
# (see .github/workflows/mobile-ios-release.yml) so nothing secret lives in the
# repo.
#
# Why manual signing (not -allowProvisioningUpdates / automatic cloud signing):
# mixing a pre-imported distribution .p12 with xcodebuild's cloud-managed
# automatic signing produced "Cloud signing permission error / No profiles
# found" at exportArchive (cloud signing also needs an Admin-role API key).
# Instead we fetch an explicit profile with the API key (sigh) and sign
# manually against the imported cert — works with any team API key.
require "base64"
default_platform(:ios)
WORKSPACE = "ios/Orca.xcworkspace"
SCHEME = "Orca"
BUNDLE_ID = "com.stably.orca.mobile"
platform :ios do
desc "Build, sign, and upload Orca Mobile to TestFlight"
lane :release do
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_content: ENV.fetch("ASC_API_KEY_P8"),
is_key_content_base64: true,
in_house: false,
)
team_id = ENV.fetch("APPLE_TEAM_ID")
# Fetch (or create) the App Store distribution profile via the API key and
# install it locally, then feed its name to the manual archive + export.
get_provisioning_profile(
api_key: api_key,
app_identifier: BUNDLE_ID,
force: true,
)
# sigh exposes the chosen profile's name in SIGH_NAME (SIGH_PROFILE_MAPPING
# doesn't exist in this fastlane version).
profile_name = lane_context[SharedValues::SIGH_NAME]
# Manual signing: the archive needs the team, profile, and signing style set
# explicitly (no -allowProvisioningUpdates). Without DEVELOPMENT_TEAM the
# archive fails: "Signing for Orca requires a development team".
build_app(
workspace: WORKSPACE,
scheme: SCHEME,
configuration: "Release",
export_method: "app-store",
xcargs: "DEVELOPMENT_TEAM=#{team_id} " \
"CODE_SIGN_STYLE=Manual " \
"CODE_SIGN_IDENTITY='Apple Distribution' " \
"PROVISIONING_PROFILE_SPECIFIER='#{profile_name}'",
export_options: {
teamID: team_id,
signingStyle: "manual",
provisioningProfiles: {
BUNDLE_ID => profile_name,
},
},
output_directory: "build",
output_name: "Orca.ipa",
clean: true,
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true,
# Why: the human still drafts "What's New" + review notes in the ASC web
# UI (see the mobile-app-store-release skill). CI only delivers the build.
distribute_external: false,
)
end
end
@@ -0,0 +1,92 @@
# Issue #5049: Android Remote Session Unresponsiveness — Findings
Date: 2026-06-09
Issue: https://github.com/stablyai/orca/issues/5049
## Reported symptoms
Android + Tailscale remote session intermittently becomes unresponsive: tab/worktree
taps do nothing, pasted text doesn't execute, the connection "appears stuck instead
of clearly disconnected", and closing/reopening the app restores the session.
## Root causes found (mobile-side)
All three independently produce the exact reported symptom — a session that looks
alive but ignores input, recoverable only by an app restart:
1. **Parked reconnect loop with no recovery path (primary).** `rpc-client.ts`
stops retrying permanently after `GIVE_UP_AFTER_ATTEMPTS` (12 attempts ≈ 6.5 min
of backoff). Android backgrounding + Doze + a Tailscale tunnel drop routinely
burns through all 12 attempts while the user is away. Nothing ever restarted the
loop: there was **no AppState listener anywhere in the transport layer**, so
returning to the foreground did not nudge the client. The state stays
`'reconnecting'` forever ("appears stuck instead of clearly disconnected").
Reopening the app creates a fresh client with a fresh attempt budget — which is
exactly why "closing and reopening usually restores the session".
2. **Half-open socket detection waits up to ~28s, and never starts earlier on
resume.** Android can kill the TCP path while backgrounded without delivering
`onclose`; `readyState` still reads OPEN, so every `terminal.send` (e.g. paste)
silently blackholes. The activity probe (20s interval + 8s timeout) eventually
reaps the link, but the first ~28s after resume look like "pasted text does not
run immediately" / "switching is very slow".
3. **Stale client after `forceReconnect` (pre-existing `useHostClient` bug).**
`forceReconnect` swaps in a fresh `RpcClient`, but `useHostClient` only re-read
the client when its ref was still `null`. Any mounted screen kept driving the
old, **closed** client forever: the status header (fed by provider-level state
listeners) shows "Connected" while every RPC instantly fails with "Client
closed" — a session that looks alive but ignores all input.
Additionally, the session screen (where users actually live) had no recovery
affordance at all: just a status label, while the Retry buttons exist only on
the home/host/tasks screens.
## Fixes
- `src/transport/rpc-client.ts` — new `notifyForeground()`:
- state `connected` → restart the probe interval and run one probe immediately
(half-open link reaped in ≤8s instead of ≤28s);
- state `reconnecting` → clear any pending backoff timer, reset the attempt
budget, reconnect immediately (un-parks the give-up cap).
- (Also extracted the duplicated close/error event serialization into
`socket-event-debug.ts` to stay under the file's line cap.)
- `src/transport/client-context.tsx`:
- `RpcClientProvider` now listens to AppState and calls `notifyForeground()` on
every live client when the app becomes active.
- `useHostClient` re-reads the underlying client on every state change, so
screens pick up the fresh client after `forceReconnect` instead of driving a
closed one.
- `app/h/[hostId]/session/[worktreeId].tsx` — the status row in the session header
becomes tappable once `classifyConnection` escalates to warning/unreachable,
showing "<label> — tap to retry" and invoking `forceReconnect`.
## Repro harnesses
- `src/transport/rpc-client.test.ts` → `foreground recovery` describe block:
deterministic fake-timer repro of the parked loop (proves it never self-recovers)
plus regression coverage for all `notifyForeground()` paths.
- `src/transport/rpc-client-live-recovery.test.ts`: opt-in live harness running the
REAL rpc-client (real sockets, real tweetnacl E2EE, real timers) against an
in-process ws server with a blackhole toggle:
- `ORCA_MOBILE_LIVE_REPRO=1 pnpm vitest run src/transport/rpc-client-live-recovery.test.ts`
— half-open-link scenario (~15s).
- `ORCA_MOBILE_LIVE_REPRO_FULL=1 …` — full parked-loop scenario (~8.5 min): waits
out all 12 backoff attempts, proves the loop stays parked even after the server
returns, then proves `notifyForeground()` recovers it.
## Not addressed (out of scope, noted for future work)
- The diagnostics in `rpc-client.ts` mention a suspected RN/OkHttp process-state
poisoning mode (every open instantly fails with 1006 until force-quit). If that
mode is real, a foreground nudge reconnect attempt would also fail; the existing
`[net]` logs (wsCount / msSinceLast\*) are designed to confirm or rule it out from
device logs.
## Follow-up audit (same PR)
- `connection-revival-triggers.ts` (via `expo-network`) extends the foreground
nudge to network restoration and Wi-Fi → cellular handoffs.
- Files and source-control screens' Retry buttons now revive the transport
(`forceReconnect`) when disconnected instead of pointlessly re-sending the
request into a parked connection.
+4
View File
@@ -24,9 +24,12 @@
"expo-constants": "^55.0.16",
"expo-crypto": "^55.0.14",
"expo-dev-client": "~55.0.35",
"expo-document-picker": "^55.0.13",
"expo-haptics": "^55.0.14",
"expo-image-picker": "^55.0.20",
"expo-linking": "^55.0.15",
"expo-modules-core": "~55.0.25",
"expo-network": "~55.0.14",
"expo-notifications": "^55.0.22",
"expo-router": "^55.0.14",
"expo-secure-store": "^55.0.13",
@@ -35,6 +38,7 @@
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"react": "^19.2.6",
"react-dom": "19.2.6",
"react-native": "^0.83.9",
"react-native-gesture-handler": "^2.31.2",
"react-native-reanimated": "^4.3.0",
@@ -0,0 +1,56 @@
import { PermissionStatus, type PermissionResponse } from 'expo-modules-core'
type EventSubscription = {
remove: () => void
}
type ExpoTwoWayAudioWebModule = {
initialize: () => Promise<boolean>
playPCMData: (audioData: Uint8Array) => void
bypassVoiceProcessing: (bypass: boolean) => void
toggleRecording: (val: boolean) => boolean
isRecording: () => boolean
tearDown: () => void
restart: () => void
getMicrophonePermissionsAsync: () => Promise<PermissionResponse>
requestMicrophonePermissionsAsync: () => Promise<PermissionResponse>
getMicrophoneModeIOS: () => null
setMicrophoneModeIOS: () => void
isPlaying: () => boolean
stopPlayback: () => void
pausePlayback: () => void
resumePlayback: () => void
addListener: (eventName: string, handler: (ev: unknown) => void) => EventSubscription
}
const deniedMicrophonePermission: PermissionResponse = {
status: PermissionStatus.DENIED,
expires: 'never',
granted: false,
canAskAgain: false
}
const noop = () => undefined
const ExpoTwoWayAudioModule: ExpoTwoWayAudioWebModule = {
// Why: the mobile app can be run on web for QA, but dictation depends on
// native audio engines that are only available in the iOS/Android builds.
initialize: async () => false,
playPCMData: noop,
bypassVoiceProcessing: noop,
toggleRecording: () => false,
isRecording: () => false,
tearDown: noop,
restart: noop,
getMicrophonePermissionsAsync: async () => deniedMicrophonePermission,
requestMicrophonePermissionsAsync: async () => deniedMicrophonePermission,
getMicrophoneModeIOS: () => null,
setMicrophoneModeIOS: noop,
isPlaying: () => false,
stopPlayback: noop,
pausePlayback: noop,
resumePlayback: noop,
addListener: () => ({ remove: noop })
}
export default ExpoTwoWayAudioModule
@@ -0,0 +1,16 @@
const { withAndroidManifest, AndroidConfig } = require('expo/config-plugins')
// Why: Expo's top-level `orientation` only emits portrait/landscape/unspecified.
// "unspecified" still auto-rotates on many Android devices even when the system
// rotation lock is on. "fullUser" honors the user's auto-rotate setting (no
// rotation when locked) while still allowing every orientation when unlocked —
// matching the iOS UISupportedInterfaceOrientations behavior. iOS is untouched.
const ANDROID_SCREEN_ORIENTATION = 'fullUser'
module.exports = function withAndroidRespectRotationLock(config) {
return withAndroidManifest(config, (cfg) => {
const activity = AndroidConfig.Manifest.getMainActivityOrThrow(cfg.modResults)
activity.$['android:screenOrientation'] = ANDROID_SCREEN_ORIENTATION
return cfg
})
}
+154 -119
View File
@@ -19,13 +19,13 @@ importers:
version: 6.0.3
expo:
specifier: ^55.0.23
version: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
version: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-build-properties:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)
expo-camera:
specifier: ^55.0.18
version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-clipboard:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -38,21 +38,30 @@ importers:
expo-dev-client:
specifier: ~55.0.35
version: 55.0.35(expo@55.0.23)
expo-document-picker:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)
expo-haptics:
specifier: ^55.0.14
version: 55.0.14(expo@55.0.23)
expo-image-picker:
specifier: ^55.0.20
version: 55.0.20(expo@55.0.23)
expo-linking:
specifier: ^55.0.15
version: 55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-modules-core:
specifier: ~55.0.25
version: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-network:
specifier: ~55.0.14
version: 55.0.14(expo@55.0.23)(react@19.2.6)
expo-notifications:
specifier: ^55.0.22
version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-router:
specifier: ^55.0.14
version: 55.0.14(6866c40ee94ad332d902aab6505cfa05)
version: 55.0.14(f081b44356743acd3a23f42461bb8a57)
expo-secure-store:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)
@@ -71,6 +80,9 @@ importers:
react:
specifier: ^19.2.6
version: 19.2.6
react-dom:
specifier: 19.2.6
version: 19.2.6(react@19.2.6)
react-native:
specifier: ^0.83.9
version: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -91,7 +103,7 @@ importers:
version: 15.15.4(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-web:
specifier: ^0.21.2
version: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
version: 0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-native-webview:
specifier: ^13.16.1
version: 13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -1424,56 +1436,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -1546,56 +1550,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.67.0':
resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.67.0':
resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.67.0':
resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.67.0':
resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.67.0':
resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.67.0':
resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.67.0':
resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.67.0':
resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==}
@@ -3347,6 +3343,11 @@ packages:
peerDependencies:
expo: '*'
expo-document-picker@55.0.13:
resolution: {integrity: sha512-IhswJElhdzs3fKDEKW8KXYRoFkWGEsXRMYAZT46Yo56zqqy8yQXrczo33RSwD2hFzNQBdLT97SJL9N311UyS3g==}
peerDependencies:
expo: '*'
expo-file-system@55.0.19:
resolution: {integrity: sha512-c4smCbMqELLI3YQrGpw21MwZIREXM2e53vQD/+KWQcae1q+hgw8J2TroEqcQ/jVOtFpZYVvyVfgu4HDKNEKmNw==}
peerDependencies:
@@ -3372,6 +3373,16 @@ packages:
peerDependencies:
expo: '*'
expo-image-loader@55.0.1:
resolution: {integrity: sha512-o8gCo1j59XpXDh0/llgNYPcnfecYQhafQAO0yw5pb+kukPizvNoEqea8tFQIIQmNYqxd6Ljgs7lLXed0gXpOdQ==}
peerDependencies:
expo: '*'
expo-image-picker@55.0.20:
resolution: {integrity: sha512-lfWt/0rPWdKz8AdDEGmGHZIJSNlVc720Dlx5bfou10FU16ZV5wAbTU63nm2jkXd8hbXke4a/2Ha1dzxCVA+LQQ==}
peerDependencies:
expo: '*'
expo-image@55.0.10:
resolution: {integrity: sha512-We+vq/Z8jy8zmGxcOP8vrhiWkkwyXFdSks8cSlPi0bpu6D0Ei6l9Nj2xHWCD+yoENh92aCEe1+QRujAwXbogGA==}
peerDependencies:
@@ -3421,6 +3432,12 @@ packages:
react-native-worklets:
optional: true
expo-network@55.0.14:
resolution: {integrity: sha512-Sy544zTPjVh+tbOLUOU8fBX87oRSrNQqUZY6TLO0w0WF/QTNb7yxlwRh6v6wfKKRg9xpZypTIIEtdG/s6q8ZQA==}
peerDependencies:
expo: '*'
react: '*'
expo-notifications@55.0.22:
resolution: {integrity: sha512-Rwvsp/lAEXfDYBxkQZpaLF9ZB25cJ/yfHhD/ESclbPesN0nbQBZ/5rGb1xS/saANtkStbEGfDlA80uHh2zEpsA==}
peerDependencies:
@@ -4975,10 +4992,10 @@ packages:
react-devtools-core@6.1.5:
resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==}
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
react-dom@19.2.6:
resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
peerDependencies:
react: ^19.2.5
react: ^19.2.6
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
@@ -7099,7 +7116,7 @@ snapshots:
'@expo-google-fonts/material-symbols@0.4.34': {}
'@expo/cli@55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)':
'@expo/cli@55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)':
dependencies:
'@expo/code-signing-certificates': 0.0.6
'@expo/config': 55.0.16(typescript@5.9.3)
@@ -7116,7 +7133,7 @@ snapshots:
'@expo/plist': 0.5.3
'@expo/prebuild-config': 55.0.17(expo@55.0.23)(typescript@5.9.3)
'@expo/require-utils': 55.0.5(typescript@5.9.3)
'@expo/router-server': 55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@expo/router-server': 55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@expo/schema-utils': 55.0.4
'@expo/spawn-async': 1.7.2
'@expo/ws-tunnel': 1.0.6
@@ -7133,7 +7150,7 @@ snapshots:
connect: 3.7.0
debug: 4.4.3
dnssd-advertise: 1.1.4
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-server: 55.0.9
fetch-nodeshim: 0.4.10
getenv: 2.0.0
@@ -7160,7 +7177,7 @@ snapshots:
ws: 8.20.1
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.14(6866c40ee94ad332d902aab6505cfa05)
expo-router: 55.0.14(f081b44356743acd3a23f42461bb8a57)
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -7231,7 +7248,7 @@ snapshots:
'@expo/dom-webview@55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -7289,7 +7306,7 @@ snapshots:
dependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
anser: 1.4.10
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
stacktrace-parser: 0.1.11
@@ -7298,7 +7315,7 @@ snapshots:
dependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
anser: 1.4.10
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
stacktrace-parser: 0.1.11
@@ -7325,25 +7342,25 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
optionalDependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
transitivePeerDependencies:
- bufferutil
- supports-color
- typescript
- utf-8-validate
'@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
'@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
'@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
anser: 1.4.10
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
pretty-format: 29.7.0
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
stacktrace-parser: 0.1.11
whatwg-fetch: 3.6.20
optionalDependencies:
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -7400,7 +7417,7 @@ snapshots:
'@expo/json-file': 10.0.14
'@react-native/normalize-colors': 0.83.6
debug: 4.4.3
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
resolve-from: 5.0.0
semver: 7.7.4
xml2js: 0.6.0
@@ -7418,18 +7435,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@expo/router-server@55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@expo/router-server@55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
debug: 4.4.3
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-server: 55.0.9
react: 19.2.6
optionalDependencies:
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-router: 55.0.14(6866c40ee94ad332d902aab6505cfa05)
react-dom: 19.2.5(react@19.2.6)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-router: 55.0.14(f081b44356743acd3a23f42461bb8a57)
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- supports-color
@@ -7695,7 +7712,7 @@ snapshots:
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -7819,14 +7836,14 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7842,23 +7859,23 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-dialog@1.1.15(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-portal': 1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-portal': 1.1.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
aria-hidden: 1.2.6
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7869,15 +7886,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7887,13 +7904,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7904,45 +7921,45 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-portal@1.1.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-presence@1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-presence@1.1.5(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-primitive@2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-roving-focus@1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-roving-focus@1.1.11(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-collection': 1.1.7(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -7960,18 +7977,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-tabs@1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)':
'@radix-ui/react-tabs@1.1.13(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-roving-focus': 1.1.11(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-presence': 1.1.5(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-roving-focus': 1.1.11(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
@@ -8964,7 +8981,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.29.2
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -9839,12 +9856,12 @@ snapshots:
expo-application@55.0.14(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-asset@55.0.17(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.14(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -9855,42 +9872,42 @@ snapshots:
expo-build-properties@55.0.13(expo@55.0.23):
dependencies:
'@expo/schema-utils': 55.0.4
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
resolve-from: 5.0.0
semver: 7.7.4
expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
barcode-detector: 3.1.3(@types/emscripten@1.41.5)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
optionalDependencies:
react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
react-native-web: 0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
transitivePeerDependencies:
- '@types/emscripten'
expo-clipboard@55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-constants@55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)):
dependencies:
'@expo/env': 2.1.2
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
transitivePeerDependencies:
- supports-color
expo-crypto@55.0.14(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-client@55.0.35(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-launcher: 55.0.36(expo@55.0.23)
expo-dev-menu: 55.0.30(expo@55.0.23)
expo-dev-menu-interface: 55.0.2(expo@55.0.23)
@@ -9900,55 +9917,68 @@ snapshots:
expo-dev-launcher@55.0.36(expo@55.0.23):
dependencies:
'@expo/schema-utils': 55.0.4
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-menu: 55.0.30(expo@55.0.23)
expo-manifests: 55.0.17(expo@55.0.23)
expo-dev-menu-interface@55.0.2(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-menu@55.0.30(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-dev-menu-interface: 55.0.2(expo@55.0.23)
expo-document-picker@55.0.13(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-file-system@55.0.19(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-font@55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
fontfaceobserver: 2.3.0
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-glass-effect@55.0.11(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-haptics@55.0.14(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-image@55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
expo-image-loader@55.0.1(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-image-picker@55.0.20(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-image-loader: 55.0.1(expo@55.0.23)
expo-image@55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
sf-symbols-typescript: 2.2.0
optionalDependencies:
react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
react-native-web: 0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
expo-json-utils@55.0.2: {}
expo-keep-awake@55.0.8(expo@55.0.23)(react@19.2.6):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
expo-linking@55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
@@ -9963,7 +9993,7 @@ snapshots:
expo-manifests@55.0.17(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.7)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.8.0))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6):
@@ -10030,12 +10060,17 @@ snapshots:
optionalDependencies:
react-native-worklets: 0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-network@55.0.14(expo@55.0.23)(react@19.2.6):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
expo-notifications@55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.14(typescript@5.9.3)
abort-controller: 3.0.0
badgin: 1.2.3
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-application: 55.0.14(expo@55.0.23)
expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
react: 19.2.6
@@ -10044,23 +10079,23 @@ snapshots:
- supports-color
- typescript
expo-router@55.0.14(6866c40ee94ad332d902aab6505cfa05):
expo-router@55.0.14(f081b44356743acd3a23f42461bb8a57):
dependencies:
'@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/schema-utils': 55.0.4
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-navigation/bottom-tabs': 7.15.11(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@react-navigation/native': 7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@react-navigation/native-stack': 7.14.12(@react-navigation/native@7.2.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-safe-area-context@5.7.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-screens@4.24.0(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
client-only: 0.0.1
debug: 4.4.3
escape-string-regexp: 4.0.0
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
expo-glass-effect: 55.0.11(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-image: 55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-image: 55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-linking: 55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-server: 55.0.9
expo-symbols: 55.0.8(expo-font@55.0.7)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -10079,13 +10114,13 @@ snapshots:
sf-symbols-typescript: 2.2.0
shallowequal: 1.1.0
use-latest-callback: 0.2.6(react@19.2.6)
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
optionalDependencies:
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.8.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-web: 0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
react-native-web: 0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
@@ -10095,14 +10130,14 @@ snapshots:
expo-secure-store@55.0.13(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-server@55.0.9: {}
expo-splash-screen@55.0.20(expo@55.0.23)(typescript@5.9.3):
dependencies:
'@expo/prebuild-config': 55.0.17(expo@55.0.23)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
- typescript
@@ -10116,7 +10151,7 @@ snapshots:
expo-symbols@55.0.8(expo-font@55.0.7)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
'@expo-google-fonts/material-symbols': 0.4.34
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
@@ -10124,12 +10159,12 @@ snapshots:
expo-updates-interface@55.1.6(expo@55.0.23):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo@55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
expo@55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
'@expo/cli': 55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
'@expo/cli': 55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
'@expo/config': 55.0.16(typescript@5.9.3)
'@expo/config-plugins': 55.0.8
'@expo/devtools': 55.0.3(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -10155,7 +10190,7 @@ snapshots:
whatwg-url-minimum: 0.1.1
optionalDependencies:
'@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.5(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
react-native-webview: 13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
@@ -10839,7 +10874,7 @@ snapshots:
'@jest/create-cache-key-function': 29.7.0
'@jest/globals': 29.7.0
babel-jest: 29.7.0(@babel/core@7.29.0)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
@@ -12070,7 +12105,7 @@ snapshots:
- bufferutil
- utf-8-validate
react-dom@19.2.5(react@19.2.6):
react-dom@19.2.6(react@19.2.6):
dependencies:
react: 19.2.6
scheduler: 0.27.0
@@ -12129,7 +12164,7 @@ snapshots:
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
warn-once: 0.1.1
react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6):
react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
'@react-native/normalize-colors': 0.74.89
@@ -12139,7 +12174,7 @@ snapshots:
nullthrows: 1.1.1
postcss-value-parser: 4.2.0
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
styleq: 0.1.3
transitivePeerDependencies:
- encoding
@@ -12955,11 +12990,11 @@ snapshots:
vary@1.1.2: {}
vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6):
vaul@1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dialog': 1.1.15(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.5(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
+19 -70
View File
@@ -1,76 +1,25 @@
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
// Why: keep these shapes in lockstep with src/shared/types.ts and
// src/shared/rate-limit-types.ts. We don't import from desktop here because
// the mobile bundle must not pull in Electron-coupled type files.
export type RateLimitWindow = {
usedPercent: number
windowMinutes: number
resetsAt: number | null
resetDescription: string | null
}
export type ProviderRateLimits = {
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go'
session: RateLimitWindow | null
weekly: RateLimitWindow | null
monthly?: RateLimitWindow | null
updatedAt: number
error: string | null
status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
}
export type InactiveAccountUsage = {
accountId: string
claude: ProviderRateLimits | null
updatedAt: number
isFetching: boolean
}
export type ClaudeAccountSummary = {
id: string
email: string
organizationName?: string | null
}
export type CodexAccountSummary = {
id: string
email: string
workspaceLabel?: string | null
}
export type AccountsSnapshot = {
claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
rateLimits: {
claude: ProviderRateLimits | null
codex: ProviderRateLimits | null
inactiveClaudeAccounts: InactiveAccountUsage[]
inactiveCodexAccounts: InactiveAccountUsage[]
}
}
export type ProviderKey = 'claude' | 'codex'
export function getActiveProviderRateLimits(
snapshot: AccountsSnapshot,
provider: ProviderKey
): ProviderRateLimits | null {
return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
}
export function getInactiveProviderUsage(
snapshot: AccountsSnapshot,
provider: ProviderKey,
accountId: string
): InactiveAccountUsage | null {
const list =
provider === 'claude'
? snapshot.rateLimits.inactiveClaudeAccounts
: snapshot.rateLimits.inactiveCodexAccounts
return list.find((u) => u.accountId === accountId) ?? null
}
// Pure types and selectors live in account-usage-state.ts (no RN imports) so
// they are unit-testable; re-exported here so existing import sites are stable.
export type {
RateLimitWindow,
ProviderRateLimits,
InactiveAccountUsage,
ClaudeAccountSummary,
CodexAccountSummary,
AccountsSnapshot,
ProviderKey,
UsageBarState
} from './account-usage-state'
export {
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
hasActiveProviderUsage,
hasRenderableUsage
} from './account-usage-state'
// Why: matches desktop StatusBar convention — bars show percent remaining
// (so a fresh account renders full, a depleted one renders empty), not
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useRef } from 'react'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentDotState } from '../worktree/agent-row-display'
// Per-agent state indicator, 1:1 with desktop AgentStateDot
// (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working',
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
// agent vocabulary into the 5-state rollup the sidebar dot uses.
const DOT_COLORS: Record<Exclude<AgentDotState, 'working'>, string> = {
done: '#10b981',
blocked: '#ef4444',
waiting: '#ef4444',
interrupted: '#ef4444',
idle: 'rgba(115,115,115,0.4)'
}
export function AgentStateDot({ state }: { state: AgentDotState }) {
const spinValue = useRef(new Animated.Value(0)).current
useEffect(() => {
if (state === 'working') {
const animation = Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true
})
)
animation.start()
return () => animation.stop()
}
spinValue.setValue(0)
return undefined
}, [state, spinValue])
if (state === 'working') {
const rotate = spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
return (
<View style={styles.wrapper}>
<Animated.View style={[styles.spinner, { transform: [{ rotate }] }]} />
</View>
)
}
return (
<View style={styles.wrapper}>
<View style={[styles.dot, { backgroundColor: DOT_COLORS[state] }]} />
</View>
)
}
const styles = StyleSheet.create({
wrapper: { width: 10, height: 10, alignItems: 'center', justifyContent: 'center' },
dot: { width: 6, height: 6, borderRadius: 3 },
spinner: {
width: 6,
height: 6,
borderRadius: 3,
borderWidth: 1.5,
borderColor: '#eab308',
borderTopColor: 'transparent'
}
})
@@ -0,0 +1,66 @@
import { View, Text, Pressable, StyleSheet } from 'react-native'
import { colors, spacing } from '../theme/mobile-theme'
// Why: auth-failed is no longer necessarily terminal (issue #5200) — a
// transient rejection can latch it even though the desktop still lists this
// device. Offer Retry (fresh client + handshake) ahead of the disruptive
// re-pair flow so the common transient case recovers without re-pairing.
export function AuthFailedBanner({
canRetry,
onRetry,
onRepair,
onRemove
}: {
canRetry: boolean
onRetry: () => void
onRepair: () => void
onRemove: () => void
}) {
return (
<View style={styles.banner}>
<Text style={styles.text}>
Authentication failed — try reconnecting first; if it keeps failing, re-pair from desktop.
</Text>
<View style={styles.actions}>
{canRetry && (
<Pressable style={styles.action} onPress={onRetry}>
<Text style={styles.actionText}>Retry</Text>
</Pressable>
)}
<Pressable style={styles.action} onPress={onRepair}>
<Text style={styles.actionText}>Re-pair</Text>
</Pressable>
<Pressable style={styles.action} onPress={onRemove}>
<Text style={[styles.actionText, { color: colors.statusRed }]}>Remove</Text>
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
banner: {
backgroundColor: colors.bgPanel,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
text: {
color: colors.statusRed,
fontSize: 13,
marginBottom: spacing.sm
},
actions: {
flexDirection: 'row',
gap: spacing.lg
},
action: {
paddingVertical: spacing.xs
},
actionText: {
color: colors.accentBlue,
fontSize: 13,
fontWeight: '600'
}
})
+1 -1
View File
@@ -231,7 +231,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
onPress={onManageShortcuts}
>
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
<Text style={styles.rowHint}>Show or hide default shortcut keys</Text>
<Text style={styles.rowHint}>Show, hide, or reorder shortcut keys</Text>
</Pressable>
</>
) : null}
+349
View File
@@ -0,0 +1,349 @@
import { useCallback, useEffect, type ReactNode } from 'react'
import { StyleSheet, View } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { GripVertical } from 'lucide-react-native'
import Animated, {
measure,
runOnJS,
scrollTo,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
withSpring,
type AnimatedRef,
type SharedValue
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { triggerMediumImpact, triggerSelection } from '../platform/haptics'
import {
clampDragReorderIndex,
dragReorderPositionsFromKeys,
moveDragReorderKey,
orderedKeysFromDragReorderPositions,
type DragReorderPositions
} from './drag-reorder-positions'
const ROW_SPRING = { damping: 28, stiffness: 350 }
const LONG_PRESS_ACTIVATION_MS = 200
// Why: joins row keys into a change-detection signature; NUL cannot occur in
// a key, so the joined string is unambiguous.
const KEY_SEPARATOR = '\u0000'
// Why: drags near the viewport edges scroll the outer ScrollView so rows can
// travel further than one screen; speed ramps up the closer the finger gets.
const AUTO_SCROLL_EDGE = 72
const AUTO_SCROLL_MAX_SPEED = 560
type DragSharedState = {
positions: SharedValue<DragReorderPositions>
activeKey: SharedValue<string | null>
activeTop: SharedValue<number>
dragStartTop: SharedValue<number>
dragStartScrollY: SharedValue<number>
dragTranslationY: SharedValue<number>
dragPointerAbsY: SharedValue<number>
}
export type DragReorderListProps<ItemT> = {
items: ItemT[]
itemKey: (item: ItemT) => string
rowHeight: number
renderRow: (item: ItemT) => ReactNode
/** Called with every item key in the new order after a drop changes it. */
onReorder: (orderedKeys: string[]) => void
/** Lets the owning screen disable its ScrollView while a row is held. */
onDragActiveChange?: (active: boolean) => void
scrollRef: AnimatedRef<Animated.ScrollView>
scrollOffsetY: SharedValue<number>
scrollContentHeight: SharedValue<number>
}
export function DragReorderList<ItemT>({
items,
itemKey,
rowHeight,
renderRow,
onReorder,
onDragActiveChange,
scrollRef,
scrollOffsetY,
scrollContentHeight
}: DragReorderListProps<ItemT>): React.JSX.Element {
const keys = items.map(itemKey)
const count = keys.length
const positions = useSharedValue<DragReorderPositions>(dragReorderPositionsFromKeys(keys))
const activeKey = useSharedValue<string | null>(null)
const activeTop = useSharedValue(0)
const dragStartTop = useSharedValue(0)
const dragStartScrollY = useSharedValue(0)
const dragTranslationY = useSharedValue(0)
const dragPointerAbsY = useSharedValue(0)
// Why: rows can be added, removed, or reordered by the owning screen;
// rebuild the position map whenever the rendered key order changes.
const keySignature = keys.join(KEY_SEPARATOR)
useEffect(() => {
positions.value = dragReorderPositionsFromKeys(
keySignature ? keySignature.split(KEY_SEPARATOR) : []
)
}, [keySignature, positions])
const updateDragPosition = (key: string): void => {
'worklet'
const rawTop =
dragStartTop.value + dragTranslationY.value + (scrollOffsetY.value - dragStartScrollY.value)
const top = Math.min(Math.max(rawTop, 0), Math.max(0, (count - 1) * rowHeight))
activeTop.value = top
const target = clampDragReorderIndex(Math.round(top / rowHeight), count)
if (positions.value[key] !== target) {
positions.value = moveDragReorderKey(positions.value, key, target)
runOnJS(triggerSelection)()
}
}
// Why: pan updates stop while the finger holds still at a screen edge, so a
// frame callback keeps scrolling (and re-slotting the row) until it moves.
const autoScroll = useFrameCallback((frame) => {
const key = activeKey.value
if (key === null) {
return
}
const viewport = measure(scrollRef)
if (viewport) {
const topEdge = viewport.pageY + AUTO_SCROLL_EDGE
const bottomEdge = viewport.pageY + viewport.height - AUTO_SCROLL_EDGE
let velocity = 0
if (dragPointerAbsY.value < topEdge) {
velocity =
-AUTO_SCROLL_MAX_SPEED * Math.min(1, (topEdge - dragPointerAbsY.value) / AUTO_SCROLL_EDGE)
} else if (dragPointerAbsY.value > bottomEdge) {
velocity =
AUTO_SCROLL_MAX_SPEED *
Math.min(1, (dragPointerAbsY.value - bottomEdge) / AUTO_SCROLL_EDGE)
}
if (velocity !== 0) {
const maxOffset = Math.max(0, scrollContentHeight.value - viewport.height)
const dtMs = frame.timeSincePreviousFrame ?? 16
const next = Math.min(
Math.max(scrollOffsetY.value + (velocity * dtMs) / 1000, 0),
maxOffset
)
if (next !== scrollOffsetY.value) {
scrollOffsetY.value = next
scrollTo(scrollRef, 0, next, false)
}
}
}
updateDragPosition(key)
}, false)
const setAutoScrollActive = autoScroll.setActive
const handleDragActiveChange = useCallback(
(active: boolean) => {
setAutoScrollActive(active)
onDragActiveChange?.(active)
},
[setAutoScrollActive, onDragActiveChange]
)
const commitReorder = useCallback(
(orderedKeys: string[]) => {
// Why: a cancelled or no-op drag should not trigger a persisted write.
if (orderedKeys.join(KEY_SEPARATOR) !== keySignature) {
onReorder(orderedKeys)
}
},
[onReorder, keySignature]
)
// Why: screen-reader users can't long-press-drag; the handle exposes
// move up/down accessibility actions that commit the same reorder.
const moveRowByAccessibilityAction = useCallback(
(key: string, delta: number) => {
const fromIndex = keys.indexOf(key)
if (fromIndex === -1) {
return
}
const toIndex = Math.min(Math.max(fromIndex + delta, 0), keys.length - 1)
if (toIndex === fromIndex) {
return
}
const next = [...keys]
next.splice(fromIndex, 1)
next.splice(toIndex, 0, key)
onReorder(next)
},
[keys, onReorder]
)
const shared: DragSharedState = {
positions,
activeKey,
activeTop,
dragStartTop,
dragStartScrollY,
dragTranslationY,
dragPointerAbsY
}
return (
<View style={{ height: count * rowHeight }}>
{items.map((item) => (
<DragReorderRow
key={itemKey(item)}
rowKey={itemKey(item)}
rowHeight={rowHeight}
shared={shared}
scrollOffsetY={scrollOffsetY}
updateDragPosition={updateDragPosition}
onDragActiveChange={handleDragActiveChange}
onCommit={commitReorder}
onAccessibilityMove={moveRowByAccessibilityAction}
>
{renderRow(item)}
</DragReorderRow>
))}
</View>
)
}
function DragReorderRow({
rowKey,
rowHeight,
shared,
scrollOffsetY,
updateDragPosition,
onDragActiveChange,
onCommit,
onAccessibilityMove,
children
}: {
rowKey: string
rowHeight: number
shared: DragSharedState
scrollOffsetY: SharedValue<number>
updateDragPosition: (key: string) => void
onDragActiveChange: (active: boolean) => void
onCommit: (orderedKeys: string[]) => void
onAccessibilityMove: (key: string, delta: number) => void
children: ReactNode
}): React.JSX.Element {
const {
positions,
activeKey,
activeTop,
dragStartTop,
dragStartScrollY,
dragTranslationY,
dragPointerAbsY
} = shared
const pan = Gesture.Pan()
.activateAfterLongPress(LONG_PRESS_ACTIVATION_MS)
.shouldCancelWhenOutside(false)
.onStart((event) => {
const index = positions.value[rowKey] ?? 0
dragStartTop.value = index * rowHeight
dragStartScrollY.value = scrollOffsetY.value
dragTranslationY.value = 0
dragPointerAbsY.value = event.absoluteY
activeTop.value = dragStartTop.value
activeKey.value = rowKey
runOnJS(onDragActiveChange)(true)
runOnJS(triggerMediumImpact)()
})
.onUpdate((event) => {
dragTranslationY.value = event.translationY
dragPointerAbsY.value = event.absoluteY
updateDragPosition(rowKey)
})
.onFinalize(() => {
if (activeKey.value !== rowKey) {
return
}
activeKey.value = null
const orderedKeys = orderedKeysFromDragReorderPositions(positions.value)
runOnJS(onCommit)(orderedKeys)
runOnJS(onDragActiveChange)(false)
})
const rowStyle = useAnimatedStyle(() => {
const index = positions.value[rowKey] ?? 0
if (activeKey.value === rowKey) {
return {
top: activeTop.value,
zIndex: 2,
elevation: 4,
shadowOpacity: 0.3,
backgroundColor: colors.bgRaised,
transform: [{ scale: 1.02 }]
}
}
return {
top: withSpring(index * rowHeight, ROW_SPRING),
zIndex: 0,
elevation: 0,
shadowOpacity: 0,
backgroundColor: colors.bgPanel,
transform: [{ scale: 1 }]
}
})
return (
<Animated.View style={[styles.row, { height: rowHeight }, rowStyle]}>
<View style={styles.rowContent}>{children}</View>
<GestureDetector gesture={pan}>
<Animated.View
style={styles.handle}
accessible
accessibilityRole="button"
accessibilityLabel="Drag to reorder"
accessibilityHint="Use the move up and move down actions to reorder without dragging"
accessibilityActions={[
{ name: 'moveUp', label: 'Move up' },
{ name: 'moveDown', label: 'Move down' }
]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'moveUp') {
onAccessibilityMove(rowKey, -1)
} else if (event.nativeEvent.actionName === 'moveDown') {
onAccessibilityMove(rowKey, 1)
}
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<GripVertical size={18} color={colors.textMuted} />
</Animated.View>
</GestureDetector>
<View style={styles.rowSeparator} />
</Animated.View>
)
}
const styles = StyleSheet.create({
row: {
position: 'absolute',
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8
},
rowContent: {
flex: 1
},
handle: {
alignSelf: 'stretch',
justifyContent: 'center',
paddingHorizontal: spacing.md
},
rowSeparator: {
position: 'absolute',
bottom: 0,
left: spacing.md,
right: spacing.md,
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
}
})
+1 -1
View File
@@ -79,7 +79,7 @@ function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number
}
export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) {
if (agentId === 'claude') {
if (agentId === 'claude' || agentId === 'claude-agent-teams') {
return <ClaudeIcon size={size} />
}
if (agentId === 'codex') {
@@ -0,0 +1,290 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View
} from 'react-native'
import { Check, Download } from 'lucide-react-native'
import { BottomDrawer } from './BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import {
downloadDictationModel,
fetchDictationSetup,
isModelInFlight,
setDictationConfig,
type MobileSpeechModel,
type MobileSpeechSetup
} from '../dictation/mobile-dictation-setup'
const POLL_INTERVAL_MS = 1500
type Props = {
visible: boolean
client: RpcClient | null
onClose: () => void
// Called after the user reaches a ready+enabled state, so the caller can retry.
onReady?: () => void
}
function formatSize(bytes: number | null): string {
if (!bytes) {
return ''
}
return `${Math.round(bytes / 1_000_000)} MB`
}
// Lets the user enable dictation and download a speech model on the paired
// desktop, from the phone. Polls while a download is in flight.
export function MobileDictationSetupSheet({ visible, client, onClose, onReady }: Props) {
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const refresh = useCallback(async () => {
if (!client) {
return
}
try {
setSetup(await fetchDictationSetup(client))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load')
}
}, [client])
useEffect(() => {
if (visible) {
setError(null)
void refresh()
}
}, [visible, refresh])
// Poll only while something is downloading/extracting; stop otherwise.
useEffect(() => {
const inFlight = setup?.models.some(isModelInFlight) ?? false
if (visible && inFlight && client) {
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
}
return undefined
}, [visible, setup, client, refresh])
const handleDownload = useCallback(
async (model: MobileSpeechModel) => {
if (!client) {
return
}
setBusy(model.id)
setError(null)
try {
await downloadDictationModel(client, model.id)
await refresh()
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Download failed')
} finally {
setBusy(null)
}
},
[client, refresh]
)
const handleUseModel = useCallback(
async (model: MobileSpeechModel) => {
if (!client) {
return
}
setBusy(model.id)
setError(null)
try {
const next = await setDictationConfig(client, { enabled: true, modelId: model.id })
setSetup(next)
triggerSuccess()
onReady?.()
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Could not select model')
} finally {
setBusy(null)
}
},
[client, onReady]
)
const handleToggleEnabled = useCallback(
async (enabled: boolean) => {
if (!client) {
return
}
setError(null)
try {
setSetup(await setDictationConfig(client, { enabled }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
}
},
[client]
)
return (
<BottomDrawer visible={visible} onClose={onClose}>
<ScrollView keyboardShouldPersistTaps="handled" style={styles.scroll}>
<Text style={styles.heading}>Set up voice dictation</Text>
<Text style={styles.subtitle}>
Download a model and enable dictation on your desktop — all from here.
</Text>
{setup === null ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : (
<>
<View style={styles.enableRow}>
<Text style={styles.enableLabel}>Dictation enabled</Text>
<Switch value={setup.enabled} onValueChange={(v) => void handleToggleEnabled(v)} />
</View>
{setup.models.map((model) => {
const isSelected = model.id === setup.selectedModelId
const inFlight = isModelInFlight(model)
const rowBusy = busy === model.id
return (
<View key={model.id} style={styles.modelRow}>
<View style={styles.modelInfo}>
<View style={styles.modelTitleRow}>
<Text style={styles.modelLabel}>{model.label}</Text>
{model.recommended ? (
<Text style={styles.recommended}>Recommended</Text>
) : null}
</View>
<Text style={styles.modelMeta}>
{model.provider === 'openai' ? 'OpenAI API' : formatSize(model.sizeBytes)}
{inFlight && model.progress != null
? ` · ${Math.round(model.progress * 100)}%`
: model.status === 'extracting'
? ' · extracting…'
: ''}
</Text>
</View>
{model.provider === 'openai' ? (
<Text style={styles.modelStateText}>
{model.status === 'ready' ? 'API key set' : 'Set up on desktop'}
</Text>
) : model.status === 'ready' ? (
isSelected ? (
<View style={styles.selectedTag}>
<Check size={14} color={colors.statusGreen} strokeWidth={2.4} />
<Text style={styles.selectedText}>In use</Text>
</View>
) : (
<Pressable
style={({ pressed }) => [
styles.actionButton,
pressed && styles.actionPressed
]}
disabled={rowBusy}
onPress={() => void handleUseModel(model)}
>
<Text style={styles.actionText}>Use</Text>
</Pressable>
)
) : inFlight ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Pressable
style={({ pressed }) => [
styles.actionButton,
pressed && styles.actionPressed
]}
disabled={rowBusy}
onPress={() => void handleDownload(model)}
>
{rowBusy ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<>
<Download size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.actionText}>Download</Text>
</>
)}
</Pressable>
)}
</View>
)
})}
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
</ScrollView>
</BottomDrawer>
)
}
const styles = StyleSheet.create({
scroll: { maxHeight: 460 },
heading: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
subtitle: {
color: colors.textSecondary,
fontSize: typography.metaSize,
marginTop: spacing.xs,
marginBottom: spacing.md
},
loading: { paddingVertical: spacing.xl, alignItems: 'center' },
enableRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
marginBottom: spacing.sm
},
enableLabel: { color: colors.textPrimary, fontSize: typography.bodySize },
modelRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
paddingVertical: spacing.sm
},
modelInfo: { flex: 1, minWidth: 0 },
modelTitleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
modelLabel: { color: colors.textPrimary, fontSize: typography.bodySize },
recommended: {
color: colors.statusGreen,
fontSize: 10,
fontWeight: '700'
},
modelMeta: { color: colors.textMuted, fontSize: typography.metaSize, marginTop: 2 },
modelStateText: { color: colors.textMuted, fontSize: typography.metaSize },
actionButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
actionPressed: { opacity: 0.7 },
actionText: { color: colors.textSecondary, fontSize: typography.metaSize, fontWeight: '600' },
selectedTag: { flexDirection: 'row', alignItems: 'center', gap: 4 },
selectedText: { color: colors.statusGreen, fontSize: typography.metaSize, fontWeight: '600' },
error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md }
})
@@ -0,0 +1,162 @@
import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native'
import { RefreshCw } from 'lucide-react-native'
import type { RefObject } from 'react'
import type { DiffComment } from '../../../src/shared/types'
import { colors } from '../theme/mobile-theme'
import { MobileDiffReviewLine } from './MobileDiffReviewLine'
import type {
ReviewDiffLine,
ReviewDiffState,
ReviewScreenState
} from '../session/mobile-diff-review-screen-model'
import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
type Props = {
activeHunkIndex: number | null
commentsByLine: ReadonlyMap<number, DiffComment[]>
currentItem: MobileDiffReviewQueueItem | null
diffState: ReviewDiffState
filteredCount: number
listRef: RefObject<FlatList<ReviewDiffLine> | null>
screenState: ReviewScreenState
staleCommentIds: ReadonlySet<string>
onAddNote: (lineNumber: number) => void
onEditNote: (comment: DiffComment) => void
onRetry: () => void
}
export function MobileDiffReviewBody({
activeHunkIndex,
commentsByLine,
currentItem,
diffState,
filteredCount,
listRef,
screenState,
staleCommentIds,
onAddNote,
onEditNote,
onRetry
}: Props) {
if (screenState.kind === 'loading') {
return <CenteredState text="Loading review..." busy />
}
if (screenState.kind === 'error' || screenState.kind === 'unavailable') {
return (
<CenteredState
title={screenState.kind === 'unavailable' ? 'Review Unavailable' : 'Unable to Load Review'}
text={screenState.message}
onRetry={onRetry}
/>
)
}
if (filteredCount === 0) {
return <CenteredState title="No Reviewable Changes" text="Try a different review filter." />
}
if (diffState.kind === 'loading') {
return <CenteredState text="Loading diff..." busy muted />
}
if (diffState.kind !== 'ready') {
return <DiffUnavailableState diffState={diffState} onRetry={onRetry} />
}
return (
<FlatList
ref={listRef}
data={diffState.lines}
keyExtractor={(_, index) => `${currentItem?.key ?? 'diff'}:${index}`}
renderItem={({ item, index }) => {
const lineNumber = item.newLineNumber ?? -1
const active =
activeHunkIndex !== null &&
index >= (diffState.hunks[activeHunkIndex]?.startIndex ?? -1) &&
index <= (diffState.hunks[activeHunkIndex]?.endIndex ?? -1)
return (
<MobileDiffReviewLine
line={item}
comments={commentsByLine.get(lineNumber) ?? []}
staleCommentIds={staleCommentIds}
active={active}
onAddNote={onAddNote}
onEditNote={onEditNote}
/>
)
}}
contentContainerStyle={styles.diffList}
onScrollToIndexFailed={(info) => {
listRef.current?.scrollToOffset({
offset: Math.max(0, info.averageItemLength * info.index),
animated: true
})
}}
ListFooterComponent={
diffState.truncated ? (
<Text style={styles.truncatedText}>Diff truncated for mobile preview.</Text>
) : null
}
/>
)
}
function DiffUnavailableState({
diffState,
onRetry
}: {
diffState: ReviewDiffState
onRetry: () => void
}) {
const title =
diffState.kind === 'binary'
? 'Binary Diff'
: diffState.kind === 'too-large'
? 'Diff Too Large'
: diffState.kind === 'deleted'
? 'Deleted File'
: 'Diff Unavailable'
const text =
diffState.kind === 'binary'
? 'This file cannot be rendered as text on mobile.'
: diffState.kind === 'too-large'
? 'This diff is too large for the mobile preview.'
: diffState.kind === 'deleted'
? 'This file was deleted. Add a file note or mark it reviewed.'
: diffState.kind === 'error'
? diffState.message
: 'Select a file to review.'
return <CenteredState title={title} text={text} onRetry={onRetry} />
}
function CenteredState({
busy,
muted,
title,
text,
onRetry
}: {
busy?: boolean
muted?: boolean
title?: string
text: string
onRetry?: () => void
}) {
return (
<View style={styles.state}>
{busy ? (
<ActivityIndicator color={muted ? colors.textSecondary : colors.textPrimary} />
) : null}
{title ? <Text style={styles.stateTitle}>{title}</Text> : null}
<Text style={styles.stateText}>{text}</Text>
{onRetry ? (
<Pressable
style={({ pressed }) => [styles.retryButton, pressed && styles.buttonPressed]}
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry loading review"
>
<RefreshCw size={14} color={colors.textPrimary} strokeWidth={2.2} />
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}
</View>
)
}
@@ -0,0 +1,294 @@
import { useMemo } from 'react'
import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'
import { Check, Copy, FileText, Plus, Send, Trash2, X } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/types'
import { colors } from '../theme/mobile-theme'
import type { ActionSheetAction } from './ActionSheetModal'
import { ActionSheetModal } from './ActionSheetModal'
import { BottomDrawer } from './BottomDrawer'
import { ConfirmModal } from './ConfirmModal'
import { mobileReviewCountLabel } from '../session/mobile-diff-review-screen-model'
import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
type Props = {
controller: ReturnType<typeof useMobileDiffReviewController>
}
export function MobileDiffReviewDrawers({ controller }: Props) {
const sendActions = useSendActions(controller)
const overflowActions = useOverflowActions(controller)
return (
<>
<ActionSheetModal
visible={controller.showOverflow}
title="Review Actions"
message={
controller.reviewedUnstagedCount > 0
? `${controller.reviewedUnstagedCount} reviewed unstaged files can be staged`
: undefined
}
actions={overflowActions}
onClose={() => controller.setShowOverflow(false)}
/>
<ActionSheetModal
visible={controller.sendSheet !== null}
title="Send Notes"
message={sendSheetMessage(controller)}
actions={sendActions}
onClose={() => controller.setSendSheet(null)}
/>
<ConfirmModal
visible={controller.discardTarget !== null}
title="Discard File"
message={
controller.discardTarget
? `Discard changes to "${controller.discardTarget.filePath}"? This cannot be undone.`
: undefined
}
confirmLabel="Discard"
destructive
onConfirm={() => {
const target = controller.discardTarget
controller.setDiscardTarget(null)
if (target) {
void controller.runGitMutation('git.discard', target)
}
}}
onCancel={() => controller.setDiscardTarget(null)}
/>
<NoteComposerDrawer controller={controller} />
<CompletionDrawer controller={controller} />
</>
)
}
function useSendActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
return useMemo<ActionSheetAction[]>(() => {
const comments = controller.unsentComments
const terminalActions =
controller.sendSheet?.kind === 'ready' || controller.sendSheet?.kind === 'error'
? controller.sendSheet.terminals.map((terminal) => ({
label: `${terminal.title || 'Terminal'} (${terminal.terminal.slice(0, 6)})`,
icon: Send,
disabled: comments.length === 0,
skipAutoClose: true,
onPress: () => void controller.sendPromptToTerminal(terminal.terminal, comments)
}))
: []
return [
...terminalActions,
{
label: 'New Agent Session',
icon: Plus,
disabled: comments.length === 0,
skipAutoClose: true,
onPress: () => void controller.createTerminalAndSend(comments)
},
{
label: 'Copy Notes',
icon: Copy,
disabled:
controller.screenState.kind !== 'ready' || controller.screenState.comments.length === 0,
onPress: () => void controller.copyNotes()
}
]
}, [controller])
}
function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
return useMemo<ActionSheetAction[]>(
() => [
{
label: 'Copy Notes',
icon: Copy,
disabled:
controller.screenState.kind !== 'ready' || controller.screenState.comments.length === 0,
onPress: () => void controller.copyNotes()
},
{
label: 'Send Unsent Notes',
icon: Send,
disabled: controller.unsentComments.length === 0,
skipAutoClose: true,
onPress: () => void controller.openSendSheet()
},
{
label: 'Clear Sent Notes',
icon: Trash2,
disabled:
controller.screenState.kind !== 'ready' ||
controller.screenState.comments.every((comment) => comment.sentAt === undefined),
skipAutoClose: true,
onPress: () => void controller.clearSentNotes()
},
{
label: 'Stage Reviewed Files',
icon: Check,
disabled: controller.reviewedUnstagedCount === 0 || controller.busyAction !== null,
skipAutoClose: true,
onPress: () => void controller.stageReviewedFiles()
},
{
label: 'Mark Unreviewed',
icon: X,
disabled:
controller.screenState.kind !== 'ready' ||
!controller.currentItem ||
!controller.currentItem.isReviewed,
skipAutoClose: true,
onPress: () => void controller.markUnreviewed()
},
{
label: 'Open in Session',
icon: FileText,
disabled: !controller.currentItem || controller.currentItem.scope === 'branch',
onPress: () => void controller.openInSession()
}
],
[controller]
)
}
function sendSheetMessage(
controller: ReturnType<typeof useMobileDiffReviewController>
): string | undefined {
return controller.sendSheet?.kind === 'loading'
? 'Loading agent sessions...'
: controller.sendSheet?.kind === 'error'
? controller.sendSheet.message
: `${controller.unsentComments.length} unsent notes`
}
function NoteComposerDrawer({ controller }: Props) {
const composer = controller.composer
return (
<BottomDrawer visible={composer !== null} onClose={controller.closeComposer}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<View style={styles.composerHeader}>
<View>
<Text style={styles.drawerTitle}>
{composer?.mode === 'edit' ? 'Edit Note' : 'Add Note'}
</Text>
<Text style={styles.drawerSubtitle}>
{composer?.mode === 'create' && composer.lineNumber > 0
? `Line ${composer.lineNumber}`
: 'File note'}
</Text>
</View>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={controller.closeComposer}
accessibilityRole="button"
accessibilityLabel="Cancel note"
>
<X size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
</View>
<TextInput
style={styles.composerInput}
value={controller.composerBody}
onChangeText={controller.setComposerBody}
multiline
autoFocus
placeholder="Review note"
placeholderTextColor={colors.textMuted}
accessibilityLabel={composerLabel(composer)}
/>
<View style={styles.drawerButtonRow}>
{composer?.mode === 'edit' ? (
<DeleteNoteButton onPress={controller.deleteComment} />
) : null}
<SaveNoteButton controller={controller} composer={composer} />
</View>
</KeyboardAvoidingView>
</BottomDrawer>
)
}
function composerLabel(
composer: { mode: 'create'; lineNumber: number } | { mode: 'edit'; comment: DiffComment } | null
): string {
return composer?.mode === 'create' && composer.lineNumber > 0
? `Save note on line ${composer.lineNumber}`
: 'Review note'
}
function DeleteNoteButton({ onPress }: { onPress: () => Promise<void> }) {
return (
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
onPress={() => void onPress()}
accessibilityRole="button"
accessibilityLabel="Delete note"
>
<Trash2 size={14} color={colors.statusRed} strokeWidth={2.2} />
<Text style={styles.destructiveText}>Delete</Text>
</Pressable>
)
}
function SaveNoteButton({
controller,
composer
}: {
controller: ReturnType<typeof useMobileDiffReviewController>
composer: ReturnType<typeof useMobileDiffReviewController>['composer']
}) {
const disabled = controller.composerBody.trim().length === 0
return (
<Pressable
style={({ pressed }) => [
styles.primaryButton,
disabled && styles.buttonDisabled,
pressed && styles.buttonPressed
]}
disabled={disabled}
onPress={() => void controller.saveComposer()}
accessibilityRole="button"
accessibilityLabel={composerLabel(composer)}
>
<Check size={14} color={colors.bgBase} strokeWidth={2.2} />
<Text style={styles.primaryButtonText}>Save</Text>
</Pressable>
)
}
function CompletionDrawer({ controller }: Props) {
const noteCount =
controller.screenState.kind === 'ready' ? controller.screenState.comments.length : 0
return (
<BottomDrawer
visible={controller.showCompletion}
onClose={() => controller.setShowCompletion(false)}
>
<Text style={styles.drawerTitle}>Review Complete</Text>
<Text style={styles.drawerSubtitle}>
{mobileReviewCountLabel(controller.queue.length, 'file', 'files')} reviewed,{' '}
{mobileReviewCountLabel(noteCount, 'note', 'notes')}
</Text>
<View style={styles.drawerButtonRow}>
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
disabled={controller.reviewedUnstagedCount === 0}
onPress={() => void controller.stageReviewedFiles()}
accessibilityRole="button"
accessibilityLabel="Stage reviewed files"
>
<Check size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.secondaryButtonText}>Stage Reviewed</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
disabled={controller.unsentComments.length === 0}
onPress={() => void controller.openSendSheet()}
accessibilityRole="button"
accessibilityLabel="Send notes to agent"
>
<Send size={14} color={colors.bgBase} strokeWidth={2.2} />
<Text style={styles.primaryButtonText}>Send Notes</Text>
</Pressable>
</View>
</BottomDrawer>
)
}
@@ -0,0 +1,129 @@
import { Pressable, Text, View } from 'react-native'
import { ArrowDown, ArrowUp } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/types'
import { colors } from '../theme/mobile-theme'
import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue'
import { MOBILE_GIT_STATUS_LABELS } from '../source-control/mobile-git-status'
import {
mobileReviewCountLabel,
mobileReviewScopeLabel,
type ReviewDiffState
} from '../session/mobile-diff-review-screen-model'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
type Props = {
currentIndex: number
diffState: ReviewDiffState
fileNotes: DiffComment[]
filteredCount: number
item: MobileDiffReviewQueueItem
staleCommentIds: ReadonlySet<string>
onEditNote: (comment: DiffComment) => void
onJumpHunk: (direction: 'next' | 'previous') => void
}
function statusColor(status: MobileDiffReviewQueueItem['status']): string {
switch (status) {
case 'added':
case 'copied':
return colors.statusGreen
case 'deleted':
return colors.statusRed
case 'renamed':
return colors.accentBlue
case 'untracked':
return colors.statusAmber
case 'modified':
default:
return colors.textSecondary
}
}
export function MobileDiffReviewFileSummary({
currentIndex,
diffState,
fileNotes,
filteredCount,
item,
staleCommentIds,
onEditNote,
onJumpHunk
}: Props) {
const hunkDisabled = diffState.kind !== 'ready' || diffState.hunks.length === 0
const badgeColor = statusColor(item.status)
return (
<View style={styles.fileHeader}>
<View style={styles.fileTitleRow}>
<View style={[styles.statusBadge, { borderColor: badgeColor }]}>
<Text style={[styles.statusBadgeText, { color: badgeColor }]}>
{MOBILE_GIT_STATUS_LABELS[item.status]}
</Text>
</View>
<View style={styles.fileTitleBlock}>
<Text style={styles.filePath} numberOfLines={1}>
{item.filePath}
</Text>
<Text style={styles.fileMeta} numberOfLines={1}>
{mobileReviewScopeLabel(item)}
{item.oldPath ? ` from ${item.oldPath}` : ''}
</Text>
</View>
</View>
<View style={styles.fileMetaRow}>
<Text style={styles.fileMeta}>
{currentIndex + 1}/{filteredCount}
</Text>
{item.isReviewed ? <Text style={styles.reviewedPill}>Reviewed</Text> : null}
{item.changedSinceReview ? <Text style={styles.stalePill}>Changed</Text> : null}
{item.noteCount > 0 ? (
<Text style={styles.fileMeta}>
{mobileReviewCountLabel(item.noteCount, 'note', 'notes')}
</Text>
) : null}
{item.staleNoteCount > 0 ? (
<Text style={styles.staleText}>{item.staleNoteCount} stale</Text>
) : null}
</View>
{fileNotes.length > 0 ? (
<View style={styles.fileNotes}>
{fileNotes.map((note) => (
<Pressable
key={note.id}
style={({ pressed }) => [styles.fileNote, pressed && styles.fileNotePressed]}
onPress={() => onEditNote(note)}
accessibilityRole="button"
accessibilityLabel="Edit file note"
>
<Text style={styles.fileNoteText} numberOfLines={2}>
{note.body}
</Text>
{staleCommentIds.has(note.id) ? <Text style={styles.staleText}>Stale</Text> : null}
</Pressable>
))}
</View>
) : null}
<View style={styles.hunkRow}>
<Pressable
style={({ pressed }) => [styles.hunkButton, pressed && styles.hunkButtonPressed]}
disabled={hunkDisabled}
onPress={() => onJumpHunk('previous')}
accessibilityRole="button"
accessibilityLabel="Previous hunk"
>
<ArrowUp size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.hunkButtonText}>Hunk</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.hunkButton, pressed && styles.hunkButtonPressed]}
disabled={hunkDisabled}
onPress={() => onJumpHunk('next')}
accessibilityRole="button"
accessibilityLabel="Next hunk"
>
<ArrowDown size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.hunkButtonText}>Hunk</Text>
</Pressable>
</View>
</View>
)
}
@@ -0,0 +1,121 @@
import { Pressable, Text, View } from 'react-native'
import {
Check,
ChevronLeft,
ChevronRight,
FileText,
Plus,
Trash2,
Undo2
} from 'lucide-react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue'
import type { GitMutationMethod } from '../session/mobile-diff-review-screen-model'
import { colors, spacing } from '../theme/mobile-theme'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
type Props = {
busyAction: string | null
item: MobileDiffReviewQueueItem
onAddFileNote: () => void
onDiscard: (item: MobileDiffReviewQueueItem) => void
onGitMutation: (method: GitMutationMethod, item: MobileDiffReviewQueueItem) => void
onMarkReviewed: () => void
onMoveFile: (direction: 'next' | 'previous') => void
}
export function MobileDiffReviewFooter({
busyAction,
item,
onAddFileNote,
onDiscard,
onGitMutation,
onMarkReviewed,
onMoveFile
}: Props) {
const insets = useSafeAreaInsets()
return (
<View style={[styles.footer, { paddingBottom: insets.bottom + spacing.sm }]}>
<View style={styles.fileActionRow}>
{item.canStage ? (
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
disabled={busyAction !== null}
onPress={() => onGitMutation('git.stage', item)}
accessibilityRole="button"
accessibilityLabel="Stage file"
>
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.secondaryButtonText}>Stage</Text>
</Pressable>
) : null}
{item.canUnstage ? (
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
disabled={busyAction !== null}
onPress={() => onGitMutation('git.unstage', item)}
accessibilityRole="button"
accessibilityLabel="Unstage file"
>
<Undo2 size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.secondaryButtonText}>Unstage</Text>
</Pressable>
) : null}
{item.canDiscard ? (
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
disabled={busyAction !== null}
onPress={() => onDiscard(item)}
accessibilityRole="button"
accessibilityLabel="Discard file"
>
<Trash2 size={14} color={colors.statusRed} strokeWidth={2.2} />
<Text style={styles.destructiveText}>Discard</Text>
</Pressable>
) : null}
</View>
<View style={styles.footerRow}>
<Pressable
style={({ pressed }) => [styles.navButton, pressed && styles.buttonPressed]}
onPress={() => onMoveFile('previous')}
accessibilityRole="button"
accessibilityLabel="Previous file"
>
<ChevronLeft size={17} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.footerButton, pressed && styles.buttonPressed]}
onPress={onAddFileNote}
accessibilityRole="button"
accessibilityLabel="Add file note"
>
<FileText size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.footerButtonText}>Note</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.primaryButton,
item.isReviewed && styles.primaryButtonDone,
pressed && styles.buttonPressed
]}
onPress={onMarkReviewed}
accessibilityRole="button"
accessibilityLabel="Mark file reviewed"
>
<Check size={14} color={colors.bgBase} strokeWidth={2.2} />
<Text style={styles.primaryButtonText}>
{item.isReviewed ? 'Reviewed' : 'Mark Reviewed'}
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.navButton, pressed && styles.buttonPressed]}
onPress={() => onMoveFile('next')}
accessibilityRole="button"
accessibilityLabel="Next file"
>
<ChevronRight size={17} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
</View>
</View>
)
}
@@ -0,0 +1,91 @@
import { FlatList, Pressable, Text, View } from 'react-native'
import { ChevronLeft, MoreHorizontal } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import type { MobileDiffReviewQueueFilter } from '../session/mobile-diff-review-queue'
import { REVIEW_FILTERS, mobileReviewCountLabel } from '../session/mobile-diff-review-screen-model'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
type Props = {
filter: MobileDiffReviewQueueFilter
queueLength: number
reviewedCount: number
unsentCount: number
worktreeLabel: string
onBack: () => void
onOpenActions: () => void
onSelectFilter: (filter: MobileDiffReviewQueueFilter) => void
}
export function MobileDiffReviewHeader({
filter,
queueLength,
reviewedCount,
unsentCount,
worktreeLabel,
onBack,
onOpenActions,
onSelectFilter
}: Props) {
return (
<View style={styles.header}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={onBack}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={19} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
<View style={styles.titleBlock}>
<Text style={styles.title} numberOfLines={1}>
Review Changes
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{worktreeLabel}
</Text>
</View>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={onOpenActions}
accessibilityRole="button"
accessibilityLabel="Open review actions"
>
<MoreHorizontal size={19} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
</View>
<View style={styles.progressRow}>
<Text style={styles.progressText}>
{reviewedCount}/{queueLength} reviewed
</Text>
<Text style={styles.progressText}>
{mobileReviewCountLabel(unsentCount, 'unsent note', 'unsent notes')}
</Text>
</View>
<FlatList
data={REVIEW_FILTERS}
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item}
contentContainerStyle={styles.filterRow}
renderItem={({ item }) => (
<Pressable
style={({ pressed }) => [
styles.filterChip,
filter === item && styles.filterChipActive,
pressed && styles.filterChipPressed
]}
onPress={() => onSelectFilter(item)}
accessibilityRole="button"
accessibilityState={{ selected: filter === item }}
accessibilityLabel={`Show ${item} review files`}
>
<Text style={[styles.filterText, filter === item && styles.filterTextActive]}>
{item === 'all' ? 'All' : item[0]?.toUpperCase() + item.slice(1)}
</Text>
</Pressable>
)}
/>
</View>
)
}
@@ -0,0 +1,160 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { MessageSquare } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/types'
import type { MobileDiffLine } from '../session/mobile-diff-lines'
import type { MobileHighlightedDiffLine } from '../session/mobile-file-syntax'
import { mobileDiffLineNumber, mobileDiffLinePrefix } from '../source-control/mobile-diff-format'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { MobileSyntaxSegments } from './MobileSyntaxSegments'
type Props = {
line: MobileHighlightedDiffLine<MobileDiffLine>
comments: readonly DiffComment[]
staleCommentIds: ReadonlySet<string>
active: boolean
onAddNote: (lineNumber: number) => void
onEditNote: (comment: DiffComment) => void
}
function accessibilityLabelForLine(line: MobileDiffLine): string {
const number = mobileDiffLineNumber(line)
const label = line.kind === 'add' ? 'Added' : line.kind === 'delete' ? 'Deleted' : 'Context'
return number ? `${label} line ${number}` : `${label} line`
}
function canCommentOnLine(line: MobileDiffLine): boolean {
return line.kind !== 'delete' && line.newLineNumber !== undefined
}
export function MobileDiffReviewLine({
line,
comments,
staleCommentIds,
active,
onAddNote,
onEditNote
}: Props) {
const lineNumber = mobileDiffLineNumber(line)
const canComment = canCommentOnLine(line)
return (
<View
style={[
styles.row,
line.kind === 'add' && styles.addedRow,
line.kind === 'delete' && styles.deletedRow,
active && styles.activeRow
]}
accessible
accessibilityLabel={accessibilityLabelForLine(line)}
>
<Text style={styles.prefix}>{mobileDiffLinePrefix(line.kind)}</Text>
<Text style={styles.lineNumber}>{lineNumber ? String(lineNumber) : ''}</Text>
<Pressable
style={({ pressed }) => [styles.code, pressed && canComment && styles.codePressed]}
disabled={!canComment}
onPress={() => {
if (canComment && line.newLineNumber !== undefined) {
onAddNote(line.newLineNumber)
}
}}
accessibilityRole={canComment ? 'button' : 'text'}
accessibilityLabel={
canComment && line.newLineNumber !== undefined
? `Add note on line ${line.newLineNumber}`
: accessibilityLabelForLine(line)
}
>
<Text style={styles.codeText}>
<MobileSyntaxSegments segments={line.segments} />
</Text>
</Pressable>
{comments.length > 0 ? (
<View style={styles.notes}>
{comments.map((comment) => (
<Pressable
key={comment.id}
style={({ pressed }) => [styles.noteButton, pressed && styles.noteButtonPressed]}
onPress={() => onEditNote(comment)}
accessibilityRole="button"
accessibilityLabel={`Edit note on line ${comment.lineNumber}`}
>
<MessageSquare
size={13}
color={staleCommentIds.has(comment.id) ? colors.statusAmber : colors.textSecondary}
strokeWidth={2}
/>
</Pressable>
))}
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
row: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'stretch',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
addedRow: {
backgroundColor: colors.diffAddedBg
},
deletedRow: {
backgroundColor: colors.diffDeletedBg
},
activeRow: {
borderLeftWidth: 2,
borderLeftColor: colors.accentBlue
},
prefix: {
width: 18,
paddingTop: spacing.sm,
textAlign: 'center',
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
lineNumber: {
width: 44,
paddingTop: spacing.sm,
paddingRight: spacing.xs,
textAlign: 'right',
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
code: {
flex: 1,
minWidth: 0,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm
},
codePressed: {
backgroundColor: colors.bgRaised
},
codeText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 12,
lineHeight: 18
},
notes: {
width: 40,
alignItems: 'center',
justifyContent: 'center',
gap: 2
},
noteButton: {
minWidth: 32,
minHeight: 28,
alignItems: 'center',
justifyContent: 'center'
},
noteButtonPressed: {
opacity: 0.72
}
})
@@ -0,0 +1,73 @@
import { SafeAreaView } from 'react-native-safe-area-context'
import { Text, View } from 'react-native'
import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
import { MobileDiffReviewBody } from './MobileDiffReviewBody'
import { MobileDiffReviewDrawers } from './MobileDiffReviewDrawers'
import { MobileDiffReviewFileSummary } from './MobileDiffReviewFileSummary'
import { MobileDiffReviewFooter } from './MobileDiffReviewFooter'
import { MobileDiffReviewHeader } from './MobileDiffReviewHeader'
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
type Props = {
controller: ReturnType<typeof useMobileDiffReviewController>
onBack: () => void
}
export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<MobileDiffReviewHeader
filter={controller.filter}
queueLength={controller.queue.length}
reviewedCount={controller.reviewedCount}
unsentCount={controller.unsentComments.length}
worktreeLabel={controller.worktreeLabel}
onBack={onBack}
onOpenActions={() => controller.setShowOverflow(true)}
onSelectFilter={controller.selectFilter}
/>
{controller.currentItem ? (
<MobileDiffReviewFileSummary
currentIndex={controller.currentIndex}
diffState={controller.diffState}
fileNotes={controller.fileNotes}
filteredCount={controller.filteredQueue.length}
item={controller.currentItem}
staleCommentIds={controller.staleCommentIds}
onEditNote={controller.openEditComposer}
onJumpHunk={controller.jumpHunk}
/>
) : null}
{controller.actionError ? (
<View style={styles.actionError}>
<Text style={styles.actionErrorText}>{controller.actionError}</Text>
</View>
) : null}
<MobileDiffReviewBody
activeHunkIndex={controller.activeHunkIndex}
commentsByLine={controller.commentsByLine}
currentItem={controller.currentItem}
diffState={controller.diffState}
filteredCount={controller.filteredQueue.length}
listRef={controller.listRef}
screenState={controller.screenState}
staleCommentIds={controller.staleCommentIds}
onAddNote={controller.openComposer}
onEditNote={controller.openEditComposer}
onRetry={controller.retryAction}
/>
{controller.currentItem ? (
<MobileDiffReviewFooter
busyAction={controller.busyAction}
item={controller.currentItem}
onAddFileNote={() => controller.openComposer(0)}
onDiscard={controller.setDiscardTarget}
onGitMutation={(method, item) => void controller.runGitMutation(method, item)}
onMarkReviewed={() => void controller.markReviewed()}
onMoveFile={controller.moveFile}
/>
) : null}
<MobileDiffReviewDrawers controller={controller} />
</SafeAreaView>
)
}
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import { WebView } from 'react-native-webview'
import { Code, Eye } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
type Props = {
html: string
// Rendered when the user flips to "Source" (the existing syntax view).
renderSource: () => React.ReactNode
}
// Renders an agent-produced HTML artifact in a sandboxed WebView, with a
// Preview/Source toggle. Navigation is locked: only the initial inline document
// loads in-place; any link tap opens externally so a page can't hijack the
// review surface.
export function MobileHtmlPreview({ html, renderSource }: Props) {
const [mode, setMode] = useState<'preview' | 'source'>('preview')
return (
<View style={styles.container}>
<View style={styles.toolbar}>
<Pressable
style={[styles.toggle, mode === 'preview' && styles.toggleActive]}
onPress={() => setMode('preview')}
accessibilityLabel="Preview rendered HTML"
>
<Eye size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.toggleText}>Preview</Text>
</Pressable>
<Pressable
style={[styles.toggle, mode === 'source' && styles.toggleActive]}
onPress={() => setMode('source')}
accessibilityLabel="View HTML source"
>
<Code size={13} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.toggleText}>Source</Text>
</Pressable>
</View>
{mode === 'preview' ? (
<WebView
style={styles.webview}
originWhitelist={['*']}
source={{ html }}
javaScriptEnabled
// Why: only the initial about:blank inline-HTML load is allowed in
// place; a tapped link opens in the system browser instead of
// navigating the review WebView away from the artifact.
onShouldStartLoadWithRequest={(request) => {
if (request.url === 'about:blank' || request.url.startsWith('data:')) {
return true
}
void Linking.openURL(request.url).catch(() => {})
return false
}}
/>
) : (
renderSource()
)}
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1 },
toolbar: {
flexDirection: 'row',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
toggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: 6,
backgroundColor: colors.bgRaised
},
toggleActive: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
toggleText: { color: colors.textSecondary, fontSize: typography.metaSize },
webview: { flex: 1, backgroundColor: '#ffffff' }
})
@@ -0,0 +1,281 @@
import { useCallback, useEffect, useState } from 'react'
import {
ActivityIndicator,
Linking,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View
} from 'react-native'
import { Sparkles } from 'lucide-react-native'
import type { HostedReviewProvider } from '../../../src/shared/hosted-review'
import { BottomDrawer } from './BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { triggerError, triggerSuccess } from '../platform/haptics'
import { createMobilePr } from '../source-control/mobile-pr-create'
type PrPrefill = {
base: string
title: string
body: string
provider: HostedReviewProvider
}
type Props = {
visible: boolean
client: RpcClient | null
worktreeId: string
prefill: PrPrefill
onClose: () => void
onCreated: (url: string) => void
}
// PR compose sheet: title/body/base/draft with AI prefill (git.generate
// PullRequestFields), submitting via hostedReview.create. Mirrors the desktop
// CreateHostedReviewComposer flow at mobile scale.
export function MobilePrComposeSheet({
visible,
client,
worktreeId,
prefill,
onClose,
onCreated
}: Props) {
const [title, setTitle] = useState(prefill.title)
const [body, setBody] = useState(prefill.body)
const [base, setBase] = useState(prefill.base)
const [draft, setDraft] = useState(false)
const [generating, setGenerating] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (visible) {
setTitle(prefill.title)
setBody(prefill.body)
setBase(prefill.base)
setDraft(false)
setError(null)
}
// Why: depend on the prefill *fields*, not the object identity — a parent
// rerender that produces a new prefill object would otherwise wipe the
// user's in-progress edits while the sheet is open.
}, [visible, prefill.title, prefill.body, prefill.base])
const generate = useCallback(async () => {
if (!client || generating) {
return
}
setGenerating(true)
setError(null)
try {
const response = await client.sendRequest('git.generatePullRequestFields', {
worktree: `id:${worktreeId}`,
base,
title,
body,
draft
})
if (!response.ok) {
setError(response.error?.message || 'Failed to generate PR fields')
return
}
const result = (response as RpcSuccess).result as {
success?: boolean
fields?: { base: string; title: string; body: string; draft: boolean }
error?: string
}
if (result.success && result.fields) {
setBase(result.fields.base || base)
setTitle(result.fields.title || title)
setBody(result.fields.body || body)
setDraft(result.fields.draft)
} else if (result.error) {
setError(result.error)
}
} finally {
setGenerating(false)
}
}, [base, body, client, draft, generating, title, worktreeId])
const submit = useCallback(async () => {
if (!client || submitting || title.trim().length === 0) {
return
}
setSubmitting(true)
setError(null)
try {
const outcome = await createMobilePr(client, worktreeId, {
provider: prefill.provider,
base,
title,
body,
draft
})
if (outcome.ok) {
triggerSuccess()
onCreated(outcome.url)
} else {
triggerError()
setError(outcome.error)
}
} finally {
setSubmitting(false)
}
}, [base, body, client, draft, onCreated, prefill.provider, submitting, title, worktreeId])
return (
<BottomDrawer visible={visible} onClose={onClose}>
<ScrollView keyboardShouldPersistTaps="handled" style={styles.scroll}>
<Text style={styles.heading}>Create Pull Request</Text>
<View style={styles.fieldRow}>
<Text style={styles.label}>Title</Text>
<Pressable
style={({ pressed }) => [styles.genButton, pressed && styles.genButtonPressed]}
disabled={generating || submitting}
onPress={() => void generate()}
accessibilityLabel="Generate PR fields with AI"
>
{generating ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={14} color={colors.textSecondary} strokeWidth={2.1} />
)}
</Pressable>
</View>
<TextInput
style={styles.titleInput}
value={title}
onChangeText={setTitle}
placeholder="Pull request title"
placeholderTextColor={colors.textMuted}
editable={!submitting}
/>
<Text style={styles.label}>Base branch</Text>
<TextInput
style={styles.titleInput}
value={base}
onChangeText={setBase}
placeholder="main"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
editable={!submitting}
/>
<Text style={styles.label}>Description</Text>
<TextInput
style={styles.bodyInput}
value={body}
onChangeText={setBody}
placeholder="Describe the change…"
placeholderTextColor={colors.textMuted}
multiline
editable={!submitting}
/>
<View style={styles.draftRow}>
<Text style={styles.label}>Draft</Text>
<Switch value={draft} onValueChange={setDraft} disabled={submitting} />
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={({ pressed }) => [
styles.submit,
(submitting || title.trim().length === 0) && styles.submitDisabled,
pressed && styles.submitPressed
]}
disabled={submitting || title.trim().length === 0}
onPress={() => void submit()}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.submitText}>Create Pull Request</Text>
)}
</Pressable>
</ScrollView>
</BottomDrawer>
)
}
export function openMobilePrUrl(url: string): void {
void Linking.openURL(url)
}
const styles = StyleSheet.create({
scroll: { maxHeight: 460 },
heading: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700',
marginBottom: spacing.sm
},
fieldRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
},
label: {
color: colors.textSecondary,
fontSize: typography.metaSize,
marginTop: spacing.md,
marginBottom: spacing.xs
},
genButton: {
width: 32,
height: 32,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.sm
},
genButtonPressed: { opacity: 0.7 },
titleInput: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize
},
bodyInput: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize,
minHeight: 96,
textAlignVertical: 'top'
},
draftRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.md
},
error: {
color: colors.statusRed,
fontSize: typography.metaSize,
marginTop: spacing.md
},
submit: {
marginTop: spacing.lg,
minHeight: 46,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
alignItems: 'center',
justifyContent: 'center'
},
submitDisabled: { opacity: 0.45 },
submitPressed: { opacity: 0.8 },
submitText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
}
})
+92
View File
@@ -0,0 +1,92 @@
import {
Bot,
Box,
Braces,
Briefcase,
Building2,
Code2,
Cpu,
Database,
Folder,
Gauge,
Globe,
Layers,
type LucideIcon,
Package,
Palette,
Rocket,
Server,
Shapes,
Sparkles,
SquareTerminal,
Wrench
} from 'lucide-react-native'
import { Image, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import { colors } from '../theme/mobile-theme'
// The lucide names the desktop repo-icon picker offers (src/renderer/src/
// components/repo/repo-icon.tsx). Mobile renders the same glyph so the project
// header icon matches desktop instead of a bare colored dot.
const REPO_LUCIDE_ICONS: Record<string, LucideIcon> = {
Folder,
Code2,
SquareTerminal,
Bot,
Package,
Database,
Globe,
Server,
Layers,
Box,
Braces,
Briefcase,
Building2,
Cpu,
Gauge,
Palette,
Rocket,
Shapes,
Sparkles,
Wrench
}
type Props = {
repoIcon?: RepoIcon | null
size?: number
color?: string
}
// Renders a repo/project icon matching the desktop sidebar: a custom image
// (favicon/avatar/upload), an emoji, or a lucide glyph. Falls back to Folder,
// the desktop default, so a project always shows an icon rather than a dot.
export function MobileRepoIcon({ repoIcon, size = 14, color = colors.textSecondary }: Props) {
if (repoIcon?.type === 'image') {
return (
<Image
source={{ uri: repoIcon.src }}
style={{ width: size, height: size, borderRadius: 3 }}
accessibilityLabel={repoIcon.label}
/>
)
}
if (repoIcon?.type === 'emoji') {
return <Text style={[styles.emoji, { fontSize: size }]}>{repoIcon.emoji}</Text>
}
const Icon = (repoIcon?.type === 'lucide' && REPO_LUCIDE_ICONS[repoIcon.name]) || Folder
return (
<View style={styles.glyph}>
<Icon size={size} color={color} strokeWidth={2} />
</View>
)
}
const styles = StyleSheet.create({
emoji: {
textAlign: 'center'
},
glyph: {
alignItems: 'center',
justifyContent: 'center'
}
})
@@ -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 }) => {
@@ -0,0 +1,428 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AppState,
View,
Text,
StyleSheet,
Pressable,
Switch,
type AppStateStatus
} from 'react-native'
import { useFocusEffect } from 'expo-router'
import { ChevronRight, X } from 'lucide-react-native'
import type Animated from 'react-native-reanimated'
import type { AnimatedRef, SharedValue } from 'react-native-reanimated'
import { CustomKeyModal, loadCustomKeys, saveCustomKeys, type CustomKey } from './CustomKeyModal'
import { DragReorderList } from './DragReorderList'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
TERMINAL_ACCESSORY_KEYS,
type TerminalAccessoryKey
} from '../terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryLayout,
loadTerminalAccessoryLayout,
reorderTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible,
type TerminalAccessoryLayout
} from '../terminal/terminal-accessory-layout'
// Why: DragReorderList absolutely positions rows, so every row in a
// reorderable section must share one fixed height.
const REORDER_ROW_HEIGHT = 56
function ShortcutBarRow({
shortcutKey,
visible,
onToggle
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
}): React.JSX.Element {
return (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
type Props = {
scrollRef: AnimatedRef<Animated.ScrollView>
scrollOffsetY: SharedValue<number>
scrollContentHeight: SharedValue<number>
onDragActiveChange: (active: boolean) => void
}
export function TerminalShortcutSettings({
scrollRef,
scrollOffsetY,
scrollContentHeight,
onDragActiveChange
}: Props): React.JSX.Element {
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [shortcutLayout, setShortcutLayout] = useState<TerminalAccessoryLayout>(
getDefaultTerminalAccessoryLayout
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback((next: TerminalAccessoryLayout) => {
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => saveTerminalAccessoryLayout(next))
.catch(() => {})
.finally(() => {
pendingLayoutWritesRef.current -= 1
})
}, [])
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void loadTerminalAccessoryLayout().then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
return
}
setShortcutLayout({
orderedBuiltInIds: layout.orderedBuiltInIds,
visibleBuiltInIds: layout.visibleBuiltInIds
})
})
}, [])
const customKeysWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const customKeysWriteSeqRef = useRef(0)
const pendingCustomKeysWritesRef = useRef(0)
// Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh
// racing an in-flight save must not overwrite the optimistic state.
const persistCustomKeys = useCallback((next: CustomKey[]) => {
customKeysWriteSeqRef.current += 1
pendingCustomKeysWritesRef.current += 1
customKeysWriteChainRef.current = customKeysWriteChainRef.current
.catch(() => {})
.then(() => saveCustomKeys(next))
.catch(() => {})
.finally(() => {
pendingCustomKeysWritesRef.current -= 1
})
}, [])
const refreshCustomKeys = useCallback(() => {
const refreshSeq = customKeysWriteSeqRef.current
void loadCustomKeys().then((keys) => {
if (pendingCustomKeysWritesRef.current > 0 || refreshSeq !== customKeysWriteSeqRef.current) {
return
}
setCustomKeys(keys)
})
}, [])
const handleDeleteCustomKey = useCallback(
(key: CustomKey) => {
setCustomKeys((current) => {
const updated = current.filter((k) => k.id !== key.id)
persistCustomKeys(updated)
return updated
})
},
[persistCustomKeys]
)
useFocusEffect(
useCallback(() => {
refreshShortcutLayout()
refreshCustomKeys()
}, [refreshShortcutLayout, refreshCustomKeys])
)
useEffect(() => {
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refreshShortcutLayout()
refreshCustomKeys()
}
})
return () => sub.remove()
}, [refreshShortcutLayout, refreshCustomKeys])
const toggleBuiltInKey = useCallback(
(id: string, visible: boolean) => {
setShortcutLayout((current) => {
const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
persistLayout(next)
return next
})
},
[persistLayout]
)
const reorderBuiltInKeys = useCallback(
(orderedKeys: string[]) => {
setShortcutLayout((current) => {
const next = reorderTerminalAccessoryBuiltInIds(current, orderedKeys)
persistLayout(next)
return next
})
},
[persistLayout]
)
const resetBuiltInKeys = useCallback(() => {
const next = getDefaultTerminalAccessoryLayout()
setShortcutLayout(next)
persistLayout(next)
}, [persistLayout])
const reorderCustomKeys = useCallback(
(orderedKeys: string[]) => {
setCustomKeys((current) => {
const byId = new Map(current.map((key) => [key.id, key]))
const reordered = orderedKeys.flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
if (reordered.length !== current.length) {
return current
}
persistCustomKeys(reordered)
return reordered
})
},
[persistCustomKeys]
)
const visibleBuiltInSet = useMemo(
() => new Set(shortcutLayout.visibleBuiltInIds),
[shortcutLayout.visibleBuiltInIds]
)
const orderedAccessoryKeys = useMemo(() => {
const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
}, [shortcutLayout.orderedBuiltInIds])
return (
<>
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<Text style={styles.groupDescription}>
Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on
the terminal shortcut bar.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<DragReorderList
items={orderedAccessoryKeys}
itemKey={(shortcutKey) => shortcutKey.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderBuiltInKeys}
renderRow={(shortcutKey) => (
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
)}
/>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>
Show every built-in shortcut key in the original order
</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<>
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
<View style={styles.separator} />
</>
) : (
<DragReorderList
items={customKeys}
itemKey={(key) => key.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderCustomKeys}
renderRow={(key) => (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
)}
/>
)}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut…</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
// Why: the modal already persisted this list; bumping the sequence
// discards refreshes that read storage before its save landed.
customKeysWriteSeqRef.current += 1
setCustomKeys(keys)
}}
/>
</>
)
}
const styles = StyleSheet.create({
groupHeading: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
lineHeight: 20,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
// Why: rows inside DragReorderList get a fixed height and a trailing grip
// handle from the list itself, so content only pads on the left.
reorderRowContent: {
flex: 1,
height: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingLeft: spacing.md + 2
},
rowContent: {
flex: 1
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSublabel: {
fontSize: typography.bodySize - 2,
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,
padding: spacing.md
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
})
+151
View File
@@ -0,0 +1,151 @@
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
import { Check, Download } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
isModelInFlight,
type MobileSpeechModel,
type MobileSpeechSetup
} from '../dictation/mobile-dictation-setup'
type Props = {
setup: MobileSpeechSetup
// Disabled mirrors desktop: the model list greys out when dictation is off.
disabled: boolean
busyModelId: string | null
onUseModel: (model: MobileSpeechModel) => void
onDownload: (model: MobileSpeechModel) => void
}
function formatSize(bytes: number | null): string {
if (!bytes) {
return ''
}
return `${Math.round(bytes / 1_000_000)} MB`
}
function modelMeta(model: MobileSpeechModel): string {
if (model.provider === 'openai') {
return 'OpenAI API'
}
const inFlight = isModelInFlight(model)
if (inFlight && model.progress != null) {
return `${formatSize(model.sizeBytes)} · ${Math.round(model.progress * 100)}%`
}
if (model.status === 'extracting') {
return `${formatSize(model.sizeBytes)} · extracting…`
}
return formatSize(model.sizeBytes)
}
// Renders the speech-model rows shared between the setup sheet and the Voice
// settings page: size/progress, recommended badge, selected check, download.
export function VoiceModelList({
setup,
disabled,
busyModelId,
onUseModel,
onDownload
}: Props): React.JSX.Element {
return (
<View style={disabled ? styles.disabled : undefined} pointerEvents={disabled ? 'none' : 'auto'}>
{setup.models.map((model, idx) => {
const isSelected = model.id === setup.selectedModelId
const inFlight = isModelInFlight(model)
const rowBusy = busyModelId === model.id
return (
<View key={model.id}>
{idx > 0 && <View style={styles.separator} />}
<View style={styles.modelRow}>
<View style={styles.modelInfo}>
<View style={styles.modelTitleRow}>
<Text style={styles.modelLabel}>{model.label}</Text>
{model.recommended ? <Text style={styles.recommended}>Recommended</Text> : null}
</View>
<Text style={styles.modelMeta}>{modelMeta(model)}</Text>
</View>
{model.provider === 'openai' ? (
<Text style={styles.modelStateText}>
{model.status === 'ready' ? 'API key set' : 'Set up on desktop'}
</Text>
) : model.status === 'ready' ? (
isSelected ? (
<View style={styles.selectedTag}>
<Check size={14} color={colors.statusGreen} strokeWidth={2.4} />
<Text style={styles.selectedText}>In use</Text>
</View>
) : (
<Pressable
style={({ pressed }) => [styles.actionButton, pressed && styles.actionPressed]}
disabled={rowBusy}
onPress={() => onUseModel(model)}
>
<Text style={styles.actionText}>Use</Text>
</Pressable>
)
) : inFlight ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.actionPressed]}
disabled={rowBusy}
onPress={() => onDownload(model)}
accessibilityLabel={'Download ' + model.label}
>
{rowBusy ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Download size={18} color={colors.textSecondary} strokeWidth={2.2} />
)}
</Pressable>
)}
</View>
</View>
)
})}
</View>
)
}
const styles = StyleSheet.create({
disabled: { opacity: 0.5 },
modelRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
modelInfo: { flex: 1, minWidth: 0 },
modelTitleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
modelLabel: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '500' },
recommended: { color: colors.statusGreen, fontSize: 10, fontWeight: '700' },
modelMeta: { color: colors.textMuted, fontSize: typography.metaSize, marginTop: 2 },
modelStateText: { color: colors.textMuted, fontSize: typography.metaSize },
actionButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
actionPressed: { opacity: 0.7 },
actionText: { color: colors.textSecondary, fontSize: typography.metaSize, fontWeight: '600' },
iconButton: {
width: 34,
height: 34,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
selectedTag: { flexDirection: 'row', alignItems: 'center', gap: 4 },
selectedText: { color: colors.statusGreen, fontSize: typography.metaSize, fontWeight: '600' },
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
}
})
@@ -0,0 +1,39 @@
import { useMemo } from 'react'
import { StyleSheet, View } from 'react-native'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { flattenAgentRowLineage } from '../worktree/agent-row-lineage'
import { WorktreeAgentRow } from './WorktreeAgentRow'
type Props = {
agents: RuntimeWorktreeAgentRow[]
now: number
unvisited: boolean
}
// Inline agent list for one worktree row: flattens the spawn lineage and renders
// a depth-indented WorktreeAgentRow per agent, mirroring the desktop sidebar's
// WorktreeCardAgents.
export function WorktreeAgentList({ agents, now, unvisited }: Props) {
// Why: rebuild the lineage tree only when the agent list changes, not on every
// re-render (the shared useNow tick re-renders this list every 30s).
const nodes = useMemo(() => flattenAgentRowLineage(agents), [agents])
return (
<View style={styles.list}>
{nodes.map((node) => (
<WorktreeAgentRow
key={node.row.paneKey}
agent={node.row}
depth={node.depth}
now={now}
unvisited={unvisited}
/>
))}
</View>
)
}
const styles = StyleSheet.create({
list: {
marginTop: 3
}
})
@@ -0,0 +1,60 @@
import { StyleSheet, Text, View } from 'react-native'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { colors, spacing } from '../theme/mobile-theme'
import { agentDisplayLabel, agentDotState, formatTimeAgo } from '../worktree/agent-row-display'
import { AgentStateDot } from './AgentStateDot'
import { MobileAgentIcon } from './MobileAgentIcon'
const INDENT_PER_DEPTH = 14
type Props = {
agent: RuntimeWorktreeAgentRow
depth: number
now: number
// Bold/foreground until the user has visited the worktree, mirroring desktop's
// unvisited rule (the workspace title and its agent rows share one signal).
unvisited: boolean
}
// One inline agent row: state dot → identity → last message/prompt → time ago.
// Mirrors desktop DashboardAgentRow's compact in-card layout.
export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
const dotState = agentDotState(agent, now)
const label = agentDisplayLabel(agent, now)
const ts = formatTimeAgo(agent.stateStartedAt, now)
return (
<View style={[styles.row, { paddingLeft: depth * INDENT_PER_DEPTH }]}>
<AgentStateDot state={dotState} />
{/* Agent identity logo (Claude/Codex/…), matching the desktop sidebar's
agent icons instead of a two-letter text code. */}
{agent.agentType ? <MobileAgentIcon agentId={agent.agentType} size={13} /> : null}
<Text style={[styles.label, unvisited && styles.labelUnvisited]} numberOfLines={1}>
{label}
</Text>
<Text style={styles.time}>{ts}</Text>
</View>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: 3
},
label: {
flex: 1,
fontSize: 11,
color: colors.textMuted
},
labelUnvisited: {
color: colors.textPrimary,
fontWeight: '600'
},
time: {
fontSize: 10,
color: colors.textMuted
}
})
+239
View File
@@ -0,0 +1,239 @@
import { Bell, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { triggerMediumImpact } from '../platform/haptics'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { AgentSpinner } from './AgentSpinner'
import { MobileRepoIcon } from './MobileRepoIcon'
import { WorktreeAgentList } from './WorktreeAgentList'
import { WorktreeMetaGlyphs, prStateColor } from './WorktreeMetaGlyphs'
// Strip the refs/heads/ prefix for display, matching the desktop sidebar
// (WorktreeCardHelpers.formatBranchName).
function displayBranch(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
// Minimal row shape needed for rendering — a structural subset of the screen's
// Worktree so this component stays decoupled from the screen's local type.
export type WorktreeListRowItem = {
worktreeId: string
repo: string
branch: string
displayName: string
liveTerminalCount: number
preview: string
unread: boolean
isActive?: boolean
linkedPR: { number: number; state: string } | null
linkedIssue?: number | null
linkedLinearIssue?: string | null
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
agents?: RuntimeWorktreeAgentRow[]
}
type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
type Props<T extends WorktreeListRowItem> = {
item: T
isReadOnly: boolean
now: number
repoColor: string
repoIcon?: RepoIcon | null
// When the list is already grouped under this repo's section header, the row
// omits its own repo icon+name to avoid the redundant "📁 orca" on every row.
hideRepo?: boolean
status: WorktreeRollupStatus
onPress: (item: T) => void
onLongPress: (item: T) => void
}
export function WorktreeListRow<T extends WorktreeListRowItem>({
item,
isReadOnly,
now,
repoColor,
repoIcon,
hideRepo = false,
status,
onPress,
onLongPress
}: Props<T>) {
return (
<Pressable
style={({ pressed }) => [
styles.worktreeRow,
item.isActive && styles.worktreeRowActive,
pressed && styles.worktreeRowPressed
]}
disabled={isReadOnly}
onPress={() => onPress(item)}
onLongPress={() => {
triggerMediumImpact()
onLongPress(item)
}}
delayLongPress={400}
>
<View style={styles.indicatorCol}>
<AgentSpinner status={status} />
{item.unread && (
<Bell
size={10}
color={colors.statusAmber}
fill={colors.statusAmber}
style={styles.unreadBell}
/>
)}
</View>
<View style={styles.worktreeMain}>
<View style={styles.worktreeNameRow}>
<Text
style={[
styles.worktreeName,
item.unread && styles.worktreeNameUnread,
isReadOnly && styles.textReadOnly
]}
numberOfLines={1}
>
{item.displayName || item.repo}
</Text>
{item.linkedPR && (
<View style={styles.prBadge}>
<GitPullRequest size={10} color={prStateColor(item.linkedPR.state)} />
<Text style={[styles.prNumber, { color: prStateColor(item.linkedPR.state) }]}>
#{item.linkedPR.number}
</Text>
</View>
)}
<WorktreeMetaGlyphs
comment={item.comment}
linkedLinearIssue={item.linkedLinearIssue}
linkedGitLabMR={item.linkedGitLabMR}
linkedIssue={item.linkedIssue}
linkedGitLabIssue={item.linkedGitLabIssue}
/>
</View>
<View style={styles.worktreeMetaRow}>
{/* Repo glyph+name only when not already grouped under this repo;
MobileRepoIcon falls back to a Folder (matching desktop's default)
rather than a bare colored dot. */}
{!hideRepo && (
<>
<MobileRepoIcon repoIcon={repoIcon} size={11} color={repoColor} />
<Text style={styles.repoName} numberOfLines={1}>
{item.repo}
</Text>
</>
)}
<Text style={styles.branchName} numberOfLines={1}>
{displayBranch(item.branch)}
</Text>
</View>
{/* Only agents get a secondary activity line, matching desktop. A plain
terminal's shell-output tail is intentionally not surfaced here. */}
{item.agents && item.agents.length > 0 ? (
<WorktreeAgentList agents={item.agents} now={now} unvisited={item.unread} />
) : null}
</View>
{item.liveTerminalCount > 0 && (
<Text style={styles.terminalCount}>{item.liveTerminalCount}</Text>
)}
</Pressable>
)
}
const styles = StyleSheet.create({
worktreeRow: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: spacing.sm + 2,
paddingHorizontal: spacing.lg,
// Reserve the active accent bar width so active/inactive rows align.
borderLeftWidth: 2,
borderLeftColor: 'transparent'
},
worktreeRowPressed: {
backgroundColor: colors.bgRaised
},
// Highlight the worktree currently focused on the desktop, mirroring the
// desktop sidebar's selected-card treatment (raised fill + left accent).
worktreeRowActive: {
backgroundColor: colors.bgPanel,
// Neutral grey accent, matching the desktop's active-tab indicator rather
// than a blue line.
borderLeftColor: colors.textSecondary
},
indicatorCol: {
width: 20,
alignItems: 'center',
paddingTop: 6,
marginRight: spacing.sm,
gap: 4
},
unreadBell: {
marginTop: 2
},
worktreeMain: {
flex: 1,
marginRight: spacing.sm
},
worktreeNameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
worktreeName: {
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
flexShrink: 1
},
worktreeNameUnread: {
fontWeight: '700'
},
textReadOnly: {
opacity: 0.5
},
prBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
backgroundColor: colors.bgRaised,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4
},
prNumber: {
fontSize: 10,
color: colors.textSecondary
},
worktreeMetaRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
gap: spacing.xs
},
repoName: {
fontSize: 11,
color: colors.textSecondary,
maxWidth: 100
},
branchName: {
fontSize: 11,
color: colors.textMuted,
fontFamily: typography.monoFamily,
flexShrink: 1
},
terminalCount: {
fontSize: typography.metaSize,
color: colors.textMuted,
minWidth: 16,
textAlign: 'right',
paddingTop: 3
}
})
@@ -0,0 +1,68 @@
import { CircleDot, GitMerge, StickyNote } from 'lucide-react-native'
import { StyleSheet, Text, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
// PR chip color by state, mirroring the desktop ReviewIcon palette: merged =
// purple, open = green, closed = red, draft/unknown = muted.
export function prStateColor(state: string): string {
const s = state.toLowerCase()
if (s === 'merged') {
return '#a78bfa'
}
if (s === 'open') {
return colors.statusGreen
}
if (s === 'closed') {
return colors.statusRed
}
return colors.textSecondary
}
type Props = {
comment?: string | null
linkedLinearIssue?: string | null
linkedGitLabMR?: number | null
linkedIssue?: number | null
linkedGitLabIssue?: number | null
}
// Presence glyphs for linked notes / Linear / GitLab MR / issue, matching the
// desktop WorktreeCardMetaBadges row. Mobile shows presence only; the full
// detail (title/state/labels) is a follow-up detail sheet.
export function WorktreeMetaGlyphs({
comment,
linkedLinearIssue,
linkedGitLabMR,
linkedIssue,
linkedGitLabIssue
}: Props) {
const hasNotes = (comment ?? '').trim().length > 0
const hasLinear = Boolean(linkedLinearIssue)
const hasGitLabMR = linkedGitLabMR != null
const hasIssue = linkedIssue != null || linkedGitLabIssue != null
if (!hasNotes && !hasLinear && !hasGitLabMR && !hasIssue) {
return null
}
return (
<View style={styles.metaGlyphs}>
{hasNotes && <StickyNote size={11} color={colors.textMuted} />}
{hasIssue && <CircleDot size={11} color={colors.textMuted} />}
{hasLinear && <Text style={styles.linearGlyph}>L</Text>}
{hasGitLabMR && <GitMerge size={11} color={colors.textMuted} />}
</View>
)
}
const styles = StyleSheet.create({
metaGlyphs: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
marginLeft: 2
},
linearGlyph: {
fontSize: 10,
fontWeight: '700',
color: colors.textMuted
}
})
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import {
getInactiveProviderUsage,
getUsageBarState,
hasActiveProviderUsage,
hasRenderableUsage,
type AccountsSnapshot,
type InactiveAccountUsage,
type ProviderRateLimits
} from './account-usage-state'
function makeLimits(overrides: Partial<ProviderRateLimits> = {}): ProviderRateLimits {
return {
provider: 'claude',
session: null,
weekly: null,
monthly: null,
updatedAt: 0,
error: null,
status: 'idle',
...overrides
}
}
function makeSnapshot(
overrides: {
claudeLimits?: ProviderRateLimits | null
codexLimits?: ProviderRateLimits | null
claudeAccounts?: AccountsSnapshot['claude']['accounts']
codexAccounts?: AccountsSnapshot['codex']['accounts']
inactiveClaudeAccounts?: InactiveAccountUsage[]
inactiveCodexAccounts?: InactiveAccountUsage[]
} = {}
): AccountsSnapshot {
return {
claude: { accounts: overrides.claudeAccounts ?? [], activeAccountId: null },
codex: { accounts: overrides.codexAccounts ?? [], activeAccountId: null },
rateLimits: {
claude: overrides.claudeLimits ?? null,
codex: overrides.codexLimits ?? null,
inactiveClaudeAccounts: overrides.inactiveClaudeAccounts ?? [],
inactiveCodexAccounts: overrides.inactiveCodexAccounts ?? []
}
}
}
describe('hasActiveProviderUsage', () => {
it('is false when there are no rate limits at all', () => {
expect(hasActiveProviderUsage(null)).toBe(false)
})
it('is true when a session window has data', () => {
expect(
hasActiveProviderUsage(
makeLimits({
status: 'ok',
session: { usedPercent: 12, windowMinutes: 300, resetsAt: null, resetDescription: null }
})
)
).toBe(true)
})
it('is true when a successful fetch returned ok even with empty windows', () => {
expect(hasActiveProviderUsage(makeLimits({ status: 'ok' }))).toBe(true)
})
it('is false for an unavailable/error provider with no window data (no creds)', () => {
expect(hasActiveProviderUsage(makeLimits({ status: 'unavailable' }))).toBe(false)
expect(hasActiveProviderUsage(makeLimits({ status: 'error', error: 'nope' }))).toBe(false)
})
})
describe('hasRenderableUsage', () => {
it('is true when the provider has at least one managed account', () => {
const snapshot = makeSnapshot({
claudeAccounts: [{ id: 'a', email: 'x@y.z' }]
})
expect(hasRenderableUsage(snapshot, 'claude')).toBe(true)
})
// The bug: system-default auth has zero managed accounts but real usage data,
// and the home screen used to hide it entirely.
it('is true with zero managed accounts when active rate-limit data exists (system default)', () => {
const snapshot = makeSnapshot({
codexLimits: makeLimits({
provider: 'codex',
status: 'ok',
session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null }
})
})
expect(hasRenderableUsage(snapshot, 'codex')).toBe(true)
})
it('is false with zero accounts and no usable rate-limit data', () => {
const snapshot = makeSnapshot({
claudeLimits: makeLimits({ status: 'unavailable' })
})
expect(hasRenderableUsage(snapshot, 'claude')).toBe(false)
expect(hasRenderableUsage(makeSnapshot(), 'claude')).toBe(false)
})
})
describe('getInactiveProviderUsage', () => {
it('returns inactive usage using the runtime rateLimits payload shape', () => {
const limits = makeLimits({
status: 'ok',
session: { usedPercent: 52, windowMinutes: 300, resetsAt: null, resetDescription: null }
})
const snapshot = makeSnapshot({
inactiveClaudeAccounts: [
{ accountId: 'account-1', rateLimits: limits, updatedAt: 123, isFetching: false }
]
})
expect(getInactiveProviderUsage(snapshot, 'claude', 'account-1')?.rateLimits).toBe(limits)
})
})
describe('getUsageBarState', () => {
it('keeps stale window data visible during a transient error', () => {
const bar = getUsageBarState(
makeLimits({
status: 'error',
error: 'temporarily unavailable',
session: { usedPercent: 72, windowMinutes: 300, resetsAt: null, resetDescription: null }
}),
'session'
)
expect(bar).toEqual({ usedPercent: 72, unavailable: false, loading: false })
})
it('shows loading for a fetching provider without a window', () => {
expect(getUsageBarState(makeLimits({ status: 'fetching' }), 'weekly')).toEqual({
usedPercent: null,
unavailable: false,
loading: true
})
})
})
@@ -0,0 +1,129 @@
// Why: keep these shapes in lockstep with src/shared/types.ts and
// src/shared/rate-limit-types.ts. We don't import from desktop here because
// the mobile bundle must not pull in Electron-coupled type files.
//
// Pure state/selectors live here (no React Native imports) so they can be
// unit-tested directly; AccountUsage.tsx re-exports them alongside the
// UsageBar component.
export type RateLimitWindow = {
usedPercent: number
windowMinutes: number
resetsAt: number | null
resetDescription: string | null
}
export type ProviderRateLimits = {
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
session: RateLimitWindow | null
weekly: RateLimitWindow | null
monthly?: RateLimitWindow | null
buckets?: Array<RateLimitWindow & { name: string }>
updatedAt: number
error: string | null
status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
}
export type InactiveAccountUsage = {
accountId: string
rateLimits: ProviderRateLimits | null
updatedAt: number
isFetching: boolean
}
export type ClaudeAccountSummary = {
id: string
email: string
organizationName?: string | null
}
export type CodexAccountSummary = {
id: string
email: string
workspaceLabel?: string | null
}
export type AccountsSnapshot = {
claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
rateLimits: {
claude: ProviderRateLimits | null
codex: ProviderRateLimits | null
inactiveClaudeAccounts: InactiveAccountUsage[]
inactiveCodexAccounts: InactiveAccountUsage[]
}
}
export type ProviderKey = 'claude' | 'codex'
export type UsageBarState = {
usedPercent: number | null
unavailable: boolean
loading: boolean
}
export function getActiveProviderRateLimits(
snapshot: AccountsSnapshot,
provider: ProviderKey
): ProviderRateLimits | null {
return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
}
export function getInactiveProviderUsage(
snapshot: AccountsSnapshot,
provider: ProviderKey,
accountId: string
): InactiveAccountUsage | null {
const list =
provider === 'claude'
? snapshot.rateLimits.inactiveClaudeAccounts
: snapshot.rateLimits.inactiveCodexAccounts
return list.find((u) => u.accountId === accountId) ?? null
}
// Why: rate limits are fetched for the active target even when no Orca-managed
// account exists (the default target is the agent's own system-default login).
// Treat a provider as having usage worth showing when a fetch succeeded or any
// window has data; an unavailable/error provider with no windows means the
// system-default login has no credentials for it, so there is nothing to show.
export function hasActiveProviderUsage(limits: ProviderRateLimits | null): boolean {
if (!limits) {
return false
}
if (
limits.session != null ||
limits.weekly != null ||
limits.monthly != null ||
(limits.buckets && limits.buckets.length > 0)
) {
return true
}
return limits.status === 'ok'
}
// Why: transient errors keep the last successful window data, so availability
// is per window rather than per provider status.
export function getUsageBarState(
limits: ProviderRateLimits | null,
windowKey: 'session' | 'weekly',
isFetchingOverride?: boolean
): UsageBarState {
const window = limits?.[windowKey] ?? null
const fetching =
isFetchingOverride ?? (limits?.status === 'fetching' || limits?.status === 'idle')
return {
usedPercent: window?.usedPercent ?? null,
unavailable: window == null && !fetching,
loading: fetching && window == null
}
}
// Why: the usage UI must render for the system-default login, not only for
// Orca-managed accounts. Show a provider when it has at least one managed
// account OR active rate-limit data for the system-default target.
export function hasRenderableUsage(snapshot: AccountsSnapshot, provider: ProviderKey): boolean {
const accounts = provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts
if (accounts.length > 0) {
return true
}
return hasActiveProviderUsage(getActiveProviderRateLimits(snapshot, provider))
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
clampDragReorderIndex,
dragReorderPositionsFromKeys,
moveDragReorderKey,
orderedKeysFromDragReorderPositions
} from './drag-reorder-positions'
describe('drag reorder positions', () => {
it('round-trips keys through positions', () => {
const keys = ['escape', 'tab', 'enter']
expect(orderedKeysFromDragReorderPositions(dragReorderPositionsFromKeys(keys))).toEqual(keys)
})
it('clamps drag indexes to the list bounds', () => {
expect(clampDragReorderIndex(-2, 3)).toBe(0)
expect(clampDragReorderIndex(1, 3)).toBe(1)
expect(clampDragReorderIndex(7, 3)).toBe(2)
expect(clampDragReorderIndex(0, 0)).toBe(0)
})
it('shifts intermediate rows down when dragging a row later', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'a', 2))).toEqual([
'b',
'c',
'a',
'd'
])
})
it('shifts intermediate rows up when dragging a row earlier', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'd', 1))).toEqual([
'a',
'd',
'b',
'c'
])
})
it('returns the same positions for no-op or unknown moves', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b'])
expect(moveDragReorderKey(positions, 'a', 0)).toBe(positions)
expect(moveDragReorderKey(positions, 'missing', 1)).toBe(positions)
})
})
@@ -0,0 +1,54 @@
// Index math for DragReorderList. Kept worklet-safe (no captures, plain
// objects) because moveDragReorderKey runs on the UI thread during a drag.
export type DragReorderPositions = Record<string, number>
export function dragReorderPositionsFromKeys(keys: string[]): DragReorderPositions {
'worklet'
const positions: DragReorderPositions = {}
for (let i = 0; i < keys.length; i++) {
positions[keys[i]!] = i
}
return positions
}
export function orderedKeysFromDragReorderPositions(positions: DragReorderPositions): string[] {
'worklet'
const keys = Object.keys(positions)
keys.sort((a, b) => positions[a]! - positions[b]!)
return keys
}
export function clampDragReorderIndex(index: number, count: number): number {
'worklet'
if (count <= 0) {
return 0
}
return Math.min(Math.max(index, 0), count - 1)
}
export function moveDragReorderKey(
positions: DragReorderPositions,
key: string,
toIndex: number
): DragReorderPositions {
'worklet'
const fromIndex = positions[key]
if (fromIndex === undefined || fromIndex === toIndex) {
return positions
}
const next: DragReorderPositions = {}
for (const currentKey of Object.keys(positions)) {
const position = positions[currentKey]!
if (currentKey === key) {
next[currentKey] = toIndex
} else if (fromIndex < toIndex && position > fromIndex && position <= toIndex) {
next[currentKey] = position - 1
} else if (toIndex < fromIndex && position >= toIndex && position < fromIndex) {
next[currentKey] = position + 1
} else {
next[currentKey] = position
}
}
return next
}

Some files were not shown because too many files have changed in this diff Show More