Merge remote-tracking branch 'origin/main' into brennanb2025/codex-structured-mobile-write

This commit is contained in:
Brennan Benson
2026-08-10 12:01:26 -07:00
859 changed files with 42036 additions and 13459 deletions
@@ -0,0 +1,64 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
function readRootEntries(sha) {
// Why: a git pathname is arbitrary bytes, and 'utf8' folds every invalid
// sequence to U+FFFD — that mangles the reported name and makes two different
// entries compare equal, so a genuinely new one can slip past the Set below.
// latin1 maps each byte to one code unit, so the bytes survive the round trip.
const stdout = execFileSync('git', ['ls-tree', '-z', '--name-only', sha], {
encoding: 'latin1',
stdio: ['ignore', 'pipe', 'inherit']
})
return stdout.split('\0').filter(Boolean)
}
function checkRootDirectoryEntries(argv) {
if (argv.length !== 2) {
console.error(`Usage: ${process.argv[1]} <base-sha> <head-sha>`)
return 2
}
const [baseSha, headSha] = argv
const baseEntries = new Set(readRootEntries(baseSha))
const blockedEntries = readRootEntries(headSha).filter((entry) => !baseEntries.has(entry))
if (blockedEntries.length === 0) {
console.log('Root directory guard passed: no new root-level files or folders.')
return 0
}
console.log(
'::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page.'
)
console.log('Root directory guard failed.')
console.log(
'New root-level files or folders are not allowed because they bloat the GitHub landing page.'
)
console.log('Move each new entry under an existing top-level directory.')
console.log('Blocked entries:')
// Why: an entry name is attacker-controlled and may start with '::' (the runner
// trims leading spaces before matching) or embed a newline, so printing it bare
// lets a PR forge annotations. Fence the untrusted list with an unguessable
// stop-commands token, and write the raw bytes rather than a re-encoded string.
const resumeToken = randomUUID()
console.log(`::stop-commands::${resumeToken}`)
for (const entry of blockedEntries) {
process.stdout.write(Buffer.from(` ${entry}\n`, 'latin1'))
}
console.log(`::${resumeToken}::`)
return 1
}
try {
// Why: process.exit truncates a piped write part-way through on macOS, so set
// exitCode and let node flush the blocked-entry list before it exits.
process.exitCode = checkRootDirectoryEntries(process.argv.slice(2))
} catch (error) {
// Why: git already reported the failure on the inherited stderr, so surface its
// status rather than a node stack trace. Anything else is a real bug — rethrow.
if (typeof error.status !== 'number') {
throw error
}
process.exitCode = error.status
}
@@ -1,37 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <base-sha> <head-sha>" >&2
exit 2
fi
base_sha=$1
head_sha=$2
git rev-parse --verify "${base_sha}^{tree}" >/dev/null
git rev-parse --verify "${head_sha}^{tree}" >/dev/null
declare -A base_entries=()
while IFS= read -r -d '' entry; do
base_entries["$entry"]=1
done < <(git ls-tree -z --name-only "$base_sha")
blocked_entries=()
while IFS= read -r -d '' entry; do
if [[ -z "${base_entries[$entry]+present}" ]]; then
blocked_entries+=("$entry")
fi
done < <(git ls-tree -z --name-only "$head_sha")
if (( ${#blocked_entries[@]} == 0 )); then
echo "Root directory guard passed: no new root-level files or folders."
exit 0
fi
echo "::error title=Root-level additions blocked::New root-level files or folders bloat the GitHub landing page."
echo "Root directory guard failed."
echo "New root-level files or folders are not allowed because they bloat the GitHub landing page."
echo "Move each new entry under an existing top-level directory."
printf 'Blocked entries:\n'
printf ' %s\n' "${blocked_entries[@]}"
exit 1
+471
View File
@@ -0,0 +1,471 @@
name: Daily macOS Dev Build
# Why: gives developers a signed macOS build of main once a day that the in-app
# updater can install directly, without waiting for an RC cut — less noise than
# hourly.
#
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - Signed AND notarized, exactly like a release. The notary round trip is the
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
# + team rather than on its cdhash, so those grants survive an update. Without
# a ticket every daily reads as a new client and silently loses file access
# under Documents/Desktop/Downloads.
#
# Artifacts publish to stablyai/orca-daily, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so high-volume
# dev tags would evict every stable/RC entry and break updates for real users.
# Separate from orca-hourly so someone riding main's hourlies never sees the
# sparse daily series mixed into that list.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use the
# same GitHub App as hourly (installed on orca-daily with Contents: Read and
# write). Provision the App secrets with
# `bash config/scripts/setup-hourly-release-token.sh`, then grant the App access
# to orca-daily with `bash config/scripts/setup-daily-release-repo.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id (shared with hourly/adhoc)
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is why this mints three times. Install
# and build need no token at all, and notarization can hold the publish step for
# tens of minutes; minting again once the build is done starts the clock at the
# first call that actually uses it rather than burning a third of it on
# `pnpm install`. The upload step's own retry budget (2x45m) can outlive that
# second token, so a third is minted after it for verify/publish/prune/cleanup —
# without it a stuck notary run would strand a draft that cleanup gets a 401 on.
# The release stays an unpublished draft until the manifest check passes, so the
# worst case is still an invisible draft — and the build-number query counts
# drafts, so it holds its number and the next run does not reuse it.
on:
schedule:
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 14 * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last daily
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: daily-mac-build
cancel-in-progress: false
env:
DAILY_REPO: stablyai/orca-daily
# Keep ~30 days so a regression can be bisected without holding a full year.
DAILY_RETAIN_COUNT: 30
jobs:
build-daily-mac:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — this is the notary queue's tail, not its median.
timeout-minutes: 150
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the daily repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Mint daily repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
# Why: main is often idle. Rebuilding an unchanged commit burns a runner
# hour and adds a redundant tag to the retention window.
- name: Check whether main moved since the last daily
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(git rev-parse HEAD)"
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
if [[ "$FORCED" == "true" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous daily records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
last_body="$(gh release list --repo "$DAILY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "No prior daily release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$DAILY_REPO" --json body \
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
echo "should_build=false" >>"$GITHUB_OUTPUT"
echo "main is unchanged since $last_body ($last_sha); skipping."
else
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "main moved to $head_sha (last daily built $last_sha); building."
fi
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes a daily installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute daily version
id: daily
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Existing titles, which carry the build number this series continues
# from. The script picks the number, because it restarts per base version
# and only the script knows which base this build resolved to.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
names="$(gh release list --repo "$DAILY_REPO" --limit 200 --json name \
--jq '.[].name // empty')"
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# daily repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_DAILY_RELEASE_NAMES="$names" \
node config/scripts/daily-build-version.mjs \
>"$RUNNER_TEMP/daily-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
if ! grep -q '^name=' "$RUNNER_TEMP/daily-identity.txt"; then
echo "::error::daily-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
fi
cat "$RUNNER_TEMP/daily-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: daily builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
# Why a second mint: everything from here on writes to the daily repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
# on steps that never touched it; restarting the clock here gives the slow
# part the full budget.
- name: Re-mint daily repo token for publish
id: app_token_publish
if: steps.freshness.outputs.should_build == 'true'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
- name: Create daily release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
TAG: v${{ steps.daily.outputs.version }}
NAME: ${{ steps.daily.outputs.name }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
short_sha="${SHA:0:12}"
# Why a file rather than an inline string: the publish step re-asserts
# this same body, and the freshness check only works if the two agree
# exactly. One source, written once, read twice.
notes_file="$RUNNER_TEMP/daily-release-notes.md"
cat >"$notes_file" <<EOF
Automated daily macOS dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed and notarized like a release, so it
installs through Orca's in-app updater and opens from a manual download
without a Gatekeeper prompt — but nothing here has been reviewed.
EOF
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
gh release create "$TAG" \
--repo "$DAILY_REPO" \
--title "$NAME" \
--draft \
--notes-file "$notes_file"
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
- name: Publish daily macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why 45 like the release pipeline: an attempt is pack + notarize +
# upload, and the notary queue is the unbounded part. Why 2 attempts and
# not 3: a missed daily costs a day, and a third attempt buys less than
# it costs in runner time once the notary is that stuck.
timeout_minutes: 45
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_DAILY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-daily.
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
# Apple notary service with the app-specific password, not with the
# signing cert. Omitting them fails the build rather than skipping it,
# since `notarize` is now on for this path.
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why a third mint: the upload step above allows two 45-minute attempts, so
# it can outlive the one-hour token minted before it. Every remaining step
# writes to the daily repo, including the failure path that discards the
# draft — a 401 there is exactly the stranded draft nobody can clean up.
#
# Why always(): a failed or cancelled upload is the case that needs this
# most, since the cleanup step below runs only on that path.
- name: Re-mint daily repo token for verify and cleanup
id: app_token_final
if: always() && steps.freshness.outputs.should_build == 'true'
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-daily
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$DAILY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Daily draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Daily draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.daily.outputs.name }}
NOTES_FILE: ${{ steps.release.outputs.notes_file }}
run: |
set -euo pipefail
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
# --notes-file for the same reason, and it matters more: the next run's
# freshness check parses the source commit back out of this body, so a
# body the publisher overwrote would rebuild an unchanged main daily.
gh release edit "$TAG" --repo "$DAILY_REPO" --draft=false --prerelease \
--title "$NAME" --notes-file "$NOTES_FILE"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
# Fall back to the publish token: if the re-mint itself is what failed,
# the older token is the only one left and may still have time on it.
GH_TOKEN: ${{ steps.app_token_final.outputs.token || steps.app_token_publish.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
# No --cleanup-tag: an unpublished draft never created a git tag.
echo "Run failed before publish; discarding draft $TAG"
gh release delete "$TAG" --repo "$DAILY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old daily releases
# Only after a live publish: $TAG is then a non-draft we must not delete,
# and failed runs should not reshuffle retention around a draft that the
# failure path is about to discard.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token_final.outputs.token }}
# Protect the tag this run just shipped; at the retain cap, a bad sort
# can otherwise mark the newest release as stale and delete it.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
#
# Sort by publishedAt (not createdAt). Many non-draft dailies can share
# one createdAt (bulk import / re-create), so createdAt ranking is
# unstable. publishedAt is real recency; tagName (...YYYYMMDDHHMM) is
# the deterministic tie-break.
#
# Why force $TAG to the front before slicing: a hard retain-window seat
# for this run's release. Dropping $TAG from the list *before* the slice
# would permanently keep retain+1 releases; skipping it only in the
# delete loop would under-prune when the sort is still wrong. Partition
# keeps relative order of every other tag.
jq_filter='map(select(.isDraft | not)) | sort_by(.publishedAt // "", .tagName) | reverse'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | (map(select(.tagName == \"${TAG//\"/\\\"}\")) + map(select(.tagName != \"${TAG//\"/\\\"}\")))"
fi
jq_filter+=" | .[${DAILY_RETAIN_COUNT}:] | .[].tagName"
stale="$(gh release list --repo "$DAILY_REPO" --limit 200 --json tagName,publishedAt,isDraft \
--jq "$jq_filter")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $DAILY_RETAIN_COUNT retained builds."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
# Belt-and-suspenders: partition above should already exclude $TAG.
if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then
echo "::warning::Prune list still included just-published $tag after protect; skipping delete."
continue
fi
echo "Pruning $tag"
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
+50 -4
View File
@@ -30,9 +30,11 @@ jobs:
# (@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
# Archive + upload is ~30–40m when healthy; 90m leaves margin without
# sitting multi-hour on ASC processing polls (see Fastfile pilot wait).
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
timeout-minutes: 90
outputs:
release_version: ${{ steps.release_metadata.outputs.version }}
build_number: ${{ steps.release_metadata.outputs.build_number }}
defaults:
run:
@@ -79,6 +81,10 @@ jobs:
FASTLANE_HIDE_CHANGELOG: '1'
run: bundle exec fastlane ios prepare_release_version version:"$MOBILE_IOS_RELEASE_VERSION" bump_patch:"$MOBILE_IOS_BUMP_PATCH_VERSION"
- name: Capture release metadata
id: release_metadata
run: node -e 'const fs = require("node:fs"); const { expo } = require("./app.json"); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expo.version}\nbuild_number=${expo.ios.buildNumber}\n`)'
- name: Expo prebuild
run: npx expo prebuild --platform ios --no-install
@@ -122,11 +128,10 @@ jobs:
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }}
# 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
run: bundle exec fastlane ios build_and_upload
- name: Upload .ipa artifact
if: always()
@@ -135,3 +140,44 @@ jobs:
name: orca-mobile-ipa
path: mobile/build/*.ipa
if-no-files-found: ignore
ios-distribute:
name: Distribute to external TestFlight testers
needs: ios-build
runs-on: ubuntu-latest
# Fastlane's processing wait fails after 20m; this outer bound leaves setup
# margin without allowing App Store Connect polling to hang for hours.
timeout-minutes: 30
defaults:
run:
working-directory: mobile
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Ruby and fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: mobile
- name: Wait for processing and distribute to peeps
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_API_KEY_P8: ${{ secrets.ASC_API_KEY_P8 }}
TESTFLIGHT_CHANGELOG: ${{ github.event.inputs.testflight_changelog }}
MOBILE_IOS_RELEASE_VERSION: ${{ needs.ios-build.outputs.release_version }}
MOBILE_IOS_BUILD_NUMBER: ${{ needs.ios-build.outputs.build_number }}
FASTLANE_SKIP_UPDATE_CHECK: '1'
FASTLANE_HIDE_CHANGELOG: '1'
run: |
if ! bundle exec fastlane ios distribute_testflight \
version:"$MOBILE_IOS_RELEASE_VERSION" \
build_number:"$MOBILE_IOS_BUILD_NUMBER"; then
echo "::error::TestFlight $MOBILE_IOS_RELEASE_VERSION ($MOBILE_IOS_BUILD_NUMBER) was uploaded but not distributed to peeps. If App Store Connect is still processing it, rerun only this failed job after the build becomes Ready; otherwise inspect the Fastlane error above."
exit 1
fi
+6 -3
View File
@@ -108,7 +108,7 @@ jobs:
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: bash .github/scripts/check-root-directory-entries.sh "$BASE_SHA" "$HEAD_SHA"
run: node .github/scripts/check-root-directory-entries.mjs "$BASE_SHA" "$HEAD_SHA"
typecheck:
runs-on: ubuntu-latest
@@ -183,8 +183,11 @@ jobs:
with:
persist-credentials: false
- name: Install zsh
run: sudo apt-get update && sudo apt-get install -y zsh
# Why fish: shell-ready.test.ts gates its live fish test on the binary being
# present, so without this the fish barrier is only covered by config-shape
# assertions and never actually exercised.
- name: Install zsh and fish
run: sudo apt-get update && sudo apt-get install -y zsh fish
- uses: ./.github/actions/install-node-dependencies
with:
@@ -37,6 +37,7 @@ const WORKER_THREAD_ENTRY_NAMES = [
'stt-worker',
'warp-theme-parser-worker',
'session-scanner-opencode-sqlite-worker-entry',
'session-scanner-worker-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry'
] as const
+18 -7
View File
@@ -20,21 +20,32 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to
// swap them over an installed Orca and macOS treats each build as a new app.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacDaily = process.env.ORCA_MAC_DAILY === '1'
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacAdhoc
const isMacRelease =
process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const devChannelBuildVersion = isMacHourly
? process.env.ORCA_HOURLY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
: isMacDaily
? process.env.ORCA_DAILY_BUILD_VERSION
: isMacAdhoc
? process.env.ORCA_ADHOC_BUILD_VERSION
: undefined
// Why each dev channel gets its own repo rather than tagging into the main one:
// the releases atom feed exposes only the 10 newest entries, so 24 hourly tags a
// day would evict every stable/RC entry and strand users on a feed with nothing
// to install. Keeping adhoc separate from hourly too means a branch build cannot
// be picked up by someone who only meant to ride main.
const devChannelRepo = isMacHourly ? 'orca-hourly' : isMacAdhoc ? 'orca-adhoc' : null
// to install. Keeping adhoc/daily separate from hourly too means a branch build
// or a once-a-day cut cannot be picked up by someone who only meant to ride
// main's hourlies.
const devChannelRepo = isMacHourly
? 'orca-hourly'
: isMacDaily
? 'orca-daily'
: isMacAdhoc
? 'orca-adhoc'
: null
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
+1
View File
@@ -10,6 +10,7 @@
"src/main/speech/stt-worker.ts",
"src/main/warp-themes/warp-theme-parser-worker.ts",
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
"src/main/ai-vault/session-scanner-worker-entry.ts",
"src/main/ports/port-scan-command-worker-entry.ts",
"src/main/ipc/parcel-watcher-process-entry.ts",
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+205 -113
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-07",
"updatedAt": "2026-08-09",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,103 +10,6 @@
}
},
"gates": [
{
"id": "ai-vault.live-session-delete-authority",
"title": "AI Vault deletion fails closed against owning-runtime session liveness",
"maturity": "experimental",
"protection": "partial",
"owner": "ai-vault-runtime",
"layer": "main-process-filesystem-mutation",
"surfaces": [
"AI Vault transcript deletion",
"local and WSL agent sessions",
"paired-client-owned host sessions"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "wsl", "ssh", "paired-runtime"],
"coveredPlatforms": ["macos", "linux", "windows"],
"coveredProviders": ["local", "wsl", "ssh", "paired-runtime"],
"coverageNotes": "Platform-independent contracts cover local and WSL authority, paired-owned and unmanaged live sessions absent from renderer or managed-PTY state, identity retention across UI dismissal, inventory and foreground-inspection ordering, SSH exclusion, unavailable and ambiguous inventory, bounded inspection concurrency, oversized inventories, returned rejection, and real temporary-file survival. Live headed/headless paired-runtime, physical Windows/WSL, and Docker SSH journeys remain uncollected.",
"motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"],
"invariant": "Main may delete a local AI transcript only after the owning runtime freshly proves that no live local or WSL provider-session identity exists, including exact hook identities absent from managed PTY inventory; live and unknown liveness preserve the transcript regardless of renderer freshness.",
"oracle": "Open confirmation against a stale empty renderer snapshot, transition the authoritative session to live before main authorization and during a controlled foreground-inspection barrier, and separately model paired and unmanaged local owners absent from renderer or managed-PTY state. Dismiss a live row and require its identity-only authority to remain. Require session-live or session-liveness-unknown rejection and the real temporary transcript to survive. Make process inventory unavailable or ambiguous and require unknown with the file preserved; prove known other sessions permit deletion and inspection concurrency stays at or below eight.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot"
],
"testFiles": [
"src/main/ai-vault/session-delete-liveness.repro.test.ts",
"src/main/ai-vault/session-liveness.test.ts",
"src/main/agent-hooks/server-ai-vault-liveness.test.ts"
],
"assertionRefs": [
{
"file": "src/main/ai-vault/session-delete-liveness.repro.test.ts",
"assertions": [
"confirmation TOCTOU returns session-live and preserves the real transcript",
"paired ownership missing from the renderer snapshot returns session-live and preserves the real transcript",
"exact live local hook identity missing from managed PTY inventory returns unknown and preserves the real transcript",
"dismissed live status retained as identity-only authority returns unknown and preserves the real transcript",
"unknown authoritative liveness fails closed and preserves the real transcript"
]
},
{
"file": "src/main/ai-vault/session-liveness.test.ts",
"assertions": [
"local, WSL, paired-runtime, and SSH host partitions resolve without local fallback",
"renderer session identity must match the identity parsed from the validated transcript",
"hook identity is captured after the controlled process-inventory barrier",
"hook identity is captured after the controlled foreground-inspection barrier",
"missing identity, unavailable inventory, unattributed ownership, and oversized inventory remain unknown",
"foreground inspection concurrency never exceeds eight and stops scheduling at the shared deadline"
]
},
{
"file": "src/main/agent-hooks/server-ai-vault-liveness.test.ts",
"assertions": [
"user dismissal retains provider identity without retaining a visible turn-status row",
"restart hydration preserves dismissed identity as unconfirmed liveness-only authority"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts src/main/agent-hooks/server-ai-vault-liveness.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1,
"summary": "Twenty deterministic deletion, identity-binding, inventory/inspection-ordering, dismissal/restart-retention, unmanaged-ownership, unknown-state, filesystem-survival, host-partition, deadline, and bounded-concurrency assertions passed."
}
],
"runtimeBudget": {
"p95Seconds": 2,
"scope": "focused main-process liveness and temporary-filesystem contracts"
},
"flakeHistory": {
"status": "unknown",
"evidence": "One local deterministic run is recorded; CI and soak history have not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical two-scenario oracle failed on v1.4.177-rc.0 (9e948fbdf4) and latest main (7a867f12aa): both returned deleted and both real transcripts were removed. On PR commit 8e2fcfd681, an exact live local hook identity absent from empty managed-PTY inventory returned deleted and removed the real transcript. Independent review then proved a session-other to session-live transition during foreground inspection returned not-live, and live-row dismissal erased the only external identity. The corrected candidate passed all 20 assertions, including restart hydration of dismissed identity; temporarily disabling only the original liveness decision made all three original deletion-safety tests red again."
},
"performanceBudget": {
"required": true,
"evidence": "Deletion parses only the validated target through the incremental parse cache, performs one local-provider process listing, makes no SSH or paired-runtime request, caps inventory at 512 PTYs, bounds foreground inspections to eight concurrent calls under one shared three-second fail-closed deadline, and adds no polling or background work."
},
"promotionCriteria": [
"Collect CI soak history for the deterministic gate.",
"Collect headed and headless paired-runtime deletion-refusal evidence.",
"Collect physical Windows/WSL and Docker SSH host-partition evidence."
],
"knownGaps": [
"No live headed/headless paired-runtime journey was run.",
"No physical Windows/WSL or Docker SSH journey was run.",
"The authorization check cannot make an external provider process start atomic with filesystem trashing; it closes the reported renderer-confirmation window at the main mutation boundary."
],
"demotionRule": "Keep experimental or demote if live or unknown ownership can reach trashing, remote ownership falls back locally, process inspection becomes unbounded, or the deterministic filesystem oracle flakes without a product or harness defect."
},
{
"id": "editor.restored-sibling-owner-reparent",
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",
@@ -4830,24 +4733,33 @@
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, installs no interval, and re-reads only once per explicit daemon-management kill/restart (which emits no pty:exit). Broader terminal interaction coverage remains on the pending reliability stack.",
"coveredProviders": ["local", "ssh", "remote-runtime"],
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, installs no interval, and re-reads only once per explicit daemon-management kill/restart. AI Vault tab-title sync uses bounded exact identities on the transcript-owning local, SSH, or runtime host and has no access to broad listing; unsupported older hosts retain persisted titles without fallback scans. WSL path translation still needs a Windows integration oracle.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7002",
"https://github.com/stablyai/orca/pull/6858",
"https://github.com/stablyai/orca/issues/9386",
"https://github.com/stablyai/orca/pull/9387"
"https://github.com/stablyai/orca/pull/9387",
"https://github.com/stablyai/orca/pull/12778"
],
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, and per-pane liveness paths must not call global pty:listSessions; they must use targeted per-PTY APIs or cached provider-owned state.",
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs; known-session reattach signals and steady closed time perform zero reads. Targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, per-pane liveness, and tab-title synchronization must not call global pty:listSessions or aiVault.listSessions; they must use targeted APIs or cached provider-owned state.",
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, serializes worker work, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts"
],
"testFiles": [
"src/main/ipc/pty.test.ts",
"src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
"src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
"src/renderer/src/lib/ai-vault-tab-title-sync.test.ts",
"src/main/ai-vault/session-scanner-worker-client.test.ts",
"src/main/ai-vault/session-title-file-reader.test.ts",
"src/main/ai-vault/session-parse-cache-persistence.test.ts",
"src/main/ipc/ai-vault.test.ts",
"src/main/runtime/rpc/methods/ai-vault.test.ts",
"src/relay/ai-vault-handler.test.ts"
],
"assertionRefs": [
{
@@ -4881,6 +4793,38 @@
"the closed inventory hook installs no interval",
"the badge count comes from cached daemon inventory rather than wake-hint bindings"
]
},
{
"file": "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts",
"assertions": [
"title sync invokes only exact resolveSessionTitles requests",
"status pings and worktree path changes do not trigger rereads",
"title requests are bounded to 64 identities per batch",
"independent hosts resolve concurrently while each host wire stays serial"
]
},
{
"file": "src/main/ai-vault/session-scanner-worker-client.test.ts",
"assertions": [
"full scans and exact-title reads share one serial FIFO worker",
"active cancellation stays serialized and queued work remains bounded",
"worker faults restart queued work and idle time preserves incremental parse state",
"worker disposal rejects retained work and terminates the worker"
]
},
{
"file": "src/main/ai-vault/session-parse-cache-persistence.test.ts",
"assertions": [
"the background scanner receives the configured persisted-cache identity",
"unchanged transcripts remain reusable across process restarts"
]
},
{
"file": "src/main/ipc/ai-vault.test.ts",
"assertions": [
"exact title identities route to local, SSH, and runtime host authority",
"unsupported remote hosts return no refresh and never fall back to broad scans"
]
}
],
"evidenceRuns": [
@@ -4892,6 +4836,15 @@
"result": "passed",
"durationSeconds": 4.3,
"summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing."
},
{
"date": "2026-08-09",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts",
"result": "passed",
"durationSeconds": 3.1,
"summary": "The focused run passed 112 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, persistent serial worker lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback."
}
],
"runtimeBudget": {
@@ -4904,11 +4857,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to one serial worker or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion."
},
"performanceBudget": {
"required": true,
"evidence": "This gate is the performance budget for global session listing in hot paths."
"evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active worker operation, 16 queued operations, four concurrent transcript parses inside the worker, a 4,096-title index, and no worktree-path-triggered refresh. The worker emits the aiVault.scan.worker span with duration and session count for full scans."
},
"promotionCriteria": [
"Add deterministic call-count instrumentation.",
@@ -4918,7 +4871,8 @@
"knownGaps": [
"Current commands cover Resource Manager readiness/lifecycle inventory counts; the broader targeted-liveness slice still lives on the pending fix-terminal-reliability stack.",
"Current coverage includes the closed Resource Manager's no-interval and known-reattach budgets, but not every hot interaction listed in the invariant.",
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero global listSessions calls."
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero PTY listSessions calls.",
"The exact-title path has deterministic provider routing coverage but no packaged Windows WSL or live SSH performance run yet."
],
"demotionRule": "Cannot promote if the test allows broad listing in any hot interaction path."
},
@@ -6658,13 +6612,14 @@
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts",
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
@@ -6682,11 +6637,18 @@
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
"file": "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
"assertions": [
"synthetic and native input-source paths are classified for covered fixtures"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts",
"assertions": [
"an OS key-binding substitution reaches the PTY instead of the raw layout character",
"the same key with no substitution in play still sends the layout character"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"assertions": ["paste/runtime forwarding avoids duplicate terminal payloads"]
@@ -6739,6 +6701,15 @@
}
],
"evidenceRuns": [
{
"date": "2026-08-09",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"result": "passed",
"durationSeconds": 0.7,
"summary": "8 test file(s) passed, 143 tests, after adding the OS key-binding substitution replay. Discrimination checked by mutation rather than assumed: suppressing the structural claim, and separately removing the single character the older punctuation table happened to list, each make the replay send the raw layout character while both paired negatives stay green."
},
{
"date": "2026-07-11",
"runner": "local",
@@ -6770,10 +6741,10 @@
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"result": "passed",
"durationSeconds": 0.7,
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage). The command shown is the gate's current one, which the manifest check requires; the run itself predates two of the files it now lists. The 2026-08-09 entry is the one that covers the current slice."
},
{
"date": "2026-07-07",
@@ -7651,6 +7622,127 @@
],
"demotionRule": "Keep experimental or demote if either Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if restart or workspace re-entry spawns/resumes/duplicates/interferes with the worker, if retained output becomes unreadable, or if pane-stable delivery reads the wrong mailbox."
},
{
"id": "orchestration.context-only-dispatch-release",
"title": "Manual Dispatch release fences assignments without stopping unsupervised terminals",
"maturity": "experimental",
"protection": "partial",
"owner": "orchestration",
"layer": "cli-runtime-sqlite-terminal-lifecycle",
"surfaces": [
"orchestration dispatch",
"dispatch-show",
"worker-abandon",
"worker-stop",
"manual terminal reuse"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "Deterministic runtime tests cover both release verbs, durable outcomes, repeat and cross-verb idempotency, superseded Task ownership, unrelated and supervised Dispatch isolation, and unknown ids. An isolated Electron journey drives the real app runtime and proves both releases preserve the exact PTY incarnation and allow immediate same-pane redispatch.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/13005",
"https://github.com/stablyai/orca/pull/13250"
],
"invariant": "A live Dispatch created by orchestration dispatch can be stopped or abandoned even though it has no supervised worker row. Release must durably record the requested outcome, revoke lifecycle authority, close questions, free the exact assignee identity, and block only the Task whose current Dispatch was released. It must never close the unsupervised terminal process, disturb unrelated or supervised workers, or let a repeat or opposite verb rewrite the persisted outcome.",
"oracle": "Create manual, unrelated, and supervised Dispatches through production runtime methods. Require dispatch-show to return the manual id while no worker row exists, then release it and require failed status with exact stopped or abandoned provenance, completion and revocation timestamps, one status notification, zero terminal closes, and immediate redispatch to the same terminal. Repeat through the opposite verb and require the first durable outcome. Create two active contexts for one Task through an explicit ready override, release the older context, and require only its identity to unlock while the newer context and Task remain dispatched. In an isolated Electron runtime, repeat both verbs against one real pane and require the same PTY/incarnation to survive before a third dispatch succeeds.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/cli/handlers/orchestration-worker-cli.test.ts --reporter=dot",
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/orchestration-low-level-dispatch-release.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-low-level-dispatch-release.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts",
"src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts",
"src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts",
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"src/cli/handlers/orchestration-worker-cli.test.ts",
"tests/e2e/orchestration-low-level-dispatch-release.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts",
"assertions": [
"worker-abandon and worker-stop durably release context-only Dispatches without closing terminals",
"repeat and cross-verb calls preserve the first stored outcome",
"superseded release frees only the old identity and leaves the current Task Dispatch active",
"unrelated and supervised workers remain unchanged"
]
},
{
"file": "src/cli/handlers/orchestration-worker-cli.test.ts",
"assertions": [
"text-mode worker-stop reports that a manual Dispatch retained its unsupervised terminal process"
]
},
{
"file": "tests/e2e/orchestration-low-level-dispatch-release.spec.ts",
"assertions": [
"dispatch-show returns the live manual Dispatch before release",
"abandon and stop record distinct durable outcomes",
"the exact PTY and incarnation survive worker-stop",
"the same pane accepts a third Dispatch after both releases"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-09",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-low-level-dispatch-release.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "failed",
"durationSeconds": 4.3,
"summary": "Current main 2dc172f666 reproduced issue #13005: dispatch-show returned the live id, then worker-abandon failed with dispatch_not_found before any release or pane reuse could occur."
},
{
"date": "2026-08-09",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/cli/handlers/orchestration-worker-cli.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 3.38,
"summary": "Five focused files passed 60 tests, including both context-only release verbs, stale/current ownership, question closure, repeat and cross-verb idempotency, supervised controls, terminal-close negative assertions, and text-mode retained-process guidance."
},
{
"date": "2026-08-09",
"runner": "local",
"platform": "macos",
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-low-level-dispatch-release.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 4.1,
"summary": "The isolated Electron journey passed both release verbs, preserved the exact pane PTY/incarnation, and dispatched a third Task to the same terminal."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "focused runtime contracts plus one isolated Electron journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic unit and Electron journeys pass locally; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical Electron oracle failed on current main 2dc172f666 with dispatch_not_found and passed after the context-only release implementation. The focused runtime oracle additionally proves durable outcomes and stale Task ownership."
},
"performanceBudget": {
"required": true,
"evidence": "Release is an explicit control-path transaction with indexed Dispatch and worker lookups, one latest-context lookup for the affected Task, bounded updates, and no polling, process inspection, provider listing, subprocess, renderer work, or terminal I/O."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history on macOS, Linux, and Windows.",
"Add isolated headed and headless paired-runtime journeys against mixed client and host versions.",
"Add Docker SSH and physical Windows WSL same-pane reuse evidence."
],
"knownGaps": [
"Live paired-runtime, headless serve, SSH, WSL, Linux, and Windows evidence is not yet attached.",
"The Electron journey calls production runtime RPC methods directly instead of spawning a separate CLI process.",
"The local journey covers a folder-backed seeded repository through the app fixture but not a git-independent folder workspace."
],
"demotionRule": "Keep experimental or demote if release closes an unsupervised terminal, rewrites a durable outcome, blocks a newer Task Dispatch, leaves an assignee identity occupied, or the focused oracle flakes without a product or harness defect."
},
{
"id": "orchestration.settled-worker-terminal-release",
"title": "Settled worker cleanup preserves one exact terminal lease and immutable output",
@@ -0,0 +1,255 @@
#!/usr/bin/env node
// Measures the no-URL PTY path that every terminal chunk enters.
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import nodeModule from 'node:module'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
if (!process.execArgv.includes('--experimental-transform-types')) {
const result = spawnSync(
process.execPath,
['--experimental-transform-types', '--no-warnings', import.meta.filename],
{ stdio: 'inherit' }
)
process.exit(result.status ?? 1)
}
nodeModule.registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
const candidate = new URL(`${specifier}.ts`, context.parentURL)
if (existsSync(fileURLToPath(candidate))) {
return { url: candidate.href, shortCircuit: true }
}
}
return nextResolve(specifier, context)
}
})
const source = readFileSync(
new URL('../../src/main/ports/advertised-url-watcher.ts', import.meta.url),
'utf8'
)
for (const marker of [
"return mayContainHttpUrl(finalized) ? stripTerminalControls(finalized) : ''",
'if (chunk.length >= PER_PTY_BUFFER_LIMIT)',
'this.raw.length + chunk.length > PER_PTY_BUFFER_LIMIT'
]) {
if (!source.includes(marker)) {
throw new Error(`advertised-url-watcher.ts no longer contains \`${marker}\``)
}
}
const { AdvertisedUrlWatcher, extractUrlCandidates, stripTerminalControls } = await import(
new URL('../../src/main/ports/advertised-url-watcher.ts', import.meta.url).href
)
const BUFFER_LIMIT = 4096
const ITERATIONS = Number(process.env.ORCA_ADVERTISED_URL_BENCH_ITERATIONS ?? '10000')
const ROUNDS = Number(process.env.ORCA_ADVERTISED_URL_BENCH_ROUNDS ?? '12')
const WARMUP = Number(process.env.ORCA_ADVERTISED_URL_BENCH_WARMUP ?? '1000')
for (const [name, value] of [
['ORCA_ADVERTISED_URL_BENCH_ITERATIONS', ITERATIONS],
['ORCA_ADVERTISED_URL_BENCH_ROUNDS', ROUNDS],
['ORCA_ADVERTISED_URL_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
if (ROUNDS % 2 !== 0) {
throw new Error('ORCA_ADVERTISED_URL_BENCH_ROUNDS must be even')
}
class BeforePtyBuffer {
raw = ''
ingest(chunk) {
const chunkHasLineBreak = chunk.includes('\n') || chunk.includes('\r')
this.raw += chunk
if (this.raw.length > BUFFER_LIMIT) {
this.raw = this.raw.slice(-BUFFER_LIMIT)
}
if (!chunkHasLineBreak) {
return ''
}
const lastNewline = lastLineBreak(this.raw)
if (lastNewline === -1) {
return ''
}
const finalized = this.raw.slice(0, lastNewline + 1)
this.raw = this.raw.slice(lastNewline + 1)
return stripTerminalControls(finalized)
}
}
class BeforeWatcher {
buffers = new Map()
ptyToWorktree = new Map()
bindPty(ptyId, worktreeId) {
this.ptyToWorktree.set(ptyId, worktreeId)
}
ingest(ptyId, chunk) {
if (!this.ptyToWorktree.has(ptyId)) {
return 0
}
let buffer = this.buffers.get(ptyId)
if (!buffer) {
buffer = new BeforePtyBuffer()
this.buffers.set(ptyId, buffer)
}
const finalized = buffer.ingest(chunk)
return finalized ? extractUrlCandidates(finalized).length : 0
}
}
function lastLineBreak(text) {
for (let index = text.length - 1; index >= 0; index -= 1) {
const code = text.charCodeAt(index)
if (code === 0x0a || code === 0x0d) {
return index
}
}
return -1
}
function repeatToLine(text, length, sample) {
const unit = `${text}${sample}\n`
return `${unit.repeat(Math.ceil(length / unit.length)).slice(0, length - 1)}\n`
}
function repeatWithoutLineBreak(text, length, sample) {
const unit = `${text}${sample}`
return unit.repeat(Math.ceil(length / unit.length)).slice(0, length)
}
const fixtures = [
{
label: 'plain 32B line',
chunks: Array.from({ length: 32 }, (_, index) => `compiled module ${index} in 183ms\n`)
},
{
label: 'ANSI 32B line',
chunks: Array.from({ length: 32 }, (_, index) => `\x1b[2K\x1b[1GThinking ${index}...\r\n`)
},
{
label: 'guard fallthrough line',
chunks: Array.from(
{ length: 32 },
(_, index) => `path/to/http_server: compiled module ${index}\n`
)
},
{
label: 'plain 4KiB line',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatToLine('INFO build completed module=renderer duration=12ms ', BUFFER_LIMIT, index)
)
},
{
label: 'ANSI 4KiB line',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatToLine(
'\x1b[2K\x1b[1Gthinking about compiler output and writing patches ',
BUFFER_LIMIT,
index
)
)
},
{
label: 'guard fallthrough 4KiB',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatToLine(
'path/to/http_server: compiled without an advertised endpoint ',
BUFFER_LIMIT,
index
)
)
},
{
label: '64KiB partial chunk',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatWithoutLineBreak('compiler output without a line break ', 64 * 1024, index)
)
},
{
label: '256KiB partial chunk',
chunks: Array.from({ length: 32 }, (_, index) =>
repeatWithoutLineBreak('compiler output without a line break ', 256 * 1024, index)
)
}
]
let checksum = 0
function runBefore(chunks, iterations) {
const watcher = new BeforeWatcher()
watcher.bindPty('pty', 'repo::/bench')
let found = 0
for (let index = 0; index < iterations; index += 1) {
found += watcher.ingest('pty', chunks[index % chunks.length])
}
checksum = Math.imul(checksum ^ found ^ watcher.buffers.get('pty').raw.length, 16777619) >>> 0
}
function runAfter(chunks, iterations) {
const watcher = new AdvertisedUrlWatcher()
watcher.bindPty('pty', 'repo::/bench')
for (let index = 0; index < iterations; index += 1) {
watcher.ingest('pty', chunks[index % chunks.length], index)
}
const buffer = watcher.buffers.get('pty')
checksum = Math.imul(checksum ^ (buffer?.raw.length ?? 0), 16777619) >>> 0
}
function median(values) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
function measure(fixture) {
runBefore(fixture.chunks, WARMUP)
runAfter(fixture.chunks, WARMUP)
const before = []
const after = []
for (let round = 0; round < ROUNDS; round += 1) {
const measureBefore = () => {
const startedAt = performance.now()
runBefore(fixture.chunks, ITERATIONS)
before.push(((performance.now() - startedAt) * 1000) / ITERATIONS)
}
const measureAfter = () => {
const startedAt = performance.now()
runAfter(fixture.chunks, ITERATIONS)
after.push(((performance.now() - startedAt) * 1000) / ITERATIONS)
}
if (round % 2 === 0) {
measureBefore()
measureAfter()
} else {
measureAfter()
measureBefore()
}
}
return { before: median(before), after: median(after) }
}
console.log('Advertised URL watcher no-match cost in microseconds/chunk. Lower is better.')
console.log(`iterations=${ITERATIONS}, rounds=${ROUNDS}, warmup=${WARMUP}`)
console.log('fixture before after speedup')
for (const fixture of fixtures) {
const result = measure(fixture)
console.log(
`${fixture.label.padEnd(26)} ${result.before.toFixed(3).padStart(8)} ${result.after
.toFixed(3)
.padStart(10)} ${(result.before / result.after).toFixed(2).padStart(9)}x`
)
}
console.log(`checksum=${checksum}`)
console.log(
'Limitations: synthetic in-process scan; explicit timestamps omit the production clock read and surrounding PTY dispatch.'
)
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.sh')
const guardScript = join(projectDir, '.github/scripts/check-root-directory-entries.mjs')
const tempDirs = []
function git(cwd, args) {
@@ -37,13 +37,45 @@ function commitFiles(root, files) {
return git(root, ['rev-parse', 'HEAD'])
}
// Why: a root entry name can be bytes no filesystem here accepts (APFS rejects
// invalid UTF-8), so build the tree in the object database instead of on disk.
// git ls-tree -z emits exactly the record format git mktree -z reads back.
function commitRawEntries(root, parent, entries) {
const parentTree = execFileSync('git', ['ls-tree', '-z', parent], { cwd: root })
const records = entries.map((name) => {
const blob = execFileSync('git', ['hash-object', '-w', '--stdin'], {
cwd: root,
encoding: 'utf8',
input: 'too prominent\n'
}).trim()
return Buffer.concat([Buffer.from(`100644 blob ${blob}\t`), name, Buffer.from([0])])
})
const tree = execFileSync('git', ['mktree', '-z'], {
cwd: root,
encoding: 'utf8',
input: Buffer.concat([parentTree, ...records])
}).trim()
return execFileSync('git', ['commit-tree', tree, '-p', parent, '-m', 'head'], {
cwd: root,
encoding: 'utf8'
}).trim()
}
function runGuard({ root, base, head }) {
return spawnSync('bash', [guardScript, base, head], {
return runGuardArgs(root, [base, head])
}
function runGuardArgs(root, args) {
return spawnSync(process.execPath, [guardScript, ...args], {
cwd: root,
encoding: 'utf8'
})
}
function runGuardBytes({ root, base, head }) {
return spawnSync(process.execPath, [guardScript, base, head], { cwd: root })
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
@@ -84,6 +116,100 @@ describe('root directory guard', () => {
expect(output).toContain('new-folder')
})
// Why: git escapes odd paths unless it is read NUL-delimited, so dropping -z
// (or decoding the bytes wrong) reports a mangled name nobody can act on.
it.skipIf(process.platform === 'win32')('reports a blocked entry byte-for-byte', () => {
const awkwardName = '日本 root file\nwith newline.txt'
const fixture = makeFixture()
const head = commitFiles(fixture.root, [[awkwardName, 'too prominent\n']])
const result = runGuard({ ...fixture, head })
expect(result.status).toBe(1)
expect(result.stdout).toContain(awkwardName)
})
// Why: decoding git's output as UTF-8 rewrites every invalid byte to U+FFFD, so
// the name the guard prints is not the name anyone has to rename.
it('reports an entry whose name is not valid UTF-8 byte-for-byte', () => {
const rawName = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('-raw.txt')])
const fixture = makeFixture()
const head = commitRawEntries(fixture.root, fixture.base, [rawName])
const result = runGuardBytes({ ...fixture, head })
expect(result.status).toBe(1)
expect(result.stdout.includes(rawName)).toBe(true)
})
// Why: U+FFFD is not injective, so two different invalid names decode to the
// same string and a new root entry gets waved through as pre-existing.
it('does not confuse two different invalid UTF-8 names for the same entry', () => {
const fixture = makeFixture()
const base = commitRawEntries(fixture.root, fixture.base, [
Buffer.concat([Buffer.from([0xc0, 0x80]), Buffer.from('.txt')])
])
const head = commitRawEntries(fixture.root, fixture.base, [
Buffer.concat([Buffer.from([0xc0, 0x81]), Buffer.from('.txt')])
])
const result = runGuardBytes({ root: fixture.root, base, head })
expect(result.status).toBe(1)
expect(result.stdout.toString('latin1')).not.toContain('guard passed')
})
// Why: the runner trims leading spaces before matching '::', so an indented
// entry name still reaches the workflow-command parser and can forge output.
it('prints blocked entries with workflow-command parsing disabled', () => {
const fixture = makeFixture()
const forgedName = '::error title=forged::injected\n::warning::second line.txt'
const head = commitRawEntries(fixture.root, fixture.base, [Buffer.from(forgedName)])
const result = runGuard({ ...fixture, head })
const lines = result.stdout.split('\n')
const stopIndex = lines.findIndex((line) => line.startsWith('::stop-commands::'))
const resumeToken = lines[stopIndex]?.slice('::stop-commands::'.length)
const resumeIndex = lines.indexOf(`::${resumeToken}::`)
const escaped = lines.filter(
(line, index) =>
(index < stopIndex || index > resumeIndex) && line.trimStart().startsWith('::')
)
expect(result.status).toBe(1)
expect(resumeToken).toMatch(/^[\da-f-]{36}$/)
expect(stopIndex).toBeLessThan(resumeIndex)
// Why: the guard's own annotation is the only line the runner may act on.
expect(escaped).toHaveLength(1)
expect(escaped[0]).toContain('Root-level additions blocked')
expect(lines.slice(stopIndex, resumeIndex).join('\n')).toContain(forgedName)
})
it('exits 2 with usage when the two shas are not both supplied', () => {
const fixture = makeFixture()
const result = runGuardArgs(fixture.root, [fixture.base])
expect(result.status).toBe(2)
expect(result.stderr).toContain('<base-sha> <head-sha>')
})
it('fails loudly instead of passing when a sha does not resolve', () => {
const fixture = makeFixture()
const result = runGuardArgs(fixture.root, [
fixture.base,
'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
])
// Why: git's own exit status, not node's. An unhandled throw is also non-zero,
// so assert the status and the absent stack trace or the guard's error
// handling can be deleted without a test noticing.
expect(result.status).toBe(128)
expect(result.stderr).not.toContain('node:internal')
expect(result.stdout).not.toContain('guard passed')
})
it('is wired into the PR verify gate', () => {
const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const guardJob = workflow.jobs.root_directory_guard
@@ -93,7 +219,7 @@ describe('root directory guard', () => {
expect(guardJob.name).toBe('root directory guard')
expect(guardJob.steps[0].with['fetch-depth']).toBe(0)
expect(guardStep.run).toContain('.github/scripts/check-root-directory-entries.sh')
expect(guardStep.run).toContain('node .github/scripts/check-root-directory-entries.mjs')
expect(workflow.jobs.verify.needs).toContain('root_directory_guard')
})
})
+109
View File
@@ -0,0 +1,109 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-daily.202607281300` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
export function createDailyBuildVersion(baseVersion, date) {
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
if (!match) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Daily build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes())
].join('')
// Why: drop any -rc.N tail. Keeping it makes every daily semver-NEWER than the
// RC it was cut from (1.4.160-rc.3-daily.X > 1.4.160-rc.3), which would let an
// ordinary RC-channel check offer untested daily builds to RC users. Stripping
// to the base parks dailies below both rc.N and stable ('daily' < 'rc'
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-daily.${stamp}`
}
/**
* The next build number for `baseVersion`, counting from the titles of existing
* daily releases.
*
* Why the series restarts at 01 on every base version: the number answers "which
* build of 1.4.163 is this", so a counter shared across versions makes it
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
*
* Why the maximum rather than a count: the prune step trims to
* DAILY_RETAIN_COUNT, so a count would roll backwards and reissue a number
* already in use. Titles that predate this naming simply do not match, which is
* how the first build of a version lands on 01.
*/
export function nextDailyBuildNumber(baseVersion, releaseNames = []) {
const prefix = `${baseVersion} • `
const highest = releaseNames.reduce((max, entry) => {
const name = String(entry ?? '')
if (!name.startsWith(prefix)) {
return max
}
const match = /^(\d+) • /.exec(name.slice(prefix.length))
return match ? Math.max(max, Number(match[1])) : max
}, 0)
return highest + 1
}
/**
* `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title,
* shown verbatim in both the GitHub releases list and the in-app build picker.
*/
export function formatDailyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Daily build number must be a positive integer: ${buildNumber}`)
}
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
formatReleaseTitleTimestamp(date),
commit.slice(0, 7)
].join(' • ')
}
// Why the number is derived here rather than passed in: it counts builds of the
// base version, and the base is only known once the published tags have been
// resolved just above. Computing it outside meant numbering against whatever
// version the caller guessed.
export function getDailyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
const version = createDailyBuildVersion(base, now)
const buildNumber = nextDailyBuildNumber(base, releaseNames ?? [])
return {
commit,
version,
buildNumber,
name: formatDailyReleaseName(version, buildNumber, commit, now)
}
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getDailyBuildIdentity(new Date(), {
publishedVersions: readPublishedVersionsFromEnv(),
// Titles are newline separated and contain spaces, so this cannot reuse the
// whitespace split the version list gets.
releaseNames: (process.env.ORCA_DAILY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
})
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
)
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
createDailyBuildVersion,
formatDailyReleaseName,
nextDailyBuildNumber
} from './daily-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createDailyBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp', () => {
expect(createDailyBuildVersion('1.4.160', new Date('2026-07-28T13:00:00Z'))).toBe(
'1.4.160-daily.202607281300'
)
})
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
// every daily semver-NEWER than the RC it was cut from (1.4.160-rc.3-daily.X >
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested daily
// builds to RC users. Dropping it parks dailies below both rc.N and stable,
// reachable only by an explicit pinned jump.
it('drops an in-flight rc tail so dailies never outrank the rc series', () => {
const version = createDailyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T13:00:00Z'))
expect(version).toBe('1.4.160-daily.202607281300')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions('1.4.160-rc.3-daily.202607281300', '1.4.160-rc.3')).toBeGreaterThan(0)
})
// Why: 'daily' sorts before 'hourly' alphabetically, so a daily of the same
// base never outranks an hourly — both stay below rc/stable and only the
// channel picker offers them.
it('sorts below the hourly build of the same base version', () => {
expect(
compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400')
).toBeLessThan(0)
})
it('rejects invalid input', () => {
expect(() => createDailyBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createDailyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('formatDailyReleaseName', () => {
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
// 13:15 UTC is 6:15AM PDT in July (the daily cut time).
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241')
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
})
it('pads to two digits and grows past them', () => {
expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ')
})
it('rejects a build number that is not a positive integer', () => {
expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/)
})
it('rejects an invalid timestamp', () => {
expect(() => formatDailyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})
describe('nextDailyBuildNumber', () => {
const titles = [
'1.4.163 • 01 • Jul 28, 6:00AM • e698241',
'1.4.163 • 02 • Jul 29, 6:00AM • aaaaaaa',
'1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'
]
it('continues the series for the version being built', () => {
expect(nextDailyBuildNumber('1.4.163', titles)).toBe(10)
})
it('restarts at 1 when the base version moves', () => {
expect(nextDailyBuildNumber('1.4.164', titles)).toBe(1)
expect(
nextDailyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 6:00AM • ccccccc'])
).toBe(2)
})
// Why max and not count: pruning trims to DAILY_RETAIN_COUNT, so counting
// would roll backwards and reissue a number already used.
it('takes the highest number, not the count', () => {
expect(nextDailyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 6:00AM • e698241'])).toBe(10)
})
it('starts at 1 with no history at all', () => {
expect(nextDailyBuildNumber('1.4.163')).toBe(1)
expect(nextDailyBuildNumber('1.4.163', [])).toBe(1)
})
it('ignores titles that are not this version', () => {
expect(nextDailyBuildNumber('1.4.16', ['1.4.163 • 09 • Aug 01, 6:00AM • bbbbbbb'])).toBe(1)
expect(nextDailyBuildNumber('1.4.163', ['v1.4.163-daily.202607311300', null, ''])).toBe(1)
})
})
@@ -19,42 +19,6 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
@@ -303,89 +267,6 @@ describe('electron-builder config', () => {
}
})
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
// Why: the two dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch build landing in orca-hourly would be offered to everyone
// riding main.
it('keeps the two dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withAdhocEnv((adhoc) => {
expect(hourly.publish.repo).not.toBe(adhoc.publish.repo)
})
})
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
@@ -0,0 +1,152 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_DAILY',
'ORCA_MAC_ADHOC',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_DAILY_BUILD_VERSION',
'ORCA_ADHOC_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
const withDailyEnv = (assert) => withEnv({ ORCA_MAC_DAILY: '1' }, assert)
const withAdhocEnv = (assert) => withEnv({ ORCA_MAC_ADHOC: '1' }, assert)
describe('electron-builder mac channel config', () => {
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why hourly must notarize despite the round trip: TCC anchors a notarized
// Developer ID app's grants on identifier + team, not on its cdhash, so they
// survive an update. An unnotarized hourly reads as a new client every build
// and loses file access under Documents/Desktop/Downloads with no re-prompt.
it('notarizes hourly builds like releases, and neither locally', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(true)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
expect(electronBuilderConfig.mac.notarize).toBe(false)
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
// Why adhoc carries the identical mac identity to hourly: it installs over a
// real Orca through the same updater path, so the same signing and the same TCC
// argument apply. Only the destination repo differs.
it('builds adhoc artifacts with the release identity and its own repo', () => {
withAdhocEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-adhoc', releaseType: 'prerelease' })
})
})
it('stamps adhoc packages with the adhoc version', () => {
withEnv(
{ ORCA_MAC_ADHOC: '1', ORCA_ADHOC_BUILD_VERSION: '1.4.160-adhoc.20260728140533' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-adhoc.20260728140533' })
}
)
})
it('builds daily artifacts with the release identity and its own repo', () => {
withDailyEnv((config) => {
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.mac.notarize).toBe(true)
expect(config.forceCodeSigning).toBe(true)
expect(config.publish).toMatchObject({ repo: 'orca-daily', releaseType: 'prerelease' })
})
})
it('stamps daily packages with the daily version', () => {
withEnv(
{ ORCA_MAC_DAILY: '1', ORCA_DAILY_BUILD_VERSION: '1.4.160-daily.202607281300' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-daily.202607281300' })
}
)
})
// Why: the dev channels share every packaging decision except where they
// publish, so a future edit that collapses them must not also collapse the
// repos — a branch or daily build landing in orca-hourly would be offered to
// everyone riding main's hourlies.
it('keeps the dev channels on separate repos', () => {
withHourlyEnv((hourly) => {
withDailyEnv((daily) => {
withAdhocEnv((adhoc) => {
expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe(
3
)
})
})
})
})
})
@@ -0,0 +1,167 @@
// Per-locale renderings that are machine-translation errors for a brand or product name, and get
// reverted to the Latin original. Ordinary vocabulary that merely looks like a brand lives in
// locale-generic-ui-terms.mjs and stays translated.
export const BRAND_MISTRANSLATIONS = {
ko: {
Codex: ['사본', '코덱스'],
Gemini: ['쌍둥이자리'],
Claude: ['클로드'],
Grok: ['그록'],
Orca: ['오르카', '범고래'],
Cursor: ['커서'],
OpenCode: ['오픈코드'],
OpenClaw: ['오픈클로'],
OpenClaude: ['오픈클로드'],
Antigravity: ['반중력'],
Continue: ['계속하다'],
Charm: ['매력'],
Goose: ['거위'],
Pi: ['파이'],
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
Git: ['힘내'],
Discord: ['디스코드'],
Linear: ['선형'],
Agent: ['에이전트'],
Agents: ['에이전트'],
agent: ['에이전트'],
agents: ['에이전트'],
Commit: ['커밋'],
Commits: ['커밋'],
commit: ['커밋'],
commits: ['커밋'],
Markdown: ['마크다운', '가격 인하'],
markdown: ['마크다운', '가격 인하'],
Repo: ['저장소', '레포'],
Repos: ['저장소', '레포'],
repo: ['저장소', '레포'],
repos: ['저장소', '레포'],
Terminal: ['터미널'],
Terminals: ['터미널'],
terminal: ['터미널'],
terminals: ['터미널']
},
zh: {
Codex: ['法典'],
Gemini: ['双子座'],
Claude: ['克洛德', '克劳德'],
Grok: ['格罗克'],
Orca: ['虎鲸', '逆戟鲸'],
Cursor: ['光标'],
OpenCode: ['开放代码'],
OpenClaw: ['开爪'],
OpenClaude: ['开放克劳德'],
Antigravity: ['反重力'],
Continue: ['继续'],
Charm: ['魅力'],
Goose: ['鹅'],
Pi: ['圆周率'],
Droid: ['机器人'],
Kimi: ['基米'],
// Why: zh-only — 登月计划 is the literal "moon landing programme", not Kimi's vendor. ja and
// ko render this keyword their own way, so it must not go in the cross-locale preserve set.
moonshot: ['登月计划'],
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
Bitbucket: ['位桶'],
Linear: ['线性', '线形'],
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: ['法典', 'コーデックス'],
Gemini: ['双子座'],
Claude: ['クロード'],
Grok: ['グロック'],
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
Cursor: ['カーソル'],
OpenCode: ['オープンコード', 'オープン・コード'],
OpenClaw: ['オープンクロー'],
OpenClaude: ['オープンクロード'],
Antigravity: ['反重力'],
Continue: ['続ける', '続行'],
Charm: ['魅力'],
Goose: ['ガチョウ', '雁'],
Pi: ['円周率'],
Droid: ['ロボット', 'ドロイド'],
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
Discord: ['不和'],
Linear: ['線形'],
Agent: ['エージェント'],
Agents: ['エージェント'],
agent: ['エージェント'],
agents: ['エージェント'],
Commit: ['コミット'],
Commits: ['コミット'],
commit: ['コミット'],
commits: ['コミット'],
Markdown: ['マークダウン'],
markdown: ['マークダウン'],
Hermes: ['エルメス', 'ヘルメス'],
PowerShell: ['パワーシェル'],
Mermaid: ['マーメイド'],
'Claude Code': ['Claude コード'],
'VS Code': ['VS コード'],
Repo: ['リポジトリ', 'リポ'],
Repos: ['リポジトリ', 'リポ'],
repo: ['リポジトリ', 'リポ'],
repos: ['リポジトリ', 'リポ'],
// 端子 is the zh mistranslation and never appears in ja; the ja one is 端末, which the phrase
// fixes rewrite to ターミナル rather than back to Latin.
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']
}
}
@@ -46,5 +46,16 @@ export const CJK_LATIN_SPACED_TERMS = [
'PowerShell',
'Git AI Author',
'Token',
'token'
'token',
'fast-forward',
'Cookie',
'Cookies',
'upstream',
'origin',
'Hermes',
'DuckDuckGo',
'Mermaid',
'VS Code',
'IDE',
'Git'
]
@@ -0,0 +1,147 @@
#!/usr/bin/env node
// Benchmarks optioned localeCompare calls used by renderer list comparators.
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
import { createJiti } from 'jiti'
const ROUND_COUNT = 5
const MIN_ROUND_MS = 120
const jiti = createJiti(import.meta.url, {
alias: { '@': fileURLToPath(new URL('../../src/renderer/src', import.meta.url)) }
})
const { compareBaseSensitivityLocaleText } = await jiti.import(
'../../src/renderer/src/lib/locale-text-collators.ts'
)
const { sortJiraIssues } = await jiti.import(
'../../src/renderer/src/components/jira-issue-sorter.ts'
)
let randomState = 0x9e3779b9
function random() {
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
return randomState / 0x100000000
}
function shuffle(values) {
for (let index = values.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(random() * (index + 1))
;[values[index], values[swapIndex]] = [values[swapIndex], values[index]]
}
return values
}
const labels = ['alpha', 'Álpha', 'beta', 'BÉTA', 'café', 'Cafe', 'zeta', 'Ångström']
function makeJiraIssues(count) {
return shuffle(
Array.from({ length: count }, (_, index) => ({
key: `TASK-${(index * 37) % (count + 17)}`,
title: labels[index % labels.length],
updatedAt: '2026-01-01T00:00:00.000Z'
}))
)
}
function makeBaseSensitivityValues(count) {
return shuffle(
Array.from(
{ length: count },
(_, index) =>
`${labels[(index * 5) % labels.length]}-${String((index * 41) % (count + 23)).padStart(4, '0')}`
)
)
}
function calibrate(run) {
for (let index = 0; index < 5; index += 1) {
run()
}
let iterations = 1
while (true) {
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
run()
}
if (performance.now() - startedAt >= MIN_ROUND_MS) {
break
}
iterations *= 2
}
return iterations
}
function measureRound(run, iterations) {
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
run()
}
return (performance.now() - startedAt) / iterations
}
function measurePair(before, after) {
const beforeIterations = calibrate(before)
const afterIterations = calibrate(after)
const beforeSamples = []
const afterSamples = []
for (let round = 0; round < ROUND_COUNT; round += 1) {
if (round % 2 === 0) {
beforeSamples.push(measureRound(before, beforeIterations))
afterSamples.push(measureRound(after, afterIterations))
} else {
afterSamples.push(measureRound(after, afterIterations))
beforeSamples.push(measureRound(before, beforeIterations))
}
}
const middle = Math.floor(ROUND_COUNT / 2)
return {
beforeMs: beforeSamples.sort((a, b) => a - b)[middle],
afterMs: afterSamples.sort((a, b) => a - b)[middle]
}
}
function assertSameOrder(before, after, label) {
const expected = before()
const actual = after()
if (
expected.length !== actual.length ||
expected.some((value, index) => value !== actual[index])
) {
throw new Error(`${label} sort order changed`)
}
}
const pad = (value, width) => String(value).padStart(width)
console.log('Renderer locale sort, ms per sort (median of 5 rounds). Lower is better.')
console.log(
`${pad('mode', 9)} ${pad('items', 7)} ${pad('per-call', 11)} ${pad('reused', 11)} ${pad('speedup', 9)}`
)
for (const count of [36, 50, 250]) {
const issues = makeJiraIssues(count)
const before = () =>
[...issues]
.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
.map((issue) => issue.key)
const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key)
assertSameOrder(before, after, `numeric ${count}`)
const { beforeMs, afterMs } = measurePair(before, after)
console.log(
`${pad('numeric', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
for (const count of [10, 50, 250]) {
const values = makeBaseSensitivityValues(count)
const before = () =>
[...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
const after = () => [...values].sort(compareBaseSensitivityLocaleText)
assertSameOrder(before, after, `base ${count}`)
const { beforeMs, afterMs } = measurePair(before, after)
console.log(
`${pad('base', 9)} ${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\n36 rows matches the Linear page size, 50 matches the picker/Jira scale, and\n250 is a stress case. Both arms assert identical output before timing.'
)
@@ -0,0 +1,10 @@
// Japanese overrides for English values whose translation depends on the call site.
export const JA_KEY_OVERRIDES = {
// "on" is the toggle state here, but the preposition in the delete confirmation below.
'auto.components.github.PRFilterSections.1e9b5244f2': { ja: 'オン' },
'auto.components.settings.TerminalPane.29154326bb': { ja: 'オン' },
'auto.components.automations.AutomationsPage.1b586f0e2b': { ja: 'の' },
// Bare "Open" is the PR state elsewhere; these two are the action, as ko/zh/es render it.
'auto.components.browser.pane.BrowserPane.756bfc25c9': { ja: '開く' },
'auto.components.right.sidebar.checks.panel.content.7c1f0a2b11': { ja: '開く' }
}
+73 -7
View File
@@ -1,9 +1,46 @@
// Japanese phrase fixes from high-visibility UI audit rounds 1–4.
// Why: keep locale-phrase-fixes.mjs under max-lines while preserving repair coverage.
const JA_SENTENCE_CHAR = '[぀-ヿ一-龯、。・「」()]'
// Two-word commands (`git commit`, `orca terminal`) must keep their second word in Latin.
const COMMAND_HEAD = '(?<!\\b(?:git|gh|glab|orca|npm|pnpm|npx|yarn|docker|kubectl) )'
// #12113 stopped new damage; ~700 values still read "terminal を閉じる" and are healed here.
// Anchored on adjacent Japanese, so `--agent`, agents.md and "Agent SDK" keep their spelling.
const RELOCALIZED_GENERIC_TERMS = [
['[Tt]erminals?', 'ターミナル', 'terminal'],
['[Cc]ommits?', 'コミット', 'commit'],
['[Rr]epos?', 'リポジトリ', 'repo'],
// Agent stays Latin in ja; this only normalizes the case.
['[Aa]gents?', 'Agent', 'agent'],
['[Ww]orktrees?', 'ワークツリー', 'worktree']
].flatMap(([latin, katakana, whenEnIncludes]) => [
{
// The trailing space only goes when Japanese follows, so 和欧間スペース survives before {{…}}.
pattern: new RegExp(
`(?<=${JA_SENTENCE_CHAR}) ?${COMMAND_HEAD}${latin}(?![-\\w.])(?! [A-Za-z])(?: (?=${JA_SENTENCE_CHAR}))?`,
'g'
),
replacement: katakana,
whenEnIncludes
},
{
pattern: new RegExp(
`(?<![-\\w])${COMMAND_HEAD}${latin}(?![-\\w.]) ?(?=${JA_SENTENCE_CHAR})`,
'g'
),
replacement: katakana,
whenEnIncludes
}
])
export const JA_PHRASE_FIXES = [
...RELOCALIZED_GENERIC_TERMS,
{ pattern: /エージェント/g, replacement: 'Agent', whenEnIncludes: 'agent' },
{ pattern: /解雇/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
{ pattern: /却下/g, replacement: '閉じる', whenEnIncludes: 'Dismiss' },
{ pattern: /代理人/g, replacement: 'エージェント', whenEnIncludes: 'agent' },
{ pattern: /代理人/g, replacement: 'Agent', whenEnIncludes: 'agent' },
{ pattern: /支店/g, replacement: 'ブランチ', whenEnIncludes: 'ranch' },
{ pattern: /港(?!口)/g, replacement: 'ポート', whenEnIncludes: 'ort' },
{ pattern: /会議/g, replacement: 'セッション', whenEnIncludes: 'session' },
@@ -198,7 +235,7 @@ export const JA_PHRASE_FIXES = [
{ pattern: /電話サイズ/g, replacement: 'スマートフォンサイズ', whenEnIncludes: 'phone' },
{
pattern: /コンピュータと電話/g,
replacement: 'コンピューターとスマートフォン',
replacement: 'コンピュータとスマートフォン',
whenEnIncludes: 'phone'
},
{ pattern: /前の問題/g, replacement: 'フロントマター', whenEnIncludes: 'Front Matter' },
@@ -235,11 +272,40 @@ export const JA_PHRASE_FIXES = [
whenEnIncludes: 'More PR actions'
},
{ pattern: /アクション/g, replacement: '操作', whenEnIncludes: 'action' },
{ pattern: /を選択してください/g, replacement: 'を選択', whenEnIncludes: 'Select' },
{ pattern: /を選択してください/g, replacement: 'を選択', whenEnIncludes: 'Choose' },
{ pattern: /入力してください/g, replacement: '入力', whenEnIncludes: 'Enter' },
{ pattern: /追加してください/g, replacement: '追加', whenEnIncludes: 'Add' },
{ pattern: /試してください/g, replacement: '試す', whenEnIncludes: 'Try' },
// Orca's host covers SSH hosts and this computer; skipped where the English also says server.
{
pattern: /(ランタイム)?サーバー/g,
replacement: 'ホスト',
whenEnMatches: /^(?![\s\S]*\bservers?\b)[\s\S]*\bhosts?\b/i
},
{ pattern: /上流/g, replacement: 'upstream', whenEnIncludes: 'upstream' },
{ pattern: /起源/g, replacement: 'origin', whenEnIncludes: 'origin' },
{ pattern: /段階的な変更/g, replacement: 'ステージ済みの変更', whenEnIncludes: 'staged' },
{ pattern: /紛争/g, replacement: '競合', whenEnIncludes: 'conflict' },
{ pattern: /資格情報/g, replacement: '認証情報', whenEnIncludes: 'credential' },
{ pattern: /未知/g, replacement: '不明', whenEnIncludes: 'unknown' },
{ pattern: /クッキー/g, replacement: 'Cookie', whenEnIncludes: 'cookie' },
{ pattern: /プロフィール/g, replacement: 'プロファイル', whenEnIncludes: 'profile' },
{ pattern: /(?<![ンプリ])ロード中/g, replacement: '読み込み中', whenEnIncludes: 'load' },
{
pattern: /(?<![ンプリ])ロードしています/g,
replacement: '読み込んでいます',
whenEnIncludes: 'load'
},
{ pattern: /構成されて/g, replacement: '設定されて', whenEnIncludes: 'configur' },
{ pattern: /第一方/g, replacement: 'ファーストパーティ', whenEnIncludes: 'first-party' },
{ pattern: /早送り/g, replacement: 'fast-forward', whenEnIncludes: 'fast-forward' },
{ pattern: /ファストフォワード/g, replacement: 'fast-forward', whenEnIncludes: 'fast-forward' },
{ pattern: /([぀-ヿ一-龯])\.\.\./g, replacement: '$1…', whenEnMatches: /./ },
{ pattern: /([゠-ヿ]) (?=[゠-ヿ])/g, replacement: '$1', whenEnMatches: /./ },
{ pattern: /サーバ(?!ー)/g, replacement: 'サーバー', whenEnMatches: /./ },
{ pattern: /フォルダ(?!ー)/g, replacement: 'フォルダー', whenEnMatches: /./ },
{ pattern: /エディタ(?!ー)/g, replacement: 'エディター', whenEnMatches: /./ },
{ pattern: /レンダラ(?!ー)/g, replacement: 'レンダラー', whenEnMatches: /./ },
{ pattern: /ブラウザー/g, replacement: 'ブラウザ', whenEnMatches: /./ },
{ pattern: /エミュレーター/g, replacement: 'エミュレータ', whenEnMatches: /./ },
{ pattern: /コンピューター/g, replacement: 'コンピュータ', whenEnMatches: /./ },
{ pattern: /インターフェイス/g, replacement: 'インターフェース', whenEnMatches: /./ },
// Why: JP engineers use "Issue" in Latin, not katakana. Runs last so all *→イシュー fixes above normalize to Issue.
{ pattern: /イシュー/g, replacement: 'Issue', whenEnIncludes: 'issue' }
]
+246
View File
@@ -0,0 +1,246 @@
// One rendering per English string. 215 sources carried two or more Japanese forms; picks follow
// the catalog majority — action labels drop する, completion notices use 〜しました, status labels
// are 体言, and デフォルト / フィルター / スコープ / ディストリビューション / 並べ替え win their pairs.
export const JA_UNIFIED_VALUES = {
"Can't reach {{value0}}": '{{value0}} に接続できません',
'Copy Address': 'アドレスをコピー',
'Open Externally': '外部で開く',
'Show Orca Mobile Button': 'Orca Mobile ボタンを表示',
Paste: '貼り付け',
'WSL default': 'WSL のデフォルト',
'Unable to open the workspace attached to this issue.':
'この Issue にリンクされたワークスペースを開けませんでした。',
'Failed to connect to project.': 'プロジェクトへの接続に失敗しました。',
'Dev-only tools for exercising UI states.': 'UI の状態を確認するための開発者専用ツール。',
'Start new workspace': '新規ワークスペースを開始',
'Open workspace attached to issue': 'Issue にリンクされたワークスペースを開く',
'No one assigned': '担当者なし',
'Edit assignees': '担当者を編集',
'Edit labels on GitHub': 'GitHub でラベルを編集',
'check #': 'チェック #',
Started: '開始済み',
'Status:': 'ステータス:',
'Loading check details…': 'チェックの詳細を読み込んでいます…',
'No description provided.': '説明はありません。',
'Review comment added.': 'レビューコメントを追加しました。',
Copied: 'コピーしました',
'No activity yet.': 'まだアクティビティはありません。',
Merge: 'マージ',
'Create workspace': 'ワークスペースを作成',
'Loading labels': 'ラベルを読み込んでいます',
Hide: '非表示',
Resolve: '解決',
'Start workspace': 'ワークスペースを開始',
Unassigned: '未割り当て',
'Copy prompt': 'プロンプトをコピー',
'Failed to copy {{value0}}': '{{value0}} のコピーに失敗しました',
'{{value0}} copied': '{{value0}} をコピーしました',
'Add project': 'プロジェクトを追加',
'Cloning...': 'クローン作成中…',
'Add SSH host': 'SSH ホストを追加',
Later: '後で',
"Don't ask again": '今後表示しない',
'Atlassian account settings': 'Atlassian のアカウント設定',
'Create a token in': 'でトークンを作成します',
'Atlassian API token': 'Atlassian API トークン',
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.':
'Jira Cloud のサイト URL、Atlassian のメールアドレス、API トークンを使って Issue を閲覧します。',
'Connect Jira site': 'Jira サイトに接続',
Updated: '更新済み',
Owner: 'オーナー',
'Connect Jira': 'Jira に接続',
'Change the query or clear it.': '検索条件を変更するか、クリアしてください。',
'No matching GitHub work': '一致する GitHub の作業はありません',
'No GitLab work matches this filter.': 'このフィルターに一致する GitLab の作業はありません。',
'No GitLab MRs match this filter.': 'このフィルターに一致する GitLab の MR はありません。',
'No GitLab issues match this filter.': 'このフィルターに一致する GitLab の Issue はありません。',
Resume: '再開',
github: 'GitHub',
'Could not build launch command for {{value0}}.':
'{{value0}} の起動コマンドを作成できませんでした。',
Update: '更新',
'Re-check': '再確認',
'Checks passed': 'チェックに合格しました',
'Copy command': 'コマンドをコピー',
Pinned: 'ピン留め済み',
'Remove {{value0}} filter': '{{value0}} フィルターを解除',
selected: '選択中',
'Remove {{value0}}': '{{value0}} を削除',
'Last active': '最終アクティブ',
'Unpushed commits': '未プッシュのコミット',
Ignored: '無視済み',
Sort: '並べ替え',
Context: 'コンテキスト',
'Delete {{value0}}': '{{value0}} を削除',
Commits: 'コミット',
'Expand details': '詳細を展開',
'Uncommitted changes': '未コミットの変更',
'No projects match your search.': '検索条件に一致するプロジェクトはありません。',
Scope: 'スコープ',
'Context copied': 'コンテキストをコピーしました',
'Worktree not ready — try again in a moment.':
'ワークツリーが準備できていません — しばらくしてから再試行してください。',
'Open a workspace before resuming a session.':
'セッションを再開する前に、ワークスペースを開いてください。',
Run: '実行',
'ios simulator': 'iOS シミュレータ',
'Kill session {{value0}}': 'セッション {{value0}} を強制終了',
Disconnected: '切断済み',
'OpenCode Go Usage': 'OpenCode Go の使用量',
'Switch to': '切り替え先',
'Update failed': '更新に失敗しました',
cancelling: 'キャンセル中',
Unavailable: '利用不可',
'Usage unavailable': '使用量を取得できません',
Usage: '使用量',
Bundled: '同梱',
'Update available': '更新があります',
Skipped: 'スキップ済み',
'1 update available': '1 件の更新があります',
'{{value0}} updates available': '{{value0}} 件の更新があります',
Stop: '停止',
'Up to date': '最新',
'Host path': 'ホストパス',
'Open project on SSH host': 'SSH ホスト上のプロジェクトを開く',
'Clone from URL': 'URL からクローン',
more: '件以上',
'Keep hidden': '非表示のままにする',
Manual: '手動',
Automation: 'オートメーション',
Notes: 'ノート',
'Board settings': 'ボード設定',
'Project on SSH host': 'SSH ホスト上のプロジェクト',
'Created by automation': 'オートメーションによって作成',
'Open in Orca': 'Orca で開く',
'Failed to copy link': 'リンクのコピーに失敗しました',
'Delete Worktree': 'ワークツリーを削除',
rename: '名前変更',
'Create workspace for {{value0}}': '{{value0}} のワークスペースを作成',
'Browse folder': 'フォルダーを参照',
'{{value0}} failed': '{{value0}} 件失敗',
Neutral: '中立',
'Set up': 'セットアップ',
'Add a persistent machine you can log into over SSH.':
'SSH でログインできる永続的なマシンを追加します。',
'on the server and paste the printed pairing URL.':
'をサーバーで実行し、出力されたペアリング URL を貼り付けます。',
'Keep Local Main Up to Date': 'ローカル main を最新の状態に保つ',
Saved: '保存済み',
Replace: '置換',
'Account scope': 'アカウントスコープ',
'Agent Permissions': 'Agent の権限',
'Show the Orca Mobile button at the top of the left sidebar.':
'左サイドバー上部に Orca Mobile ボタンを表示します。',
'Enable Orca CLI': 'Orca CLI を有効化',
'Registered the Orca CLI in PATH.': 'Orca CLI を PATH に登録しました。',
'Failed to load CLI status.': 'CLI ステータスの読み込みに失敗しました。',
'Fix PATH': 'PATH を修正',
installed: 'インストール済み',
cli: 'CLI',
'Opened macOS Privacy & Security': 'macOS のプライバシーとセキュリティを開きました',
'Could not request permission': '権限をリクエストできませんでした',
Shown: '表示中',
'Clear all': 'すべてクリア',
'Each connected Linear workspace has one key stored by the active runtime. Full-access keys can cover all teams the key owner can access; restricted keys can be replaced any time.':
'接続された各 Linear ワークスペースには、アクティブなランタイムが保存したキーが 1 つあります。フルアクセスキーはキー所有者がアクセスできるすべてのチームを対象にできます。制限付きキーはいつでも置き換えられます。',
'Add workspace access': 'ワークスペースへのアクセスを追加',
'Learn more': '詳しく見る',
'Gitea credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.':
'Gitea の認証情報は設定されていますが、認証できませんでした。トークン、API ベース URL、リポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
'Azure DevOps credentials are configured but could not authenticate. Check the token, API base URL, and repository permissions, then restart Orca if environment variables changed.':
'Azure DevOps の認証情報は設定されていますが、認証できませんでした。トークン、API ベース URL、リポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
'Bitbucket credentials are configured but could not authenticate. Check the token and repository permissions, then restart Orca if environment variables changed.':
'Bitbucket の認証情報は設定されていますが、認証できませんでした。トークンとリポジトリの権限を確認し、環境変数を変更した場合は Orca を再起動してください。',
'The GitLab CLI is installed but not authenticated. Run this command in a terminal:':
'GitLab CLI はインストールされていますが、認証されていません。ターミナルで次のコマンドを実行してください:',
'Install GitLab CLI': 'GitLab CLI をインストール',
'CLI.': 'CLI。',
'The GitHub CLI is installed but not authenticated. Run this command in a terminal:':
'GitHub CLI はインストールされていますが、認証されていません。ターミナルで次のコマンドを実行してください:',
'Install GitHub CLI': 'GitHub CLI をインストール',
'Disconnect {{value0}}': '{{value0}} の接続を解除',
'Add Linear access to browse and link issues.':
'Linear アクセスを追加して、Issue を閲覧およびリンクします。',
'Pull requests and build statuses via Azure DevOps REST API tokens.':
'Azure DevOps REST API トークン経由の PR とビルドステータス。',
'Pull requests and build statuses via Bitbucket Cloud API tokens.':
'Bitbucket Cloud API トークン経由の PR とビルドステータス。',
'Reload from Disk': 'ディスクから再読み込み',
'Refresh network interfaces': 'ネットワークインターフェースを更新',
'CLI arguments': 'CLI 引数',
Created: '作成日時',
Switch: '切り替え',
'Status unavailable': 'ステータスを取得できません',
'Checking…': '確認中…',
'Updating servers…': 'サーバーを更新しています…',
'{{value0}} shortcut': '{{value0}} のショートカット',
'Connect {{provider}}': '{{provider}} に接続',
'Try again': '再試行',
'Windows default': 'Windows のデフォルト',
'Loading distributions': 'ディストリビューションを読み込んでいます',
'WSL Distribution': 'WSL ディストリビューション',
'Default Shell': 'デフォルトのシェル',
'Right-click pastes the clipboard. Ctrl+right-click opens the context menu.':
'右クリックでクリップボードを貼り付けます。Ctrl+右クリックでコンテキストメニューを開きます。',
credentials: '認証情報',
display: 'ディスプレイ',
setup: 'セットアップ',
note: 'メモ',
update: '更新',
'default branch': 'デフォルトブランチ',
target: '対象',
'Client default': 'クライアントのデフォルト',
'Select distro': 'ディストリビューションを選択',
'Sign out': 'サインアウト',
'Available on': 'で利用可能',
'Update this server manually once to enable remote updates.':
'リモート更新を有効にするには、このサーバーを一度手動で更新してください。',
Official: '公式',
'Open on {{value0}}': '{{value0}} で開く',
'Create {{value0}}': '{{value0}} を作成',
'{{value0}} #{{value1}} is already open': '{{value0}} #{{value1}} はすでに開いています',
'Copy Details': '詳細をコピー',
'Clear Notes': 'ノートをクリア',
'Fix with AI': 'AI で修正',
'AI Fix': 'AI 修正',
'Committed on Branch': 'コミット先のブランチ',
Changes: '変更点',
'Filter files by name': 'ファイル名でフィルター',
'Choose the agent and edit the full command input before launch.':
'起動前に Agent を選択し、コマンド入力全体を編集してください。',
'Unable to resolve the workspace connection.': 'ワークスペースの接続を解決できません。',
'Branch has unpushed commits': 'ブランチに未プッシュのコミットがあります',
'No enabled AI agents. Configure agents in Settings.':
'有効な AI Agent がありません。[設定] で Agent を構成してください。',
'Saved AI agent is unavailable. Use Customize launch to choose another agent.':
'保存された AI Agent は使用できません。[起動のカスタマイズ] から別の Agent を選択してください。',
'Commit failure prompt is empty. Update Source Control AI settings.':
'コミット失敗プロンプトが空です。ソース管理の AI 設定を更新してください。',
'Create PR': 'PR を作成',
'{{value0}} msgs': '{{value0}} 件のメッセージ',
'No projects/folders match your search.': '検索に一致するプロジェクト/フォルダーはありません。',
Keep: '保持',
'Skill command copied and inserted below for review.':
'スキルコマンドをコピーし、確認用に以下へ挿入しました。',
'Try it out': '試してみる',
'1 pending': '保留中 1 件',
'Zoom in': 'ズームイン',
'Zoom out': 'ズームアウト',
Undo: '元に戻す',
'{{value0}} failing': '{{value0}} 件失敗',
'{{value0}} pending': '{{value0}} 件保留中',
'Draft review': '下書きレビュー',
'Switch profile': 'プロファイルを切り替え',
'Draw on the page, then copy the markup to paste into your agent.':
'ページに描き込み、マークアップをコピーして Agent に貼り付けます。',
// Grab mode picks a page element and hands it to the AI; 掴む / グラブモード were literal.
Grab: '取得',
'Grab mode is unavailable in the web client.':
'要素の取得モードは Web クライアントでは使用できません。',
agent: 'Agent',
'{{value0}} workspace{{value1}} connected':
'{{value0}} ワークスペース{{value1}} が接続されました',
Plugins: 'プラグイン',
'Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}':
'Orca は引き続き使用できますが、このセッションでは orca status や orca terminal などのコマンドとオーケストレーションを利用できません。\n\n{{guidance}}\n\n原因: {{cause}}'
}
+320 -10
View File
@@ -1,25 +1,27 @@
import { JA_UNIFIED_VALUES } from './locale-ja-unified-values.mjs'
// Japanese value overrides from high-visibility UI audit rounds 1–4.
// Why: keep locale-value-overrides.mjs under max-lines while preserving exact-match repairs.
export const JA_VALUE_OVERRIDES = {
const JA_AUDIT_OVERRIDES = {
'Retry loading presets': 'プリセットの読み込みを再試行',
'Retry Download': 'ダウンロードを再試行',
'Enter a reviewer': 'レビュアーを入力',
'Choose folder': 'フォルダーを選択',
'Choose pet': 'ペットを選択',
'Choose a sound': 'サウンドを選択',
'Pick your default agent': 'デフォルトのエージェントを選択',
'Pick your default agent': 'デフォルトの Agent を選択',
'Complete {{artifact_url}}': '{{artifact_url}} を完了',
'Control terminals and agents from your phone.':
'スマートフォンからターミナルとエージェントを操作',
'スマートフォンからターミナルと Agent を操作します。',
'Add reviewer': 'レビュアーを追加',
Reviewer: 'レビュアー',
Reviewers: 'レビュアー',
'Fix broken checks': '失敗したチェックを修正',
'No broken checks to fix.': '修正が必要な失敗したチェックはありません',
'No broken checks to fix.': '修正が必要なチェックはありません。',
'Started an AI agent for the broken checks.':
'失敗したチェックに対して AI エージェントを開始しました。',
'失敗したチェックに対して AI Agent を開始しました。',
'Failed to start an AI agent for the broken checks: {{value0}}':
'失敗したチェックのため AI エージェントを開始できませんでした: {{value0}}',
'失敗したチェックのため AI Agent を開始できませんでした: {{value0}}',
'Checks unavailable': 'チェックは利用できません',
'Checks pending': '保留中のチェック',
checks: 'チェック',
@@ -92,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
Launch: '起動',
'Launch:': '起動:',
'Launch plan': '起動プラン',
'Launch agent': 'エージェントを起動',
'Launch agent': 'Agent を起動',
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
Play: '再生',
Action: '操作',
@@ -148,8 +150,8 @@ export const JA_VALUE_OVERRIDES = {
'Select a workspace to browse files': 'ファイルを参照するワークスペースを選択',
'Select a workspace to search': '検索するワークスペースを選択',
'Select a workspace to view changes': '変更を表示するワークスペースを選択',
'Choose agent to fix commit failure': 'コミット失敗を修正するエージェントを選択',
'Choose an agent for this commit failure': 'このコミット失敗に対するエージェントを選択',
'Choose agent to fix commit failure': 'コミット失敗を修正する Agent を選択',
'Choose an agent for this commit failure': 'このコミット失敗に対する Agent を選択',
'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.':
@@ -234,5 +236,313 @@ export const JA_VALUE_OVERRIDES = {
'Cursor Opacity': 'カーソルの不透明度',
'Cursor Shape': 'カーソル形状',
'Blinking Cursor': 'カーソルの点滅',
'Terminal Cursor': 'Terminal カーソル'
'Terminal Cursor': 'ターミナルのカーソル',
'Local folders and Git repositories': 'ローカルフォルダーと Git リポジトリ',
'Started an AI agent for the conflicts.': '競合の解決のために AI Agent を開始しました。',
'Previous conflict': '前の競合',
'1 commit ahead of {{value0}}': '{{value0}} より 1 コミット進んでいます',
'1 commit behind {{value0}}': '{{value0}} より 1 コミット遅れています',
'3 commits ahead': '3 コミット進んでいます',
'Fast-forwarded {{branch}} by 1 commit.': '{{branch}} を 1 コミット分 fast-forward しました。',
'e.g. cloudflared access ssh --hostname %h': '例: cloudflared access ssh --hostname %h',
'Git is not installed': 'Git がインストールされていません',
'CLI not installed': 'CLI がインストールされていません',
'Locate SDK folder': 'SDK フォルダーを選択',
'Not found. Install Android Studio, then create a Virtual Device.':
'見つかりません。Android Studio をインストールしてから、仮想デバイスを作成してください。',
'ANSI blue color': 'ANSI青色',
'MR !{{value0}}': 'MR !{{value0}}',
mrs: 'MR',
'No human comments.': 'ユーザーによるコメントはありません。',
'Any state': 'すべての状態',
State: '状態',
Health: '健全性',
Ordering: '並べ替え',
Start: '開始',
'Local {{value0}} is behind {{value1}}': 'ローカル {{value0}} は {{value1}} より遅れています',
'behind main': 'main より遅れています',
'behind (base commit:': '遅れ (ベースコミット:',
'No pending todos. You’re all caught up!': '保留中の ToDo はありません。すべて完了しています!',
High: '高',
Medium: '中',
Low: '低',
'Split Terminal Down': 'ターミナルを下に分割',
'Split Terminal Right': 'ターミナルを右に分割',
'Split Up': '上に分割',
'Split Down': '下に分割',
"Install the Browser Use skill so agents can operate Orca's browser.":
'Browser Use スキルをインストールすると、 Agent が Orca のブラウザを操作できるようになります。',
'bold ·': '太字 ·',
'Duplicate of another issue in this repository': 'このリポジトリ内の別の Issue と重複',
'Choose a different issue.': '別の Issue を選択してください。',
'No matching issues loaded.': '一致する Issue がありません。',
'Duplicate of another issue': '別の Issue と重複',
'mentioned this': 'がこの Issue をメンションしました',
'closed this': 'がこの Issue をクローズしました',
'reopened this': 'がこの Issue を再オープンしました',
'moved this': 'がこの Issue を移動しました',
'Failed to save setup startup behavior.': 'セットアップ時の起動設定を保存できませんでした。',
'Failed to resolve MR base.': 'MR のベースブランチを特定できませんでした。',
'Failed to copy context.': 'コンテキストのコピーに失敗しました。',
// 3-way split: [this][query]["]. The closing side is already 」, so end the sentence here.
'No settings found for "': '検索条件に一致する設定が見つかりませんでした:「',
"Doesn't apply to agents where you've overridden launch arguments.":
'起動引数を上書きしている Agent には適用されません。',
'Check for Server Updates': 'サーバーの更新を確認',
'All servers are up to date.': 'すべてのサーバーが最新です。',
'Open worktree': 'ワークツリーを開く',
'Update this server': 'このサーバーを更新',
'Git repository': 'Git リポジトリ',
'{{host}} is no longer a saved SSH host, so this workspace is no longer connected to a live host. It can only be removed from Orca — files and branches on the remote are left untouched.':
'{{host}} は保存済みの SSH ホストではなくなったため、このワークスペースは稼働中のホストに接続されていません。Orca から削除することのみ可能です。リモート上のファイルとブランチはそのまま残ります。',
'The app shell could not finish rendering. Retry to remount it, or relaunch Orca if the error persists.':
'アプリシェルのレンダリングを完了できませんでした。再マウントを試すか、エラーが解決しない場合は Orca を再起動してください。',
'Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server.':
'モバイル向けペアリングではワークスペースを使用できません。[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] から、ブラウザ用リンクで再接続してください。',
'Change this later in Settings → Browser.': '後で [設定] → [ブラウザ] から変更できます。',
'Orca Cloud sign-in is not configured': 'Orca Cloud へのサインインが設定されていません',
'{{provider}} could not authenticate the credentials available in this environment. Check the {{provider}} login or environment token, then retry.':
'{{provider}} はこの環境で利用できる認証情報で認証できませんでした。{{provider}} のログインまたは環境トークンを確認して、再試行してください。',
'e.g. ollama run llama3.1 {prompt}': '例: ollama run llama3.1 {prompt}',
'e.g. ollama run llama3.1 {{value0}}': '例: ollama run llama3.1 {{value0}}',
'Connecting terminal...': 'ターミナルに接続中…',
'Starring…': 'スターを付けています…',
'self-hosted': 'セルフホスト',
'not-authenticated': '未認証',
'side-by-side': '左右に並べて',
'Side-by-side': '左右に並べて',
'Pull requests and commit statuses via the Gitea REST API.':
'Gitea REST API 経由の PR とコミットステータス。',
Key: 'キー',
slug: 'スラッグ',
// Rendered as [fragment][var name][fragment] by two cards that share these fragments.
'Public repositories are detected from their git remote. Set':
'パブリックリポジトリは git リモートから検出されます。設定:',
'for private repositories, and set': '(プライベートリポジトリ用)。また、',
'only when Orca cannot derive the API base URL from the git remote.':
'は、Orca が git リモートから API のベース URL を取得できない場合のみ設定します。',
'only when Orca cannot derive the API URL from the remote.':
'は、Orca がリモートから API URL を取得できない場合のみ設定します。',
Set: '設定:',
'. Set': '。設定:',
', or set': '、または',
and: 'と',
// Concatenated: the leading space is part of the string, not padding.
' and ': ' と ',
' in {{value0}}': '({{value0}})',
' • Last scan error: {{value0}}': ' • 最終スキャンエラー: {{value0}}',
' vs {{value0}}': ' vs {{value0}}',
'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as':
'ワークスペースを作成すると、Orca はリモートベースを更新し、一致するローカルブランチを安全に fast-forward します。対象は',
'. This keeps commands like': '。これにより、',
'from comparing against stale history. Orca skips the update if that branch has uncommitted changes or local-only commits.':
'などのコマンドが古い履歴と比較されるのを防ぎます。対象のブランチにコミットしていない変更やローカルのみのコミットがある場合、Orca は更新をスキップします。',
'Supports skills, file paths, and built-in commands like':
'スキル、ファイルパス、および次のような組み込みコマンドに対応しています:',
' · {{value0}} external': ' · 外部 {{value0}} 件',
'Use your current': '現在のアカウントを使用',
Running: '実行中',
'Back to runs': '実行一覧に戻る',
Failed: '失敗',
Unknown: '不明',
'Identity file': '秘密鍵ファイル',
'Identity File': '秘密鍵ファイル',
key: 'キー',
failing: '失敗',
'Selected host': '選択したホスト',
'Copied cleanup payload.': 'クリーンアップ用ペイロードをコピーしました。',
'Jump to...': '移動先…',
'Start work': '作業を開始',
'Remove {{value0}} contained {{value1}} from Orca':
'含まれている {{value1}} {{value0}} 件を Orca から削除',
'People in {{orgName}} who can collaborate on Orca.':
'{{orgName}} で Orca を共同利用できるメンバーです。',
'People in your organization who can collaborate on Orca.':
'組織内で Orca を共同利用できるメンバーです。',
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
'ソース管理のコミット、PR、ブランチ名、修正操作に使うアクションレシピを追加します。',
'Repo fallback for text actions that select Custom command.':
'「カスタムコマンド」を選択したテキスト操作に使う、リポジトリ単位のフォールバック。',
'Recipe-created runtimes are workspace-owned. Clean up stale entries after crashes, failed creates, or manual recovery.':
'レシピで作成されたランタイムはワークスペースに紐づいています。クラッシュ、作成失敗、手動リカバリの後に残った古いエントリをクリーンアップします。',
'Failed to register the recipe-created project root on the runtime.':
'レシピによって作成されたプロジェクトルートをランタイムに登録できませんでした。',
'Update all {{value0}} servers': '{{value0}} 台のサーバーをすべて更新',
'This QR code grants limited (mobile) access. To use the full web app, open the browser access link from Settings → Runtime Environments → Share this Orca server → New Link.':
'この QR コードでは、モバイル向けの制限付きアクセスのみ利用できます。完全版の Web アプリを使用するには、[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] → [新規リンク] から、ブラウザ用アクセスリンクを開いてください。',
'Loading skills...': 'スキルを読み込み中…',
'Start agents on your tasks without leaving Orca': 'Orca から離れずに、タスクから Agent を開始',
'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}':
'ワークスペースは {{value0}} から作成されましたが、{{value2}} のため Orca はローカル {{value1}} を fast-forward できませんでした',
'Terminal, browser, or editor rendering failed in this workspace. Retry to remount it.':
'このワークスペースでは、ターミナル、ブラウザ、またはエディターのレンダリングに失敗しました。再マウントを試してください。',
'The dashboard could not finish rendering. Retry to remount it, or reopen it.':
'ダッシュボードのレンダリングを完了できませんでした。再マウントを試すか、開き直してください。',
'Review the prompt before starting an agent.':
'Agent を開始する前に、プロンプトを確認してください。',
'Use Add Project to enter a path on the selected host.':
'[プロジェクトの追加] から、選択したホスト上のパスを入力します。',
'Use repo-relative paths like packages/web or apps/api.':
'packages/web や apps/api などのリポジトリ相対パスを使用します。',
'orca.yaml first, then your local commands.': 'まず orca.yaml、次にローカルのコマンドの順です。',
'Rename Orca-created branches from the initial agent task.':
'Orca が作成したブランチの名前を、最初の Agent タスクに基づいて変更します。',
'Dim files matched by .gitignore in the file explorer.':
'ファイルエクスプローラーで、.gitignore に一致するファイルを薄く表示します。',
'Command line Orca runs when a text recipe uses Custom command.':
'テキストレシピで「カスタムコマンド」を使用したときに Orca が実行するコマンドライン。',
'Let programs in the terminal copy to the system clipboard through OSC 52, including over SSH.':
'ターミナル内のプログラムが、OSC 52 を使ってシステムのクリップボードにコピーできるようにします(SSH 経由を含む)。',
'Choose which optional saved Codex account powers live quota reads.':
'ライブクォータの取得に使用する、保存済みの Codex アカウント(任意)を選択します。',
'This plugin has no worker process. Its instructional content can still cause actions when you or an agent use it. Review the instructions and commands below before enabling it.':
'このプラグインにはワーカープロセスがありません。それでも、ユーザーや Agent が使用したときに、記載された手順が操作を引き起こす可能性があります。有効にする前に、以下の手順とコマンドを確認してください。',
'This removes the saved SSH host and its credentials from this computer. Remote files are not deleted.':
'保存済みの SSH ホストとその認証情報を、このコンピュータから削除します。リモートのファイルは削除されません。',
'Your new worktree is current, but local {{value0}} is {{value1}} {{value2}} behind. AI diffs may miss recent commits.':
'新規ワークツリーは最新ですが、ローカル {{value0}} は {{value1}} {{value2}} 遅れています。AI の差分に最近のコミットが反映されない可能性があります。',
'This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.':
'マージのブロックが解除されるまでに、このチェックには GitHub 上での手動操作(例: ワークフロー実行の承認)が必要です。',
'This file changed on disk while you have unsaved edits. Saving will overwrite the newer disk content.':
'未保存の編集がある状態で、このファイルがディスク上で変更されました。保存すると、ディスク上の最新の内容を上書きします。',
'Local terminal reveal is unavailable while a remote runtime is active':
'リモートランタイムがアクティブな間は、ローカルターミナルの表示は利用できません',
'No quick commands saved.': '保存されているクイックコマンドはありません。',
'Repository not in Orca': 'このリポジトリは Orca に登録されていません',
'host folder not selected': 'ホストフォルダーが選択されていません',
'Add a Git repository or folder that already exists on the selected host.':
'選択したホストに既に存在する Git リポジトリまたはフォルダーを追加します。',
'Add another project from the selected host.': '選択したホストから別のプロジェクトを追加します。',
'Enter a host path for the clone destination.': 'クローン先のホスト上のパスを入力します。',
'Use a host path to add projects from a remote host.':
'ホスト上のパスを指定して、リモートホストからプロジェクトを追加します。',
'Use Add Project to enter a host path.':
'[プロジェクトの追加] から、ホスト上のパスを入力します。',
'Opens as a project on this host · {{value0}}':
'このホスト上のプロジェクトとして開きます · {{value0}}',
'No project path is available on this host for attachments.':
'このホスト上に、添付に使用できるプロジェクトパスがありません。',
'Clears them from Orca only. Remote files, worktrees, and branches are left untouched.':
'Orca からのみ削除します。リモートのファイル、ワークツリー、ブランチはそのまま残ります。',
'Agent location': 'Agent の実行場所',
upstream: 'upstream',
Upstream: 'upstream',
'Commit staged changes': 'ステージ済みの変更をコミット',
'Generate the commit message from staged changes.':
'ステージ済みの変更からコミットメッセージを生成します。',
lan: 'LAN',
deploy: 'deploy',
'Connect this source to check for Hermes automations in the remote profile.':
'このソースに接続すると、リモートプロファイル内の Hermes オートメーションを確認できます。',
'Notes sent to active agent.': 'メモをアクティブな Agent に送信しました。',
'Review before attaching. Captured page context may include visible site content.':
'添付する前に確認してください。取得したページのコンテキストには、表示中のサイトの内容が含まれる場合があります。',
'Agent took too long to start. The workspace is ready — paste the {{value0}} when the agent is idle.':
'Agent の起動に時間がかかりすぎました。ワークスペースの準備はできています。 Agent がアイドル状態になったら {{value0}} を貼り付けてください。',
'Install the Orca CLI before running agent skill setup.':
'Agent スキルのセットアップを実行する前に、Orca CLI をインストールしてください。',
'Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.':
'Orca を実行しているコンピュータのターミナルで grok を実行し、起動するまで待ってください。サインインを求められた場合は完了してから、使用状況を再取得してください。チャットメッセージを送る必要はありません。',
'Choose an enabled agent before saving.': '保存する前に、有効な Agent を選択してください。',
'Selected agent is disabled. Choose an enabled agent before creating.':
'選択した Agent は無効です。作成する前に、有効な Agent を選択してください。',
'Choose an agent before starting.': '開始する前に、 Agent を選択してください。',
'Change this later from the project menu.': '後でプロジェクトメニューから変更できます。',
"Orca works with every CLI agent. Choose the one you'll reach for most. Switch any time.":
'Orca はすべての CLI Agent で動作します。最もよく使うものを選択してください。いつでも切り替えられます。',
'Downloading paused': 'ダウンロード一時停止中',
Canceled: 'キャンセル済み',
'Thinking...': '思考中…',
'No attached worktrees yet': '紐づくワークツリーはまだありません',
'Started an AI agent for the commit failure.':
'コミット失敗を修正するために AI Agent を開始しました。',
'terminals until reset': 'リセットするまでターミナルを維持',
'Failed to import repo icon': 'リポジトリアイコンのインポートに失敗しました',
'Terminal Panes': 'ターミナルペイン',
'Option composes special characters for your keyboard layout.':
'Option キーで、キーボードレイアウトに応じた特殊文字を入力します。',
'There are local terminals with running processes. Close the window anyway?':
'プロセスが実行中のローカルターミナルがあります。このままウィンドウを閉じますか?',
'Auto-generates a new name when you leave this text box empty.':
'このテキストボックスを空のままにすると、名前が自動生成されます。',
'Auto-name workspace from first agent message':
'最初の Agent メッセージからワークスペース名を自動生成',
'Controls the split divider line between panes in light mode.':
'ライトモードでのペイン間の分割線を制御します。',
'Controls the split divider line between panes in dark mode.':
'ダークモードでのペイン間の分割線を制御します。',
'Countdown timer showing time until prompt cache expires (Claude agents).':
'プロンプトキャッシュが期限切れになるまでの時間を示すカウントダウンタイマー(Claude Agent )。',
'Install and manage experimental Orca plugins.':
'実験的機能の Orca プラグインをインストール・管理します。',
"Resident memory held by Orca plus the processes under each worktree's terminals.":
'Orca が保持する常駐メモリと、各ワークツリーのターミナル配下で実行中のプロセス。',
'No local Claude, Codex, or OpenCode usage found yet. The overview will populate after the next agent session writes token logs.':
'ローカルの Claude、Codex、OpenCode の使用状況はまだ見つかりません。次の Agent セッションがトークンログを書き込むと、概要に反映されます。',
'Choose or add a project before creating a workspace.':
'ワークスペースを作成する前に、プロジェクトを選択または追加してください。',
'Choose a project to get started.': '開始するには、プロジェクトを選択してください。',
'Please enter feedback before submitting.': '送信する前に、フィードバックを入力してください。',
'Add a Git project under this folder to attach GitHub or GitLab tasks.':
'GitHub または GitLab のタスクを関連付けるには、このフォルダー内に Git プロジェクトを追加してください。',
'Enter a valid website URL.': '有効な Web サイトの URL を入力してください。',
'Choose a WSL distro before projects can inherit WSL.':
'プロジェクトが WSL を継承できるようにするには、先に WSL ディストリビューションを選択してください。',
'Choose a different base branch before creating a {{value0}}.':
'{{value0}} を作成する前に、別のベースブランチを選択してください。',
'Optional. Orca can use your normal Codex login; add accounts only if you want quick switching in Orca.':
'任意。Orca は通常の Codex ログインを使用できます。Orca 上ですばやく切り替えたい場合にのみ、アカウントを追加してください。',
'Optional. Orca can use your normal Claude login; add accounts only if you want quick switching without moving chat sessions.':
'任意。Orca は通常の Claude ログインを使用できます。チャットセッションを移さずにすばやく切り替えたい場合にのみ、アカウントを追加してください。',
'Optional. Orca works with your existing provider logins; add accounts only if you want Orca to help switch between them.':
'任意。Orca は既存のプロバイダーのログインと連携します。Orca に切り替えを任せたい場合にのみ、アカウントを追加してください。',
'Use this computer by default. Choose a saved server only when you want supported projects, files, terminals, provider checks, and browser/mobile handoff to run through that server.':
'既定ではこのコンピュータを使用します。対応するプロジェクト、ファイル、ターミナル、プロバイダーチェック、ブラウザ/モバイルの引き継ぎをそのサーバー経由で実行したい場合にのみ、保存済みサーバーを選択してください。',
'Window Blur': 'ウィンドウのぼかし',
'Medium section heading.': '中サイズのセクション見出し。',
'macOS Option key': 'macOS の Option キー',
'Failed to add folder on this host': 'このホスト上でのフォルダーの追加に失敗しました',
'This workspace is locked by Git. Run git worktree unlock <worktree-path> from its repository, then retry deletion.':
'このワークスペースは Git によってロックされています。リポジトリで git worktree unlock <worktree-path> を実行してから、削除を再試行してください。',
'This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock <worktree-path> from its repository, then retry deletion.':
'このワークスペースは Git によってロックされています。Git の報告: {{value0}}。リポジトリで git worktree unlock <worktree-path> を実行してから、削除を再試行してください。',
'Permanently deletes the remote Git worktrees and their branches. Cannot be undone.':
'リモートの Git ワークツリーとそのブランチを完全に削除します。この操作は取り消せません。',
'Click a card to copy a prompt. Use these in a Linear-linked worktree after the skill is installed.':
'カードをクリックするとプロンプトをコピーできます。スキルのインストール後、Linear にリンクしたワークツリーで使用してください。',
'Ticket actions work best in a worktree created from Tasks so the issue stays linked as context.':
'チケット操作は、タスクから作成したワークツリーで最も効果的です。Issue がコンテキストとしてリンクされたままになります。',
'Choose a base branch.': 'ベースブランチを選択してください。',
'Enter a {{value0}} title.': '{{value0}} のタイトルを入力してください。',
'Enter a valid five-field cron before saving.':
'保存する前に、有効な 5 フィールドの cron を入力してください。',
'Choose an available workspace before saving.':
'保存する前に、使用可能なワークスペースを選択してください。',
'Enter a valid advanced schedule before saving.':
'保存する前に、有効な詳細スケジュールを入力してください。',
'Creating...': '作成中…',
'Creating…': '作成中…',
'Reopening...': '再オープン中…',
'Thinking…': '思考中…',
'Hide from sidebar': 'サイドバーから非表示',
'Show in sidebar': 'サイドバーに表示',
'Recent or tab strip.': '「最近」またはタブストリップ。',
// Settings-search keywords: proper nouns keep their canonical spelling, and the rest were
// translated in the wrong sense (windows→窓, gitignore→ギティ無視, component→成分).
windows: 'Windows',
'windows powershell': 'Windows PowerShell',
'powershell 7': 'PowerShell 7',
neovim: 'Neovim',
hermes: 'Hermes',
duckduckgo: 'DuckDuckGo',
gitignore: 'gitignore',
component: 'コンポーネント',
compose: '特殊文字の入力',
sidekick: 'Sidekick',
'api token': 'API トークン',
'compare base': '比較ベース',
ahead: '進んでいる'
}
export const JA_VALUE_OVERRIDES = { ...JA_AUDIT_OVERRIDES, ...JA_UNIFIED_VALUES }
@@ -1,4 +1,5 @@
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
import { JA_KEY_OVERRIDES } from './locale-ja-key-overrides.mjs'
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
@@ -11,6 +12,9 @@ export function mergeLocaleKeyOverrides(base) {
// KO split overrides can share keys with zh/ja repairs; merge per locale.
merged[key] = { ...merged[key], ...overrides }
}
for (const [key, overrides] of Object.entries(JA_KEY_OVERRIDES)) {
merged[key] = { ...merged[key], ...overrides }
}
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
merged[key] = { ...merged[key], ...overrides }
}
+9 -9
View File
@@ -16,7 +16,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
'menu.showMobileButton': {
ko: 'Orca 모바일 버튼 표시',
zh: '显示 Orca Mobile 按钮',
ja: 'Orca モバイル ボタンを表示'
ja: 'Orca Mobile ボタンを表示'
},
'menu.toggleLeftSidebar': {
ko: '왼쪽 사이드바 표시/숨기기',
@@ -169,7 +169,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
ko: '에이전트 실행',
zh: '启动代理',
ja: 'エージェントを起動'
ja: 'Agent を起動'
},
'auto.components.sidebar.SidebarNav.c39ab10000': {
ko: 'Linear 작업 열기',
@@ -343,7 +343,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
'settings.appearance.statusBar.kimiToggleDescription': {
ko: '활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.',
zh: '显示当前工作区的 Kimi 订阅使用情况。',
ja: 'Kimi サブスクリプション'
ja: 'アクティブなワークスペースの Kimi サブスクリプション使用状況を表示します。'
},
'auto.components.mobile.MobileHero.cd4e5e816f': {
ko: '주머니 속의 워크스페이스.',
@@ -433,17 +433,17 @@ const BASE_LOCALE_KEY_OVERRIDES = {
'auto.components.settings.AgentsPane.9bccf48906': {
ko: '에이전트 위치',
zh: '代理位置',
ja: 'エージェントの場所'
ja: 'Agent の場所'
},
'auto.components.skills.SkillsPage.38e0951c3a': {
ko: '에이전트 스킬',
zh: '代理技能',
ja: 'エージェントのスキル'
ja: 'Agent のスキル'
},
'auto.components.sidebar.SidebarNav.e518f544b1': {
ko: '감지된 에이전트 없음',
zh: '未检测到代理',
ja: 'エージェントが検出されません'
ja: 'Agent が検出されません'
},
'auto.components.onboarding.OnboardingFlow.04ae28d8ca': {
ko: '몇 시간 내내 보고 싶은 테마를 선택하세요.',
@@ -478,7 +478,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
'auto.components.settings.ExperimentalPane.0277901cf7': {
ko: '완료된 에이전트, 차단 질문, 읽지 않은 상태 및 작업 트리 생성 이벤트에 대한 스레드 작업 트리 피드가 있는 에이전트 항목을 왼쪽 사이드바에 추가합니다. 실험적 — 이벤트 모델과 UI가 변경될 수 있습니다.',
zh: '将代理条目添加到左侧边栏,其中包含已完成代理、阻塞待办、未读状态和工作树创建事件的线程工作树提要。实验性——事件模型和 UI 可能会改变。',
ja: '完了したエージェント、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付きエージェント項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。'
ja: '完了した Agent、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付き Agent 項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。'
},
'auto.lib.fix.checks.agent.launch.9f00d7df0c': {
ko: '검사 프롬프트가 비어 있습니다. 소스 제어 AI 설정을 업데이트하세요.',
@@ -488,7 +488,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
'auto.components.TaskPage.d0e3c8f933': {
ko: '일치하는 GitHub 작업이 없습니다',
zh: '没有匹配的 GitHub 工作项',
ja: '一致する GitHub 作業がありません'
ja: '一致する GitHub の作業はありません'
},
'auto.components.TaskPage.2af3ab5c58': {
ko: 'Linear에서 열 팀을 하나 선택하세요',
@@ -513,7 +513,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
'auto.components.settings.IntegrationsPane.33ae9730a8': {
ko: 'Linear 액세스를 추가해 이슈를 탐색하고 연결합니다.',
zh: '添加 Linear 访问以浏览和链接议题。',
ja: 'Linear アクセスを追加してイシューを参照し、リンクします。'
ja: 'Linear アクセスを追加して、Issue を閲覧およびリンクします。'
},
'auto.components.GitHubItemDialog.3ab6ac0fc8': {
ko: '선택한 GitHub 이슈 또는 PR을 미리보기하고 편집합니다.',
+33
View File
@@ -0,0 +1,33 @@
// CSS that lives in the i18n catalog. MT rewrote a selector to [データスラッシュメニュー] and a keyframe
// name to ブラウザフラッシュ, breaking the view in that locale only. Removing these from en.json is
// the real fix; until then the policy pins them to English.
const STYLE_BLOCK =
/@(keyframes|media|supports|font-face)\b|[.#][a-zA-Z][\w-]*[^{}]*\{[^{}]*[a-z-]+\s*:|\[[a-z-]+=["'][^"']*["']\]|\{[^{}]*[a-z-]+\s*:\s*[^{}]*[;}]/
// A selector with no declaration block has no braces for STYLE_BLOCK to key off.
const SELECTOR_TOKEN = /^(?:[>+~]|[a-zA-Z.#[][\w.#:()[\]"'=^$|*-]*)$/
const LONE_SELECTOR = /^#[a-zA-Z][\w-]*$|^\.[a-zA-Z][\w-]*-[\w-]+$/
// One dotted, colon or bracketed token — button.primary, a:hover, wsl.exe, localhost:3000. It is
// code whether it names a selector, a file or a host, and a translation breaks all three.
const LONE_QUALIFIED = /^[a-zA-Z][\w-]*(?:[.#:][\w-]+|\[[^\]]+\])$/
function isStandaloneSelector(enValue) {
if (/[{}]/.test(enValue)) {
return false
}
const tokens = enValue.trim().split(/\s+/)
if (!tokens.every((token) => SELECTOR_TOKEN.test(token))) {
return false
}
// A selector join, not a sentence period: two sentences would otherwise clear the threshold.
const marked = tokens.filter((token) => /^[#.][a-zA-Z]|[\w-][.#:][a-zA-Z]|\[/.test(token))
if (marked.length >= 2) {
return true
}
return tokens.length === 1 && (LONE_SELECTOR.test(tokens[0]) || LONE_QUALIFIED.test(tokens[0]))
}
export function isStyleValue(enValue) {
return STYLE_BLOCK.test(enValue) || isStandaloneSelector(enValue)
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue, shouldPreserveEnglishValue } from './locale-translation-policy.mjs'
const ja = (enValue, localeValue, key = 'auto.test.relocalization') =>
repairTranslatedValue({ key, enValue, localeValue, locale: 'ja' })
describe('locale-translation-policy ja relocalization', () => {
it('heals generic terms the brand revert left in Latin mid-sentence', () => {
expect(ja('Close terminal?', 'この terminal を閉じますか?')).toBe('このターミナルを閉じますか?')
expect(ja('New Terminal', '新規 Terminal')).toBe('新規ターミナル')
expect(ja('Stop the agent?', 'この agent を停止しますか?')).toBe('この Agent を停止しますか?')
expect(ja('Nothing to commit.', 'commit するものはありません。')).toBe(
'コミットするものはありません。'
)
expect(ja('Open the repo', 'repo を開く')).toBe('リポジトリを開く')
})
it('leaves the second word of a two-word command in Latin', () => {
expect(ja('a commit hook or git commit fails', 'コミットフックまたは git commit が失敗')).toBe(
'コミットフックまたは git commit が失敗'
)
expect(ja('Run orca terminal', 'orca terminal を実行')).toBe('orca terminal を実行')
expect(ja('Install with pnpm install', 'pnpm install でインストール')).toBe(
'pnpm install でインストール'
)
})
it('leaves identifiers, flags, filenames, and Latin-only labels alone', () => {
expect(ja('Pass --agent to select one.', '--agent を渡して選択します。')).toBe(
'--agent を渡して選択します。'
)
expect(ja('Edit agents.md', 'agents.md を編集')).toBe('agents.md を編集')
expect(ja('Agent SDK settings', 'Agent SDK の設定')).toBe('Agent SDK の設定')
expect(ja('Agent SDK settings', 'この Agent SDK の設定')).toBe('この Agent SDK の設定')
expect(ja('Run git commit -m', 'git commit -m を実行')).toBe('git commit -m を実行')
})
it('keeps commands and identifiers in English so they still run', () => {
expect(shouldPreserveEnglishValue('pnpm install')).toBe(true)
expect(shouldPreserveEnglishValue('glab auth login')).toBe(true)
expect(shouldPreserveEnglishValue("gh pr list --json number -q '.[0].number'")).toBe(true)
expect(shouldPreserveEnglishValue('--model sonnet')).toBe(true)
expect(shouldPreserveEnglishValue('localhost, 127.0.0.1, *.internal')).toBe(true)
expect(shouldPreserveEnglishValue('stale-agent-row-{{value0}}')).toBe(true)
expect(shouldPreserveEnglishValue('text-foreground')).toBe(true)
expect(shouldPreserveEnglishValue('{basePrompt}')).toBe(true)
expect(shouldPreserveEnglishValue('{firstPrompt}')).toBe(true)
expect(shouldPreserveEnglishValue('{assistantMessage}')).toBe(true)
expect(shouldPreserveEnglishValue('/pricing')).toBe(true)
expect(shouldPreserveEnglishValue('/signup')).toBe(true)
})
it('repairs translated code examples back to executable values', () => {
expect(ja('--model sonnet', '--モデルソネット')).toBe('--model sonnet')
expect(
ja("gh pr list --json number -q '.[0].number'", "gh pr list --json 番号 -q '.[0].number'")
).toBe("gh pr list --json number -q '.[0].number'")
expect(ja('{basePrompt}', '{ベースプロンプト}')).toBe('{basePrompt}')
expect(ja('/pricing', '/価格設定')).toBe('/pricing')
})
it('keeps style blocks in English because a translated selector breaks the view', () => {
expect(shouldPreserveEnglishValue('.pp-card[data-card="starter"] .pp-cta')).toBe(true)
expect(shouldPreserveEnglishValue('@keyframes browser-flash { to { opacity: 1; } }')).toBe(true)
expect(shouldPreserveEnglishValue('.card { background: var(--card); }')).toBe(true)
// Why: the declaration requirement is what keeps prose containing braces translatable.
expect(shouldPreserveEnglishValue('e.g. {{value0}}')).toBe(false)
})
it('normalizes Japanese typography and settled terminology', () => {
expect(ja('Loading…', '読み込み中...')).toBe('読み込み中…')
expect(ja('Merge conflict', 'マージ紛争')).toBe('マージ競合')
// Why: MT inserts a Microsoft-style separator space that Japanese compounds do not use.
expect(ja('Status bar', 'ステータス バー')).toBe('ステータスバー')
expect(ja('Missing credentials', '資格情報がありません')).toBe('認証情報がありません')
expect(ja('Unknown', '未知')).toBe('不明')
expect(ja('Restart the server', 'サーバを再起動')).toBe('サーバーを再起動')
expect(ja('Open the browser', 'ブラウザーを開く')).toBe('ブラウザを開く')
})
it('leaves ranges and token samples out of the ellipsis rule', () => {
expect(ja('Compare main...HEAD', 'main...HEAD を比較')).toBe('main...HEAD を比較')
})
it('spaces the Latin terms the phrase fixes write back in', () => {
expect(ja('Clear cookies', 'クッキーを消去')).toBe('Cookie を消去')
expect(ja('Safely fast-forward the branch', 'ブランチを安全に早送りします')).toBe(
'ブランチを安全に fast-forward します'
)
})
it('uses ホスト for host, and leaves サーバー alone when English says server', () => {
expect(ja('Host path', 'サーバーパス')).toBe('ホストパス')
expect(ja('Enter a path that exists on the selected host.', 'ランタイムサーバー上のパス')).toBe(
'ホスト上のパス'
)
// Why: host and server are deliberately distinct wherever the English uses both.
expect(ja('Save the server host', 'サーバーを保存')).toBe('サーバーを保存')
})
it('restores Git vocabulary and brand names the MT localized', () => {
expect(ja('Branch upstream', 'ブランチの上流')).toBe('ブランチの upstream')
expect(ja('origin has commits', '起源にコミットがあります')).toBe('origin にコミットがあります')
expect(ja('Discard all staged changes?', '段階的な変更を破棄しますか?')).toBe(
'ステージ済みの変更を破棄しますか?'
)
expect(ja('Create Hermes automation', 'エルメスオートメーションを作成')).toBe(
'Hermes オートメーションを作成'
)
expect(ja('Delete the worktree', 'worktree を削除')).toBe('ワークツリーを削除')
})
it('keeps class strings, refs, and fixture filenames in English', () => {
for (const value of [
'size-4 text-muted-foreground',
'text-amber-700 dark:text-amber-300',
'origin/main',
'upstream/main',
'login.spec.ts',
'bastion.example.com'
]) {
expect(shouldPreserveEnglishValue(value)).toBe(true)
}
})
it('preserves a selector that has no declaration block', () => {
for (const value of [
'div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta',
'.pp-cta',
'#dialog',
'.pp-card[data-card="starter"] .pp-cta',
'button.primary',
'a:hover',
'input[type="text"]',
'localhost:3000'
]) {
expect(shouldPreserveEnglishValue(value)).toBe(true)
}
// Why: two ordinary sentences carry two periods, which must not read as selector joins.
for (const value of [
'Open Settings > Git and try again.',
'Show live workspace ports. Click it for workspace-scoped ports and external listeners.',
'orca.yaml + local',
'.gitignore',
'Status:',
'Done.',
'#{{value0}}'
]) {
expect(shouldPreserveEnglishValue(value)).toBe(false)
}
})
})
@@ -108,18 +108,18 @@ describe('locale-translation-policy ja round 5', () => {
repairTranslatedValue({
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
enValue: 'Terminals',
localeValue: '端子',
localeValue: '端末',
locale: 'ja'
})
).toBe('Terminals')
).toBe('ターミナル')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
enValue: 'Agent Skills',
localeValue: 'エージェントのスキル',
localeValue: 'Agent のスキル',
locale: 'ja'
})
).toBe('エージェントのスキル')
).toBe('Agent のスキル')
expect(
repairTranslatedValue({
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
+53 -159
View File
@@ -4,11 +4,14 @@ import {
overlapsCanonicalRendering
} from './locale-generic-ui-terms.mjs'
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
import { BRAND_MISTRANSLATIONS } from './locale-brand-mistranslations.mjs'
import { isStyleValue } from './locale-style-values.mjs'
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
import { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
import { LOCALE_VALUE_OVERRIDES } from './locale-value-overrides.mjs'
export { BRAND_MISTRANSLATIONS } from './locale-brand-mistranslations.mjs'
export { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
export { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
export { SEARCH_KEYWORD_OVERRIDES } from './locale-search-keyword-overrides.mjs'
@@ -164,167 +167,55 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'lint',
'MD',
'/home/user/projects',
'Claude Code'
'Claude Code',
// Commands, refs, class strings and code samples: a translated one no longer runs or matches.
'pnpm install',
'glab auth login',
"gh pr list --json number -q '.[0].number'",
'--model sonnet',
'localhost, 127.0.0.1, *.internal',
'packages/web shared/ui',
'stale-agent-row-{{value0}}',
'text-foreground',
'source-control',
'combined-branch',
'pr-view',
'fix-login-flow',
'my-project',
'serve-sim',
'pnpm playwright test',
'gh auth login',
'size-4 text-muted-foreground',
'text-amber-700 dark:text-amber-300',
'text-emerald-700 dark:text-emerald-300',
'src/renderer packages/ui',
'upstream/main',
'origin/main',
'example.com',
'bastion.example.com',
'dashboard.spec.ts',
'checkout.spec.ts',
'login.spec.ts',
'untitled.md',
'review src/auth',
'throw src/auth',
// Rendered inside <code> or a font-mono element, so they are code the user copies or types.
'{prompt}',
'{basePrompt}',
'{firstPrompt}',
'{assistantMessage}',
'/goal',
'/pricing',
'/signup',
'npm run dev',
'nbformat',
'orca.yaml',
'upstream',
'LIN-329',
'GH #1799',
'orca · zsh'
])
export const BRAND_MISTRANSLATIONS = {
ko: {
Codex: ['사본', '코덱스'],
Gemini: ['쌍둥이자리'],
Claude: ['클로드'],
Grok: ['그록'],
Orca: ['오르카', '범고래'],
Cursor: ['커서'],
OpenCode: ['오픈코드'],
OpenClaw: ['오픈클로'],
OpenClaude: ['오픈클로드'],
Antigravity: ['반중력'],
Continue: ['계속하다'],
Charm: ['매력'],
Goose: ['거위'],
Pi: ['파이'],
'GitHub Copilot': ['GitHub 코파일럿', '코파일럿'],
Git: ['힘내'],
Discord: ['디스코드'],
Linear: ['선형'],
Agent: ['에이전트'],
Agents: ['에이전트'],
agent: ['에이전트'],
agents: ['에이전트'],
Commit: ['커밋'],
Commits: ['커밋'],
commit: ['커밋'],
commits: ['커밋'],
Markdown: ['마크다운', '가격 인하'],
markdown: ['마크다운', '가격 인하'],
Repo: ['저장소', '레포'],
Repos: ['저장소', '레포'],
repo: ['저장소', '레포'],
repos: ['저장소', '레포'],
Terminal: ['터미널'],
Terminals: ['터미널'],
terminal: ['터미널'],
terminals: ['터미널']
},
zh: {
Codex: ['法典'],
Gemini: ['双子座'],
Claude: ['克洛德', '克劳德'],
Grok: ['格罗克'],
Orca: ['虎鲸', '逆戟鲸'],
Cursor: ['光标'],
OpenCode: ['开放代码'],
OpenClaw: ['开爪'],
OpenClaude: ['开放克劳德'],
Antigravity: ['反重力'],
Continue: ['继续'],
Charm: ['魅力'],
Goose: ['鹅'],
Pi: ['圆周率'],
Droid: ['机器人'],
Kimi: ['基米'],
// Why: zh-only — 登月计划 is the literal "moon landing programme", not Kimi's vendor. ja and
// ko render this keyword their own way, so it must not go in the cross-locale preserve set.
moonshot: ['登月计划'],
'GitHub Copilot': ['GitHub 副驾驶', '副驾驶'],
Bitbucket: ['位桶'],
Linear: ['线性', '线形'],
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: ['法典', 'コーデックス'],
Gemini: ['双子座'],
Claude: ['クロード'],
Grok: ['グロック'],
Orca: ['シャチ', '逆戟鲸', 'オルカ'],
Cursor: ['カーソル'],
OpenCode: ['オープンコード', 'オープン・コード'],
OpenClaw: ['オープンクロー'],
OpenClaude: ['オープンクロード'],
Antigravity: ['反重力'],
Continue: ['続ける', '続行'],
Charm: ['魅力'],
Goose: ['ガチョウ', '雁'],
Pi: ['円周率'],
Droid: ['ロボット', 'ドロイド'],
'GitHub Copilot': ['GitHub コパイロット', 'コパイロット'],
Discord: ['不和'],
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: '日本語', spanish: 'Español' },
ko: { chinese: '中文(简体)', korean: '한국어', japanese: '日本語', spanish: 'Español' },
@@ -348,6 +239,9 @@ export function shouldPreserveEnglishValue(enValue, key = '') {
if (isEnglishOnlyKey(key)) {
return true
}
if (isStyleValue(enValue)) {
return true
}
return NEVER_TRANSLATE_VALUES.has(enValue)
}
@@ -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('スマートフォンからターミナルと Agent を操作します。')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.934add88b6',
@@ -398,7 +398,7 @@ describe('locale-translation-policy', () => {
localeValue: 'コンピューターと携帯電話で。',
locale: 'ja'
})
).toBe('コンピューターとスマートフォンで。')
).toBe('コンピュータとスマートフォンで。')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.3ab6ac0fc8',
@@ -0,0 +1,335 @@
/**
* Synthetic benchmark for native chat's post-incremental renderer hot paths.
* Run: pnpm exec tsx config/scripts/native-chat-live-session-benchmark.ts
*
* This excludes transcript parsing, IPC/remote latency, React, and DOM work. The
* 2 KB/message fixtures intentionally stress fallback-key normalization and are
* not estimates of average production message size or end-to-end frame latency.
*/
import { deepStrictEqual, strictEqual } from 'node:assert'
import { performance } from 'node:perf_hooks'
import type { NativeChatMessage, NativeChatSession } from '../../src/shared/native-chat-types'
import { getVerifiedNativeChatCommands } from '../../src/shared/native-chat-agent-profiles'
import { surfaceSkillInvocationUserTurns } from '../../src/shared/native-chat-command-envelope'
import { prepareNativeChatLiveMessages } from '../../src/renderer/src/components/native-chat/native-chat-live-message-preparation'
import { mergeNativeChatLiveSession } from '../../src/renderer/src/components/native-chat/native-chat-live-status'
import {
matchingNativeChatUserTexts,
selectPendingIndicesRepresentedByUserTexts
} from '../../src/renderer/src/components/native-chat/native-chat-pending-occurrence'
import { pendingSendsAsMessages } from '../../src/renderer/src/components/native-chat/native-chat-pending'
import { assembleNativeChatSession } from '../../src/renderer/src/components/native-chat/native-chat-session-assembler'
type Operation = (index: number) => number
type ExpectedChecksum = (iterations: number) => number
type Calibration = { iterations: number; elapsedMs: number; capped: boolean }
const TARGET_SAMPLE_MS = 50
const MAX_ITERATIONS = 16_777_216
const ROUNDS = 10
let checksum = 0
let expectedChecksum = 0
let validatedCases = 0
let cappedCalibrations = 0
let sessionSink: NativeChatSession | null = null
let messageArraySink: NativeChatMessage[] | null = null
let contentSink = ''
let pendingMatchSink: Set<number> | null = null
const benchmarkStartedAt = performance.now()
function proseFixture(count: number, bytes: number, withTurnId: boolean): NativeChatMessage[] {
const payload = 'Ab Cd '.repeat(Math.ceil(bytes / 7)).slice(0, bytes)
return Array.from({ length: count }, (_, index) => ({
id: `message-${index}`,
role: index % 2 === 0 ? ('user' as const) : ('assistant' as const),
blocks: [{ type: 'text' as const, text: `${payload}-${index}` }],
timestamp: index,
source: 'transcript' as const,
...(withTurnId ? { turnId: `turn-${index}` } : {})
}))
}
function toolFixture(count: number): NativeChatMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `tool-${index}`,
role: index % 2 === 0 ? ('assistant' as const) : ('tool' as const),
blocks:
index % 2 === 0
? [
{
type: 'tool-call' as const,
name: 'read',
input: { path: `${index}.txt`, context: 'x'.repeat(512) }
}
]
: [{ type: 'tool-result' as const, output: `result-${index}-${'x'.repeat(512)}` }],
timestamp: index,
source: 'transcript' as const
}))
}
function legacySession(messages: NativeChatMessage[]): NativeChatSession {
return assembleNativeChatSession({
sources: { transcript: messages },
sessionId: 'benchmark',
agent: 'claude'
})
}
function legacyMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession {
const commandNames = new Set(
getVerifiedNativeChatCommands('claude').map((command) => command.name)
)
return legacySession(surfaceSkillInvocationUserTurns(messages, commandNames))
}
function directMessageUpdateSession(messages: NativeChatMessage[]): NativeChatSession {
return mergeNativeChatLiveSession({
messages: prepareNativeChatLiveMessages(messages, 'claude'),
sessionId: 'benchmark',
agent: 'claude',
hookState: null
})
}
function oldEmptyPending(messages: NativeChatMessage[]): NativeChatMessage[] {
pendingMatchSink = selectPendingIndicesRepresentedByUserTexts(
[],
matchingNativeChatUserTexts(messages)
)
return []
}
function blockContent(message: NativeChatMessage): string {
const block = message.blocks[0]
if (!block) {
return ''
}
if (block.type === 'text') {
return block.text
}
if (block.type === 'tool-call') {
return block.name
}
if (block.type === 'tool-result') {
return block.output
}
return block.path ?? block.url ?? block.alt ?? ''
}
function messageWeight(message: NativeChatMessage, content: string): number {
const idTail = message.id.length > 0 ? message.id.charCodeAt(message.id.length - 1) : 0
const contentTail = content.length > 0 ? content.charCodeAt(content.length - 1) : 0
return message.id.length + idTail + message.role.charCodeAt(0) + content.length + contentTail
}
function consumeSession(session: NativeChatSession, index: number): number {
sessionSink = session
messageArraySink = session.messages
const message = session.messages[index % session.messages.length]
if (!message) {
contentSink = ''
return session.status.charCodeAt(0)
}
contentSink = blockContent(message)
return session.status.charCodeAt(0) + messageWeight(message, contentSink)
}
function consumePendingOutput(messages: NativeChatMessage[], index: number): number {
messageArraySink = messages
return (index % 7) + 1
}
function cyclicChecksum(values: readonly number[], iterations: number): number {
if (values.length === 0) {
return 0
}
const cycle = values.reduce((sum, value) => sum + value, 0)
const fullCycles = Math.floor(iterations / values.length)
let total = cycle * fullCycles
for (let index = 0; index < iterations % values.length; index += 1) {
total += values[index]!
}
return total
}
function sessionExpectedChecksum(session: NativeChatSession): ExpectedChecksum {
const statusWeight = session.status.charCodeAt(0)
const weights = session.messages.map((message) => {
const content = blockContent(message)
return messageWeight(message, content)
})
return (iterations) => statusWeight * iterations + cyclicChecksum(weights, iterations)
}
function pendingExpectedChecksum(iterations: number): number {
return cyclicChecksum([1, 2, 3, 4, 5, 6, 7], iterations)
}
function runSample(operation: Operation, expected: ExpectedChecksum, iterations: number): number {
let sampleChecksum = 0
const startedAt = performance.now()
for (let index = 0; index < iterations; index += 1) {
sampleChecksum += operation(index)
}
const elapsedMs = performance.now() - startedAt
checksum += sampleChecksum
expectedChecksum += expected(iterations)
return elapsedMs
}
function median(samples: number[]): number {
return [...samples].sort((left, right) => left - right)[Math.floor(samples.length / 2)]!
}
function calibrate(operation: Operation, expected: ExpectedChecksum): Calibration {
let iterations = 1
let reachedTarget = false
while (true) {
const elapsedMs = runSample(operation, expected, iterations)
if (elapsedMs >= TARGET_SAMPLE_MS) {
if (reachedTarget) {
return { iterations, elapsedMs, capped: false }
}
reachedTarget = true
continue
}
if (iterations >= MAX_ITERATIONS) {
return { iterations, elapsedMs, capped: true }
}
reachedTarget = false
iterations = Math.min(iterations * 2, MAX_ITERATIONS)
}
}
function benchmark(
name: string,
baseline: Operation,
optimized: Operation,
expected: ExpectedChecksum
): void {
const baselineValue = baseline(0)
const optimizedValue = optimized(0)
strictEqual(optimizedValue, baselineValue, `${name}: timed arms returned different checksums`)
validatedCases += 1
const baselineCalibration = calibrate(baseline, expected)
const optimizedCalibration = calibrate(optimized, expected)
cappedCalibrations += Number(baselineCalibration.capped) + Number(optimizedCalibration.capped)
const baselineSamples: number[] = []
const optimizedSamples: number[] = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
baselineSamples.push(
runSample(baseline, expected, baselineCalibration.iterations) /
baselineCalibration.iterations
)
optimizedSamples.push(
runSample(optimized, expected, optimizedCalibration.iterations) /
optimizedCalibration.iterations
)
} else {
optimizedSamples.push(
runSample(optimized, expected, optimizedCalibration.iterations) /
optimizedCalibration.iterations
)
baselineSamples.push(
runSample(baseline, expected, baselineCalibration.iterations) /
baselineCalibration.iterations
)
}
}
const baselineMs = median(baselineSamples)
const optimizedMs = median(optimizedSamples)
const speedup = baselineMs / Math.max(optimizedMs, Number.EPSILON)
console.log(
`${name}\t${baselineCalibration.iterations}\t${optimizedCalibration.iterations}\t${baselineCalibration.elapsedMs.toFixed(1)}\t${optimizedCalibration.elapsedMs.toFixed(1)}\t${baselineMs.toFixed(6)}\t${optimizedMs.toFixed(6)}\t${speedup.toFixed(1)}x`
)
}
function benchmarkSessionArms(
name: string,
baselineSession: () => NativeChatSession,
optimizedSession: () => NativeChatSession
): void {
const expectedSession = baselineSession()
deepStrictEqual(optimizedSession(), expectedSession, `${name}: session mismatch`)
const expected = sessionExpectedChecksum(expectedSession)
benchmark(
name,
(index) => consumeSession(baselineSession(), index),
(index) => consumeSession(optimizedSession(), index),
expected
)
}
function benchmarkMessageUpdate(name: string, messages: NativeChatMessage[]): void {
benchmarkSessionArms(
name,
() => legacyMessageUpdateSession(messages),
() => directMessageUpdateSession(messages)
)
}
const prose300 = proseFixture(300, 2_048, false)
const fixtures = [
['300 x 2KB prose, no turnId', prose300],
['300 x 2KB prose, with turnId', proseFixture(300, 2_048, true)],
['300 tool-heavy, no turnId', toolFixture(300)],
['100 x 2KB prose, no turnId', proseFixture(100, 2_048, false)],
['500 x 2KB prose, no turnId', proseFixture(500, 2_048, false)]
] as const
console.log(
`Node ${process.version}; ${ROUNDS} alternating interleaved median rounds; ${TARGET_SAMPLE_MS} ms calibration target; ${MAX_ITERATIONS} iteration cap`
)
console.log(
'case\tbaseline iters\toptimized iters\tbaseline cal ms\toptimized cal ms\tbaseline ms/op\toptimized ms/op\tspeedup'
)
for (const [name, messages] of fixtures) {
benchmarkMessageUpdate(name, messages)
}
benchmarkSessionArms(
'300 x 2KB status-only frame',
() => legacySession(prose300),
() =>
mergeNativeChatLiveSession({
messages: prose300,
sessionId: 'benchmark',
agent: 'claude',
hookState: null
})
)
deepStrictEqual(pendingSendsAsMessages([], prose300), oldEmptyPending(prose300))
benchmark(
'300 x 2KB empty pending',
(index) => consumePendingOutput(oldEmptyPending(prose300), index),
(index) => consumePendingOutput(pendingSendsAsMessages([], prose300), index),
pendingExpectedChecksum
)
const combinedSessionExpected = sessionExpectedChecksum(legacyMessageUpdateSession(prose300))
benchmark(
'300 x 2KB combined',
(index) =>
consumeSession(legacyMessageUpdateSession(prose300), index) +
consumePendingOutput(oldEmptyPending(prose300), index),
(index) =>
consumeSession(directMessageUpdateSession(prose300), index) +
consumePendingOutput(pendingSendsAsMessages([], prose300), index),
(iterations) => combinedSessionExpected(iterations) + pendingExpectedChecksum(iterations)
)
strictEqual(checksum, expectedChecksum, 'benchmark checksum accounting drifted')
strictEqual(sessionSink?.sessionId, 'benchmark', 'session outputs did not escape')
strictEqual(Array.isArray(messageArraySink), true, 'message arrays did not escape')
strictEqual(contentSink.length > 0, true, 'message content did not escape')
strictEqual(pendingMatchSink instanceof Set, true, 'pending baseline scan did not escape')
console.log(
`validated=${validatedCases} cases, checksum=${checksum}, capped calibrations=${cappedCalibrations}, runtime=${(
performance.now() - benchmarkStartedAt
).toFixed(0)} ms`
)
@@ -104,6 +104,23 @@ describe('orca CLI skill guidance', () => {
expect(skill).not.toContain('sk_live_')
expect(skill).not.toContain('live_sk_')
})
// Publishing defaults to off, so an agent that follows the unconditional share workflow
// just loops on denials. The guide has to teach the opt-in and the recovery.
it('teaches the artifact publish opt-in and its recovery path', () => {
// Normalized so the assertions survive reflowing the guide's prose.
const skill = readSkill().replace(/\s+/gu, ' ')
expect(skill).toContain('**Publishing is off by default and only a human can turn it on.**')
expect(skill).toContain('Settings → Artifacts')
expect(skill).toContain('Allow publishing public artifact links')
expect(skill).toContain('artifact_sharing_disabled')
expect(skill).toContain('There is no CLI or RPC way to grant it')
expect(skill).toContain('Do not retry')
// The gate is device-wide, and revocation surfaces stay reachable.
expect(skill).toContain('every caller on the device, agent or human')
expect(skill).toContain('`list`, `unshare`, and `delete` are never gated')
})
})
describe('orca CLI install stub', () => {
@@ -55,18 +55,35 @@ describe('PR workflow parallelism', () => {
}
})
it('runs real-zsh coverage once outside the general shards', () => {
it('runs real-shell coverage once outside the general shards', () => {
const shellStep = workflow.jobs.shell_contracts.steps.find(
(step) => step.name === 'Test real shell contracts'
)
const shellInstall = workflow.jobs.shell_contracts.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
// Why parsed rather than substring-matched: the step name changes as shells are
// added, and `includes('fish')` would also match a comment or a longer package.
const aptPackages = (step) =>
(step.run?.match(/apt-get install[^\n]*/)?.[0] ?? '')
.split(/\s+/)
.filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token))
.filter((token) => !token.startsWith('-'))
const jobsInstallingPackages = Object.entries(workflow.jobs)
.filter(([, job]) => (job.steps ?? []).some((step) => aptPackages(step).length > 0))
.map(([name]) => name)
expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false)
expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe(
true
)
expect(shellStep).toBeDefined()
expect(shellInstall).toBeDefined()
// Why the whole workflow, not just the general shards: any other lane installing
// these shells would silently start running the real-shell tests twice.
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
// Why each shell is asserted: the live tests skip themselves when the binary is
// missing, so a dropped package silently empties this lane instead of failing it.
const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages)
for (const shell of ['zsh', 'fish']) {
expect(shellPackages).toContain(shell)
}
expect(shellInstall.with['native-runtime']).toBe('node')
for (const testFile of nativeShellContractFiles) {
expect(shellStep.run).toContain(testFile)
@@ -0,0 +1,166 @@
import { performance } from 'node:perf_hooks'
import { platform, release } from 'node:os'
import {
HEADER_LENGTH,
MAX_MESSAGE_SIZE,
MessageType,
encodeFrame,
encodePreparedJsonRpcFrame,
prepareJsonRpcPayload,
type JsonRpcNotification
} from '../../src/relay/protocol'
type BenchmarkCase = {
name: string
message: JsonRpcNotification
targets: number
iterations: number
}
type BenchmarkResult = {
name: string
targets: number
payloadBytes: number
legacyMicros: number
preparedMicros: number
reductionPercent: number
speedup: number
}
const SAMPLES = 17
let checksum = 0
function encodeLegacyJsonRpcFrame(message: JsonRpcNotification, id: number, ack: number): Buffer {
const payload = Buffer.from(JSON.stringify(message), 'utf8')
if (payload.length > MAX_MESSAGE_SIZE) {
throw new Error(`Message too large: ${payload.length} bytes`)
}
return encodeFrame(MessageType.Regular, id, ack, payload)
}
function runLegacy(message: JsonRpcNotification, targets: number, iteration: number): void {
const estimated = encodeLegacyJsonRpcFrame(message, 0, 0).length
checksum = (checksum + estimated) >>> 0
for (let target = 0; target < targets; target++) {
consume(encodeLegacyJsonRpcFrame(message, iteration + target + 1, iteration))
}
}
function runPrepared(message: JsonRpcNotification, targets: number, iteration: number): void {
const payload = prepareJsonRpcPayload(message)
checksum = (checksum + HEADER_LENGTH + payload.byteLength) >>> 0
for (let target = 0; target < targets; target++) {
consume(encodePreparedJsonRpcFrame(payload, iteration + target + 1, iteration))
}
}
function consume(frame: Buffer): void {
checksum = (checksum + frame.length + frame[0] + frame.at(-1)!) >>> 0
}
function measure(operation: (iteration: number) => void, iterations: number): number {
const startedAt = performance.now()
for (let iteration = 0; iteration < iterations; iteration++) {
operation(iteration)
}
return ((performance.now() - startedAt) * 1000) / iterations
}
function median(values: readonly number[]): number {
const sorted = [...values].sort((left, right) => left - right)
return sorted[Math.floor(sorted.length / 2)]
}
function benchmarkCase(entry: BenchmarkCase): BenchmarkResult {
const legacy = (iteration: number): void => runLegacy(entry.message, entry.targets, iteration)
const prepared = (iteration: number): void => runPrepared(entry.message, entry.targets, iteration)
for (let warmup = 0; warmup < 3; warmup++) {
measure(legacy, Math.min(20, entry.iterations))
measure(prepared, Math.min(20, entry.iterations))
}
const legacySamples: number[] = []
const preparedSamples: number[] = []
for (let sample = 0; sample < SAMPLES; sample++) {
if (sample % 2 === 0) {
legacySamples.push(measure(legacy, entry.iterations))
preparedSamples.push(measure(prepared, entry.iterations))
} else {
preparedSamples.push(measure(prepared, entry.iterations))
legacySamples.push(measure(legacy, entry.iterations))
}
}
const legacyMicros = median(legacySamples)
const preparedMicros = median(preparedSamples)
return {
name: entry.name,
targets: entry.targets,
payloadBytes: Buffer.byteLength(JSON.stringify(entry.message)),
legacyMicros,
preparedMicros,
reductionPercent: ((legacyMicros - preparedMicros) / legacyMicros) * 100,
speedup: legacyMicros / preparedMicros
}
}
function terminalData(bytes: number): string {
const row = '\u001b[38;5;45mcompile src/renderer/pane.ts\u001b[0m\r\n'
return row.repeat(Math.ceil(bytes / row.length)).slice(0, bytes)
}
function watcherEvents(count: number): {
kind: string
absolutePath: string
isDirectory: boolean
}[] {
return Array.from({ length: count }, (_, index) => ({
kind: index % 7 === 0 ? 'create' : 'update',
absolutePath: `/repo/src/features/terminal/generated-${String(index).padStart(4, '0')}.ts`,
isDirectory: false
}))
}
const ptyMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'pty.data',
params: { id: 'pty-42', data: terminalData(16 * 1024), seq: 18_432, rawLength: 16 * 1024 }
}
const watcherMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'fs.changed',
params: { events: watcherEvents(256) }
}
const fileMessage: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'fs.streamChunk',
params: {
streamId: 37,
seq: 11,
data: Buffer.alloc(256 * 1024, 0x61).toString('base64')
}
}
const cases: BenchmarkCase[] = [
{ name: 'PTY 16KiB', message: ptyMessage, targets: 1, iterations: 800 },
{ name: 'PTY 16KiB', message: ptyMessage, targets: 2, iterations: 600 },
{ name: 'watcher 256 events', message: watcherMessage, targets: 1, iterations: 400 },
{ name: 'watcher 256 events', message: watcherMessage, targets: 2, iterations: 300 },
{ name: 'file 256KiB raw', message: fileMessage, targets: 1, iterations: 50 },
{ name: 'file 256KiB raw', message: fileMessage, targets: 2, iterations: 35 }
]
const results = cases.map(benchmarkCase)
const lines = [
`relay JSON payload benchmark: Node ${process.version}, ${platform()} ${release()}, ${SAMPLES} interleaved samples`,
'case | targets | JSON bytes | legacy median us/op | prepared median us/op | reduction | speedup',
...results.map(
(result) =>
`${result.name} | ${result.targets} | ${result.payloadBytes} | ` +
`${result.legacyMicros.toFixed(2)} | ${result.preparedMicros.toFixed(2)} | ` +
`${result.reductionPercent.toFixed(1)}% | ${result.speedup.toFixed(2)}x`
),
`checksum=${checksum}`,
'Limitations: in-process framing only; excludes dispatcher scheduling, sink/network I/O, and tail-GC.',
'Legacy is the exact pre-change framing composition retained in this script, not a separate checkout.',
'The two-target rows model dispatcher fan-out; watcher batches and file streams are commonly single-target.'
]
process.stdout.write(`${lines.join('\n')}\n`)
+1 -1
View File
@@ -86,7 +86,7 @@ Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in
1. Open: https://github.com/organizations/$ORG/settings/installations
2. Configure -> $APP_SLUG
3. Repository access -> Only select repositories -> add $ADHOC_REPO
(keep orca-hourly selected; both dev channels use this one App)
(keep orca-hourly/orca-daily selected; all dev channels use this one App)
4. Save.
EOF
fi
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# Creates stablyai/orca-daily and grants the existing release App write access to
# it, so daily-mac-build.yml can publish there.
#
# Why a separate repo rather than reusing orca-hourly: the daily channel is a
# once-a-day cut that people ride deliberately. Sharing hourly's list would mix a
# sparse daily series into the 72-entry hourly retention window and make both
# pickers harder to read.
#
# Why no secrets are set here: the daily workflow reuses the same GitHub App as
# hourly/adhoc — one App id, one private key, one thing to rotate. This script
# only has to widen that App's installation to cover the new repo.
#
# Run once, after config/scripts/setup-hourly-release-token.sh:
# bash config/scripts/setup-daily-release-repo.sh
#
set -euo pipefail
ORG="stablyai"
DAILY_REPO="$ORG/orca-daily"
MAIN_REPO="$ORG/orca"
APP_SLUG="orca-hourly-release"
fail() {
echo "error: $*" >&2
exit 1
}
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
if gh api "repos/$DAILY_REPO" --jq '.full_name' >/dev/null 2>&1; then
echo "$DAILY_REPO already exists."
else
echo "Creating $DAILY_REPO..."
# Why public: the in-app updater fetches release assets unauthenticated, exactly
# as it does for orca-hourly. A private repo would 404 for every client.
#
# Why the features are off: this repo holds releases and nothing else. Leaving
# issues open invites bug reports against an unvetted daily in a repo nobody
# watches, where they are simply lost.
#
# Why --add-readme in a repo with no source: publishing a release creates a tag,
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
gh repo create "$DAILY_REPO" \
--public \
--description "Daily macOS dev builds of Orca, cut from main each morning. Not a source repo." \
--add-readme \
--disable-issues \
--disable-wiki ||
fail "Could not create $DAILY_REPO."
fi
# Also checked outside the create branch: a repo made before --add-readme is here.
if ! gh api "repos/$DAILY_REPO/commits" --jq 'length' >/dev/null 2>&1; then
fail "$DAILY_REPO has no commits — releases cannot be tagged. Add any file to it first."
fi
echo
echo "Granting $APP_SLUG access to $DAILY_REPO..."
# Why attempt the API before printing instructions: an org owner can do this in
# one call. Everyone else gets a 403 and the manual path below — GitHub does not
# let a mere admin widen an App's repository selection.
INSTALL_ID="$(gh api "orgs/$ORG/installations" --paginate \
--jq ".installations[] | select(.app_slug == \"$APP_SLUG\") | .id" 2>/dev/null || true)"
REPO_ID="$(gh api "repos/$DAILY_REPO" --jq '.id' 2>/dev/null || true)"
GRANTED=false
if [[ -n "$INSTALL_ID" && -n "$REPO_ID" ]]; then
if gh api -X PUT "user/installations/$INSTALL_ID/repositories/$REPO_ID" >/dev/null 2>&1; then
GRANTED=true
echo "Done — $APP_SLUG can now write to $DAILY_REPO."
fi
fi
if [[ "$GRANTED" != "true" ]]; then
# Why no automated check afterwards: the endpoints that report an App's
# repository access (repos/*/installation, user/installations/*/repositories)
# both reject an ordinary `gh auth login` token, so any "verified" this script
# printed would be guesswork. The smoke test below is the real check.
cat <<EOF
Could not do it from here${INSTALL_ID:+ (needs an Organization Owner)}. Do it in the browser:
1. Open: https://github.com/organizations/$ORG/settings/installations
2. Configure -> $APP_SLUG
3. Repository access -> Only select repositories -> add $DAILY_REPO
(keep orca-hourly and orca-adhoc selected; all dev channels use this one App)
4. Save.
EOF
fi
echo
echo "Smoke-test the pipeline (after this merges):"
echo " gh workflow run daily-mac-build.yml --repo $MAIN_REPO -f force=true"
echo " gh run watch --repo $MAIN_REPO"
+4 -3
View File
@@ -8,9 +8,10 @@
# — no yearly rotation — and it belongs to the org rather than to the person who
# created it, so it survives that person leaving.
#
# The same App also serves adhoc-mac-build.yml, which reads these same two
# secrets: one credential, one rotation, both dev channels. Widening it to cover
# stablyai/orca-adhoc is config/scripts/setup-adhoc-release-repo.sh's job.
# The same App also serves adhoc-mac-build.yml and daily-mac-build.yml, which
# read these same two secrets: one credential, one rotation, all dev channels.
# Widening it to cover stablyai/orca-adhoc / orca-daily is
# setup-adhoc-release-repo.sh / setup-daily-release-repo.sh's job.
#
# The key is read from a file and piped straight into `gh secret set`. It is never
# echoed, never passed as a command-line argument (argv is world-readable via
@@ -0,0 +1,117 @@
#!/usr/bin/env node
// Run with: node config/scripts/worktree-base-pending-marker-benchmark.mjs
import fs from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { syncBuiltinESMExports } from 'node:module'
import os from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { createJiti } from 'jiti'
const CANDIDATE_COUNT = Number(process.env.ORCA_PENDING_MARKER_BENCH_CANDIDATES ?? '64')
const TOTAL_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_TICKS ?? '900')
const STEADY_TICKS = Number(process.env.ORCA_PENDING_MARKER_BENCH_STEADY_TICKS ?? '300')
for (const [name, value] of [
['ORCA_PENDING_MARKER_BENCH_CANDIDATES', CANDIDATE_COUNT],
['ORCA_PENDING_MARKER_BENCH_TICKS', TOTAL_TICKS],
['ORCA_PENDING_MARKER_BENCH_STEADY_TICKS', STEADY_TICKS]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
if (STEADY_TICKS >= TOTAL_TICKS) {
throw new Error('ORCA_PENDING_MARKER_BENCH_STEADY_TICKS must be smaller than total ticks')
}
const root = await mkdtemp(join(os.tmpdir(), 'orca-pending-marker-bench-'))
const markerPaths = new Set()
for (let index = 0; index < CANDIDATE_COUNT; index += 1) {
const candidate = join(root, `ordinary-folder-${index}`)
await mkdir(candidate)
markerPaths.add(join(candidate, '.git'))
}
const originalStat = fs.promises.stat
let visibleTick = 0
const markerStatTicks = []
fs.promises.stat = async (path, ...args) => {
if (markerPaths.has(String(path))) {
markerStatTicks.push(visibleTick)
}
return originalStat(path, ...args)
}
syncBuiltinESMExports()
let poller
let timeout
try {
const jiti = createJiti(import.meta.url)
const { startWorktreeBaseDirectoryPoller } = await jiti.import(
'../../src/main/ipc/worktree-base-directory-poller.ts'
)
const repo = { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false }
const target = {
key: `base:local:${root}`,
kind: 'base',
path: root,
repos: new Map([[repo.repoId, repo]])
}
let finish
let fail
const completed = new Promise((resolve, reject) => {
finish = resolve
fail = reject
})
timeout = setTimeout(() => fail(new Error('poller benchmark timed out')), 30_000)
const startedAt = performance.now()
poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
() => {},
{
pollIntervalMs: 0,
visibility: {
isWindowVisible: () => {
visibleTick += 1
if (visibleTick > TOTAL_TICKS) {
finish()
return false
}
return true
},
onWindowBecameVisible: () => () => {}
}
}
)
await completed
clearTimeout(timeout)
await poller.unsubscribe()
poller = undefined
const steadyStartTick = TOTAL_TICKS - STEADY_TICKS
const steadyMarkerStats = markerStatTicks.filter((tick) => tick > steadyStartTick).length
const totalMarkerStats = markerStatTicks.length
const statsPerCandidateTick = steadyMarkerStats / CANDIDATE_COUNT / STEADY_TICKS
const elapsedMs = performance.now() - startedAt
console.log('Worktree-base marker cooldown benchmark. Lower is better.')
console.log(
JSON.stringify({
candidates: CANDIDATE_COUNT,
totalTicks: TOTAL_TICKS,
steadyTicks: STEADY_TICKS,
totalMarkerStats,
steadyMarkerStats,
statsPerCandidateTick: Number(statsPerCandidateTick.toFixed(4)),
elapsedMs: Number(elapsedMs.toFixed(1))
})
)
} finally {
clearTimeout(timeout)
await poller?.unsubscribe()
fs.promises.stat = originalStat
syncBuiltinESMExports()
await rm(root, { recursive: true, force: true })
}
+393
View File
@@ -0,0 +1,393 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { posix } from 'node:path'
import { performance } from 'node:perf_hooks'
import process from 'node:process'
import { createJiti } from 'jiti'
const DEFAULT_SAMPLES = 20
const DEFAULT_WARMUPS = 3
function parseArgs(argv) {
const options = {
distro: null,
nativeRepo: null,
mountedRepo: null,
samples: DEFAULT_SAMPLES,
warmups: DEFAULT_WARMUPS,
loginDelayMs: 0
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = argv[++index]
if (!value) {
throw new Error(`${arg} requires a value`)
}
if (arg === '--distro') {
options.distro = value
} else if (arg === '--native-repo') {
options.nativeRepo = value
} else if (arg === '--mounted-repo') {
options.mountedRepo = value
} else if (arg === '--samples') {
options.samples = Number(value)
} else if (arg === '--warmups') {
options.warmups = Number(value)
} else if (arg === '--login-delay-ms') {
options.loginDelayMs = Number(value)
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
for (const [name, value, minimum] of [
['samples', options.samples, 5],
['warmups', options.warmups, 0],
['login-delay-ms', options.loginDelayMs, 0]
]) {
if (!Number.isInteger(value) || value < minimum || value > 1_000) {
throw new Error(`--${name} must be an integer between ${minimum} and 1000`)
}
}
if (!options.nativeRepo || !options.mountedRepo) {
throw new Error('--native-repo and --mounted-repo are required')
}
return options
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
const stdout = []
const stderr = []
child.stdout.on('data', (chunk) => stdout.push(chunk))
child.stderr.on('data', (chunk) => stderr.push(chunk))
child.on('error', reject)
child.on('close', (status) => {
const result = {
status,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr)
}
if (status === 0 || options.allowFailure) {
resolve(result)
} else {
reject(new Error(`${command} exited ${status}: ${result.stderr.toString('utf8').trim()}`))
}
})
})
}
function wslArgs(distro, args) {
return ['-d', distro, '--exec', ...args]
}
function wslShellArgs(distro, args) {
return ['-d', distro, '--', ...args]
}
async function resolveDistro(requested) {
if (requested) {
return requested
}
const result = await run('wsl.exe', ['--list', '--quiet'], {
env: { ...process.env, WSL_UTF8: '1' }
})
const distro = result.stdout
.toString('utf8')
.replaceAll('\0', '')
.split(/\r?\n/)
.map((value) => value.trim())
.find(Boolean)
if (!distro) {
throw new Error('No WSL distro is installed')
}
return distro
}
function percentile(samples, value) {
const sorted = [...samples].sort((left, right) => left - right)
return sorted[Math.ceil((value / 100) * sorted.length) - 1]
}
function summarize(samples) {
return {
samples: samples.length,
medianMs: Number(percentile(samples, 50).toFixed(1)),
p95Ms: Number(percentile(samples, 95).toFixed(1)),
minMs: Number(Math.min(...samples).toFixed(1)),
maxMs: Number(Math.max(...samples).toFixed(1))
}
}
function assertRepoPath(path, expectedPrefix) {
if (!path.startsWith(expectedPrefix) || path.includes('\0') || path.includes('\n')) {
throw new Error(`Unexpected benchmark repository path: ${path}`)
}
}
async function main() {
if (process.platform !== 'win32') {
throw new Error('This benchmark requires a Windows host with WSL')
}
const options = parseArgs(process.argv.slice(2))
assertRepoPath(options.nativeRepo, '/')
assertRepoPath(options.mountedRepo, '/mnt/')
const distro = await resolveDistro(options.distro)
const jiti = createJiti(import.meta.url)
const { buildWslLoginShellCommand, escapeWslShCommandForWindows, quotePosixShell } =
await jiti.import('../../src/shared/wsl-login-shell-command.ts')
const loginProbe = buildWslLoginShellCommand(
`printf '\\n__ORCA_PATH__%s\\n__ORCA_GIT__%s\\n__ORCA_HOME__%s\\n' "$PATH" "$(command -v git)" "$HOME"`
)
const probe = await run(
'wsl.exe',
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(loginProbe)])
)
const probeText = probe.stdout.toString('utf8')
const loginPath = /__ORCA_PATH__(.*)/.exec(probeText)?.[1]?.trim()
const gitPath = /__ORCA_GIT__(.*)/.exec(probeText)?.[1]?.trim()
const loginHome = /__ORCA_HOME__(.*)/.exec(probeText)?.[1]?.trim()
if (!loginPath || !gitPath?.startsWith('/') || !loginHome?.startsWith('/')) {
throw new Error(`Could not resolve login-shell Git environment: ${probeText.trim()}`)
}
const outputMarker = `__ORCA_GIT_OUTPUT_${process.pid}__\n`
const runGit = async (mode, repo, args) => {
const startedAt = performance.now()
let result
if (mode === 'login') {
const command = [
`cd ${quotePosixShell(repo)} &&`,
`printf ${quotePosixShell(outputMarker)} &&`,
`LC_ALL=C LANG=C ${quotePosixShell('git')}`,
...args.map(quotePosixShell)
].join(' ')
const delay = options.loginDelayMs > 0 ? `sleep ${options.loginDelayMs / 1_000}; ` : ''
const script = `${delay}${buildWslLoginShellCommand(command)}`
result = await run(
'wsl.exe',
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
)
const markerOffset = result.stdout.indexOf(outputMarker)
if (markerOffset < 0) {
throw new Error('Login shell did not emit the Git output marker')
}
result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker))
} else {
result = await run(
'wsl.exe',
wslArgs(distro, [
'/usr/bin/env',
`PATH=${loginPath}`,
`HOME=${loginHome}`,
'LC_ALL=C',
'LANG=C',
gitPath,
'-C',
repo,
...args
])
)
}
return { ...result, elapsedMs: performance.now() - startedAt }
}
const measure = async (label, runArm) => {
for (let index = 0; index < options.warmups; index += 1) {
await runArm('login')
await runArm('fast')
}
const samples = { login: [], fast: [] }
for (let index = 0; index < options.samples; index += 1) {
const order = index % 2 === 0 ? ['login', 'fast'] : ['fast', 'login']
const results = []
for (const mode of order) {
const result = await runArm(mode)
samples[mode].push(result.elapsedMs)
results.push(result)
}
if (
results[0].status !== results[1].status ||
!results[0].stdout.equals(results[1].stdout) ||
!results[0].stderr.equals(results[1].stderr)
) {
throw new Error(
`${label} correctness mismatch between ${order.join(' and ')} arms:\n` +
`${order[0]}=${JSON.stringify({ stdout: results[0].stdout.toString('utf8'), stderr: results[0].stderr.toString('utf8') })}\n` +
`${order[1]}=${JSON.stringify({ stdout: results[1].stdout.toString('utf8'), stderr: results[1].stderr.toString('utf8') })}`
)
}
}
const login = summarize(samples.login)
const fast = summarize(samples.fast)
return {
login,
fast,
medianSpeedup: Number((login.medianMs / fast.medianMs).toFixed(2)),
medianSavedMs: Number((login.medianMs - fast.medianMs).toFixed(1))
}
}
const benchmarkRepo = async (repo) => {
const branch = (
await runGit('fast', repo, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
).stdout
.toString('utf8')
.trim()
const fixtureName = `.orca-wsl-git-shell-benchmark-${process.pid}-${randomUUID()}.txt`
const fixturePath = posix.join(repo, fixtureName)
const prepareStage = async () => {
await runGit('fast', repo, ['reset', '--quiet', '--', fixtureName])
await run(
'wsl.exe',
wslArgs(distro, [
'/bin/sh',
'-c',
'printf "benchmark\\n" > "$1"',
'orca-wsl-git-shell-benchmark',
fixturePath
])
)
}
const cleanupStage = async () => {
await runGit('fast', repo, ['reset', '--quiet', '--', fixtureName])
await run('wsl.exe', wslArgs(distro, ['/bin/rm', '-f', '--', fixturePath]))
}
const operations = {
status: [
'-c',
'core.quotePath=false',
'status',
'--porcelain=v2',
'--branch',
'--untracked-files=all'
],
numstat: ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M'],
upstream: [
'for-each-ref',
'--format=%(refname)%00%(upstream)%00%(upstream:short)',
'--count=1',
`refs/heads/${branch}`
]
}
const result = {}
for (const [name, args] of Object.entries(operations)) {
result[name] = await measure(`${repo}:${name}`, (mode) => runGit(mode, repo, args))
}
const created = await run(
'wsl.exe',
wslArgs(distro, [
'/bin/sh',
'-c',
'set -C; printf "benchmark\\n" > "$1"',
'orca-wsl-git-shell-benchmark',
fixturePath
]),
{ allowFailure: true }
)
if (created.status !== 0) {
throw new Error(`Could not exclusively create benchmark fixture: ${fixturePath}`)
}
try {
result.stageRefresh = await measure(`${repo}:stage-refresh`, async (mode) => {
await prepareStage()
const startedAt = performance.now()
await runGit('login', repo, ['add', '--', fixtureName])
const status = await runGit(mode, repo, operations.status)
const numstat = await runGit(mode, repo, [
'-c',
'core.quotePath=false',
'diff',
'-z',
'--cached',
'--numstat',
'-M'
])
return {
status: 0,
stdout: Buffer.concat([status.stdout, numstat.stdout]),
stderr: Buffer.concat([status.stderr, numstat.stderr]),
elapsedMs: performance.now() - startedAt
}
})
} finally {
await cleanupStage()
}
return result
}
const verifyProductionRunner = async (repo) => {
const windowsRepo = (
await run('wsl.exe', wslArgs(distro, ['/usr/bin/wslpath', '-w', repo]))
).stdout
.toString('utf8')
.trim()
const args = [
'-c',
'core.quotePath=false',
'status',
'--porcelain=v2',
'--branch',
'--untracked-files=all'
]
const [{ gitExecFileAsync }, { getWslGitReadEnvironment }, expected] = await Promise.all([
jiti.import('../../src/main/git/runner.ts'),
jiti.import('../../src/main/git/wsl-git-read-environment.ts'),
runGit('fast', repo, args)
])
await getWslGitReadEnvironment(distro)
const startedAt = performance.now()
const actual = await gitExecFileAsync(args, {
cwd: windowsRepo,
preferWslDirectGit: true,
wslDistro: distro
})
if (
!Buffer.from(actual.stdout).equals(expected.stdout) ||
!Buffer.from(actual.stderr).equals(expected.stderr)
) {
throw new Error(`Production runner output mismatch for ${windowsRepo}`)
}
return {
windowsRepo,
environmentPrimed: true,
elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
outputBytes: expected.stdout.length
}
}
const result = {
distro,
gitPath,
gitVersion: (await runGit('fast', options.nativeRepo, ['--version'])).stdout
.toString('utf8')
.trim(),
samples: options.samples,
warmups: options.warmups,
injectedLoginDelayMs: options.loginDelayMs,
loginProbePreambleBytes: Buffer.byteLength(probeText.split('__ORCA_PATH__', 1)[0]),
guestProcessShape: {
login: 'sh -> interactive login shell -> git',
fast: 'env -> git'
},
native: await benchmarkRepo(options.nativeRepo),
mounted: await benchmarkRepo(options.mountedRepo),
productionRunnerVerification: {
native: await verifyProductionRunner(options.nativeRepo),
mounted: await verifyProductionRunner(options.mountedRepo)
}
}
console.log(JSON.stringify(result, null, 2))
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
@@ -364,8 +364,9 @@ async function main() {
const { runtime, getController } = createRuntimeStub()
// Why hooks off: fresh WSL spawn still runs buildPtyHostEnv, which calls ensureForDistro when
// hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach),
// which does not gate on agentStatusHooksEnabled.
// hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach);
// the manager's own hooks gate lives behind the ensureForDistro patch above, so it stays out
// of the measurement — `manager` below resolves its own (enabled) managed-hook settings.
ptyIpc.registerPtyHandlers(
createRendererWindowStub(),
runtime,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 244 KiB

+2 -2
View File
@@ -36,7 +36,7 @@
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-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
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 el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+2 -2
View File
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android :** [Télécharger l'APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android :** [Télécharger l'APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [Baixar APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+3
View File
@@ -217,6 +217,9 @@ export const electronViteConfig: UserConfig = {
'session-scanner-opencode-sqlite-worker-entry': resolve(
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
),
'session-scanner-worker-entry': resolve(
'src/main/ai-vault/session-scanner-worker-entry.ts'
),
// Why: libuv spawns processes inline on the calling loop, so the port
// scan's probe commands run on a worker thread instead of the UI one.
'port-scan-command-worker-entry': resolve(
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.42",
"version": "0.0.43",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 11
"versionCode": 12
},
"plugins": [
"expo-router",
@@ -106,7 +106,7 @@ import type {
TerminalModes,
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { isTerminalOscLinkRanges } from '../../../../../src/shared/terminal-osc-link-ranges'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
+106 -95
View File
@@ -80,6 +80,7 @@ import {
import { shouldResolveHostedReviewStartPoint } from '../../../src/tasks/hosted-review-start-point'
import { getLinkedWorkItemSuggestedName } from '../../../src/tasks/mobile-workspace-name'
import {
dropFailedGitHubRepoSlugEntries,
filterGitHubProjectRowsForRepos,
findRepoForGitHubProjectRepository,
type GitHubRepoSlugCacheEntry
@@ -132,6 +133,9 @@ import {
resolveVisibleTaskProvider,
type TaskProvider
} from '../../../src/tasks/mobile-task-providers'
import { hasSettledHostRepoList } from '../../../src/tasks/host-repo-list'
import { useHostRepoList } from '../../../src/tasks/use-host-repo-list'
import { isHostedTaskRepo, reconcileRepoSelection } from '../../../src/tasks/hosted-repo-selection'
import {
extractLinearIssueReadItems,
type LinearMobileIssue
@@ -1118,22 +1122,6 @@ async function mapWithConcurrency<T, R>(
return results
}
function isHostedTaskRepo(repo: RepoSummary): boolean {
return repo.kind !== 'folder'
}
function reconcileRepoSelection(
repos: RepoSummary[],
persisted: string[] | null | undefined
): Set<string> {
if (!persisted || persisted.length === 0) {
return new Set()
}
const availableIds = new Set(repos.filter(isHostedTaskRepo).map((repo) => repo.id))
const selected = persisted.filter((id) => availableIds.has(id))
return selected.length === 0 ? new Set() : new Set(selected)
}
function createLinearTask(issue: LinearIssue): TaskItem {
return {
key: `linear:${issue.workspaceId ?? 'workspace'}:${issue.id}`,
@@ -2084,10 +2072,22 @@ export default function MobileTasksScreen() {
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const clientRef = useRef<RpcClient | null>(null)
const reposRef = useRef<RepoSummary[]>([])
const loadGenerationRef = useRef(0)
const taskResumeRef = useRef<TaskResumeState>({})
const [repos, setRepos] = useState<RepoSummary[]>([])
const repoList = useHostRepoList<RepoSummary>(
client,
client && connState === 'connected'
? async () => {
const response = await client.sendRequest('repo.list')
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
return (response.result as { repos: RepoSummary[] }).repos
}
: null
)
const repos = repoList.state.repos
const { ensureLoaded: repoListEnsureLoaded, reload: repoListReload } = repoList
const [provider, setProvider] = useState<TaskProvider>('github')
const [visibleProviders, setVisibleProviders] = useState<TaskProvider[]>(() =>
normalizeVisibleTaskProviders(undefined)
@@ -2378,13 +2378,17 @@ export default function MobileTasksScreen() {
) as RepoSummary | null,
[activeGitHubProjectHost, githubRepoSlugCache, hostedRepos]
)
// Why: `every` is vacuously true on an empty repo list, so readiness has to ask
// the resource whether that list is real yet. Otherwise the board renders
// "No project items" for a board whose repos simply have not arrived.
const githubProjectRepoSlugReady = useMemo(
() =>
hasSettledHostRepoList(repoList.state) &&
hostedRepos.every((repo) => {
const cached = githubRepoSlugCache[repo.id]
return cached !== undefined && cached.path === repo.path
}),
[githubRepoSlugCache, hostedRepos]
[githubRepoSlugCache, hostedRepos, repoList.state]
)
const visibleGitHubProjectRows = useMemo(
() =>
@@ -2488,13 +2492,10 @@ export default function MobileTasksScreen() {
throw new Error(response.error.message)
}
const result = response.result as GitHubOwnerRepo | null
return {
repoId: repo.id,
path: repo.path,
repository: result
}
return { repoId: repo.id, entry: { path: repo.path, repository: result } }
} catch {
return { repoId: repo.id, path: repo.path, repository: null }
// Cached so readiness settles; `failed` marks it for retry on refresh.
return { repoId: repo.id, entry: { path: repo.path, repository: null, failed: true } }
}
}).then((entries) => {
if (cancelled) {
@@ -2503,7 +2504,7 @@ export default function MobileTasksScreen() {
setGithubRepoSlugCache((current) => {
const next = { ...current }
for (const entry of entries) {
next[entry.repoId] = { path: entry.path, repository: entry.repository }
next[entry.repoId] = entry.entry
}
return next
})
@@ -2621,8 +2622,31 @@ export default function MobileTasksScreen() {
// Why: task-loading effects use this as a stale-client guard, so the ref
// must be current before those passive effects can run after commit.
const resetGitHubItemsState = useCallback(() => {
setGithubRepoSources({})
setGithubPages([])
setGithubCurrentPage(0)
setGithubTotalCount(null)
setGithubSourceErrors([])
setGithubSourceFallbacks([])
}, [])
// Why: Expo reuses this screen for the next host, so an effect reset runs a
// render too late and the previous host's rows show under the new one. The
// repo list resets itself; these are the other client-scoped caches.
const [boundClient, setBoundClient] = useState(client)
if (boundClient !== client) {
setBoundClient(client)
setItems([])
setGithubRepoSlugCache({})
resetGitHubItemsState()
}
useLayoutEffect(() => {
clientRef.current = client
// Why: ref writes belong in the commit phase. Doing this during render would
// leak out of a concurrent render React later abandons.
repoSelectionHydratedRef.current = false
}, [client])
const persistTaskResumeState = useCallback(
@@ -2877,7 +2901,7 @@ export default function MobileTasksScreen() {
// pair but must not receive the newer task-specific method calls.
setTasksSupportState({ kind: 'unsupported', client })
setItems([])
setGithubPages([])
resetGitHubItemsState()
setGithubProjectTable(null)
setShowLinearWorkspacePicker(false)
setShowLinearTeamPicker(false)
@@ -3035,32 +3059,26 @@ export default function MobileTasksScreen() {
setProvider(resolveVisibleTaskProvider(provider, visibleProviders))
}, [provider, visibleProviders])
const loadRepos = useCallback(async (): Promise<RepoSummary[]> => {
if (!client || connState !== 'connected') {
return []
// Selection follows the list rather than the fetch, so it reconciles the same
// way no matter which caller triggered the load.
useEffect(() => {
if (repoList.state.status !== 'loaded') {
return
}
const response = await client.sendRequest('repo.list')
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
const result = response.result as { repos: RepoSummary[] }
reposRef.current = result.repos
setRepos(result.repos)
if (!repoSelectionHydratedRef.current) {
repoSelectionHydratedRef.current = true
setSelectedRepoIds(reconcileRepoSelection(result.repos, defaultRepoSelectionRef.current))
} else {
setSelectedRepoIds((current) => {
if (current.size === 0) {
return current
}
const availableIds = new Set(result.repos.filter(isHostedTaskRepo).map((repo) => repo.id))
const next = new Set([...current].filter((id) => availableIds.has(id)))
return next.size === current.size ? current : next
})
setSelectedRepoIds(reconcileRepoSelection(repos, defaultRepoSelectionRef.current))
return
}
return result.repos
}, [client, connState])
setSelectedRepoIds((current) => {
if (current.size === 0) {
return current
}
const availableIds = new Set(repos.filter(isHostedTaskRepo).map((repo) => repo.id))
const next = new Set([...current].filter((id) => availableIds.has(id)))
return next.size === current.size ? current : next
})
}, [repoList.state.status, repos])
const loadLinearContext = useCallback(async (): Promise<void> => {
if (!client || connState !== 'connected' || !tasksSupported) {
@@ -3251,24 +3269,26 @@ export default function MobileTasksScreen() {
}
try {
if (provider !== 'github' || githubMode !== 'items') {
setGithubPages([])
setGithubCurrentPage(0)
setGithubTotalCount(null)
setGithubSourceErrors([])
setGithubSourceFallbacks([])
}
if (provider === 'github' && githubMode === 'project') {
setItems([])
return
resetGitHubItemsState()
}
if (provider === 'linear' && !linearConnected) {
setItems([])
return
}
const currentRepos = reposRef.current.length > 0 ? reposRef.current : await loadRepos()
// Why: Linear issues do not need the repo list, only the composer does, so
// start the fetch either way but never make Linear wait on it.
const repoListRequest = repoListEnsureLoaded()
void repoListRequest.catch(() => {})
const currentRepos = provider === 'linear' ? [] : await repoListRequest
if (!isCurrent()) {
return
}
// Why: project mode fetches no work items, but its rows are matched against
// the repo list, so it must not return before loadRepos() has run.
if (provider === 'github' && githubMode === 'project') {
setItems([])
return
}
if (provider === 'github' || provider === 'gitlab') {
const supportedRepos = currentRepos.filter(isHostedTaskRepo)
const queriedRepos =
@@ -3280,11 +3300,7 @@ export default function MobileTasksScreen() {
return
}
setItems([])
setGithubPages([])
setGithubCurrentPage(0)
setGithubTotalCount(null)
setGithubSourceErrors([])
setGithubSourceFallbacks([])
resetGitHubItemsState()
return
}
if (provider === 'github') {
@@ -3320,11 +3336,6 @@ export default function MobileTasksScreen() {
return
}
if (provider === 'gitlab' && gitlabView === 'todos') {
setGithubPages([])
setGithubCurrentPage(0)
setGithubTotalCount(null)
setGithubSourceErrors([])
setGithubSourceFallbacks([])
const response = await requestClient.sendRequest('gitlab.todos', {
repo: `id:${queriedRepos[0]!.id}`
})
@@ -3341,11 +3352,6 @@ export default function MobileTasksScreen() {
)
return
}
setGithubPages([])
setGithubCurrentPage(0)
setGithubTotalCount(null)
setGithubSourceErrors([])
setGithubSourceFallbacks([])
const results = await mapWithConcurrency(
queriedRepos,
GITHUB_REPO_CONCURRENCY,
@@ -3429,8 +3435,7 @@ export default function MobileTasksScreen() {
return
}
setItems([])
setGithubSourceErrors([])
setGithubSourceFallbacks([])
resetGitHubItemsState()
setError(err instanceof Error ? err.message : 'Failed to load tasks')
} finally {
if (isCurrent()) {
@@ -3451,7 +3456,9 @@ export default function MobileTasksScreen() {
linearConnected,
linearFilter,
linearOrderBy,
loadRepos,
// resetGitHubItemsState is useCallback([]), so its identity never changes
// and listing it here would only cost a line against the max-lines budget.
repoListEnsureLoaded,
provider,
selectedLinearTeamIds,
selectedLinearWorkspaceId,
@@ -3890,6 +3897,19 @@ export default function MobileTasksScreen() {
tasksSupported
])
// Why: a refresh must re-read the host, not replay the cached list, or a repo
// added since this screen mounted can never appear.
const refreshTasks = useCallback(() => {
void repoListReload().catch(() => {})
void loadTasks({ silent: true })
}, [loadTasks, repoListReload])
const refreshGitHubProject = useCallback(() => {
setGithubRepoSlugCache(dropFailedGitHubRepoSlugEntries)
refreshTasks()
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
}, [appliedGithubProjectSearch, loadGitHubProjectTable, refreshTasks])
useEffect(() => {
if (!taskStateHydrated) {
return
@@ -8120,22 +8140,15 @@ export default function MobileTasksScreen() {
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
setRepos((current) =>
current.map((candidate) =>
candidate.id === repo.id
? { ...candidate, issueSourcePreference: preference }
: candidate
)
)
reposRef.current = reposRef.current.map((candidate) =>
candidate.id === repo.id ? { ...candidate, issueSourcePreference: preference } : candidate
)
// Why: the host owns issueSourcePreference, so re-read the list instead of
// patching the cached copy and hoping the two stay in step.
await repoListReload().catch(() => {})
await loadTasks({ silent: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update issue source')
}
},
[client, loadTasks, taskUiReady]
[client, loadTasks, repoListReload, taskUiReady]
)
const renderCommentComposer = (args: {
@@ -8691,10 +8704,10 @@ export default function MobileTasksScreen() {
return
}
if (provider === 'github' && githubMode === 'project') {
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
refreshGitHubProject()
return
}
void loadTasks({ silent: true })
refreshTasks()
}}
>
<RefreshCw size={16} color={taskUiReady ? colors.textSecondary : colors.textMuted} />
@@ -9278,9 +9291,7 @@ export default function MobileTasksScreen() {
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
refreshing={githubProjectLoading}
onRefresh={() =>
void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch })
}
onRefresh={refreshGitHubProject}
renderItem={({ item: entry }) => {
if (entry.type === 'group') {
return (
@@ -9485,7 +9496,7 @@ export default function MobileTasksScreen() {
}
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
refreshing={refreshing}
onRefresh={() => void loadTasks({ silent: true })}
onRefresh={refreshTasks}
renderItem={({ item: entry }) => {
if (entry.type === 'section') {
return (
@@ -9584,7 +9595,7 @@ export default function MobileTasksScreen() {
}
contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]}
refreshing={refreshing}
onRefresh={() => void loadTasks({ silent: true })}
onRefresh={refreshTasks}
ListFooterComponent={
provider === 'github' && githubMode === 'items' && githubCanShowPagination ? (
<View style={styles.paginationFooter}>
+60 -8
View File
@@ -2,8 +2,9 @@
#
# 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
# fetched via the App Store Connect API key, uploads the .ipa, then distributes
# the processed build to external TestFlight testers. Credentials come from CI
# env vars
# (see .github/workflows/mobile-ios-release.yml) so nothing secret lives in the
# repo.
#
@@ -28,6 +29,9 @@ WORKSPACE = File.join(MOBILE_ROOT, "ios", "Orca.xcworkspace")
BUILD_OUTPUT_DIRECTORY = File.join(MOBILE_ROOT, "build")
SCHEME = "Orca"
BUNDLE_ID = "com.stably.orca.mobile"
TESTFLIGHT_GROUPS = ["peeps"].freeze
DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze
TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS = 20 * 60
# App Store version states in which the version "train" is terminally closed to
# new TestFlight build uploads (altool rejects with 90186 "train ... is
@@ -72,6 +76,11 @@ def current_mobile_version(config)
config.fetch("expo").fetch("version")
end
def testflight_changelog
changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip
changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog
end
# True when either state field reports a terminally closed train. `respond_to?`
# guards the newer field, which older spaceship versions do not define.
def closed_state?(app_store_version)
@@ -172,7 +181,7 @@ platform :ios do
end
desc "Build, sign, and upload Orca Mobile to App Store Connect / TestFlight"
lane :release do
lane :build_and_upload do
api_key = app_store_connect_api_key_from_env
team_id = ENV.fetch("APPLE_TEAM_ID")
@@ -214,13 +223,56 @@ platform :ios do
upload_to_testflight(
api_key: api_key,
# Why: ASC processing wait hung for hours (0.0.42 builds 1–2) with no Ready
# build and no email. Exit after upload acceptance. Do not pass changelog —
# Pilot only fully skips waiting when changelog is nil (otherwise it still
# polls until the build appears). External distribute (e.g. peeps) from ASC
# or a follow-up lane once Ready.
# Distribution runs separately so a processing timeout can be retried
# against this exact build without uploading another binary.
skip_waiting_for_build_processing: true,
distribute_external: false,
)
end
desc "Wait for and distribute an uploaded build to external TestFlight testers"
lane :distribute_testflight do |options|
version = options[:version].to_s.strip
build_number = options[:build_number].to_s.strip
UI.user_error!("version is required") if version.empty?
UI.user_error!("build_number is required") if build_number.empty?
api_key = app_store_connect_api_key_from_env
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
UI.user_error!("Could not find App Store Connect app #{BUNDLE_ID}") unless app
available_groups = app.get_beta_groups.map(&:name)
missing_groups = TESTFLIGHT_GROUPS - available_groups
unless missing_groups.empty?
UI.user_error!(
"Missing TestFlight group(s): #{missing_groups.join(', ')}. " \
"Create them in App Store Connect or update TESTFLIGHT_GROUPS.",
)
end
upload_to_testflight(
api_key: api_key,
app_identifier: BUNDLE_ID,
app_version: version,
build_number: build_number,
distribute_only: true,
skip_waiting_for_build_processing: false,
wait_processing_timeout_duration: TESTFLIGHT_PROCESSING_TIMEOUT_SECONDS,
distribute_external: true,
groups: TESTFLIGHT_GROUPS,
notify_external_testers: true,
changelog: testflight_changelog,
)
end
desc "Build, upload, and distribute Orca Mobile to external TestFlight testers"
lane :release do
build_and_upload
config = load_mobile_app_config
distribute_testflight(
version: current_mobile_version(config),
build_number: config.fetch("expo").fetch("ios").fetch("buildNumber"),
)
end
end
+39 -7
View File
@@ -10279,7 +10279,7 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
@@ -10299,7 +10299,7 @@ snapshots:
dependencies:
'@typescript-eslint/scope-manager': 8.59.2
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
eslint: 9.39.4
@@ -10316,6 +10316,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.59.2':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -10325,6 +10334,10 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -10354,6 +10367,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
@@ -11633,11 +11661,11 @@ snapshots:
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
eslint: 9.39.4
eslint-config-prettier: 9.1.2(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
eslint-plugin-node: 11.1.0(eslint@9.39.4)
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
@@ -11661,7 +11689,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -11684,7 +11712,7 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -11695,7 +11723,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
hasown: 2.0.3
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -14948,6 +14976,10 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
ts-declaration-location@1.0.7(typescript@5.9.3):
dependencies:
picomatch: 4.0.4
+4 -21
View File
@@ -157,28 +157,12 @@ const RESET_SNAPSHOT = {
}
} as const
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
async function renderAccountsRoute(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(AccountsScreen))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
await act(async () => {
renderer = create(createElement(AccountsScreen))
await Promise.resolve()
})
if (!renderer) {
throw new Error('Accounts route did not render')
}
@@ -229,7 +213,6 @@ describe('accounts route Codex reset credit', () => {
let storedValues: Map<string, string>
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetCodexResetAttemptJournalForTests()
storedValues = new Map()
dependencies.alert.mockReset()
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { CodexResetCreditAction } from './CodexResetCreditAction'
vi.mock('react-native', () => ({
@@ -19,35 +19,19 @@ const summary = {
expiryLabel: 'Expires in 5d'
}
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
act(() => {
renderer = create(
createElement(CodexResetCreditAction, {
summary,
scopeLabel: 'dev@example.com on the host',
busy,
disabled,
onPress: vi.fn()
})
)
})
} finally {
restore()
}
act(() => {
renderer = create(
createElement(CodexResetCreditAction, {
summary,
scopeLabel: 'dev@example.com on the host',
busy,
disabled,
onPress: vi.fn()
})
)
})
if (!renderer) {
throw new Error('Reset action did not render')
}
@@ -55,10 +39,6 @@ function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
}
describe('CodexResetCreditAction', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes a 44pt touch target and enabled accessibility state', () => {
const renderer = renderAction(false, false)
const button = renderer.root.findByType('Pressable')
@@ -78,7 +78,6 @@ describe('HostProtocolGate', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
nativeTestState.openUrl.mockClear()
nativeTestState.platform.OS = 'ios'
probeMounts.count = 0
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HostProfile } from '../transport/types'
import { MobileHomeQuickActions } from './MobileHomeQuickActions'
@@ -37,10 +37,6 @@ function host(id: string, name: string, endpoint: string): HostProfile {
describe('MobileHomeQuickActions', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
+1 -5
View File
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileHostCard } from './MobileHostCard'
vi.mock('react-native', () => ({
@@ -30,10 +30,6 @@ function suppressRendererDeprecation() {
describe('MobileHostCard', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionVerdict } from '../transport/connection-health'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types'
@@ -39,10 +39,6 @@ const loaded: HostWorktreeInfo = {
describe('MobileHostCard', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -38,7 +38,6 @@ describe('MobileMarkdown file links', () => {
const onOpenFile = vi.fn()
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
onOpenFile.mockClear()
openURL.mockClear()
})
@@ -1,52 +0,0 @@
import { Linking } from 'react-native'
import { BottomDrawer } from './BottomDrawer'
import { MobilePrComposeForm, type PrComposePrefill } from './pr-sidebar/MobilePrComposeForm'
import type { RpcClient } from '../transport/rpc-client'
type Props = {
visible: boolean
client: RpcClient | null
worktreeId: string
prefill: PrComposePrefill
// Head branch — enables the base≠head guard and the "from <branch>" hint.
head?: string | null
onClose: () => void
onCreated: (url: string, warning?: string) => void
}
// BottomDrawer wrapper around the inline compose form, for full-screen roots
// (source-control modals). The PR sidebar empty-state renders MobilePrComposeForm
// inline instead, since a BottomDrawer overlay nested in a ScrollView clips it.
export function MobilePrComposeSheet({
visible,
client,
worktreeId,
prefill,
head,
onClose,
onCreated
}: Props) {
return (
<BottomDrawer visible={visible} onClose={onClose}>
{/* Why: key on visible + prefill fields so reopening (or a new prefill)
remounts the form with fresh initial values; the previous sheet reset
its fields via an effect on the same signals. */}
<MobilePrComposeForm
key={`${visible}:${prefill.title}:${prefill.base}:${prefill.body}`}
client={client}
worktreeId={worktreeId}
prefill={prefill}
head={head}
onCancel={onClose}
onCreated={onCreated}
/>
</BottomDrawer>
)
}
export function openMobilePrUrl(url: string): void {
// Why: Linking.openURL rejects when iOS/Android can't open the URL (no app,
// bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught
// (in promise) Error: Unable to open URL…" over the PR screen.
void Linking.openURL(url).catch(() => {})
}
@@ -58,7 +58,6 @@ describe('NewWorktreeModal repo list', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
setCachedRepos('host-1', repos)
})
@@ -24,7 +24,6 @@ describe('PickerModal accessibility', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { WorktreeAgentList } from './WorktreeAgentList'
@@ -39,10 +39,6 @@ function agent(paneKey: string, parentPaneKey: string | null = null): RuntimeWor
describe('WorktreeAgentList', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -127,7 +127,6 @@ describe('memoized worktree rows', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
agentSpinnerRender.mockClear()
agentStateDotRender.mockClear()
})
@@ -51,14 +51,12 @@ function mountedDrawer(renderer: ReactTestRenderer) {
describe('BottomDrawer close lifecycle', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const originalConsoleError = console.error
vi.spyOn(console, 'error').mockImplementation((...args) => {
const message = args[0]
if (
typeof message === 'string' &&
(message.includes('react-test-renderer is deprecated') ||
message.includes('The current testing environment is not configured to support act'))
message.includes('The current testing environment is not configured to support act')
) {
return
}
@@ -17,6 +17,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'),
ante: require('../../../src/shared/agent-icons/ante.png'),
trae: require('../../../src/shared/agent-icons/trae.png'),
'prime-agent': require('../../../src/shared/agent-icons/prime-agent.png'),
gemini: require('../../../src/shared/agent-icons/gemini.png'),
antigravity: require('../../../src/shared/agent-icons/antigravity.png'),
goose: require('../../../src/shared/agent-icons/goose.png'),
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileAgentIcon } from './MobileAgentIcon'
vi.mock('react-native', () => ({
@@ -35,10 +35,6 @@ vi.mock('./AgentIcons', () => ({
describe('MobileAgentIcon OMP gradient', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
+6
View File
@@ -0,0 +1,6 @@
import { Linking } from 'react-native'
export function openMobilePrUrl(url: string): void {
// Prevent unhandled rejections when no app accepts the URL.
void Linking.openURL(url).catch(() => {})
}
@@ -38,11 +38,7 @@ type Props = {
onCreated: (url: string, warning?: string) => void
}
// PR compose form body: title/body/base/draft with AI prefill (git.generate
// PullRequestFields), submitting via createMobilePr. Renders a plain View so it
// can sit inline inside the PR sidebar's existing ScrollView (a BottomDrawer
// overlay trapped in a ScrollView clips the form). The BottomDrawer wrapper
// MobilePrComposeSheet reuses this body at full-screen roots.
// A plain View keeps the compose form usable inside the PR sidebar ScrollView.
export function MobilePrComposeForm({
client,
worktreeId,
@@ -32,18 +32,6 @@ vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} }))
vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} }))
vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } }))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function comment(id: number): PRComment {
return {
id,
@@ -65,14 +53,9 @@ function detailsWithComments(count: number): GitHubWorkItemDetails {
async function renderComments(details: GitHubWorkItemDetails): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
})
} finally {
restoreConsoleError()
}
await act(async () => {
renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
})
if (!renderer) {
throw new Error('PRCommentsSection did not render')
}
@@ -107,7 +90,6 @@ describe('PRCommentsSection', () => {
afterEach(() => {
renderer?.unmount()
renderer = null
vi.restoreAllMocks()
})
it('resets pagination only when the user chooses a different audience filter', async () => {
@@ -7,7 +7,7 @@ import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-acti
import { prStateBadge } from './pr-checks-presentation'
import { statusColor } from './pr-sidebar-status-color'
import { canEditPRTitle } from '../../session/pr-title-edit'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
import { openMobilePrUrl } from '../mobile-pr-url'
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
import { prCommentComposerStyles as composerStyles } from './pr-comment-composer-styles'
@@ -18,7 +18,7 @@ import {
runMobileHostedReviewCreateIntent
} from '../../source-control/mobile-hosted-review-create-intent-runner'
import { fetchWorktreeLinkedPR } from '../../source-control/mobile-pr-link'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
import { openMobilePrUrl } from '../mobile-pr-url'
import { MobileLinkPrForm } from './MobileLinkPrForm'
import { prCreateEmptyStateStyles as styles } from './pr-create-empty-state-styles'
@@ -83,18 +83,6 @@ vi.mock('../transport/client-context', () => ({
})
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function entry(name: string, isDirectory = false): MobileDirEntry {
return { name, isDirectory }
}
@@ -113,21 +101,16 @@ function createMockClient(entriesByPath: Record<string, MobileDirEntry[]>): Mock
async function renderExplorer(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileFileExplorerPanel, {
hostId: 'host-a',
worktreeId: 'worktree-a',
name: 'Example Worktree',
embedded: true
})
)
})
} finally {
restoreConsoleError()
}
await act(async () => {
renderer = create(
createElement(MobileFileExplorerPanel, {
hostId: 'host-a',
worktreeId: 'worktree-a',
name: 'Example Worktree',
embedded: true
})
)
})
if (!renderer) {
throw new Error('MobileFileExplorerPanel did not render')
}
@@ -33,28 +33,11 @@ vi.mock('./mobile-file-preview-styles', () => ({
type PreviewProps = Parameters<typeof MobileFileMarkdownPreview>[0]
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderPreview(props: PreviewProps): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(MobileFileMarkdownPreview, props))
})
} finally {
restoreConsoleError()
}
await act(async () => {
renderer = create(createElement(MobileFileMarkdownPreview, props))
})
if (!renderer) {
throw new Error('MobileFileMarkdownPreview did not render')
}
+1 -11
View File
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const appState = vi.hoisted(() => ({
current: 'active',
@@ -25,7 +25,6 @@ import { useNow } from './use-now'
describe('useNow', () => {
let renderer: ReactTestRenderer | null = null
let latest = 0
let consoleSpy: MockInstance
function Harness({ enabled = true }: { enabled?: boolean }): null {
latest = useNow(1_000, enabled)
@@ -42,18 +41,10 @@ describe('useNow', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
globalThis.IS_REACT_ACT_ENVIRONMENT = true
appState.current = 'active'
appState.listener = null
appState.remove.mockClear()
latest = 0
const original = console.error
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
act(() => {
renderer = create(createElement(Harness))
})
@@ -63,7 +54,6 @@ describe('useNow', () => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
consoleSpy.mockRestore()
})
it('ticks while active, pauses in the background, and refreshes immediately on resume', () => {
@@ -46,29 +46,12 @@ vi.mock('./transport/client-context', () => ({
usePrimeHosts: () => dependencies.primeHosts
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(EditHostScreen))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
await act(async () => {
renderer = create(createElement(EditHostScreen))
await Promise.resolve()
})
if (!renderer) {
throw new Error('Edit host route did not render')
}
@@ -77,7 +60,6 @@ async function renderEditHostRoute(): Promise<ReactTestRenderer> {
describe('edit host route accessibility', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.loadHosts.mockReset().mockResolvedValue([
{
id: 'host-1',
+4 -23
View File
@@ -56,29 +56,12 @@ const HOST_FIXTURE = {
lastConnected: 1
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(EditHostScreen))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
await act(async () => {
renderer = create(createElement(EditHostScreen))
await Promise.resolve()
})
if (!renderer) {
throw new Error('Edit host route did not render')
}
@@ -135,7 +118,6 @@ function findText(renderer: ReactTestRenderer, match: string): boolean {
describe('edit host handleSave', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.hostId = 'host-1'
dependencies.back.mockReset()
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
@@ -285,7 +267,6 @@ describe('edit host handleSave', () => {
describe('edit host load() error states', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.hostId = 'host-1'
dependencies.back.mockReset()
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileOnboardingPage } from './MobileOnboardingPage'
vi.mock('react-native', async () => {
@@ -24,10 +24,6 @@ vi.mock('lucide-react-native', () => ({
describe('MobileOnboardingPage', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -56,7 +56,6 @@ describe('MobileOnboardingScreen', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
mocks.params = { hostId: 'paired-host', steps: 'session-view,notifications' }
mocks.replace.mockReset()
mocks.reducedMotionEnabled = false
+1 -1
View File
@@ -1,8 +1,8 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
type Props = {
prompt: AskPrompt
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { radii, spacing } from '../theme/mobile-theme'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
@@ -43,24 +43,9 @@ vi.mock('../components/BottomDrawer', async () => {
}
})
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
describe('MobileNativeChatComposer', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -71,21 +56,16 @@ describe('MobileNativeChatComposer', () => {
onChangeText: () => void,
isAttaching = false
) {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' hello ',
onChangeText,
onSend,
isAttaching
})
)
})
} finally {
restore()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' hello ',
onChangeText,
onSend,
isAttaching
})
)
})
}
function sendButton(): { props: { onPress: () => Promise<void> } } {
@@ -130,20 +110,15 @@ describe('MobileNativeChatComposer', () => {
it('preserves leading whitespace so prose is not turned into a slash command', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' /clear is prose ',
onChangeText: vi.fn(),
onSend
})
)
})
} finally {
restore()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' /clear is prose ',
onChangeText: vi.fn(),
onSend
})
)
})
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
})
@@ -181,67 +156,57 @@ describe('MobileNativeChatComposer', () => {
invokeAction: vi.fn(),
recordCommand: vi.fn()
}
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'run the tests',
onChangeText: vi.fn(),
onSend,
sessionOptions: { isWorking: false, controller }
})
)
})
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
) as { props: { accessibilityState: { disabled: boolean } } }
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
// Start the send but don't await it — it stays in flight on purpose.
let pressed!: Promise<void>
await act(async () => {
pressed = sendButton().props.onPress()
await Promise.resolve()
})
expect(onSend).toHaveBeenCalled()
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
await act(async () => {
releaseSend?.(true)
await pressed
})
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
} finally {
restore()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'run the tests',
onChangeText: vi.fn(),
onSend,
sessionOptions: { isWorking: false, controller }
})
)
})
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
) as { props: { accessibilityState: { disabled: boolean } } }
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
// Start the send but don't await it — it stays in flight on purpose.
let pressed!: Promise<void>
await act(async () => {
pressed = sendButton().props.onPress()
await Promise.resolve()
})
expect(onSend).toHaveBeenCalled()
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
await act(async () => {
releaseSend?.(true)
await pressed
})
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
})
it('blocks composer submission while a session-option command is pending', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sessionOptions: {
isWorking: false,
controller: {
snapshot: [],
pendingId: 'model',
setOption: vi.fn(),
invokeAction: vi.fn(),
recordCommand: vi.fn()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'hello',
onChangeText: vi.fn(),
onSend,
sessionOptions: {
isWorking: false,
controller: {
snapshot: [],
pendingId: 'model',
setOption: vi.fn(),
invokeAction: vi.fn(),
recordCommand: vi.fn()
}
})
)
})
} finally {
restore()
}
}
})
)
})
expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
@@ -268,21 +233,16 @@ describe('MobileNativeChatComposer', () => {
})
it('keeps the text input editable while the send is locked', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'half-typed',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
disabled: true
})
)
})
} finally {
restore()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: 'half-typed',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
disabled: true
})
)
})
// Revoking `editable` on a focused field resigns first responder on iOS and
// yanks the keyboard mid-typing (#10681) — the lock may only gate sending.
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
@@ -294,25 +254,20 @@ describe('MobileNativeChatComposer', () => {
it('renders a removable thumbnail for each pending image attachment', async () => {
const onRemoveAttachment = vi.fn()
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
attachments: [
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
],
onRemoveAttachment
})
)
})
} finally {
restore()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
attachments: [
{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
{ id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
],
onRemoveAttachment
})
)
})
const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{
props: { source: { uri: string } }
}>
@@ -327,42 +282,32 @@ describe('MobileNativeChatComposer', () => {
it('enables send with an attached image even when the text is empty', async () => {
const onSend = vi.fn().mockResolvedValue(true)
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend,
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
})
)
})
} finally {
restore()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend,
attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
})
)
})
expect(sendButton().props).toMatchObject({ disabled: false })
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('')
})
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
agent: 'claude'
})
)
})
} finally {
restore()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
agent: 'claude'
})
)
})
const input = () =>
renderer!.root.find((node) => node.type === 'TextInput') as {
props: {
@@ -390,21 +335,16 @@ describe('MobileNativeChatComposer', () => {
})
it('serves the active agent’s shared command catalog with descriptions', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
agent: 'codex'
})
)
})
} finally {
restore()
}
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
agent: 'codex'
})
)
})
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void }
}
@@ -428,45 +368,40 @@ describe('MobileNativeChatComposer', () => {
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate'
) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } }
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'hold',
onMicPressIn,
onMicPressOut
})
)
})
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
expect(mic().props.onPress).toBeUndefined()
expect(mic().props.onPressIn).toBe(onMicPressIn)
expect(mic().props.onPressOut).toBe(onMicPressOut)
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'hold',
onMicPressIn,
onMicPressOut
})
)
})
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
expect(mic().props.onPress).toBeUndefined()
expect(mic().props.onPressIn).toBe(onMicPressIn)
expect(mic().props.onPressOut).toBe(onMicPressOut)
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'toggle',
onMicPressIn,
onMicPressOut
})
)
})
// Toggle mode: tap drives dictation, press-in/out inert.
expect(mic().props.onPress).toBe(onMicPress)
expect(mic().props.onPressIn).toBeUndefined()
expect(mic().props.onPressOut).toBeUndefined()
} finally {
restore()
}
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'toggle',
onMicPressIn,
onMicPressOut
})
)
})
// Toggle mode: tap drives dictation, press-in/out inert.
expect(mic().props.onPress).toBe(onMicPress)
expect(mic().props.onPressIn).toBeUndefined()
expect(mic().props.onPressOut).toBeUndefined()
})
})
@@ -1,8 +1,8 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
vi.mock('react-native', async () => {
const React = await import('react')
@@ -38,9 +38,6 @@ function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
describe('MobileNativeChatMessage', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -50,20 +47,9 @@ describe('MobileNativeChatMessage', () => {
message: NativeChatMessage,
props: { toolsExpanded?: boolean } = {}
): ReactTestRenderer {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
return
}
original(...a)
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
try {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
} finally {
spy.mockRestore()
}
return renderer!
}
+12 -15
View File
@@ -2,25 +2,22 @@ import { memo, useEffect, useRef, useState } from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors } from '../theme/mobile-theme'
import {
isImageRefBlock,
isTextBlock,
pairToolBlocks,
splitNativeChatBlocks,
type ToolPair
} from './mobile-native-chat-blocks'
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
import type { NativeChatDiffLine as DiffLine } from '../../../src/shared/native-chat-diff'
import { pairToolBlocks, splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
import type { NativeChatToolPair as ToolPair } from '../../../src/shared/native-chat-tool-fold'
import {
createToolInputDisplay,
summarizeToolRun,
truncateToolDetail
} from './mobile-native-chat-tool-summary'
} from '../../../src/shared/native-chat-tool-summary'
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors } from '../theme/mobile-theme'
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
const MAX_VISIBLE_TOOL_PAIRS = 6
const MAX_TOOL_RUN_DIFF_ROWS = 240
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
@@ -16,17 +16,6 @@ function assistantTurn(id: string, text: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
}
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
/** One render of the route: chat visible or not, the transcript it currently
* holds, and the agent-status stream behind it. */
type Tick = {
@@ -69,24 +58,15 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
describe('MobileNativeChatOverlay streaming gate', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(tick: Tick): Promise<void> {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(overlayElement(tick))
})
} finally {
restore()
}
await act(async () => {
renderer = create(overlayElement(tick))
})
}
async function update(tick: Tick): Promise<void> {
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
vi.mock('react-native', () => ({
@@ -15,10 +15,6 @@ vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' }))
describe('MobileNativeChatPermission', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
@@ -28,25 +24,14 @@ describe('MobileNativeChatPermission', () => {
let resolveResponse: (accepted: boolean) => void = () => {}
const response = new Promise<boolean>((resolve) => (resolveResponse = resolve))
const onRespond = vi.fn(() => response)
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
await act(async () => {
renderer = create(
createElement(MobileNativeChatPermission, {
permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] },
onRespond
})
)
})
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatPermission, {
permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] },
onRespond
})
)
})
} finally {
consoleSpy.mockRestore()
}
const button = renderer.root.findByType('Pressable')
act(() => {
@@ -149,7 +149,6 @@ describe('MobileNativeChatSessionOptionPickers', () => {
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
setOption.mockReset()
setOption.mockResolvedValue(true)
invokeAction.mockReset()
@@ -1,6 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatView } from './MobileNativeChatView'
@@ -73,17 +73,6 @@ type Overrides = {
onSend?: (text: string) => Promise<boolean>
}
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
function assistantTurn(id: string, text: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
}
@@ -105,24 +94,15 @@ function chatViewElement(overrides: Overrides): ReturnType<typeof createElement>
describe('MobileNativeChatView', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(overrides: Overrides = {}): Promise<void> {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(chatViewElement(overrides))
})
} finally {
restore()
}
await act(async () => {
renderer = create(chatViewElement(overrides))
})
}
async function update(overrides: Overrides = {}): Promise<void> {
+1 -1
View File
@@ -11,6 +11,7 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
@@ -26,7 +27,6 @@ import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
+1 -3
View File
@@ -29,9 +29,7 @@ vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentI
describe('QuickCommandsList search', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
beforeEach(() => {})
afterEach(() => {
act(() => renderer?.unmount())
+1 -12
View File
@@ -1,6 +1,6 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalQuickCommand } from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import { MAX_QUICK_COMMANDS } from '../terminal/quick-commands'
@@ -65,26 +65,15 @@ function deferred<T>() {
describe('QuickCommandsSheet', () => {
let renderer: ReactTestRenderer | null = null
let consoleSpy: MockInstance
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
mocks.alert.mockReset()
mocks.commands = []
mocks.persist.mockReset()
const originalConsoleError = console.error
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
consoleSpy.mockRestore()
})
it('keeps the sheet open when a launch is rejected', async () => {
+3 -34
View File
@@ -1,4 +1,7 @@
import type { DiffComment, DiffReviewScope } from '../../../src/shared/types'
import { formatDiffComment, formatDiffComments } from '../../../src/shared/diff-comments-format'
export { formatDiffComment, formatDiffComments }
export type CreateMobileDiffCommentInput = {
worktreeId: string
@@ -16,44 +19,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isMarkdownComment(comment: Pick<DiffComment, 'source'>): boolean {
return comment.source === 'markdown'
}
function normalizeScope(value: unknown): DiffReviewScope | undefined {
return value === 'unstaged' || value === 'staged' || value === 'branch' ? value : undefined
}
// Why: mobile Vitest/Metro run from the mobile package and cannot transform
// runtime imports from root src/shared. Keep this byte-for-byte compatible with
// the desktop shared formatter contract.
export function formatDiffComment(c: DiffComment): string {
const escaped = c.body
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
const locationLabel =
c.lineNumber === 0
? 'Scope: file'
: c.startLine !== undefined && c.startLine !== c.lineNumber
? `Lines: ${c.startLine}-${c.lineNumber}`
: `Line: ${c.lineNumber}`
if (!isMarkdownComment(c)) {
return [`File: ${c.filePath}`, locationLabel, `User comment: "${escaped}"`].join('\n')
}
return [
`File: ${c.filePath}`,
'Source: markdown',
locationLabel,
`User comment: "${escaped}"`
].join('\n')
}
export function formatDiffComments(comments: readonly DiffComment[]): string {
return comments.map(formatDiffComment).join('\n\n')
}
export function formatMobileDiffReviewPrompt(comments: readonly DiffComment[]): string {
return [
'You are reviewing the current worktree. Address the following mobile review notes.',
@@ -104,31 +104,6 @@ export function mergeMobileDiffReviewState(
}
}
export function markMobileDiffReviewFileOpened(
state: MobileDiffReviewState,
descriptor: MobileDiffReviewFileDescriptor,
now: number
): MobileDiffReviewState {
const previous = state.files[descriptor.key]
return {
...state,
updatedAt: now,
files: {
...state.files,
[descriptor.key]: {
key: descriptor.key,
filePath: descriptor.filePath,
oldPath: descriptor.oldPath,
scope: descriptor.scope,
reviewedAt: previous?.reviewedAt,
reviewDiffIdentity: previous?.reviewDiffIdentity,
lastOpenedAt: now,
lastSeenDiffIdentity: descriptor.diffIdentity
}
}
}
}
export function markMobileDiffReviewFileReviewed(
state: MobileDiffReviewState,
descriptor: MobileDiffReviewFileDescriptor,
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildAskAnswerKeys,
extractPendingAsk,
formatAskAnswer,
parseAskFromStatus
} from './mobile-native-chat-ask'
} from '../../../src/shared/native-chat-ask'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage {
return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' }
@@ -1,2 +0,0 @@
// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift.
export * from '../../../src/shared/native-chat-ask'
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
foldToolMessages,
pairToolBlocks,
splitNativeChatBlocks
} from './mobile-native-chat-blocks'
} from '../../../src/shared/native-chat-tool-fold'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
function msg(
role: NativeChatMessage['role'],
@@ -1,11 +0,0 @@
export {
isImageRefBlock,
isTextBlock,
NATIVE_CHAT_SOURCE_PRIORITY
} from '../../../src/shared/native-chat-types'
export {
foldToolMessages,
pairToolBlocks,
splitNativeChatBlocks,
type NativeChatToolPair as ToolPair
} from '../../../src/shared/native-chat-tool-fold'
@@ -1,7 +1,11 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type {
AskAnswerSelection,
AskPrompt,
parseAskFromStatus
} from '../../../src/shared/native-chat-ask'
import type { detectAgentPermission } from './mobile-native-chat-permission'
import type { parseAgentQuestion } from './mobile-native-chat-question'
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff'
import { diffFromText, diffFromToolCall } from '../../../src/shared/native-chat-diff'
describe('diffFromToolCall', () => {
it('builds del/add lines from an Edit tool call', () => {
@@ -36,8 +36,6 @@ describe('diffFromText', () => {
expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull()
})
// Parity guard: the mobile module re-exports the shared parser, so the -- / ++
// content collision must stay fixed on both platforms.
it('counts a removed -- comment line despite the --- prefix collision', () => {
const lines = diffFromText('@@ -1,2 +1,2 @@\n ctx\n---sql comment\n+new')
expect(lines).toEqual([

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