mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
Merge branch 'nwparker/term-speed-2-skip-grammar-removal' into nwparker/term-speed-2-architecture-docs
This commit is contained in:
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
}
|
||||
|
||||
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 \
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<p 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 & Linear, Native
|
||||
|
||||
Browse PRs, issues, and project boards in-app — open a worktree from any task and review without a context switch.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/linear)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="docs/assets/feature-wall/github-linear.gif" type="image/gif"><img src="docs/assets/feature-wall/github-linear.jpg" alt="GitHub and Linear task workflows in Orca" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### SSH Worktrees
|
||||
|
||||
Run agents on a beefy remote box with full file editing, git, and terminals — auto-reconnect and port forwarding included.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/ssh)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="docs/assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/ssh-worktrees.jpg" alt="Remote worktrees over SSH" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Annotate AI Diffs
|
||||
|
||||
Drop comments on any diff line and ship them back to the agent — review, edit, and commit without leaving Orca.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="docs/assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="docs/assets/feature-wall/annotate-diff.jpg" alt="Annotate AI-generated diffs" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Drag Files to Agents
|
||||
|
||||
VS Code's editor with autosave everywhere — drag files or images straight into an agent prompt.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="docs/assets/feature-wall/file-drag.gif" type="image/gif"><img src="docs/assets/feature-wall/file-drag.jpg" alt="Drag files and images into an agent prompt" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Orca CLI
|
||||
|
||||
Agents drive Orca too — script every workflow with `orca worktree create`, `snapshot`, `click`, and `fill`.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Also in the box:**
|
||||
|
||||
- **[Quick open](https://www.onorca.dev/docs/model/quick-open)** — Search across worktrees, files, agents, commands, and repo context without leaving your flow.
|
||||
- **[Account switcher & usage tracking](https://www.onorca.dev/docs/agents/usage-tracking)** — See Claude and Codex usage and rate-limit resets, and hot-swap accounts without re-logging in.
|
||||
- **[Rich repo previews](https://www.onorca.dev/docs/editing/markdown)** — Preview Markdown, images, PDFs, and repo docs in the workspace.
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Let agents operate desktop apps and visible UI when a workflow needs real interaction.
|
||||
- **[Notifications and unread state](https://www.onorca.dev/docs/notifications)** — Know when an agent finishes or needs attention, then mark threads unread to come back later.
|
||||
- **And many, many more** — we ship daily, so this list is perpetually behind. The [changelog](https://github.com/stablyai/orca/releases) is the real feature list.
|
||||
|
||||
---
|
||||
|
||||
## Supported Agents
|
||||
|
||||
Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://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>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ any CLI agent</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
### Mac, Linux, Windows
|
||||
### Desktop — macOS, Windows, Linux
|
||||
|
||||
- **[Download from onOrca.dev](https://onOrca.dev)**
|
||||
- Or via **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**
|
||||
- **[Download from onOrca.dev](https://onorca.dev/download)**
|
||||
- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
|
||||
|
||||
_Alternatively, install from a package manager:_
|
||||
|
||||
### macOS (Homebrew)
|
||||
_Or via a package manager:_
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Precompiled binary
|
||||
# Arch Linux (AUR) — or stably-orca-git to build from source
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# Build from GitHub source
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
### Mobile Companion — iOS, Android
|
||||
|
||||
## Mobile Companion App
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
Control your agents from your phone.
|
||||
|
||||
<p align="center">
|
||||
<picture><source srcset="docs/assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="docs/assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop with the mobile companion app" width="720" /></picture>
|
||||
</p>
|
||||
|
||||
- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Download APK from GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## Feature Showcase
|
||||
|
||||
Click any tile to explore the workflow.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Parallel Worktrees</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/parallel-worktrees.jpg" alt="Parallel worktree orchestration" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminal Splits</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="docs/assets/feature-wall/terminal-splits.jpg" alt="Ghostty-class terminal splits" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Design Mode</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/design-mode.gif" type="image/gif"><img src="docs/assets/feature-wall/design-mode.jpg" alt="Embedded browser and Design Mode" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub & Linear, Native</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/github-linear.gif" type="image/gif"><img src="docs/assets/feature-wall/github-linear.jpg" alt="GitHub and Linear task workflows in Orca" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Every CLI Agent</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/cli-agents.gif" type="image/gif"><img src="docs/assets/feature-wall/cli-agents.jpg" alt="Works with every CLI agent" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktrees</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="docs/assets/feature-wall/ssh-worktrees.jpg" alt="Remote worktrees over SSH" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Drag Files to Agents</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/file-drag.gif" type="image/gif"><img src="docs/assets/feature-wall/file-drag.jpg" alt="Drag files and images into an agent prompt" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Annotate AI Diffs</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="docs/assets/feature-wall/annotate-diff.jpg" alt="Annotate AI-generated diffs" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/orca-cli.gif" type="image/gif"><img src="docs/assets/feature-wall/orca-cli.jpg" alt="Script Orca from the CLI" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Native Search</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="docs/assets/feature-wall/keyboard-native.jpg" alt="Native search across Orca workflows" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Account Switcher & Usage Tracking</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="docs/assets/feature-wall/codex-accounts.jpg" alt="Account switching and usage tracking" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Rich Repo Previews</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="docs/assets/feature-wall/markdown-editor.jpg" alt="Markdown, images, PDFs, and repo document previews" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Split Anything</strong><br/><br/><picture><source srcset="docs/assets/feature-wall/split-screen.gif" type="image/gif"><img src="docs/assets/feature-wall/split-screen.jpg" alt="Split panes for agents, terminals, browsers, and files" width="390" /></picture><br/></kbd></a>
|
||||
</p>
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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 & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
- **Show Support:** Star this repo to follow along with our daily ships.
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
- **Show Support:** [Star](https://github.com/stablyai/orca) this repo to follow along with our daily ships.
|
||||
|
||||
---
|
||||
|
||||
## Developing
|
||||
|
||||
Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTING.md) guide.
|
||||
|
||||
<a href="https://github.com/stablyai/orca/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
Orca is free and open source under the [MIT License](LICENSE).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 アカウント'
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 を含むフォルダー')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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).':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(', ')}`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Características
|
||||
|
||||
- **Sin login** — Usa tu propia suscripción de Claude Code, OpenClaude, Codex, Grok o Antigravity.
|
||||
- **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante.
|
||||
- **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
|
||||
- **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca.
|
||||
- **Integración con GitHub** — PRs, issues y checks de Actions vinculados automáticamente a cada worktree.
|
||||
- **Soporte SSH** — Conéctate a máquinas remotas y ejecuta agentes en ellas directamente desde Orca.
|
||||
- **Notificaciones** — Entérate cuando un agente termine o necesite tu atención. Marca hilos como no leídos para retomarlos después.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### App companion móvil
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ any CLI agent</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Instalación
|
||||
|
||||
### Mac, Linux, Windows
|
||||
### Escritorio — macOS, Windows, Linux
|
||||
|
||||
- **[Descarga desde onOrca.dev](https://onOrca.dev)**
|
||||
- O desde la **[página de GitHub Releases](https://github.com/stablyai/orca/releases/latest)**
|
||||
- **[Descarga desde onOrca.dev](https://onorca.dev/download)**
|
||||
- O descarga un build directamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos los builds](https://github.com/stablyai/orca/releases/latest)
|
||||
|
||||
_También puedes instalar desde un gestor de paquetes:_
|
||||
|
||||
### macOS (Homebrew)
|
||||
_O mediante un gestor de paquetes:_
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Binario precompilado
|
||||
# Arch Linux (AUR) — or stably-orca-git to build from source
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# Compilar desde el código de GitHub
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
### App companion móvil — iOS, Android
|
||||
|
||||
## App companion móvil
|
||||
|
||||
Controla tus agentes desde el teléfono.
|
||||
|
||||
<p align="center">
|
||||
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca de escritorio con la app companion móvil" width="720" /></picture>
|
||||
</p>
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar APK desde GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## Showcase de funciones
|
||||
|
||||
Haz clic en cualquier tarjeta para explorar el flujo de trabajo.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>Worktrees en paralelo</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestación de worktrees en paralelo" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>Terminales divididas</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminales divididas de nivel Ghostty" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>Modo diseño</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado y modo diseño" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub y Linear nativos</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Flujos de GitHub y Linear en Orca" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>Cualquier agente CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="Compatible con cualquier agente CLI" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>Worktrees por SSH</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>Archivos a agentes</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arrastra archivos e imágenes al prompt de un agente" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>Anotar diffs de IA</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs generados por IA" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatiza Orca desde la CLI" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>Búsqueda nativa</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Búsqueda nativa en los flujos de Orca" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>Cambio de cuenta y seguimiento de uso</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="Cambio de cuenta y seguimiento de uso" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>Previews ricos del repo</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Previsualización de Markdown, imágenes, PDFs y documentos del repo" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>Divide cualquier cosa</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="Paneles divididos para agentes, terminales, navegadores y archivos" width="390" /></picture><br/></kbd></a>
|
||||
</p>
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
|
||||
- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
|
||||
- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
|
||||
- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
|
||||
- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
|
||||
- **GitHub 連携** — PR、Issue、Actions チェックが各ワークツリーに自動で紐づきます。
|
||||
- **SSH サポート** — リモートマシンに接続し、Orca から直接エージェントを実行できます。
|
||||
- **通知** — エージェントが完了したときや注意が必要なときに通知します。スレッドを未読にして後で戻ることもできます。
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### モバイル Companion
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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 & Linear をネイティブに
|
||||
|
||||
PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意のタスクからワークツリーを開き、コンテキストスイッチなしでレビューできます。
|
||||
|
||||
[ドキュメント →](https://www.onorca.dev/docs/review/linear)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear タスクワークフロー" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### SSH ワークツリー
|
||||
|
||||
強力なリモートマシン上でエージェントを実行 — ファイル編集、git、ターミナルをフルに使え、自動再接続とポートフォワーディングも付属します。
|
||||
|
||||
[ドキュメント →](https://www.onorca.dev/docs/ssh)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### AI Diff に注釈
|
||||
|
||||
任意の Diff 行にコメントを付けてエージェントへ送り返せます — Orca から離れずにレビュー、編集、コミットまで完結します。
|
||||
|
||||
[ドキュメント →](https://www.onorca.dev/docs/review/annotate-ai-diff)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### ファイルをエージェントへドラッグ
|
||||
|
||||
オートセーブが全面的に効く VS Code のエディタ — ファイルや画像をそのままエージェントのプロンプトへドラッグできます。
|
||||
|
||||
[ドキュメント →](https://www.onorca.dev/docs/editing/file-explorer)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Orca CLI
|
||||
|
||||
エージェント自身も Orca を操作できます — `orca worktree create`、`snapshot`、`click`、`fill` であらゆるワークフローをスクリプト化できます。
|
||||
|
||||
[ドキュメント →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**さらに同梱:**
|
||||
|
||||
- **[クイックオープン](https://www.onorca.dev/docs/model/quick-open)** — フローを離れずに、ワークツリー、ファイル、エージェント、コマンド、リポジトリコンテキストを横断検索できます。
|
||||
- **[アカウント切り替えと使用量トラッキング](https://www.onorca.dev/docs/agents/usage-tracking)** — Claude と Codex の使用量やレート制限のリセットを確認し、再ログインなしでアカウントを切り替えられます。
|
||||
- **[リッチなリポジトリプレビュー](https://www.onorca.dev/docs/editing/markdown)** — Markdown、画像、PDF、リポジトリ文書をワークスペース内でプレビューできます。
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 実際の操作が必要なワークフローでは、エージェントにデスクトップアプリや画面上の UI を操作させられます。
|
||||
- **[通知と未読ステータス](https://www.onorca.dev/docs/notifications)** — エージェントの完了や要対応をすぐに把握し、スレッドを未読に戻して後で確認できます。
|
||||
- **その他、まだまだたくさん** — 毎日リリースしているので、このリストは常に追いついていません。本当の機能一覧は[チェンジログ](https://github.com/stablyai/orca/releases)です。
|
||||
|
||||
---
|
||||
|
||||
## 対応するエージェント
|
||||
|
||||
**あらゆる CLI エージェント**で動作します — ターミナルで動くものなら、Orca でも動きます。
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ any CLI agent</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## インストール
|
||||
|
||||
### Mac, Linux, Windows
|
||||
### デスクトップ — macOS, Windows, Linux
|
||||
|
||||
- **[onOrca.dev からダウンロード](https://onOrca.dev)**
|
||||
- または **[GitHub Releases ページ](https://github.com/stablyai/orca/releases/latest)** から入手
|
||||
- **[onOrca.dev からダウンロード](https://onorca.dev/download)**
|
||||
- またはビルドを直接入手: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [すべてのビルド](https://github.com/stablyai/orca/releases/latest)
|
||||
|
||||
_パッケージマネージャーからもインストールできます:_
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# ビルド済みバイナリ
|
||||
# Arch Linux (AUR) — or stably-orca-git to build from source
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# GitHub ソースからビルド
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
### モバイル Companion — iOS, Android
|
||||
|
||||
## モバイル Companion アプリ
|
||||
|
||||
スマートフォンからエージェントを操作できます。
|
||||
|
||||
<p align="center">
|
||||
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca デスクトップとモバイル companion アプリ" width="720" /></picture>
|
||||
</p>
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [GitHub Releases から APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## 機能ショーケース
|
||||
|
||||
各タイルをクリックすると、そのワークフローを確認できます。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>並列ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="並列ワークツリーのオーケストレーション" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>ターミナル分割</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty クラスのターミナル分割" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>デザインモード</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="組み込みブラウザとデザインモード" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub と Linear をネイティブに</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca の GitHub と Linear ワークフロー" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意の CLI エージェント</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="任意の CLI エージェントに対応" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH ワークツリー</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH 経由のリモートワークツリー" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>ファイルをエージェントへ</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="ファイルや画像をエージェントのプロンプトへドラッグ" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 注釈</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI が生成した Diff への注釈" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI から Orca をスクリプト操作" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>ネイティブ検索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca ワークフロー全体のネイティブ検索" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>アカウント切り替えと使用量トラッキング</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="アカウント切り替えと使用量トラッキング" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>リッチなリポジトリプレビュー</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、画像、PDF、リポジトリ文書のプレビュー" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>何でも分割表示</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="エージェント、ターミナル、ブラウザ、ファイルの分割表示" width="390" /></picture><br/></kbd></a>
|
||||
</p>
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 기능
|
||||
|
||||
- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
|
||||
- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다.
|
||||
- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다.
|
||||
- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다.
|
||||
- **GitHub 통합** — PR, issue, Actions 체크가 각 worktree에 자동으로 연결됩니다.
|
||||
- **SSH 지원** — 원격 머신에 연결하고 Orca에서 직접 에이전트를 실행할 수 있습니다.
|
||||
- **알림** — 에이전트가 완료되거나 주의가 필요할 때 알려줍니다. 스레드를 읽지 않음으로 표시해 나중에 다시 볼 수 있습니다.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### 모바일 Companion
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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 & Linear 네이티브
|
||||
|
||||
PR, issue, 프로젝트 보드를 앱 안에서 탐색하세요 — 어떤 작업에서든 worktree를 열고 컨텍스트 전환 없이 리뷰할 수 있습니다.
|
||||
|
||||
[문서 →](https://www.onorca.dev/docs/review/linear)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 작업 워크플로" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### SSH Worktree
|
||||
|
||||
강력한 원격 머신에서 에이전트를 실행하세요. 파일 편집, git, 터미널을 모두 지원하며 자동 재연결과 포트 포워딩도 포함됩니다.
|
||||
|
||||
[문서 →](https://www.onorca.dev/docs/ssh)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### AI Diff 주석
|
||||
|
||||
diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내세요 — Orca를 떠나지 않고 리뷰하고 수정하고 커밋할 수 있습니다.
|
||||
|
||||
[문서 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### 에이전트로 파일 드래그
|
||||
|
||||
어디서나 자동 저장되는 VS Code 에디터 — 파일이나 이미지를 에이전트 프롬프트로 바로 드래그하세요.
|
||||
|
||||
[문서 →](https://www.onorca.dev/docs/editing/file-explorer)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Orca CLI
|
||||
|
||||
에이전트도 Orca를 조작할 수 있습니다 — `orca worktree create`, `snapshot`, `click`, `fill`로 모든 워크플로를 스크립팅하세요.
|
||||
|
||||
[문서 →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**그 밖에 기본으로 제공되는 기능:**
|
||||
|
||||
- **[빠른 열기](https://www.onorca.dev/docs/model/quick-open)** — 작업 흐름을 벗어나지 않고 worktree, 파일, 에이전트, 커맨드, 리포지토리 컨텍스트를 검색하세요.
|
||||
- **[계정 전환 및 사용량 추적](https://www.onorca.dev/docs/agents/usage-tracking)** — Claude와 Codex의 사용량과 rate limit 초기화 시점을 확인하고, 다시 로그인하지 않고 계정을 바로 전환하세요.
|
||||
- **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다.
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요.
|
||||
- **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요.
|
||||
- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다.
|
||||
|
||||
---
|
||||
|
||||
## 지원 에이전트
|
||||
|
||||
**모든 CLI 에이전트**와 함께 작동합니다 — 터미널에서 실행되는 에이전트라면 Orca에서도 실행됩니다.
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ any CLI agent</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 설치
|
||||
|
||||
### Mac, Linux, Windows
|
||||
### 데스크톱 — macOS, Windows, Linux
|
||||
|
||||
- **[onOrca.dev에서 다운로드](https://onOrca.dev)**
|
||||
- 또는 **[GitHub Releases 페이지](https://github.com/stablyai/orca/releases/latest)** 에서 받기
|
||||
- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
|
||||
- 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
|
||||
|
||||
_패키지 매니저로도 설치할 수 있습니다:_
|
||||
|
||||
### macOS (Homebrew)
|
||||
_또는 패키지 매니저로 설치:_
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# 사전 컴파일된 바이너리
|
||||
# Arch Linux (AUR) — or stably-orca-git to build from source
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# GitHub 소스에서 빌드
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
### 모바일 Companion — iOS, Android
|
||||
|
||||
## 모바일 Companion 앱
|
||||
|
||||
휴대폰에서 에이전트를 제어하세요.
|
||||
|
||||
<p align="center">
|
||||
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 데스크톱과 모바일 companion 앱" width="720" /></picture>
|
||||
</p>
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [GitHub Releases에서 APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## 기능 쇼케이스
|
||||
|
||||
타일을 클릭해 각 워크플로를 살펴보세요.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>병렬 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="병렬 worktree 오케스트레이션" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>터미널 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty급 터미널 분할" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>디자인 모드</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="내장 브라우저와 디자인 모드" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 및 Linear 네이티브</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca의 GitHub 및 Linear 워크플로" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>모든 CLI 에이전트</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="모든 CLI 에이전트 지원" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="SSH를 통한 원격 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>에이전트로 파일 드래그</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="파일과 이미지를 에이전트 프롬프트로 드래그" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>AI Diff 주석</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="AI가 생성한 diff에 주석 달기" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="CLI에서 Orca 스크립팅" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>네이티브 검색</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="Orca 워크플로 전반의 네이티브 검색" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>계정 전환 및 사용량 추적</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="계정 전환 및 사용량 추적" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>풍부한 리포지토리 미리보기</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown, 이미지, PDF, 리포지토리 문서 미리보기" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>무엇이든 분할</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="에이전트, 터미널, 브라우저, 파일을 위한 분할 패널" width="390" /></picture><br/></kbd></a>
|
||||
</p>
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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
@@ -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>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 特性
|
||||
|
||||
- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。
|
||||
- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
|
||||
- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
|
||||
- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
|
||||
- **GitHub 集成** — PR、issue 和 Actions 检查会自动链接到对应的 worktree。
|
||||
- **SSH 支持** — 连接远程机器,并直接从 Orca 在远程机器上运行智能体。
|
||||
- **通知** — 智能体完成任务或需要关注时及时通知你。可将会话标记为未读,方便稍后返回处理。
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### 移动 Companion 应用
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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 & Linear 原生集成
|
||||
|
||||
在应用内浏览 PR、issue 和项目看板 — 从任意任务打开 worktree,无需切换上下文即可完成评审。
|
||||
|
||||
[文档 →](https://www.onorca.dev/docs/review/linear)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 任务工作流" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### SSH Worktree
|
||||
|
||||
在高性能远程机器上运行智能体,完整支持文件编辑、git 和终端 — 自动重连与端口转发一应俱全。
|
||||
|
||||
[文档 →](https://www.onorca.dev/docs/ssh)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### 标注 AI Diff
|
||||
|
||||
在任意 diff 行上添加评论并发回给智能体 — 评审、编辑、提交,全程无需离开 Orca。
|
||||
|
||||
[文档 →](https://www.onorca.dev/docs/review/annotate-ai-diff)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### 拖文件给智能体
|
||||
|
||||
VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智能体提示。
|
||||
|
||||
[文档 →](https://www.onorca.dev/docs/editing/file-explorer)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Orca CLI
|
||||
|
||||
智能体也能驱动 Orca — 用 `orca worktree create`、`snapshot`、`click` 和 `fill` 把每个工作流脚本化。
|
||||
|
||||
[文档 →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**开箱即用的还有:**
|
||||
|
||||
- **[快速打开](https://www.onorca.dev/docs/model/quick-open)** — 在 worktree、文件、智能体、命令和仓库上下文之间搜索,不打断你的心流。
|
||||
- **[账号切换与用量追踪](https://www.onorca.dev/docs/agents/usage-tracking)** — 查看 Claude 和 Codex 的用量与限额重置时间,并且无需重新登录即可热切换账号。
|
||||
- **[丰富仓库预览](https://www.onorca.dev/docs/editing/markdown)** — 在工作区中预览 Markdown、图片、PDF 和仓库文档。
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 当工作流需要真实交互时,让智能体操作桌面应用和可见 UI。
|
||||
- **[通知与未读状态](https://www.onorca.dev/docs/notifications)** — 第一时间知道智能体何时完成或需要关注,并可将会话标记为未读,稍后再回来处理。
|
||||
- **还有很多很多** — 我们每天发布新功能,这个列表永远跟不上。[更新日志](https://github.com/stablyai/orca/releases)才是真正的功能列表。
|
||||
|
||||
---
|
||||
|
||||
## 支持的智能体
|
||||
|
||||
适配**任何 CLI 智能体** — 只要能在终端里运行,就能在 Orca 里运行。
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Claude Code logo" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Codex logo" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" alt="Gemini logo" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Charm logo" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Cline logo" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Codebuff logo" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Command Code logo" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Continue logo" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Droid logo" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Kilocode logo" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Kimi logo" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Kiro logo" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ 任何 CLI 智能体</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### Mac, Linux, Windows
|
||||
### 桌面端 — macOS、Windows、Linux
|
||||
|
||||
- **[从 onOrca.dev 下载](https://onOrca.dev)**
|
||||
- 或通过 **[GitHub Releases 页面](https://github.com/stablyai/orca/releases/latest)** 获取
|
||||
- **[从 onOrca.dev 下载](https://onorca.dev/download)**
|
||||
- 或直接获取安装包:[macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [全部构建](https://github.com/stablyai/orca/releases/latest)
|
||||
|
||||
_也可以通过包管理器安装:_
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
```
|
||||
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# 预编译二进制
|
||||
# Arch Linux (AUR) — or stably-orca-git to build from source
|
||||
yay -S stably-orca-bin
|
||||
|
||||
# 从 GitHub 源码构建
|
||||
yay -S stably-orca-git
|
||||
```
|
||||
|
||||
---
|
||||
### 移动 Companion 应用 — iOS、Android
|
||||
|
||||
## 移动 Companion 应用
|
||||
|
||||
用手机控制你的智能体。
|
||||
|
||||
<p align="center">
|
||||
<picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca 桌面端与移动 companion 应用" width="720" /></picture>
|
||||
</p>
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [从 GitHub Releases 下载 APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## 功能展示
|
||||
|
||||
点击任意卡片了解对应工作流。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><kbd><strong>并行 Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="并行 worktree 编排" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/terminal"><kbd><strong>终端分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Ghostty 级终端分屏" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><kbd><strong>设计模式</strong><br/><br/><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="内置浏览器与设计模式" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><kbd><strong>GitHub 与 Linear 原生集成</strong><br/><br/><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Orca 中的 GitHub 与 Linear 工作流" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/supported"><kbd><strong>任意 CLI 智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/cli-agents.gif" type="image/gif"><img src="../assets/feature-wall/cli-agents.jpg" alt="支持任意 CLI 智能体" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/ssh"><kbd><strong>SSH Worktree</strong><br/><br/><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="通过 SSH 使用远程 worktree" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><kbd><strong>拖文件给智能体</strong><br/><br/><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="将文件和图片拖入智能体提示" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><kbd><strong>标注 AI Diff</strong><br/><br/><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="标注 AI 生成的 diff" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><kbd><strong>Orca CLI</strong><br/><br/><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="从 CLI 脚本化 Orca" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/settings"><kbd><strong>原生搜索</strong><br/><br/><picture><source srcset="../assets/feature-wall/keyboard-native.gif" type="image/gif"><img src="../assets/feature-wall/keyboard-native.jpg" alt="贯穿 Orca 工作流的原生搜索" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/agents/usage-tracking"><kbd><strong>账号切换与用量追踪</strong><br/><br/><picture><source srcset="../assets/feature-wall/codex-accounts.gif" type="image/gif"><img src="../assets/feature-wall/codex-accounts.jpg" alt="账号切换与用量追踪" width="390" /></picture><br/></kbd></a>
|
||||
<a href="https://www.onorca.dev/docs/editing/markdown"><kbd><strong>丰富仓库预览</strong><br/><br/><picture><source srcset="../assets/feature-wall/markdown-editor.gif" type="image/gif"><img src="../assets/feature-wall/markdown-editor.jpg" alt="Markdown、图片、PDF 和仓库文档预览" width="390" /></picture><br/></kbd></a><br/><br/>
|
||||
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><kbd><strong>任意分屏</strong><br/><br/><picture><source srcset="../assets/feature-wall/split-screen.gif" type="image/gif"><img src="../assets/feature-wall/split-screen.jpg" alt="为智能体、终端、浏览器和文件分屏" width="390" /></picture><br/></kbd></a>
|
||||
</p>
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-v0.0.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?"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
+263
-545
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
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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'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'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't apply there.
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Autocomplete & 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)'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
app_identifier(ENV["IOS_BUNDLE_IDENTIFIER"] || "com.stably.orca.mobile")
|
||||
team_id(ENV["APPLE_TEAM_ID"])
|
||||
@@ -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.
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Generated
+154
-119
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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)'
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user